后端:资料距离、语音图片会员限制、免短信注册、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
+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)
}
}