1158 lines
42 KiB
Go
1158 lines
42 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.118im.com/im_team/im_workbench/internal/ai"
|
|
"git.118im.com/im_team/im_workbench/internal/auth"
|
|
"git.118im.com/im_team/im_workbench/internal/config"
|
|
"git.118im.com/im_team/im_workbench/internal/events"
|
|
"git.118im.com/im_team/im_workbench/internal/store"
|
|
)
|
|
|
|
type API struct {
|
|
store *store.Store
|
|
auth *auth.Authenticator
|
|
events events.Publisher
|
|
authCfg config.AuthConfig
|
|
ai *ai.Service
|
|
}
|
|
|
|
const workbenchEventPublishTimeout = 15 * time.Second
|
|
|
|
const (
|
|
maxMailboxSubjectLength = 200
|
|
maxMailboxContentLength = 5000
|
|
)
|
|
|
|
func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher, authCfg config.AuthConfig, aiService *ai.Service) *API {
|
|
if publisher == nil {
|
|
publisher = events.NoopPublisher{}
|
|
}
|
|
return &API{store: st, auth: authenticator, events: publisher, authCfg: authCfg, ai: aiService}
|
|
}
|
|
|
|
func (a *API) Register(mux *http.ServeMux) {
|
|
mux.HandleFunc("/v1/workbench", a.withAuth(a.workbench))
|
|
mux.HandleFunc("/v1/workbench/apps", a.withAuth(a.workbenchApps))
|
|
mux.HandleFunc("/v1/workbench/apps/search", a.withAuth(a.workbenchAppsSearch))
|
|
mux.HandleFunc("/v1/workbench/ai/capabilities", a.withAuth(a.aiCapabilities))
|
|
mux.HandleFunc("/v1/workbench/ai/text/polish", a.withAuth(a.aiTextPolish))
|
|
mux.HandleFunc("/v1/workbench/ai/documents/generate", a.withAuth(a.aiDocumentGenerate))
|
|
mux.HandleFunc("/v1/workbench/ai/documents", a.withAuth(a.aiDocuments))
|
|
mux.HandleFunc("/v1/workbench/ai/documents/", a.withAuth(a.aiDocumentResource))
|
|
mux.HandleFunc("/v1/workbench/ai/table-analyses", a.withAuth(a.aiTableAnalyses))
|
|
mux.HandleFunc("/v1/workbench/ai/table-analyses/", a.withAuth(a.aiTableAnalysisResource))
|
|
mux.HandleFunc("/v1/workbench/ai/records", a.withAuth(a.aiRecords))
|
|
mux.HandleFunc("/v1/workbench/ai/records/", a.withAuth(a.aiRecordResource))
|
|
mux.HandleFunc("/v1/workbench/ai/tasks/", a.withAuth(a.aiTaskResource))
|
|
mux.HandleFunc("/v1/workbench/organization/departments", a.withAuth(a.organizationDepartments))
|
|
mux.HandleFunc("/v1/workbench/organization/departments/", a.withAuth(a.organizationDepartmentResource))
|
|
mux.HandleFunc("/v1/workbench/organization/members", a.withAuth(a.organizationMembers))
|
|
mux.HandleFunc("/v1/workbench/organization/search", a.withAuth(a.organizationSearch))
|
|
mux.HandleFunc("/v1/workbench/attendance/config", a.withAuth(a.attendanceConfig))
|
|
mux.HandleFunc("/v1/workbench/attendance/records", a.withAuth(a.attendanceRecords))
|
|
mux.HandleFunc("/v1/workbench/attendance/checkins", a.withAuth(a.attendanceCheckins))
|
|
mux.HandleFunc("/v1/workbench/attendance/appeals", a.withAuth(a.attendanceAppeals))
|
|
mux.HandleFunc("/v1/workbench/meeting-notes", a.withAuth(a.meetingNotes))
|
|
mux.HandleFunc("/v1/workbench/meetings", a.withAuth(a.meetings))
|
|
mux.HandleFunc("/v1/workbench/meetings/", a.withAuth(a.meetingResource))
|
|
mux.HandleFunc("/v1/workbench/reports/daily/statistics", a.withAuth(a.dailyReportStatistics))
|
|
mux.HandleFunc("/v1/workbench/reports/daily/reminders", a.withAuth(a.dailyReportReminders))
|
|
mux.HandleFunc("/v1/workbench/reports/daily", a.withAuth(a.dailyReports))
|
|
mux.HandleFunc("/v1/workbench/reports/daily/", a.withAuth(a.dailyReportResource))
|
|
mux.HandleFunc("/v1/workbench/reports/monthly/statistics", a.withAuth(a.monthlyReportStatistics))
|
|
mux.HandleFunc("/v1/workbench/reports/monthly", a.withAuth(a.monthlyReports))
|
|
mux.HandleFunc("/v1/workbench/reports/monthly/", a.withAuth(a.monthlyReportResource))
|
|
mux.HandleFunc("/v1/workbench/executive-mailbox/config", a.withAuth(a.mailboxConfig))
|
|
mux.HandleFunc("/v1/workbench/executive-mailbox/submissions", a.withAuth(a.mailboxSubmissions))
|
|
mux.HandleFunc("/internal/admin/workbench/apps", a.withInternalAdminAuth(a.adminWorkbenchApps))
|
|
mux.HandleFunc("/internal/admin/workbench/apps/disable", a.withInternalAdminAuth(a.adminWorkbenchAppDisable))
|
|
mux.HandleFunc("/internal/admin/workbench/apps/reorder", a.withInternalAdminAuth(a.adminWorkbenchAppsReorder))
|
|
mux.HandleFunc("/internal/admin/workbench/organization/departments", a.withInternalAdminAuth(a.adminOrganizationDepartments))
|
|
mux.HandleFunc("/internal/admin/workbench/organization/members", a.withInternalAdminAuth(a.adminOrganizationMembers))
|
|
mux.HandleFunc("/internal/admin/workbench/mailbox-config", a.withInternalAdminAuth(a.adminMailboxConfig))
|
|
mux.HandleFunc("/internal/admin/workbench/feedback-config", a.withInternalAdminAuth(a.adminFeedbackConfig))
|
|
mux.HandleFunc("/internal/admin/workbench/tenants/initialize", a.withInternalAdminAuth(a.adminInitializeTenant))
|
|
}
|
|
|
|
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
|
|
writeSuccess(w, r, map[string]any{"status": "ok", "service": "im_workbench"})
|
|
}
|
|
|
|
type handlerFunc func(http.ResponseWriter, *http.Request, *auth.Principal, *store.Store)
|
|
|
|
func (a *API) withAuth(next handlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
principal, err := a.auth.Authenticate(r)
|
|
if err != nil {
|
|
switch err {
|
|
case auth.ErrForbidden:
|
|
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
|
|
default:
|
|
writeError(w, r, http.StatusUnauthorized, 30043, reasonAuthRequired, "authentication required", nil)
|
|
}
|
|
return
|
|
}
|
|
st, err := a.store.ForTenant(r.Context(), principal.TenantID, principal.TenantCode)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
next(w, r, principal, st)
|
|
}
|
|
}
|
|
|
|
func (a *API) workbench(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
modules, version, err := st.WorkbenchModules(r.Context(), p.UserID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
items := workbenchModulePayloads(modules)
|
|
if err := a.populateUnread(st, r, p, items); err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, map[string]any{
|
|
"layout_version": version,
|
|
"modules": items,
|
|
"enterprise": map[string]any{
|
|
"tenant_code": p.TenantCode,
|
|
"feature_flags": map[string]any{
|
|
"organization": true,
|
|
"attendance": true,
|
|
"meetings": true,
|
|
"daily_reports": true,
|
|
"monthly_reports": true,
|
|
"executive_mailbox": true,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (a *API) organizationDepartments(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
parentID := strings.TrimSpace(r.URL.Query().Get("parent_id"))
|
|
if parentID == "" {
|
|
parentID = "root"
|
|
}
|
|
items, next, err := st.Departments(r.Context(), p.UserID, parentID, r.URL.Query().Get("cursor"), limit(r, 50, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(organizationDepartmentPayloads(items), next))
|
|
}
|
|
|
|
func (a *API) organizationDepartmentResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
departmentID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/organization/departments/")
|
|
if !ok || tail != "members" {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "organization route not found", nil)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
items, next, err := st.DepartmentMembers(r.Context(), p.UserID, departmentID, r.URL.Query().Get("cursor"), limit(r, 50, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
payload := pagePayload(organizationMemberPayloads(items), next)
|
|
payload["department_id"] = departmentID
|
|
writeSuccess(w, r, payload)
|
|
}
|
|
|
|
func (a *API) organizationMembers(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
query := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword"))
|
|
items, next, err := st.Members(r.Context(), p.UserID, r.URL.Query().Get("department_id"), strings.TrimSpace(query), r.URL.Query().Get("cursor"), limit(r, 50, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
payload := pagePayload(organizationMemberPayloads(items), next)
|
|
payload["department_id"] = strings.TrimSpace(r.URL.Query().Get("department_id"))
|
|
writeSuccess(w, r, payload)
|
|
}
|
|
|
|
func (a *API) organizationSearch(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
if query == "" {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "q"})
|
|
return
|
|
}
|
|
departments, _, err := st.Departments(r.Context(), p.UserID, "root", "", limit(r, 10, 50))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
members, _, err := st.Members(r.Context(), p.UserID, "", query, "", limit(r, 20, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(departments)+len(members))
|
|
for _, department := range departments {
|
|
if strings.Contains(department.Name, query) {
|
|
items = append(items, map[string]any{"type": "department", "department": organizationDepartmentPayload(department)})
|
|
}
|
|
}
|
|
for _, member := range members {
|
|
items = append(items, map[string]any{"type": "user", "user": organizationMemberPayload(member)})
|
|
}
|
|
writeSuccess(w, r, map[string]any{"items": items, "next_cursor": "", "has_more": false})
|
|
}
|
|
|
|
func (a *API) attendanceConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
date, err := parseDate(firstNonEmpty(r.URL.Query().Get("date"), time.Now().Format("2006-01-02")))
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
cfg, err := st.AttendanceConfig(r.Context(), p.UserID, date)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, attendanceConfigPayload(cfg))
|
|
}
|
|
|
|
func (a *API) attendanceRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
records, next, stats, err := st.AttendanceRecords(r.Context(), p.UserID, store.AttendanceListOptions{
|
|
Scope: r.URL.Query().Get("scope"),
|
|
From: r.URL.Query().Get("from"),
|
|
To: r.URL.Query().Get("to"),
|
|
Date: r.URL.Query().Get("date"),
|
|
Status: r.URL.Query().Get("status"),
|
|
Cursor: r.URL.Query().Get("cursor"),
|
|
Limit: limit(r, 50, 100),
|
|
UserID: parseUserIDQuery(r.URL.Query().Get("user_id")),
|
|
})
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
payload := pagePayload(attendanceRecordPayloads(records), next)
|
|
payload["statistics"] = attendanceStatsPayload(stats)
|
|
payload["permissions"] = map[string]any{"can_checkin": true, "can_view_managed": true, "can_appeal": true}
|
|
writeSuccess(w, r, payload)
|
|
}
|
|
|
|
func (a *API) attendanceCheckins(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIdempotencyKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Date string `json:"date"`
|
|
Action string `json:"action"`
|
|
RuleID string `json:"rule_id"`
|
|
ShiftID string `json:"shift_id"`
|
|
ClientTime string `json:"client_time"`
|
|
Location json.RawMessage `json:"location"`
|
|
Wifi json.RawMessage `json:"wifi"`
|
|
Device json.RawMessage `json:"device"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
action := strings.TrimSpace(body.Action)
|
|
if action == "" || len(action) > 32 {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "action"})
|
|
return
|
|
}
|
|
ruleID := strings.TrimSpace(body.RuleID)
|
|
if len(ruleID) > 64 {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "rule_id"})
|
|
return
|
|
}
|
|
shiftID := strings.TrimSpace(body.ShiftID)
|
|
if shiftID == "" {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "shift_id"})
|
|
return
|
|
}
|
|
date, err := parseDate(body.Date)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
if maxShiftIDLength := maxAttendanceShiftIDLength(p.UserID, date); len(shiftID) > maxShiftIDLength {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "shift_id", "max_length": maxShiftIDLength})
|
|
return
|
|
}
|
|
clientTime, err := parseTime(body.ClientTime)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "client_time"})
|
|
return
|
|
}
|
|
location, err := normalizeJSONObject(body.Location)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "location"})
|
|
return
|
|
}
|
|
wifi, err := normalizeJSONObject(body.Wifi)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "wifi"})
|
|
return
|
|
}
|
|
device, err := normalizeJSONObject(body.Device)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "device"})
|
|
return
|
|
}
|
|
record, checkin, err := st.CreateAttendanceCheckin(r.Context(), p.UserID, "attc_"+randomToken(12), store.AttendanceCheckinRequest{
|
|
Date: date, Action: action, RuleID: ruleID, ShiftID: shiftID,
|
|
ClientTime: clientTime, Location: location, Wifi: wifi, Device: device, IdempotencyKey: idempotencyKey,
|
|
})
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "attendance", "attendance.record.updated", record.RecordID,
|
|
[]int64{p.UserID}, "打卡", record.Date.Format("2006-01-02"),
|
|
map[string]any{"record_id": record.RecordID, "checkin_id": checkin.CheckinID, "status": record.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, map[string]any{"record": attendanceRecordPayload(record), "checkin": attendanceCheckinPayload(*checkin)})
|
|
}
|
|
|
|
func (a *API) attendanceAppeals(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
items, next, err := st.AttendanceAppeals(r.Context(), p.UserID, r.URL.Query().Get("status"), r.URL.Query().Get("cursor"), limit(r, 50, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(attendanceAppealPayloads(items), next))
|
|
case http.MethodPost:
|
|
idempotencyKey, ok := requireIdempotencyKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
RecordID string `json:"record_id"`
|
|
AppealType string `json:"appeal_type"`
|
|
Reason string `json:"reason"`
|
|
TargetAction string `json:"target_action"`
|
|
TargetTime string `json:"target_time"`
|
|
AttachmentFileIDs []string `json:"attachment_file_ids"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
var targetTime *time.Time
|
|
if strings.TrimSpace(body.TargetTime) != "" {
|
|
parsed, err := parseTime(body.TargetTime)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "target_time"})
|
|
return
|
|
}
|
|
targetTime = &parsed
|
|
}
|
|
attachments, _ := json.Marshal(body.AttachmentFileIDs)
|
|
appeal, err := st.CreateAttendanceAppeal(r.Context(), p.UserID, store.AttendanceAppeal{
|
|
AppealID: "atta_" + randomToken(12), RecordID: strings.TrimSpace(body.RecordID), UserID: p.UserID, AppealType: strings.TrimSpace(body.AppealType),
|
|
Reason: strings.TrimSpace(body.Reason), TargetAction: strings.TrimSpace(body.TargetAction), TargetTime: targetTime, AttachmentFileIDs: string(attachments),
|
|
}, idempotencyKey)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "attendance", "attendance.appeal.created", appeal.AppealID,
|
|
[]int64{p.UserID}, "考勤申诉", appeal.RecordID,
|
|
map[string]any{"appeal_id": appeal.AppealID, "record_id": appeal.RecordID, "status": appeal.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, attendanceAppealPayload(appeal))
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
items, next, err := st.Meetings(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(meetingPayloads(items, p.UserID), next))
|
|
case http.MethodPost:
|
|
idempotencyKey, ok := requireIdempotencyKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Title string `json:"title"`
|
|
RecorderUserID userID `json:"recorder_user_id"`
|
|
StartAt string `json:"start_at"`
|
|
EndAt string `json:"end_at"`
|
|
Location string `json:"location"`
|
|
AttendeeUserIDs []userID `json:"attendee_user_ids"`
|
|
ReminderMinutes []int `json:"reminder_minutes"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
startAt, err := parseTime(body.StartAt)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"})
|
|
return
|
|
}
|
|
endAt, err := parseTime(body.EndAt)
|
|
if err != nil || !endAt.After(startAt) {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"})
|
|
return
|
|
}
|
|
reminders, _ := json.Marshal(body.ReminderMinutes)
|
|
meeting, err := st.CreateMeeting(r.Context(), p.UserID, store.Meeting{
|
|
MeetingID: "mtg_" + randomToken(12), RecorderID: int64(body.RecorderUserID), Title: strings.TrimSpace(body.Title), StartAt: startAt, EndAt: endAt,
|
|
Location: strings.TrimSpace(body.Location), ReminderMinutes: string(reminders), IdempotencyKey: idempotencyKey,
|
|
}, parseUserIDList(body.AttendeeUserIDs))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
targets := meetingTargetUsers(*meeting)
|
|
if !a.publishWorkbenchEvent(w, r, p, "meeting", "meeting.created", meeting.MeetingID,
|
|
targets, "会议通知", meeting.Title,
|
|
map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingPayload(*meeting, p.UserID))
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
meetingID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/meetings/")
|
|
if !ok {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "meeting route not found", nil)
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "" && r.Method == http.MethodGet:
|
|
meeting, err := st.Meeting(r.Context(), p.UserID, meetingID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingPayload(*meeting, p.UserID))
|
|
case tail == "" && r.Method == http.MethodPatch:
|
|
var body struct {
|
|
Title *string `json:"title"`
|
|
RecorderUserID optionalUserID `json:"recorder_user_id"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
meeting, err := st.UpdateMeeting(r.Context(), p.UserID, meetingID, store.MeetingPatch{Title: body.Title, RecorderID: body.RecorderUserID.ptr(), Version: body.Version})
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "meeting", "meeting.updated", meeting.MeetingID,
|
|
meetingTargetUsers(*meeting), "会议变更", meeting.Title,
|
|
map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingPayload(*meeting, p.UserID))
|
|
case tail == "cancel" && r.Method == http.MethodPost:
|
|
meeting, err := st.CancelMeeting(r.Context(), p.UserID, meetingID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "meeting", "meeting.canceled", meeting.MeetingID,
|
|
meetingTargetUsers(*meeting), "会议取消", meeting.Title,
|
|
map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingPayload(*meeting, p.UserID))
|
|
case tail == "my-response" && r.Method == http.MethodPut:
|
|
var body struct {
|
|
Response string `json:"response"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
meeting, err := st.RespondMeeting(r.Context(), p.UserID, meetingID, strings.TrimSpace(body.Response), body.Version)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "meeting", "meeting.response.updated", meeting.MeetingID,
|
|
[]int64{meeting.OrganizerID, meeting.RecorderID}, "会议回执", meeting.Title,
|
|
map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "response": strings.TrimSpace(body.Response)}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingPayload(*meeting, p.UserID))
|
|
case tail == "notes" && r.Method == http.MethodGet:
|
|
a.meetingNotesDetail(w, r, p, st, meetingID)
|
|
case tail == "ai-summary":
|
|
a.aiMeetingSummary(w, r, p, st, meetingID)
|
|
case tail == "notes/files" && r.Method == http.MethodPost:
|
|
a.meetingNoteFiles(w, r, p, st, meetingID)
|
|
case strings.HasPrefix(tail, "notes/files/"):
|
|
a.meetingNoteFileResource(w, r, p, st, meetingID, strings.TrimPrefix(tail, "notes/files/"))
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) meetingNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
items, next, err := st.MeetingNotes(r.Context(), p.UserID, r.URL.Query().Get("query"), r.URL.Query().Get("cursor"), limit(r, 50, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(meetingNoteListPayloads(items, p.UserID), next))
|
|
}
|
|
|
|
func (a *API) meetingNotesDetail(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) {
|
|
meeting, err := st.Meeting(r.Context(), p.UserID, meetingID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
files, err := st.MeetingNoteFiles(r.Context(), p.UserID, meetingID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingNotesDetailPayload(*meeting, files, p.UserID))
|
|
}
|
|
|
|
func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) {
|
|
idempotencyKey, ok := requireIdempotencyKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
FileID string `json:"file_id"`
|
|
FileName string `json:"file_name"`
|
|
Name string `json:"name"`
|
|
MIMEType string `json:"mime_type"`
|
|
MIME string `json:"mime"`
|
|
FileSize *int64 `json:"file_size"`
|
|
Size *int64 `json:"size"`
|
|
FileETag string `json:"file_etag"`
|
|
ETag string `json:"etag"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
fileID := strings.TrimSpace(body.FileID)
|
|
if fileID == "" {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
|
|
return
|
|
}
|
|
file, err := st.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), store.MeetingNoteFileInput{
|
|
FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), FileETag: firstNonEmpty(body.FileETag, body.ETag),
|
|
}, idempotencyKey)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "meeting_note", "meeting_note.file.created", file.NoteFileID,
|
|
[]int64{p.UserID}, "会议笔记", file.FileName,
|
|
map[string]any{"meeting_id": file.MeetingID, "note_file_id": file.NoteFileID, "file_id": file.FileID, "version": file.Version}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingNoteFilePayload(*file, p.UserID))
|
|
}
|
|
|
|
func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID, noteFileID string) {
|
|
idempotencyKey, ok := requireIdempotencyKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
var body struct {
|
|
FileID string `json:"file_id"`
|
|
FileName string `json:"file_name"`
|
|
Name string `json:"name"`
|
|
MIMEType string `json:"mime_type"`
|
|
MIME string `json:"mime"`
|
|
FileSize *int64 `json:"file_size"`
|
|
Size *int64 `json:"size"`
|
|
FileETag string `json:"file_etag"`
|
|
ETag string `json:"etag"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
fileID := strings.TrimSpace(body.FileID)
|
|
if fileID == "" {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
|
|
return
|
|
}
|
|
file, err := st.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, store.MeetingNoteFileInput{
|
|
FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), FileETag: firstNonEmpty(body.FileETag, body.ETag),
|
|
}, idempotencyKey, body.Version)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "meeting_note", "meeting_note.file.replaced", file.NoteFileID,
|
|
[]int64{p.UserID}, "会议笔记", file.FileName,
|
|
map[string]any{"meeting_id": file.MeetingID, "note_file_id": file.NoteFileID, "file_id": file.FileID, "version": file.Version}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingNoteFilePayload(*file, p.UserID))
|
|
case http.MethodDelete:
|
|
version, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("version")))
|
|
if err != nil || version <= 0 {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
|
|
return
|
|
}
|
|
file, err := st.DeleteMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, idempotencyKey, version)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "meeting_note", "meeting_note.file.deleted", file.NoteFileID,
|
|
[]int64{p.UserID}, "会议笔记", file.FileName,
|
|
map[string]any{"meeting_id": file.MeetingID, "note_file_id": file.NoteFileID, "file_id": file.FileID, "version": file.Version}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, meetingNoteFilePayload(*file, p.UserID))
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) dailyReports(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
reportDate := ""
|
|
if rawDate := strings.TrimSpace(r.URL.Query().Get("date")); rawDate != "" {
|
|
date, parseErr := parseDate(rawDate)
|
|
if parseErr != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
reportDate = date.Format("2006-01-02")
|
|
}
|
|
items, next, err := st.DailyReports(r.Context(), p.UserID, reportDate, r.URL.Query().Get("cursor"), limit(r, 50, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(dailyReportPayloads(items), next))
|
|
case http.MethodPost:
|
|
var body struct {
|
|
ReportDate string `json:"report_date"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
date, err := parseDate(body.ReportDate)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"})
|
|
return
|
|
}
|
|
report, err := st.CreateDailyDraft(r.Context(), p.UserID, "dr_"+randomToken(12), date)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.draft.created", report.ReportID,
|
|
[]int64{p.UserID}, "日报", report.ReportDate.Format("2006-01-02"),
|
|
map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, dailyReportPayload(*report))
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
reportID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/reports/daily/")
|
|
if !ok {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "daily report route not found", nil)
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "" && r.Method == http.MethodGet:
|
|
report, err := st.DailyReport(r.Context(), p.UserID, reportID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, dailyReportPayload(*report))
|
|
case tail == "draft" && r.Method == http.MethodPut:
|
|
var body struct {
|
|
Content string `json:"content"`
|
|
Attachments []string `json:"attachments"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
report, err := st.SaveDailyDraft(r.Context(), p.UserID, reportID, body.Content, body.Attachments, body.Version)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.draft.saved", report.ReportID,
|
|
[]int64{p.UserID}, "日报", report.ReportDate.Format("2006-01-02"),
|
|
map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, dailyReportPayload(*report))
|
|
case tail == "submit" && r.Method == http.MethodPost:
|
|
var body struct {
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
report, err := st.SubmitDaily(r.Context(), p.UserID, reportID, body.Version)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.submitted", report.ReportID,
|
|
[]int64{p.UserID}, "日报已提交", report.ReportDate.Format("2006-01-02"),
|
|
map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, dailyReportPayload(*report))
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) dailyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
date, err := parseDate(firstNonEmpty(r.URL.Query().Get("date"), time.Now().Format("2006-01-02")))
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
|
return
|
|
}
|
|
stats, err := st.DailyReportStats(r.Context(), date, r.URL.Query().Get("department_id"))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, reportStatsPayload(stats))
|
|
}
|
|
|
|
func (a *API) dailyReportReminders(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIdempotencyKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Date string `json:"date"`
|
|
UserIDs []userID `json:"user_ids"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.reminded", strings.TrimSpace(body.Date),
|
|
parseUserIDList(body.UserIDs), "日报提醒", strings.TrimSpace(body.Date),
|
|
map[string]any{"date": strings.TrimSpace(body.Date), "idempotency_key": idempotencyKey, "reminded_count": len(body.UserIDs)}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, map[string]any{"status": "queued"})
|
|
}
|
|
|
|
func (a *API) monthlyReports(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
items, next, err := st.MonthlyReports(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, pagePayload(monthlyReportPayloads(items), next))
|
|
case http.MethodPost:
|
|
var body struct {
|
|
Month string `json:"month"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
report, err := st.CreateMonthlyDraft(r.Context(), p.UserID, "mr_"+randomToken(12), strings.TrimSpace(body.Month))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.draft.created", report.ReportID,
|
|
[]int64{p.UserID}, "月报", report.Month,
|
|
map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, monthlyReportPayload(*report))
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
reportID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/reports/monthly/")
|
|
if !ok {
|
|
writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "monthly report route not found", nil)
|
|
return
|
|
}
|
|
switch {
|
|
case tail == "" && r.Method == http.MethodGet:
|
|
report, err := st.MonthlyReport(r.Context(), p.UserID, reportID)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, monthlyReportPayload(*report))
|
|
case tail == "draft" && r.Method == http.MethodPut:
|
|
var body struct {
|
|
WorkSummary string `json:"work_summary"`
|
|
NextPlan string `json:"next_plan"`
|
|
Attachments []string `json:"attachments"`
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
report, err := st.SaveMonthlyDraft(r.Context(), p.UserID, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.draft.saved", report.ReportID,
|
|
[]int64{p.UserID}, "月报", report.Month,
|
|
map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, monthlyReportPayload(*report))
|
|
case tail == "submit" && r.Method == http.MethodPost:
|
|
var body struct {
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
report, err := st.SubmitMonthly(r.Context(), p.UserID, reportID, body.Version)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.submitted", report.ReportID,
|
|
[]int64{p.UserID}, "月报已提交", report.Month,
|
|
map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, monthlyReportPayload(*report))
|
|
case tail == "reopen" && r.Method == http.MethodPost:
|
|
var body struct {
|
|
Version int `json:"version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
report, err := st.ReopenMonthly(r.Context(), p.UserID, reportID, body.Version)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.reopened", report.ReportID,
|
|
[]int64{p.UserID}, "月报已重开", report.Month,
|
|
map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, monthlyReportPayload(*report))
|
|
default:
|
|
methodNotAllowed(w, r)
|
|
}
|
|
}
|
|
|
|
func (a *API) monthlyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
month := strings.TrimSpace(r.URL.Query().Get("month"))
|
|
if month == "" {
|
|
month = time.Now().Format("2006-01")
|
|
}
|
|
stats, err := st.MonthlyReportStats(r.Context(), month, r.URL.Query().Get("department_id"))
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, reportStatsPayload(stats))
|
|
}
|
|
|
|
func (a *API) mailboxConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
cfg, err := st.MailboxConfig(r.Context())
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
writeSuccess(w, r, mailboxConfigPayload(*cfg))
|
|
}
|
|
|
|
func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, r)
|
|
return
|
|
}
|
|
idempotencyKey, ok := requireIdempotencyKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Subject string `json:"subject"`
|
|
Content string `json:"content"`
|
|
Anonymous bool `json:"anonymous"`
|
|
AttachmentFileIDs []string `json:"attachment_file_ids"`
|
|
NoticeVersion string `json:"notice_version"`
|
|
}
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
subject := strings.TrimSpace(body.Subject)
|
|
content := strings.TrimSpace(body.Content)
|
|
if ok := validateMailboxSubmission(w, r, subject, content); !ok {
|
|
return
|
|
}
|
|
attachments, _ := json.Marshal(body.AttachmentFileIDs)
|
|
submission, err := st.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{
|
|
SubmissionID: "mb_" + randomToken(12), Subject: subject, Content: content,
|
|
Anonymous: body.Anonymous, AttachmentFileIDs: string(attachments), NoticeVersion: strings.TrimSpace(body.NoticeVersion),
|
|
}, idempotencyKey)
|
|
if err != nil {
|
|
writeStoreError(w, r, err)
|
|
return
|
|
}
|
|
if !a.publishWorkbenchEvent(w, r, p, "mailbox", "mailbox.submitted", submission.SubmissionID,
|
|
[]int64{p.UserID}, "总经理信箱", submission.Subject,
|
|
map[string]any{"submission_id": submission.SubmissionID, "status": submission.Status, "anonymous": submission.Anonymous}) {
|
|
return
|
|
}
|
|
writeSuccess(w, r, mailboxSubmissionPayload(*submission))
|
|
}
|
|
|
|
func validateMailboxSubmission(w http.ResponseWriter, r *http.Request, subject, content string) bool {
|
|
if len([]rune(subject)) > maxMailboxSubjectLength {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "subject", "max_length": maxMailboxSubjectLength})
|
|
return false
|
|
}
|
|
if len([]rune(content)) > maxMailboxContentLength {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "content", "max_length": maxMailboxContentLength})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (a *API) publishWorkbenchEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal, module, action, resourceID string, targets []int64, title, summary string, data map[string]any) bool {
|
|
if a.events == nil {
|
|
return true
|
|
}
|
|
event := events.NewWorkbenchEvent(p, requestID(r), module, action, resourceID, targets, title, summary, data)
|
|
start := time.Now()
|
|
ctx, cancel := context.WithTimeout(r.Context(), workbenchEventPublishTimeout)
|
|
defer cancel()
|
|
if err := a.events.Publish(ctx, event); err != nil {
|
|
log.Printf("workbench event publish failed: event_id=%s module=%s action=%s resource_id=%s trace_id=%s elapsed=%s err=%v",
|
|
event.EventID, module, action, resourceID, event.TraceID, time.Since(start).Round(time.Millisecond), err)
|
|
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "workbench event publish failed", map[string]any{
|
|
"event_id": event.EventID,
|
|
"module": module,
|
|
"event": action,
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func meetingTargetUsers(meeting store.Meeting) []int64 {
|
|
targets := []int64{meeting.OrganizerID, meeting.RecorderID}
|
|
for _, participant := range meeting.Participants {
|
|
targets = append(targets, participant.UserID)
|
|
}
|
|
return targets
|
|
}
|
|
|
|
func (a *API) populateUnread(st *store.Store, r *http.Request, p *auth.Principal, modules []map[string]any) error {
|
|
for _, module := range modules {
|
|
if module["code"] == "meetings" {
|
|
count, err := st.MeetingUnreadCount(r.Context(), p.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
module["unread_count"] = count
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, r, http.StatusMethodNotAllowed, 30002, reasonMethodNotAllowed, "method not allowed", nil)
|
|
}
|
|
|
|
func requireIdempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
|
if key == "" {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
|
|
return "", false
|
|
}
|
|
return key, true
|
|
}
|
|
|
|
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func limit(r *http.Request, fallback, max int) int {
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit")))
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
if parsed > max {
|
|
return max
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseUserIDQuery(value string) int64 {
|
|
parsed, _ := parseUserID(value)
|
|
return parsed
|
|
}
|
|
|
|
func parseDate(value string) (time.Time, error) {
|
|
return time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local)
|
|
}
|
|
|
|
func parseTime(value string) (time.Time, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}, fmt.Errorf("empty time")
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
|
return parsed, nil
|
|
}
|
|
return time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
|
}
|
|
|
|
func maxAttendanceShiftIDLength(userID int64, date time.Time) int {
|
|
prefix := fmt.Sprintf("att_%d_%s_", userID, date.Format("20060102"))
|
|
return 64 - len(prefix)
|
|
}
|
|
|
|
func normalizeJSONObject(raw json.RawMessage) (string, error) {
|
|
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || string(raw) == "null" {
|
|
return "{}", nil
|
|
}
|
|
var text string
|
|
if err := json.Unmarshal(raw, &text); err == nil {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" || text == "null" {
|
|
return "{}", nil
|
|
}
|
|
raw = json.RawMessage(text)
|
|
}
|
|
var value map[string]any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return "", err
|
|
}
|
|
normalized, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(normalized), nil
|
|
}
|
|
|
|
func randomToken(size int) string {
|
|
buf := make([]byte, size)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf)
|
|
}
|
|
|
|
func resourceTail(path, prefix string) (string, string, bool) {
|
|
trimmed := strings.TrimPrefix(path, prefix)
|
|
if trimmed == path || trimmed == "" {
|
|
return "", "", false
|
|
}
|
|
parts := strings.SplitN(trimmed, "/", 2)
|
|
id := strings.TrimSpace(parts[0])
|
|
tail := ""
|
|
if len(parts) == 2 {
|
|
tail = strings.Trim(parts[1], "/")
|
|
}
|
|
return id, tail, id != ""
|
|
}
|
|
|
|
func pagePayload(items []map[string]any, next string) map[string]any {
|
|
return map[string]any{"items": items, "next_cursor": next, "has_more": next != ""}
|
|
}
|