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/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 } const workbenchEventPublishTimeout = 15 * time.Second const ( maxMailboxSubjectLength = 200 maxMailboxContentLength = 5000 ) func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher, authCfg config.AuthConfig) *API { if publisher == nil { publisher = events.NoopPublisher{} } return &API{store: st, auth: authenticator, events: publisher, authCfg: authCfg} } func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/v1/workbench", a.withAuth(a.workbench)) 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/mailbox-config", a.withInternalAdminAuth(a.adminMailboxConfig)) mux.HandleFunc("/internal/admin/workbench/feedback-config", a.withInternalAdminAuth(a.adminFeedbackConfig)) } 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) 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 } next(w, r, principal) } } func (a *API) workbench(w http.ResponseWriter, r *http.Request, p *auth.Principal) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } modules, version, err := a.store.WorkbenchModules(r.Context(), p.UserID) if err != nil { writeStoreError(w, r, err) return } items := workbenchModulePayloads(modules) if err := a.populateUnread(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) { 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 := a.store.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) { 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 := a.store.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) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } query := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")) items, next, err := a.store.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) { 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 := a.store.Departments(r.Context(), p.UserID, "root", "", limit(r, 10, 50)) if err != nil { writeStoreError(w, r, err) return } members, _, err := a.store.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) { 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 := a.store.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) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } records, next, stats, err := a.store.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: parseInt64(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) { 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 := a.store.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) { switch r.Method { case http.MethodGet: items, next, err := a.store.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 := a.store.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) { switch r.Method { case http.MethodGet: items, next, err := a.store.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 int64 `json:"recorder_user_id"` StartAt string `json:"start_at"` EndAt string `json:"end_at"` Location string `json:"location"` AttendeeUserIDs []int64 `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 := a.store.CreateMeeting(r.Context(), p.UserID, store.Meeting{ MeetingID: "mtg_" + randomToken(12), RecorderID: body.RecorderUserID, Title: strings.TrimSpace(body.Title), StartAt: startAt, EndAt: endAt, Location: strings.TrimSpace(body.Location), ReminderMinutes: string(reminders), IdempotencyKey: idempotencyKey, }, 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) { 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 := a.store.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 *int64 `json:"recorder_user_id"` Version int `json:"version"` } if !decodeBody(w, r, &body) { return } meeting, err := a.store.UpdateMeeting(r.Context(), p.UserID, meetingID, store.MeetingPatch{Title: body.Title, RecorderID: body.RecorderUserID, 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 := a.store.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 := a.store.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, meetingID) case tail == "notes/files" && r.Method == http.MethodPost: a.meetingNoteFiles(w, r, p, meetingID) case strings.HasPrefix(tail, "notes/files/"): a.meetingNoteFileResource(w, r, p, meetingID, strings.TrimPrefix(tail, "notes/files/")) default: methodNotAllowed(w, r) } } func (a *API) meetingNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } items, next, err := a.store.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, meetingID string) { meeting, err := a.store.Meeting(r.Context(), p.UserID, meetingID) if err != nil { writeStoreError(w, r, err) return } files, err := a.store.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, meetingID string) { idempotencyKey, ok := requireIdempotencyKey(w, r) if !ok { return } var body struct { FileID string `json:"file_id"` } if !decodeBody(w, r, &body) { return } file, err := a.store.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), strings.TrimSpace(body.FileID), 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, 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"` Version int `json:"version"` } if !decodeBody(w, r, &body) { return } file, err := a.store.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, strings.TrimSpace(body.FileID), 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 := a.store.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) { switch r.Method { case http.MethodGet: items, next, err := a.store.DailyReports(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(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 := a.store.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) { 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 := a.store.DailyReport(r.Context(), p.UserID, reportID) 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 == "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 := a.store.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.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)) case tail == "submit" && r.Method == http.MethodPost: var body struct { Version int `json:"version"` } if !decodeBody(w, r, &body) { return } report, err := a.store.SubmitDaily(r.Context(), p.UserID, reportID, body.Version) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, dailyReportPayload(*report)) default: methodNotAllowed(w, r) } } func (a *API) dailyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } writeSuccess(w, r, map[string]any{"expected_count": 0, "submitted_count": 0, "missing_count": 0, "scope": map[string]any{"department_id": r.URL.Query().Get("department_id")}}) } func (a *API) dailyReportReminders(w http.ResponseWriter, r *http.Request, p *auth.Principal) { 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 []int64 `json:"user_ids"` } if !decodeBody(w, r, &body) { return } if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.reminded", strings.TrimSpace(body.Date), 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) { switch r.Method { case http.MethodGet: items, next, err := a.store.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 := a.store.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) { 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 := a.store.MonthlyReport(r.Context(), p.UserID, reportID) 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 == "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 := a.store.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.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 == "submit" && r.Method == http.MethodPost: var body struct { Version int `json:"version"` } if !decodeBody(w, r, &body) { return } report, err := a.store.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.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)) case tail == "reopen" && r.Method == http.MethodPost: var body struct { Version int `json:"version"` } if !decodeBody(w, r, &body) { return } report, err := a.store.ReopenMonthly(r.Context(), p.UserID, reportID, body.Version) if err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, monthlyReportPayload(*report)) default: methodNotAllowed(w, r) } } func (a *API) monthlyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } writeSuccess(w, r, map[string]any{"expected_count": 0, "submitted_count": 0, "missing_count": 0, "scope": map[string]any{"department_id": r.URL.Query().Get("department_id")}}) } func (a *API) mailboxConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } cfg, err := a.store.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) { 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 := a.store.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(r *http.Request, p *auth.Principal, modules []map[string]any) error { for _, module := range modules { if module["code"] == "meetings" { count, err := a.store.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 parseInt64(value string) int64 { parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) 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 != ""} }