From 228e3a54fdad0c12b198d8471c7106cfe144abe1 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 17 Sep 2026 13:38:36 +0800 Subject: [PATCH] fix: align meeting note payloads --- internal/httpapi/payload.go | 90 ++++++++++----- internal/httpapi/payload_userid_test.go | 44 +++++++ internal/store/mysql.go | 145 ++++++++++++++++++++++++ internal/store/types.go | 9 ++ 4 files changed, 262 insertions(+), 26 deletions(-) diff --git a/internal/httpapi/payload.go b/internal/httpapi/payload.go index d64488f..6e74e7a 100644 --- a/internal/httpapi/payload.go +++ b/internal/httpapi/payload.go @@ -226,27 +226,32 @@ func meetingPayload(meeting store.Meeting, viewer int64) map[string]any { if recorderID == 0 { recorderID = meeting.OrganizerID } + organizer := userSummary(meeting.OrganizerID, meeting.Organizer) + recorder := userSummary(recorderID, meeting.Recorder) + state := meetingState(meeting) data := map[string]any{ - "meeting_id": meeting.MeetingID, - "title": meeting.Title, - "organizer_id": formatUserID(meeting.OrganizerID), - "organizer": userSummary(meeting.OrganizerID), - "recorder_user_id": formatUserID(recorderID), - "recorder": userSummary(recorderID), - "start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano), - "end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano), - "meeting_time": timeRange(meeting.StartAt, meeting.EndAt), - "time_range": timeRange(meeting.StartAt, meeting.EndAt), - "location": meeting.Location, - "state": meeting.State, - "reminder_minutes": jsonList(meeting.ReminderMinutes), - "version": meeting.Version, - "participants": participants, - "participant_count": len(participants), - "participant_names": []string{}, - "permissions": meetingPermissions(meeting, viewer), - "created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano), + "meeting_id": meeting.MeetingID, + "title": meeting.Title, + "organizer_id": formatUserID(meeting.OrganizerID), + "organizer": organizer, + "organizer_display_name": organizer["display_name"], + "recorder_user_id": formatUserID(recorderID), + "recorder": recorder, + "recorder_display_name": recorder["display_name"], + "start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano), + "end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano), + "meeting_time": timeRange(meeting.StartAt, meeting.EndAt), + "time_range": timeRange(meeting.StartAt, meeting.EndAt), + "location": meeting.Location, + "state": state, + "reminder_minutes": jsonList(meeting.ReminderMinutes), + "version": meeting.Version, + "participants": participants, + "participant_count": len(participants), + "participant_names": meetingParticipantNames(participants), + "permissions": meetingPermissions(meeting, viewer), + "created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano), } if meeting.CanceledAt != nil { data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano) @@ -263,7 +268,11 @@ func meetingPayload(meeting store.Meeting, viewer int64) map[string]any { func meetingParticipantPayloads(participants []store.MeetingParticipant) []map[string]any { items := make([]map[string]any, 0, len(participants)) for _, participant := range participants { - item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response} + summary := userSummary(participant.UserID, participant.User) + item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response, "display_name": summary["display_name"]} + if avatarFileID, ok := summary["avatar_file_id"]; ok { + item["avatar_file_id"] = avatarFileID + } if participant.ResponseAt != nil { item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano) } @@ -319,7 +328,7 @@ func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string "preview_mode": file.PreviewMode, "preview_file_id": file.PreviewFileID, "uploader_user_id": formatUserID(file.UploaderID), - "uploader": userSummary(file.UploaderID), + "uploader": userSummary(file.UploaderID, store.UserSummary{}), "version": file.Version, "permissions": map[string]any{ "can_preview": file.Status == "active", @@ -420,7 +429,7 @@ func mailboxSubmissionPayload(submission store.MailboxSubmission) map[string]any func meetingPermissions(meeting store.Meeting, viewer int64) map[string]any { isOrganizer := meeting.OrganizerID == viewer - isScheduled := meeting.State == "scheduled" + isScheduled := meetingState(meeting) == "scheduled" return map[string]any{ "can_update": isOrganizer && isScheduled, "can_cancel": isOrganizer && isScheduled, @@ -434,12 +443,41 @@ func meetingNotePermissions(meeting store.Meeting, viewer int64) map[string]any if recorderID == 0 { recorderID = meeting.OrganizerID } - canWrite := recorderID == viewer || meeting.OrganizerID == viewer + readable := meetingState(meeting) == "ended" + canWrite := readable && (recorderID == viewer || meeting.OrganizerID == viewer) return map[string]any{"can_view": true, "can_upload": canWrite, "can_replace": canWrite, "can_delete": canWrite, "can_download": true} } -func userSummary(userID int64) map[string]any { - return map[string]any{"user_id": formatUserID(userID), "display_name": formatUserID(userID)} +func userSummary(userID int64, summary store.UserSummary) map[string]any { + displayName := strings.TrimSpace(summary.DisplayName) + if displayName == "" { + displayName = formatUserID(userID) + } + data := map[string]any{"user_id": formatUserID(userID), "display_name": displayName} + if strings.TrimSpace(summary.AvatarFileID) != "" { + data["avatar_file_id"] = strings.TrimSpace(summary.AvatarFileID) + } + return data +} + +func meetingState(meeting store.Meeting) string { + state := strings.TrimSpace(meeting.State) + if state == "scheduled" && !meeting.EndAt.IsZero() && !time.Now().UTC().Before(meeting.EndAt.UTC()) { + return "ended" + } + return state +} + +func meetingParticipantNames(participants []map[string]any) []string { + names := make([]string, 0, len(participants)) + for _, participant := range participants { + name, _ := participant["display_name"].(string) + name = strings.TrimSpace(name) + if name != "" { + names = append(names, name) + } + } + return names } func jsonList(raw string) []any { diff --git a/internal/httpapi/payload_userid_test.go b/internal/httpapi/payload_userid_test.go index 85d3258..83b3840 100644 --- a/internal/httpapi/payload_userid_test.go +++ b/internal/httpapi/payload_userid_test.go @@ -47,6 +47,50 @@ func TestPayloadUserIDsAreStrings(t *testing.T) { } } +func TestMeetingPayloadIncludesNamesAndDerivedEndedState(t *testing.T) { + now := time.Now().UTC() + userA, err := parseUserID("usrag7w9xJ6hto") + if err != nil { + t.Fatal(err) + } + userB, err := parseUserID("usr1RqTFgHSenM") + if err != nil { + t.Fatal(err) + } + + payload := meetingPayload(store.Meeting{ + MeetingID: "mtg_1", + Title: "周会", + OrganizerID: userA, + RecorderID: userA, + Organizer: store.UserSummary{UserID: userA, DisplayName: "张三"}, + Recorder: store.UserSummary{UserID: userA, DisplayName: "张三"}, + StartAt: now.Add(-2 * time.Hour), + EndAt: now.Add(-time.Hour), + State: "scheduled", + CreatedAt: now, + UpdatedAt: now, + Participants: []store.MeetingParticipant{ + {UserID: userB, Response: "pending", User: store.UserSummary{UserID: userB, DisplayName: "李四"}}, + }, + }, userA) + + if payload["state"] != "ended" { + t.Fatalf("state=%v, want ended", payload["state"]) + } + if payload["organizer_display_name"] != "张三" { + t.Fatalf("organizer_display_name=%v", payload["organizer_display_name"]) + } + names, ok := payload["participant_names"].([]string) + if !ok || len(names) != 1 || names[0] != "李四" { + t.Fatalf("participant_names=%#v", payload["participant_names"]) + } + participants, ok := payload["participants"].([]map[string]any) + if !ok || len(participants) != 1 || participants[0]["display_name"] != "李四" { + t.Fatalf("participants=%#v", payload["participants"]) + } +} + func assertNoNumericUserIDs(t *testing.T, value any) { t.Helper() switch typed := value.(type) { diff --git a/internal/store/mysql.go b/internal/store/mysql.go index 34e148c..f599754 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -331,6 +331,7 @@ func (s *Store) Meetings(ctx context.Context, userID int64, cursor string, limit } for i := range items { items[i].Participants, _ = s.meetingParticipants(ctx, items[i].MeetingID) + _ = s.hydrateMeetingUsers(ctx, &items[i]) } return items, next, nil } @@ -345,6 +346,7 @@ func (s *Store) Meeting(ctx context.Context, userID int64, meetingID string) (*M return nil, err } meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID) + _ = s.hydrateMeetingUsers(ctx, &meeting) if !meetingVisible(meeting, userID) { return nil, ErrForbidden } @@ -791,6 +793,149 @@ func (s *Store) meetingParticipants(ctx context.Context, meetingID string) ([]Me 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 employment_state='active' AND user_id IN (?) ORDER BY user_id ASC,id ASC`, 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} + } + + query, args, err = sqlx.In(`SELECT user_id,nickname AS display_name,IFNULL(avatar_file_id,'') AS avatar_file_id + FROM im_user_profiles WHERE user_id IN (?) ORDER BY user_id ASC`, ids) + if err != nil { + return nil, err + } + var profileRows []struct { + UserID int64 `db:"user_id"` + DisplayName string `db:"display_name"` + AvatarFileID string `db:"avatar_file_id"` + } + if err := s.db.SelectContext(ctx, &profileRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) { + return nil, err + } + for _, row := range profileRows { + summary := result[row.UserID] + if summary.UserID == 0 { + summary.UserID = row.UserID + } + if summary.DisplayName == "" { + summary.DisplayName = strings.TrimSpace(row.DisplayName) + } + if summary.AvatarFileID == "" { + summary.AvatarFileID = row.AvatarFileID + } + result[row.UserID] = summary + } + + query, args, err = sqlx.In(`SELECT id AS user_id, + COALESCE( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.display_name')),''), + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.fn')),''), + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.nickname')),''), + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.name')),''), + '' + ) AS display_name + FROM im_users WHERE id IN (?)`, ids) + if err != nil { + return nil, err + } + var userRows []struct { + UserID int64 `db:"user_id"` + DisplayName string `db:"display_name"` + } + if err := s.db.SelectContext(ctx, &userRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) { + return nil, err + } + for _, row := range userRows { + summary := result[row.UserID] + if summary.UserID == 0 { + summary.UserID = row.UserID + } + if summary.DisplayName == "" { + summary.DisplayName = strings.TrimSpace(row.DisplayName) + } + result[row.UserID] = summary + } + + 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 diff --git a/internal/store/types.go b/internal/store/types.go index 1a5ad38..2662560 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -35,6 +35,12 @@ type OrganizationMember struct { Version int `db:"version"` } +type UserSummary struct { + UserID int64 + DisplayName string + AvatarFileID string +} + type AttendanceRule struct { RuleID string `db:"rule_id"` Name string `db:"name"` @@ -128,6 +134,8 @@ type Meeting struct { CanceledAt *time.Time `db:"canceled_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` + Organizer UserSummary + Recorder UserSummary Participants []MeetingParticipant } @@ -136,6 +144,7 @@ type MeetingParticipant struct { UserID int64 `db:"user_id"` Response string `db:"response"` ResponseAt *time.Time `db:"response_at"` + User UserSummary } type MeetingNoteFile struct {