后端:资料距离、语音图片会员限制、免短信注册、AI 托管回复修复

- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和
  聊天头部因此无内容可显示。沿用同一套 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 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-04 10:00:08 +08:00
co-authored by Claude Opus 5
parent 842990b0e7
commit acd8933dbb
25 changed files with 1478 additions and 45 deletions
+5
View File
@@ -10,3 +10,8 @@ backend/uploads/
*.log
deploy/.env.production
deploy/certs/
# 发布中间产物:编译好的二进制、源码快照、Python 缓存,不进仓库。
**/__pycache__/
.deploy/xingyu-api-*
.deploy/xim-api-*
.deploy/backend-source-*.zip
+4 -1
View File
@@ -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
}
@@ -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, "查询托管账号失败")
+497
View File
@@ -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_at<? AND (status=1 OR (status=3 AND updated_at<?)) ORDER BY created_at,message_id LIMIT ?`, before, retryBefore, chatMediaBatchSize)
if err != nil {
return result, err
}
ids := []int64{}
for rows.Next() {
var id int64
if scanErr := rows.Scan(&id); scanErr != nil {
_ = rows.Close()
return result, scanErr
}
ids = append(ids, id)
}
if err = rows.Close(); err != nil {
return result, err
}
if len(ids) == 0 {
break
}
for _, id := range ids {
if ctx.Err() != nil {
return result, ctx.Err()
}
result.Scanned++
deleted, cleanupErr := a.cleanupChatMedia(ctx, id, "超过保留期限自动清理")
if cleanupErr != nil {
result.Failed++
continue
}
if deleted {
result.Deleted++
}
}
if len(ids) < chatMediaBatchSize {
break
}
}
return result, nil
}
func (a *App) recoverStuckChatMediaCleanups(ctx context.Context) {
_, _ = a.db.ExecContext(ctx, `UPDATE im_message_media SET status=3,cleanup_error='上次清理中断,可重试' WHERE status=2 AND updated_at<DATE_SUB(NOW(3),INTERVAL 10 MINUTE)`)
}
func (a *App) cleanupChatMedia(ctx context.Context, messageID int64, reason string) (bool, error) {
var currentStatus int
if err := a.db.QueryRowContext(ctx, `SELECT status FROM im_message_media WHERE message_id=?`, messageID).Scan(&currentStatus); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, fmt.Errorf("聊天媒体不存在")
}
return false, err
}
if currentStatus == 0 {
return false, nil
}
claim, err := a.db.ExecContext(ctx, `UPDATE im_message_media SET status=2,cleanup_error='' WHERE message_id=? AND status IN (1,3)`, messageID)
if err != nil {
return false, err
}
if affected, _ := claim.RowsAffected(); affected == 0 {
return false, fmt.Errorf("聊天媒体正在清理")
}
target := chatMediaTarget{MessageID: messageID}
if err = a.db.QueryRowContext(ctx, `SELECT mm.media_asset_id,mm.media_type,mm.public_url,m.conversation_id,m.seq,COALESCE(ma.storage_provider,''),COALESCE(ma.bucket,''),COALESCE(ma.object_key,''),COALESCE(ma.status,-1) FROM im_message_media mm JOIN im_messages m ON m.id=mm.message_id LEFT JOIN media_assets ma ON ma.id=mm.media_asset_id WHERE mm.message_id=?`, messageID).Scan(
&target.MediaAssetID, &target.MediaType, &target.PublicURL, &target.ConversationID, &target.Seq, &target.StorageProvider, &target.Bucket, &target.ObjectKey, &target.AssetStatus,
); err != nil {
a.failChatMediaCleanup(ctx, messageID, err)
return false, err
}
if !target.MediaAssetID.Valid || target.ObjectKey == "" {
err = fmt.Errorf("未找到关联的媒体上传记录,无法确认对象文件位置")
a.failChatMediaCleanup(ctx, messageID, err)
return false, err
}
if target.AssetStatus != 0 {
if used, useErr := a.mediaUsedOutsideChat(ctx, target.PublicURL); useErr != nil {
a.failChatMediaCleanup(ctx, messageID, useErr)
return false, useErr
} else if used {
err = fmt.Errorf("该文件仍被动态、头像或认证材料使用,已跳过删除")
a.failChatMediaCleanup(ctx, messageID, err)
return false, err
}
storage, closeStorage, storageErr := a.newMediaObjectStorageForProvider(ctx, target.StorageProvider, target.Bucket)
if storageErr != nil {
a.failChatMediaCleanup(ctx, messageID, storageErr)
return false, storageErr
}
defer closeStorage()
for _, key := range chatMediaObjectKeys(target.ObjectKey, target.MediaType) {
if deleteErr := storage.Delete(ctx, key); deleteErr != nil && !isMissingMediaObject(deleteErr) {
err = fmt.Errorf("删除对象 %s 失败: %w", filepath.Base(key), deleteErr)
a.failChatMediaCleanup(ctx, messageID, err)
return false, err
}
}
}
if err = a.finishChatMediaCleanup(ctx, target, reason); err != nil {
a.failChatMediaCleanup(ctx, messageID, err)
return false, err
}
return true, nil
}
func (a *App) mediaUsedOutsideChat(ctx context.Context, publicURL string) (bool, error) {
var used int
err := a.db.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM post_media WHERE media_url=?
UNION ALL SELECT 1 FROM user_profiles WHERE avatar_url=? OR cover_url=?
UNION ALL SELECT 1 FROM user_verifications WHERE JSON_CONTAINS(IF(JSON_VALID(evidence_json),evidence_json,'[]'),JSON_QUOTE(?))
)`, publicURL, publicURL, publicURL, publicURL).Scan(&used)
return used == 1, err
}
func (a *App) failChatMediaCleanup(ctx context.Context, messageID int64, cleanupErr error) {
_, _ = a.db.ExecContext(context.WithoutCancel(ctx), `UPDATE im_message_media SET status=3,cleanup_error=? WHERE message_id=? AND status=2`, chatMediaCleanupError(cleanupErr), messageID)
}
func (a *App) finishChatMediaCleanup(ctx context.Context, target chatMediaTarget, reason string) error {
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
events := []chatMediaEvent{}
rows, err := tx.QueryContext(ctx, `SELECT m.id,m.conversation_id,m.seq FROM im_message_media mm JOIN im_messages m ON m.id=mm.message_id WHERE mm.media_asset_id=? AND mm.status<>0`, 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()
}
}
}
@@ -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)
}
}
@@ -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})
}
+25 -6
View File
@@ -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
@@ -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)
}
}
+5
View File
@@ -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)},
+58 -16
View File
@@ -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<?`
query += ` AND m.seq<?`
args = append(args, beforeSeq)
}
if afterSeq > 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)
}
@@ -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)
}
})
}
@@ -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)
+28 -2
View File
@@ -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)
@@ -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)
+9 -1
View File
@@ -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})
}
@@ -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}
}
+14 -1
View File
@@ -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
}
@@ -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("开启后客户端必须要求验证码")
}
})
}
+15 -4
View File
@@ -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 {
@@ -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)
}
})
}
+41 -6
View File
@@ -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)
}
}
@@ -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);
@@ -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);
+24
View File
@@ -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,确认路由与鉴权正常。
+24
View File
@@ -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,确认路由与鉴权正常。