diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index be359ba..33d0870 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -2203,8 +2203,8 @@ X-Tinode-APIKey: <预置的普通 API Key> | `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | | `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` | | `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | -| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | -| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"本月完成客户拜访和合同整理...","next_plan_short":"下月推进回款和方案评审","status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"完成项目阶段验收...","next_plan_short":"继续推进上线准备","status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` | | `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` | | `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index a33fabd..12a8e84 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -2541,7 +2541,35 @@ func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error { jpriv := common.ToJSON(sub.Private) decoded_uid := store.DecodeUid(t.ParseUid(sub.User)) - _, err := tx.Exec( + + existing := struct { + ID int64 `db:"id"` + DeletedAt sql.NullTime `db:"deletedat"` + }{} + err := tx.Get(&existing, "SELECT id,deletedat FROM im_subscriptions WHERE topic=? AND userid=? ORDER BY deletedat IS NULL DESC,id ASC LIMIT 1", + sub.Topic, decoded_uid) + if err != nil && err != sql.ErrNoRows { + return err + } + if err == nil { + if existing.DeletedAt.Valid { + _, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+ + "delid=0,recvseqid=0,readseqid=0,private=? WHERE id=?", + sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, existing.ID) + } else { + _, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,modeWant=?,modeGiven=?,private=? WHERE id=?", + sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, existing.ID) + } + if err != nil { + return err + } + if isOwner { + _, err = tx.Exec("UPDATE im_topics SET owner=? WHERE name=?", decoded_uid, sub.Topic) + } + return err + } + + _, err = tx.Exec( "INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+ "VALUES(?,?,NULL,?,?,?,?,?)", sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv) @@ -2596,8 +2624,18 @@ func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error { topic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}} topic.ObjHeader.MergeTimes(&initiator.ObjHeader) topic.TouchedAt = initiator.GetTouchedAt() - err = a.topicCreate(tx, topic) - if err != nil { + var existingTopicID int64 + err = tx.Get(&existingTopicID, "SELECT id FROM im_topics WHERE name=? ORDER BY id ASC LIMIT 1", topic.Id) + if err != nil && err != sql.ErrNoRows { + return err + } + if err == sql.ErrNoRows { + err = a.topicCreate(tx, topic) + if err != nil { + return err + } + } else if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE id=?", + topic.UpdatedAt, topic.TouchedAt, t.StateOK, existingTopicID); err != nil { return err } @@ -3624,6 +3662,9 @@ func (a *adapter) MessageSave(msg *t.Message) error { "INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?)", msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, store.DecodeUid(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content)) + if isDupe(err) { + return t.ErrDuplicate + } if err == nil { id, _ := res.LastInsertId() // Replacing ID given by store by ID given by the DB. diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 000aba9..5d8c45f 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -682,7 +682,17 @@ func (a *adapter) TenantTopicCreateP2P(id t.TenantID, one, two *t.Subscription) topic := &t.Topic{TenantID: id, ObjHeader: t.ObjHeader{Id: one.Topic}} topic.ObjHeader.MergeTimes(&one.ObjHeader) topic.TouchedAt = one.GetTouchedAt() - if err = tenantTopicCreate(tx, topic); err != nil { + var existingTopicID int64 + err = tx.Get(&existingTopicID, "SELECT id FROM im_topics WHERE name=? ORDER BY id ASC LIMIT 1", topic.Id) + if err != nil && err != sql.ErrNoRows { + return err + } + if err == sql.ErrNoRows { + if err = tenantTopicCreate(tx, topic); err != nil { + return err + } + } else if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE id=?", + topic.UpdatedAt, topic.TouchedAt, t.StateOK, existingTopicID); err != nil { return err } return tx.Commit() @@ -1757,6 +1767,9 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error { res, err := a.db.ExecContext(ctx, "INSERT INTO im_messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?,?)", id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content)) + if isDupe(err) { + return t.ErrDuplicate + } if err == nil { lastID, _ := res.LastInsertId() msg.SetUid(t.Uid(lastID)) diff --git a/server/im_http.go b/server/im_http.go index 3ab214b..e7dca2c 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -68,6 +68,7 @@ const ( imQRCodeMaxExpiresInSeconds = 7 * 86400 imPublicUploadMaxBytes = 10 << 20 groupMaxAdminCount = 10 + monthlyReportPreviewMaxRunes = 120 ) type imHTTPResponse struct { @@ -7206,14 +7207,16 @@ func monthlyReportListPayload(report types.MonthlyReport) map[string]any { action = "edit" } data := map[string]any{ - "report_id": report.ReportID, - "author_id": report.AuthorID.UserId(), - "month": report.Month, - "status": report.Status, - "read_state": report.ReadState, - "version": report.Version, - "action": action, - "updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), + "report_id": report.ReportID, + "author_id": report.AuthorID.UserId(), + "month": report.Month, + "work_summary_short": monthlyReportPreview(report.WorkSummary), + "next_plan_short": monthlyReportPreview(report.NextPlan), + "status": report.Status, + "read_state": report.ReadState, + "version": report.Version, + "action": action, + "updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), } if report.SubmittedAt != nil { data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) @@ -7221,6 +7224,15 @@ func monthlyReportListPayload(report types.MonthlyReport) map[string]any { return data } +func monthlyReportPreview(text string) string { + text = strings.Join(strings.Fields(text), " ") + runes := []rune(text) + if len(runes) <= monthlyReportPreviewMaxRunes { + return text + } + return string(runes[:monthlyReportPreviewMaxRunes]) + "..." +} + func monthlyReportDetailPayload(report *types.MonthlyReport, viewer types.Uid) map[string]any { data := monthlyReportListPayload(*report) data["work_summary"] = report.WorkSummary diff --git a/server/im_http_test.go b/server/im_http_test.go index a1e0a9e..ae7671a 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -2442,6 +2442,46 @@ func TestServeIMMonthlyReportsCreate(t *testing.T) { } } +func TestServeIMMonthlyReportsListIncludesPreviews(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMonthlyReports := store.MonthlyReports + now := time.Now().UTC().Round(time.Millisecond) + reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ + ReportID: "mr_test", + AuthorID: 42, + Month: "2026-08", + WorkSummary: strings.Repeat("工作", 80), + NextPlan: " 下月计划一 \n 下月计划二 ", + Status: types.DailyReportStatusDraft, + ReadState: "unread", + Version: 1, + CreatedAt: now, + UpdatedAt: now, + }} + store.MonthlyReports = reports + defer func() { store.MonthlyReports = oldMonthlyReports }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/monthly?scope=mine", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMonthlyReports(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + item := items[0].(map[string]any) + if item["work_summary_short"] != strings.Repeat("工作", 60)+"..." { + t.Fatalf("unexpected work summary preview: %#v", item["work_summary_short"]) + } + if item["next_plan_short"] != "下月计划一 下月计划二" { + t.Fatalf("unexpected next plan preview: %#v", item["next_plan_short"]) + } +} + func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/store/store.go b/server/store/store.go index 29b1552..feadf32 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -3136,6 +3136,9 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme err = tenantAdp.TenantGroupMessageSave(tenantID, msg) } else { err = tenantAdp.TenantMessageSave(tenantID, msg) + if err == types.ErrDuplicate && clientMsgID == "" { + err = retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, msg) + } } if err != nil { if isGroupMessage && err == types.ErrDuplicate && clientMsgID != "" { @@ -3217,6 +3220,21 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: markedReadBySender}, nil } +func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, msg *types.Message) error { + latest, err := tenantAdp.TenantMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1}) + if err != nil { + return err + } + if len(latest) == 0 || latest[0].SeqId < msg.SeqId { + return types.ErrDuplicate + } + msg.SeqId = latest[0].SeqId + 1 + if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil { + return err + } + return tenantAdp.TenantMessageSave(tenantID, msg) +} + func normalizeClientMsgID(head types.KVMap) (string, error) { if head == nil { return "", nil