package store import ( "context" "database/sql" "encoding/base32" "encoding/base64" "encoding/binary" "encoding/json" "errors" "fmt" "mime" "net/url" "path/filepath" "strings" "sync" "time" "git.118im.com/im_team/im_workbench/internal/config" "github.com/jmoiron/sqlx" "golang.org/x/crypto/xtea" ) type Store struct { db *sqlx.DB dsn string core *CoreStore tenantID int64 tenantStores map[int64]*Store tenantStoreMu sync.Mutex } const defaultTenantID int64 = 1 var ( uidCipherMu sync.RWMutex uidCipher *xtea.Cipher ) func init() { if err := SetUIDKey("dkXxWLwqUNRfYmdXRzOkZw=="); err != nil { panic(err) } } func SetUIDKey(encoded string) error { key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) if err != nil { return fmt.Errorf("invalid uid key: %w", err) } cipher, err := xtea.NewCipher(key) if err != nil { return fmt.Errorf("invalid uid key: %w", err) } uidCipherMu.Lock() uidCipher = cipher uidCipherMu.Unlock() return nil } func Open(cfg, coreCfg config.MySQLConfig) (*Store, error) { st, err := openDSN(cfg) if err != nil { return nil, err } if coreStoreOwnsDB(cfg.DSN, coreCfg.DSN) { core, err := openCoreDSN(coreCfg) if err != nil { _ = st.Close() return nil, err } st.core = core } else { st.core = &CoreStore{db: st.db} } st.tenantStores = make(map[int64]*Store) return st, nil } func openDSN(cfg config.MySQLConfig) (*Store, error) { db, err := openMySQLDB(cfg) if err != nil { return nil, err } return &Store{db: db, dsn: cfg.DSN}, nil } func openMySQLDB(cfg config.MySQLConfig) (*sqlx.DB, error) { db, err := sqlx.Open("mysql", cfg.DSN) if err != nil { return nil, err } db.SetMaxOpenConns(cfg.MaxOpenConns) db.SetMaxIdleConns(cfg.MaxIdleConns) db.SetConnMaxLifetime(30 * time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, err } return db, nil } func (s *Store) tenant() int64 { if s != nil && s.tenantID > 0 { return s.tenantID } return defaultTenantID } 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.db != s.db { _ = st.Close() } delete(s.tenantStores, id) } s.tenantStoreMu.Unlock() if s.core != nil && s.core.db != s.db { _ = s.core.Close() } return s.db.Close() } // ForTenant returns a tenant-scoped Store over the configured shared database. func (s *Store) ForTenant(ctx context.Context, tenantID int64, tenantCode string) (*Store, error) { if s == nil { return nil, errors.New("store is nil") } _ = ctx _ = tenantCode cacheID := tenantID if cacheID <= 0 { cacheID = defaultTenantID } if cacheID == s.tenant() { return s, nil } s.tenantStoreMu.Lock() if st := s.tenantStores[cacheID]; st != nil { s.tenantStoreMu.Unlock() return st, nil } st := &Store{db: s.db, dsn: s.dsn, core: s.core, tenantID: cacheID} s.tenantStores[cacheID] = st s.tenantStoreMu.Unlock() return st, 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 FROM im_workbench_apps WHERE tenant_id=? AND enabled=1 ORDER BY sort_order ASC,id ASC`, s.tenant()) if err != nil { if isMissingTable(err) { return []WorkbenchModule{}, 1, nil } return nil, 0, err } for i := range modules { modules[i].UnreadCount = 0 if modules[i].Permission == "" { modules[i].Permission = "enabled" } } return modules, 1, nil } func (s *Store) MeetingUnreadCount(ctx context.Context, userID int64) (int, error) { if s.core == nil || s.core.db == nil { return 0, nil } var count int query := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE tenant_id=? AND user_id=? AND read_at IS NULL AND event_type LIKE 'meeting.%%'`, tenantSystemNotificationsTable(s.tenant())) err := s.core.db.GetContext(ctx, &count, query, s.tenant(), userID) if isMissingTable(err) { return 0, nil } return count, err } func tenantSystemNotificationsTable(tenantID int64) string { return fmt.Sprintf("im_%03d_system_notifications", tenantID) } func (s *Store) Departments(ctx context.Context, userID int64, parentID, cursor string, limit int) ([]OrganizationDepartment, string, error) { if parentID == "" { parentID = "root" } var items []OrganizationDepartment query := `SELECT d.department_id,d.name,d.parent_id, (SELECT COUNT(*) FROM im_org_departments c WHERE c.tenant_id=d.tenant_id AND c.parent_id=d.department_id) AS child_count, (SELECT COUNT(*) FROM im_org_members m WHERE m.tenant_id=d.tenant_id AND m.department_id=d.department_id AND m.employment_state='active') AS member_count, d.version FROM im_org_departments d WHERE d.tenant_id=? AND d.parent_id=?` args := []any{s.tenant(), parentID} if cursor != "" { query += ` AND d.department_id>?` args = append(args, cursor) } query += ` ORDER BY d.department_id ASC LIMIT ?` args = append(args, limit+1) if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { return nil, "", err } return trimDepartmentPage(items, limit) } func (s *Store) DepartmentMembers(ctx context.Context, userID int64, departmentID, cursor string, limit int) ([]OrganizationMember, string, error) { var items []OrganizationMember query := `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email, IFNULL(avatar_file_id,'') AS avatar_file_id,employment_state AS status,contactable,version FROM im_org_members WHERE tenant_id=? AND department_id=? AND employment_state='active'` args := []any{s.tenant(), departmentID} if cursor != "" { query += ` AND user_id>?` args = append(args, cursor) } query += ` ORDER BY user_id ASC LIMIT ?` args = append(args, limit+1) if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { return nil, "", err } return trimMemberPage(items, limit) } func (s *Store) Members(ctx context.Context, userID int64, departmentID, keyword, cursor string, limit int) ([]OrganizationMember, string, error) { var items []OrganizationMember where := []string{"tenant_id=?", "employment_state='active'"} args := []any{s.tenant()} if departmentID != "" { where = append(where, "department_id=?") args = append(args, departmentID) } if keyword != "" { where = append(where, "(display_name LIKE ? OR position LIKE ? OR mobile LIKE ?)") like := "%" + keyword + "%" args = append(args, like, like, like) } if cursor != "" { where = append(where, "user_id>?") args = append(args, cursor) } query := `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email, IFNULL(avatar_file_id,'') AS avatar_file_id,employment_state AS status,contactable,version FROM im_org_members WHERE ` + strings.Join(where, " AND ") + ` ORDER BY user_id ASC LIMIT ?` args = append(args, limit+1) if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { return nil, "", err } return trimMemberPage(items, limit) } func (s *Store) AttendanceConfig(ctx context.Context, userID int64, date time.Time) (*AttendanceConfig, error) { var rule AttendanceRule if err := s.db.GetContext(ctx, &rule, `SELECT rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,NULL AS appeal_deadline FROM im_attendance_rules WHERE tenant_id=? AND state='active' ORDER BY id ASC LIMIT 1`, s.tenant()); err != nil { if errors.Is(err, sql.ErrNoRows) { return &AttendanceConfig{Date: date, Timezone: "Asia/Shanghai", Rule: AttendanceRule{}, Shifts: []AttendanceShift{}}, nil } return nil, err } var shifts []AttendanceShift if err := s.db.SelectContext(ctx, &shifts, `SELECT shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end FROM im_attendance_shifts WHERE tenant_id=? AND rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, s.tenant(), rule.RuleID); err != nil { return nil, err } record, _ := s.attendanceRecordByDate(ctx, userID, date) return &AttendanceConfig{Date: date, Timezone: "Asia/Shanghai", Rule: rule, Shifts: shifts, TodayRecord: record}, nil } func (s *Store) AttendanceRecords(ctx context.Context, userID int64, opts AttendanceListOptions) ([]AttendanceRecord, string, *AttendanceStats, error) { where := []string{"tenant_id=?"} args := []any{s.tenant()} if opts.UserID > 0 { where = append(where, "user_id=?") args = append(args, opts.UserID) } else if opts.Scope != "managed" { where = append(where, "user_id=?") args = append(args, userID) } if opts.Date != "" { where = append(where, "record_date=?") args = append(args, opts.Date) } if opts.From != "" { where = append(where, "record_date>=?") args = append(args, opts.From) } if opts.To != "" { where = append(where, "record_date<=?") args = append(args, opts.To) } if opts.Status != "" { where = append(where, "status=?") args = append(args, opts.Status) } if opts.Cursor != "" { where = append(where, "id opts.Limit { next = records[opts.Limit-1].RecordID records = records[:opts.Limit] } for i := range records { records[i].Checkins, _ = s.attendanceCheckins(ctx, records[i].RecordID) } stats := &AttendanceStats{ExpectedCount: len(records), CheckedCount: len(records)} return records, next, stats, nil } type AttendanceListOptions struct { Scope string From string To string Date string Status string Cursor string Limit int UserID int64 } func (s *Store) CreateAttendanceCheckin(ctx context.Context, userID int64, checkinID string, req AttendanceCheckinRequest) (*AttendanceRecord, *AttendanceCheckin, error) { now := time.Now().UTC().Round(time.Millisecond) recordID := fmt.Sprintf("att_%d_%s_%s", userID, req.Date.Format("20060102"), req.ShiftID) result := "normal" status := "checked_in" if req.Action == "check_out" { status = "normal" } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, nil, err } defer tx.Rollback() _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(tenant_id,record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,'[]','none',1,?,?) ON DUPLICATE KEY UPDATE status=VALUES(status),version=version+1,updated_at=VALUES(updated_at)`, s.tenant(), recordID, userID, req.Date, req.RuleID, req.ShiftID, status, now, now) if err != nil { return nil, nil, err } _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_checkins(tenant_id,checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, s.tenant(), checkinID, recordID, userID, req.Date, req.Action, req.RuleID, req.ShiftID, req.ClientTime, now, result, req.Location, req.Wifi, req.Device, req.IdempotencyKey) if isDuplicate(err) { _ = tx.Rollback() return s.attendanceRecordAndCheckinByIdempotency(ctx, userID, req.IdempotencyKey) } if err != nil { return nil, nil, err } if err := tx.Commit(); err != nil { return nil, nil, err } return s.attendanceRecordAndCheckinByIdempotency(ctx, userID, req.IdempotencyKey) } type AttendanceCheckinRequest struct { Date time.Time Action string RuleID string ShiftID string ClientTime time.Time Location string Wifi string Device string IdempotencyKey string } func (s *Store) AttendanceAppeals(ctx context.Context, userID int64, status, cursor string, limit int) ([]AttendanceAppeal, string, error) { where := []string{"tenant_id=?", "user_id=?"} args := []any{s.tenant(), userID} if status != "" { where = append(where, "status=?") args = append(args, status) } if cursor != "" { where = append(where, "id limit { next = items[limit-1].AppealID items = items[:limit] } return items, next, nil } func (s *Store) CreateAttendanceAppeal(ctx context.Context, userID int64, appeal AttendanceAppeal, idempotencyKey string) (*AttendanceAppeal, error) { now := time.Now().UTC().Round(time.Millisecond) if appeal.Status == "" { appeal.Status = "pending" } _, err := s.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(tenant_id,appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)`, s.tenant(), appeal.AppealID, appeal.RecordID, userID, appeal.AppealType, appeal.Reason, appeal.TargetAction, appeal.TargetTime, appeal.AttachmentFileIDs, appeal.Status, idempotencyKey, now, now, now) if isDuplicate(err) { var existing AttendanceAppeal if getErr := s.db.GetContext(ctx, &existing, `SELECT appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,submitted_at,created_at,updated_at FROM im_attendance_appeals WHERE tenant_id=? AND user_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), userID, idempotencyKey); getErr != nil { return nil, getErr } return &existing, nil } if err != nil { return nil, err } return &appeal, nil } func (s *Store) Meetings(ctx context.Context, userID int64, cursor string, limit int) ([]Meeting, string, error) { var items []Meeting query := `SELECT meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,canceled_at,created_at,updated_at FROM im_meetings WHERE tenant_id=? AND (organizer_id=? OR EXISTS(SELECT 1 FROM im_meeting_participants p WHERE p.tenant_id=im_meetings.tenant_id AND p.meeting_id=im_meetings.meeting_id AND p.user_id=?))` args := []any{s.tenant(), userID, userID} if cursor != "" { query += ` AND meeting_id>?` args = append(args, cursor) } query += ` ORDER BY start_at DESC,id DESC LIMIT ?` args = append(args, limit+1) if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { return nil, "", err } next := "" if len(items) > limit { next = items[limit-1].MeetingID items = items[:limit] } for i := range items { items[i].Participants, _ = s.meetingParticipants(ctx, items[i].MeetingID) _ = s.hydrateMeetingUsers(ctx, &items[i]) } return items, next, nil } func (s *Store) Meeting(ctx context.Context, userID int64, meetingID string) (*Meeting, error) { var meeting Meeting if err := s.db.GetContext(ctx, &meeting, `SELECT meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,canceled_at,created_at,updated_at FROM im_meetings WHERE tenant_id=? AND meeting_id=? LIMIT 1`, s.tenant(), meetingID); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if isMissingTable(err) { return nil, ErrNotFound } return nil, err } meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID) _ = s.hydrateMeetingUsers(ctx, &meeting) if !meetingVisible(meeting, userID) { return nil, ErrForbidden } return &meeting, nil } func (s *Store) CreateMeeting(ctx context.Context, userID int64, meeting Meeting, attendees []int64) (*Meeting, error) { now := time.Now().UTC().Round(time.Millisecond) if meeting.RecorderID == 0 { meeting.RecorderID = userID } if meeting.State == "" { meeting.State = "scheduled" } reminders := meeting.ReminderMinutes if reminders == "" { reminders = "[]" } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() _, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(tenant_id,meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, s.tenant(), meeting.MeetingID, userID, meeting.RecorderID, meeting.Title, meeting.StartAt, meeting.EndAt, meeting.Location, meeting.State, reminders, meeting.IdempotencyKey, now, now) if isDuplicate(err) && meeting.IdempotencyKey != "" { _ = tx.Rollback() var existingID string if getErr := s.db.GetContext(ctx, &existingID, `SELECT meeting_id FROM im_meetings WHERE tenant_id=? AND organizer_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), userID, meeting.IdempotencyKey); getErr != nil { return nil, getErr } return s.Meeting(ctx, userID, existingID) } if err != nil { return nil, err } seen := map[int64]bool{userID: true} attendees = append(attendees, userID, meeting.RecorderID) for _, uid := range attendees { if uid <= 0 || seen[uid] { continue } seen[uid] = true _, err = tx.ExecContext(ctx, `INSERT INTO im_meeting_participants(tenant_id,meeting_id,user_id,response) VALUES(?,?,?,?)`, s.tenant(), meeting.MeetingID, uid, "pending") if err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return s.Meeting(ctx, userID, meeting.MeetingID) } func (s *Store) UpdateMeeting(ctx context.Context, userID int64, meetingID string, patch MeetingPatch) (*Meeting, error) { meeting, err := s.Meeting(ctx, userID, meetingID) if err != nil { return nil, err } if meeting.OrganizerID != userID || meeting.State != "scheduled" || patch.Version != meeting.Version { return nil, ErrConflict } sets := []string{"version=version+1", "updated_at=?"} args := []any{time.Now().UTC().Round(time.Millisecond)} if patch.Title != nil { sets = append(sets, "title=?") args = append(args, strings.TrimSpace(*patch.Title)) } if patch.RecorderID != nil { sets = append(sets, "recorder_user_id=?") args = append(args, *patch.RecorderID) } args = append(args, s.tenant(), meetingID) if _, err := s.db.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE tenant_id=? AND meeting_id=?`, args...); err != nil { return nil, err } return s.Meeting(ctx, userID, meetingID) } type MeetingPatch struct { Title *string RecorderID *int64 Version int } func (s *Store) CancelMeeting(ctx context.Context, userID int64, meetingID string) (*Meeting, error) { now := time.Now().UTC().Round(time.Millisecond) if _, err := s.db.ExecContext(ctx, `UPDATE im_meetings SET state='canceled',canceled_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND meeting_id=? AND organizer_id=? AND state='scheduled'`, now, now, s.tenant(), meetingID, userID); err != nil { return nil, err } return s.Meeting(ctx, userID, meetingID) } func (s *Store) RespondMeeting(ctx context.Context, userID int64, meetingID, response string, version int) (*Meeting, error) { now := time.Now().UTC().Round(time.Millisecond) if _, err := s.db.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=? WHERE tenant_id=? AND meeting_id=? AND user_id=?`, response, now, s.tenant(), meetingID, userID); err != nil { return nil, err } return s.Meeting(ctx, userID, meetingID) } func (s *Store) MeetingNotes(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]Meeting, string, error) { items, next, err := s.Meetings(ctx, userID, cursor, limit) if err != nil { return nil, "", err } filtered := items[:0] now := time.Now() for _, item := range items { if item.EndAt.Before(now) && item.State != "canceled" && (queryText == "" || strings.Contains(item.Title, queryText)) { filtered = append(filtered, item) } } return filtered, next, nil } func (s *Store) MeetingNoteFiles(ctx context.Context, userID int64, meetingID string) ([]MeetingNoteFile, error) { if _, err := s.Meeting(ctx, userID, meetingID); err != nil { return nil, err } var files []MeetingNoteFile if err := s.db.SelectContext(ctx, &files, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at FROM im_meeting_note_files WHERE tenant_id=? AND meeting_id=? AND status='active' ORDER BY id DESC`, s.tenant(), meetingID); err != nil { return nil, err } if err := s.hydrateMeetingNoteFilesUploaders(ctx, files); err != nil { return nil, err } return files, nil } func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID string, input MeetingNoteFileInput, idempotencyKey string) (*MeetingNoteFile, error) { meeting, err := s.Meeting(ctx, userID, meetingID) if err != nil { return nil, err } if meeting.RecorderID != userID && meeting.OrganizerID != userID { return nil, ErrForbidden } now := time.Now().UTC().Round(time.Millisecond) fileID := strings.TrimSpace(input.FileID) meta := s.fileUploadMetadata(ctx, fileID) fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID) _, err = s.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(tenant_id,note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,idempotency_key,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,'active','processing','native','',1,?,?,?)`, s.tenant(), noteFileID, meetingID, fileID, userID, fileName, mimeType, fileSize, fileETag, idempotencyKey, now, now) if isDuplicate(err) && idempotencyKey != "" { var existing MeetingNoteFile if getErr := s.db.GetContext(ctx, &existing, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at FROM im_meeting_note_files WHERE tenant_id=? AND meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), meetingID, userID, idempotencyKey); getErr != nil { return nil, getErr } if getErr := s.hydrateMeetingNoteFileUploader(ctx, &existing); getErr != nil { return nil, getErr } return &existing, nil } if err != nil { return nil, err } var created MeetingNoteFile if err := s.db.GetContext(ctx, &created, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at FROM im_meeting_note_files WHERE tenant_id=? AND note_file_id=? LIMIT 1`, s.tenant(), noteFileID); err != nil { return nil, err } if err := s.hydrateMeetingNoteFileUploader(ctx, &created); err != nil { return nil, err } return &created, nil } func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID string, input MeetingNoteFileInput, idempotencyKey string, version int) (*MeetingNoteFile, error) { meeting, err := s.Meeting(ctx, userID, meetingID) if err != nil { return nil, err } if meeting.RecorderID != userID && meeting.OrganizerID != userID { return nil, ErrForbidden } fileID := strings.TrimSpace(input.FileID) meta := s.fileUploadMetadata(ctx, fileID) fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID) res, err := s.db.ExecContext(ctx, `UPDATE im_meeting_note_files SET file_id=?,file_name=?,mime_type=?,file_size=?,file_etag=?,preview_status='processing',preview_file_id='',version=version+1,idempotency_key=?,updated_at=? WHERE tenant_id=? AND meeting_id=? AND note_file_id=? AND status='active' AND version=?`, fileID, fileName, mimeType, fileSize, fileETag, idempotencyKey, time.Now().UTC().Round(time.Millisecond), s.tenant(), meetingID, noteFileID, version) if err != nil { return nil, err } if rows, _ := res.RowsAffected(); rows == 0 { return nil, ErrConflict } return s.meetingNoteFile(ctx, meetingID, noteFileID) } type fileUploadMetadata struct { MIMEType string `db:"mimetype"` FileSize int64 `db:"size"` FileETag string `db:"etag"` Location string `db:"location"` OriginalName string `db:"original_name"` } func (s *Store) fileUploadMetadata(ctx context.Context, fileID string) fileUploadMetadata { if s.core == nil { return fileUploadMetadata{} } return s.core.fileUploadMetadata(ctx, s.tenant(), fileID) } func parseUploadFileID(fileID string) (int64, string) { name := uploadFileName(fileID) if dot := strings.LastIndexByte(name, '.'); dot > 0 { ext := name[dot+1:] if len(ext) == 0 || len(ext) > 16 { return 0, "" } for _, r := range ext { if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') { return 0, "" } } name = name[:dot] } decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(name) if err != nil || len(decoded) < 8 { if isStorageFileName(name) { return 0, name } return 0, "" } storageData := make([]byte, 8) binary.LittleEndian.PutUint64(storageData, binary.LittleEndian.Uint64(decoded[:8])) storageName := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(storageData)) plain := make([]byte, 8) uidCipherMu.RLock() cipher := uidCipher uidCipherMu.RUnlock() cipher.Decrypt(plain, decoded[:8]) return int64(binary.LittleEndian.Uint64(plain)), storageName } func uploadFileName(fileID string) string { name := strings.TrimSpace(fileID) if parsed, err := url.Parse(name); err == nil && parsed.Path != "" { name = parsed.Path } if q := strings.IndexAny(name, "?#"); q >= 0 { name = name[:q] } name = strings.TrimRight(strings.ReplaceAll(name, "\\", "/"), "/") if slash := strings.LastIndexByte(name, '/'); slash >= 0 { name = name[slash+1:] } return strings.TrimSpace(name) } func uploadLocationCandidates(fileID, storageName string) []string { seen := map[string]bool{} var candidates []string add := func(value string) { value = strings.Trim(strings.TrimSpace(value), "/") if value == "" || seen[value] { return } seen[value] = true candidates = append(candidates, value) } add(storageName) raw := strings.TrimSpace(fileID) if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" { add(parsed.Path) } add(raw) return candidates } func isStorageFileName(name string) bool { if len(name) != 13 { return false } for _, r := range name { if (r < 'a' || r > 'z') && (r < '2' || r > '7') { return false } } return true } func normalizeMeetingNoteFileMetadata(input MeetingNoteFileInput, meta fileUploadMetadata, fileID string) (string, string, int64, string) { mimeType := firstNonEmpty(input.MIMEType, meta.MIMEType) fileSize := int64(0) if input.FileSize != nil && *input.FileSize > 0 { fileSize = *input.FileSize } else if meta.FileSize > 0 { fileSize = meta.FileSize } fileETag := firstNonEmpty(input.FileETag, meta.FileETag) fileName := normalizeMeetingNoteFileName(firstNonEmpty(input.FileName, meta.OriginalName, fileNameFromLocation(meta.Location), fallbackFileName(fileID, mimeType)), fileID) return fileName, strings.TrimSpace(mimeType), fileSize, strings.TrimSpace(fileETag) } func normalizeMeetingNoteFileName(fileName, fileID string) string { fileName = strings.TrimSpace(strings.ReplaceAll(fileName, "\\", "/")) fileName = filepath.Base(fileName) if fileName == "." || fileName == "/" || fileName == "" { fileName = strings.TrimSpace(fileID) } if len([]rune(fileName)) <= 255 { return fileName } runes := []rune(fileName) ext := filepath.Ext(fileName) if ext == "" || len([]rune(ext)) >= 255 { return string(runes[:255]) } extRunes := []rune(ext) return string(runes[:255-len(extRunes)]) + ext } func fileNameFromLocation(location string) string { name := strings.TrimSpace(strings.ReplaceAll(location, "\\", "/")) name = filepath.Base(name) if name == "." || name == "/" { return "" } if filepath.Ext(name) == "" { return "" } return name } func fallbackFileName(fileID, mimeType string) string { fileID = strings.TrimSpace(fileID) if filepath.Ext(fileID) != "" || strings.TrimSpace(mimeType) == "" { return fileID } exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType)) if len(exts) == 0 { return fileID } return fileID + strings.ToLower(exts[0]) } func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func (s *Store) DeleteMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, idempotencyKey string, version int) (*MeetingNoteFile, error) { meeting, err := s.Meeting(ctx, userID, meetingID) if err != nil { return nil, err } if meeting.RecorderID != userID && meeting.OrganizerID != userID { return nil, ErrForbidden } res, err := s.db.ExecContext(ctx, `UPDATE im_meeting_note_files SET status='deleted',version=version+1,idempotency_key=?,updated_at=? WHERE tenant_id=? AND meeting_id=? AND note_file_id=? AND status='active' AND version=?`, idempotencyKey, time.Now().UTC().Round(time.Millisecond), s.tenant(), meetingID, noteFileID, version) if err != nil { return nil, err } if rows, _ := res.RowsAffected(); rows == 0 { return nil, ErrConflict } return s.meetingNoteFile(ctx, meetingID, noteFileID) } func (s *Store) DailyReports(ctx context.Context, userID int64, reportDate, cursor string, limit int) ([]DailyReport, string, error) { var items []DailyReport query := `SELECT report_id,author_id,report_date,content,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_daily_reports WHERE tenant_id=? AND author_id=?` args := []any{s.tenant(), userID} if reportDate != "" { query += ` AND report_date=?` args = append(args, reportDate) } if cursor != "" { query += ` AND report_id>?` args = append(args, cursor) } query += ` ORDER BY report_date DESC,id DESC LIMIT ?` args = append(args, limit+1) if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { return nil, "", err } next := "" if len(items) > limit { next = items[limit-1].ReportID items = items[:limit] } return items, next, nil } func (s *Store) CreateDailyDraft(ctx context.Context, userID int64, reportID string, reportDate time.Time) (*DailyReport, error) { now := time.Now().UTC().Round(time.Millisecond) _, err := s.db.ExecContext(ctx, `INSERT INTO im_daily_reports(tenant_id,report_id,author_id,report_date,content,attachments,status,read_state,version,created_at,updated_at) VALUES(?,?,?,?,'','[]','draft','unread',1,?,?)`, s.tenant(), reportID, userID, reportDate, now, now) if isDuplicate(err) { return nil, ErrConflict } if err != nil { return nil, err } return s.DailyReport(ctx, userID, reportID) } func (s *Store) DailyReport(ctx context.Context, userID int64, reportID string) (*DailyReport, error) { var report DailyReport if err := s.db.GetContext(ctx, &report, `SELECT report_id,author_id,report_date,content,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_daily_reports WHERE tenant_id=? AND report_id=? LIMIT 1`, s.tenant(), reportID); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } if report.AuthorID != userID { return nil, ErrForbidden } return &report, nil } func (s *Store) SaveDailyDraft(ctx context.Context, userID int64, reportID, content string, attachments []string, version int) (*DailyReport, error) { data, _ := json.Marshal(attachments) res, err := s.db.ExecContext(ctx, `UPDATE im_daily_reports SET content=?,attachments=?,version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`, content, string(data), time.Now().UTC().Round(time.Millisecond), s.tenant(), reportID, userID, version) if err != nil { return nil, err } if rows, _ := res.RowsAffected(); rows == 0 { return nil, ErrConflict } return s.DailyReport(ctx, userID, reportID) } func (s *Store) SubmitDaily(ctx context.Context, userID int64, reportID string, version int) (*DailyReport, error) { now := time.Now().UTC().Round(time.Millisecond) res, err := s.db.ExecContext(ctx, `UPDATE im_daily_reports SET status='submitted',submitted_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`, now, now, s.tenant(), reportID, userID, version) if err != nil { return nil, err } if rows, _ := res.RowsAffected(); rows == 0 { return nil, ErrConflict } return s.DailyReport(ctx, userID, reportID) } func (s *Store) DailyReportStats(ctx context.Context, date time.Time, departmentID string) (*ReportStats, error) { stats := &ReportStats{DepartmentID: strings.TrimSpace(departmentID)} memberWhere, memberArgs := reportMemberScope(stats.DepartmentID) memberWhere = append([]string{"tenant_id=?"}, memberWhere...) memberArgs = append([]any{s.tenant()}, memberArgs...) if err := s.db.GetContext(ctx, &stats.ExpectedCount, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE `+strings.Join(memberWhere, " AND "), memberArgs...); err != nil { if isMissingTable(err) { return stats, nil } return nil, err } args := append([]any{s.tenant(), date.Format("2006-01-02")}, memberArgs...) if err := s.db.GetContext(ctx, &stats.SubmittedCount, `SELECT COUNT(DISTINCT r.author_id) FROM im_daily_reports AS r INNER JOIN im_org_members AS m ON m.tenant_id=r.tenant_id AND m.user_id=r.author_id WHERE r.tenant_id=? AND r.report_date=? AND r.status='submitted' AND `+strings.Join(prefixReportMemberScope(memberWhere), " AND "), args...); err != nil { if isMissingTable(err) { return stats, nil } return nil, err } stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount if stats.MissingCount < 0 { stats.MissingCount = 0 } return stats, nil } func (s *Store) MonthlyReports(ctx context.Context, userID int64, fromMonth, toMonth, cursor string, limit int) ([]MonthlyReport, string, error) { var items []MonthlyReport query := `SELECT report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_monthly_reports WHERE tenant_id=? AND author_id=?` args := []any{s.tenant(), userID} if fromMonth != "" { query += ` AND report_month>=?` args = append(args, fromMonth) } if toMonth != "" { query += ` AND report_month<=?` args = append(args, toMonth) } if cursor != "" { query += ` AND report_id>?` args = append(args, cursor) } query += ` ORDER BY report_month DESC,id DESC LIMIT ?` args = append(args, limit+1) if err := s.db.SelectContext(ctx, &items, query, args...); err != nil { return nil, "", err } next := "" if len(items) > limit { next = items[limit-1].ReportID items = items[:limit] } return items, next, nil } func (s *Store) CreateMonthlyDraft(ctx context.Context, userID int64, reportID, month string) (*MonthlyReport, error) { now := time.Now().UTC().Round(time.Millisecond) _, err := s.db.ExecContext(ctx, `INSERT INTO im_monthly_reports(tenant_id,report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,created_at,updated_at) VALUES(?,?,?,?,'','','[]','draft','unread',1,?,?)`, s.tenant(), reportID, userID, month, now, now) if isDuplicate(err) { return nil, ErrConflict } if err != nil { return nil, err } return s.MonthlyReport(ctx, userID, reportID) } func (s *Store) MonthlyReport(ctx context.Context, userID int64, reportID string) (*MonthlyReport, error) { var report MonthlyReport if err := s.db.GetContext(ctx, &report, `SELECT report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_monthly_reports WHERE tenant_id=? AND report_id=? LIMIT 1`, s.tenant(), reportID); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } if report.AuthorID != userID { return nil, ErrForbidden } return &report, nil } func (s *Store) SaveMonthlyDraft(ctx context.Context, userID int64, reportID, summary, nextPlan string, attachments []string, version int) (*MonthlyReport, error) { data, _ := json.Marshal(attachments) res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET work_summary=?,next_plan=?,attachments=?,version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`, summary, nextPlan, string(data), time.Now().UTC().Round(time.Millisecond), s.tenant(), reportID, userID, version) if err != nil { return nil, err } if rows, _ := res.RowsAffected(); rows == 0 { return nil, ErrConflict } return s.MonthlyReport(ctx, userID, reportID) } func (s *Store) SubmitMonthly(ctx context.Context, userID int64, reportID string, version int) (*MonthlyReport, error) { now := time.Now().UTC().Round(time.Millisecond) res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET status='submitted',submitted_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`, now, now, s.tenant(), reportID, userID, version) if err != nil { return nil, err } if rows, _ := res.RowsAffected(); rows == 0 { return nil, ErrConflict } return s.MonthlyReport(ctx, userID, reportID) } func (s *Store) ReopenMonthly(ctx context.Context, userID int64, reportID string, version int) (*MonthlyReport, error) { res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET status='draft',version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`, time.Now().UTC().Round(time.Millisecond), s.tenant(), reportID, userID, version) if err != nil { return nil, err } if rows, _ := res.RowsAffected(); rows == 0 { return nil, ErrConflict } return s.MonthlyReport(ctx, userID, reportID) } func (s *Store) MonthlyReportStats(ctx context.Context, month, departmentID string) (*ReportStats, error) { stats := &ReportStats{DepartmentID: strings.TrimSpace(departmentID)} memberWhere, memberArgs := reportMemberScope(stats.DepartmentID) memberWhere = append([]string{"tenant_id=?"}, memberWhere...) memberArgs = append([]any{s.tenant()}, memberArgs...) if err := s.db.GetContext(ctx, &stats.ExpectedCount, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE `+strings.Join(memberWhere, " AND "), memberArgs...); err != nil { if isMissingTable(err) { return stats, nil } return nil, err } args := append([]any{s.tenant(), strings.TrimSpace(month)}, memberArgs...) if err := s.db.GetContext(ctx, &stats.SubmittedCount, `SELECT COUNT(DISTINCT r.author_id) FROM im_monthly_reports AS r INNER JOIN im_org_members AS m ON m.tenant_id=r.tenant_id AND m.user_id=r.author_id WHERE r.tenant_id=? AND r.report_month=? AND r.status='submitted' AND `+strings.Join(prefixReportMemberScope(memberWhere), " AND "), args...); err != nil { if isMissingTable(err) { return stats, nil } return nil, err } stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount if stats.MissingCount < 0 { stats.MissingCount = 0 } return stats, nil } func (s *Store) MailboxConfig(ctx context.Context) (*MailboxConfig, error) { var cfg MailboxConfig if err := s.db.GetContext(ctx, &cfg, `SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,CAST(COALESCE(UNIX_TIMESTAMP(notice_updated_at),1) AS UNSIGNED) AS version FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil { if errors.Is(err, sql.ErrNoRows) { return &MailboxConfig{Enabled: true, AllowAnonymous: true, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", Version: 1}, nil } return nil, err } return &cfg, nil } func (s *Store) CreateMailboxSubmission(ctx context.Context, userID int64, submission MailboxSubmission, idempotencyKey string) (*MailboxSubmission, error) { cfg, err := s.MailboxConfig(ctx) if err != nil { return nil, err } submission.Subject = strings.TrimSpace(submission.Subject) submission.Content = strings.TrimSpace(submission.Content) if !cfg.Enabled || (submission.Anonymous && !cfg.AllowAnonymous) { return nil, ErrPolicy } maxLength := cfg.MaxLength if maxLength <= 0 { maxLength = 5000 } if len([]rune(submission.Subject)) > 200 || len([]rune(submission.Content)) > maxLength { return nil, ErrPolicy } now := time.Now().UTC().Round(time.Millisecond) _, err = s.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(tenant_id,submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, s.tenant(), submission.SubmissionID, userID, submission.Subject, submission.Content, submission.Anonymous, submission.AttachmentFileIDs, submission.NoticeVersion, idempotencyKey, "received", now) if isDuplicate(err) && idempotencyKey != "" { var existing MailboxSubmission if getErr := s.db.GetContext(ctx, &existing, `SELECT submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,status,submitted_at FROM im_mailbox_submissions WHERE tenant_id=? AND sender_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), userID, idempotencyKey); getErr != nil { return nil, getErr } return &existing, nil } if err != nil { return nil, err } submission.SenderID = userID submission.Status = "received" submission.SubmittedAt = now return &submission, nil } func (s *Store) attendanceRecordByDate(ctx context.Context, userID int64, date time.Time) (*AttendanceRecord, error) { var record AttendanceRecord if err := s.db.GetContext(ctx, &record, `SELECT record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at FROM im_attendance_records WHERE tenant_id=? AND user_id=? AND record_date=? ORDER BY id DESC LIMIT 1`, s.tenant(), userID, date.Format("2006-01-02")); err != nil { return nil, err } record.Checkins, _ = s.attendanceCheckins(ctx, record.RecordID) return &record, nil } func (s *Store) attendanceCheckins(ctx context.Context, recordID string) ([]AttendanceCheckin, error) { var checkins []AttendanceCheckin err := s.db.SelectContext(ctx, &checkins, `SELECT checkin_id,action,client_time,server_time,result,location,wifi,device FROM im_attendance_checkins WHERE tenant_id=? AND record_id=? ORDER BY id ASC`, s.tenant(), recordID) return checkins, err } func (s *Store) attendanceRecordAndCheckinByIdempotency(ctx context.Context, userID int64, key string) (*AttendanceRecord, *AttendanceCheckin, error) { var checkin AttendanceCheckin var recordID string if err := s.db.QueryRowxContext(ctx, `SELECT checkin_id,record_id,action,client_time,server_time,result,location,wifi,device FROM im_attendance_checkins WHERE tenant_id=? AND user_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), userID, key). Scan(&checkin.CheckinID, &recordID, &checkin.Action, &checkin.ClientTime, &checkin.ServerTime, &checkin.Result, &checkin.Location, &checkin.Wifi, &checkin.Device); err != nil { return nil, nil, err } var record AttendanceRecord if err := s.db.GetContext(ctx, &record, `SELECT record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at FROM im_attendance_records WHERE tenant_id=? AND record_id=?`, s.tenant(), recordID); err != nil { return nil, nil, err } record.Checkins, _ = s.attendanceCheckins(ctx, recordID) return &record, &checkin, nil } func (s *Store) meetingParticipants(ctx context.Context, meetingID string) ([]MeetingParticipant, error) { var items []MeetingParticipant err := s.db.SelectContext(ctx, &items, `SELECT meeting_id,user_id,response,response_at FROM im_meeting_participants WHERE tenant_id=? AND meeting_id=? ORDER BY id ASC`, s.tenant(), meetingID) return items, err } func (s *Store) hydrateMeetingUsers(ctx context.Context, meeting *Meeting) error { if meeting == nil { return nil } seen := map[int64]bool{} userIDs := make([]int64, 0, len(meeting.Participants)+2) add := func(userID int64) { if userID > 0 && !seen[userID] { seen[userID] = true userIDs = append(userIDs, userID) } } add(meeting.OrganizerID) add(meeting.RecorderID) for _, participant := range meeting.Participants { add(participant.UserID) } summaries, err := s.UserSummaries(ctx, userIDs) if err != nil { return err } meeting.Organizer = summaryOrDefault(meeting.OrganizerID, summaries) meeting.Recorder = summaryOrDefault(meeting.RecorderID, summaries) for i := range meeting.Participants { meeting.Participants[i].User = summaryOrDefault(meeting.Participants[i].UserID, summaries) } return nil } func (s *Store) UserSummaries(ctx context.Context, userIDs []int64) (map[int64]UserSummary, error) { result := make(map[int64]UserSummary, len(userIDs)) ids := uniquePositiveInt64s(userIDs) if len(ids) == 0 { return result, nil } query, args, err := sqlx.In(`SELECT user_id,display_name,IFNULL(avatar_file_id,'') AS avatar_file_id FROM im_org_members WHERE tenant_id=? AND employment_state='active' AND user_id IN (?) ORDER BY user_id ASC,id ASC`, s.tenant(), ids) if err != nil { return nil, err } var orgRows []struct { UserID int64 `db:"user_id"` DisplayName string `db:"display_name"` AvatarFileID string `db:"avatar_file_id"` } if err := s.db.SelectContext(ctx, &orgRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) { return nil, err } for _, row := range orgRows { if _, ok := result[row.UserID]; ok { continue } result[row.UserID] = UserSummary{UserID: row.UserID, DisplayName: strings.TrimSpace(row.DisplayName), AvatarFileID: row.AvatarFileID} } if s.core != nil { if err := s.core.populateUserSummaries(ctx, s.tenant(), ids, result); err != nil { return nil, err } } for _, id := range ids { result[id] = summaryOrDefault(id, result) } return result, nil } func uniquePositiveInt64s(values []int64) []int64 { seen := map[int64]bool{} out := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 || seen[value] { continue } seen[value] = true out = append(out, value) } return out } func summaryOrDefault(userID int64, summaries map[int64]UserSummary) UserSummary { if userID <= 0 { return UserSummary{} } summary := summaries[userID] if summary.UserID == 0 { summary.UserID = userID } return summary } func (s *Store) meetingNoteFile(ctx context.Context, meetingID, noteFileID string) (*MeetingNoteFile, error) { var file MeetingNoteFile if err := s.db.GetContext(ctx, &file, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at FROM im_meeting_note_files WHERE tenant_id=? AND meeting_id=? AND note_file_id=? LIMIT 1`, s.tenant(), meetingID, noteFileID); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } if err := s.hydrateMeetingNoteFileUploader(ctx, &file); err != nil { return nil, err } return &file, nil } func (s *Store) hydrateMeetingNoteFileUploader(ctx context.Context, file *MeetingNoteFile) error { if file == nil { return nil } summaries, err := s.UserSummaries(ctx, []int64{file.UploaderID}) if err != nil { return err } file.Uploader = summaryOrDefault(file.UploaderID, summaries) return nil } func (s *Store) hydrateMeetingNoteFilesUploaders(ctx context.Context, files []MeetingNoteFile) error { if len(files) == 0 { return nil } userIDs := make([]int64, 0, len(files)) for _, file := range files { userIDs = append(userIDs, file.UploaderID) } summaries, err := s.UserSummaries(ctx, userIDs) if err != nil { return err } for i := range files { files[i].Uploader = summaryOrDefault(files[i].UploaderID, summaries) } return nil } func meetingVisible(meeting Meeting, userID int64) bool { if meeting.OrganizerID == userID || meeting.RecorderID == userID { return true } for _, participant := range meeting.Participants { if participant.UserID == userID { return true } } return false } func trimDepartmentPage(items []OrganizationDepartment, limit int) ([]OrganizationDepartment, string, error) { next := "" if len(items) > limit { next = items[limit-1].DepartmentID items = items[:limit] } return items, next, nil } func trimMemberPage(items []OrganizationMember, limit int) ([]OrganizationMember, string, error) { next := "" if len(items) > limit { next = fmt.Sprintf("%d", items[limit-1].UserID) items = items[:limit] } return items, next, nil } func reportMemberScope(departmentID string) ([]string, []any) { where := []string{"employment_state='active'"} args := []any{} if departmentID = strings.TrimSpace(departmentID); departmentID != "" { where = append(where, "department_id=?") args = append(args, departmentID) } return where, args } func prefixReportMemberScope(where []string) []string { out := make([]string, 0, len(where)) for _, condition := range where { condition = strings.TrimSpace(condition) switch { case strings.HasPrefix(condition, "tenant_id"): out = append(out, "m."+condition) case strings.HasPrefix(condition, "employment_state"): out = append(out, "m."+condition) case strings.HasPrefix(condition, "department_id"): out = append(out, "m."+condition) default: out = append(out, condition) } } return out } func isDuplicate(err error) bool { return err != nil && strings.Contains(err.Error(), "Duplicate entry") } func isMissingTable(err error) bool { return err != nil && strings.Contains(err.Error(), "doesn't exist") }