fix: route workbench requests to tenant database

This commit is contained in:
richjohnrambo
2026-09-17 18:32:26 +08:00
parent dd5986d57a
commit 9cfeb5f2c7
3 changed files with 224 additions and 72 deletions
+73 -68
View File
@@ -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"}) 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 { func (a *API) withAuth(next handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -86,22 +86,27 @@ func (a *API) withAuth(next handlerFunc) http.HandlerFunc {
} }
return 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return return
} }
modules, version, err := a.store.WorkbenchModules(r.Context(), p.UserID) modules, version, err := st.WorkbenchModules(r.Context(), p.UserID)
if err != nil { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
} }
items := workbenchModulePayloads(modules) 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) writeStoreError(w, r, err)
return 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return return
@@ -131,7 +136,7 @@ func (a *API) organizationDepartments(w http.ResponseWriter, r *http.Request, p
if parentID == "" { if parentID == "" {
parentID = "root" 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -139,7 +144,7 @@ func (a *API) organizationDepartments(w http.ResponseWriter, r *http.Request, p
writeSuccess(w, r, pagePayload(organizationDepartmentPayloads(items), next)) 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/") departmentID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/organization/departments/")
if !ok || tail != "members" { if !ok || tail != "members" {
writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "organization route not found", nil) 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) methodNotAllowed(w, r)
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -159,13 +164,13 @@ func (a *API) organizationDepartmentResource(w http.ResponseWriter, r *http.Requ
writeSuccess(w, r, payload) 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return return
} }
query := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")) 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -175,7 +180,7 @@ func (a *API) organizationMembers(w http.ResponseWriter, r *http.Request, p *aut
writeSuccess(w, r, payload) 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return 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"}) writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "q"})
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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}) 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return 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"}) writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
return return
} }
cfg, err := a.store.AttendanceConfig(r.Context(), p.UserID, date) cfg, err := st.AttendanceConfig(r.Context(), p.UserID, date)
if err != nil { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -225,12 +230,12 @@ func (a *API) attendanceConfig(w http.ResponseWriter, r *http.Request, p *auth.P
writeSuccess(w, r, attendanceConfigPayload(cfg)) 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return 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"), Scope: r.URL.Query().Get("scope"),
From: r.URL.Query().Get("from"), From: r.URL.Query().Get("from"),
To: r.URL.Query().Get("to"), 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) 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 { if r.Method != http.MethodPost {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return 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"}) writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "device"})
return 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, Date: date, Action: action, RuleID: ruleID, ShiftID: shiftID,
ClientTime: clientTime, Location: location, Wifi: wifi, Device: device, IdempotencyKey: idempotencyKey, 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)}) 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 { switch r.Method {
case http.MethodGet: 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -367,7 +372,7 @@ func (a *API) attendanceAppeals(w http.ResponseWriter, r *http.Request, p *auth.
targetTime = &parsed targetTime = &parsed
} }
attachments, _ := json.Marshal(body.AttachmentFileIDs) 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), 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), Reason: strings.TrimSpace(body.Reason), TargetAction: strings.TrimSpace(body.TargetAction), TargetTime: targetTime, AttachmentFileIDs: string(attachments),
}, idempotencyKey) }, 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 { switch r.Method {
case http.MethodGet: 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -423,7 +428,7 @@ func (a *API) meetings(w http.ResponseWriter, r *http.Request, p *auth.Principal
return return
} }
reminders, _ := json.Marshal(body.ReminderMinutes) 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, 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, Location: strings.TrimSpace(body.Location), ReminderMinutes: string(reminders), IdempotencyKey: idempotencyKey,
}, parseUserIDList(body.AttendeeUserIDs)) }, 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/") meetingID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/meetings/")
if !ok { if !ok {
writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "meeting route not found", nil) 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 { switch {
case tail == "" && r.Method == http.MethodGet: 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -466,7 +471,7 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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)) writeSuccess(w, r, meetingPayload(*meeting, p.UserID))
case tail == "cancel" && r.Method == http.MethodPost: 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -497,7 +502,7 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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)) writeSuccess(w, r, meetingPayload(*meeting, p.UserID))
case tail == "notes" && r.Method == http.MethodGet: 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: 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/"): 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: default:
methodNotAllowed(w, r) 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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)) writeSuccess(w, r, pagePayload(meetingNoteListPayloads(items, p.UserID), next))
} }
func (a *API) meetingNotesDetail(w http.ResponseWriter, r *http.Request, p *auth.Principal, meetingID string) { func (a *API) meetingNotesDetail(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) {
meeting, err := a.store.Meeting(r.Context(), p.UserID, meetingID) meeting, err := st.Meeting(r.Context(), p.UserID, meetingID)
if err != nil { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
} }
files, err := a.store.MeetingNoteFiles(r.Context(), p.UserID, meetingID) files, err := st.MeetingNoteFiles(r.Context(), p.UserID, meetingID)
if err != nil { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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)) 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) idempotencyKey, ok := requireIdempotencyKey(w, r)
if !ok { if !ok {
return return
@@ -557,7 +562,7 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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)) 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) idempotencyKey, ok := requireIdempotencyKey(w, r)
if !ok { if !ok {
return return
@@ -584,7 +589,7 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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"}) writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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 { switch r.Method {
case http.MethodGet: 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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"}) writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"})
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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/") reportID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/reports/daily/")
if !ok { if !ok {
writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "daily report route not found", nil) 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 { switch {
case tail == "" && r.Method == http.MethodGet: 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -682,7 +687,7 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -700,7 +705,7 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return 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")}}) 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 { if r.Method != http.MethodPost {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return 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"}) 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 { switch r.Method {
case http.MethodGet: 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -759,7 +764,7 @@ func (a *API) monthlyReports(w http.ResponseWriter, r *http.Request, p *auth.Pri
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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/") reportID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/reports/monthly/")
if !ok { if !ok {
writeError(w, r, http.StatusNotFound, 30004, reasonMalformedRequest, "monthly report route not found", nil) 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 { switch {
case tail == "" && r.Method == http.MethodGet: 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -804,7 +809,7 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -822,7 +827,7 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -840,7 +845,7 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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 { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return 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")}}) 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 { if r.Method != http.MethodGet {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return return
} }
cfg, err := a.store.MailboxConfig(r.Context()) cfg, err := st.MailboxConfig(r.Context())
if err != nil { if err != nil {
writeStoreError(w, r, err) writeStoreError(w, r, err)
return return
@@ -872,7 +877,7 @@ func (a *API) mailboxConfig(w http.ResponseWriter, r *http.Request, p *auth.Prin
writeSuccess(w, r, mailboxConfigPayload(*cfg)) 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 { if r.Method != http.MethodPost {
methodNotAllowed(w, r) methodNotAllowed(w, r)
return return
@@ -897,7 +902,7 @@ func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth
return return
} }
attachments, _ := json.Marshal(body.AttachmentFileIDs) 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, SubmissionID: "mb_" + randomToken(12), Subject: subject, Content: content,
Anonymous: body.Anonymous, AttachmentFileIDs: string(attachments), NoticeVersion: strings.TrimSpace(body.NoticeVersion), Anonymous: body.Anonymous, AttachmentFileIDs: string(attachments), NoticeVersion: strings.TrimSpace(body.NoticeVersion),
}, idempotencyKey) }, idempotencyKey)
@@ -954,10 +959,10 @@ func meetingTargetUsers(meeting store.Meeting) []int64 {
return targets 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 { for _, module := range modules {
if module["code"] == "meetings" { if module["code"] == "meetings" {
count, err := a.store.MeetingUnreadCount(r.Context(), p.UserID) count, err := st.MeetingUnreadCount(r.Context(), p.UserID)
if err != nil { if err != nil {
return err return err
} }
+121 -4
View File
@@ -7,19 +7,32 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/config" "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" "github.com/jmoiron/sqlx"
) )
type Store struct { 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -32,16 +45,120 @@ func Open(cfg config.MySQLConfig) (*Store, error) {
_ = db.Close() _ = db.Close()
return nil, err return nil, err
} }
return &Store{db: db}, nil return &Store{db: db, dsn: dsn}, nil
} }
func (s *Store) Close() error { func (s *Store) Close() error {
if s == nil || s.db == nil { if s == nil || s.db == nil {
return 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() 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) { func (s *Store) WorkbenchModules(ctx context.Context, userID int64) ([]WorkbenchModule, int, error) {
var modules []WorkbenchModule 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 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
+30
View File
@@ -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)
}
}