From 9cfeb5f2c7e51fe9aa761610cc54665ad7d44fea Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 17 Sep 2026 18:32:26 +0800 Subject: [PATCH] fix: route workbench requests to tenant database --- internal/httpapi/api.go | 141 ++++++++++++++++++----------------- internal/store/mysql.go | 125 ++++++++++++++++++++++++++++++- internal/store/mysql_test.go | 30 ++++++++ 3 files changed, 224 insertions(+), 72 deletions(-) create mode 100644 internal/store/mysql_test.go diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 1b8b269..071863c 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -72,7 +72,7 @@ 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) +type handlerFunc func(http.ResponseWriter, *http.Request, *auth.Principal, *store.Store) func (a *API) withAuth(next handlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -86,22 +86,27 @@ func (a *API) withAuth(next handlerFunc) http.HandlerFunc { } return } - next(w, r, principal) + st, err := a.store.ForTenant(r.Context(), principal.TenantID, principal.TenantCode) + if err != nil { + writeStoreError(w, r, err) + return + } + next(w, r, principal, st) } } -func (a *API) workbench(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) workbench(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } - modules, version, err := a.store.WorkbenchModules(r.Context(), p.UserID) + modules, version, err := st.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 { + if err := a.populateUnread(st, r, p, items); err != nil { writeStoreError(w, r, err) return } @@ -122,7 +127,7 @@ func (a *API) workbench(w http.ResponseWriter, r *http.Request, p *auth.Principa }) } -func (a *API) organizationDepartments(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) organizationDepartments(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return @@ -131,7 +136,7 @@ func (a *API) organizationDepartments(w http.ResponseWriter, r *http.Request, p if parentID == "" { parentID = "root" } - items, next, err := a.store.Departments(r.Context(), p.UserID, parentID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + items, next, err := st.Departments(r.Context(), p.UserID, parentID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) if err != nil { writeStoreError(w, r, err) return @@ -139,7 +144,7 @@ func (a *API) organizationDepartments(w http.ResponseWriter, r *http.Request, p writeSuccess(w, r, pagePayload(organizationDepartmentPayloads(items), next)) } -func (a *API) organizationDepartmentResource(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) organizationDepartmentResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { departmentID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/organization/departments/") if !ok || tail != "members" { writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "organization route not found", nil) @@ -149,7 +154,7 @@ func (a *API) organizationDepartmentResource(w http.ResponseWriter, r *http.Requ methodNotAllowed(w, r) return } - items, next, err := a.store.DepartmentMembers(r.Context(), p.UserID, departmentID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + items, next, err := st.DepartmentMembers(r.Context(), p.UserID, departmentID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) if err != nil { writeStoreError(w, r, err) return @@ -159,13 +164,13 @@ func (a *API) organizationDepartmentResource(w http.ResponseWriter, r *http.Requ writeSuccess(w, r, payload) } -func (a *API) organizationMembers(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) organizationMembers(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } query := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")) - items, next, err := 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)) + items, next, err := st.Members(r.Context(), p.UserID, r.URL.Query().Get("department_id"), strings.TrimSpace(query), r.URL.Query().Get("cursor"), limit(r, 50, 100)) if err != nil { writeStoreError(w, r, err) return @@ -175,7 +180,7 @@ func (a *API) organizationMembers(w http.ResponseWriter, r *http.Request, p *aut writeSuccess(w, r, payload) } -func (a *API) organizationSearch(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) organizationSearch(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return @@ -185,12 +190,12 @@ func (a *API) organizationSearch(w http.ResponseWriter, r *http.Request, p *auth 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)) + departments, _, err := st.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)) + members, _, err := st.Members(r.Context(), p.UserID, "", query, "", limit(r, 20, 100)) if err != nil { writeStoreError(w, r, err) return @@ -207,7 +212,7 @@ func (a *API) organizationSearch(w http.ResponseWriter, r *http.Request, p *auth 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) { +func (a *API) attendanceConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return @@ -217,7 +222,7 @@ func (a *API) attendanceConfig(w http.ResponseWriter, r *http.Request, p *auth.P 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) + cfg, err := st.AttendanceConfig(r.Context(), p.UserID, date) if err != nil { writeStoreError(w, r, err) return @@ -225,12 +230,12 @@ func (a *API) attendanceConfig(w http.ResponseWriter, r *http.Request, p *auth.P writeSuccess(w, r, attendanceConfigPayload(cfg)) } -func (a *API) attendanceRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) attendanceRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } - records, next, stats, err := a.store.AttendanceRecords(r.Context(), p.UserID, store.AttendanceListOptions{ + records, next, stats, err := st.AttendanceRecords(r.Context(), p.UserID, store.AttendanceListOptions{ Scope: r.URL.Query().Get("scope"), From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), @@ -250,7 +255,7 @@ func (a *API) attendanceRecords(w http.ResponseWriter, r *http.Request, p *auth. writeSuccess(w, r, payload) } -func (a *API) attendanceCheckins(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) attendanceCheckins(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodPost { methodNotAllowed(w, r) return @@ -316,7 +321,7 @@ func (a *API) attendanceCheckins(w http.ResponseWriter, r *http.Request, p *auth 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{ + record, checkin, err := st.CreateAttendanceCheckin(r.Context(), p.UserID, "attc_"+randomToken(12), store.AttendanceCheckinRequest{ Date: date, Action: action, RuleID: ruleID, ShiftID: shiftID, ClientTime: clientTime, Location: location, Wifi: wifi, Device: device, IdempotencyKey: idempotencyKey, }) @@ -332,10 +337,10 @@ func (a *API) attendanceCheckins(w http.ResponseWriter, r *http.Request, p *auth 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) { +func (a *API) attendanceAppeals(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { switch r.Method { case http.MethodGet: - items, next, err := a.store.AttendanceAppeals(r.Context(), p.UserID, r.URL.Query().Get("status"), r.URL.Query().Get("cursor"), limit(r, 50, 100)) + items, next, err := st.AttendanceAppeals(r.Context(), p.UserID, r.URL.Query().Get("status"), r.URL.Query().Get("cursor"), limit(r, 50, 100)) if err != nil { writeStoreError(w, r, err) return @@ -367,7 +372,7 @@ func (a *API) attendanceAppeals(w http.ResponseWriter, r *http.Request, p *auth. targetTime = &parsed } attachments, _ := json.Marshal(body.AttachmentFileIDs) - appeal, err := a.store.CreateAttendanceAppeal(r.Context(), p.UserID, store.AttendanceAppeal{ + appeal, err := st.CreateAttendanceAppeal(r.Context(), p.UserID, store.AttendanceAppeal{ AppealID: "atta_" + randomToken(12), RecordID: strings.TrimSpace(body.RecordID), UserID: p.UserID, AppealType: strings.TrimSpace(body.AppealType), Reason: strings.TrimSpace(body.Reason), TargetAction: strings.TrimSpace(body.TargetAction), TargetTime: targetTime, AttachmentFileIDs: string(attachments), }, idempotencyKey) @@ -386,10 +391,10 @@ func (a *API) attendanceAppeals(w http.ResponseWriter, r *http.Request, p *auth. } } -func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { switch r.Method { case http.MethodGet: - items, next, err := a.store.Meetings(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + items, next, err := st.Meetings(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) if err != nil { writeStoreError(w, r, err) return @@ -423,7 +428,7 @@ func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal return } reminders, _ := json.Marshal(body.ReminderMinutes) - meeting, err := a.store.CreateMeeting(r.Context(), p.UserID, store.Meeting{ + meeting, err := st.CreateMeeting(r.Context(), p.UserID, store.Meeting{ MeetingID: "mtg_" + randomToken(12), RecorderID: int64(body.RecorderUserID), Title: strings.TrimSpace(body.Title), StartAt: startAt, EndAt: endAt, Location: strings.TrimSpace(body.Location), ReminderMinutes: string(reminders), IdempotencyKey: idempotencyKey, }, parseUserIDList(body.AttendeeUserIDs)) @@ -443,7 +448,7 @@ func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal } } -func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { meetingID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/meetings/") if !ok { writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "meeting route not found", nil) @@ -451,7 +456,7 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr } switch { case tail == "" && r.Method == http.MethodGet: - meeting, err := a.store.Meeting(r.Context(), p.UserID, meetingID) + meeting, err := st.Meeting(r.Context(), p.UserID, meetingID) if err != nil { writeStoreError(w, r, err) return @@ -466,7 +471,7 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr if !decodeBody(w, r, &body) { return } - meeting, err := a.store.UpdateMeeting(r.Context(), p.UserID, meetingID, store.MeetingPatch{Title: body.Title, RecorderID: body.RecorderUserID.ptr(), Version: body.Version}) + meeting, err := st.UpdateMeeting(r.Context(), p.UserID, meetingID, store.MeetingPatch{Title: body.Title, RecorderID: body.RecorderUserID.ptr(), Version: body.Version}) if err != nil { writeStoreError(w, r, err) return @@ -478,7 +483,7 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr } writeSuccess(w, r, meetingPayload(*meeting, p.UserID)) case tail == "cancel" && r.Method == http.MethodPost: - meeting, err := a.store.CancelMeeting(r.Context(), p.UserID, meetingID) + meeting, err := st.CancelMeeting(r.Context(), p.UserID, meetingID) if err != nil { writeStoreError(w, r, err) return @@ -497,7 +502,7 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr if !decodeBody(w, r, &body) { return } - meeting, err := a.store.RespondMeeting(r.Context(), p.UserID, meetingID, strings.TrimSpace(body.Response), body.Version) + meeting, err := st.RespondMeeting(r.Context(), p.UserID, meetingID, strings.TrimSpace(body.Response), body.Version) if err != nil { writeStoreError(w, r, err) return @@ -509,22 +514,22 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr } writeSuccess(w, r, meetingPayload(*meeting, p.UserID)) case tail == "notes" && r.Method == http.MethodGet: - a.meetingNotesDetail(w, r, p, meetingID) + a.meetingNotesDetail(w, r, p, st, meetingID) case tail == "notes/files" && r.Method == http.MethodPost: - a.meetingNoteFiles(w, r, p, meetingID) + a.meetingNoteFiles(w, r, p, st, meetingID) case strings.HasPrefix(tail, "notes/files/"): - a.meetingNoteFileResource(w, r, p, meetingID, strings.TrimPrefix(tail, "notes/files/")) + a.meetingNoteFileResource(w, r, p, st, meetingID, strings.TrimPrefix(tail, "notes/files/")) default: methodNotAllowed(w, r) } } -func (a *API) meetingNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) meetingNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } - items, next, err := a.store.MeetingNotes(r.Context(), p.UserID, r.URL.Query().Get("query"), r.URL.Query().Get("cursor"), limit(r, 50, 100)) + items, next, err := st.MeetingNotes(r.Context(), p.UserID, r.URL.Query().Get("query"), r.URL.Query().Get("cursor"), limit(r, 50, 100)) if err != nil { writeStoreError(w, r, err) return @@ -532,13 +537,13 @@ func (a *API) meetingNotes(w http.ResponseWriter, r *http.Request, p *auth.Princ 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) +func (a *API) meetingNotesDetail(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { + meeting, err := st.Meeting(r.Context(), p.UserID, meetingID) if err != nil { writeStoreError(w, r, err) return } - files, err := a.store.MeetingNoteFiles(r.Context(), p.UserID, meetingID) + files, err := st.MeetingNoteFiles(r.Context(), p.UserID, meetingID) if err != nil { writeStoreError(w, r, err) return @@ -546,7 +551,7 @@ func (a *API) meetingNotesDetail(w http.ResponseWriter, r *http.Request, p *auth writeSuccess(w, r, meetingNotesDetailPayload(*meeting, files, p.UserID)) } -func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.Principal, meetingID string) { +func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { idempotencyKey, ok := requireIdempotencyKey(w, r) if !ok { return @@ -557,7 +562,7 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P if !decodeBody(w, r, &body) { return } - file, err := a.store.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), strings.TrimSpace(body.FileID), idempotencyKey) + file, err := st.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), strings.TrimSpace(body.FileID), idempotencyKey) if err != nil { writeStoreError(w, r, err) return @@ -570,7 +575,7 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P writeSuccess(w, r, meetingNoteFilePayload(*file, p.UserID)) } -func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, meetingID, noteFileID string) { +func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID, noteFileID string) { idempotencyKey, ok := requireIdempotencyKey(w, r) if !ok { return @@ -584,7 +589,7 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p if !decodeBody(w, r, &body) { return } - file, err := a.store.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, strings.TrimSpace(body.FileID), idempotencyKey, body.Version) + file, err := st.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, strings.TrimSpace(body.FileID), idempotencyKey, body.Version) if err != nil { writeStoreError(w, r, err) return @@ -601,7 +606,7 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p 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) + file, err := st.DeleteMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, idempotencyKey, version) if err != nil { writeStoreError(w, r, err) return @@ -617,10 +622,10 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p } } -func (a *API) dailyReports(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) dailyReports(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { 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)) + items, next, err := st.DailyReports(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) if err != nil { writeStoreError(w, r, err) return @@ -638,7 +643,7 @@ func (a *API) dailyReports(w http.ResponseWriter, r *http.Request, p *auth.Princ 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) + report, err := st.CreateDailyDraft(r.Context(), p.UserID, "dr_"+randomToken(12), date) if err != nil { writeStoreError(w, r, err) return @@ -654,7 +659,7 @@ func (a *API) dailyReports(w http.ResponseWriter, r *http.Request, p *auth.Princ } } -func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { reportID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/reports/daily/") if !ok { writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "daily report route not found", nil) @@ -662,7 +667,7 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut } switch { case tail == "" && r.Method == http.MethodGet: - report, err := a.store.DailyReport(r.Context(), p.UserID, reportID) + report, err := st.DailyReport(r.Context(), p.UserID, reportID) if err != nil { writeStoreError(w, r, err) return @@ -682,7 +687,7 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut if !decodeBody(w, r, &body) { return } - report, err := a.store.SaveDailyDraft(r.Context(), p.UserID, reportID, body.Content, body.Attachments, body.Version) + report, err := st.SaveDailyDraft(r.Context(), p.UserID, reportID, body.Content, body.Attachments, body.Version) if err != nil { writeStoreError(w, r, err) return @@ -700,7 +705,7 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut if !decodeBody(w, r, &body) { return } - report, err := a.store.SubmitDaily(r.Context(), p.UserID, reportID, body.Version) + report, err := st.SubmitDaily(r.Context(), p.UserID, reportID, body.Version) if err != nil { writeStoreError(w, r, err) return @@ -711,7 +716,7 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut } } -func (a *API) dailyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) dailyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return @@ -719,7 +724,7 @@ func (a *API) dailyReportStatistics(w http.ResponseWriter, r *http.Request, p *a 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) { +func (a *API) dailyReportReminders(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodPost { methodNotAllowed(w, r) return @@ -743,10 +748,10 @@ func (a *API) dailyReportReminders(w http.ResponseWriter, r *http.Request, p *au writeSuccess(w, r, map[string]any{"status": "queued"}) } -func (a *API) monthlyReports(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) monthlyReports(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { switch r.Method { case http.MethodGet: - items, next, err := a.store.MonthlyReports(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) + items, next, err := st.MonthlyReports(r.Context(), p.UserID, r.URL.Query().Get("cursor"), limit(r, 50, 100)) if err != nil { writeStoreError(w, r, err) return @@ -759,7 +764,7 @@ func (a *API) monthlyReports(w http.ResponseWriter, r *http.Request, p *auth.Pri if !decodeBody(w, r, &body) { return } - report, err := a.store.CreateMonthlyDraft(r.Context(), p.UserID, "mr_"+randomToken(12), strings.TrimSpace(body.Month)) + report, err := st.CreateMonthlyDraft(r.Context(), p.UserID, "mr_"+randomToken(12), strings.TrimSpace(body.Month)) if err != nil { writeStoreError(w, r, err) return @@ -775,7 +780,7 @@ func (a *API) monthlyReports(w http.ResponseWriter, r *http.Request, p *auth.Pri } } -func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { reportID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/reports/monthly/") if !ok { writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "monthly report route not found", nil) @@ -783,7 +788,7 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a } switch { case tail == "" && r.Method == http.MethodGet: - report, err := a.store.MonthlyReport(r.Context(), p.UserID, reportID) + report, err := st.MonthlyReport(r.Context(), p.UserID, reportID) if err != nil { writeStoreError(w, r, err) return @@ -804,7 +809,7 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a if !decodeBody(w, r, &body) { return } - report, err := a.store.SaveMonthlyDraft(r.Context(), p.UserID, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version) + report, err := st.SaveMonthlyDraft(r.Context(), p.UserID, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version) if err != nil { writeStoreError(w, r, err) return @@ -822,7 +827,7 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a if !decodeBody(w, r, &body) { return } - report, err := a.store.SubmitMonthly(r.Context(), p.UserID, reportID, body.Version) + report, err := st.SubmitMonthly(r.Context(), p.UserID, reportID, body.Version) if err != nil { writeStoreError(w, r, err) return @@ -840,7 +845,7 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a if !decodeBody(w, r, &body) { return } - report, err := a.store.ReopenMonthly(r.Context(), p.UserID, reportID, body.Version) + report, err := st.ReopenMonthly(r.Context(), p.UserID, reportID, body.Version) if err != nil { writeStoreError(w, r, err) return @@ -851,7 +856,7 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a } } -func (a *API) monthlyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) monthlyReportStatistics(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return @@ -859,12 +864,12 @@ func (a *API) monthlyReportStatistics(w http.ResponseWriter, r *http.Request, p 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) { +func (a *API) mailboxConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodGet { methodNotAllowed(w, r) return } - cfg, err := a.store.MailboxConfig(r.Context()) + cfg, err := st.MailboxConfig(r.Context()) if err != nil { writeStoreError(w, r, err) return @@ -872,7 +877,7 @@ func (a *API) mailboxConfig(w http.ResponseWriter, r *http.Request, p *auth.Prin writeSuccess(w, r, mailboxConfigPayload(*cfg)) } -func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth.Principal) { +func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { if r.Method != http.MethodPost { methodNotAllowed(w, r) return @@ -897,7 +902,7 @@ func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth return } attachments, _ := json.Marshal(body.AttachmentFileIDs) - submission, err := a.store.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{ + submission, err := st.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{ SubmissionID: "mb_" + randomToken(12), Subject: subject, Content: content, Anonymous: body.Anonymous, AttachmentFileIDs: string(attachments), NoticeVersion: strings.TrimSpace(body.NoticeVersion), }, idempotencyKey) @@ -954,10 +959,10 @@ func meetingTargetUsers(meeting store.Meeting) []int64 { return targets } -func (a *API) populateUnread(r *http.Request, p *auth.Principal, modules []map[string]any) error { +func (a *API) populateUnread(st *store.Store, r *http.Request, p *auth.Principal, modules []map[string]any) error { for _, module := range modules { if module["code"] == "meetings" { - count, err := a.store.MeetingUnreadCount(r.Context(), p.UserID) + count, err := st.MeetingUnreadCount(r.Context(), p.UserID) if err != nil { return err } diff --git a/internal/store/mysql.go b/internal/store/mysql.go index f599754..4e2ffa0 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -7,19 +7,32 @@ import ( "errors" "fmt" "strings" + "sync" "time" "git.118im.com/im_team/im_workbench/internal/config" - _ "github.com/go-sql-driver/mysql" + mysqlDriver "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" ) type Store struct { - db *sqlx.DB + db *sqlx.DB + dsn string + tenantStores map[int64]*Store + tenantStoreMu sync.Mutex } func Open(cfg config.MySQLConfig) (*Store, error) { - db, err := sqlx.Open("mysql", cfg.DSN) + st, err := openDSN(cfg.DSN) + if err != nil { + return nil, err + } + st.tenantStores = make(map[int64]*Store) + return st, nil +} + +func openDSN(dsn string) (*Store, error) { + db, err := sqlx.Open("mysql", dsn) if err != nil { return nil, err } @@ -32,16 +45,120 @@ func Open(cfg config.MySQLConfig) (*Store, error) { _ = db.Close() return nil, err } - return &Store{db: db}, nil + return &Store{db: db, dsn: dsn}, nil } func (s *Store) Close() error { if s == nil || s.db == nil { return nil } + s.tenantStoreMu.Lock() + for id, st := range s.tenantStores { + if st != nil && st != s { + _ = st.Close() + } + delete(s.tenantStores, id) + } + s.tenantStoreMu.Unlock() return s.db.Close() } +type tenantDBConfig struct { + ID int64 `db:"id"` + Code string `db:"code"` + DBName string `db:"db_name"` + DBDSN string `db:"db_dsn"` + State int `db:"state"` +} + +// ForTenant returns a Store connected to the tenant database configured by manager/hotgo. +func (s *Store) ForTenant(ctx context.Context, tenantID int64, tenantCode string) (*Store, error) { + if s == nil { + return nil, errors.New("store is nil") + } + if tenantID <= 0 && strings.TrimSpace(tenantCode) == "" { + return s, nil + } + cfg, err := s.tenantDBConfig(ctx, tenantID, tenantCode) + if err != nil { + return nil, err + } + if cfg.State != 1 { + return nil, fmt.Errorf("tenant disabled: id=%d code=%s", cfg.ID, cfg.Code) + } + dsn, err := tenantDSN(s.dsn, cfg.DBName, cfg.DBDSN) + if err != nil { + return nil, err + } + if dsn == "" || dsn == s.dsn { + return s, nil + } + cacheID := cfg.ID + if cacheID <= 0 { + cacheID = tenantID + } + s.tenantStoreMu.Lock() + if st := s.tenantStores[cacheID]; st != nil { + s.tenantStoreMu.Unlock() + return st, nil + } + s.tenantStoreMu.Unlock() + + st, err := openDSN(dsn) + if err != nil { + return nil, err + } + s.tenantStoreMu.Lock() + if existing := s.tenantStores[cacheID]; existing != nil { + s.tenantStoreMu.Unlock() + _ = st.Close() + return existing, nil + } + s.tenantStores[cacheID] = st + s.tenantStoreMu.Unlock() + return st, nil +} + +func (s *Store) tenantDBConfig(ctx context.Context, tenantID int64, tenantCode string) (*tenantDBConfig, error) { + var cfg tenantDBConfig + if tenantID > 0 { + err := s.db.GetContext(ctx, &cfg, `SELECT id,code,IFNULL(db_name,'') AS db_name,IFNULL(db_dsn,'') AS db_dsn,state + FROM hotgo.im_tenant WHERE id=? LIMIT 1`, tenantID) + if err == nil { + return &cfg, nil + } + if !errors.Is(err, sql.ErrNoRows) || strings.TrimSpace(tenantCode) == "" { + return nil, err + } + } + err := s.db.GetContext(ctx, &cfg, `SELECT id,code,IFNULL(db_name,'') AS db_name,IFNULL(db_dsn,'') AS db_dsn,state + FROM hotgo.im_tenant WHERE code=? LIMIT 1`, strings.TrimSpace(tenantCode)) + if err != nil { + return nil, err + } + return &cfg, nil +} + +func tenantDSN(baseDSN, dbName, dbDSN string) (string, error) { + dbDSN = strings.TrimSpace(dbDSN) + if strings.HasPrefix(dbDSN, "mysql:") { + dbDSN = strings.TrimPrefix(dbDSN, "mysql:") + } + if dbDSN != "" { + return dbDSN, nil + } + dbName = strings.TrimSpace(dbName) + if dbName == "" { + return baseDSN, nil + } + cfg, err := mysqlDriver.ParseDSN(baseDSN) + if err != nil { + return "", err + } + cfg.DBName = dbName + return cfg.FormatDSN(), nil +} + func (s *Store) WorkbenchModules(ctx context.Context, userID int64) ([]WorkbenchModule, int, error) { var modules []WorkbenchModule err := s.db.SelectContext(ctx, &modules, `SELECT code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,0 AS unread_count,enabled,permission diff --git a/internal/store/mysql_test.go b/internal/store/mysql_test.go new file mode 100644 index 0000000..8190c55 --- /dev/null +++ b/internal/store/mysql_test.go @@ -0,0 +1,30 @@ +package store + +import ( + "strings" + "testing" +) + +func TestTenantDSNUsesConfiguredDSN(t *testing.T) { + got, err := tenantDSN("root:pass@tcp(127.0.0.1:3306)/tinode?parseTime=true", "", "mysql:user:secret@tcp(db:3306)/meeting_im?parseTime=true") + if err != nil { + t.Fatalf("tenantDSN returned error: %v", err) + } + want := "user:secret@tcp(db:3306)/meeting_im?parseTime=true" + if got != want { + t.Fatalf("tenantDSN = %q, want %q", got, want) + } +} + +func TestTenantDSNDerivesDBName(t *testing.T) { + got, err := tenantDSN("root:pass@tcp(127.0.0.1:3306)/tinode?charset=utf8mb4&parseTime=true&loc=Local", "meeting_im", "") + if err != nil { + t.Fatalf("tenantDSN returned error: %v", err) + } + if got == "" || got == "root:pass@tcp(127.0.0.1:3306)/tinode?charset=utf8mb4&parseTime=true&loc=Local" { + t.Fatalf("tenantDSN did not derive a tenant database DSN: %q", got) + } + if parsed := "meeting_im"; !strings.Contains(got, "/"+parsed+"?") { + t.Fatalf("tenantDSN = %q, want database %q", got, parsed) + } +}