fix: preserve meeting note file metadata
This commit is contained in:
+31
-5
@@ -557,12 +557,25 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
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) {
|
if !decodeBody(w, r, &body) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeStoreError(w, r, err)
|
writeStoreError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -583,13 +596,26 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p
|
|||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
var body struct {
|
var body struct {
|
||||||
FileID string `json:"file_id"`
|
FileID string `json:"file_id"`
|
||||||
Version int `json:"version"`
|
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) {
|
if !decodeBody(w, r, &body) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeStoreError(w, r, err)
|
writeStoreError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -315,13 +317,18 @@ func meetingNotesDetailPayload(meeting store.Meeting, files []store.MeetingNoteF
|
|||||||
}
|
}
|
||||||
|
|
||||||
func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string]any {
|
func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string]any {
|
||||||
|
extension := meetingNoteFileExtension(file.FileName, file.MIMEType)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"note_file_id": file.NoteFileID,
|
"note_file_id": file.NoteFileID,
|
||||||
"meeting_id": file.MeetingID,
|
"meeting_id": file.MeetingID,
|
||||||
"file_id": file.FileID,
|
"file_id": file.FileID,
|
||||||
|
"name": file.FileName,
|
||||||
"file_name": file.FileName,
|
"file_name": file.FileName,
|
||||||
|
"mime": file.MIMEType,
|
||||||
"mime_type": file.MIMEType,
|
"mime_type": file.MIMEType,
|
||||||
|
"size": file.FileSize,
|
||||||
"file_size": file.FileSize,
|
"file_size": file.FileSize,
|
||||||
|
"file_extension": extension,
|
||||||
"file_etag": file.FileETag,
|
"file_etag": file.FileETag,
|
||||||
"status": file.Status,
|
"status": file.Status,
|
||||||
"preview_status": file.PreviewStatus,
|
"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 {
|
func dailyReportPayloads(reports []store.DailyReport) []map[string]any {
|
||||||
items := make([]map[string]any, 0, len(reports))
|
items := make([]map[string]any, 0, len(reports))
|
||||||
for _, report := range reports {
|
for _, report := range reports {
|
||||||
|
|||||||
@@ -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) {
|
func assertNoNumericUserIDs(t *testing.T, value any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
|
|||||||
@@ -87,3 +87,12 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonNilInt64(values ...*int64) *int64 {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+49
-6
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -592,7 +593,7 @@ func (s *Store) MeetingNoteFiles(ctx context.Context, userID int64, meetingID st
|
|||||||
return files, nil
|
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)
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -601,8 +602,20 @@ func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meeting
|
|||||||
return nil, ErrForbidden
|
return nil, ErrForbidden
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
fileName, mimeType, fileSize, fileETag := fileID, "", int64(0), ""
|
fileID := strings.TrimSpace(input.FileID)
|
||||||
_ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&mimeType, &fileSize, &fileETag)
|
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)
|
_, 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)
|
VALUES(?,?,?,?,?,?,?,?,'active','processing','native','',1,?,?,?)`, noteFileID, meetingID, fileID, userID, fileName, mimeType, fileSize, fileETag, idempotencyKey, now, now)
|
||||||
if isDuplicate(err) && idempotencyKey != "" {
|
if isDuplicate(err) && idempotencyKey != "" {
|
||||||
@@ -624,7 +637,7 @@ func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meeting
|
|||||||
return &created, nil
|
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)
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -632,8 +645,20 @@ func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetin
|
|||||||
if meeting.RecorderID != userID && meeting.OrganizerID != userID {
|
if meeting.RecorderID != userID && meeting.OrganizerID != userID {
|
||||||
return nil, ErrForbidden
|
return nil, ErrForbidden
|
||||||
}
|
}
|
||||||
fileName, mimeType, fileSize, fileETag := fileID, "", int64(0), ""
|
fileID := strings.TrimSpace(input.FileID)
|
||||||
_ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&mimeType, &fileSize, &fileETag)
|
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=?
|
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=?`,
|
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)
|
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)
|
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) {
|
func (s *Store) DeleteMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, idempotencyKey string, version int) (*MeetingNoteFile, error) {
|
||||||
meeting, err := s.Meeting(ctx, userID, meetingID)
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ type MeetingNoteFile struct {
|
|||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MeetingNoteFileInput struct {
|
||||||
|
FileID string
|
||||||
|
FileName string
|
||||||
|
MIMEType string
|
||||||
|
FileSize *int64
|
||||||
|
}
|
||||||
|
|
||||||
type DailyReport struct {
|
type DailyReport struct {
|
||||||
ReportID string `db:"report_id"`
|
ReportID string `db:"report_id"`
|
||||||
AuthorID int64 `db:"author_id"`
|
AuthorID int64 `db:"author_id"`
|
||||||
|
|||||||
Reference in New Issue
Block a user