diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 34626c3..1b8b269 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -238,7 +238,7 @@ func (a *API) attendanceRecords(w http.ResponseWriter, r *http.Request, p *auth. 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")), + UserID: parseUserIDQuery(r.URL.Query().Get("user_id")), }) if err != nil { writeStoreError(w, r, err) @@ -401,13 +401,13 @@ func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal 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"` + 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 @@ -424,9 +424,9 @@ func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal } 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, + 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, - }, body.AttendeeUserIDs) + }, parseUserIDList(body.AttendeeUserIDs)) if err != nil { writeStoreError(w, r, err) return @@ -459,14 +459,14 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr 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"` + Title *string `json:"title"` + RecorderUserID optionalUserID `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}) + meeting, err := a.store.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 @@ -729,14 +729,14 @@ func (a *API) dailyReportReminders(w http.ResponseWriter, r *http.Request, p *au return } var body struct { - Date string `json:"date"` - UserIDs []int64 `json:"user_ids"` + 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), - body.UserIDs, "日报提醒", 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 } @@ -1000,8 +1000,8 @@ func limit(r *http.Request, fallback, max int) int { return parsed } -func parseInt64(value string) int64 { - parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) +func parseUserIDQuery(value string) int64 { + parsed, _ := parseUserID(value) return parsed } diff --git a/internal/httpapi/payload.go b/internal/httpapi/payload.go index b80f482..d64488f 100644 --- a/internal/httpapi/payload.go +++ b/internal/httpapi/payload.go @@ -54,7 +54,7 @@ func organizationMemberPayloads(members []store.OrganizationMember) []map[string func organizationMemberPayload(member store.OrganizationMember) map[string]any { payload := map[string]any{ - "user_id": member.UserID, + "user_id": formatUserID(member.UserID), "department_id": member.DepartmentID, "display_name": member.DisplayName, "position": member.Position, @@ -133,7 +133,7 @@ func attendanceRecordPayload(record *store.AttendanceRecord) map[string]any { } return map[string]any{ "record_id": record.RecordID, - "user_id": record.UserID, + "user_id": formatUserID(record.UserID), "date": record.Date.Format("2006-01-02"), "rule_id": record.RuleID, "shift_id": record.ShiftID, @@ -195,7 +195,7 @@ func attendanceAppealPayload(appeal *store.AttendanceAppeal) map[string]any { data := map[string]any{ "appeal_id": appeal.AppealID, "record_id": appeal.RecordID, - "user_id": appeal.UserID, + "user_id": formatUserID(appeal.UserID), "appeal_type": appeal.AppealType, "reason": appeal.Reason, "target_action": appeal.TargetAction, @@ -229,9 +229,9 @@ func meetingPayload(meeting store.Meeting, viewer int64) map[string]any { data := map[string]any{ "meeting_id": meeting.MeetingID, "title": meeting.Title, - "organizer_id": meeting.OrganizerID, + "organizer_id": formatUserID(meeting.OrganizerID), "organizer": userSummary(meeting.OrganizerID), - "recorder_user_id": recorderID, + "recorder_user_id": formatUserID(recorderID), "recorder": userSummary(recorderID), "start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano), "end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano), @@ -263,7 +263,7 @@ func meetingPayload(meeting store.Meeting, viewer int64) map[string]any { 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} + item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response} if participant.ResponseAt != nil { item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano) } @@ -318,7 +318,7 @@ func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string "preview_status": file.PreviewStatus, "preview_mode": file.PreviewMode, "preview_file_id": file.PreviewFileID, - "uploader_user_id": file.UploaderID, + "uploader_user_id": formatUserID(file.UploaderID), "uploader": userSummary(file.UploaderID), "version": file.Version, "permissions": map[string]any{ @@ -343,7 +343,7 @@ func dailyReportPayloads(reports []store.DailyReport) []map[string]any { func dailyReportPayload(report store.DailyReport) map[string]any { data := map[string]any{ "report_id": report.ReportID, - "author_id": report.AuthorID, + "author_id": formatUserID(report.AuthorID), "report_date": report.ReportDate.Format("2006-01-02"), "content": report.Content, "content_short": preview(report.Content), @@ -372,7 +372,7 @@ func monthlyReportPayloads(reports []store.MonthlyReport) []map[string]any { func monthlyReportPayload(report store.MonthlyReport) map[string]any { data := map[string]any{ "report_id": report.ReportID, - "author_id": report.AuthorID, + "author_id": formatUserID(report.AuthorID), "month": report.Month, "work_summary": report.WorkSummary, "next_plan": report.NextPlan, @@ -408,7 +408,7 @@ func mailboxConfigPayload(config store.MailboxConfig) map[string]any { func mailboxSubmissionPayload(submission store.MailboxSubmission) map[string]any { return map[string]any{ "submission_id": submission.SubmissionID, - "sender_id": submission.SenderID, + "sender_id": formatUserID(submission.SenderID), "subject": submission.Subject, "anonymous": submission.Anonymous, "attachment_file_ids": jsonList(submission.AttachmentFileIDs), @@ -439,7 +439,7 @@ func meetingNotePermissions(meeting store.Meeting, viewer int64) map[string]any } func userSummary(userID int64) map[string]any { - return map[string]any{"user_id": userID, "display_name": userID} + return map[string]any{"user_id": formatUserID(userID), "display_name": formatUserID(userID)} } func jsonList(raw string) []any { diff --git a/internal/httpapi/uid.go b/internal/httpapi/uid.go new file mode 100644 index 0000000..e88e67d --- /dev/null +++ b/internal/httpapi/uid.go @@ -0,0 +1,100 @@ +package httpapi + +import ( + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +type userID int64 + +func (id *userID) UnmarshalJSON(raw []byte) error { + value, err := parseUserIDJSON(raw) + if err != nil { + return err + } + *id = userID(value) + return nil +} + +type optionalUserID struct { + value int64 + set bool +} + +func (id *optionalUserID) UnmarshalJSON(raw []byte) error { + if strings.TrimSpace(string(raw)) == "null" { + id.set = true + id.value = 0 + return nil + } + value, err := parseUserIDJSON(raw) + if err != nil { + return err + } + id.set = true + id.value = value + return nil +} + +func (id optionalUserID) ptr() *int64 { + if !id.set { + return nil + } + return &id.value +} + +func parseUserIDJSON(raw []byte) (int64, error) { + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return parseUserID(text) + } + var number int64 + if err := json.Unmarshal(raw, &number); err == nil { + return number, nil + } + return 0, fmt.Errorf("invalid user id") +} + +func parseUserID(value string) (int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, fmt.Errorf("empty user id") + } + if strings.HasPrefix(value, "usr") { + decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(strings.TrimPrefix(value, "usr")) + if err != nil || len(decoded) < 8 { + return 0, fmt.Errorf("invalid user id") + } + return int64(binary.LittleEndian.Uint64(decoded[:8])), nil + } + return strconv.ParseInt(value, 10, 64) +} + +func formatUserID(id int64) string { + if id == 0 { + return "" + } + src := make([]byte, 8) + dst := make([]byte, base64.URLEncoding.WithPadding(base64.NoPadding).EncodedLen(8)) + binary.LittleEndian.PutUint64(src, uint64(id)) + base64.URLEncoding.WithPadding(base64.NoPadding).Encode(dst, src) + return "usr" + string(dst) +} + +func parseUserIDList(values []userID) []int64 { + items := make([]int64, 0, len(values)) + seen := make(map[int64]bool, len(values)) + for _, value := range values { + id := int64(value) + if id == 0 || seen[id] { + continue + } + seen[id] = true + items = append(items, id) + } + return items +} diff --git a/internal/httpapi/uid_test.go b/internal/httpapi/uid_test.go new file mode 100644 index 0000000..91be278 --- /dev/null +++ b/internal/httpapi/uid_test.go @@ -0,0 +1,33 @@ +package httpapi + +import ( + "encoding/json" + "testing" +) + +func TestUserIDJSONAcceptsIMUserID(t *testing.T) { + var body struct { + RecorderUserID userID `json:"recorder_user_id"` + AttendeeUserIDs []userID `json:"attendee_user_ids"` + } + raw := []byte(`{"recorder_user_id":"usrag7w9xJ6hto","attendee_user_ids":["usr1RqTFgHSenM"]}`) + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.RecorderUserID == 0 || len(body.AttendeeUserIDs) != 1 || body.AttendeeUserIDs[0] == 0 { + t.Fatalf("decoded zero ids: %+v", body) + } + if got := formatUserID(int64(body.RecorderUserID)); got != "usrag7w9xJ6hto" { + t.Fatalf("roundtrip recorder=%q", got) + } +} + +func TestUserIDJSONStillAcceptsNumericID(t *testing.T) { + var id userID + if err := json.Unmarshal([]byte(`1573862062872858624`), &id); err != nil { + t.Fatalf("unmarshal numeric: %v", err) + } + if id != 1573862062872858624 { + t.Fatalf("id=%d", id) + } +}