feat: scaffold independent workbench service

This commit is contained in:
richjohnrambo
2026-09-13 22:17:01 +08:00
parent 482f9b80d9
commit 982e71f093
21 changed files with 3649 additions and 2 deletions
+974
View File
@@ -0,0 +1,974 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/auth"
"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
}
func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher) *API {
if publisher == nil {
publisher = events.NoopPublisher{}
}
return &API{store: st, auth: authenticator, events: publisher}
}
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))
}
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 string `json:"location"`
Wifi string `json:"wifi"`
Device string `json:"device"`
}
if !decodeBody(w, r, &body) {
return
}
date, err := parseDate(body.Date)
if err != nil {
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
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
}
record, checkin, err := a.store.CreateAttendanceCheckin(r.Context(), p.UserID, "attc_"+randomToken(12), store.AttendanceCheckinRequest{
Date: date, Action: strings.TrimSpace(body.Action), RuleID: strings.TrimSpace(body.RuleID), ShiftID: strings.TrimSpace(body.ShiftID),
ClientTime: clientTime, Location: strings.TrimSpace(body.Location), Wifi: strings.TrimSpace(body.Wifi), Device: strings.TrimSpace(body.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
}
attachments, _ := json.Marshal(body.AttachmentFileIDs)
submission, err := a.store.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{
SubmissionID: "mb_" + randomToken(12), Subject: strings.TrimSpace(body.Subject), Content: strings.TrimSpace(body.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 (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
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
event := events.NewWorkbenchEvent(p, requestID(r), module, action, resourceID, targets, title, summary, data)
if err := a.events.Publish(ctx, event); err != nil {
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "workbench event publish failed", map[string]any{"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 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 != ""}
}
+9
View File
@@ -0,0 +1,9 @@
package httpapi
import "errors"
var (
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("forbidden")
ErrConflict = errors.New("conflict")
)
+474
View File
@@ -0,0 +1,474 @@
package httpapi
import (
"encoding/json"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/store"
)
func workbenchModulePayloads(modules []store.WorkbenchModule) []map[string]any {
items := make([]map[string]any, 0, len(modules))
for _, module := range modules {
items = append(items, map[string]any{
"code": module.Code,
"name": module.Name,
"icon_file_id": module.IconFileID,
"route": module.Route,
"sort_order": module.SortOrder,
"unread_count": module.UnreadCount,
"enabled": module.Enabled,
"permission": firstNonEmpty(module.Permission, "enabled"),
})
}
return items
}
func organizationDepartmentPayloads(departments []store.OrganizationDepartment) []map[string]any {
items := make([]map[string]any, 0, len(departments))
for _, department := range departments {
items = append(items, organizationDepartmentPayload(department))
}
return items
}
func organizationDepartmentPayload(department store.OrganizationDepartment) map[string]any {
return map[string]any{
"department_id": department.DepartmentID,
"name": department.Name,
"parent_id": department.ParentID,
"child_count": department.ChildCount,
"member_count": department.MemberCount,
"version": department.Version,
}
}
func organizationMemberPayloads(members []store.OrganizationMember) []map[string]any {
items := make([]map[string]any, 0, len(members))
for _, member := range members {
items = append(items, organizationMemberPayload(member))
}
return items
}
func organizationMemberPayload(member store.OrganizationMember) map[string]any {
payload := map[string]any{
"user_id": member.UserID,
"department_id": member.DepartmentID,
"display_name": member.DisplayName,
"position": member.Position,
"avatar_file_id": member.AvatarFileID,
"status": member.Status,
"contactable": member.Contactable,
"version": member.Version,
}
if member.Mobile != "" {
payload["mobile"] = member.Mobile
}
if member.Email != "" {
payload["email"] = member.Email
}
return payload
}
func attendanceConfigPayload(config *store.AttendanceConfig) map[string]any {
return map[string]any{
"date": config.Date.Format("2006-01-02"),
"timezone": config.Timezone,
"rule": attendanceRulePayload(config.Rule),
"shifts": attendanceShiftPayloads(config.Shifts),
"allowed_actions": []string{"check_in", "check_out"},
"today_record": attendanceRecordPayload(config.TodayRecord),
"permissions": map[string]any{
"can_checkin": true,
"can_view_managed": true,
"can_appeal": config.Rule.AppealAllowed,
},
}
}
func attendanceRulePayload(rule store.AttendanceRule) map[string]any {
data := map[string]any{
"rule_id": rule.RuleID,
"name": rule.Name,
"workday": rule.Workday,
"location_required": rule.LocationRequired,
"wifi_required": rule.WifiRequired,
"allow_outside_checkin": rule.AllowOutsideCheckin,
"appeal_allowed": rule.AppealAllowed,
}
if rule.AppealDeadline != nil {
data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano)
}
return data
}
func attendanceShiftPayloads(shifts []store.AttendanceShift) []map[string]any {
items := make([]map[string]any, 0, len(shifts))
for _, shift := range shifts {
items = append(items, map[string]any{
"shift_id": shift.ShiftID,
"name": shift.Name,
"work_start": shift.WorkStart,
"work_end": shift.WorkEnd,
"checkin_start": shift.CheckinStart,
"checkout_end": shift.CheckoutEnd,
})
}
return items
}
func attendanceRecordPayloads(records []store.AttendanceRecord) []map[string]any {
items := make([]map[string]any, 0, len(records))
for i := range records {
items = append(items, attendanceRecordPayload(&records[i]))
}
return items
}
func attendanceRecordPayload(record *store.AttendanceRecord) map[string]any {
if record == nil {
return nil
}
return map[string]any{
"record_id": record.RecordID,
"user_id": record.UserID,
"date": record.Date.Format("2006-01-02"),
"rule_id": record.RuleID,
"shift_id": record.ShiftID,
"status": record.Status,
"checkins": attendanceCheckinPayloads(record.Checkins),
"exceptions": jsonList(record.Exceptions),
"appeal_status": record.AppealStatus,
"version": record.Version,
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func attendanceCheckinPayloads(checkins []store.AttendanceCheckin) []map[string]any {
items := make([]map[string]any, 0, len(checkins))
for _, checkin := range checkins {
items = append(items, attendanceCheckinPayload(checkin))
}
return items
}
func attendanceCheckinPayload(checkin store.AttendanceCheckin) map[string]any {
return map[string]any{
"checkin_id": checkin.CheckinID,
"action": checkin.Action,
"client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano),
"server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano),
"result": checkin.Result,
"location": checkin.Location,
"wifi": checkin.Wifi,
"device": checkin.Device,
}
}
func attendanceStatsPayload(stats *store.AttendanceStats) map[string]any {
if stats == nil {
return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0}
}
return map[string]any{
"expected_count": stats.ExpectedCount,
"checked_count": stats.CheckedCount,
"exception_count": stats.ExceptionCount,
"not_checked_count": stats.NotCheckedCount,
}
}
func attendanceAppealPayloads(appeals []store.AttendanceAppeal) []map[string]any {
items := make([]map[string]any, 0, len(appeals))
for i := range appeals {
items = append(items, attendanceAppealPayload(&appeals[i]))
}
return items
}
func attendanceAppealPayload(appeal *store.AttendanceAppeal) map[string]any {
if appeal == nil {
return nil
}
data := map[string]any{
"appeal_id": appeal.AppealID,
"record_id": appeal.RecordID,
"user_id": appeal.UserID,
"appeal_type": appeal.AppealType,
"reason": appeal.Reason,
"target_action": appeal.TargetAction,
"attachment_file_ids": jsonList(appeal.AttachmentFileIDs),
"status": appeal.Status,
"version": appeal.Version,
"submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano),
"created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if appeal.TargetTime != nil {
data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano)
}
return data
}
func meetingPayloads(meetings []store.Meeting, viewer int64) []map[string]any {
items := make([]map[string]any, 0, len(meetings))
for _, meeting := range meetings {
items = append(items, meetingPayload(meeting, viewer))
}
return items
}
func meetingPayload(meeting store.Meeting, viewer int64) map[string]any {
participants := meetingParticipantPayloads(meeting.Participants)
recorderID := meeting.RecorderID
if recorderID == 0 {
recorderID = meeting.OrganizerID
}
data := map[string]any{
"meeting_id": meeting.MeetingID,
"title": meeting.Title,
"organizer_id": meeting.OrganizerID,
"organizer": userSummary(meeting.OrganizerID),
"recorder_user_id": recorderID,
"recorder": userSummary(recorderID),
"start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano),
"end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano),
"meeting_time": timeRange(meeting.StartAt, meeting.EndAt),
"time_range": timeRange(meeting.StartAt, meeting.EndAt),
"location": meeting.Location,
"state": meeting.State,
"reminder_minutes": jsonList(meeting.ReminderMinutes),
"version": meeting.Version,
"participants": participants,
"participant_count": len(participants),
"participant_names": []string{},
"permissions": meetingPermissions(meeting, viewer),
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if meeting.CanceledAt != nil {
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
}
for _, participant := range meeting.Participants {
if participant.UserID == viewer {
data["my_response"] = participant.Response
break
}
}
return data
}
func meetingParticipantPayloads(participants []store.MeetingParticipant) []map[string]any {
items := make([]map[string]any, 0, len(participants))
for _, participant := range participants {
item := map[string]any{"user_id": participant.UserID, "response": participant.Response}
if participant.ResponseAt != nil {
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
}
items = append(items, item)
}
return items
}
func meetingNoteListPayloads(meetings []store.Meeting, viewer int64) []map[string]any {
items := make([]map[string]any, 0, len(meetings))
for _, meeting := range meetings {
item := meetingPayload(meeting, viewer)
item["note_status"] = "empty"
item["file_count"] = 0
item["permissions"] = meetingNotePermissions(meeting, viewer)
items = append(items, item)
}
return items
}
func meetingNotesDetailPayload(meeting store.Meeting, files []store.MeetingNoteFile, viewer int64) map[string]any {
meetingData := meetingPayload(meeting, viewer)
fileItems := make([]map[string]any, 0, len(files))
for _, file := range files {
fileItems = append(fileItems, meetingNoteFilePayload(file, viewer))
}
data := map[string]any{}
for k, v := range meetingData {
data[k] = v
}
data["meeting"] = meetingData
data["note_status"] = "empty"
if len(files) > 0 {
data["note_status"] = "available"
}
data["file_count"] = len(files)
data["files"] = fileItems
data["permissions"] = meetingNotePermissions(meeting, viewer)
return data
}
func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string]any {
return map[string]any{
"note_file_id": file.NoteFileID,
"meeting_id": file.MeetingID,
"file_id": file.FileID,
"file_name": file.FileName,
"mime_type": file.MIMEType,
"file_size": file.FileSize,
"file_etag": file.FileETag,
"status": file.Status,
"preview_status": file.PreviewStatus,
"preview_mode": file.PreviewMode,
"preview_file_id": file.PreviewFileID,
"uploader_user_id": file.UploaderID,
"uploader": userSummary(file.UploaderID),
"version": file.Version,
"permissions": map[string]any{
"can_preview": file.Status == "active",
"can_download": file.Status == "active",
"can_replace": file.Status == "active" && file.UploaderID == viewer,
"can_delete": file.Status == "active" && file.UploaderID == viewer,
},
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func dailyReportPayloads(reports []store.DailyReport) []map[string]any {
items := make([]map[string]any, 0, len(reports))
for _, report := range reports {
items = append(items, dailyReportPayload(report))
}
return items
}
func dailyReportPayload(report store.DailyReport) map[string]any {
data := map[string]any{
"report_id": report.ReportID,
"author_id": report.AuthorID,
"report_date": report.ReportDate.Format("2006-01-02"),
"content": report.Content,
"content_short": preview(report.Content),
"attachments": jsonList(report.Attachments),
"status": report.Status,
"read_state": report.ReadState,
"version": report.Version,
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if report.SubmittedAt != nil {
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func monthlyReportPayloads(reports []store.MonthlyReport) []map[string]any {
items := make([]map[string]any, 0, len(reports))
for _, report := range reports {
items = append(items, monthlyReportPayload(report))
}
return items
}
func monthlyReportPayload(report store.MonthlyReport) map[string]any {
data := map[string]any{
"report_id": report.ReportID,
"author_id": report.AuthorID,
"month": report.Month,
"work_summary": report.WorkSummary,
"next_plan": report.NextPlan,
"attachments": jsonList(report.Attachments),
"status": report.Status,
"read_state": report.ReadState,
"version": report.Version,
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if report.SubmittedAt != nil {
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func mailboxConfigPayload(config store.MailboxConfig) map[string]any {
return map[string]any{
"enabled": config.Enabled,
"allow_anonymous": config.AllowAnonymous,
"required_fields": jsonList(config.RequiredFields),
"max_length": config.MaxLength,
"attachment_policy": jsonObject(config.AttachmentPolicy),
"notice": map[string]any{
"title": config.NoticeTitle,
"content": config.NoticeContent,
"version": config.Version,
},
}
}
func mailboxSubmissionPayload(submission store.MailboxSubmission) map[string]any {
return map[string]any{
"submission_id": submission.SubmissionID,
"sender_id": submission.SenderID,
"subject": submission.Subject,
"anonymous": submission.Anonymous,
"attachment_file_ids": jsonList(submission.AttachmentFileIDs),
"notice_version": submission.NoticeVersion,
"status": submission.Status,
"submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano),
}
}
func meetingPermissions(meeting store.Meeting, viewer int64) map[string]any {
isOrganizer := meeting.OrganizerID == viewer
isScheduled := meeting.State == "scheduled"
return map[string]any{
"can_update": isOrganizer && isScheduled,
"can_cancel": isOrganizer && isScheduled,
"can_remind": isOrganizer && isScheduled,
"can_respond": !isOrganizer && isScheduled,
}
}
func meetingNotePermissions(meeting store.Meeting, viewer int64) map[string]any {
recorderID := meeting.RecorderID
if recorderID == 0 {
recorderID = meeting.OrganizerID
}
canWrite := recorderID == viewer || meeting.OrganizerID == viewer
return map[string]any{"can_view": true, "can_upload": canWrite, "can_replace": canWrite, "can_delete": canWrite, "can_download": true}
}
func userSummary(userID int64) map[string]any {
return map[string]any{"user_id": userID, "display_name": userID}
}
func jsonList(raw string) []any {
var items []any
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &items); err != nil {
return []any{}
}
return items
}
func jsonObject(raw string) map[string]any {
var item map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &item); err != nil {
return map[string]any{}
}
return item
}
func preview(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= 40 {
return string(runes)
}
return string(runes[:40])
}
func timeRange(startAt, endAt time.Time) string {
if startAt.Format("2006-01-02") == endAt.Format("2006-01-02") {
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("15:04")
}
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("2006-01-02 15:04")
}
+86
View File
@@ -0,0 +1,86 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"time"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store"
)
const (
reasonMalformedRequest = "MALFORMED_REQUEST"
reasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
reasonAuthRequired = "AUTH_REQUIRED"
reasonForbidden = "FORBIDDEN"
reasonNotFound = "RESOURCE_NOT_FOUND"
reasonConflict = "CONFLICT"
reasonInternal = "INTERNAL"
)
type apiError struct {
Code int `json:"code"`
Reason string `json:"reason"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
ServerTime string `json:"server_time"`
}
func writeSuccess(w http.ResponseWriter, r *http.Request, data any) {
writeJSON(w, r, http.StatusOK, map[string]any{
"code": 0,
"data": data,
"request_id": requestID(r),
"server_time": time.Now().UTC().Format(time.RFC3339Nano),
})
}
func writeError(w http.ResponseWriter, r *http.Request, status, code int, reason, message string, details map[string]any) {
writeJSON(w, r, status, apiError{
Code: code,
Reason: reason,
Message: message,
Details: details,
RequestID: requestID(r),
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, ErrNotFound), errors.Is(err, store.ErrNotFound):
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
case errors.Is(err, ErrForbidden), errors.Is(err, store.ErrForbidden), errors.Is(err, auth.ErrForbidden):
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
case errors.Is(err, ErrConflict), errors.Is(err, store.ErrConflict):
writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", nil)
default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
}
func writeJSON(w http.ResponseWriter, r *http.Request, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-IM-Schema", firstNonEmpty(r.Header.Get("X-IM-Schema"), "1"))
if rid := requestID(r); rid != "" {
w.Header().Set("X-Request-ID", rid)
}
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func requestID(r *http.Request) string {
return r.Header.Get("X-Request-ID")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}