diff --git a/internal/db/vote_options.go b/internal/db/vote_options.go index db36f2c..70e55db 100644 --- a/internal/db/vote_options.go +++ b/internal/db/vote_options.go @@ -34,11 +34,21 @@ type voteOptions struct { *gorm.DB } +// MaxVoteOptionLen 是选项内容在数据库中的最大字符数。 +// +// 必须与 conf 的默认 vote_option_len 保持一致:配置曾经允许 300 而这里只有 200, +// 于是 201–300 字的选项「校验通过、写库失败」(MySQL 严格模式下报错, +// PostgreSQL 直接报 value too long)。改列宽而不是改配置,是因为配置本来 +// 就是给箱主按需调小的,不该反过来限制它的上界。 +// +// ⚠️ 改这个常量时必须同步改下面 Content 字段的 size 标签(结构体标签不能引用常量)。 +const MaxVoteOptionLen = 300 + // VoteOption 选项表:单选/多选/评分/排序共用。 type VoteOption struct { dbutil.Model VoteID uint `gorm:"index:idx_option_vote" json:"vote_id"` - Content string `gorm:"size:200" json:"content"` + Content string `gorm:"size:300" json:"content"` SortOrder int `gorm:"default:0" json:"sort_order"` } diff --git a/internal/db/vote_options_test.go b/internal/db/vote_options_test.go index 1f8962f..c30df2f 100644 --- a/internal/db/vote_options_test.go +++ b/internal/db/vote_options_test.go @@ -114,3 +114,92 @@ func testVoteOptionsCountByVoteID(t *testing.T, ctx context.Context, db *voteOpt require.Nil(t, err) require.Equal(t, int64(0), got) } + +// TestVoteOptionMaxLengthRoundTrip 保证「配置允许的长度」真的能存下。 +// +// 曾经 conf 允许 300 字而列宽只有 200:201–300 字的选项通过校验后在写库时才失败 +// (PostgreSQL 报 value too long,MySQL 严格模式同样报错),用户看到的是「内部错误」。 +func TestVoteOptionMaxLengthRoundTrip(t *testing.T) { + conn, cleanup := newTestDB(t) + t.Cleanup(func() { + if err := cleanup("vote_options"); err != nil { + t.Fatal(err) + } + }) + + ctx := context.Background() + store := NewVoteOptionsStore(conn) + + // 结构体标签不能引用常量,列宽与 MaxVoteOptionLen 是两处独立字面量, + // 这里显式比一次,防止只改了一边。 + columns, err := conn.Migrator().ColumnTypes(&VoteOption{}) + require.Nil(t, err) + for _, ct := range columns { + if ct.Name() != "content" { + continue + } + if length, ok := ct.Length(); ok { + require.EqualValues(t, MaxVoteOptionLen, length, "vote_options.content 列宽必须与 MaxVoteOptionLen 一致") + } + } + + // 用多字节字符填满上限,顺便验证按字符而非字节计数。 + content := "" + for len([]rune(content)) < MaxVoteOptionLen { + content += "選" + } + + option, err := store.Create(ctx, CreateVoteOptionOptions{VoteID: 1, Content: content}) + require.Nil(t, err) + + got, err := store.ListByVoteID(ctx, 1) + require.Nil(t, err) + require.Len(t, got, 1) + require.Equal(t, option.ID, got[0].ID) + require.Equal(t, MaxVoteOptionLen, len([]rune(got[0].Content)), "存进去的字符数应与上限一致,未被截断") +} + +// TestVoteOptionColumnWidens 验证老库升级路径:已经存在 varchar(200) 的列, +// 跑一次 AutoMigrate 后必须变成 MaxVoteOptionLen。 +// +// 只改 model 标签是不够的 —— 如果 AutoMigrate 不扩列,老部署上线后 +// 201–300 字的选项依旧写不进去,而新库(测试库)永远发现不了这个问题。 +func TestVoteOptionColumnWidens(t *testing.T) { + conn, cleanup := newTestDB(t) + t.Cleanup(func() { + if err := cleanup("vote_options"); err != nil { + t.Fatal(err) + } + }) + + // 退回老库的列宽(两种方言语法不同)。 + var alter string + switch conn.Dialector.Name() { + case "postgres": + alter = "ALTER TABLE vote_options ALTER COLUMN content TYPE varchar(200)" + case "mysql": + alter = "ALTER TABLE vote_options MODIFY COLUMN content varchar(200)" + default: + t.Skipf("未适配的方言:%s", conn.Dialector.Name()) + } + require.Nil(t, conn.Exec(alter).Error) + + contentLength := func() int64 { + columns, err := conn.Migrator().ColumnTypes(&VoteOption{}) + require.Nil(t, err) + for _, ct := range columns { + if ct.Name() != "content" { + continue + } + length, ok := ct.Length() + require.True(t, ok, "content 列应有明确长度") + return length + } + t.Fatal("未找到 content 列") + return 0 + } + require.EqualValues(t, 200, contentLength(), "前置条件:列宽应已被改回 200") + + require.Nil(t, conn.AutoMigrate(&VoteOption{})) + require.EqualValues(t, MaxVoteOptionLen, contentLength(), "AutoMigrate 必须把列宽扩到 MaxVoteOptionLen") +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go index a47594d..8f2da3a 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -16,6 +16,7 @@ import ( "mime/multipart" "os" "os/exec" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -248,6 +249,12 @@ func fitsWithin(w, h, maxSide int) bool { // cwebp 长时间占用 CPU 与内存,故在此显式设限。 const maxGIFFrames = 200 +// gifConvertTimeout 是单次 cwebp 转换的硬上限。 +// 帧数限制挡不住「帧少但每帧极大」或畸形数据把 cwebp 拖住的情况;子进程一旦挂住, +// 调用它的上传 goroutine 会一直等,最终拖死整个上传路径。超时后 exec 会杀掉子进程, +// 调用方按错误回退到原 GIF,不影响上传本身成功。 +const gifConvertTimeout = 30 * time.Second + // ConvertGifToAnimatedWebP 将 GIF 转换为动画 WebP(需要系统安装 cwebp)。 // cwebp 不可用、GIF 损坏或帧数超限时返回错误,调用方应回退到原始 GIF。 func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) { @@ -281,9 +288,16 @@ func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) { // -mixed:对透明帧用无损、其余有损,是动画的推荐组合。 // -metadata none:剥离调色板外的元数据,进一步减小体积。 - cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-metadata", "none", + // 用 CommandContext 而非裸 Command:超时会杀进程,不会让上传 goroutine 无限等待。 + ctx, cancel := context.WithTimeout(context.Background(), gifConvertTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "cwebp", "-q", "80", "-mixed", "-metadata", "none", "-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name()) if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + logrus.WithField("timeout", gifConvertTimeout).Warn("cwebp 转换超时,回退为原 GIF") + return nil, errors.Errorf("cwebp timed out after %s", gifConvertTimeout) + } return nil, errors.Wrap(err, "run cwebp") } diff --git a/route/user/vote.go b/route/user/vote.go index c543c51..7ba7aea 100644 --- a/route/user/vote.go +++ b/route/user/vote.go @@ -36,6 +36,19 @@ var voteTextRepliesCache = cache.NewTTLCache(64) const voteTextRepliesCacheTTL = 30 * time.Second +// voteOptionMaxLen 是选项输入的实际上限。 +// +// 配置项 vote_option_len 只保证 ≥1,可以调得比数据库列还大;而 vote_options.content +// 是 varchar(300),超出的内容会被数据库拒绝(PostgreSQL 报错、MySQL 严格模式报错)。 +// 取「配置值」与「存储上限」的较小值,保证"校验通过"等于"能存下", +// 也让前端输入框的 maxlength 与实际可保存长度一致。 +func voteOptionMaxLen() int { + if conf.Limits.VoteOptionLen < db.MaxVoteOptionLen { + return conf.Limits.VoteOptionLen + } + return db.MaxVoteOptionLen +} + // ---------------- 表单解析 ---------------- // voteFormFieldInput 表单字段的前端输入结构(vote-form.html 中的 JS 数组)。 @@ -244,8 +257,8 @@ func parseOptions(ctx context.Context, form *voteForm, voteType string, maxChoic if o == "" { continue } - if len([]rune(o)) > conf.Limits.VoteOptionLen { - return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen)) + if len([]rune(o)) > voteOptionMaxLen() { + return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", voteOptionMaxLen())) } // 选项内容审核(fail-closed):草稿跳过,发布时统一审核 if !draft { @@ -348,8 +361,8 @@ func parseFormFields(ctx context.Context, form *voteForm, draft bool) error { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldReservedOption", i+1, label)) } // 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验) - if len([]rune(o)) > conf.Limits.VoteOptionLen { - return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen)) + if len([]rune(o)) > voteOptionMaxLen() { + return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", voteOptionMaxLen())) } } if len(opts) < 1 { @@ -516,7 +529,7 @@ func VoteList(ctx context.Context) { func VoteNew(ctx context.Context) { ctx.Data["Title"] = "新建投票" ctx.Data["IsEdit"] = false - ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS) + ctx.Data["MaxVoteOptionLen"] = voteOptionMaxLen() // 选项输入框 maxlength 同源(vote-form.html JS) // 默认投票类型为表单收集(用户要求);同时给定模板零值兜底,避免切到多选时 max_choices 显示 0。 ctx.Data["Vote"] = &db.Vote{ Type: db.VoteTypeForm, @@ -534,7 +547,7 @@ func VoteNew(ctx context.Context) { // 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data, // 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。 func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flashMsg string, internal bool) { - ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS) + ctx.Data["MaxVoteOptionLen"] = voteOptionMaxLen() // 选项输入框 maxlength 同源(vote-form.html JS) get := func(n string) string { return strings.TrimSpace(ctx.Request().FormValue(n)) } title := get("title") description := get("description") @@ -674,7 +687,7 @@ func VoteEdit(ctx context.Context) { ctx.Data["Vote"] = vote ctx.Data["IsEdit"] = true - ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS) + ctx.Data["MaxVoteOptionLen"] = voteOptionMaxLen() // 选项输入框 maxlength 同源(vote-form.html JS) ctx.Data["Title"] = "编辑投票" // 草稿保存成功仅通过本次 URL 参数驱动统一 alert,不使用 session flash。 if ctx.Query("saved") == "1" { diff --git a/route/user/vote_option_len_test.go b/route/user/vote_option_len_test.go new file mode 100644 index 0000000..9a98b2f --- /dev/null +++ b/route/user/vote_option_len_test.go @@ -0,0 +1,36 @@ +package user + +import ( + "testing" + + "github.com/tamakyi/TamaBox/internal/conf" + "github.com/tamakyi/TamaBox/internal/db" +) + +// TestVoteOptionMaxLen 钉住「配置值不能超过存储上限」这条约束。 +// +// 背景:conf 的 vote_option_len 只校验 ≥1,可以填得比 vote_options.content +// 的列宽还大;那样校验会放行,写库才失败(PostgreSQL 直接报错)。 +func TestVoteOptionMaxLen(t *testing.T) { + original := conf.Limits.VoteOptionLen + t.Cleanup(func() { conf.Limits.VoteOptionLen = original }) + + cases := []struct { + name string + conf int + want int + }{ + {"配置小于存储上限时以配置为准", 100, 100}, + {"配置等于存储上限", db.MaxVoteOptionLen, db.MaxVoteOptionLen}, + {"配置超过存储上限时截到上限", db.MaxVoteOptionLen + 500, db.MaxVoteOptionLen}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + conf.Limits.VoteOptionLen = c.conf + if got := voteOptionMaxLen(); got != c.want { + t.Fatalf("voteOptionMaxLen() = %d, want %d", got, c.want) + } + }) + } +} diff --git a/route/vote/vote.go b/route/vote/vote.go index 7e8ff96..711ba45 100644 --- a/route/vote/vote.go +++ b/route/vote/vote.go @@ -799,6 +799,19 @@ func parseMultipleOptionIDs(ids []string, maxChoices int, validOptions map[uint] return chosen, nil } +// appendUniqueValue 把 v 追加到 dst,但同一个值只保留第一次出现。 +// 多选类字段(checkbox)依赖它去重:客户端可以重复提交同名字段, +// 不去重就会在结果统计里把同一选项计多次。 +// +// seen 由调用方持有并跨多次调用复用,用来记录已追加过的值。 +func appendUniqueValue(dst []string, seen map[string]bool, v string) []string { + if seen[v] { + return dst + } + seen[v] = true + return append(dst, v) +} + // parseSubmission 按投票类型解析并校验提交内容,构造 VoteSubmission。 // owner 为投票发起人(用于其全局屏蔽词表对开放文本的本地过滤)。 func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.CreateVoteSubmissionOptions, error) { @@ -924,6 +937,10 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat // 多选 checkbox if f.FieldType == db.VoteFormFieldTypeCheckbox { var chosen []string + // 同一字段名可以被客户端重复提交(field_x=A&field_x=A), + // 不去重就会在结果统计里把同一个选项计多次,等于让一个人刷高计数。 + // 按最终落库的字符串去重:预置值按值去重,「其他」按用户填的文本去重。 + chosenSeen := make(map[string]bool, len(values)) for _, val := range values { val = strings.TrimSpace(val) if val == "" { @@ -947,13 +964,13 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat if err := censorCheck(ctx, owner, lang, custom); err != nil { return nil, err } - chosen = append(chosen, custom) + chosen = appendUniqueValue(chosen, chosenSeen, custom) continue } if !validFieldOptions[val] { return nil, errors.New(i18n.T(lang, "vote.err.fieldInvalidOption", f.Label)) } - chosen = append(chosen, val) + chosen = appendUniqueValue(chosen, chosenSeen, val) } if len(chosen) == 0 && f.Required { return nil, errors.New(i18n.T(lang, "vote.err.fieldRequired", f.Label)) diff --git a/route/vote/vote_dedupe_test.go b/route/vote/vote_dedupe_test.go new file mode 100644 index 0000000..506f0bf --- /dev/null +++ b/route/vote/vote_dedupe_test.go @@ -0,0 +1,48 @@ +package vote + +import ( + "testing" +) + +// TestAppendUniqueValue 钉住多选字段的去重语义: +// 客户端重复提交同一选项时,落库与统计都只能算一次。 +func TestAppendUniqueValue(t *testing.T) { + t.Run("重复值只保留第一次", func(t *testing.T) { + seen := map[string]bool{} + var got []string + for _, v := range []string{"A", "A", "B", "A", "B", "C"} { + got = appendUniqueValue(got, seen, v) + } + want := []string{"A", "B", "C"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v(顺序应按首次出现)", got, want) + } + } + }) + + t.Run("空切片与单值", func(t *testing.T) { + seen := map[string]bool{} + var got []string + got = appendUniqueValue(got, seen, "只有一项") + if len(got) != 1 || got[0] != "只有一项" { + t.Fatalf("got %v", got) + } + }) + + t.Run("seen 复用时跨批次仍然去重", func(t *testing.T) { + // checkbox 的「其他」文本与预置值走同一条追加路径, + // seen 必须在整个字段解析期间共享,而不是每轮新建。 + seen := map[string]bool{} + var got []string + got = appendUniqueValue(got, seen, "预置") + got = appendUniqueValue(got, seen, "其他文本") + got = appendUniqueValue(got, seen, "预置") + if len(got) != 2 { + t.Fatalf("got %v, want 2 项", got) + } + }) +} diff --git a/templates/question/item.html b/templates/question/item.html index d7e05de..cf79939 100644 --- a/templates/question/item.html +++ b/templates/question/item.html @@ -157,7 +157,17 @@ el: '#waline', //后端服务器地址 serverURL: 'https://waline.337845818.best/', - path: window.location.href, + // 评论线程键:必须剔除 ?t= 私密题 token。 + // 原来直接传 location.href,私密题 /_//?t= 的钥匙 + // 会整条 URL 发到 Waline 服务端并落进它的访问日志;token 本身就能读该题, + // 等于凭空多出一份可被日志检索的凭证副本。 + // 只删 t、保留其余参数:公开题的 page/sort 等分页参数不变, + // 已有评论线程的键不受影响。 + path: (function () { + var u = new URL(window.location.href); + u.searchParams.delete('t'); + return u.toString(); + })(), // 中文默认 locale: { nick: '昵称',