From 90185b6c88edb93e43be2a2383204b4fe04329e7 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Mon, 7 Sep 2026 11:41:01 +0800 Subject: [PATCH] Support meeting note upload registration --- docs/IM-前后端对接文档.md | 14 +++++ server/hdl_files.go | 53 ++++++++++++++++- server/hdl_files_test.go | 119 ++++++++++++++++++++++++++++++++++++++ server/im_http_test.go | 16 +++-- 4 files changed, 196 insertions(+), 6 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 751c507..7f910b9 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -2266,6 +2266,16 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。 - 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 +### 9.4.1 会议笔记 + +**状态:新增。** + +- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20` 返回可查看会议笔记的会议列表、`note_status/file_count/permissions`。 +- 详情:`GET /v1/im/meetings/{meeting_id}/notes` 返回会议信息和 `files[]`。 +- 上传并登记:调用通用文件上传 `POST /v0/file/u/`,multipart 包含 `file=@...`、`purpose=meeting_note`、`meeting_id=`、`id=<本次上传幂等ID>`,请求头按文件上传鉴权携带 `X-MeetingIM-APIKey`、`X-MeetingIM-Auth`。服务端上传成功后会立即登记会议笔记文件,响应 `ctrl.params` 包含 `url,file_id,purpose,meeting_id,note_file_id,note_file_status,version`。 +- 兼容绑定:若客户端先完成普通文件上传,也可以调用 `POST /v1/im/meetings/{meeting_id}/notes/files`,Body `{"file_id":"..."}`,并携带 `Idempotency-Key` 完成登记。 +- 替换/删除:`PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}`、`DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}?version=n`。只有记录人/发起人按权限可上传、替换和删除。 + ### 9.5 日报.png 日报 @@ -2386,6 +2396,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` | | `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` | | `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/meeting-notes?query=&cursor=` | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","note_status":"available","file_count":1,"permissions":{"can_upload":true,"can_download":true}}],"next_cursor":"","has_more":false}` | +| `GET /v1/im/meetings/{meeting_id}/notes` | Path meeting ID | `{"meeting_id":"mtg_1","note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","file_name":"会议纪要.pdf","mime_type":"application/pdf","permissions":{"can_download":true}}]}` | +| `POST /v0/file/u/` 会议笔记上传并登记 | Multipart `purpose=meeting_note&meeting_id=mtg_1&id=upload_1&file=@note.pdf` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file_1.pdf","file_id":"file_1","purpose":"meeting_note","meeting_id":"mtg_1","note_file_id":"mnote_1","note_file_status":"active","version":1}}}` | +| `POST /v1/im/meetings/{meeting_id}/notes/files` | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","status":"active","version":1}` | | `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` | | `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | | `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` | diff --git a/server/hdl_files.go b/server/hdl_files.go index 09f90d7..a86b9df 100644 --- a/server/hdl_files.go +++ b/server/hdl_files.go @@ -37,6 +37,8 @@ import ( // See https://www.iana.org/assignments/media-types/media-types.xhtml var allowedMimeTypes = []string{"application/", "audio/", "font/", "image/", "text/", "video/"} +const fileUploadPurposeMeetingNote = "meeting_note" + // RequestPrincipal is a tenant-scoped identity authenticated for HTTP/gRPC file access. type RequestPrincipal struct { TenantID types.TenantID @@ -249,6 +251,11 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) { writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil) return } + meetingID, meetingNoteIdempotencyKey, err := meetingNoteUploadParams(req, principal) + if err != nil { + writeHttpResponse(decodeStoreError(err, msgID, now, nil), err) + return + } // Check if uploads are handled elsewhere. headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true) @@ -354,16 +361,60 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) { logs.Warn.Printf("media upload: failed to enqueue file lifecycle event file=%s: %v", fdef.Id, err) } - params := map[string]string{"url": url} + params := map[string]any{"url": url, "file_id": fdef.Uid().String()} if globals.mediaGcPeriod > 0 { // How long this file is guaranteed to exist without being attached to a message or a topic. params["expires"] = now.Add(globals.mediaGcPeriod).Format(types.TimeFormatRFC3339) } + if meetingID != "" { + noteFile, err := createMeetingNoteFromUpload(principal, meetingID, meetingNoteIdempotencyKey, fdef) + if err != nil { + writeHttpResponse(decodeStoreError(err, msgID, now, nil), err) + return + } + params["purpose"] = fileUploadPurposeMeetingNote + params["meeting_id"] = noteFile.MeetingID + params["note_file_id"] = noteFile.NoteFileID + params["note_file_status"] = noteFile.Status + params["version"] = noteFile.Version + } writeHttpResponse(NoErrParams(msgID, "", now, params), nil) logs.Info.Println("media upload: ok", fdef.Id, fdef.Location) } +func meetingNoteUploadParams(req *http.Request, principal RequestPrincipal) (string, string, error) { + if strings.TrimSpace(req.FormValue("purpose")) != fileUploadPurposeMeetingNote { + return "", "", nil + } + if principal.UID.IsZero() { + return "", "", types.ErrPermissionDenied + } + if store.MeetingNotes == nil { + return "", "", types.ErrUnsupported + } + meetingID := strings.TrimSpace(req.FormValue("meeting_id")) + if meetingID == "" { + return "", "", types.ErrMalformed + } + idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) + if idempotencyKey == "" { + idempotencyKey = strings.TrimSpace(req.FormValue("id")) + } + if idempotencyKey == "" { + return "", "", types.ErrMalformed + } + return meetingID, idempotencyKey, nil +} + +func createMeetingNoteFromUpload(principal RequestPrincipal, meetingID, idempotencyKey string, fdef *types.FileDef) (*types.MeetingNoteFile, error) { + if fdef == nil || fdef.Uid().IsZero() { + return nil, types.ErrMalformed + } + noteFileID := "mnote_" + store.Store.GetUidString() + return store.MeetingNotes.CreateFile(principal.TenantID, principal.UID, meetingID, noteFileID, fdef.Uid().String(), idempotencyKey) +} + // LargeFileServe is the gRPC equivalent of largeFileServeHTTP. func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error { now := types.TimeNow() diff --git a/server/hdl_files_test.go b/server/hdl_files_test.go index e70c0bb..62ccc07 100644 --- a/server/hdl_files_test.go +++ b/server/hdl_files_test.go @@ -1,19 +1,51 @@ package main import ( + "bytes" "encoding/base64" + "encoding/json" + "io" + "mime/multipart" + "net/http" "net/http/httptest" + "net/url" "testing" "time" "github.com/golang/mock/gomock" "github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/auth/mock_auth" + "github.com/tinode/chat/server/media" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/mock_store" "github.com/tinode/chat/server/store/types" ) +type testLargeFileMediaHandler struct { + def *types.FileDef + data string +} + +func (h *testLargeFileMediaHandler) Init(string) error { return nil } +func (h *testLargeFileMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) { + return nil, 0, nil +} +func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (string, int64, error) { + data, _ := io.ReadAll(file) + h.def = def + h.data = string(data) + def.Location = "/v0/file/s/" + def.Uid().String() + ".txt" + def.ETag = "etag" + return def.Location, int64(len(data)), nil +} +func (h *testLargeFileMediaHandler) Download(types.TenantID, string) (*types.FileDef, media.ReadSeekCloser, error) { + return nil, nil, types.ErrUnsupported +} +func (h *testLargeFileMediaHandler) Delete([]string) error { return nil } +func (h *testLargeFileMediaHandler) GetIdFromUrl(string) types.Uid { + return types.ZeroUid +} + func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) { oldSessionStore := globals.sessionStore globals.sessionStore = NewSessionStore(time.Minute) @@ -137,3 +169,90 @@ func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) { largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25) } + +func TestLargeFileReceiveMeetingNoteUploadCreatesNoteFile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + oldStore := store.Store + oldFiles := store.Files + oldMeetingNotes := store.MeetingNotes + oldKafkaBus := kafkaBus + defer func() { + store.Store = oldStore + store.Files = oldFiles + store.MeetingNotes = oldMeetingNotes + kafkaBus = oldKafkaBus + }() + + handler := &testLargeFileMediaHandler{} + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + files := mock_store.NewMockFilePersistenceInterface(ctrl) + notes := &testMeetingNotesStore{} + store.Store = mockStore + store.Files = files + store.MeetingNotes = notes + kafkaBus = nil + + fileID := types.Uid(101) + mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes() + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) + mockStore.EXPECT().GetUidString().Return(fileID.String()) + mockStore.EXPECT().GetUidString().Return("note-1") + tokenAuth.EXPECT().Authenticate(auth.AuthContext{RemoteAddr: "192.0.2.1:12345"}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) + files.EXPECT().FinishUpload(types.TenantID(7), gomock.Any(), true, int64(11)).DoAndReturn( + func(_ types.TenantID, fd *types.FileDef, _ bool, size int64) (*types.FileDef, error) { + fd.Status = types.UploadCompleted + fd.Size = size + return fd, nil + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("id", "upload-meeting-note-1"); err != nil { + t.Fatal(err) + } + if err := writer.WriteField("purpose", fileUploadPurposeMeetingNote); err != nil { + t.Fatal(err) + } + if err := writer.WriteField("meeting_id", "mtg_test"); err != nil { + t.Fatal(err) + } + part, err := writer.CreateFormFile("file", "notes.txt") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte("hello notes")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/v0/file/u/", &body) + req.RemoteAddr = "192.0.2.1:12345" + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) + req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte("token-1"))) + rec := httptest.NewRecorder() + + largeFileReceiveHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp ServerComMessage + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v body=%s", err, rec.Body.String()) + } + params := resp.Ctrl.Params.(map[string]any) + if params["file_id"] != fileID.String() || params["purpose"] != fileUploadPurposeMeetingNote || + params["meeting_id"] != "mtg_test" || params["note_file_id"] != "mnote_note-1" { + t.Fatalf("unexpected upload params: %#v", params) + } + if handler.data != "hello notes" || notes.createdFileID != fileID.String() || notes.createdMeetingID != "mtg_test" || notes.createdIdempotencyKey != "upload-meeting-note-1" { + t.Fatalf("unexpected side effects: handler=%#v notes=%#v", handler, notes) + } +} diff --git a/server/im_http_test.go b/server/im_http_test.go index 4f811ff..7e5620f 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -270,11 +270,14 @@ type testMeetingsStore struct { } type testMeetingNotesStore struct { - meetings []types.Meeting - files []types.MeetingNoteFile - query string - cursor string - limit int + meetings []types.Meeting + files []types.MeetingNoteFile + query string + cursor string + limit int + createdMeetingID string + createdFileID string + createdIdempotencyKey string } type testDailyReportsStore struct { @@ -679,6 +682,9 @@ func (s *testMeetingNotesStore) ListFiles(_ types.TenantID, _ types.Uid, _ strin func (s *testMeetingNotesStore) CreateFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error) { now := time.Now().UTC().Round(time.Millisecond) + s.createdMeetingID = meetingID + s.createdFileID = fileID + s.createdIdempotencyKey = idempotencyKey return &types.MeetingNoteFile{ NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid, FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native",