- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和 聊天头部因此无内容可显示。沿用同一套 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>
241 lines
8.9 KiB
Go
241 lines
8.9 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type mediaUploadDB struct {
|
|
directory string
|
|
inserted, finalized, deleted, failFinalize bool
|
|
publicURL, mime string
|
|
size int64
|
|
}
|
|
type mediaUploadConnector struct{ db *mediaUploadDB }
|
|
|
|
func (c mediaUploadConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil }
|
|
func (c mediaUploadConnector) Driver() driver.Driver { return oauthTestDriver{} }
|
|
func (*mediaUploadDB) Prepare(string) (driver.Stmt, error) {
|
|
return nil, errors.New("unexpected prepare")
|
|
}
|
|
func (*mediaUploadDB) Begin() (driver.Tx, error) { return nil, errors.New("unexpected transaction") }
|
|
func (*mediaUploadDB) Close() error { return nil }
|
|
func (s *mediaUploadDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
|
if !strings.HasPrefix(query, "SELECT config_value,value_type FROM system_configs") {
|
|
return nil, errors.New("unexpected query")
|
|
}
|
|
values := map[string]string{"storage.provider": "local", "storage.object_prefix": "media", "storage.local.directory": s.directory, "storage.local.public_base_url": "https://media.example.com/uploads"}
|
|
value, ok := values[args[0].Value.(string)]
|
|
if !ok {
|
|
return &oauthTestRows{columns: []string{"config_value", "value_type"}}, nil
|
|
}
|
|
return oauthRow(value, "text"), nil
|
|
}
|
|
func (s *mediaUploadDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
|
switch {
|
|
case strings.HasPrefix(query, "INSERT INTO media_assets"):
|
|
s.inserted = true
|
|
s.mime = args[5].Value.(string)
|
|
s.size = args[6].Value.(int64)
|
|
return provisioningInsertResult{}, nil
|
|
case strings.HasPrefix(query, "UPDATE media_assets SET public_url="):
|
|
if s.failFinalize {
|
|
return nil, errors.New("finalization unavailable")
|
|
}
|
|
s.publicURL = args[0].Value.(string)
|
|
s.finalized = true
|
|
case strings.HasPrefix(query, "DELETE FROM media_assets"):
|
|
s.deleted = true
|
|
default:
|
|
return nil, errors.New("unexpected exec")
|
|
}
|
|
return driver.RowsAffected(1), nil
|
|
}
|
|
|
|
func uploadTestApp(t *testing.T) (*App, *mediaUploadDB) {
|
|
t.Helper()
|
|
store := &mediaUploadDB{directory: t.TempDir()}
|
|
db := sql.OpenDB(mediaUploadConnector{store})
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return &App{db: db, config: Config{MediaDir: store.directory, Environment: "development"}}, store
|
|
}
|
|
func uploadTestRequest(t *testing.T, a *App, source []byte, purpose string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("file", "camera.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = part.Write(source); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if purpose != "" {
|
|
if err = writer.WriteField("purpose", purpose); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err = writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "/api/v1/media/upload", &body)
|
|
r.Header.Set("Content-Type", writer.FormDataContentType())
|
|
r = r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{ID: 42, Role: "user"}))
|
|
w := httptest.NewRecorder()
|
|
a.uploadMedia(w, r)
|
|
return w
|
|
}
|
|
|
|
func TestAvatarUploadStoresAndServesAllVariants(t *testing.T) {
|
|
a, store := uploadTestApp(t)
|
|
source := avatarPNG(t, 1000, 800)
|
|
w := uploadTestRequest(t, a, source, "avatar")
|
|
if w.Code != 200 {
|
|
t.Fatalf("upload failed: %s", w.Body.String())
|
|
}
|
|
var payload struct {
|
|
Data struct {
|
|
URL string `json:"url"`
|
|
ThumbnailURL string `json:"thumbnailUrl"`
|
|
OriginalURL string `json:"originalUrl"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data := payload.Data
|
|
if !store.inserted || !store.finalized || store.publicURL != data.URL || store.mime != "image/jpeg" {
|
|
t.Fatal("media record does not identify the display image")
|
|
}
|
|
for _, variant := range []struct {
|
|
url string
|
|
max int
|
|
}{{data.URL, 640}, {data.ThumbnailURL, 256}, {data.OriginalURL, 0}} {
|
|
u, err := url.Parse(variant.url)
|
|
if err != nil || !mediaNamePattern.MatchString(filepath.Base(u.Path)) {
|
|
t.Fatal("invalid variant URL", variant.url)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(store.directory, filepath.Base(u.Path)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if variant.max == 0 {
|
|
if !bytes.Equal(content, source) {
|
|
t.Fatal("original upload was not preserved")
|
|
}
|
|
} else {
|
|
cfg, format, err := image.DecodeConfig(bytes.NewReader(content))
|
|
if err != nil || format != "jpeg" || cfg.Width != variant.max || cfg.Height > variant.max {
|
|
t.Fatal("invalid variant dimensions", err)
|
|
}
|
|
if variant.url == data.URL && store.size != int64(len(content)) {
|
|
t.Fatal("DB file_size does not match uploaded JPEG")
|
|
}
|
|
}
|
|
response := httptest.NewRecorder()
|
|
a.serveMedia(response, httptest.NewRequest(http.MethodGet, u.Path, nil))
|
|
if response.Code != 200 || !bytes.Equal(response.Body.Bytes(), content) || !strings.Contains(response.Header().Get("Cache-Control"), "immutable") {
|
|
t.Fatal("variant is not publicly cacheable")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAvatarUploadFinalizationFailureCleansEveryObject(t *testing.T) {
|
|
a, store := uploadTestApp(t)
|
|
store.failFinalize = true
|
|
w := uploadTestRequest(t, a, avatarPNG(t, 300, 200), "avatar")
|
|
files, err := os.ReadDir(store.directory)
|
|
if w.Code != 500 || !store.deleted || store.finalized || err != nil || len(files) != 0 {
|
|
t.Fatal("failed upload retained objects or an active record", w.Code, err, len(files))
|
|
}
|
|
}
|
|
|
|
func TestOrdinaryMediaUploadPreservesOriginal(t *testing.T) {
|
|
a, store := uploadTestApp(t)
|
|
source := avatarPNG(t, 800, 400)
|
|
w := uploadTestRequest(t, a, source, "")
|
|
if w.Code != 200 || store.mime != "image/png" || store.size != int64(len(source)) {
|
|
t.Fatal("ordinary image upload changed", w.Body.String())
|
|
}
|
|
var payload struct {
|
|
Data struct {
|
|
URL string `json:"url"`
|
|
ThumbnailURL string `json:"thumbnailUrl"`
|
|
OriginalURL string `json:"originalUrl"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload.Data.ThumbnailURL == "" || payload.Data.OriginalURL != "" || !strings.Contains(payload.Data.URL, "-im1.png") {
|
|
t.Fatal("ordinary image did not publish a bounded list thumbnail")
|
|
}
|
|
files, err := os.ReadDir(store.directory)
|
|
if err != nil || len(files) != 2 {
|
|
t.Fatal("unexpected variants on ordinary media")
|
|
}
|
|
mainURL, _ := url.Parse(payload.Data.URL)
|
|
data, err := os.ReadFile(filepath.Join(store.directory, filepath.Base(mainURL.Path)))
|
|
if err != nil || !bytes.Equal(data, source) {
|
|
t.Fatal("ordinary image was recompressed")
|
|
}
|
|
thumbnailURL, _ := url.Parse(payload.Data.ThumbnailURL)
|
|
thumbnail, err := os.ReadFile(filepath.Join(store.directory, filepath.Base(thumbnailURL.Path)))
|
|
config, format, decodeErr := image.DecodeConfig(bytes.NewReader(thumbnail))
|
|
if err != nil || decodeErr != nil || format != "jpeg" || config.Width > 256 || config.Height > 256 {
|
|
t.Fatal("ordinary image thumbnail is invalid", err, decodeErr)
|
|
}
|
|
}
|
|
|
|
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)
|
|
w := uploadTestRequest(t, a, []byte("GIF89a\x01\x00\x01\x00broken"), purpose)
|
|
if w.Code != 400 || store.inserted {
|
|
t.Fatal("invalid upload created media records", w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMediaThumbnailURLOnlyRewritesVersionedImages(t *testing.T) {
|
|
tests := map[string]string{
|
|
"https://cdn.example.com/media/42-1-im1.png": "https://cdn.example.com/media/42-1-im1-thumb.jpg",
|
|
"https://cdn.example.com/media/42-1-im1-thumb.jpg": "https://cdn.example.com/media/42-1-im1-thumb.jpg",
|
|
"https://cdn.example.com/media/legacy.png": "https://cdn.example.com/media/legacy.png",
|
|
"https://cdn.example.com/media/42-1-im1.png?signature=x": "https://cdn.example.com/media/42-1-im1.png?signature=x",
|
|
}
|
|
for source, want := range tests {
|
|
if got := mediaThumbnailURL(source); got != want {
|
|
t.Errorf("mediaThumbnailURL(%q) = %q, want %q", source, got, want)
|
|
}
|
|
}
|
|
}
|