Files
tamabox/internal/db/vote_options.go
T
tamakyi 5fdc3df22d 修复: 评论组件泄露私密 token、cwebp 无超时、选项长度与去重
Waline(安全)
- item.html 的 path 从 window.location.href 改为剔除 ?t= 后的 URL。
  私密题 /_/<domain>/<id>?t=<token> 的钥匙原本会整条发往 Waline 服务端
  并落进其访问日志。只删 t、保留其他参数,公开题的评论线程键不变。

cwebp(可靠性)
- ConvertGifToAnimatedWebP 改用 exec.CommandContext + 30s 上限。
  原来裸 exec.Command 无超时,畸形/超大 GIF 会把子进程和上传 goroutine 一起挂住。
  超时杀进程并记 Warn,调用方按既有逻辑回退为原 GIF,不影响上传成功。

投票选项长度(正确性)
- vote_options.content 由 varchar(200) 放宽到 300,与配置默认值一致。
  此前 201–300 字的选项通过校验后在写库阶段失败。
- 新增 db.MaxVoteOptionLen;voteOptionMaxLen() 取配置与存储上限的较小值,
  校验与前端 maxlength 同源,避免配置调大后再次越界。

多选 checkbox 去重(正确性)
- 抽出 appendUniqueValue,预置值与「其他」文本共用同一 seen 集合。
  此前重复提交同名字段会让同一选项在统计里被计多次。

测试
- vote_dedupe_test.go:去重与顺序。
- vote_option_len_test.go:配置小于/等于/大于存储上限三种情形。
- TestVoteOptionMaxLengthRoundTrip:300 个多字节字符存回不截断。
- TestVoteOptionColumnWidens:把列改回 200 后 AutoMigrate 能扩回 300(老库升级路径)。

验证:WSL2 Debian + PostgreSQL 全量 go test ./... 27 个包全绿;
隔离实例渲染单题页确认新的 path 逻辑。
2026-11-05 17:04:47 +08:00

174 lines
5.7 KiB
Go

// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var VoteOptions VoteOptionsStore
type VoteOptionsStore interface {
Create(ctx context.Context, opts CreateVoteOptionOptions) (*VoteOption, error)
BulkCreate(ctx context.Context, voteID uint, contents []string) ([]*VoteOption, error)
ListByVoteID(ctx context.Context, voteID uint) ([]*VoteOption, error)
ReplaceByVoteID(ctx context.Context, voteID uint, contents []string) error
DeleteByVoteID(ctx context.Context, voteID uint) error
DeleteByID(ctx context.Context, id uint) error
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error)
}
func NewVoteOptionsStore(db *gorm.DB) VoteOptionsStore {
return &voteOptions{db}
}
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:300" json:"content"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
type CreateVoteOptionOptions struct {
VoteID uint
Content string
SortOrder int
}
func (db *voteOptions) Create(ctx context.Context, opts CreateVoteOptionOptions) (*VoteOption, error) {
option := &VoteOption{
VoteID: opts.VoteID,
Content: opts.Content,
SortOrder: opts.SortOrder,
}
if err := db.WithContext(ctx).Create(option).Error; err != nil {
return nil, errors.Wrap(err, "create vote option")
}
return option, nil
}
// BulkCreate 批量创建选项(用于箱主一次提交全部选项)。
func (db *voteOptions) BulkCreate(ctx context.Context, voteID uint, contents []string) ([]*VoteOption, error) {
options := make([]*VoteOption, 0, len(contents))
for i, content := range contents {
options = append(options, &VoteOption{
VoteID: voteID,
Content: content,
SortOrder: i,
})
}
if len(options) == 0 {
return options, nil
}
if err := db.WithContext(ctx).Create(&options).Error; err != nil {
return nil, errors.Wrap(err, "bulk create vote options")
}
return options, nil
}
// ListByVoteID 获取某投票的全部选项(按 SortOrder 升序,保证选项顺序稳定)。
func (db *voteOptions) ListByVoteID(ctx context.Context, voteID uint) ([]*VoteOption, error) {
var options []*VoteOption
if err := db.WithContext(ctx).
Where("vote_id = ?", voteID).
Order("sort_order ASC, id ASC").
Find(&options).Error; err != nil {
return nil, errors.Wrap(err, "list vote options by vote id")
}
return options, nil
}
// ReplaceByVoteID 在单个事务内替换投票的全部选项(先删后建),保证原子性。
// 仅在草稿状态编辑时调用(发布后不可改选项,避免破坏历史投票归因)。
func (db *voteOptions) ReplaceByVoteID(ctx context.Context, voteID uint, contents []string) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("vote_id = ?", voteID).Delete(&VoteOption{}).Error; err != nil {
return err
}
if len(contents) == 0 {
return nil
}
options := make([]*VoteOption, 0, len(contents))
for i, content := range contents {
options = append(options, &VoteOption{VoteID: voteID, Content: content, SortOrder: i})
}
return tx.Create(&options).Error
})
}
// DeleteByVoteID 删除投票时级联清理全部选项。
func (db *voteOptions) DeleteByVoteID(ctx context.Context, voteID uint) error {
if err := db.WithContext(ctx).Where("vote_id = ?", voteID).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options by vote id")
}
return nil
}
func (db *voteOptions) DeleteByID(ctx context.Context, id uint) error {
res := db.WithContext(ctx).Delete(&VoteOption{}, id)
if res.Error != nil {
return errors.Wrap(res.Error, "delete vote option")
}
if res.RowsAffected == 0 {
return ErrVoteOptionNotExist
}
return nil
}
var ErrVoteOptionNotExist = errors.New("投票选项不存在")
func (db *voteOptions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteOption{}).Where("vote_id = ?", voteID).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote options by vote id")
}
return count, nil
}
// CountByVoteIDs 一次查询批量统计多个投票的选项数(替代逐投票 N 次 COUNT,消除 N+1)。
func (db *voteOptions) CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error) {
out := make(map[uint]int64, len(voteIDs))
if len(voteIDs) == 0 {
return out, nil
}
type row struct {
VoteID uint
Cnt int64
}
var rows []row
if err := db.WithContext(ctx).Model(&VoteOption{}).
Select("vote_id, COUNT(id) AS cnt").
Where("vote_id IN ?", voteIDs).
Group("vote_id").
Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "count vote options by vote ids")
}
for _, r := range rows {
out[r.VoteID] = r.Cnt
}
return out, nil
}