diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 071863c..8c42cf6 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -557,12 +557,25 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P return } var body struct { - FileID string `json:"file_id"` + FileID string `json:"file_id"` + FileName string `json:"file_name"` + Name string `json:"name"` + MIMEType string `json:"mime_type"` + MIME string `json:"mime"` + FileSize *int64 `json:"file_size"` + Size *int64 `json:"size"` } if !decodeBody(w, r, &body) { return } - file, err := st.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), strings.TrimSpace(body.FileID), idempotencyKey) + fileID := strings.TrimSpace(body.FileID) + if fileID == "" { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) + return + } + file, err := st.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), store.MeetingNoteFileInput{ + FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), + }, idempotencyKey) if err != nil { writeStoreError(w, r, err) return @@ -583,13 +596,26 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p switch r.Method { case http.MethodPut: var body struct { - FileID string `json:"file_id"` - Version int `json:"version"` + FileID string `json:"file_id"` + FileName string `json:"file_name"` + Name string `json:"name"` + MIMEType string `json:"mime_type"` + MIME string `json:"mime"` + FileSize *int64 `json:"file_size"` + Size *int64 `json:"size"` + Version int `json:"version"` } if !decodeBody(w, r, &body) { return } - file, err := st.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, strings.TrimSpace(body.FileID), idempotencyKey, body.Version) + fileID := strings.TrimSpace(body.FileID) + if fileID == "" { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) + return + } + file, err := st.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, store.MeetingNoteFileInput{ + FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), + }, idempotencyKey, body.Version) if err != nil { writeStoreError(w, r, err) return diff --git a/internal/httpapi/payload.go b/internal/httpapi/payload.go index 6e74e7a..3f2b06c 100644 --- a/internal/httpapi/payload.go +++ b/internal/httpapi/payload.go @@ -2,6 +2,8 @@ package httpapi import ( "encoding/json" + "mime" + "path/filepath" "strings" "time" @@ -315,13 +317,18 @@ func meetingNotesDetailPayload(meeting store.Meeting, files []store.MeetingNoteF } func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string]any { + extension := meetingNoteFileExtension(file.FileName, file.MIMEType) return map[string]any{ "note_file_id": file.NoteFileID, "meeting_id": file.MeetingID, "file_id": file.FileID, + "name": file.FileName, "file_name": file.FileName, + "mime": file.MIMEType, "mime_type": file.MIMEType, + "size": file.FileSize, "file_size": file.FileSize, + "file_extension": extension, "file_etag": file.FileETag, "status": file.Status, "preview_status": file.PreviewStatus, @@ -341,6 +348,17 @@ func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string } } +func meetingNoteFileExtension(fileName, mimeType string) string { + if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(fileName)), ".")); ext != "" { + return ext + } + exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType)) + if len(exts) == 0 { + return "" + } + return strings.ToLower(strings.TrimPrefix(exts[0], ".")) +} + func dailyReportPayloads(reports []store.DailyReport) []map[string]any { items := make([]map[string]any, 0, len(reports)) for _, report := range reports { diff --git a/internal/httpapi/payload_userid_test.go b/internal/httpapi/payload_userid_test.go index 83b3840..8ec942a 100644 --- a/internal/httpapi/payload_userid_test.go +++ b/internal/httpapi/payload_userid_test.go @@ -91,6 +91,30 @@ func TestMeetingPayloadIncludesNamesAndDerivedEndedState(t *testing.T) { } } +func TestMeetingNoteFilePayloadKeepsOriginalNameAndExtension(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + payload := meetingNoteFilePayload(store.MeetingNoteFile{ + NoteFileID: "mnote_1", + FileID: "file_1", + FileName: "会议纪要.pdf", + MIMEType: "application/pdf", + FileSize: 2048, + UploaderID: 1, + Status: "active", + CreatedAt: now, + UpdatedAt: now, + }, 1) + if payload["file_name"] != "会议纪要.pdf" || payload["name"] != "会议纪要.pdf" { + t.Fatalf("payload names = file_name:%v name:%v", payload["file_name"], payload["name"]) + } + if payload["file_extension"] != "pdf" { + t.Fatalf("file_extension = %v, want pdf", payload["file_extension"]) + } + if payload["mime"] != "application/pdf" || payload["size"] != int64(2048) { + t.Fatalf("payload aliases = mime:%v size:%v", payload["mime"], payload["size"]) + } +} + func assertNoNumericUserIDs(t *testing.T, value any) { t.Helper() switch typed := value.(type) { diff --git a/internal/httpapi/response.go b/internal/httpapi/response.go index c505105..a43a1f6 100644 --- a/internal/httpapi/response.go +++ b/internal/httpapi/response.go @@ -87,3 +87,12 @@ func firstNonEmpty(values ...string) string { } return "" } + +func firstNonNilInt64(values ...*int64) *int64 { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} diff --git a/internal/store/mysql.go b/internal/store/mysql.go index 4e2ffa0..ba0b9f0 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "path/filepath" "strings" "sync" "time" @@ -592,7 +593,7 @@ func (s *Store) MeetingNoteFiles(ctx context.Context, userID int64, meetingID st return files, nil } -func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, fileID, idempotencyKey string) (*MeetingNoteFile, error) { +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 @@ -601,8 +602,20 @@ func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meeting return nil, ErrForbidden } now := time.Now().UTC().Round(time.Millisecond) - fileName, mimeType, fileSize, fileETag := fileID, "", int64(0), "" - _ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&mimeType, &fileSize, &fileETag) + fileID := strings.TrimSpace(input.FileID) + fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileName(input.FileName, fileID), strings.TrimSpace(input.MIMEType), int64(0), "" + if input.FileSize != nil && *input.FileSize > 0 { + fileSize = *input.FileSize + } + var uploadMime string + var uploadSize int64 + _ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&uploadMime, &uploadSize, &fileETag) + if mimeType == "" { + mimeType = uploadMime + } + if fileSize <= 0 { + fileSize = uploadSize + } _, err = s.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(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,?,?,?)`, noteFileID, meetingID, fileID, userID, fileName, mimeType, fileSize, fileETag, idempotencyKey, now, now) if isDuplicate(err) && idempotencyKey != "" { @@ -624,7 +637,7 @@ func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meeting return &created, nil } -func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*MeetingNoteFile, error) { +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 @@ -632,8 +645,20 @@ func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetin if meeting.RecorderID != userID && meeting.OrganizerID != userID { return nil, ErrForbidden } - fileName, mimeType, fileSize, fileETag := fileID, "", int64(0), "" - _ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&mimeType, &fileSize, &fileETag) + fileID := strings.TrimSpace(input.FileID) + fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileName(input.FileName, fileID), strings.TrimSpace(input.MIMEType), int64(0), "" + if input.FileSize != nil && *input.FileSize > 0 { + fileSize = *input.FileSize + } + var uploadMime string + var uploadSize int64 + _ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&uploadMime, &uploadSize, &fileETag) + if mimeType == "" { + mimeType = uploadMime + } + if fileSize <= 0 { + fileSize = uploadSize + } 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 meeting_id=? AND note_file_id=? AND status='active' AND version=?`, fileID, fileName, mimeType, fileSize, fileETag, idempotencyKey, time.Now().UTC().Round(time.Millisecond), meetingID, noteFileID, version) @@ -646,6 +671,24 @@ func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetin return s.meetingNoteFile(ctx, meetingID, noteFileID) } +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 (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 { diff --git a/internal/store/types.go b/internal/store/types.go index 2662560..91f3103 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -165,6 +165,13 @@ type MeetingNoteFile struct { UpdatedAt time.Time `db:"updated_at"` } +type MeetingNoteFileInput struct { + FileID string + FileName string + MIMEType string + FileSize *int64 +} + type DailyReport struct { ReportID string `db:"report_id"` AuthorID int64 `db:"author_id"`