From acd8933dbb322a6d182447fb569ebc47081ca568 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 4 Sep 2026 10:00:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=9A=E8=B5=84=E6=96=99?= =?UTF-8?q?=E8=B7=9D=E7=A6=BB=E3=80=81=E8=AF=AD=E9=9F=B3=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E9=99=90=E5=88=B6=E3=80=81=E5=85=8D=E7=9F=AD?= =?UTF-8?q?=E4=BF=A1=E6=B3=A8=E5=86=8C=E3=80=81AI=20=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和 聊天头部因此无内容可显示。沿用同一套 haversine 与隐私开关。 - 语音/图片消息可限定会员发送,两个开关在管理端「运营配置」中修改 (迁移 032)。校验放在 persistMessageContext,HTTP 与 WebSocket 两条发送路径都覆盖;文本消息永不受限。 - 短信服务关闭时注册不再要求验证码:关掉之后没人能拿到验证码,继续 要求就等于关闭注册通道。重置密码不做同样放宽,那里缺验证码等于 凭手机号夺号。app/config 增加 smsVerification 供客户端决定表单形态。 - 修复 AI 托管账号之间不回复:原规则按「发送方是否托管账号」拦截, 把真人操作测试号的正常对话也挡了。改为标记 worker 自己写入的回复, 只对 AI 生成的消息跳过入队。 - ai.default_model_id 同时接受模型 ID 与名称,填名称时不再被 MySQL 静默转成 0 而使配置失效。 - 聊天媒体留存管理与清理任务(迁移 033,两台线上均已应用)。 新增集成测试均针对真实 MySQL:会员限制、免短信注册、AI 入队规则、 默认模型解析、资料距离。 Co-Authored-By: Claude Opus 5 --- im/.gitignore | 5 + im/backend/internal/app/admin_ai.go | 5 +- im/backend/internal/app/admin_ai_agents.go | 13 + im/backend/internal/app/admin_chat_media.go | 497 ++++++++++++++++++ .../internal/app/admin_chat_media_test.go | 101 ++++ .../internal/app/admin_commerce_messages.go | 12 +- im/backend/internal/app/ai_agent.go | 31 +- .../app/ai_enqueue_integration_test.go | 170 ++++++ im/backend/internal/app/app.go | 5 + im/backend/internal/app/im.go | 74 ++- .../im_membership_gate_integration_test.go | 129 +++++ .../app/im_unread_integration_test.go | 3 - im/backend/internal/app/media.go | 30 +- im/backend/internal/app/media_upload_test.go | 15 + im/backend/internal/app/membership.go | 10 +- im/backend/internal/app/membership_limits.go | 41 ++ im/backend/internal/app/public_auth.go | 15 +- .../app/register_sms_gate_integration_test.go | 119 +++++ im/backend/internal/app/social.go | 19 +- .../app/social_profile_integration_test.go | 64 +++ im/backend/internal/app/storage_providers.go | 47 +- .../032_message_membership_gate.sql | 5 + .../migrations/033_chat_media_management.sql | 65 +++ im/deploy/im.bchongw.com_部署记录.md | 24 + im/deploy/xim.bchongw.com_部署记录.md | 24 + 25 files changed, 1478 insertions(+), 45 deletions(-) create mode 100644 im/backend/internal/app/admin_chat_media.go create mode 100644 im/backend/internal/app/admin_chat_media_test.go create mode 100644 im/backend/internal/app/ai_enqueue_integration_test.go create mode 100644 im/backend/internal/app/im_membership_gate_integration_test.go create mode 100644 im/backend/internal/app/register_sms_gate_integration_test.go create mode 100644 im/backend/internal/app/social_profile_integration_test.go create mode 100644 im/backend/migrations/032_message_membership_gate.sql create mode 100644 im/backend/migrations/033_chat_media_management.sql diff --git a/im/.gitignore b/im/.gitignore index f41c7df..d9e11bb 100644 --- a/im/.gitignore +++ b/im/.gitignore @@ -10,3 +10,8 @@ backend/uploads/ *.log deploy/.env.production deploy/certs/ +# 发布中间产物:编译好的二进制、源码快照、Python 缓存,不进仓库。 +**/__pycache__/ +.deploy/xingyu-api-* +.deploy/xim-api-* +.deploy/backend-source-*.zip diff --git a/im/backend/internal/app/admin_ai.go b/im/backend/internal/app/admin_ai.go index 8d089f3..dc608e8 100644 --- a/im/backend/internal/app/admin_ai.go +++ b/im/backend/internal/app/admin_ai.go @@ -63,7 +63,10 @@ func (a *App) loadAIModel(ctx context.Context, id int64) (aiModel, error) { // binding: the configured id first, then the flagged row. func (a *App) defaultAIModel(ctx context.Context) (aiModel, error) { if configured := strings.TrimSpace(a.configPlain(ctx, "ai.default_model_id", "")); configured != "" { - row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE id=? AND status=1`, configured) + // The field is labelled "ID", but typing the model's name into it is an + // easy mistake that MySQL would quietly turn into id 0. Accept either, + // rather than silently ignoring what the operator chose. + row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE status=1 AND (id=? OR name=?) ORDER BY id=? DESC LIMIT 1`, configured, configured, configured) if model, err := a.scanAIModel(row.Scan); err == nil { return model, nil } diff --git a/im/backend/internal/app/admin_ai_agents.go b/im/backend/internal/app/admin_ai_agents.go index 5921fff..1290579 100644 --- a/im/backend/internal/app/admin_ai_agents.go +++ b/im/backend/internal/app/admin_ai_agents.go @@ -36,9 +36,22 @@ func (a *App) adminAIAgents(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(r.URL.Query().Get("managed")) == "1" { where += " AND g.user_id IS NOT NULL AND g.enabled=1" } + // Same keyword shape as the user list: nickname or 星遇号. + if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" { + if len([]rune(keyword)) > 50 { + fail(w, http.StatusBadRequest, 20001, "搜索关键词最多 50 字") + return + } + where += " AND (p.nickname LIKE ? OR u.public_id LIKE ?)" + like := "%" + keyword + "%" + args = append(args, like, like) + } var total int countArgs := append([]any{}, args...) + // The profile join has to match the list query, otherwise a nickname filter + // would count rows the list cannot show. if err = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u + JOIN user_profiles p ON p.user_id=u.id LEFT JOIN ai_agents g ON g.user_id=u.id WHERE u.status=1 AND u.deleted_at IS NULL`+where, countArgs...).Scan(&total); err != nil { fail(w, http.StatusInternalServerError, 50001, "查询托管账号失败") diff --git a/im/backend/internal/app/admin_chat_media.go b/im/backend/internal/app/admin_chat_media.go new file mode 100644 index 0000000..be62b29 --- /dev/null +++ b/im/backend/internal/app/admin_chat_media.go @@ -0,0 +1,497 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + chatMediaCleanupLock = "im_chat_media_cleanup_v1" + chatMediaBatchSize = 200 +) + +type chatMediaCleanupSummary struct { + Scanned int `json:"scanned"` + Deleted int `json:"deleted"` + Failed int `json:"failed"` +} + +type chatMediaTarget struct { + MessageID int64 + MediaAssetID sql.NullInt64 + MediaType string + PublicURL string + ConversationID int64 + Seq int64 + StorageProvider string + Bucket string + ObjectKey string + AssetStatus int +} + +type chatMediaEvent struct { + MessageID int64 + ConversationID int64 + Seq int64 +} + +func clampChatMediaRetentionDays(days int) int { + if days < 1 || days > 3650 { + return 90 + } + return days +} + +func chatMediaObjectKeys(objectKey, mediaType string) []string { + objectKey = strings.TrimSpace(objectKey) + if objectKey == "" { + return nil + } + keys := []string{objectKey} + if mediaType == "image" { + extension := filepath.Ext(objectKey) + stem := strings.TrimSuffix(objectKey, extension) + // Chat/post images uploaded by this service use the immutable -im1 + // suffix and always have a derived thumbnail sibling. + if strings.HasSuffix(stem, "-im1") { + keys = append(keys, stem+"-thumb.jpg") + } + } + return keys +} + +func chatMediaCleanupError(err error) string { + if err == nil { + return "" + } + message := strings.TrimSpace(err.Error()) + runes := []rune(message) + if len(runes) > 480 { + message = string(runes[:480]) + } + return message +} + +func isMissingMediaObject(err error) bool { + if err == nil || errors.Is(err, os.ErrNotExist) { + return true + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "nosuchkey") || + strings.Contains(message, "no such key") || + strings.Contains(message, "not found") || + strings.Contains(message, "status code: 404") || + strings.Contains(message, "statuscode=404") +} + +func (a *App) adminChatMedia(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + mediaType := strings.TrimSpace(r.URL.Query().Get("type")) + statusText := strings.TrimSpace(r.URL.Query().Get("status")) + where := ` WHERE 1=1` + args := []any{} + if mediaType == "image" || mediaType == "voice" { + where += ` AND mm.media_type=?` + args = append(args, mediaType) + } + if statusText != "" { + status, err := strconv.Atoi(statusText) + if err == nil && status >= 0 && status <= 3 { + where += ` AND mm.status=?` + args = append(args, status) + } + } + if keyword != "" { + like := "%" + keyword + "%" + where += ` AND (sp.nickname LIKE ? OR su.public_id LIKE ? OR mm.public_url LIKE ? OR CAST(mm.message_id AS CHAR) LIKE ? OR CAST(m.conversation_id AS CHAR) LIKE ?)` + args = append(args, like, like, like, like, like) + } + base := ` FROM im_message_media mm JOIN im_messages m ON m.id=mm.message_id JOIN users su ON su.id=m.sender_id JOIN user_profiles sp ON sp.user_id=m.sender_id LEFT JOIN media_assets ma ON ma.id=mm.media_asset_id` + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+base+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询聊天媒体失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT mm.message_id,mm.media_asset_id,m.conversation_id,m.seq,m.sender_id,su.public_id,sp.nickname,sp.avatar_url,mm.media_type,mm.public_url,mm.duration_ms,mm.status,mm.delete_reason,mm.cleanup_error,mm.deleted_at,mm.created_at,COALESCE(ma.storage_provider,''),COALESCE(ma.bucket,''),COALESCE(ma.object_key,''),COALESCE(ma.mime_type,''),COALESCE(ma.file_size,0)`+base+where+` ORDER BY mm.created_at DESC,mm.message_id DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询聊天媒体失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var messageID, conversationID, seq, senderID, fileSize int64 + var assetID sql.NullInt64 + var durationMS sql.NullInt64 + var deletedAt sql.NullTime + var publicID, nickname, avatar, typ, publicURL, reason, cleanupError, provider, bucket, objectKey, mimeType string + var status int + var createdAt time.Time + if err = rows.Scan(&messageID, &assetID, &conversationID, &seq, &senderID, &publicID, &nickname, &avatar, &typ, &publicURL, &durationMS, &status, &reason, &cleanupError, &deletedAt, &createdAt, &provider, &bucket, &objectKey, &mimeType, &fileSize); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取聊天媒体失败") + return + } + items = append(items, map[string]any{ + "id": messageID, "messageId": messageID, "mediaAssetId": nullableInt64(assetID), "conversationId": conversationID, "seq": seq, + "senderId": senderID, "senderPublicId": publicID, "senderNickname": nickname, "senderAvatar": avatar, + "type": typ, "url": publicURL, "durationMs": nullableInt64(durationMS), "status": status, "deleteReason": reason, + "cleanupError": cleanupError, "deletedAt": nullableTime(deletedAt), "createdAt": createdAt, "storageProvider": provider, + "bucket": bucket, "objectKey": objectKey, "mimeType": mimeType, "fileSize": fileSize, + }) + } + if err = rows.Err(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取聊天媒体失败") + return + } + + stats := map[string]any{"total": 0, "active": 0, "deleted": 0, "failed": 0, "activeBytes": 0} + var statsTotal, active, deleted, failed, activeBytes int64 + if scanErr := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*),COALESCE(SUM(mm.status=1),0),COALESCE(SUM(mm.status=0),0),COALESCE(SUM(mm.status=3),0),COALESCE(SUM(IF(mm.status=1,ma.file_size,0)),0) FROM im_message_media mm LEFT JOIN media_assets ma ON ma.id=mm.media_asset_id`).Scan(&statsTotal, &active, &deleted, &failed, &activeBytes); scanErr == nil { + stats = map[string]any{"total": statsTotal, "active": active, "deleted": deleted, "failed": failed, "activeBytes": activeBytes} + } + reply(w, map[string]any{ + "items": items, "total": total, "page": page, "size": size, "stats": stats, + "retention": map[string]any{ + "enabled": a.configBool(r.Context(), "im.chat_media_retention_enabled", false), + "days": clampChatMediaRetentionDays(a.configInt(r.Context(), "im.chat_media_retention_days", 90)), + "lastAt": a.configPlain(r.Context(), "im.chat_media_cleanup_last_at", ""), + "lastResult": a.configPlain(r.Context(), "im.chat_media_cleanup_last_result", ""), + }, + }) +} + +func (a *App) adminUpdateChatMediaRetention(w http.ResponseWriter, r *http.Request) { + var req struct { + Enabled bool `json:"enabled"` + Days int `json:"days"` + } + if decode(r, &req) != nil || req.Days < 1 || req.Days > 3650 { + fail(w, http.StatusBadRequest, 20001, "保留天数应为 1 至 3650 天") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key='im.chat_media_retention_enabled'`, strconv.FormatBool(req.Enabled)) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key='im.chat_media_retention_days'`, strconv.Itoa(req.Days)) + } + if err != nil || tx.Commit() != nil { + if tx != nil { + _ = tx.Rollback() + } + fail(w, http.StatusInternalServerError, 50001, "保存聊天媒体清理策略失败") + return + } + a.audit(r, "update_retention", "chat_media", 0, map[string]any{"enabled": req.Enabled, "days": req.Days}) + reply(w, map[string]any{"enabled": req.Enabled, "days": req.Days}) +} + +func (a *App) adminCleanupDueChatMedia(w http.ResponseWriter, r *http.Request) { + days := clampChatMediaRetentionDays(a.configInt(r.Context(), "im.chat_media_retention_days", 90)) + result, acquired, err := a.withChatMediaCleanupLock(r.Context(), func(ctx context.Context) (chatMediaCleanupSummary, error) { + return a.cleanupDueChatMedia(ctx, time.Now().Add(-time.Duration(days)*24*time.Hour)) + }) + if err != nil && !acquired { + fail(w, http.StatusInternalServerError, 50001, "无法启动聊天媒体清理任务") + return + } + if !acquired { + fail(w, http.StatusConflict, 20001, "聊天媒体清理任务正在执行") + return + } + if err != nil { + fail(w, http.StatusBadGateway, 50001, "聊天媒体清理任务执行失败") + return + } + a.recordChatMediaCleanupResult(r.Context(), result) + a.audit(r, "cleanup_due", "chat_media", 0, map[string]any{"retentionDays": days, "result": result}) + reply(w, result) +} + +func (a *App) adminDeleteChatMedia(w http.ResponseWriter, r *http.Request) { + messageID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "聊天媒体编号无效") + return + } + result, acquired, runErr := a.withChatMediaCleanupLock(r.Context(), func(ctx context.Context) (chatMediaCleanupSummary, error) { + summary := chatMediaCleanupSummary{Scanned: 1} + deleted, cleanupErr := a.cleanupChatMedia(ctx, messageID, "管理员手动清理") + if cleanupErr != nil { + summary.Failed = 1 + return summary, cleanupErr + } + if deleted { + summary.Deleted = 1 + } + return summary, nil + }) + if runErr != nil && !acquired { + fail(w, http.StatusInternalServerError, 50001, "无法启动聊天媒体清理任务") + return + } + if !acquired { + fail(w, http.StatusConflict, 20001, "聊天媒体清理任务正在执行") + return + } + if runErr != nil { + a.audit(r, "cleanup_failed", "chat_media", messageID, map[string]any{"error": chatMediaCleanupError(runErr)}) + fail(w, http.StatusBadGateway, 50001, "文件清理失败,记录已保留并可重试") + return + } + a.audit(r, "cleanup", "chat_media", messageID, map[string]any{"result": result}) + reply(w, result) +} + +func (a *App) withChatMediaCleanupLock(ctx context.Context, run func(context.Context) (chatMediaCleanupSummary, error)) (chatMediaCleanupSummary, bool, error) { + conn, err := a.db.Conn(ctx) + if err != nil { + return chatMediaCleanupSummary{}, false, err + } + defer conn.Close() + var acquired int + if err = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, chatMediaCleanupLock).Scan(&acquired); err != nil || acquired != 1 { + return chatMediaCleanupSummary{}, false, err + } + defer func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, _ = conn.ExecContext(releaseCtx, `SELECT RELEASE_LOCK(?)`, chatMediaCleanupLock) + }() + result, err := run(ctx) + return result, true, err +} + +func (a *App) cleanupDueChatMedia(ctx context.Context, before time.Time) (chatMediaCleanupSummary, error) { + result := chatMediaCleanupSummary{} + a.recoverStuckChatMediaCleanups(ctx) + for batch := 0; batch < 50; batch++ { + retryBefore := time.Now().Add(-time.Hour) + rows, err := a.db.QueryContext(ctx, `SELECT message_id FROM im_message_media WHERE created_at0`, target.MediaAssetID.Int64) + if err != nil { + return err + } + for rows.Next() { + var event chatMediaEvent + if err = rows.Scan(&event.MessageID, &event.ConversationID, &event.Seq); err != nil { + _ = rows.Close() + return err + } + events = append(events, event) + } + if err = rows.Close(); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE media_assets SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=?`, target.MediaAssetID.Int64); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE im_message_media SET status=0,delete_reason=?,cleanup_error='',deleted_at=COALESCE(deleted_at,NOW(3)) WHERE media_asset_id=? AND status<>0`, reason, target.MediaAssetID.Int64); err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + for _, event := range events { + a.broadcastChatMediaDeleted(ctx, event) + } + return nil +} + +func (a *App) broadcastChatMediaDeleted(ctx context.Context, event chatMediaEvent) { + rows, err := a.db.QueryContext(ctx, `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, event.ConversationID) + if err != nil { + return + } + members := []int64{} + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + members = append(members, id) + } + } + _ = rows.Close() + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_MEDIA_DELETED", "data": map[string]any{"id": event.MessageID, "conversationId": event.ConversationID, "seq": event.Seq, "mediaDeleted": true}}) +} + +func (a *App) recordChatMediaCleanupResult(ctx context.Context, result chatMediaCleanupSummary) { + lastAt := time.Now().Format(time.RFC3339) + lastResult := fmt.Sprintf("检查 %d 个,清理 %d 个,失败 %d 个", result.Scanned, result.Deleted, result.Failed) + _, _ = a.db.ExecContext(ctx, `UPDATE system_configs SET config_value=? WHERE config_key='im.chat_media_cleanup_last_at'`, lastAt) + _, _ = a.db.ExecContext(ctx, `UPDATE system_configs SET config_value=? WHERE config_key='im.chat_media_cleanup_last_result'`, lastResult) +} + +func (a *App) runChatMediaCleanupWorker(ctx context.Context) { + run := func() { + a.recoverStuckChatMediaCleanups(ctx) + if !a.configBool(ctx, "im.chat_media_retention_enabled", false) { + return + } + workerCtx, cancel := context.WithTimeout(ctx, 20*time.Minute) + defer cancel() + days := clampChatMediaRetentionDays(a.configInt(workerCtx, "im.chat_media_retention_days", 90)) + result, acquired, err := a.withChatMediaCleanupLock(workerCtx, func(runCtx context.Context) (chatMediaCleanupSummary, error) { + return a.cleanupDueChatMedia(runCtx, time.Now().Add(-time.Duration(days)*24*time.Hour)) + }) + if err != nil && !acquired { + log.Printf("chat media cleanup lock failed: %v", err) + return + } + if !acquired { + return + } + if err != nil { + log.Printf("chat media cleanup failed: %v", err) + return + } + a.recordChatMediaCleanupResult(workerCtx, result) + } + run() + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} diff --git a/im/backend/internal/app/admin_chat_media_test.go b/im/backend/internal/app/admin_chat_media_test.go new file mode 100644 index 0000000..95b3fb1 --- /dev/null +++ b/im/backend/internal/app/admin_chat_media_test.go @@ -0,0 +1,101 @@ +package app + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestChatMediaObjectKeys(t *testing.T) { + tests := []struct { + name string + objectKey string + mediaType string + want []string + }{ + {name: "chat image includes thumbnail", objectKey: "media/2026/09/1-2-im1.png", mediaType: "image", want: []string{"media/2026/09/1-2-im1.png", "media/2026/09/1-2-im1-thumb.jpg"}}, + {name: "voice only has original", objectKey: "media/2026/09/1-2.m4a", mediaType: "voice", want: []string{"media/2026/09/1-2.m4a"}}, + {name: "unrecognized image does not guess siblings", objectKey: "imported/photo.jpg", mediaType: "image", want: []string{"imported/photo.jpg"}}, + {name: "empty key", objectKey: "", mediaType: "image", want: nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := chatMediaObjectKeys(test.objectKey, test.mediaType); !reflect.DeepEqual(got, test.want) { + t.Fatalf("chatMediaObjectKeys() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestChatMediaRetentionClamp(t *testing.T) { + for input, want := range map[int]int{-1: 90, 0: 90, 1: 1, 3650: 3650, 3651: 90} { + if got := clampChatMediaRetentionDays(input); got != want { + t.Fatalf("clampChatMediaRetentionDays(%d) = %d, want %d", input, got, want) + } + } +} + +func TestMissingMediaObjectDetection(t *testing.T) { + if !isMissingMediaObject(os.ErrNotExist) || !isMissingMediaObject(errors.New("NoSuchKey")) || isMissingMediaObject(errors.New("permission denied")) { + t.Fatal("missing-object detection did not classify storage errors correctly") + } +} + +func TestChatMediaCleanupMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + directory := t.TempDir() + if _, err := db.Exec(`UPDATE system_configs SET config_value=? WHERE config_key='storage.local.directory'`, directory); err != nil { + t.Fatal(err) + } + const mainKey = "1-100-im1.png" + const thumbnailKey = "1-100-im1-thumb.jpg" + for _, name := range []string{mainKey, thumbnailKey} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("media"), 0o600); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(`INSERT INTO im_conversations(id,conversation_type,last_seq) VALUES(100,1,1)`); err != nil { + t.Fatal(err) + } + result, err := db.Exec(`INSERT INTO media_assets(owner_user_id,media_type,storage_provider,object_key,public_url,mime_type,file_size,status) VALUES(1,'image','local',?,'https://media.example/chat.png','image/png',5,1)`, mainKey) + if err != nil { + t.Fatal(err) + } + assetID, _ := result.LastInsertId() + result, err = db.Exec(`INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body) VALUES(100,1,1,'cleanup-test-message-0001',2,'{"url":"https://media.example/chat.png"}')`) + if err != nil { + t.Fatal(err) + } + messageID, _ := result.LastInsertId() + if _, err = db.Exec(`INSERT INTO im_message_media(message_id,media_asset_id,media_type,public_url,status) VALUES(?,?,'image','https://media.example/chat.png',1)`, messageID, assetID); err != nil { + t.Fatal(err) + } + + a := &App{db: db, config: Config{MediaDir: directory}, hub: NewHub()} + deleted, err := a.cleanupChatMedia(context.Background(), messageID, "test cleanup") + if err != nil || !deleted { + t.Fatalf("cleanupChatMedia() deleted=%v err=%v", deleted, err) + } + for _, name := range []string{mainKey, thumbnailKey} { + if _, statErr := os.Stat(filepath.Join(directory, name)); !os.IsNotExist(statErr) { + t.Fatalf("expected %s to be removed, stat error=%v", name, statErr) + } + } + var mediaStatus, assetStatus int + if err = db.QueryRow(`SELECT status FROM im_message_media WHERE message_id=?`, messageID).Scan(&mediaStatus); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`SELECT status FROM media_assets WHERE id=?`, assetID).Scan(&assetStatus); err != nil { + t.Fatal(err) + } + if mediaStatus != 0 || assetStatus != 0 { + t.Fatalf("cleanup statuses media=%d asset=%d, want both 0", mediaStatus, assetStatus) + } + loaded := a.loadMessageContext(context.Background(), messageID) + if !loaded.MediaDeleted || !loaded.Recalled || loaded.Content != nil { + t.Fatalf("deleted media must be hidden from clients: %#v", loaded) + } +} diff --git a/im/backend/internal/app/admin_commerce_messages.go b/im/backend/internal/app/admin_commerce_messages.go index 1321f21..d9fc0cf 100644 --- a/im/backend/internal/app/admin_commerce_messages.go +++ b/im/backend/internal/app/admin_commerce_messages.go @@ -189,14 +189,14 @@ func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) { like := "%" + keyword + "%" args = append(args, like, like, like, like, like, like) } - base := ` FROM im_messages m JOIN users su ON su.id=m.sender_id JOIN user_profiles sp ON sp.user_id=m.sender_id` + base := ` FROM im_messages m JOIN users su ON su.id=m.sender_id JOIN user_profiles sp ON sp.user_id=m.sender_id LEFT JOIN im_message_media mm ON mm.message_id=m.id` var total int64 if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+base+where, args...).Scan(&total); err != nil { fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") return } queryArgs := append(append([]any{}, args...), size, offset) - rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.conversation_id,m.seq,m.sender_id,su.public_id,sp.nickname,sp.avatar_url,m.client_msg_id,m.message_type,m.body,m.moderation_status,m.recalled_at,m.admin_removed_at,m.admin_removed_by,m.admin_remove_reason,m.created_at,COALESCE((SELECT GROUP_CONCAT(CONCAT(kp.nickname,' (',ku.public_id,')') ORDER BY kp.nickname SEPARATOR '、') FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id),'')`+base+where+` ORDER BY m.created_at DESC,m.id DESC LIMIT ? OFFSET ?`, queryArgs...) + rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.conversation_id,m.seq,m.sender_id,su.public_id,sp.nickname,sp.avatar_url,m.client_msg_id,m.message_type,m.body,m.moderation_status,m.recalled_at,m.admin_removed_at,m.admin_removed_by,m.admin_remove_reason,m.created_at,COALESCE(mm.status,-1),mm.deleted_at,COALESCE(mm.delete_reason,''),COALESCE(mm.cleanup_error,''),COALESCE((SELECT GROUP_CONCAT(CONCAT(kp.nickname,' (',ku.public_id,')') ORDER BY kp.nickname SEPARATOR '、') FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id),'')`+base+where+` ORDER BY m.created_at DESC,m.id DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") return @@ -208,17 +208,19 @@ func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) { var publicID, nickname, avatar, clientMsgID, recipients, adminRemoveReason string var typ, moderation int var body []byte - var recalledAt, adminRemovedAt sql.NullTime + var recalledAt, adminRemovedAt, mediaDeletedAt sql.NullTime var adminRemovedBy sql.NullInt64 + var mediaStatus int + var mediaDeleteReason, mediaCleanupError string var createdAt time.Time - if rows.Scan(&id, &convID, &seq, &senderID, &publicID, &nickname, &avatar, &clientMsgID, &typ, &body, &moderation, &recalledAt, &adminRemovedAt, &adminRemovedBy, &adminRemoveReason, &createdAt, &recipients) != nil { + if rows.Scan(&id, &convID, &seq, &senderID, &publicID, &nickname, &avatar, &clientMsgID, &typ, &body, &moderation, &recalledAt, &adminRemovedAt, &adminRemovedBy, &adminRemoveReason, &createdAt, &mediaStatus, &mediaDeletedAt, &mediaDeleteReason, &mediaCleanupError, &recipients) != nil { continue } var content any if json.Unmarshal(body, &content) != nil { content = string(body) } - items = append(items, map[string]any{"id": id, "conversationId": convID, "seq": seq, "senderId": senderID, "senderPublicId": publicID, "senderNickname": nickname, "senderAvatar": avatar, "recipients": recipients, "clientMsgId": clientMsgID, "type": typ, "content": content, "moderationStatus": moderation, "recalledAt": nullableTime(recalledAt), "adminRemovedAt": nullableTime(adminRemovedAt), "adminRemovedBy": nullableInt64(adminRemovedBy), "adminRemoveReason": adminRemoveReason, "createdAt": createdAt}) + items = append(items, map[string]any{"id": id, "conversationId": convID, "seq": seq, "senderId": senderID, "senderPublicId": publicID, "senderNickname": nickname, "senderAvatar": avatar, "recipients": recipients, "clientMsgId": clientMsgID, "type": typ, "content": content, "moderationStatus": moderation, "recalledAt": nullableTime(recalledAt), "adminRemovedAt": nullableTime(adminRemovedAt), "adminRemovedBy": nullableInt64(adminRemovedBy), "adminRemoveReason": adminRemoveReason, "createdAt": createdAt, "chatMedia": typ == 2 || typ == 3, "mediaStatus": mediaStatus, "mediaDeletedAt": nullableTime(mediaDeletedAt), "mediaDeleteReason": mediaDeleteReason, "mediaCleanupError": mediaCleanupError}) } reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) } diff --git a/im/backend/internal/app/ai_agent.go b/im/backend/internal/app/ai_agent.go index 476b24c..0e604d3 100644 --- a/im/backend/internal/app/ai_agent.go +++ b/im/backend/internal/app/ai_agent.go @@ -43,10 +43,28 @@ type aiReplyJob struct { // Called right after a message is committed, from every send path. It must stay // cheap and must never fail the send: a missing reply is better than a failed // message. +// aiGeneratedKey marks the context in which the worker writes its own reply. +// The loop it prevents is "a reply triggers another reply", which is a property +// of the message, not of the account: a person operating a managed test account +// is having a real conversation and deserves an answer. +type aiGeneratedKey struct{} + +func markAIGenerated(ctx context.Context) context.Context { + return context.WithValue(ctx, aiGeneratedKey{}, true) +} + +func isAIGenerated(ctx context.Context) bool { + value, _ := ctx.Value(aiGeneratedKey{}).(bool) + return value +} + func (a *App) enqueueAIReply(ctx context.Context, conversationID, senderID int64, members []int64, messageID int64) { if !a.configBool(ctx, "ai.enabled", false) { return } + if isAIGenerated(ctx) { + return // The worker's own reply must never trigger the next one. + } peerID := int64(0) for _, member := range members { if member != senderID { @@ -61,13 +79,14 @@ func (a *App) enqueueAIReply(ctx context.Context, conversationID, senderID int64 } var agentUserID int64 var testBatch string - // The sender must not itself be managed, otherwise two agents would keep - // each other talking forever. + // Note there is no condition on the sender: whoever typed this, the reply is + // owed by the recipient. Runaway agent-to-agent chatter is prevented above, + // where the worker's own writes are recognised and skipped, and bounded by + // the per-agent daily reply limits. err := a.db.QueryRowContext(ctx, `SELECT g.user_id,u.test_batch FROM ai_agents g JOIN users u ON u.id=g.user_id - WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL - AND NOT EXISTS(SELECT 1 FROM ai_agents s WHERE s.user_id=? AND s.enabled=1)`, - peerID, senderID).Scan(&agentUserID, &testBatch) + WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL`, + peerID).Scan(&agentUserID, &testBatch) if err != nil || agentUserID == 0 { return } @@ -260,7 +279,7 @@ func (a *App) processAIReplyJob(ctx context.Context, job aiReplyJob) error { } func (a *App) sendAIReply(ctx context.Context, job aiReplyJob, text string) error { - item, members, err := a.persistMessageContext(ctx, job.ConversationID, job.AgentUserID, + item, members, err := a.persistMessageContext(markAIGenerated(ctx), job.ConversationID, job.AgentUserID, "ai-"+randomToken()[:20], 1, map[string]any{"text": text}) if err != nil { return err diff --git a/im/backend/internal/app/ai_enqueue_integration_test.go b/im/backend/internal/app/ai_enqueue_integration_test.go new file mode 100644 index 0000000..e8b424c --- /dev/null +++ b/im/backend/internal/app/ai_enqueue_integration_test.go @@ -0,0 +1,170 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "testing" +) + +// Who typed the message decides nothing; who receives it decides everything. +// Operating a managed test account by hand is a real conversation, and the +// account on the other side still owes an answer. Only the worker's own reply +// is barred from triggering the next one. +func TestEnqueueAIReplyMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + for _, statement := range []string{ + `INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('ai.enabled','true','boolean','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, + `UPDATE users SET is_test=1,test_batch='regression' WHERE id IN (2,3)`, + `INSERT INTO ai_agents(user_id,model_id,enabled) VALUES(2,0,1),(3,0,1)`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } + } + conversation := func(viewer, peer int64) int64 { + t.Helper() + var created struct { + ID int64 `json:"id"` + } + body := fmt.Sprintf(`{"userId":%d}`, peer) + if err := json.Unmarshal(imTestCall(t, a.directConversation, viewer, "POST", "/api/v1/im/conversations", body, 200), &created); err != nil { + t.Fatal(err) + } + return created.ID + } + pending := func(conversationID int64) (int64, int64) { + t.Helper() + var jobs, agent int64 + if err := db.QueryRow(`SELECT COUNT(*),COALESCE(MAX(agent_user_id),0) FROM ai_reply_jobs WHERE conversation_id=?`, conversationID).Scan(&jobs, &agent); err != nil { + t.Fatal(err) + } + return jobs, agent + } + send := func(ctx context.Context, conversationID, sender int64, key string) { + t.Helper() + if _, _, err := a.persistMessageContext(ctx, conversationID, sender, key, 1, map[string]any{"text": "在吗"}); err != nil { + t.Fatal(err) + } + } + + t.Run("an ordinary account messaging an agent queues a reply", func(t *testing.T) { + id := conversation(1, 2) + send(context.Background(), id, 1, "human-to-agent") + jobs, agent := pending(id) + if jobs != 1 || agent != 2 { + t.Fatalf("jobs=%d agent=%d", jobs, agent) + } + }) + + // This is the case that looked like "AI stopped replying": the tester was + // signed in as one managed test account and messaging another. + t.Run("a person typing from a managed account still gets an answer", func(t *testing.T) { + id := conversation(2, 3) + send(context.Background(), id, 2, "agent-account-operated-by-hand") + jobs, agent := pending(id) + if jobs != 1 || agent != 3 { + t.Fatalf("托管账号之间的人工消息也应当入队: jobs=%d agent=%d", jobs, agent) + } + }) + + t.Run("the worker's own reply never queues another", func(t *testing.T) { + id := conversation(1, 3) + send(markAIGenerated(context.Background()), id, 3, "worker-written-reply") + if jobs, _ := pending(id); jobs != 0 { + t.Fatalf("AI 自己的回复不能再触发回复: jobs=%d", jobs) + } + // A human message in the same conversation still queues one. + send(context.Background(), id, 1, "human-follow-up") + if jobs, _ := pending(id); jobs != 1 { + t.Fatalf("jobs=%d", jobs) + } + }) + + t.Run("a burst of messages collapses into a single pending reply", func(t *testing.T) { + id := conversation(1, 2) + for index := 0; index < 3; index++ { + send(context.Background(), id, 1, fmt.Sprintf("burst-%d", index)) + } + var pendingJobs int + if err := db.QueryRow(`SELECT COUNT(*) FROM ai_reply_jobs WHERE conversation_id=? AND status=0`, id).Scan(&pendingJobs); err != nil { + t.Fatal(err) + } + if pendingJobs != 1 { + t.Fatalf("pending=%d, want 1", pendingJobs) + } + }) + + t.Run("a disabled agent, or the switch turned off, queues nothing", func(t *testing.T) { + if _, err := db.Exec(`UPDATE ai_agents SET enabled=0 WHERE user_id=2`); err != nil { + t.Fatal(err) + } + id := conversation(1, 2) + if _, err := db.Exec(`DELETE FROM ai_reply_jobs WHERE conversation_id=?`, id); err != nil { + t.Fatal(err) + } + send(context.Background(), id, 1, "disabled-agent") + if jobs, _ := pending(id); jobs != 0 { + t.Fatalf("jobs=%d", jobs) + } + if _, err := db.Exec(`UPDATE ai_agents SET enabled=1 WHERE user_id=2`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE system_configs SET config_value='false' WHERE config_key='ai.enabled'`); err != nil { + t.Fatal(err) + } + send(context.Background(), id, 1, "switch-off") + if jobs, _ := pending(id); jobs != 0 { + t.Fatalf("总开关关闭后不该入队: jobs=%d", jobs) + } + }) +} + +// The admin field is labelled "默认模型 ID" and a model name is an easy thing to +// type into it. MySQL turns that string into id 0, which used to mean the choice +// was silently ignored. +func TestDefaultAIModelAcceptsNameOrIDMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + for _, statement := range []string{ + `INSERT INTO ai_models(id,name,protocol,base_url,model_name,api_key_cipher,status,is_default) VALUES(1,'deepseek-v4-flash','openai','https://api.deepseek.com','deepseek-v4-flash-vision-exp','',1,0)`, + `INSERT INTO ai_models(id,name,protocol,base_url,model_name,api_key_cipher,status,is_default) VALUES(2,'backup','openai','https://api.example.com','backup-model','',1,1)`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } + } + setDefault := func(value string) { + t.Helper() + if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('ai.default_model_id',?,'text','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, value); err != nil { + t.Fatal(err) + } + } + resolve := func() aiModel { + t.Helper() + model, err := a.defaultAIModel(context.Background()) + if err != nil { + t.Fatal(err) + } + return model + } + + setDefault("1") + if model := resolve(); model.ID != 1 { + t.Fatalf("id = %d, want 1", model.ID) + } + setDefault("deepseek-v4-flash") + if model := resolve(); model.ID != 1 { + t.Fatalf("按名字也要能选中: id = %d", model.ID) + } + // Anything that matches nothing still falls back to the flagged row. + setDefault("deepseek-v4-flash-vision-exp") + if model := resolve(); model.ID != 2 { + t.Fatalf("无法匹配时回退到默认标记: id = %d", model.ID) + } + setDefault("") + if model := resolve(); model.ID != 2 { + t.Fatalf("留空时使用标记为默认的一条: id = %d", model.ID) + } +} diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index 2ed450b..4004d83 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -114,6 +114,7 @@ func (a *App) Run() { }() go a.runAIReplyWorker(workerContext) go a.runAIMaintenance(workerContext) + go a.runChatMediaCleanupWorker(workerContext) server := rest.MustNewServer(rest.RestConf{ Host: a.config.Host, Port: a.config.Port, @@ -320,6 +321,10 @@ func (a *App) adminRoutes() []rest.Route { {Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)}, {Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)}, {Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)}, + {Method: http.MethodGet, Path: "/admin/v1/chat-media", Handler: permit("messages:view", a.adminChatMedia)}, + {Method: http.MethodPut, Path: "/admin/v1/chat-media/retention", Handler: permit("messages:manage", a.adminUpdateChatMediaRetention)}, + {Method: http.MethodPost, Path: "/admin/v1/chat-media/cleanup", Handler: permit("messages:manage", a.adminCleanupDueChatMedia)}, + {Method: http.MethodDelete, Path: "/admin/v1/chat-media/:id", Handler: permit("messages:manage", a.adminDeleteChatMedia)}, {Method: http.MethodGet, Path: "/admin/v1/client-feedback", Handler: permit("users:view", a.adminFeedback)}, {Method: http.MethodPut, Path: "/admin/v1/client-feedback/:id", Handler: permit("users:manage", a.adminFeedback)}, {Method: http.MethodGet, Path: "/admin/v1/account-closures", Handler: permit("users:view", a.adminAccountClosures)}, diff --git a/im/backend/internal/app/im.go b/im/backend/internal/app/im.go index 31a5244..ebebf8c 100644 --- a/im/backend/internal/app/im.go +++ b/im/backend/internal/app/im.go @@ -28,6 +28,7 @@ type messageView struct { Type int `json:"type"` Content any `json:"content"` Recalled bool `json:"recalled"` + MediaDeleted bool `json:"mediaDeleted,omitempty"` CreatedAt time.Time `json:"createdAt"` } @@ -319,10 +320,10 @@ func (a *App) conversations(w http.ResponseWriter, r *http.Request) { (SELECT COUNT(*) FROM im_messages unread_msg WHERE unread_msg.conversation_id=c.id AND unread_msg.seq>GREATEST(m.read_seq,m.clear_seq,m.join_seq) AND unread_msg.sender_id<>m.user_id AND unread_msg.recalled_at IS NULL AND unread_msg.admin_removed_at IS NULL),m.pinned,m.muted, - other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,privacy.online_visible,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),''),msg.recalled_at,msg.admin_removed_at + other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,privacy.online_visible,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),''),COALESCE(msg.message_type,0),msg.recalled_at,msg.admin_removed_at,COALESCE(last_media.status,1),COALESCE((SELECT CAST(MAX(changed.updated_at) AS CHAR) FROM im_message_media changed JOIN im_messages changed_message ON changed_message.id=changed.message_id WHERE changed_message.conversation_id=c.id),'') FROM im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id JOIN im_conversation_members other ON other.conversation_id=c.id AND other.user_id<>m.user_id - JOIN user_profiles p ON p.user_id=other.user_id JOIN user_privacy_settings privacy ON privacy.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id + JOIN user_profiles p ON p.user_id=other.user_id JOIN user_privacy_settings privacy ON privacy.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id LEFT JOIN im_message_media last_media ON last_media.message_id=msg.id WHERE m.user_id=? AND m.status=1 AND c.status=1 ORDER BY m.pinned DESC,c.last_message_at DESC`, who.ID) if err != nil { fail(w, 500, 50001, err.Error()) @@ -334,24 +335,36 @@ func (a *App) conversations(w http.ResponseWriter, r *http.Request) { var id, lastSeq, unread, otherID int64 var lastAt sql.NullTime var pinned, muted, vip int + var messageType, mediaStatus int var nick, avatar, body string + var mediaRevision string var active sql.NullTime var recalledAt, adminRemovedAt sql.NullTime var onlineVisible int - if err := rows.Scan(&id, &lastSeq, &lastAt, &unread, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &onlineVisible, &body, &recalledAt, &adminRemovedAt); err != nil { + if err := rows.Scan(&id, &lastSeq, &lastAt, &unread, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &onlineVisible, &body, &messageType, &recalledAt, &adminRemovedAt, &mediaStatus, &mediaRevision); err != nil { fail(w, 500, 50001, "读取会话失败") return } preview := "开始聊天吧" var content map[string]any - if recalledAt.Valid || adminRemovedAt.Valid { + if (messageType == 2 || messageType == 3) && mediaStatus == 0 { + if messageType == 2 { + preview = "图片已清理" + } else { + preview = "语音已清理" + } + } else if recalledAt.Valid || adminRemovedAt.Valid { preview = "消息已撤回" } else if json.Unmarshal([]byte(body), &content) == nil { if text, ok := content["text"].(string); ok { preview = text + } else if messageType == 2 { + preview = "[图片]" + } else if messageType == 3 { + preview = "[语音]" } } - items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": unread, "lastMessageAt": nullableTime(lastAt), "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "avatarThumbnail": avatarThumbnailURL(avatar), "vip": vip == 1, "online": onlineVisible == 1 && a.onlineNow(otherID)}}) + items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": unread, "lastMessageAt": nullableTime(lastAt), "mediaRevision": mediaRevision, "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "avatarThumbnail": avatarThumbnailURL(avatar), "vip": vip == 1, "online": onlineVisible == 1 && a.onlineNow(otherID)}}) } if err := rows.Err(); err != nil { fail(w, 500, 50001, "读取会话失败") @@ -380,18 +393,19 @@ func (a *App) messages(w http.ResponseWriter, r *http.Request) { if limit <= 0 || limit > 100 { limit = 50 } - query := `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE conversation_id=?` + query := `SELECT m.id,m.conversation_id,m.seq,m.sender_id,m.client_msg_id,m.message_type,m.body,m.recalled_at,m.admin_removed_at,m.created_at,COALESCE(mm.status,1) + FROM im_messages m LEFT JOIN im_message_media mm ON mm.message_id=m.id WHERE m.conversation_id=?` args := []any{id} if beforeSeq > 0 { - query += ` AND seq 0 { - query += ` AND seq>?` + query += ` AND m.seq>?` args = append(args, afterSeq) - query += ` ORDER BY seq ASC LIMIT ?` + query += ` ORDER BY m.seq ASC LIMIT ?` } else { - query += ` ORDER BY seq DESC LIMIT ?` + query += ` ORDER BY m.seq DESC LIMIT ?` } args = append(args, limit+1) rows, err := a.db.QueryContext(r.Context(), query, args...) @@ -405,12 +419,14 @@ func (a *App) messages(w http.ResponseWriter, r *http.Request) { var item messageView var body []byte var recalledAt, adminRemovedAt sql.NullTime - if err := rows.Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt); err != nil { + var mediaStatus int + if err := rows.Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt, &mediaStatus); err != nil { fail(w, 500, 50001, "读取消息失败") return } var content any - item.Recalled = recalledAt.Valid || adminRemovedAt.Valid + item.MediaDeleted = (item.Type == 2 || item.Type == 3) && mediaStatus == 0 + item.Recalled = recalledAt.Valid || adminRemovedAt.Valid || item.MediaDeleted if !item.Recalled { _ = json.Unmarshal(body, &content) } @@ -469,6 +485,13 @@ func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusTooManyRequests, 30005, limitErr.Error()) return } + // Its own code and status: the client turns this one into an upgrade + // prompt rather than a plain "send failed" toast. + var gateErr *messageMembershipGateError + if errors.As(err, &gateErr) { + fail(w, http.StatusPaymentRequired, 30006, gateErr.Error()) + return + } fail(w, 400, 30004, err.Error()) return } @@ -546,6 +569,10 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI if messageType == 0 { messageType = 1 } + if err = a.ensureMessageMembership(ctx, senderID, messageType); err != nil { + return item, nil, err + } + var mediaAssetID int64 if messageType == 2 || messageType == 3 { contentMap, _ := content.(map[string]any) mediaURL, _ := contentMap["url"].(string) @@ -553,8 +580,7 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI if messageType == 3 { expectedType = "audio" } - var owned int - if queryErr := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type=? AND status=1 AND moderation_status=1)`, senderID, mediaURL, expectedType).Scan(&owned); queryErr != nil || owned != 1 { + if queryErr := a.db.QueryRowContext(ctx, `SELECT id FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type=? AND status=1 AND moderation_status=1 ORDER BY id DESC LIMIT 1`, senderID, mediaURL, expectedType).Scan(&mediaAssetID); queryErr != nil { return item, nil, fmt.Errorf("消息媒体必须由当前账号上传") } } @@ -602,6 +628,20 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI return item, nil, err } messageID, _ := result.LastInsertId() + if mediaAssetID > 0 { + contentMap, _ := content.(map[string]any) + mediaURL, _ := contentMap["url"].(string) + mediaType := "image" + var durationMS any + if messageType == 3 { + mediaType = "voice" + duration, _ := numericDuration(contentMap["duration"]) + durationMS = duration * 1000 + } + if _, err = tx.ExecContext(ctx, `INSERT INTO im_message_media(message_id,media_asset_id,media_type,public_url,duration_ms,status) VALUES(?,?,?,?,?,1)`, messageID, mediaAssetID, mediaType, mediaURL, durationMS); err != nil { + return item, nil, err + } + } _, err = tx.ExecContext(ctx, `UPDATE im_conversations SET last_seq=?,last_message_id=?,last_message_at=NOW(3) WHERE id=?`, seq, messageID, conversationID) if err != nil { return item, nil, err @@ -660,8 +700,10 @@ func (a *App) loadMessageContext(ctx context.Context, id int64) messageView { var item messageView var body []byte var recalledAt, adminRemovedAt sql.NullTime - _ = a.db.QueryRowContext(ctx, `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt) - item.Recalled = recalledAt.Valid || adminRemovedAt.Valid + var mediaStatus int + _ = a.db.QueryRowContext(ctx, `SELECT m.id,m.conversation_id,m.seq,m.sender_id,m.client_msg_id,m.message_type,m.body,m.recalled_at,m.admin_removed_at,m.created_at,COALESCE(mm.status,1) FROM im_messages m LEFT JOIN im_message_media mm ON mm.message_id=m.id WHERE m.id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt, &mediaStatus) + item.MediaDeleted = (item.Type == 2 || item.Type == 3) && mediaStatus == 0 + item.Recalled = recalledAt.Valid || adminRemovedAt.Valid || item.MediaDeleted if !item.Recalled { _ = json.Unmarshal(body, &item.Content) } diff --git a/im/backend/internal/app/im_membership_gate_integration_test.go b/im/backend/internal/app/im_membership_gate_integration_test.go new file mode 100644 index 0000000..fd16e95 --- /dev/null +++ b/im/backend/internal/app/im_membership_gate_integration_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" +) + +// Voice and image messages can be limited to members from the admin console. +// The switch has to hold on the real send path, not only in the UI. +func TestMessageMembershipGateMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + setGate := func(key, value string) { + t.Helper() + if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES(?,?,'boolean','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, key, value); err != nil { + t.Fatal(err) + } + } + for _, media := range []struct { + url string + kind string + }{{"https://cdn.example.com/voice-1.m4a", "audio"}, {"https://cdn.example.com/image-1.jpg", "image"}} { + if _, err := db.Exec(`INSERT INTO media_assets(owner_user_id,media_type,public_url,status,moderation_status) VALUES(1,?,?,1,1)`, media.kind, media.url); err != nil { + t.Fatal(err) + } + } + var conversation struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &conversation); err != nil { + t.Fatal(err) + } + path := fmt.Sprintf("/api/v1/im/conversations/%d/messages", conversation.ID) + voice := `{"clientMsgId":%q,"type":3,"content":{"url":"https://cdn.example.com/voice-1.m4a","duration":3}}` + image := `{"clientMsgId":%q,"type":2,"content":{"url":"https://cdn.example.com/image-1.jpg"}}` + send := func(payload, key string, wantStatus int) string { + t.Helper() + w := httptest.NewRecorder() + a.sendMessageHTTP(w, imTestRequest(1, "POST", path, fmt.Sprintf(payload, key))) + if w.Code != wantStatus { + t.Fatalf("HTTP %d, want %d: %s", w.Code, wantStatus, w.Body.String()) + } + return w.Body.String() + } + member := func(active bool) { + t.Helper() + if _, err := db.Exec(`DELETE FROM subscriptions WHERE user_id=1`); err != nil { + t.Fatal(err) + } + if !active { + return + } + if _, err := db.Exec(`INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(1,1,'test',1,NOW(3),DATE_ADD(NOW(3),INTERVAL 30 DAY))`); err != nil { + t.Fatal(err) + } + } + + t.Run("both kinds are open while the switches are off", func(t *testing.T) { + setGate("membership.voice_message_requires_vip", "false") + setGate("membership.image_message_requires_vip", "false") + send(voice, "open-voice", 200) + send(image, "open-image", 200) + }) + + t.Run("a non member is refused with its own code once the switch is on", func(t *testing.T) { + setGate("membership.voice_message_requires_vip", "true") + setGate("membership.image_message_requires_vip", "true") + body := send(voice, "gated-voice", 402) + if !strings.Contains(body, "30006") || !strings.Contains(body, "发送语音消息需要开通会员") { + t.Fatalf("voice refusal = %s", body) + } + if body := send(image, "gated-image", 402); !strings.Contains(body, "发送图片消息需要开通会员") { + t.Fatalf("image refusal = %s", body) + } + // Text is never gated: the two switches must not silence the chat. + imTestCall(t, a.sendMessageHTTP, 1, "POST", path, `{"clientMsgId":"gated-text","type":1,"content":{"text":"仍然可以说话"}}`, 200) + }) + + t.Run("each switch only gates its own kind", func(t *testing.T) { + setGate("membership.voice_message_requires_vip", "true") + setGate("membership.image_message_requires_vip", "false") + send(voice, "half-voice", 402) + send(image, "half-image", 200) + }) + + t.Run("a member sends both kinds", func(t *testing.T) { + setGate("membership.voice_message_requires_vip", "true") + setGate("membership.image_message_requires_vip", "true") + member(true) + defer member(false) + send(voice, "member-voice", 200) + send(image, "member-image", 200) + }) + + t.Run("the membership status tells the client before it records anything", func(t *testing.T) { + setGate("membership.voice_message_requires_vip", "true") + setGate("membership.image_message_requires_vip", "false") + var status struct { + Entitlements struct { + VoiceRequiresVIP bool `json:"voiceMessageRequiresVip"` + ImageRequiresVIP bool `json:"imageMessageRequiresVip"` + CanSendVoice bool `json:"canSendVoiceMessage"` + CanSendImage bool `json:"canSendImageMessage"` + } `json:"entitlements"` + } + read := func(user int64) { + t.Helper() + if err := json.Unmarshal(imTestCall(t, a.membershipStatus, user, "GET", "/api/v1/membership/status", "", 200), &status); err != nil { + t.Fatal(err) + } + } + read(1) + if !status.Entitlements.VoiceRequiresVIP || status.Entitlements.ImageRequiresVIP { + t.Fatalf("switches not reported: %+v", status.Entitlements) + } + if status.Entitlements.CanSendVoice || !status.Entitlements.CanSendImage { + t.Fatalf("non member capabilities: %+v", status.Entitlements) + } + member(true) + defer member(false) + read(1) + if !status.Entitlements.CanSendVoice { + t.Fatalf("member must be allowed to send voice: %+v", status.Entitlements) + } + }) +} diff --git a/im/backend/internal/app/im_unread_integration_test.go b/im/backend/internal/app/im_unread_integration_test.go index e9cc5b7..ff66717 100644 --- a/im/backend/internal/app/im_unread_integration_test.go +++ b/im/backend/internal/app/im_unread_integration_test.go @@ -64,9 +64,6 @@ func isolatedIMDatabase(t *testing.T) *sql.DB { t.Fatal(err) } for _, file := range files { - if filepath.Base(file)[:3] > "026" { - continue - } body, err := os.ReadFile(file) if err != nil { t.Fatal(err) diff --git a/im/backend/internal/app/media.go b/im/backend/internal/app/media.go index 7da967f..f1f282e 100644 --- a/im/backend/internal/app/media.go +++ b/im/backend/internal/app/media.go @@ -18,6 +18,28 @@ const maxUploadBytes int64 = 16 << 20 var mediaNamePattern = regexp.MustCompile(`^[0-9]+-[0-9]+(?:-av1(?:-thumb|-original)?|-im1(?:-thumb)?)?\.(?:gif|jpe?g|png|webp|mp3|wav|amr|m4a)$`) +// Android's recorder can emit an MP3 stream without an ID3 tag. Go's generic +// sniffer identifies only the ID3 form, so validate the first MPEG audio frame +// header as a fallback instead of rejecting a valid phone recording as binary. +func isMPEGAudioFrameHeader(header []byte) bool { + if len(header) < 4 || header[0] != 0xff || header[1]&0xe0 != 0xe0 { + return false + } + version := (header[1] >> 3) & 0x03 + layer := (header[1] >> 1) & 0x03 + bitrateIndex := (header[2] >> 4) & 0x0f + sampleRateIndex := (header[2] >> 2) & 0x03 + return version != 0x01 && layer != 0 && bitrateIndex != 0 && bitrateIndex != 0x0f && sampleRateIndex != 0x03 +} + +func detectUploadContentType(header []byte) string { + contentType := http.DetectContentType(header) + if isMPEGAudioFrameHeader(header) { + return "audio/mpeg" + } + return contentType +} + func mediaThumbnailURL(source string) string { if source == "" || strings.ContainsAny(source, "?#") { return source @@ -45,7 +67,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { } defer r.MultipartForm.RemoveAll() purpose := r.FormValue("purpose") - if purpose != "" && purpose != "avatar" { + if purpose != "" && purpose != "avatar" && purpose != "voice" { fail(w, http.StatusBadRequest, 20001, "上传用途无效") return } @@ -85,7 +107,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { "audio/amr": ".amr", "audio/mp4": ".m4a", } - contentType := http.DetectContentType(header) + contentType := detectUploadContentType(header) extension, ok := extensions[contentType] if !ok { fail(w, http.StatusBadRequest, 20001, "仅支持常见图片或语音格式") @@ -102,6 +124,10 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { } originalType, originalExtension, originalSize := contentType, extension, size isImage := strings.HasPrefix(contentType, "image/") + if purpose == "voice" && isImage { + fail(w, http.StatusBadRequest, 20001, "语音文件格式无效") + return + } var imageVariants avatarImages if isImage { imageVariants, err = createAvatarImages(r.Context(), file) diff --git a/im/backend/internal/app/media_upload_test.go b/im/backend/internal/app/media_upload_test.go index c0428e5..8ce611d 100644 --- a/im/backend/internal/app/media_upload_test.go +++ b/im/backend/internal/app/media_upload_test.go @@ -200,6 +200,21 @@ func TestOrdinaryMediaUploadPreservesOriginal(t *testing.T) { } } +func TestRawMP3FrameUploadIsAcceptedAsVoice(t *testing.T) { + a, store := uploadTestApp(t) + // A native recorder may begin directly with an MPEG-1 Layer III frame and + // omit the ID3 prefix recognized by http.DetectContentType. + source := append([]byte{0xff, 0xfb, 0x90, 0x64}, bytes.Repeat([]byte{0x55}, 1024)...) + w := uploadTestRequest(t, a, source, "voice") + if w.Code != 200 || store.mime != "audio/mpeg" || store.size != int64(len(source)) { + t.Fatal("raw MP3 voice upload failed", w.Code, w.Body.String(), store.mime, + "detected", http.DetectContentType(source), detectUploadContentType(source)) + } + if !strings.HasSuffix(store.publicURL, ".mp3") { + t.Fatal("voice upload was not published as MP3", store.publicURL) + } +} + func TestInvalidAvatarUploadCreatesNoRecords(t *testing.T) { for _, purpose := range []string{"avatar", "unknown"} { a, store := uploadTestApp(t) diff --git a/im/backend/internal/app/membership.go b/im/backend/internal/app/membership.go index 6dece38..f139e75 100644 --- a/im/backend/internal/app/membership.go +++ b/im/backend/internal/app/membership.go @@ -75,7 +75,12 @@ func (a *App) membershipStatus(w http.ResponseWriter, r *http.Request) { likeRemaining = 0 } } - entitlementView := map[string]any{"dailyActiveChatLimit": entitlements.DailyActiveChatLimit, "dailyLikeLimit": entitlements.DailyLikeLimit, "dailyLikeUsed": likeUsed, "dailyLikeRemaining": likeRemaining, "canViewVisitors": entitlements.CanViewVisitors, "canInvisibleVisit": entitlements.CanInvisibleVisit, "recommendationWeight": entitlements.RecommendationWeight} + // The chat page reads these two to explain the lock before a recording or an + // upload is made, instead of letting the send fail after the work is done. + _, voiceGated := a.messageMembershipGate(r.Context(), messageTypeVoice) + _, imageGated := a.messageMembershipGate(r.Context(), messageTypeImage) + member := a.hasActiveMembership(r.Context(), current(r).ID) + entitlementView := map[string]any{"dailyActiveChatLimit": entitlements.DailyActiveChatLimit, "dailyLikeLimit": entitlements.DailyLikeLimit, "dailyLikeUsed": likeUsed, "dailyLikeRemaining": likeRemaining, "canViewVisitors": entitlements.CanViewVisitors, "canInvisibleVisit": entitlements.CanInvisibleVisit, "recommendationWeight": entitlements.RecommendationWeight, "voiceMessageRequiresVip": voiceGated, "imageMessageRequiresVip": imageGated, "canSendVoiceMessage": member || !voiceGated, "canSendImageMessage": member || !imageGated} var planName string var level int var expires time.Time @@ -339,6 +344,9 @@ func (a *App) appConfig(w http.ResponseWriter, r *http.Request) { "feed": a.configBool(r.Context(), "app.features.feed", true), "membership": a.configBool(r.Context(), "app.features.membership", true), "im": a.configBool(r.Context(), "app.features.im", true), + // The sign-up form hides its code field when this is off, instead of + // asking for something the server will never send. + "smsVerification": a.smsVerificationRequired(r.Context()), } reply(w, map[string]any{"configs": configs, "platform": platform, "features": features, "maintenance": map[string]any{"enabled": a.configBool(r.Context(), "app.maintenance.enabled", false), "message": a.configPlain(r.Context(), "app.maintenance.message", "系统维护中,请稍后再试")}, "legal": map[string]string{"userAgreementVersion": a.configPlain(r.Context(), "legal.user_agreement_version", "1.0"), "privacyPolicyVersion": a.configPlain(r.Context(), "legal.privacy_policy_version", "1.0"), "operatorName": a.configPlain(r.Context(), "legal.operator_name", ""), "contact": a.configPlain(r.Context(), "legal.contact", ""), "effectiveDate": a.configPlain(r.Context(), "legal.effective_date", ""), "userAgreementUrl": a.configPlain(r.Context(), "legal.user_agreement_url", ""), "privacyPolicyUrl": a.configPlain(r.Context(), "legal.privacy_policy_url", "")}, "minVersion": a.configPlain(r.Context(), "app.min_version."+platform, "1.0.0"), "latest": latest}) } diff --git a/im/backend/internal/app/membership_limits.go b/im/backend/internal/app/membership_limits.go index 16e5323..cefd5af 100644 --- a/im/backend/internal/app/membership_limits.go +++ b/im/backend/internal/app/membership_limits.go @@ -120,3 +120,44 @@ func (a *App) reserveDailyActiveChat(ctx context.Context, tx *sql.Tx, conversati _, err = tx.ExecContext(ctx, `UPDATE im_daily_active_chat_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, senderID) return err } + +const ( + messageTypeImage = 2 + messageTypeVoice = 3 +) + +// messageMembershipGateError names the message kind the reader was blocked on, +// so the client can offer the right upgrade prompt instead of a generic error. +type messageMembershipGateError struct{ Kind string } + +func (e *messageMembershipGateError) Error() string { + return fmt.Sprintf("发送%s消息需要开通会员", e.Kind) +} + +// hasActiveMembership uses the same subscription row that /membership/status +// reports as "active", so the app never offers what the server will refuse. +// Every grant path — order, admin, gateway callback — writes that row. +func (a *App) hasActiveMembership(ctx context.Context, userID int64) bool { + var one int + return a.db.QueryRowContext(ctx, `SELECT 1 FROM subscriptions WHERE user_id=? AND status=1 AND started_at<=NOW(3) AND expires_at>NOW(3) LIMIT 1`, userID).Scan(&one) == nil +} + +// messageMembershipGate reports whether one message type is members-only and +// what to call it. Both switches are edited from the admin console. +func (a *App) messageMembershipGate(ctx context.Context, messageType int) (string, bool) { + switch messageType { + case messageTypeImage: + return "图片", a.configBool(ctx, "membership.image_message_requires_vip", false) + case messageTypeVoice: + return "语音", a.configBool(ctx, "membership.voice_message_requires_vip", false) + } + return "", false +} + +func (a *App) ensureMessageMembership(ctx context.Context, senderID int64, messageType int) error { + kind, gated := a.messageMembershipGate(ctx, messageType) + if !gated || a.hasActiveMembership(ctx, senderID) { + return nil + } + return &messageMembershipGateError{Kind: kind} +} diff --git a/im/backend/internal/app/public_auth.go b/im/backend/internal/app/public_auth.go index 1a960b7..158a5d4 100644 --- a/im/backend/internal/app/public_auth.go +++ b/im/backend/internal/app/public_auth.go @@ -1,6 +1,7 @@ package app import ( + "context" "crypto/rand" "crypto/sha256" "database/sql" @@ -81,6 +82,12 @@ func (a *App) sendSMS(w http.ResponseWriter, r *http.Request) { reply(w, data) } +// smsVerificationRequired reports whether sign-up must carry a code. It follows +// the same sms.enabled switch that lets /auth/sms/send issue one at all. +func (a *App) smsVerificationRequired(ctx context.Context) bool { + return a.configBool(ctx, "sms.enabled", true) +} + func (a *App) register(w http.ResponseWriter, r *http.Request) { var req authRequest if err := decode(r, &req); err != nil { @@ -94,7 +101,13 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) { if !a.rateLimit(w, r, "register_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "register_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { return } - if !a.consumeSMSCode(r, req.Phone, "register", req.Code) { + // With the SMS service switched off in the admin console nobody can obtain a + // code, so demanding one would close registration altogether. The switch is + // the operator saying they accept sign-ups without phone verification; the + // per-IP and per-phone limits above are what still holds the door. + // Password reset is deliberately not relaxed the same way: no code there + // means anyone could take over an account by knowing its number. + if a.smsVerificationRequired(r.Context()) && !a.consumeSMSCode(r, req.Phone, "register", req.Code) { fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") return } diff --git a/im/backend/internal/app/register_sms_gate_integration_test.go b/im/backend/internal/app/register_sms_gate_integration_test.go new file mode 100644 index 0000000..3877592 --- /dev/null +++ b/im/backend/internal/app/register_sms_gate_integration_test.go @@ -0,0 +1,119 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" +) + +// Turning the SMS service off in the admin console has to leave sign-up usable: +// nobody can obtain a code once it is off, so demanding one would close the door +// entirely. Password reset keeps demanding one, because there a missing code is +// an account takeover. +func TestRegisterWithoutSMSVerificationMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + setSMS := func(enabled string) { + t.Helper() + if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('sms.enabled',?,'boolean','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, enabled); err != nil { + t.Fatal(err) + } + } + register := func(phone, code string) (int, string) { + t.Helper() + body := fmt.Sprintf(`{"phone":%q,"code":%q,"password":"Str0ng!Passw0rd","nickname":"新用户","deviceId":"test-device"}`, phone, code) + w := httptest.NewRecorder() + a.register(w, httptest.NewRequest("POST", "/api/v1/auth/register", strings.NewReader(body))) + return w.Code, w.Body.String() + } + + t.Run("with SMS on, a missing code still refuses the sign-up", func(t *testing.T) { + setSMS("true") + if status, body := register("13900000001", ""); status != 400 || !strings.Contains(body, "验证码") { + t.Fatalf("HTTP %d: %s", status, body) + } + }) + + t.Run("with SMS off, the phone alone creates the account", func(t *testing.T) { + setSMS("false") + status, body := register("13900000002", "") + if status != 200 { + t.Fatalf("HTTP %d: %s", status, body) + } + var payload struct { + Data struct { + AccessToken string `json:"accessToken"` + UserID int64 `json:"userId"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatal(err) + } + if payload.Data.AccessToken == "" || payload.Data.UserID == 0 { + t.Fatalf("sign-up must return a usable session: %s", body) + } + var nickname string + if err := db.QueryRow(`SELECT nickname FROM user_profiles WHERE user_id=?`, payload.Data.UserID).Scan(&nickname); err != nil || nickname != "新用户" { + t.Fatalf("profile not created: %v %q", err, nickname) + } + }) + + t.Run("the same number still cannot be registered twice", func(t *testing.T) { + setSMS("false") + if status, body := register("13900000002", ""); status != 409 { + t.Fatalf("HTTP %d: %s", status, body) + } + }) + + // Dropping the code does not drop the rest of the form: the fields the + // server has always required are still required. + t.Run("the remaining fields are still validated without a code", func(t *testing.T) { + setSMS("false") + for _, body := range []string{ + `{"phone":"13900000003","code":"","password":"","nickname":"没有密码","deviceId":"test-device"}`, + `{"phone":"1390000","code":"","password":"Str0ng!Passw0rd","nickname":"号码不对","deviceId":"test-device"}`, + `{"phone":"13900000004","code":"","password":"Str0ng!Passw0rd","nickname":" ","deviceId":"test-device"}`, + } { + w := httptest.NewRecorder() + a.register(w, httptest.NewRequest("POST", "/api/v1/auth/register", strings.NewReader(body))) + if w.Code != 400 { + t.Fatalf("HTTP %d for %s: %s", w.Code, body, w.Body.String()) + } + } + }) + + t.Run("password reset is never relaxed the same way", func(t *testing.T) { + setSMS("false") + w := httptest.NewRecorder() + a.resetPassword(w, httptest.NewRequest("POST", "/api/v1/auth/password/reset", + strings.NewReader(`{"phone":"13900000002","code":"","password":"An0ther!Passw0rd"}`))) + if w.Code == 200 { + t.Fatal("关闭短信不能让任何人凭手机号改密码") + } + }) + + t.Run("the app config tells the client which form to show", func(t *testing.T) { + read := func() bool { + t.Helper() + var payload struct { + Features struct { + SMSVerification bool `json:"smsVerification"` + } `json:"features"` + } + if err := json.Unmarshal(imTestCall(t, a.appConfig, 1, "GET", "/api/v1/app/config", "", 200), &payload); err != nil { + t.Fatal(err) + } + return payload.Features.SMSVerification + } + setSMS("false") + if read() { + t.Fatal("关闭后客户端不该再要求验证码") + } + setSMS("true") + if !read() { + t.Fatal("开启后客户端必须要求验证码") + } + }) +} diff --git a/im/backend/internal/app/social.go b/im/backend/internal/app/social.go index 0efa31b..bf8db5c 100644 --- a/im/backend/internal/app/social.go +++ b/im/backend/internal/app/social.go @@ -138,16 +138,27 @@ func (a *App) loadProfile(r *http.Request, id, viewerID int64) (profileView, err var birthday sql.NullTime var active sql.NullTime var vip int - var onlineVisible, lastActiveVisible int - err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.occupation,p.education,p.relationship_status,p.bio,p.is_vip,p.vip_level,p.last_active_at,privacy.online_visible,privacy.last_active_visible, + var onlineVisible, lastActiveVisible, distanceVisible int + var lat, lng sql.NullFloat64 + err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.occupation,p.education,p.relationship_status,p.bio,p.is_vip,p.vip_level,p.last_active_at,privacy.online_visible,privacy.last_active_visible,privacy.distance_visible,l.latitude,l.longitude, (SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND status=1),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id), EXISTS(SELECT 1 FROM user_follows WHERE user_id=? AND target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=u.id) - FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id + FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id LEFT JOIN user_location_states l ON l.user_id=u.id WHERE u.id=? AND u.status=1 AND (u.id=? OR NOT EXISTS(SELECT 1 FROM user_blocks blocked WHERE (blocked.user_id=? AND blocked.blocked_user_id=u.id) OR (blocked.user_id=u.id AND blocked.blocked_user_id=?)))`, viewerID, viewerID, id, viewerID, viewerID, viewerID).Scan( - &item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Occupation, &item.Education, &item.RelationshipStatus, &item.Bio, &vip, &item.VIPLevel, &active, &onlineVisible, &lastActiveVisible, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked) + &item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Occupation, &item.Education, &item.RelationshipStatus, &item.Bio, &vip, &item.VIPLevel, &active, &onlineVisible, &lastActiveVisible, &distanceVisible, &lat, &lng, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked) if err != nil { return item, err } + // The profile and chat headers label distance the same way the nearby list + // does, so one extra lookup here keeps the two readings consistent. + if id != viewerID && distanceVisible == 1 && lat.Valid && lng.Valid { + var myLat, myLng sql.NullFloat64 + _ = a.db.QueryRowContext(r.Context(), `SELECT latitude,longitude FROM user_location_states WHERE user_id=?`, viewerID).Scan(&myLat, &myLng) + if myLat.Valid && myLng.Valid { + item.Distance = haversine(myLat.Float64, myLng.Float64, lat.Float64, lng.Float64) + item.DistanceText = distanceText(item.Distance) + } + } item.VIP = vip == 1 item.AvatarThumbnail = avatarThumbnailURL(item.Avatar) if birthday.Valid { diff --git a/im/backend/internal/app/social_profile_integration_test.go b/im/backend/internal/app/social_profile_integration_test.go new file mode 100644 index 0000000..7cce985 --- /dev/null +++ b/im/backend/internal/app/social_profile_integration_test.go @@ -0,0 +1,64 @@ +package app + +import ( + "encoding/json" + "fmt" + "math" + "testing" +) + +// The profile and chat headers label distance, so the single-profile endpoint +// has to answer with the same reading the nearby list gives. +func TestProfileDistanceMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + // Shenzhen city centre and a point a few kilometres north of it. + for _, seed := range []struct { + user int64 + lat, lng float64 + }{{1, 22.5431, 114.0579}, {2, 22.5731, 114.0579}} { + if _, err := db.Exec(`INSERT INTO user_location_states(user_id,city_code,latitude,longitude) VALUES(?,'440300',?,?)`, seed.user, seed.lat, seed.lng); err != nil { + t.Fatal(err) + } + } + profile := func(viewer, target int64) profileView { + t.Helper() + var item profileView + path := fmt.Sprintf("/api/v1/users/%d/profile", target) + if err := json.Unmarshal(imTestCall(t, a.userProfile, viewer, "GET", path, "", 200), &item); err != nil { + t.Fatal(err) + } + return item + } + + t.Run("another profile carries the distance between the two locations", func(t *testing.T) { + item := profile(1, 2) + if math.Abs(item.Distance-3.34) > 0.1 { + t.Fatalf("distance = %v km, want about 3.34", item.Distance) + } + if item.DistanceText != distanceText(item.Distance) { + t.Fatalf("distanceText = %q, want %q", item.DistanceText, distanceText(item.Distance)) + } + }) + + t.Run("my own profile has no distance to label", func(t *testing.T) { + if item := profile(1, 1); item.DistanceText != "" { + t.Fatalf("distanceText = %q, want empty", item.DistanceText) + } + }) + + t.Run("a viewer without a location sees no distance", func(t *testing.T) { + if item := profile(3, 2); item.DistanceText != "" { + t.Fatalf("distanceText = %q, want empty", item.DistanceText) + } + }) + + t.Run("hiding distance hides it here too", func(t *testing.T) { + if _, err := db.Exec(`UPDATE user_privacy_settings SET distance_visible=0 WHERE user_id=2`); err != nil { + t.Fatal(err) + } + if item := profile(1, 2); item.DistanceText != "" || item.Distance != 0 { + t.Fatalf("hidden distance leaked: %v %q", item.Distance, item.DistanceText) + } + }) +} diff --git a/im/backend/internal/app/storage_providers.go b/im/backend/internal/app/storage_providers.go index 2089b48..e640a9e 100644 --- a/im/backend/internal/app/storage_providers.go +++ b/im/backend/internal/app/storage_providers.go @@ -224,9 +224,35 @@ func (a *App) newMediaObjectStorage(ctx context.Context) (mediaObjectStorage, fu return nil, func() {}, err } provider := a.configPlain(ctx, "storage.provider", "local") + return a.newMediaObjectStorageForProvider(ctx, provider, "") +} + +// Historical media must be deleted through the provider that originally +// stored it, even when uploads have since moved to another provider. The +// credentials remain in system_configs and the recorded bucket protects us +// from deleting an object with the same key from a newly configured bucket. +func (a *App) newMediaObjectStorageForProvider(ctx context.Context, provider, recordedBucket string) (mediaObjectStorage, func(), error) { + provider = strings.TrimSpace(provider) + if provider == "local" { + directory := a.configPlain(ctx, "storage.local.directory", a.config.MediaDir) + if err := validateLocalStorageDirectory(directory); err != nil { + return nil, func() {}, err + } + return localMediaStorage{directory: directory}, func() {}, nil + } keyPrefix := "storage." + provider + "." + configuredBucket := strings.TrimSpace(a.configPlain(ctx, keyPrefix+"bucket", "")) + if configuredBucket == "" { + return nil, func() {}, fmt.Errorf("%s Bucket 未配置", storageProviderName(provider)) + } + if recordedBucket != "" && configuredBucket != recordedBucket { + return nil, func() {}, fmt.Errorf("记录 Bucket %q 与当前配置 %q 不一致", recordedBucket, configuredBucket) + } switch provider { case "aliyun_oss": + if a.configPlain(ctx, keyPrefix+"access_key_id", "") == "" || a.configPlain(ctx, keyPrefix+"access_key_secret", "") == "" { + return nil, func() {}, fmt.Errorf("阿里云 OSS 凭证未配置") + } config := aliyunoss.LoadDefaultConfig(). WithCredentialsProvider(aliyuncredentials.NewStaticCredentialsProvider(a.configPlain(ctx, keyPrefix+"access_key_id", ""), a.configPlain(ctx, keyPrefix+"access_key_secret", ""))). WithRegion(a.configPlain(ctx, keyPrefix+"region", "")). @@ -234,25 +260,34 @@ func (a *App) newMediaObjectStorage(ctx context.Context) (mediaObjectStorage, fu WithConnectTimeout(5 * time.Second). WithReadWriteTimeout(30 * time.Second). WithRetryMaxAttempts(3) - return &aliyunOSSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: aliyunoss.NewClient(config)}, func() {}, nil + return &aliyunOSSStorage{bucket: configuredBucket, client: aliyunoss.NewClient(config)}, func() {}, nil case "tencent_cos": - endpoint, _ := url.Parse(a.configPlain(ctx, keyPrefix+"endpoint", "")) + endpoint, err := url.Parse(a.configPlain(ctx, keyPrefix+"endpoint", "")) + if err != nil || endpoint.Host == "" || a.configPlain(ctx, keyPrefix+"secret_id", "") == "" || a.configPlain(ctx, keyPrefix+"secret_key", "") == "" { + return nil, func() {}, fmt.Errorf("腾讯云 COS 地址或凭证未配置") + } client := tencentcos.NewClient(&tencentcos.BaseURL{BucketURL: endpoint}, &http.Client{ Timeout: 30 * time.Second, Transport: &tencentcos.AuthorizationTransport{SecretID: a.configPlain(ctx, keyPrefix+"secret_id", ""), SecretKey: a.configPlain(ctx, keyPrefix+"secret_key", "")}, }) - return &tencentCOSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: client}, func() {}, nil + return &tencentCOSStorage{bucket: configuredBucket, client: client}, func() {}, nil case "qiniu": accessKey, secretKey := a.configPlain(ctx, keyPrefix+"access_key", ""), a.configPlain(ctx, keyPrefix+"secret_key", "") + if accessKey == "" || secretKey == "" { + return nil, func() {}, fmt.Errorf("七牛云凭证未配置") + } manager := qiniuuploader.NewUploadManager(&qiniuuploader.UploadManagerOptions{Options: qiniuhttpclient.Options{Credentials: qiniucredentials.NewCredentials(accessKey, secretKey), BasicHTTPClient: storageHTTPClient()}, MultiPartsThreshold: 8 << 20, PartSize: 4 << 20, Concurrency: 2}) - return &qiniuStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), uploader: manager, deleteMac: qiniuauth.NewMac(accessKey, secretKey)}, func() {}, nil + return &qiniuStorage{bucket: configuredBucket, uploader: manager, deleteMac: qiniuauth.NewMac(accessKey, secretKey)}, func() {}, nil case "huawei_obs", "huawei_flexus": + if a.configPlain(ctx, keyPrefix+"access_key", "") == "" || a.configPlain(ctx, keyPrefix+"secret_key", "") == "" { + return nil, func() {}, fmt.Errorf("%s凭证未配置", storageProviderName(provider)) + } client, err := huaweiobs.New(a.configPlain(ctx, keyPrefix+"access_key", ""), a.configPlain(ctx, keyPrefix+"secret_key", ""), a.configPlain(ctx, keyPrefix+"endpoint", ""), huaweiobs.WithConnectTimeout(5), huaweiobs.WithSocketTimeout(30), huaweiobs.WithMaxRetryCount(2)) if err != nil { return nil, func() {}, err } - return &huaweiOBSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: client}, client.Close, nil + return &huaweiOBSStorage{bucket: configuredBucket, client: client}, client.Close, nil default: - return nil, func() {}, nil + return nil, func() {}, fmt.Errorf("不支持的文件存储厂商 %q", provider) } } diff --git a/im/backend/migrations/032_message_membership_gate.sql b/im/backend/migrations/032_message_membership_gate.sql new file mode 100644 index 0000000..c1abeee --- /dev/null +++ b/im/backend/migrations/032_message_membership_gate.sql @@ -0,0 +1,5 @@ +-- 语音/图片消息是否仅限会员发送,两个开关都在管理端「系统配置」中修改。 +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('membership.voice_message_requires_vip','true','boolean','发送语音消息是否需要开通会员'), + ('membership.image_message_requires_vip','true','boolean','发送图片消息是否需要开通会员') +ON DUPLICATE KEY UPDATE description=VALUES(description); diff --git a/im/backend/migrations/033_chat_media_management.sql b/im/backend/migrations/033_chat_media_management.sql new file mode 100644 index 0000000..c64dc38 --- /dev/null +++ b/im/backend/migrations/033_chat_media_management.sql @@ -0,0 +1,65 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS im_message_media ( + message_id BIGINT UNSIGNED NOT NULL, + media_asset_id BIGINT UNSIGNED NULL, + media_type VARCHAR(20) NOT NULL, + public_url VARCHAR(500) NOT NULL DEFAULT '', + duration_ms INT UNSIGNED NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '0 deleted, 1 active, 2 deleting, 3 cleanup failed', + delete_reason VARCHAR(255) NOT NULL DEFAULT '', + cleanup_error VARCHAR(500) NOT NULL DEFAULT '', + deleted_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (message_id), + KEY idx_im_message_media_cleanup (status, created_at), + KEY idx_im_message_media_asset (media_asset_id), + KEY idx_im_message_media_type_created (media_type, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Associate media messages that predate this table with their immutable upload +-- record. A nullable asset id keeps the audit row visible even if historical +-- data was imported without a matching media_assets entry. +INSERT IGNORE INTO im_message_media ( + message_id, + media_asset_id, + media_type, + public_url, + duration_ms, + status, + created_at +) +SELECT + m.id, + ( + SELECT ma.id + FROM media_assets ma + WHERE ma.owner_user_id=m.sender_id + AND ma.public_url=JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(CONVERT(m.body USING utf8mb4)),CONVERT(m.body USING utf8mb4),'{}'), '$.url')) + AND ma.media_type=IF(m.message_type=2, 'image', 'audio') + ORDER BY ma.id DESC + LIMIT 1 + ), + IF(m.message_type=2, 'image', 'voice'), + JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(CONVERT(m.body USING utf8mb4)),CONVERT(m.body USING utf8mb4),'{}'), '$.url')), + IF( + m.message_type=3, + CAST(JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(CONVERT(m.body USING utf8mb4)),CONVERT(m.body USING utf8mb4),'{}'), '$.duration')) AS UNSIGNED) * 1000, + NULL + ), + 1, + m.created_at +FROM im_messages m +WHERE m.message_type IN (2,3) + AND JSON_VALID(CONVERT(m.body USING utf8mb4)) + AND JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(CONVERT(m.body USING utf8mb4)),CONVERT(m.body USING utf8mb4),'{}'), '$.url')) IS NOT NULL; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('im.chat_media_retention_enabled', 'false', 'boolean', '是否按保留天数自动清理聊天图片和语音文件'), + ('im.chat_media_retention_days', '90', 'number', '聊天图片和语音文件保留天数(1-3650)'), + ('im.chat_media_cleanup_last_at', '', 'string', '聊天媒体最近一次定期清理时间'), + ('im.chat_media_cleanup_last_result', '', 'string', '聊天媒体最近一次定期清理结果') +ON DUPLICATE KEY UPDATE + value_type=VALUES(value_type), + description=VALUES(description); diff --git a/im/deploy/im.bchongw.com_部署记录.md b/im/deploy/im.bchongw.com_部署记录.md index b801223..5bd0865 100644 --- a/im/deploy/im.bchongw.com_部署记录.md +++ b/im/deploy/im.bchongw.com_部署记录.md @@ -402,3 +402,27 @@ H5 的 OAuth 请求已兼容线上严格 JSON 解码:不发送空 `appProof` - 生产数据库中 100 条测试用户头像链接前后指纹一致,更新行数为 0;未修改 COS 配置、用户资料或其他媒体。 - 备份与验证记录位于 `/www/backup/xingyu-cos-avatars-thumbnails-20260901-110619`,目录仅 root 可读。迁移二进制 SHA-256 为 `9516326bb0cd9b3e5090ee0a567de164bbfa8644545b5c7021918ea56ff92695`。 - 此操作只新增静态对象,无需重启后端。客户端重新进入“附近”页后即可直接请求缩略图。 + +## 2026-09-03 语音上传兼容修复(10:20) + +已将手机录音上传兼容修复发布到 `im.bchongw.com`。服务端新增对无 ID3 标签、直接以 MPEG 音频帧开头的 MP3 录音识别,并兼容客户端显式传入 `purpose=voice`;原有图片、头像和普通媒体上传契约保持不变。 + +- 发布目录:`/www/server/xingyu-im/releases/voice-upload-20260903-102001`。 +- 回滚备份:`/www/backup/xingyu-voice-upload-20260903-102001/xingyu-api-original`。 +- 新后端 SHA-256:`ac1b0c9f5a48b2386fbecb604003fc97299951e580c97e02f8765e1e0228deee`。 +- 上一后端 SHA-256:`b250dd522a74a8812d392e5ed3ae17b2981c799978abf402770dfff1c7442086`。 +- 源码归档 SHA-256:`0dbc6fd23c2bd8e69cc0a15e21d3b1f5b60e7586c91dd20ef486a46071ad047b`。 +- 本次没有数据库迁移、业务数据写入、Nginx 改动或前端目录切换。 + +发布前完整执行 `go test ./...`,包含裸 MPEG Layer III 录音上传回归用例。发布后宝塔 Go 项目 PID 为 `58246`,磁盘文件与 `/proc` 运行映像摘要均为新版本;本机和公网健康检查返回 200,未认证媒体上传请求返回 401。 + +## 2026-09-03 聊天媒体查询修复(11:50) + +管理端“聊天媒体”页面已发布,但数据库漏执行 `033_chat_media_management.sql`,导致列表接口查询不存在的 `im_message_media` 表并返回“查询聊天媒体失败”。本次先完成整库 gzip 备份,再补执行该迁移。 + +- 数据库备份:`/www/backup/xingyu-chat-media-migration-20260903-115013/im.sql.gz`,备份目录权限 0700、文件权限 0600,gzip 完整性检查通过。 +- 迁移:`033_chat_media_management.sql`,SHA-256 `677c8dfe790e5996326f29967e41dba1f6a2a8018112be7eb42dc9fab1c600df`;迁移记录与工作区文件校验值一致。 +- 历史媒体消息 0 条,管理表 0 条;列表联表查询与统计查询均执行成功。 +- 自动清理保持关闭,默认保留天数为 90 天;本次未删除、覆盖或上传任何聊天文件。 +- 管理端发布目录仍为 `/www/wwwroot/xingyu-admin/releases/20260903-112753`。宝塔实际运行二进制 `/www/server/xingyu-im/bt/xingyu-api` SHA-256 为 `5a97957780900428cc106f2140d640d2d04963729eeeefb396fc8fffc32763a4`,已包含聊天媒体接口,与副站一致,无需重启。 +- 复核 `https://im.bchongw.com/healthz`、管理端入口均返回 200;未登录访问聊天媒体接口返回 401,确认路由与鉴权正常。 diff --git a/im/deploy/xim.bchongw.com_部署记录.md b/im/deploy/xim.bchongw.com_部署记录.md index 5a28ba4..9c64c1e 100644 --- a/im/deploy/xim.bchongw.com_部署记录.md +++ b/im/deploy/xim.bchongw.com_部署记录.md @@ -96,3 +96,27 @@ tail -n 200 /www/wwwlogs/go/xim_im.log ``` 首次登录后应在管理端修改管理员密码。短信、对象存储、第三方登录和正式支付渠道仍需在管理端填入实际厂商参数并分别完成联调;本次没有写入任何真实第三方密钥。 + +## 2026-09-03 语音上传兼容修复(10:21) + +已同步发布与 `im.bchongw.com` 完全相同的后端二进制。服务端新增对无 ID3 标签、直接以 MPEG 音频帧开头的 MP3 录音识别,并兼容客户端显式传入 `purpose=voice`;原有媒体上传接口与鉴权规则不变。 + +- 当前版本:`/www/server/xim-im/releases/voice-upload-20260903-102059`,`/www/server/xim-im/current` 已原子切换至该目录。 +- 回滚备份:`/www/backup/xim-voice-upload-20260903-102059`;上一版本目录 `/www/server/xim-im/releases/20260903-090411` 继续保留。 +- 新后端 SHA-256:`ac1b0c9f5a48b2386fbecb604003fc97299951e580c97e02f8765e1e0228deee`。 +- 上一后端 SHA-256:`b250dd522a74a8812d392e5ed3ae17b2981c799978abf402770dfff1c7442086`。 +- 源码归档 SHA-256:`0dbc6fd23c2bd8e69cc0a15e21d3b1f5b60e7586c91dd20ef486a46071ad047b`。 +- 本次没有数据库迁移、业务数据写入、Nginx 改动或管理端目录切换。 + +发布前完整执行 `go test ./...`。发布后宝塔 Go 项目 PID 为 `1523690`,磁盘文件与 `/proc` 运行映像摘要均为新版本;本机和公网健康检查返回 200,未认证媒体上传请求返回 401。两台接口服务器的运行二进制摘要一致。 + +## 2026-09-03 聊天媒体查询修复(11:50) + +管理端“聊天媒体”页面已发布,但数据库漏执行 `033_chat_media_management.sql`,导致列表接口查询不存在的 `im_message_media` 表并返回“查询聊天媒体失败”。本次先完成整库 gzip 备份,再补执行该迁移。 + +- 数据库备份:`/www/backup/xim-chat-media-migration-20260903-115006/xim.sql.gz`,备份目录权限 0700、文件权限 0600,gzip 完整性检查通过。 +- 迁移:`033_chat_media_management.sql`,SHA-256 `677c8dfe790e5996326f29967e41dba1f6a2a8018112be7eb42dc9fab1c600df`;迁移记录与工作区文件校验值一致。 +- 6 条历史图片/语音消息已全部回填至管理表,缺失 0 条;列表联表查询返回 6 条,统计查询为正常保存 6 条、已清理 0 条、清理失败 0 条,总文件大小 480098 字节。 +- 自动清理保持关闭,默认保留天数为 90 天;本次未删除、覆盖或上传任何聊天文件。 +- 管理端发布目录仍为 `/www/wwwroot/xim-admin/releases/20260903-112804`;后端 SHA-256 为 `5a97957780900428cc106f2140d640d2d04963729eeeefb396fc8fffc32763a4`,与主站实际运行版本一致,无需重启。 +- 复核 `https://xim.bchongw.com/healthz`、管理端入口均返回 200;未登录访问聊天媒体接口返回 401,确认路由与鉴权正常。