- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和 聊天头部因此无内容可显示。沿用同一套 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>
272 lines
9.8 KiB
Go
272 lines
9.8 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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
|
|
}
|
|
lower := strings.ToLower(source)
|
|
if strings.HasSuffix(lower, "-im1-thumb.jpg") {
|
|
return source
|
|
}
|
|
for _, extension := range []string{".jpeg", ".jpg", ".png", ".webp", ".gif"} {
|
|
if strings.HasSuffix(lower, "-im1"+extension) {
|
|
return source[:len(source)-len(extension)] + "-thumb.jpg"
|
|
}
|
|
}
|
|
return source
|
|
}
|
|
|
|
func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
|
// Multipart boundaries and headers add a small amount of overhead. Keep the
|
|
// actual file limit at 16 MiB without rejecting a file that is exactly at
|
|
// that limit solely because of the multipart envelope.
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
|
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "媒体文件不得超过 16MB")
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
purpose := r.FormValue("purpose")
|
|
if purpose != "" && purpose != "avatar" && purpose != "voice" {
|
|
fail(w, http.StatusBadRequest, 20001, "上传用途无效")
|
|
return
|
|
}
|
|
|
|
file, fileHeader, err := r.FormFile("file")
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "请选择媒体文件")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
size, err := file.Seek(0, io.SeekEnd)
|
|
if err != nil || size <= 0 || size > maxUploadBytes {
|
|
fail(w, http.StatusBadRequest, 20001, "媒体文件大小无效或超过 16MB")
|
|
return
|
|
}
|
|
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件")
|
|
return
|
|
}
|
|
header := make([]byte, min(size, 512))
|
|
read, err := io.ReadFull(file, header)
|
|
if err != nil && err != io.ErrUnexpectedEOF {
|
|
fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件")
|
|
return
|
|
}
|
|
header = header[:read]
|
|
extensions := map[string]string{
|
|
"image/gif": ".gif",
|
|
"image/jpeg": ".jpg",
|
|
"image/png": ".png",
|
|
"image/webp": ".webp",
|
|
"audio/mpeg": ".mp3",
|
|
"audio/mp3": ".mp3",
|
|
"audio/wav": ".wav",
|
|
"audio/x-wav": ".wav",
|
|
"audio/amr": ".amr",
|
|
"audio/mp4": ".m4a",
|
|
}
|
|
contentType := detectUploadContentType(header)
|
|
extension, ok := extensions[contentType]
|
|
if !ok {
|
|
fail(w, http.StatusBadRequest, 20001, "仅支持常见图片或语音格式")
|
|
return
|
|
}
|
|
|
|
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件")
|
|
return
|
|
}
|
|
if err = a.validateStorageProviderConfig(r.Context()); err != nil {
|
|
fail(w, http.StatusServiceUnavailable, 50001, "文件存储尚未正确配置")
|
|
return
|
|
}
|
|
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)
|
|
if err != nil {
|
|
if errors.Is(err, errAvatarBusy) {
|
|
w.Header().Set("Retry-After", "2")
|
|
fail(w, http.StatusServiceUnavailable, 50001, strings.ReplaceAll(err.Error(), "头像", "图片"))
|
|
} else {
|
|
fail(w, http.StatusBadRequest, 20001, strings.ReplaceAll(err.Error(), "头像", "图片"))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if purpose == "avatar" {
|
|
if !isImage {
|
|
fail(w, http.StatusBadRequest, 20001, "头像必须是图片")
|
|
return
|
|
}
|
|
contentType, extension, size = "image/jpeg", "-av1.jpg", int64(len(imageVariants.Display))
|
|
} else if isImage {
|
|
// Mark image uploads that have an immutable thumbnail sibling. The main
|
|
// object remains byte-for-byte original for detail and preview screens.
|
|
extension = "-im1" + originalExtension
|
|
}
|
|
|
|
provider := a.configPlain(r.Context(), "storage.provider", "local")
|
|
name := fmt.Sprintf("%d-%d%s", current(r).ID, time.Now().UnixNano(), extension)
|
|
objectKey := name
|
|
if provider != "local" {
|
|
prefix := strings.Trim(a.configPlain(r.Context(), "storage.object_prefix", "media"), "/")
|
|
objectKey = fmt.Sprintf("%s/%s/%s", prefix, time.Now().Format("2006/01"), name)
|
|
}
|
|
mediaType := "audio"
|
|
if strings.HasPrefix(contentType, "image/") {
|
|
mediaType = "image"
|
|
}
|
|
|
|
var bucket, publicURL string
|
|
var storage mediaObjectStorage
|
|
closeStorage := func() {}
|
|
if provider == "local" {
|
|
storage = localMediaStorage{directory: a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)}
|
|
baseURL := strings.TrimSpace(a.configPlain(r.Context(), "storage.local.public_base_url", ""))
|
|
if baseURL == "" {
|
|
scheme := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0])
|
|
if scheme != "https" {
|
|
scheme = "http"
|
|
}
|
|
baseURL = fmt.Sprintf("%s://%s/uploads", scheme, r.Host)
|
|
}
|
|
publicURL = storagePublicURL(baseURL, objectKey)
|
|
} else {
|
|
storage, closeStorage, err = a.newMediaObjectStorage(r.Context())
|
|
if err != nil {
|
|
log.Printf("storage provider init failed provider=%s error=%v", provider, err)
|
|
fail(w, http.StatusServiceUnavailable, 50001, "文件存储连接初始化失败")
|
|
return
|
|
}
|
|
defer closeStorage()
|
|
bucket = storage.Bucket()
|
|
publicURL = storagePublicURL(a.configPlain(r.Context(), "storage."+provider+".public_base_url", ""), objectKey)
|
|
}
|
|
if len(publicURL) > 500 || len(objectKey) > 500 {
|
|
fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长")
|
|
return
|
|
}
|
|
objects := []mediaUploadObject{{Key: objectKey, ContentType: contentType, Size: size, Body: file}}
|
|
var thumbnailURL, originalURL string
|
|
if isImage {
|
|
mainExtension := filepath.Ext(objectKey)
|
|
stem := strings.TrimSuffix(objectKey, mainExtension)
|
|
urlStem := strings.TrimSuffix(publicURL, mainExtension)
|
|
thumbnailURL, originalURL = urlStem+"-thumb.jpg", urlStem+"-original"+originalExtension
|
|
if len(thumbnailURL) > 500 || len(stem+"-thumb.jpg") > 500 || (purpose == "avatar" && (len(originalURL) > 500 || len(stem+"-original"+originalExtension) > 500)) {
|
|
fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长")
|
|
return
|
|
}
|
|
// Publish the primary URL only once its thumbnail is available.
|
|
if purpose == "avatar" {
|
|
objects = []mediaUploadObject{
|
|
{Key: stem + "-original" + originalExtension, ContentType: originalType, Size: originalSize, Body: file},
|
|
{Key: stem + "-thumb.jpg", ContentType: "image/jpeg", Size: int64(len(imageVariants.Thumbnail)), Body: bytes.NewReader(imageVariants.Thumbnail)},
|
|
{Key: objectKey, ContentType: contentType, Size: size, Body: bytes.NewReader(imageVariants.Display)},
|
|
}
|
|
} else {
|
|
originalURL = ""
|
|
objects = []mediaUploadObject{
|
|
{Key: stem + "-thumb.jpg", ContentType: "image/jpeg", Size: int64(len(imageVariants.Thumbnail)), Body: bytes.NewReader(imageVariants.Thumbnail)},
|
|
{Key: objectKey, ContentType: originalType, Size: originalSize, Body: file},
|
|
}
|
|
}
|
|
}
|
|
|
|
result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, current(r).ID, mediaType, provider, bucket, objectKey, contentType, size)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建媒体记录失败")
|
|
return
|
|
}
|
|
mediaID, _ := result.LastInsertId()
|
|
cleanupRecord := func() {
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
|
defer cancel()
|
|
_, _ = a.db.ExecContext(ctx, `DELETE FROM media_assets WHERE id=? AND status=0`, mediaID)
|
|
}
|
|
|
|
cleanupObjects, err := putMediaObjects(r.Context(), storage, objects)
|
|
if err != nil {
|
|
cleanupRecord()
|
|
log.Printf("media upload failed provider=%s bucket=%s object=%s original=%q error=%v", provider, bucket, objectKey, filepath.Base(fileHeader.Filename), err)
|
|
fail(w, http.StatusBadGateway, 50001, "文件上传失败,请检查存储配置后重试")
|
|
return
|
|
}
|
|
if _, err = a.db.ExecContext(r.Context(), `UPDATE media_assets SET public_url=?,status=1 WHERE id=? AND status=0`, publicURL, mediaID); err != nil {
|
|
cleanupObjects()
|
|
cleanupRecord()
|
|
fail(w, http.StatusInternalServerError, 50001, "完成媒体记录失败")
|
|
return
|
|
}
|
|
reply(w, map[string]any{
|
|
"id": mediaID,
|
|
"name": name,
|
|
"url": publicURL,
|
|
"provider": provider,
|
|
"objectKey": objectKey,
|
|
"thumbnailUrl": thumbnailURL,
|
|
"originalUrl": originalURL,
|
|
})
|
|
}
|
|
|
|
func (a *App) serveMedia(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
name := parts[len(parts)-1]
|
|
if !mediaNamePattern.MatchString(name) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)
|
|
http.ServeFile(w, r, filepath.Join(directory, filepath.Base(name)))
|
|
}
|