Files
kefu/im/backend/internal/app/admin_chat_media.go
T
Your NameandClaude Opus 5 acd8933dbb 后端:资料距离、语音图片会员限制、免短信注册、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>
2026-09-04 10:00:08 +08:00

498 lines
19 KiB
Go

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()
}
}
}