chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2022 E99p1ant. 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"
"encoding/json"
"time"
"github.com/pkg/errors"
"github.com/wuhan005/gadget"
"gorm.io/gorm"
)
var CensorLogs CensorLogsStore
type CensorLogsStore interface {
GetByText(ctx context.Context, sourceName, text string, noLongerThan ...time.Time) (*CensorLog, error)
Create(ctx context.Context, options CreateCensorLogOptions) error
}
func NewCensorLogsStore(db *gorm.DB) CensorLogsStore {
return &censorLogs{db}
}
type censorLogs struct {
*gorm.DB
}
type CensorLog struct {
gorm.Model
SourceName string
Input string
InputHash string `gorm:"index"`
Pass bool
RawResponse json.RawMessage
}
var ErrCensorLogsNotFound = errors.New("censor logs dose not exist")
func (db *censorLogs) GetByText(ctx context.Context, sourceName, text string, noLongerThan ...time.Time) (*CensorLog, error) {
hash := hashText(text)
var censorLog CensorLog
q := db.WithContext(ctx).Where("source_name = ? AND input_hash = ?", sourceName, hash)
if len(noLongerThan) > 0 && !noLongerThan[0].IsZero() {
q = q.Where("created_at > ?", noLongerThan[0])
}
if err := q.First(&censorLog).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCensorLogsNotFound
}
return nil, err
}
return &censorLog, nil
}
type CreateCensorLogOptions struct {
SourceName string
Input string
Pass bool
RawResponse json.RawMessage
}
func (db *censorLogs) Create(ctx context.Context, options CreateCensorLogOptions) error {
censorLog := CensorLog{
SourceName: options.SourceName,
Input: options.Input,
InputHash: hashText(options.Input),
Pass: options.Pass,
RawResponse: options.RawResponse,
}
return db.WithContext(ctx).Create(&censorLog).Error
}
func hashText(text string) string {
return gadget.Md5(text)
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2023 E99p1ant. 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"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestCensorLogs(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
censorLogsStore := NewCensorLogsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *censorLogs)
}{
{"Create", testCensorLogsCreate},
{"GetByText", testCensorLogsGetByText},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("censor_logs"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, censorLogsStore.(*censorLogs))
})
}
}
func testCensorLogsCreate(t *testing.T, ctx context.Context, db *censorLogs) {
err := db.Create(ctx, CreateCensorLogOptions{
SourceName: "aliyun",
Input: "hello world",
Pass: true,
RawResponse: []byte(`{}`),
})
require.Nil(t, err)
}
func testCensorLogsGetByText(t *testing.T, ctx context.Context, db *censorLogs) {
err := db.Create(ctx, CreateCensorLogOptions{
SourceName: "aliyun",
Input: "hello world",
Pass: true,
RawResponse: []byte(`{}`),
})
require.Nil(t, err)
t.Run("normal by cache", func(t *testing.T) {
got, err := db.GetByText(ctx, "aliyun", "hello world")
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &CensorLog{
Model: gorm.Model{
ID: 1,
},
SourceName: "aliyun",
Input: "hello world",
InputHash: hashText("hello world"),
Pass: true,
RawResponse: []byte(`{}`),
}
require.Equal(t, want, got)
})
t.Run("no longer than", func(t *testing.T) {
_, err := db.GetByText(ctx, "aliyun", "hello world", time.Now())
require.Equal(t, ErrCensorLogsNotFound, err)
})
}
+231
View File
@@ -0,0 +1,231 @@
// Copyright 2022 E99p1ant. 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"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Conversations ConversationStore
type ConversationStore interface {
Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error)
GetByID(ctx context.Context, id uint) (*Conversation, error)
GetByToken(ctx context.Context, token string) (*Conversation, error)
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error)
GetByIDAndAsker(ctx context.Context, id, askerID uint) (*Conversation, error)
IncrementMessageCount(ctx context.Context, id uint) error
IncrementMessageCountIfBelow(ctx context.Context, id uint, max int) (bool, error)
DecrementMessageCount(ctx context.Context, id uint) error
MaybeClose(ctx context.Context, id uint, max int) error
Close(ctx context.Context, id uint) error
// ListByOwner 返回某箱主拥有的会话。limit>0 时启用分页(取 limit 条、跳过 offset 条),
// 用于私聊列表分页;导出场景传 limit=0 取全量,行为与改造前一致。
ListByOwner(ctx context.Context, ownerID uint, limit, offset int) ([]*Conversation, error)
// CountByOwner 统计某箱主拥有的会话总数(分页看板与"加载更多"判定用)。
CountByOwner(ctx context.Context, ownerID uint) (int64, error)
// ListByAsker 返回某提问者发起的会话;分页语义同 ListByOwner。
ListByAsker(ctx context.Context, askerID uint, limit, offset int) ([]*Conversation, error)
// CountByAsker 统计某提问者发起的会话总数。
CountByAsker(ctx context.Context, askerID uint) (int64, error)
MarkDeletedByOwner(ctx context.Context, id uint) error
}
func NewConversationStore(db *gorm.DB) ConversationStore {
return &conversations{db}
}
type conversations struct {
*gorm.DB
}
type Conversation struct {
dbutil.Model
OwnerID uint `gorm:"index:idx_conversation_owner" json:"owner_id"`
AskerID uint `gorm:"index:idx_conversation_asker" json:"asker_id"`
Token string `gorm:"size:6;uniqueIndex" json:"token"`
MaxMessages int `gorm:"default:20" json:"max_messages"`
MessageCount int `gorm:"default:0" json:"message_count"`
IsClosed bool `gorm:"default:false" json:"is_closed"`
IsDeletedByOwner bool `gorm:"default:false" json:"is_deleted_by_owner"` // 新增
}
func (c *Conversation) BeforeCreate(tx *gorm.DB) error {
c.MessageCount = 0
c.Token = randstr.String(6)
return nil
}
type CreateConversationOptions struct {
OwnerID uint
AskerID uint
MaxMessages int
}
func (db *conversations) Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) {
conversation := &Conversation{
OwnerID: opts.OwnerID,
AskerID: opts.AskerID,
MaxMessages: opts.MaxMessages,
}
if err := db.WithContext(ctx).Create(conversation).Error; err != nil {
return nil, errors.Wrap(err, "create conversation")
}
return conversation, nil
}
func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
return nil, errors.Wrap(err, "get conversation")
}
return &conv, nil
}
func (db *conversations) GetByToken(ctx context.Context, token string) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("token = ?", token).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get conversation by token")
}
return &conv, nil
}
func (db *conversations) GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("id = ? AND owner_id = ?", id, ownerID).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get by owner")
}
return &conv, nil
}
func (db *conversations) GetByIDAndAsker(ctx context.Context, id, askerID uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("id = ? AND asker_id = ?", id, askerID).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get by asker")
}
return &conv, nil
}
func (db *conversations) IncrementMessageCount(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error
}
// IncrementMessageCountIfBelow 原子地检查并递增消息计数:
// 仅当 message_count < max 且未关闭时才 +1,返回 true。
// 并发场景下(同一对话两端同时发送)可防止消息数超出上限。
func (db *conversations) IncrementMessageCountIfBelow(ctx context.Context, id uint, max int) (bool, error) {
res := db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count < ? AND is_closed = ?", id, max, false).
UpdateColumn("message_count", gorm.Expr("message_count + 1"))
if res.Error != nil {
return false, errors.Wrap(res.Error, "increment message count if below")
}
return res.RowsAffected > 0, nil
}
// DecrementMessageCount 回滚一次消息计数(计数已递增但消息落库失败时调用)。
func (db *conversations) DecrementMessageCount(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count > 0", id).
UpdateColumn("message_count", gorm.Expr("message_count - 1")).Error
}
// MaybeClose 当消息数达到上限时关闭对话(幂等,供发送消息后调用)。
func (db *conversations) MaybeClose(ctx context.Context, id uint, max int) error {
return db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count >= ? AND is_closed = ?", id, max, false).
Update("is_closed", true).Error
}
func (db *conversations) Close(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_closed", true).Error
}
func (db *conversations) ListByOwner(ctx context.Context, ownerID uint, limit, offset int) ([]*Conversation, error) {
var convs []*Conversation
q := db.WithContext(ctx).Where("owner_id = ? AND is_deleted_by_owner = ? AND token != ''", ownerID, false).
Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit)
if offset > 0 {
q = q.Offset(offset)
}
}
if err := q.Find(&convs).Error; err != nil {
return nil, errors.Wrap(err, "list by owner")
}
return convs, nil
}
func (db *conversations) CountByOwner(ctx context.Context, ownerID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&Conversation{}).
Where("owner_id = ? AND is_deleted_by_owner = ? AND token != ''", ownerID, false).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count by owner")
}
return count, nil
}
func (db *conversations) ListByAsker(ctx context.Context, askerID uint, limit, offset int) ([]*Conversation, error) {
var convs []*Conversation
q := db.WithContext(ctx).Where("asker_id = ? AND is_deleted_by_owner = ? AND token != ''", askerID, false).
Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit)
if offset > 0 {
q = q.Offset(offset)
}
}
if err := q.Find(&convs).Error; err != nil {
return nil, errors.Wrap(err, "list by asker")
}
return convs, nil
}
func (db *conversations) CountByAsker(ctx context.Context, askerID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&Conversation{}).
Where("asker_id = ? AND is_deleted_by_owner = ? AND token != ''", askerID, false).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count by asker")
}
return count, nil
}
func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error {
var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 已删除或不存在:幂等,不影响调用方。
return nil
}
return errors.Wrap(err, "get conversation for delete")
}
if conv.IsDeletedByOwner {
// 已经是删除状态:幂等返回,避免重复请求多次回退配额计数。
return nil
}
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil {
return errors.Wrap(err, "mark conversation deleted by owner")
}
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
// 否则达上限后即便删掉旧会话,功能仍会显示 quota_reached 关闭,须手动重置计数。
if conv.OwnerID != 0 {
if err := FeatureControls.IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement chat quota for user %d", conv.OwnerID)
}
}
return nil
}
+234
View File
@@ -0,0 +1,234 @@
package db
import (
"fmt"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/uptrace/opentelemetry-go-extra/otelgorm"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
)
var AllTables = []interface{}{
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
// 匿名投票
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
&FeatureControl{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
var dialector gorm.Dialector
switch typ {
case "mysql", "":
dialector = mysql.Open(dsn)
case "postgres":
dialector = postgres.Open(dsn)
default:
return nil, errors.Errorf("unknown database type: %q", typ)
}
db, err := gorm.Open(dialector, &gorm.Config{
SkipDefaultTransaction: true,
})
if err != nil {
return nil, errors.Wrap(err, "connect to database")
}
// 配置连接池
sqlDB, err := db.DB()
if err != nil {
return nil, errors.Wrap(err, "get sql.DB from gorm")
}
// 1C1G 低配环境:调低连接池,避免每个 DB 连接占用过多内存。
// 提问箱是低并发场景,10 个打开连接绰绰有余。
// 最大打开连接数
sqlDB.SetMaxOpenConns(10)
// 最大空闲连接数
sqlDB.SetMaxIdleConns(4)
// 连接最大存活时间(避免长连接被数据库服务端断开)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
// 空闲连接最大存活时间
sqlDB.SetConnMaxIdleTime(3 * time.Minute)
// 仅在明确开启迁移清理开关时处理 users 表历史重复值,避免服务启动时静默改写账号数据。
// 正常部署应先离线检查并清理重复 email/domain,再执行 AutoMigrate。
if conf.Database.DedupeUniqueFields {
if err := dedupeUniqueFields(db); err != nil {
return nil, errors.Wrap(err, "dedupe unique fields before migrate")
}
}
if err := db.AutoMigrate(AllTables...); err != nil {
return nil, errors.Wrap(err, "auto migrate")
}
// 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。
// 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。
if err := backfillFeatureControls(db); err != nil {
logrus.WithError(err).Warn("初始化功能开关配置时异常(可在功能管理页手动保存一次以生成配置)")
}
// 私聊开关纠偏:旧版 backfill 曾把未开启私聊的账号写为开启(GORM 零值陷阱),
// 启动期幂等复位;同样不阻断启动,失败仅告警。
if err := reconcileLegacyChatEnabled(db); err != nil {
logrus.WithError(err).Warn("私聊开关历史数据纠偏时异常(可在功能管理页手动调整私聊开关)")
}
// 私聊收集上限下线:私聊不再支持收集上限(UI 已隐藏,保存时强制清零),
// 启动期把历史残留的上限/计数一并清零,避免私聊「莫名关闭」。幂等,失败仅告警。
if err := normalizeChatQuota(db); err != nil {
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
}
// 启动期补充高频查询复合索引(幂等,适合既有库)。
// 仅 PostgreSQL 需要:这些索引针对服务端真实查询模式(按归属用户+私密+时间、
// 按 asker、按展示/投票状态+可见性+时间、按会话/发送者+时间)。
// 本项目实际使用 PostgreSQL(详见 code-review-2026-08-28.md P1-4)。
if typ == "postgres" {
if err := ensureFrequentlyUsedIndexes(db); err != nil {
// 索引创建失败不应阻断启动,仅告警,便于在运维侧排查。
logrus.WithError(err).Warn("创建高频查询复合索引时异常(可能部分索引未建立,不影响服务启动)")
}
}
Users = NewUsersStore(db)
Questions = NewQuestionsStore(db)
FollowUps = NewFollowUpsStore(db)
CensorLogs = NewCensorLogsStore(db)
UploadImages = NewUploadImagesStore(db)
Conversations = NewConversationStore(db)
Messages = NewMessageStore(db)
MessageImages = NewMessageImageStore(db)
Votes = NewVotesStore(db)
VoteOptions = NewVoteOptionsStore(db)
VoteFormFields = NewVoteFormFieldsStore(db)
VoteSubmissions = NewVoteSubmissionsStore(db)
FeatureControls = NewFeatureControlsStore(db)
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
)); err != nil {
return nil, errors.Wrap(err, "register otelgorm plugin")
}
if typ == "postgres" {
if err := fixPostgresSequences(db); err != nil {
return nil, errors.Wrap(err, "fix postgres sequences")
}
}
return db, nil
}
// dedupeUniqueFields 在 AutoMigrate 之前清理 users 表中重复的 email / domain:
// 每个重复值仅保留 id 最小(最早注册)的一行,其余行该字段置为 NULL。
// NULL 在 MySQL / PostgreSQL / SQLite 的唯一索引中互不冲突,因此置空即可让唯一索引成功建立。
// 幂等:重复数据被清理后再次启动时无任何操作。跨数据库兼容(仅使用标准 SQL,无目标表子查询)。
func dedupeUniqueFields(db *gorm.DB) error {
for _, field := range []string{"email", "domain"} {
var keepIDs []uint
var dupVals []string
rows, err := db.Table("users").
Select("MIN(id) AS keep_id, " + field + " AS val").
Where(field + " IS NOT NULL").
Group(field).
Having("COUNT(*) > 1").
Rows()
if err != nil {
return errors.Wrapf(err, "query duplicate %s", field)
}
for rows.Next() {
var keepID uint
var val string
if err := rows.Scan(&keepID, &val); err != nil {
rows.Close()
return errors.Wrapf(err, "scan duplicate %s", field)
}
keepIDs = append(keepIDs, keepID)
dupVals = append(dupVals, val)
}
if err := rows.Close(); err != nil {
return errors.Wrapf(err, "close rows for %s", field)
}
if rows.Err() != nil {
return errors.Wrapf(rows.Err(), "iterate duplicate %s", field)
}
if len(keepIDs) == 0 {
continue
}
res := db.Table("users").
Where(field+" IS NOT NULL").
Where(field+" IN ?", dupVals).
Where("id NOT IN ?", keepIDs).
Update(field, nil)
if res.Error != nil {
return errors.Wrapf(res.Error, "clean duplicate %s", field)
}
if res.RowsAffected > 0 {
logrus.WithField("field", field).
Warnf("迁移前置清理:%d 行重复的 %s 已置空(保留最早注册的账号)", res.RowsAffected, field)
}
}
return nil
}
// ensureFrequentlyUsedIndexes 在 AutoMigrate 之后创建高频查询所需的复合索引。
// 全部使用 CREATE INDEX IF NOT EXISTS,幂等、可在既有库上安全运行,重复执行无副作用。
// 索引设计依据服务端真实查询模式(详见 code-review-2026-08-28.md P1-4):
// - questions: 按 user_id + is_private + created_at 过滤排序(提问箱列表);
// 按 asker_user_id 查询(我发起的提问)。
// - users: 按 show_in_home + created_at(首页展示箱列表)。
// - votes: 按 status + visibility + created_at(探索页/提问箱页公开投票)。
// - messages: 按 conversation_id + created_at、sender_id + created_at(私信列表/会话消息)。
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_users_show_in_home_created ON users(show_in_home, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_votes_status_visibility_created ON votes(status, visibility, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_messages_sender_created ON messages(sender_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at)`,
}
for _, ddl := range indexes {
if err := db.Exec(ddl).Error; err != nil {
return errors.Wrapf(err, "create index: %s", ddl)
}
}
return nil
}
func fixPostgresSequences(db *gorm.DB) error {
// 使用配置的 schema(未配置时回退 public),避免硬编码 nekobox。
schema := conf.Database.Schema
if schema == "" {
schema = "public"
}
tablesWithID := []string{"users", "questions", "follow_ups", "censor_logs", "upload_images", "conversations", "messages", "message_images", "votes", "vote_options", "vote_form_fields", "vote_submissions", "feature_controls"}
for _, table := range tablesWithID {
seqName := table + "_id_seq"
resetQuery := fmt.Sprintf(`
CREATE SEQUENCE IF NOT EXISTS %s.%s;
ALTER TABLE %s.%s
ALTER COLUMN id SET DEFAULT nextval('%s.%s');
SELECT setval('%s.%s',
COALESCE((SELECT MAX(id) FROM %s.%s), 1)::bigint,
true);
`, schema, seqName, schema, table, schema, seqName, schema, seqName, schema, table)
if err := db.Exec(resetQuery).Error; err != nil {
return fmt.Errorf("修复表 %s 序列时出错: %w", table, err)
}
}
return nil
}
+723
View File
@@ -0,0 +1,723 @@
// 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"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var FeatureControls FeatureControlsStore
var _ FeatureControlsStore = (*featureControls)(nil)
// 受控功能标识(feature_controls.feature 取值)。
const (
FeatureQuestion = "question" // 提问
FeatureChat = "chat" // 私聊
FeatureVote = "vote" // 投票
)
// FeatureKeys 全部受控功能,顺序即功能管理页的展示顺序。
var FeatureKeys = []string{FeatureQuestion, FeatureChat, FeatureVote}
// 功能不可用原因(空串表示可用)。
const (
FeatureReasonDisabled = "disabled" // 手动关闭
FeatureReasonNotStarted = "not_started" // 未到开放时间
FeatureReasonEnded = "ended" // 已过关闭时间
FeatureReasonQuotaReached = "quota_reached" // 已达定量收集上限
)
// QuotaLimitMax 单个功能的收集上限最大值,防止误填极大值。
const QuotaLimitMax = 1_000_000
type FeatureControlsStore interface {
// Get 读取某功能的配置;无记录时返回该功能的默认配置(不落库)。
Get(ctx context.Context, userID uint, feature string) (*FeatureControl, error)
// ListByUserID 读取某用户全部功能的配置(无记录的功能不出现在结果中)。
ListByUserID(ctx context.Context, userID uint) ([]*FeatureControl, error)
// ListByUserIDs 批量读取多个用户同一功能的配置(探索页等批量场景用,避免 N+1)。
ListByUserIDs(ctx context.Context, userIDs []uint, feature string) (map[uint]*FeatureControl, error)
// Save 写入配置(不存在则创建,存在则更新开关/时间窗/上限,不影响已收集计数)。
Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error
// ResetQuota 清零收集计数,重新开始一轮定量收集。
ResetQuota(ctx context.Context, userID uint, feature string) error
// IncrementQuota 累加收集计数;无记录时按 delta 创建。
IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error
}
func NewFeatureControlsStore(db *gorm.DB) FeatureControlsStore {
return &featureControls{db}
}
type featureControls struct {
*gorm.DB
}
// FeatureControl 提问箱单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
//
// 三类控制互不覆盖,最终可用状态由 Evaluate 综合计算:
// - Enabled:手动总开关;
// - OpenAt / CloseAt:定时开放与定时关闭(nil 表示不限制该侧);
// - QuotaLimit / QuotaCount:定量收集,累计收集数达到上限后自动关闭(0 表示不限制)。
//
// 计数不做定时重置:一轮收集结束后由箱主在功能管理页手动「重置计数」开启下一轮,
// 避免与定时开关叠加产生难以预期的自动恢复行为。
type FeatureControl struct {
dbutil.Model
UserID uint `gorm:"uniqueIndex:idx_feature_control_user_feature;not null" json:"-"`
Feature string `gorm:"size:20;uniqueIndex:idx_feature_control_user_feature;not null" json:"feature"`
// ⚠️ 不要给 Enabled 加 gorm:"default:true" 标签:
// GORM 在 Create 时会把零值 false 替换为字段默认值(true),导致「取消勾选保存」后
// 数据库里仍是 true、复选框自动恢复为勾选。去掉标签后 GORM 会原样写入显式设置的值。
Enabled bool `json:"enabled"`
// OpenAt / CloseAt:一次性定时开放 / 关闭(nil 表示不限制该侧)。
OpenAt *time.Time `json:"open_at"`
CloseAt *time.Time `json:"close_at"`
// 循环定时(每周循环):ScheduleDays 为逗号分隔的周几(0=周日…6=周六,与 time.Weekday 一致),
// 空表示每天都开放;ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示该日全天)。
// 两者都为空时表示未启用循环定时。
ScheduleDays string `gorm:"size:31" json:"schedule_days"`
ScheduleStart string `gorm:"size:5" json:"schedule_start"`
ScheduleEnd string `gorm:"size:5" json:"schedule_end"`
QuotaLimit int64 `gorm:"default:0" json:"quota_limit"`
QuotaCount int64 `gorm:"default:0" json:"quota_count"`
QuotaResetAt *time.Time `json:"quota_reset_at"`
}
// FeatureState 某功能在某一时刻的综合状态。
type FeatureState struct {
Enabled bool // 手动开关
Open bool // 综合(开关 + 定时 + 定量)后的最终可用状态
Reason string // 不可用原因,空串表示可用(取值见 FeatureReason*)
QuotaLimit int64 // 收集上限(0 = 不限制)
QuotaCount int64 // 已收集数量
OpenAt *time.Time // 一次性定时开放时间
CloseAt *time.Time // 一次性定时关闭时间
NextOpenAt *time.Time // 下一次开放时间(可能来自一次性窗口或循环定时;永久关闭时为 nil)
LastCloseAt *time.Time // 最近一次关闭时间(从未开放过时为 nil)
}
// Evaluate 计算该功能在 now 时刻的综合状态。
// 判定优先级:手动关闭 > 未到一次性开放时间 > 已过一次性关闭时间 > 不在循环定时窗口内 > 达到收集上限。
func (fc *FeatureControl) Evaluate(now time.Time) FeatureState {
st := FeatureState{
Enabled: fc.Enabled,
QuotaLimit: fc.QuotaLimit,
QuotaCount: fc.QuotaCount,
OpenAt: fc.OpenAt,
CloseAt: fc.CloseAt,
}
// 先算好下次开放 / 最近关闭,供 reason 判定与展示复用。
st.NextOpenAt = fc.nextOpenAt(now)
st.LastCloseAt = fc.lastCloseAt(now)
switch {
case !fc.Enabled:
st.Reason = FeatureReasonDisabled
// 手动关闭与时间无关,不展示开放/关闭时间
st.NextOpenAt, st.LastCloseAt = nil, nil
case fc.OpenAt != nil && now.Before(*fc.OpenAt):
st.Reason = FeatureReasonNotStarted
case fc.CloseAt != nil && !now.Before(*fc.CloseAt):
// 已过一次性关闭时间:一次性窗口是硬性外边界,循环定时仅在窗口内生效,
// 因此之后不会再开放(nextOpenAt 已按 CloseAt 约束计算,返回 nil)。
st.Reason = FeatureReasonEnded
st.NextOpenAt = nil
default:
switch fc.scheduleSlotStatus(now) {
case scheduleBeforeStart:
st.Reason = FeatureReasonNotStarted
case scheduleAfterEnd:
if st.NextOpenAt != nil {
st.Reason = FeatureReasonNotStarted
} else {
st.Reason = FeatureReasonEnded
}
}
if st.Reason == "" && fc.QuotaLimit > 0 && fc.QuotaCount >= fc.QuotaLimit {
st.Reason = FeatureReasonQuotaReached
// 定量上限与时间无关,不展示开放/关闭时间
st.NextOpenAt, st.LastCloseAt = nil, nil
}
}
st.Open = st.Reason == ""
return st
}
// scheduleSlotStatus 表示 now 相对循环定时窗口的位置。
type scheduleSlotStatus int
const (
scheduleInWindow scheduleSlotStatus = iota // 在开放窗口内
scheduleBeforeStart // 今天开放,但未到开始时间
scheduleAfterEnd // 已过结束时间,或今天不在开放日
)
// scheduleActive 判断是否启用了循环定时。
// 规则:设置了开放日,或同时设置了开始/结束时间,即视为启用;
// 只设置单侧时间视为配置异常,由路由层校验拦截。
func (fc *FeatureControl) scheduleActive() bool {
return fc.ScheduleDays != "" || (fc.ScheduleStart != "" && fc.ScheduleEnd != "")
}
// scheduleDayOpen 判断某天是否为开放日(ScheduleDays 为空 = 每天都开放)。
func (fc *FeatureControl) scheduleDayOpen(day time.Time) bool {
return fc.ScheduleDays == "" || containsWeekday(fc.ScheduleDays, int(day.Weekday()))
}
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。
// 未设置时间段时视为全天 [0, 1440)。配置非法时返回错误(调用方按"不限制"处理)。
func (fc *FeatureControl) scheduleWindow() (startM, endM int, err error) {
if fc.ScheduleStart == "" && fc.ScheduleEnd == "" {
return 0, 24 * 60, nil
}
if startM, err = parseHHMM(fc.ScheduleStart); err != nil {
return 0, 0, errors.Wrap(err, "parse schedule start")
}
if endM, err = parseHHMM(fc.ScheduleEnd); err != nil {
return 0, 0, errors.Wrap(err, "parse schedule end")
}
if endM <= startM {
return 0, 0, errors.New("schedule end must be after start")
}
return startM, endM, nil
}
// scheduleSlotStatus 返回 now 相对循环定时窗口的位置;未启用循环定时时恒为"在窗口内"。
func (fc *FeatureControl) scheduleSlotStatus(now time.Time) scheduleSlotStatus {
if !fc.scheduleActive() {
return scheduleInWindow
}
if !fc.scheduleDayOpen(now) {
return scheduleAfterEnd
}
startM, endM, err := fc.scheduleWindow()
if err != nil {
return scheduleInWindow // 配置异常按不限制处理(路由层已校验,此处兜底)
}
mins := now.Hour()*60 + now.Minute()
switch {
case mins < startM:
return scheduleBeforeStart
case mins >= endM:
return scheduleAfterEnd
}
return scheduleInWindow
}
// nextOpenAt 计算下一次可开放的时间(严格晚于 now)。
// 同时考虑一次性窗口与循环定时,返回二者中最早的下一次开放时刻;永久关闭时返回 nil。
func (fc *FeatureControl) nextOpenAt(now time.Time) *time.Time {
var earliest *time.Time
// 未启用循环定时时,一次性开放时间即为下一次开放。
// 启用后由下方按日扫描统一处理(一次性开放时间也必须落在循环窗口内)。
if fc.OpenAt != nil && fc.OpenAt.After(now) && !fc.scheduleActive() {
t := *fc.OpenAt
earliest = &t
}
if fc.scheduleActive() {
startM, endM, err := fc.scheduleWindow()
if err == nil {
day := startOfDay(now)
// 扫描今天起的 8 天:7 天覆盖全部周几组合,多 1 天兜底边界。
for i := 0; i < 8; i++ {
d := day.AddDate(0, 0, i)
if !fc.scheduleDayOpen(d) {
continue
}
slotStart := d.Add(time.Duration(startM) * time.Minute)
slotEnd := d.Add(time.Duration(endM) * time.Minute)
// 与一次性窗口求交集:有效开放起点 = max(时段起点, 一次性开放时间),且须在时段与一次性关闭时间之前
effStart := slotStart
if fc.OpenAt != nil && fc.OpenAt.After(effStart) {
effStart = *fc.OpenAt
}
if !effStart.Before(slotEnd) {
continue // 有效开放起点晚于该日时段结束,该日无有效窗口
}
if fc.CloseAt != nil && !effStart.Before(*fc.CloseAt) {
continue // 有效开放起点已过一次性关闭时间
}
if effStart.After(now) {
if earliest == nil || effStart.Before(*earliest) {
t := effStart
earliest = &t
}
break // 该日找到最早有效开放点即可(同一日只有一个窗口)
}
}
}
}
return earliest
}
// lastCloseAt 计算最近一次关闭的时间(严格早于 now);从未开放过时返回 nil。
func (fc *FeatureControl) lastCloseAt(now time.Time) *time.Time {
var latest *time.Time
// 未启用循环定时时,一次性关闭时间即为最近一次关闭。
// 启用后由下方按日扫描统一处理(与时段结束取较早者)。
if fc.CloseAt != nil && fc.CloseAt.Before(now) && !fc.scheduleActive() {
t := *fc.CloseAt
latest = &t
}
if fc.scheduleActive() {
startM, endM, err := fc.scheduleWindow()
if err == nil {
day := startOfDay(now)
// 从今天向前扫描 8 天,找最近一次已结束的窗口。
for i := 0; i < 8; i++ {
d := day.AddDate(0, 0, -i)
if !fc.scheduleDayOpen(d) {
continue
}
slotStart := d.Add(time.Duration(startM) * time.Minute)
slotEnd := d.Add(time.Duration(endM) * time.Minute)
// 与一次性窗口求交集:有效关闭点 = min(时段结束, 一次性关闭时间)
effEnd := slotEnd
if fc.CloseAt != nil && fc.CloseAt.Before(effEnd) {
effEnd = *fc.CloseAt
}
if effEnd.Before(slotStart) {
continue // 一次性关闭时间早于该日时段起点,该日无有效窗口
}
if effEnd.Before(now) {
if latest == nil || effEnd.After(*latest) {
t := effEnd
latest = &t
}
break // 已找到最近一次关闭
}
}
}
}
return latest
}
// parseHHMM 解析 "HH:MM" 为当天第几分钟。
func parseHHMM(s string) (int, error) {
t, err := time.Parse("15:04", s)
if err != nil {
return 0, errors.Wrap(err, "parse HH:MM")
}
return t.Hour()*60 + t.Minute(), nil
}
// containsWeekday 判断逗号分隔的周几列表(0=周日…6=周六)是否包含 wd。
func containsWeekday(days string, wd int) bool {
for _, part := range strings.Split(days, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err == nil && n == wd {
return true
}
}
return false
}
// startOfDay 返回 t 所在日历日的零点(保持时区一致)。
func startOfDay(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。
// 提问与投票默认开启(与历史行为一致);私聊默认关闭,沿用 users.allow_chat 的默认值,
// 避免历史账号在功能上线后被动开启私聊。
func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
return &FeatureControl{
UserID: userID,
Feature: feature,
Enabled: feature != FeatureChat,
}
}
// FeatureStateOf 便捷入口:读取某用户某功能在当前的综合状态。
// 查询失败或无存储时回退到默认配置,保证单个依赖表异常不会让整站功能不可用。
func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureState {
if FeatureControls == nil {
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
}
fc, err := FeatureControls.Get(ctx, userID, feature)
if err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to get feature control %q of user %d", feature, userID)
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
}
return fc.Evaluate(time.Now())
}
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
return FeatureStateOf(ctx, userID, feature).Open
}
// IsValidFeature 判断功能标识是否合法(防越权写入未知功能)。
func IsValidFeature(feature string) bool {
for _, k := range FeatureKeys {
if k == feature {
return true
}
}
return false
}
func (db *featureControls) Get(ctx context.Context, userID uint, feature string) (*FeatureControl, error) {
var fc FeatureControl
err := db.WithContext(ctx).Where("user_id = ? AND feature = ?", userID, feature).First(&fc).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return DefaultFeatureControl(userID, feature), nil
}
return nil, errors.Wrap(err, "get feature control")
}
return &fc, nil
}
func (db *featureControls) ListByUserID(ctx context.Context, userID uint) ([]*FeatureControl, error) {
var list []*FeatureControl
err := db.WithContext(ctx).
Where("user_id = ?", userID).
Order("feature ASC").
Find(&list).Error
if err != nil {
return nil, errors.Wrap(err, "list feature controls by user id")
}
return list, nil
}
func (db *featureControls) ListByUserIDs(ctx context.Context, userIDs []uint, feature string) (map[uint]*FeatureControl, error) {
result := make(map[uint]*FeatureControl, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
var list []*FeatureControl
if err := db.WithContext(ctx).
Where("user_id IN ? AND feature = ?", userIDs, feature).
Find(&list).Error; err != nil {
return nil, errors.Wrap(err, "list feature controls by user ids")
}
for _, fc := range list {
result[fc.UserID] = fc
}
return result, nil
}
type UpdateFeatureControlOptions struct {
Enabled bool
OpenAt *time.Time
CloseAt *time.Time
ScheduleDays string
ScheduleStart string
ScheduleEnd string
QuotaLimit int64
}
// Save 写入功能配置。使用 ON CONFLICT 幂等写入,并显式列出更新列,
// 避免把 quota_count(已收集数量)误重置为 0。
func (db *featureControls) Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error {
now := time.Now()
fc := &FeatureControl{
// UpdatedAt 显式写入(gorm 的 on-conflict 更新列会用到);CreatedAt 留零值由 gorm 自动填充
Model: dbutil.Model{UpdatedAt: now},
UserID: userID,
Feature: feature,
Enabled: opts.Enabled,
OpenAt: opts.OpenAt,
CloseAt: opts.CloseAt,
ScheduleDays: opts.ScheduleDays,
ScheduleStart: opts.ScheduleStart,
ScheduleEnd: opts.ScheduleEnd,
QuotaLimit: opts.QuotaLimit,
}
err := db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "feature"}},
DoUpdates: clause.AssignmentColumns([]string{
"enabled", "open_at", "close_at",
"schedule_days", "schedule_start", "schedule_end",
"quota_limit", "updated_at",
}),
}).Create(fc).Error
if err != nil {
return errors.Wrap(err, "save feature control")
}
return nil
}
// ResetQuota 清零收集计数并记录重置时间。无记录时创建一条(沿用该功能的默认开关值)。
func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature string) error {
now := time.Now()
res := db.WithContext(ctx).Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{
"quota_count": 0,
"quota_reset_at": now,
"updated_at": now,
})
if res.Error != nil {
return errors.Wrap(res.Error, "reset feature quota")
}
if res.RowsAffected > 0 {
return nil
}
// 尚无记录:按默认配置创建(提问/投票默认开启,私聊默认关闭)
fc := DefaultFeatureControl(userID, feature)
fc.QuotaResetAt = &now
if err := db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
Create(fc).Error; err != nil {
return errors.Wrap(err, "create feature control on reset quota")
}
return nil
}
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
if delta == 0 {
return nil
}
now := time.Now()
q := db.WithContext(ctx).Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature)
if delta < 0 {
q = q.Where("quota_count > 0")
}
res := q.Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta),
"updated_at": now,
})
if res.Error != nil {
return errors.Wrap(res.Error, "increment feature quota")
}
if res.RowsAffected > 0 {
return nil
}
// 尚无配置行:建一条并把计数初始化为本条(负数按 0 起步,不产生负计数)。
fc := DefaultFeatureControl(userID, feature)
if delta > 0 {
fc.QuotaCount = delta
}
fc.QuotaResetAt = &now
created := db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
Create(fc)
if created.Error != nil {
return errors.Wrap(created.Error, "create feature control on increment quota")
}
if created.RowsAffected > 0 || delta <= 0 {
return nil
}
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
retry := db.WithContext(ctx).Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta),
"updated_at": time.Now(),
})
if retry.Error != nil {
return errors.Wrap(retry.Error, "increment feature quota (retry)")
}
return nil
}
// backfillFeatureControls 为尚未初始化的历史账号补齐功能配置行。
//
// 背景:私聊开关此前落在 users.allow_chat,提问/投票则一直默认开启。功能上线后若没有
// 对应的配置行,Get 会回退到默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭。
// 因此在 AutoMigrate 之后按 users.allow_chat 回填私聊开关,其余功能按"开启"回填。
//
// 幂等:每轮只挑出仍缺配置行的账号,写完后进入下一轮,直到没有遗漏。
// 分批 + ON CONFLICT DO NOTHING,避免"边插入边查同一张表"在不同数据库上的差异行为,
// 并保证并发启动(多实例同时启动)时不会重复插入。
func backfillFeatureControls(db *gorm.DB) error {
// backfillBatchSize 每轮处理的账号数,控制单次事务体积与内存占用。
const backfillBatchSize = 500
type backfillRow struct {
UserID uint
AllowChat bool
}
for _, feature := range FeatureKeys {
defaultEnabled := feature != FeatureChat
created := int64(0)
for {
var rows []backfillRow
err := db.Table("users").
Select("users.id AS user_id, users.allow_chat AS allow_chat").
Joins("LEFT JOIN feature_controls fc ON fc.user_id = users.id AND fc.feature = ?", feature).
Where("users.deleted_at IS NULL AND fc.id IS NULL").
Limit(backfillBatchSize).
Scan(&rows).Error
if err != nil {
return errors.Wrapf(err, "query users without feature control %s", feature)
}
if len(rows) == 0 {
break
}
batch := make([]FeatureControl, 0, len(rows))
for _, r := range rows {
enabled := defaultEnabled
if feature == FeatureChat {
enabled = r.AllowChat
}
batch = append(batch, FeatureControl{
UserID: r.UserID,
Feature: feature,
Enabled: enabled,
})
}
res := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&batch)
if res.Error != nil {
return errors.Wrapf(res.Error, "backfill feature controls: %s", feature)
}
// 本轮没有任何新行落库:说明并发实例已写完或数据异常,停止循环避免死循环。
if res.RowsAffected == 0 {
break
}
created += res.RowsAffected
if len(rows) < backfillBatchSize {
break
}
}
if created > 0 {
logrus.WithField("feature", feature).
Infof("功能开关初始化:为 %d 个历史账号补齐配置", created)
}
}
return nil
}
// reconcileLegacyChatEnabled 修复旧版 backfill 写入的私聊错误开关。
//
// 背景:早期版本 FeatureControl.Enabled 带 gorm:"default:true" 标签,backfill 为
// allow_chat=false 的用户补私聊行时,false 被 GORM 替换为默认值 true,导致私聊被被动开启;
// 且 UpdateChatSettings 会把 users.allow_chat 反向同步为 true,形成持久化。
//
// 纠偏条件(必须同时满足,只命中"纯 backfill 默认行",绝不碰箱主配置过的行):
// - feature = 'chat' 且 enabled = true;
// - 对应 users.allow_chat = false(箱主从未在面板开启私聊——面板开启会同步 allow_chat=true);
// - 该行无任何自定义配置(open_at / close_at 为空、quota_limit = 0、循环定时为空);
// - updated_at <= created_at(从未被面板保存过,updated_at 未变化)。
//
// 满足条件即复位 feature_controls.enabled = false,并同步 users.allow_chat = false。
// 幂等:每轮只处理仍满足条件的行,直到没有遗漏;已复位/已配置的行不会重复命中。
func reconcileLegacyChatEnabled(db *gorm.DB) error {
// reconcileBatchSize 每轮处理的账号数,控制单次事务体积。
const reconcileBatchSize = 500
// pureDefaultFilterFC / pureDefaultFilterPlain 只匹配纯默认配置的私聊行
// (无一次性窗口/循环定时/定量上限)。两者仅列前缀不同:
// - FC 版带 "fc." 前缀,用于 SELECT(feature_controls fc 别名场景);
// - 纯版不带前缀,用于 UPDATE(GORM 不会为 UPDATE 自动加别名,带前缀会报
// missing FROM-clause entry for table "fc")。
// 同时兼容旧行 schedule 列为 NULL 与新行空串两种情况。
pureDefaultFilterFC := "fc.open_at IS NULL AND fc.close_at IS NULL AND fc.quota_limit = 0" +
" AND (fc.schedule_days IS NULL OR fc.schedule_days = '')" +
" AND (fc.schedule_start IS NULL OR fc.schedule_start = '')" +
" AND (fc.schedule_end IS NULL OR fc.schedule_end = '')"
pureDefaultFilterPlain := "open_at IS NULL AND close_at IS NULL AND quota_limit = 0" +
" AND (schedule_days IS NULL OR schedule_days = '')" +
" AND (schedule_start IS NULL OR schedule_start = '')" +
" AND (schedule_end IS NULL OR schedule_end = '')"
fixed := int64(0)
for {
var rows []struct{ UserID uint }
err := db.Table("feature_controls fc").
Select("fc.user_id").
Joins("JOIN users u ON u.id = fc.user_id AND u.allow_chat = false").
Where("fc.feature = ? AND fc.enabled = true", FeatureChat).
Where(pureDefaultFilterFC).
Where("fc.updated_at <= fc.created_at").
Where("fc.deleted_at IS NULL AND u.deleted_at IS NULL").
Limit(reconcileBatchSize).
Scan(&rows).Error
if err != nil {
return errors.Wrap(err, "query legacy chat rows to reconcile")
}
if len(rows) == 0 {
break
}
userIDs := make([]uint, 0, len(rows))
for _, r := range rows {
userIDs = append(userIDs, r.UserID)
}
// 复位 feature_controls.enabled(同一批用户 + 同样的纯默认条件,避免误伤并发新增配置)。
res := db.Table("feature_controls").
Where("user_id IN ? AND feature = ? AND enabled = true", userIDs, FeatureChat).
Where(pureDefaultFilterPlain).
Where("deleted_at IS NULL").
Update("enabled", false)
if res.Error != nil {
return errors.Wrap(res.Error, "reset legacy chat enabled")
}
fixed += res.RowsAffected
// 同步 users.allow_chat,保证两处一致。
if err := db.Table("users").
Where("id IN ? AND allow_chat = true", userIDs).
Where("deleted_at IS NULL").
Update("allow_chat", false).Error; err != nil {
return errors.Wrap(err, "sync legacy users.allow_chat")
}
if int64(len(rows)) < reconcileBatchSize {
break
}
}
if fixed > 0 {
logrus.WithField("count", fixed).
Warn("私聊开关纠偏:旧版数据把未开启私聊的账号置为开启,已复位为关闭")
}
return nil
}
// normalizeChatQuota 清零私聊行上历史残留的收集上限与计数。
//
// 背景:私聊不提供收集上限能力(会话是双向对话,限制「新建会话数」意义不大),
// UI 已隐藏该字段、保存时强制清零;此处在启动期做一次性兜底,把旧版本
// 设置过的私聊上限/计数统一清零,避免达到残留上限后私聊「莫名关闭」。
// 幂等:重复执行不产生任何变更,失败由调用方告警(不阻断启动)。
func normalizeChatQuota(db *gorm.DB) error {
res := db.Model(&FeatureControl{}).
Where("feature = ? AND (quota_limit > 0 OR quota_count > 0)", FeatureChat).
Updates(map[string]interface{}{"quota_limit": 0, "quota_count": 0})
if res.Error != nil {
return errors.Wrap(res.Error, "normalize chat feature quota")
}
if res.RowsAffected > 0 {
logrus.WithField("count", res.RowsAffected).
Info("私聊收集上限已下线:已清零历史残留的私聊收集上限与计数")
}
return nil
}
+341
View File
@@ -0,0 +1,341 @@
// 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 (
"testing"
"time"
)
// TestFeatureControlEvaluate 覆盖开关 / 定时 / 定量三类控制的组合与优先级。
// 纯逻辑测试,不依赖外部数据库。
func TestFeatureControlEvaluate(t *testing.T) {
now := time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC)
past := now.Add(-time.Hour)
future := now.Add(time.Hour)
tests := []struct {
name string
fc FeatureControl
wantOk bool
want string
}{
{
name: "开启且无限制",
fc: FeatureControl{Enabled: true},
wantOk: true, want: "",
},
{
name: "手动关闭优先于其它条件",
fc: FeatureControl{Enabled: false, OpenAt: &past, CloseAt: &future, QuotaLimit: 1},
wantOk: false, want: FeatureReasonDisabled,
},
{
name: "未到开放时间",
fc: FeatureControl{Enabled: true, OpenAt: &future},
wantOk: false, want: FeatureReasonNotStarted,
},
{
name: "开放时间已过但功能仍开启",
fc: FeatureControl{Enabled: true, OpenAt: &past},
wantOk: true, want: "",
},
{
name: "已过关闭时间",
fc: FeatureControl{Enabled: true, OpenAt: &past, CloseAt: &past},
wantOk: false, want: FeatureReasonEnded,
},
{
name: "刚好到达关闭时间即关闭",
fc: FeatureControl{Enabled: true, CloseAt: &now},
wantOk: false, want: FeatureReasonEnded,
},
{
name: "未到收集上限",
fc: FeatureControl{Enabled: true, QuotaLimit: 10, QuotaCount: 9},
wantOk: true, want: "",
},
{
name: "恰好达到收集上限",
fc: FeatureControl{Enabled: true, QuotaLimit: 10, QuotaCount: 10},
wantOk: false, want: FeatureReasonQuotaReached,
},
{
name: "超过收集上限",
fc: FeatureControl{Enabled: true, QuotaLimit: 10, QuotaCount: 12},
wantOk: false, want: FeatureReasonQuotaReached,
},
{
name: "上限为 0 表示不限制",
fc: FeatureControl{Enabled: true, QuotaLimit: 0, QuotaCount: 9999},
wantOk: true, want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := tt.fc.Evaluate(now)
if st.Open != tt.wantOk || st.Reason != tt.want {
t.Fatalf("Evaluate() = (open=%v, reason=%q), want (open=%v, reason=%q)",
st.Open, st.Reason, tt.wantOk, tt.want)
}
})
}
}
// TestDefaultFeatureControl 私聊默认关闭(沿用 users.allow_chat 的历史默认),
// 提问与投票默认开启,保证功能上线不改变既有行为。
func TestDefaultFeatureControl(t *testing.T) {
cases := map[string]bool{
FeatureQuestion: true,
FeatureChat: false,
FeatureVote: true,
}
for feature, want := range cases {
if got := DefaultFeatureControl(1, feature).Enabled; got != want {
t.Fatalf("DefaultFeatureControl(%q).Enabled = %v, want %v", feature, got, want)
}
}
}
func TestIsValidFeature(t *testing.T) {
for _, k := range FeatureKeys {
if !IsValidFeature(k) {
t.Fatalf("IsValidFeature(%q) = false, want true", k)
}
}
for _, k := range []string{"", "chat ", "CHAT", "poll", "messages"} {
if IsValidFeature(k) {
t.Fatalf("IsValidFeature(%q) = true, want false", k)
}
}
}
// TestFeatureControlSchedule 覆盖循环定时(每周开放日 + 每日时间段)的 Evaluate 判定。
func TestFeatureControlSchedule(t *testing.T) {
// 固定在一个周四 12:00(time.Weekday: 0=周日…4=周四…6=周六)
now := time.Date(2026, 9, 3, 12, 0, 0, 0, time.UTC) // 2026-09-03 是周四
thursday := int(now.Weekday())
if thursday != 4 {
t.Fatalf("test precondition: 2026-09-03 should be Thursday, got %d", thursday)
}
tests := []struct {
name string
fc FeatureControl
wantOk bool
want string
}{
{
name: "无循环定时不影响判定",
fc: FeatureControl{Enabled: true},
wantOk: true, want: "",
},
{
name: "每日时间段内开放",
fc: FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: true, want: "",
},
{
name: "每日时间段开始前关闭",
fc: FeatureControl{Enabled: true, ScheduleStart: "13:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonNotStarted,
},
{
name: "每日时间段结束后关闭(次日会再开)",
fc: FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "11:00"},
wantOk: false, want: FeatureReasonNotStarted,
},
{
name: "当天不在开放日关闭(未来还会再开)",
fc: FeatureControl{Enabled: true, ScheduleDays: "0", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonNotStarted,
},
{
name: "只设开放日(不设时间)表示全天",
fc: FeatureControl{Enabled: true, ScheduleDays: "4"},
wantOk: true, want: "",
},
{
name: "只设时间(不设开放日)表示每天该时段",
fc: FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: true, want: "",
},
{
name: "开放日与时间段组合:非开放日关闭",
fc: FeatureControl{Enabled: true, ScheduleDays: "1,3,5", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonNotStarted, // 周四不在 1,3,5 中
},
{
name: "开放日与时间段组合:开放日+时段内开放",
fc: FeatureControl{Enabled: true, ScheduleDays: "2,4", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: true, want: "",
},
{
name: "循环定时达到收集上限",
fc: FeatureControl{Enabled: true, ScheduleDays: "4", ScheduleStart: "09:00", ScheduleEnd: "18:00", QuotaLimit: 5, QuotaCount: 5},
wantOk: false, want: FeatureReasonQuotaReached,
},
{
name: "手动关闭优先于循环定时",
fc: FeatureControl{Enabled: false, ScheduleDays: "4", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonDisabled,
},
{
name: "一次性窗口与循环定时叠加:窗口内+时段内开放",
fc: FeatureControl{Enabled: true, OpenAt: &[]time.Time{now.Add(-time.Hour)}[0], CloseAt: &[]time.Time{now.Add(24 * time.Hour)}[0], ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: true, want: "",
},
{
name: "一次性窗口已关闭后循环定时不再生效(硬边界)",
fc: FeatureControl{Enabled: true, OpenAt: &[]time.Time{now.Add(-48 * time.Hour)}[0], CloseAt: &[]time.Time{now.Add(-24 * time.Hour)}[0], ScheduleDays: "4", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonEnded,
},
{
name: "一次性窗口已关闭且无循环定时",
fc: FeatureControl{Enabled: true, OpenAt: &[]time.Time{now.Add(-48 * time.Hour)}[0], CloseAt: &[]time.Time{now.Add(-24 * time.Hour)}[0]},
wantOk: false, want: FeatureReasonEnded,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := tt.fc.Evaluate(now)
if st.Open != tt.wantOk || st.Reason != tt.want {
t.Fatalf("Evaluate() = (open=%v, reason=%q), want (open=%v, reason=%q)",
st.Open, st.Reason, tt.wantOk, tt.want)
}
})
}
}
// TestFeatureControlNextOpenAtLastCloseAt 覆盖「下次开放时间 / 上次关闭时间」的计算。
func TestFeatureControlNextOpenAtLastCloseAt(t *testing.T) {
// 2026-09-03 12:00 UTC(周四)
now := time.Date(2026, 9, 3, 12, 0, 0, 0, time.UTC)
t.Run("循环定时与一次性窗口交集:当天时段内更晚开放", func(t *testing.T) {
// now=周四 12:00,循环每天 09:00-18:00;一次性开放时间 = 今天 14:00 → 今天 14:00 开放
openAt := time.Date(2026, 9, 3, 14, 0, 0, 0, time.UTC)
closeAt := time.Date(2026, 9, 3, 20, 0, 0, 0, time.UTC)
fc := FeatureControl{Enabled: true, OpenAt: &openAt, CloseAt: &closeAt,
ScheduleStart: "09:00", ScheduleEnd: "18:00"}
st := fc.Evaluate(now)
if st.Reason != FeatureReasonNotStarted {
t.Fatalf("reason = %q, want not_started", st.Reason)
}
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(openAt) {
t.Fatalf("NextOpenAt = %v, want %v(今天 14:00)", st.NextOpenAt, openAt)
}
})
t.Run("一次性窗口在循环时段结束后才开放则当天不开放", func(t *testing.T) {
// 一次性开放时间 20:00 晚于循环时段结束 18:00 → 今天无有效窗口,下次开放 = 明天 09:00
openAt := time.Date(2026, 9, 3, 20, 0, 0, 0, time.UTC)
closeAt := time.Date(2026, 9, 5, 20, 0, 0, 0, time.UTC)
fc := FeatureControl{Enabled: true, OpenAt: &openAt, CloseAt: &closeAt,
ScheduleStart: "09:00", ScheduleEnd: "18:00"}
st := fc.Evaluate(now)
if st.Reason != FeatureReasonNotStarted {
t.Fatalf("reason = %q, want not_started", st.Reason)
}
wantNext := time.Date(2026, 9, 4, 9, 0, 0, 0, time.UTC)
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(wantNext) {
t.Fatalf("NextOpenAt = %v, want %v(明天 09:00)", st.NextOpenAt, wantNext)
}
})
t.Run("手动关闭与定量上限不展示时间信息", func(t *testing.T) {
disabled := FeatureControl{Enabled: false, ScheduleStart: "09:00", ScheduleEnd: "18:00"}
st := disabled.Evaluate(now)
if st.Reason != FeatureReasonDisabled || st.NextOpenAt != nil || st.LastCloseAt != nil {
t.Fatalf("disabled state = (reason=%q, next=%v, last=%v), want nil times",
st.Reason, st.NextOpenAt, st.LastCloseAt)
}
quota := FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00", QuotaLimit: 1, QuotaCount: 1}
st = quota.Evaluate(now)
if st.Reason != FeatureReasonQuotaReached || st.NextOpenAt != nil || st.LastCloseAt != nil {
t.Fatalf("quota state = (reason=%q, next=%v, last=%v), want nil times",
st.Reason, st.NextOpenAt, st.LastCloseAt)
}
})
t.Run("一次性窗口未到开放时间", func(t *testing.T) {
openAt := now.Add(2 * time.Hour)
closeAt := now.Add(24 * time.Hour)
fc := FeatureControl{Enabled: true, OpenAt: &openAt, CloseAt: &closeAt}
st := fc.Evaluate(now)
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(openAt) {
t.Fatalf("NextOpenAt = %v, want %v", st.NextOpenAt, openAt)
}
if st.LastCloseAt != nil {
t.Fatalf("LastCloseAt = %v, want nil(从未开放过)", st.LastCloseAt)
}
})
t.Run("一次性窗口已结束", func(t *testing.T) {
openAt := now.Add(-48 * time.Hour)
closeAt := now.Add(-24 * time.Hour)
fc := FeatureControl{Enabled: true, OpenAt: &openAt, CloseAt: &closeAt}
st := fc.Evaluate(now)
if st.NextOpenAt != nil {
t.Fatalf("NextOpenAt = %v, want nil(无循环定时,永久关闭)", st.NextOpenAt)
}
if st.LastCloseAt == nil || !st.LastCloseAt.Equal(closeAt) {
t.Fatalf("LastCloseAt = %v, want %v", st.LastCloseAt, closeAt)
}
})
t.Run("循环定时:每天 09:00-18:00", func(t *testing.T) {
fc := FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00"}
// 12:00 在窗口内:下次开放 = 明天 09:00;上次关闭 = 今天 18:00 尚未结束 → 昨天 18:00
st := fc.Evaluate(now)
if st.Open != true {
t.Fatalf("should be open, got reason=%q", st.Reason)
}
wantNext := time.Date(2026, 9, 4, 9, 0, 0, 0, time.UTC)
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(wantNext) {
t.Fatalf("NextOpenAt = %v, want %v", st.NextOpenAt, wantNext)
}
wantLast := time.Date(2026, 9, 2, 18, 0, 0, 0, time.UTC)
if st.LastCloseAt == nil || !st.LastCloseAt.Equal(wantLast) {
t.Fatalf("LastCloseAt = %v, want %v", st.LastCloseAt, wantLast)
}
})
t.Run("循环定时:18:00 已过", func(t *testing.T) {
evening := time.Date(2026, 9, 3, 20, 0, 0, 0, time.UTC)
fc := FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00"}
st := fc.Evaluate(evening)
if st.Open {
t.Fatal("should be closed after daily end")
}
wantNext := time.Date(2026, 9, 4, 9, 0, 0, 0, time.UTC)
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(wantNext) {
t.Fatalf("NextOpenAt = %v, want %v", st.NextOpenAt, wantNext)
}
wantLast := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
if st.LastCloseAt == nil || !st.LastCloseAt.Equal(wantLast) {
t.Fatalf("LastCloseAt = %v, want %v", st.LastCloseAt, wantLast)
}
})
t.Run("循环定时:仅周五开放(今天周四)", func(t *testing.T) {
fc := FeatureControl{Enabled: true, ScheduleDays: "5"} // 周五全天
st := fc.Evaluate(now)
if st.Open {
t.Fatal("should be closed on non-scheduled day")
}
wantNext := time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC) // 周五零点
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(wantNext) {
t.Fatalf("NextOpenAt = %v, want %v", st.NextOpenAt, wantNext)
}
// 全天窗口 [周五 00:00, 周六 00:00),最近一次关闭 = 上周六零点
wantLast := time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC)
if st.LastCloseAt == nil || !st.LastCloseAt.Equal(wantLast) {
t.Fatalf("LastCloseAt = %v, want %v", st.LastCloseAt, wantLast)
}
})
}
+221
View File
@@ -0,0 +1,221 @@
package db
import (
"context"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// MaxFollowUpCount 每条提问允许的最大追问次数(防滥用)。
// 提问者被回答后,用 token(或登录账号)继续追问,达到上限后不可再追问。
const MaxFollowUpCount = 3
// 追问相关错误(供 handler 区分处理,避免把业务错误当 500)
var (
// ErrFollowUpLimitReached 追问次数已达上限
ErrFollowUpLimitReached = errors.New("follow up limit reached")
// ErrFollowUpNotFound 追问不存在或不属于该问题
ErrFollowUpNotFound = errors.New("follow up not found")
// ErrFollowUpAlreadyAnswered 追问已被回答(禁止覆盖)
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
)
var FollowUps FollowUpsStore
type FollowUpsStore interface {
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建。
// 返回 created=false 且 err==ErrFollowUpLimitReached 表示已达上限。
// 通过事务内锁定问题行,串行化同一问题的并发追问,消除 count-then-create 竞态。
CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (created bool, err error)
GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error)
// GetLatestByQuestionID 返回某条提问最新的一条追问;无追问时返回 (nil, nil)。
GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error)
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回。
// 用于账号导出等场景,将 N 次单题查询合并为 1 次 IN 查询,避免 N+1。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error)
CountByQuestionID(ctx context.Context, questionID uint) (int64, error)
// AnswerByID 回答某条追问;questionID 用于防止越权(必须属于该问题)。
// 仅允许回答尚未回答的追问(answer 为空),已回答的返回 ErrFollowUpAlreadyAnswered,
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 与 AnswerByID 不同,此处允许覆盖已回答的追问(供主人修改回复使用)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
func NewFollowUpsStore(db *gorm.DB) FollowUpsStore {
return &followUps{db}
}
type followUps struct {
*gorm.DB
}
// FollowUp 表示对某条提问的一轮追问。
type FollowUp struct {
dbutil.Model
QuestionID uint `gorm:"index:idx_follow_up_question" json:"question_id"`
Content string `gorm:"type:text" json:"content"`
Answer string `gorm:"type:text" json:"answer"`
}
type CreateFollowUpOptions struct {
QuestionID uint
Content string
}
func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (bool, error) {
created := false
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 锁定问题行,串行化同一问题的并发追问(MySQL/Postgres 均支持 FOR UPDATE)。
var q Question
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").
First(&q, opts.QuestionID).Error; err != nil {
return errors.Wrap(err, "lock question for follow up")
}
var count int64
if err := tx.Model(&FollowUp{}).
Where("question_id = ?", opts.QuestionID).
Count(&count).Error; err != nil {
return errors.Wrap(err, "count follow ups by question id")
}
if count >= MaxFollowUpCount {
return ErrFollowUpLimitReached
}
f := &FollowUp{
QuestionID: opts.QuestionID,
Content: opts.Content,
}
if err := tx.Create(f).Error; err != nil {
return errors.Wrap(err, "create follow up")
}
created = true
return nil
})
if err != nil {
return false, err
}
return created, nil
}
// GetByQuestionID 按时间正序返回某条提问的所有追问。
func (db *followUps) GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error) {
var followUps []*FollowUp
if err := db.WithContext(ctx).
Where("question_id = ?", questionID).
Order("created_at ASC").
Find(&followUps).Error; err != nil {
return nil, errors.Wrap(err, "get follow ups by question id")
}
return followUps, nil
}
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序取首条);
// 无追问时返回 (nil, nil)。供发布追问前校验"上一条是否已回复"。
func (db *followUps) GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) {
var fu FollowUp
err := db.WithContext(ctx).
Where("question_id = ?", questionID).
Order("created_at DESC").
Limit(1).
First(&fu).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, errors.Wrap(err, "get latest follow up by question id")
}
return &fu, nil
}
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(解决导出 N+1)。
func (db *followUps) GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error) {
result := make(map[uint][]*FollowUp)
if len(questionIDs) == 0 {
return result, nil
}
var items []*FollowUp
if err := db.WithContext(ctx).
Where("question_id IN ?", questionIDs).
Order("question_id ASC, created_at ASC").
Find(&items).Error; err != nil {
return nil, errors.Wrap(err, "get follow ups by question ids")
}
for _, fu := range items {
result[fu.QuestionID] = append(result[fu.QuestionID], fu)
}
return result, nil
}
// CountByQuestionID 统计某条提问的追问次数,用于限制追问上限。
func (db *followUps) CountByQuestionID(ctx context.Context, questionID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).
Model(&FollowUp{}).
Where("question_id = ?", questionID).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count follow ups by question id")
}
return count, nil
}
// AnswerByID 更新某条追问的回答内容(带 question_id 条件防止越权)。
// 原子更新条件含 answer=”:已回答的追问不会被覆盖(避免重复提交时邮件/回答被二次写入)。
// RowsAffected==0 时再查一次以区分"不存在"与"已回答",返回对应错误。
func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ? AND answer = ''", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "answer follow up")
}
if res.RowsAffected == 0 {
var cnt int64
if err := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Count(&cnt).Error; err != nil {
return errors.Wrap(err, "check follow up existence")
}
if cnt == 0 {
return ErrFollowUpNotFound
}
return ErrFollowUpAlreadyAnswered
}
return nil
}
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "update follow up answer")
}
if res.RowsAffected == 0 {
return ErrFollowUpNotFound
}
return nil
}
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
func (db *followUps) DeleteByQuestionID(ctx context.Context, questionID uint) error {
if err := db.WithContext(ctx).Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups by question id")
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2023 E99p1ant. 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 (
"testing"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// newTestDB returns a test database instance with the cleanup function.
func newTestDB(t *testing.T) (*gorm.DB, func(...string) error) {
return dbutil.NewTestDB(t, AllTables...)
}
+132
View File
@@ -0,0 +1,132 @@
package db
import (
"context"
"fmt"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Messages MessageStore
type MessageStore interface {
Create(ctx context.Context, opts CreateMessageOptions) (*Message, error)
GetByConversationID(ctx context.Context, conversationID uint, cursor *dbutil.Cursor) ([]*Message, error)
// GetFirstMessagesByConversationIDs 批量获取多个会话的第一条消息(解决 N+1 查询)
GetFirstMessagesByConversationIDs(ctx context.Context, conversationIDs []uint) (map[uint]string, error)
// GetAllByConversationIDs 批量获取多个会话的全部消息(导出用,不分页),按 conversation_id 分组。
// 将每个会话原本的 1 次查询合并为 1 次 IN 查询,避免 N+1。
GetAllByConversationIDs(ctx context.Context, conversationIDs []uint) (map[uint][]*Message, error)
}
func NewMessageStore(db *gorm.DB) MessageStore {
return &messages{db}
}
type messages struct {
*gorm.DB
}
type Message struct {
dbutil.Model
ConversationID uint `gorm:"index:idx_message_conversation" json:"conversation_id"`
SenderID uint `json:"sender_id"`
Content string `gorm:"type:text" json:"content"`
Read bool `gorm:"default:false" json:"read"`
}
type CreateMessageOptions struct {
ConversationID uint
SenderID uint
Content string
}
func (db *messages) Create(ctx context.Context, opts CreateMessageOptions) (*Message, error) {
msg := &Message{
ConversationID: opts.ConversationID,
SenderID: opts.SenderID,
Content: opts.Content,
}
if err := db.WithContext(ctx).Create(msg).Error; err != nil {
return nil, errors.Wrap(err, "create message")
}
return msg, nil
}
func (db *messages) GetByConversationID(ctx context.Context, conversationID uint, cursor *dbutil.Cursor) ([]*Message, error) {
var messages []*Message
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID)
limit := 50
if cursor != nil {
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
q = q.Where("id < ?", cursor.Value)
}
limit = cursor.Limit()
}
q = q.Order("created_at ASC")
q = q.Limit(limit)
if err := q.Find(&messages).Error; err != nil {
return nil, errors.Wrap(err, "get messages")
}
return messages, nil
}
// GetFirstMessagesByConversationIDs 批量获取多个会话的第一条消息内容。
// 使用窗口函数 ROW_NUMBER() 选出每个会话最早的消息,避免 N+1 查询。
func (db *messages) GetFirstMessagesByConversationIDs(ctx context.Context, conversationIDs []uint) (map[uint]string, error) {
if len(conversationIDs) == 0 {
return make(map[uint]string), nil
}
// 使用子查询 + ROW_NUMBER 窗口函数获取每个 conversation 的第一条消息
// 兼容 MySQL 8+ / PostgreSQL / SQLite 3.25+
var results []struct {
ConversationID uint `gorm:"column:conversation_id"`
Content string `gorm:"column:content"`
}
err := db.WithContext(ctx).Raw(`
SELECT conversation_id, content FROM (
SELECT conversation_id, content,
ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at ASC) AS rn
FROM messages
WHERE conversation_id IN ?
) sub WHERE rn = 1
`, conversationIDs).Scan(&results).Error
if err != nil {
return nil, errors.Wrap(err, "get first messages batch")
}
resultMap := make(map[uint]string, len(results))
for _, r := range results {
resultMap[r.ConversationID] = r.Content
}
return resultMap, nil
}
// GetAllByConversationIDs 批量获取多个会话的全部消息(导出用,不分页),按 conversation_id 分组。
// 用于账号导出:将每个会话原本的 1 次查询合并为 1 次 IN 查询,避免 N+1。
func (db *messages) GetAllByConversationIDs(ctx context.Context, conversationIDs []uint) (map[uint][]*Message, error) {
result := make(map[uint][]*Message)
if len(conversationIDs) == 0 {
return result, nil
}
var items []*Message
if err := db.WithContext(ctx).
Where("conversation_id IN ?", conversationIDs).
Order("conversation_id ASC, created_at ASC").
Find(&items).Error; err != nil {
return nil, errors.Wrap(err, "get messages by conversation ids")
}
for _, m := range items {
result[m.ConversationID] = append(result[m.ConversationID], m)
}
return result, nil
}
+63
View File
@@ -0,0 +1,63 @@
package db
import (
"context"
"github.com/pkg/errors"
"gorm.io/gorm"
)
var MessageImages MessageImageStore
type MessageImageStore interface {
Create(ctx context.Context, opts CreateMessageImageOptions) (*MessageImage, error)
GetByMessageIDs(ctx context.Context, messageIDs []uint) (map[uint][]*MessageImage, error)
}
func NewMessageImageStore(db *gorm.DB) MessageImageStore {
return &messageImages{db}
}
type messageImages struct {
*gorm.DB
}
type MessageImage struct {
gorm.Model
MessageID uint `gorm:"index" json:"message_id"`
URL string `gorm:"size:500" json:"url"`
FileName string `gorm:"size:200" json:"file_name"`
FileSize int64 `json:"file_size"`
}
type CreateMessageImageOptions struct {
MessageID uint
URL string
FileName string
FileSize int64
}
func (db *messageImages) Create(ctx context.Context, opts CreateMessageImageOptions) (*MessageImage, error) {
img := &MessageImage{
MessageID: opts.MessageID,
URL: opts.URL,
FileName: opts.FileName,
FileSize: opts.FileSize,
}
if err := db.WithContext(ctx).Create(img).Error; err != nil {
return nil, errors.Wrap(err, "create message image")
}
return img, nil
}
func (db *messageImages) GetByMessageIDs(ctx context.Context, messageIDs []uint) (map[uint][]*MessageImage, error) {
var images []*MessageImage
if err := db.WithContext(ctx).Where("message_id IN ?", messageIDs).Find(&images).Error; err != nil {
return nil, errors.Wrap(err, "get message images")
}
result := make(map[uint][]*MessageImage)
for _, img := range images {
result[img.MessageID] = append(result[img.MessageID], img)
}
return result, nil
}
+85
View File
@@ -0,0 +1,85 @@
package db
import (
"crypto/subtle"
"encoding/hex"
"strings"
"github.com/sirupsen/logrus"
"github.com/wuhan005/gadget"
"golang.org/x/crypto/bcrypt"
"github.com/tamakyi/TamaBox/internal/conf"
)
// bcryptCost is the cost parameter for bcrypt (10 is a good default, 12 for higher security).
const bcryptCost = 10
// bcryptMaxPasswordLen is the effective input length of bcrypt (72 bytes).
// bcrypt 只使用前 72 字节;新版 x/crypto 对超长密码直接返回错误,
// 这里统一截断到 72 字节(与 bcrypt 原生语义一致),避免走错误分支。
const bcryptMaxPasswordLen = 72
// encodePasswordBcrypt hashes a password with bcrypt.
// 注意:失败时绝不能静默降级为 HMAC-SHA1 弱哈希(此前行为),
// 返回空串会让该账号所有密码校验失败并留下错误日志,比弱哈希更安全。
func encodePasswordBcrypt(password string) string {
if len(password) > bcryptMaxPasswordLen {
password = password[:bcryptMaxPasswordLen]
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
// cost 为常量 10,正常不会到这里;防御性记录,不降级为弱哈希。
logrus.WithError(err).Error("bcrypt generate failed")
return ""
}
return string(hash)
}
// isBcryptHash checks if the given string is a bcrypt hash.
func isBcryptHash(hash string) bool {
return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$")
}
// IsBcryptHash exported for use in other packages.
func IsBcryptHash(hash string) bool {
return isBcryptHash(hash)
}
// authenticatePassword verifies a password against either bcrypt or legacy HMAC-SHA1 hash.
// Returns (true, newHash) if the password matches. If the old HMAC-SHA1 format was used,
// newHash contains the upgraded bcrypt hash — caller should persist it.
func authenticatePassword(storedHash, password string) (bool, string) {
if storedHash == "" {
return false, ""
}
// Try bcrypt first (new format).
if isBcryptHash(storedHash) {
err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
return err == nil, ""
}
// Fall back to legacy HMAC-SHA1(常量时间比较,防时序侧信道)。
legacyHash := gadget.HmacSha1(password, conf.Server.Salt)
if subtle.ConstantTimeCompare([]byte(legacyHash), []byte(storedHash)) == 1 {
// Password matches legacy format — upgrade to bcrypt.
return true, encodePasswordBcrypt(password)
}
return false, ""
}
// needsRehash checks if a bcrypt hash should be rehashed (e.g., cost changed).
func needsRehash(hash string) bool {
cost, err := bcrypt.Cost([]byte(hash))
if err != nil {
return true
}
return cost != bcryptCost
}
// HexEncodeToString is a helper to encode bytes as hex for legacy hash comparison.
func HexEncodeToString(src []byte) string {
return hex.EncodeToString(src)
}
+546
View File
@@ -0,0 +1,546 @@
// Copyright 2022 E99p1ant. 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Questions QuestionsStore
type QuestionsStore interface {
Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error)
GetByID(ctx context.Context, id uint) (*Question, error)
GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) error
DeleteByID(ctx context.Context, id uint) error
UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error
Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, error)
SetPrivate(ctx context.Context, id uint) error
SetPublic(ctx context.Context, id uint) error
SetPinned(ctx context.Context, id uint, pinned bool) error
SetRead(ctx context.Context, id uint) error
SetUnread(ctx context.Context, id uint) error
SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error)
GetHotQuestions(ctx context.Context, limit int) ([]*Question, error)
// 全站统计
QuestionStats(ctx context.Context) (*QuestionStats, error)
}
// QuestionStats 全站提问聚合统计(不按单个用户过滤,供首页"提问箱信息统计"使用)。
type QuestionStats struct {
TotalQuestions int64
AnsweredQuestions int64
LastQuestionAt *time.Time // 最后一个提问发生时间(questions.created_at 最大值)
LastAnswerAt *time.Time // 最后一个回答发生时间(已回答问题的 updated_at 最大值)
}
func NewQuestionsStore(db *gorm.DB) QuestionsStore {
return &questions{db}
}
type questions struct {
*gorm.DB
}
type Question struct {
dbutil.Model
FromIP string `json:"-"`
UserID uint `gorm:"index:idx_question_user_id" json:"-"`
Content string `json:"content"`
ContentCensorMetadata datatypes.JSON `json:"-"`
Token string `json:"-"`
Answer string `json:"answer"`
AnswerCensorMetadata datatypes.JSON `json:"-"`
ReceiveReplyEmail string `json:"-"`
AskerUserID uint `json:"-"`
IsPrivate bool `gorm:"default: FALSE; NOT NULL" json:"-"`
IsPinned bool `gorm:"default: FALSE; NOT NULL" json:"is_pinned"`
// IsRead 标记箱主是否已读/处理过该提问。有效"已读" = IsRead=true 或 已有回答(answer != "")。
// 已回答的问题恒为已读且锁定,不可改回未读(见 SetUnread 的 answer='' 条件与上层校验)。
IsRead bool `gorm:"default: FALSE; NOT NULL" json:"is_read"`
}
type CreateQuestionOptions struct {
FromIP string
UserID uint
Content string
ReceiveReplyEmail string
AskerUserID uint
IsPrivate bool
}
func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) {
question := Question{
FromIP: opts.FromIP,
UserID: opts.UserID,
Token: randstr.String(6),
Content: opts.Content,
ReceiveReplyEmail: opts.ReceiveReplyEmail,
AskerUserID: opts.AskerUserID,
IsPrivate: opts.IsPrivate,
}
return &question, db.WithContext(ctx).Create(&question).Error
}
type UpdateQuestionCensorOptions struct {
ContentCensorMetadata json.RawMessage
AnswerCensorMetadata json.RawMessage
}
func (db *questions) UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error {
question, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get by ID")
}
contentCensorMetadata := question.ContentCensorMetadata
if checkTextCensorResponseValid(opts.ContentCensorMetadata) {
contentCensorMetadata = datatypes.JSON(opts.ContentCensorMetadata)
}
answerCensorMetadata := question.AnswerCensorMetadata
if checkTextCensorResponseValid(opts.AnswerCensorMetadata) {
answerCensorMetadata = datatypes.JSON(opts.AnswerCensorMetadata)
}
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Updates(&Question{
ContentCensorMetadata: contentCensorMetadata,
AnswerCensorMetadata: answerCensorMetadata,
}).Error
}
func checkTextCensorResponseValid(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
if bytes.EqualFold(raw, []byte("null")) {
return false
}
var response struct {
SourceName string `json:"source_name"`
}
if err := json.Unmarshal(raw, &response); err != nil {
return false
}
return response.SourceName != ""
}
var ErrQuestionNotExist = errors.New("提问不存在")
func (db *questions) GetByID(ctx context.Context, id uint) (*Question, error) {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrQuestionNotExist
}
return nil, errors.Wrap(err, "get question by ID")
}
return &question, nil
}
type GetQuestionsByUserIDOptions struct {
*dbutil.Cursor
FilterAnswered bool // 已废弃,推荐使用 AnsweredStatus
ShowPrivate bool // 已废弃,推荐使用 PrivateStatus
AnsweredStatus int // 0: 全部, 1: 已回答, 2: 未回答
PrivateStatus int // 0: 全部, 1: 仅公开(is_private=false), 2: 仅私密(is_private=true)
ReadStatus int // 0: 全部, 1: 已读(is_read=true 或已回答), 2: 未读
SortOrder string // "ASC" 或 "DESC",默认 "DESC"
PinFirst bool // 置顶优先:is_pinned 的提问排在最前(公开主页使用)
}
// GetByUserID 根据用户 ID 获取提问列表,支持游标分页
func (db *questions) GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error) {
q := db.WithContext(ctx).Model(&Question{}).Where("user_id = ?", userID)
// 回答状态筛选
if opts.AnsweredStatus == 1 {
q = q.Where("answer != ''")
} else if opts.AnsweredStatus == 2 {
q = q.Where("answer = ''")
} else if opts.FilterAnswered {
// 兼容旧调用
q = q.Where("answer != ''")
}
// 私密状态筛选
if opts.PrivateStatus == 1 {
q = q.Where("is_private = false")
} else if opts.PrivateStatus == 2 {
q = q.Where("is_private = true")
} else if !opts.ShowPrivate && opts.PrivateStatus == 0 {
// 兼容旧调用:如果 ShowPrivate=false 且未指定 PrivateStatus,则过滤私密
q = q.Where("is_private = false")
}
// 阅读状态筛选:已读 = is_read=true 或已回答;未读 = is_read=false 且未回答。
// 使用括号包裹 OR 条件,避免与前面的 user_id 等条件产生 AND/OR 优先级问题。
if opts.ReadStatus == 1 {
q = q.Where("(is_read = ? OR answer != '')", true)
} else if opts.ReadStatus == 2 {
q = q.Where("(is_read = ? AND answer = '')", false)
}
// 排序方向先规范化,游标方向依赖它(ASC=下一页 id 更大,DESC=下一页 id 更小)。
sortOrder := opts.SortOrder
if sortOrder == "" {
sortOrder = "DESC"
}
if sortOrder != "ASC" && sortOrder != "DESC" {
sortOrder = "DESC"
}
// 游标分页
// 关键语义:携带游标却无法解析时,不再“忽略游标、从首页重新开始”。
// 旧行为会导致「加载更多」重复返回第一页内容、按钮永不消失(内容重复的
// 直接根因之一,见 history-template 的复合游标改造历史)。改为直接返回
// 空列表:上层(ListAPI/Pager 等)据此判定 has_more=false,前端按钮收敛。
if opts.Cursor != nil && opts.Cursor.Value != nil {
cursorStr := fmt.Sprintf("%v", opts.Cursor.Value)
if cursorStr != "" {
if opts.PinFirst {
// 置顶优先场景下,排序为 (is_pinned DESC, created_at, id),
// 必须用"复合键集"(keyset)游标,否则置顶项与非置顶项 id 交错时
// 单纯 id < 游标会漏掉介于置顶项 id 之间、但排在更后面的非置顶项。
// 游标格式:"<isPinned>.<createdAtUnixNano>.<id>"。
isPinned, createdAt, idVal, ok := parsePinCursor(cursorStr)
if !ok {
return []*Question{}, nil
}
// 排序为 (is_pinned DESC, created_at DESC, id DESC),下一页 = 排序序位严格在游标之后的行。
// 注意:is_pinned 在 PG 下是 boolean,与整型比较(boolean < integer)无操作符会直接报错,
// 因此必须传 Go bool 而非 0/1 整型(MySQL 下 bool 亦兼容 tinyint)。
// 游标位于置顶段时,后续行 = 置顶段内 (created_at,id) 更小 + 全部非置顶段;
// 游标位于非置顶段时,后续行 = 非置顶段内 (created_at,id) 更小。
if isPinned == 1 {
q = q.Where(
"(is_pinned = ? AND created_at < ?) OR (is_pinned = ? AND created_at = ? AND id < ?) OR (is_pinned = ?)",
true, createdAt, true, createdAt, idVal, false,
)
} else {
q = q.Where(
"(is_pinned = ? AND created_at < ?) OR (is_pinned = ? AND created_at = ? AND id < ?)",
false, createdAt, false, createdAt, idVal,
)
}
} else {
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
// 且 ASC(旧在前)时下一页 id 更大,DESC(新在前)时下一页 id 更小。
idVal, err := strconv.ParseUint(cursorStr, 10, 64)
if err != nil {
// 同上:游标非法不再回退首页,直接收敛为空列表。
return []*Question{}, nil
}
if sortOrder == "ASC" {
q = q.Where("id > ?", idVal)
} else {
q = q.Where("id < ?", idVal)
}
}
}
}
if opts.Cursor != nil {
limit := opts.Cursor.Limit()
q = q.Limit(limit)
}
if opts.PinFirst {
// 公开主页:置顶优先;用 id 作为稳定 tiebreaker 保证排序完全确定。
q = q.Order("is_pinned DESC, created_at " + sortOrder + ", id " + sortOrder)
} else {
q = q.Order("created_at " + sortOrder + ", id " + sortOrder)
}
var questions []*Question
if err := q.Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get questions by user id")
}
return questions, nil
}
// parsePinCursor 解析 PinFirst 分页的复合键集游标,格式 "<isPinned>.<createdAtUnixNano>.<id>"。
// 返回 (isPinned, createdAt, id, ok);格式非法时 ok=false,调用方应忽略该游标(从首页重新开始)。
//
// 时区约定(务必保持,否则线上翻页会退化为首页死循环):
// EncodePinCursor 的 nano 取自 DB 读回的 CreatedAt.UnixNano(),生产列是
// timestamp WITHOUT time zone,读写按「墙钟即 UTC」对称,因此 Parse 侧必须把 nano
// 还原成 UTC 墙钟的 time.Time(time.Unix(0,nano).UTC()),再交给 GORM 与无时区列比较。
// 若直接返回 time.Unix(0, nano)(Location=服务器本地时区),当进程 TZ 非 UTC 时
// (如 Asia/Shanghai)墙钟会偏移 +8h,导致 keyset 条件 created_at < 偏移时间恒为真,
// 游标形同虚设,每次请求都退回第一页 → 「加载更多」无限重复且按钮永不消失。
func parsePinCursor(s string) (int, time.Time, uint, bool) {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return 0, time.Time{}, 0, false
}
isPinned, err := strconv.Atoi(parts[0])
if err != nil {
return 0, time.Time{}, 0, false
}
nano, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, time.Time{}, 0, false
}
idVal, err := strconv.ParseUint(parts[2], 10, 64)
if err != nil {
return 0, time.Time{}, 0, false
}
return isPinned, time.Unix(0, nano).UTC(), uint(idVal), true
}
// EncodePinCursor 生成 PinFirst 分页游标(供路由层把末条记录编码进下一页游标)。
func EncodePinCursor(isPinned bool, createdAt time.Time, id uint) string {
v := 0
if isPinned {
v = 1
}
return fmt.Sprintf("%d.%d.%d", v, createdAt.UnixNano(), id)
}
type GetQuestionsByAskUserIDOptions struct {
*dbutil.Cursor
FilterAnswered bool
ShowPrivate bool
}
func (db *questions) GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error) {
where := `asker_user_id = ?`
args := userID
if opts.FilterAnswered {
where = `asker_user_id = ? AND answer != ''`
}
if !opts.ShowPrivate {
where += ` AND is_private = false`
}
questions, err := db.getBy(ctx, opts.Cursor, where, args)
if err != nil {
return nil, errors.Wrap(err, "get by")
}
return questions, nil
}
// getBy 保留用于 GetByAskUserID 等旧逻辑
func (db *questions) getBy(ctx context.Context, cursor *dbutil.Cursor, whereQuery string, args ...interface{}) ([]*Question, error) {
var questions []*Question
q := db.WithContext(ctx).Where(whereQuery, args...)
if cursor != nil {
cursorID := cursor.Value
if cursorID != nil && fmt.Sprintf("%v", cursorID) != "" {
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
// 解析失败不再忽略游标回退首页(会导致「加载更多」重复首页内容),
// 直接收敛为空列表,令调用方按“无更多”处理。
idVal, err := strconv.ParseUint(fmt.Sprintf("%v", cursorID), 10, 64)
if err != nil {
return []*Question{}, nil
}
if idVal > 0 {
q = q.Where(`id < ?`, idVal)
}
}
limit := cursor.Limit()
q = q.Limit(limit)
}
q = q.Order("created_at DESC")
if err := q.Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get questions by page ID")
}
return questions, nil
}
func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
Updates(map[string]interface{}{"answer": answer, "is_read": true}).Error; err != nil {
return errors.Wrap(err, "update question answer")
}
return nil
}
func (db *questions) DeleteByID(ctx context.Context, id uint) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Delete(&Question{}, id).Error; err != nil {
return errors.Wrap(err, "delete question")
}
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
if err := FollowUps.DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
}
if err := UploadImages.DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id)
}
// 回退定量收集计数:删除提问等价于少一条已收集内容,
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
if question.UserID != 0 {
if err := FeatureControls.IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID)
}
}
return nil
}
type GetQuestionsCountOptions struct {
FilterAnswered bool
ShowPrivate bool
IsPrivate *bool // 非 nil 时按该值过滤 is_private
IsPinned *bool // 非 nil 时按该值过滤 is_pinned
ReadStatus int // 0: 全部, 1: 已读(is_read=true 或已回答), 2: 未读
}
func (db *questions) Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, error) {
q := db.WithContext(ctx).Model(&Question{})
if opts.FilterAnswered {
q = q.Where(`user_id = ? AND answer != ''`, userID)
} else {
q = q.Where(`user_id = ?`, userID)
}
if !opts.ShowPrivate {
q = q.Where(`is_private = ?`, false)
}
if opts.IsPrivate != nil {
q = q.Where(`is_private = ?`, *opts.IsPrivate)
}
if opts.IsPinned != nil {
q = q.Where(`is_pinned = ?`, *opts.IsPinned)
}
// 阅读状态筛选:已读 = is_read=true 或已回答;未读 = is_read=false 且未回答。
if opts.ReadStatus == 1 {
q = q.Where("(is_read = ? OR answer != '')", true)
} else if opts.ReadStatus == 2 {
q = q.Where("(is_read = ? AND answer = '')", false)
}
var count int64
return count, q.Count(&count).Error
}
func (db *questions) SetPrivate(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", true).Error
}
// SetPinned 设置/取消问题的置顶状态。
func (db *questions) SetPinned(ctx context.Context, id uint, pinned bool) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_pinned", pinned).Error
}
func (db *questions) SetPublic(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", false).Error
}
// SetRead 标记提问为已读(箱主侧手动标记,或作为其他语义化操作的前置状态)。
func (db *questions) SetRead(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_read", true).Error
}
// SetUnread 标记提问为未读。已回答的提问恒为已读且锁定,故追加 answer=” 条件:
// 即使上层遗漏校验,也不会把已回答的提问错误地改回未读(纵深防御)。
func (db *questions) SetUnread(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ? AND answer = ''", id).Update("is_read", false).Error
}
// SearchByUserID 全文搜索提问箱主人收到的提问(含私密,仅限本人管理后台使用)。
// 使用 LIKE 模糊匹配问题内容与回答;keyword 中的通配符 % _ \ 会被转义,避免模式滥用。
func (db *questions) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error) {
escaped := escapeLike(keyword)
var questions []*Question
if err := db.WithContext(ctx).
Where("user_id = ? AND (content LIKE ? OR answer LIKE ?)", userID, "%"+escaped+"%", "%"+escaped+"%").
Order("created_at DESC").
Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "search questions by user id")
}
return questions, nil
}
// escapeLike 转义 LIKE 模式中的特殊字符。
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// GetHotQuestions 返回"热门问题":最新活跃的公开已回答提问(按回答更新时间倒序)。
// 提问箱没有点赞数据,用"最近被回答"作为热度近似,适合主页/探索页展示。
func (db *questions) GetHotQuestions(ctx context.Context, limit int) ([]*Question, error) {
if limit <= 0 {
limit = 10
}
var questions []*Question
if err := db.WithContext(ctx).
Where("is_private = ? AND answer != ''", false).
Order("updated_at DESC").
Limit(limit).
Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get hot questions")
}
return questions, nil
}
// QuestionStats 返回全站提问相关聚合统计。
// 使用 gorm 查询构造器生成 COUNT / MAX,兼容 MySQL 与 PostgreSQL。
// 最后一个回答时间取"已回答问题"的 updated_at 最大值——回答动作会更新 updated_at,
// 与 crontab 旧逻辑(answer 非空记录中 updated_at 最大)一致。
func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error) {
var total, answered int64
if err := db.WithContext(ctx).Model(&Question{}).Count(&total).Error; err != nil {
return nil, errors.Wrap(err, "count questions")
}
if err := db.WithContext(ctx).Model(&Question{}).Where("answer != ''").Count(&answered).Error; err != nil {
return nil, errors.Wrap(err, "count answered questions")
}
var t struct {
LastQ *time.Time `gorm:"column:last_q"`
LastA *time.Time `gorm:"column:last_a"`
}
if err := db.WithContext(ctx).Model(&Question{}).
Select("MAX(created_at) AS last_q, MAX(CASE WHEN answer != '' THEN updated_at ELSE NULL END) AS last_a").
Scan(&t).Error; err != nil {
return nil, errors.Wrap(err, "get last question/answer time")
}
return &QuestionStats{
TotalQuestions: total,
AnsweredQuestions: answered,
LastQuestionAt: t.LastQ,
LastAnswerAt: t.LastA,
}, nil
}
+467
View File
@@ -0,0 +1,467 @@
// Copyright 2023 E99p1ant. 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"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
func TestQuestions(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
questionsStore := NewQuestionsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *questions)
}{
{"Create", testQuestionsCreate},
{"GetByID", testQuestionsGetByID},
{"GetByUserID", testQuestionsGetByUserID},
{"GetByAskUserID", testQuestionsGetByAskUserID},
{"AnswerByID", testQuestionsAnswerByID},
{"DeleteByID", testQuestionsDeleteByID},
{"UpdateCensor", testQuestionsUpdateCensor},
{"Count", testQuestionsCount},
{"SetPrivate", testQuestionsSetPrivate},
{"SetPublic", testQuestionsSetPublic},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("questions"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, questionsStore.(*questions))
})
}
}
func testQuestionsCreate(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
got, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Hello, world!",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
got.Token = ""
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &Question{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Hello, world!",
ReceiveReplyEmail: "[email protected]",
}
require.Equal(t, got, want)
})
}
func testQuestionsGetByID(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Hello, world!",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
got.Token = ""
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &Question{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Hello, world!",
ReceiveReplyEmail: "[email protected]",
}
require.Equal(t, got, want)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsGetByUserID(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 3",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
t.Run("all", func(t *testing.T) {
got, err := db.GetByUserID(ctx, 1, GetQuestionsByUserIDOptions{})
require.Nil(t, err)
for _, g := range got {
g.Token = ""
g.CreatedAt = time.Time{}
g.UpdatedAt = time.Time{}
}
want := []*Question{
{
Model: dbutil.Model{
ID: 3,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 3",
ReceiveReplyEmail: "[email protected]",
},
{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
},
}
require.Equal(t, want, got)
})
t.Run("cursor", func(t *testing.T) {
got, err := db.GetByUserID(ctx, 1, GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
Value: 3,
PageSize: 10,
},
})
require.Nil(t, err)
for _, g := range got {
g.Token = ""
g.CreatedAt = time.Time{}
g.UpdatedAt = time.Time{}
}
want := []*Question{
{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
},
}
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsGetByAskUserID(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 2,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 2,
})
require.Nil(t, err)
t.Run("all", func(t *testing.T) {
got, err := db.GetByAskUserID(ctx, 1, GetQuestionsByAskUserIDOptions{})
require.Nil(t, err)
for _, g := range got {
g.Token = ""
g.CreatedAt = time.Time{}
g.UpdatedAt = time.Time{}
}
want := []*Question{
{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
},
}
require.Equal(t, want, got)
})
t.Run("cursor", func(t *testing.T) {
got, err := db.GetByAskUserID(ctx, 2, GetQuestionsByAskUserIDOptions{
Cursor: &dbutil.Cursor{
Value: 3,
PageSize: 10,
},
})
require.Nil(t, err)
for _, g := range got {
g.Token = ""
g.CreatedAt = time.Time{}
g.UpdatedAt = time.Time{}
}
want := []*Question{
{
Model: dbutil.Model{
ID: 2,
},
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 2,
},
}
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsAnswerByID(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
err = db.AnswerByID(ctx, 1, "Answer - 1")
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Answer - 1", got.Answer)
})
t.Run("not found", func(t *testing.T) {
err := db.AnswerByID(ctx, 404, "Answer - 1")
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsDeleteByID(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
err = db.DeleteByID(ctx, 1)
require.Nil(t, err)
_, err = db.GetByID(ctx, 1)
require.Equal(t, ErrQuestionNotExist, err)
})
t.Run("not found", func(t *testing.T) {
err := db.DeleteByID(ctx, 404)
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsUpdateCensor(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
err = db.DeleteByID(ctx, 1)
require.Nil(t, err)
_, err = db.GetByID(ctx, 1)
require.Equal(t, ErrQuestionNotExist, err)
})
t.Run("not found", func(t *testing.T) {
err := db.UpdateCensor(ctx, 404, UpdateQuestionCensorOptions{})
require.NotNil(t, err)
})
}
func testQuestionsCount(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 3",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
err = db.AnswerByID(ctx, 3, "Answer - 3")
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.Count(ctx, 1, GetQuestionsCountOptions{})
require.Nil(t, err)
want := int64(2)
require.Equal(t, want, got)
})
t.Run("filter", func(t *testing.T) {
got, err := db.Count(ctx, 1, GetQuestionsCountOptions{FilterAnswered: true})
require.Nil(t, err)
want := int64(1)
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
got, err := db.Count(ctx, 404, GetQuestionsCountOptions{})
require.Nil(t, err)
want := int64(0)
require.Equal(t, want, got)
})
}
func testQuestionsSetPrivate(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err = db.SetPrivate(ctx, 1)
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.True(t, got.IsPrivate)
})
}
func testQuestionsSetPublic(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err = db.SetPublic(ctx, 1)
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.False(t, got.IsPrivate)
})
}
+190
View File
@@ -0,0 +1,190 @@
// Copyright 2023 E99p1ant. 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"
"github.com/samber/lo"
"gorm.io/gorm"
)
var UploadImages UploadImagesStore
var _ UploadImagesStore = (*uploadImages)(nil)
type UploadImagesStore interface {
Create(ctx context.Context, opts CreateUploadImageOptions) (*UploadImage, error)
GetByQuestionID(ctx context.Context, questionID uint) ([]*UploadImage, error)
// GetByQuestionIDs 批量获取多个提问的图片,按 question_id 分组返回。
// 用于账号导出等场景,将 N 次单题查询合并为少量 IN 查询,避免 N+1。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error)
GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error)
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
func NewUploadImagesStore(db *gorm.DB) UploadImagesStore {
return &uploadImages{db}
}
type uploadImages struct {
*gorm.DB
}
type UploadImage struct {
gorm.Model
UploaderUserID uint
Name string
FileSize int64
Md5 string
Key string
}
type UploadImageQuestionType string
const (
UploadImageQuestionTypeAsk UploadImageQuestionType = "ask"
UploadImageQuestionTypeAnswer UploadImageQuestionType = "answer"
)
type UploadImageQuestion struct {
Type UploadImageQuestionType
UploadImageID uint `gorm:"primaryKey"`
QuestionID uint `gorm:"primaryKey"`
}
type CreateUploadImageOptions struct {
Type UploadImageQuestionType
QuestionID uint
UploaderUserID uint
Name string
FileSize int64
Md5 string
Key string
IsDeletingPrevious bool
}
func (db *uploadImages) Create(ctx context.Context, opts CreateUploadImageOptions) (*UploadImage, error) {
image := &UploadImage{
UploaderUserID: opts.UploaderUserID,
Name: opts.Name,
FileSize: opts.FileSize,
Md5: opts.Md5,
Key: opts.Key,
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(image).Error; err != nil {
return errors.Wrap(err, "create image")
}
if opts.IsDeletingPrevious {
if err := tx.WithContext(ctx).Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete previous image question link")
}
}
if err := tx.WithContext(ctx).Create(&UploadImageQuestion{
Type: opts.Type,
UploadImageID: image.ID,
QuestionID: opts.QuestionID,
}).Error; err != nil {
return errors.Wrap(err, "create image question link")
}
return nil
}); err != nil {
return nil, err
}
return image, nil
}
func (db *uploadImages) getBy(ctx context.Context, where string, args ...interface{}) ([]*UploadImage, error) {
var uploadImageQuestions []*UploadImageQuestion
if err := db.WithContext(ctx).Model(&UploadImageQuestion{}).Where(where, args...).Find(&uploadImageQuestions).Error; err != nil {
return nil, errors.Wrap(err, "get image questions")
}
uploadImageIDs := lo.Map(uploadImageQuestions, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
var uploadImages []*UploadImage
if err := db.WithContext(ctx).Model(&UploadImage{}).Where("id IN ?", uploadImageIDs).Order("id ASC").Find(&uploadImages).Error; err != nil {
return nil, errors.Wrap(err, "get images")
}
return uploadImages, nil
}
func (db *uploadImages) GetByQuestionID(ctx context.Context, questionID uint) ([]*UploadImage, error) {
return db.getBy(ctx, "question_id = ?", questionID)
}
// GetByQuestionIDs 批量获取多个提问的图片,按 question_id 分组返回(解决导出 N+1)。
// 实现:先按 IN 批量取关联行,再按 IN 批量取图片主体,最后在内存中按 question_id 归组,
// 将每个提问原本的 2 次查询降为整体 2 次查询。
func (db *uploadImages) GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error) {
result := make(map[uint][]*UploadImage)
if len(questionIDs) == 0 {
return result, nil
}
var links []*UploadImageQuestion
if err := db.WithContext(ctx).Model(&UploadImageQuestion{}).Where("question_id IN ?", questionIDs).Find(&links).Error; err != nil {
return nil, errors.Wrap(err, "get image questions batch")
}
if len(links) == 0 {
return result, nil
}
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
var images []*UploadImage
if err := db.WithContext(ctx).Model(&UploadImage{}).Where("id IN ?", imageIDs).Order("id ASC").Find(&images).Error; err != nil {
return nil, errors.Wrap(err, "get images batch")
}
imageByID := lo.SliceToMap(images, func(img *UploadImage) (uint, *UploadImage) {
return img.ID, img
})
for _, link := range links {
if img, ok := imageByID[link.UploadImageID]; ok {
result[link.QuestionID] = append(result[link.QuestionID], img)
}
}
return result, nil
}
func (db *uploadImages) GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error) {
return db.getBy(ctx, "type = ? AND question_id = ?", typ, questionID)
}
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
// 注意:question_id 列在关联表 upload_image_questions 上,不在 upload_images 表上,
// 需先查关联行拿到 imageID 列表,再软删图片主体并删除关联行(同一事务内完成)。
// 注:仅清理数据库记录,对象存储中的实际文件不在此处删除(避免误删复用资源)。
func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint) error {
return db.Transaction(func(tx *gorm.DB) error {
var links []*UploadImageQuestion
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Find(&links).Error; err != nil {
return errors.Wrap(err, "get image questions for delete")
}
if len(links) == 0 {
return nil
}
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete upload images by question id")
}
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete image question links")
}
return nil
})
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2023 E99p1ant. 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"
"testing"
"github.com/stretchr/testify/require"
)
func TestUploadImages(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
uploadImagesStore := NewUploadImagesStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *uploadImages)
}{
{"Create", testUploadImagesCreate},
{"GetByQuestionID", testUploadImagesGetByQuestionID},
{"GetByTypeQuestionID", testUploadImagesGetByTypeQuestionID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("upload_images"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, uploadImagesStore.(*uploadImages))
})
}
}
func testUploadImagesCreate(t *testing.T, ctx context.Context, db *uploadImages) {
got, err := db.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAsk,
QuestionID: 1,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
IsDeletingPrevious: false,
})
require.Nil(t, err)
want := &UploadImage{
Model: got.Model,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
}
require.Equal(t, want, got)
}
func testUploadImagesGetByQuestionID(t *testing.T, ctx context.Context, db *uploadImages) {
_, err := db.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAsk,
QuestionID: 1,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
IsDeletingPrevious: false,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByQuestionID(ctx, 1)
require.Nil(t, err)
want := []*UploadImage{{
Model: got[0].Model,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
}}
require.Equal(t, got, want)
})
t.Run("not found", func(t *testing.T) {
got, err := db.GetByQuestionID(ctx, 2)
require.Nil(t, err)
require.Equal(t, 0, len(got))
})
}
func testUploadImagesGetByTypeQuestionID(t *testing.T, ctx context.Context, db *uploadImages) {
_, err := db.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAsk,
QuestionID: 1,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
IsDeletingPrevious: false,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByTypeQuestionID(ctx, UploadImageQuestionTypeAsk, 1)
require.Nil(t, err)
want := []*UploadImage{{
Model: got[0].Model,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
}}
require.Equal(t, got, want)
})
t.Run("not found", func(t *testing.T) {
got, err := db.GetByTypeQuestionID(ctx, UploadImageQuestionTypeAsk, 2)
require.Nil(t, err)
require.Equal(t, 0, len(got))
})
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2022 E99p1ant. 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 "time"
// UserBlock 记录用户拉黑关系(UserID 拉黑了 BlockedID)。
// 使用独立关联表而非在 users 上存 JSON,以获得:
// - (user_id, blocked_id) 唯一索引保证幂等,并发拉黑不会互相覆盖;
// - 判断是否拉黑走索引查询,无需反序列化整串 JSON。
//
// 注意:本表采用物理删除(取消拉黑即删行),不使用 gorm.DeletedAt 软删除,
// 否则「拉黑 → 取消 → 再拉黑」会因软删记录仍占用唯一索引而插入冲突。
type UserBlock struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null;uniqueIndex:idx_user_block_unique"`
BlockedID uint `gorm:"not null;uniqueIndex:idx_user_block_unique"`
CreatedAt time.Time `json:"-"`
}
+921
View File
@@ -0,0 +1,921 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Modify by tamakyi
package db
import (
"context"
"sort"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/rs/xid"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/conf"
)
var Users UsersStore
var _ UsersStore = (*users)(nil)
type UsersStore interface {
Create(ctx context.Context, opts CreateUserOptions) error
GetByID(ctx context.Context, id uint) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
GetByDomain(ctx context.Context, domain string) (*User, error)
GetByIDs(ctx context.Context, ids []uint) ([]*User, error)
Update(ctx context.Context, id uint, opts UpdateUserOptions) error
// 防骚扰设置拆分为两个独立更新:仅允许注册用户(harassment_setting 列)与
// 全局屏蔽词(block_words 列)。两者设置入口现已分处不同面板(屏蔽词在
// 提问/私聊/投票三个面板共用一份),必须分开写,避免单侧保存误清空另一侧。
UpdateHarassmentType(ctx context.Context, id uint, typ HarassmentSettingType) error
UpdateBlockWords(ctx context.Context, id uint, words string) error
Authenticate(ctx context.Context, email, password string) (*User, error)
ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error
UpdatePassword(ctx context.Context, id uint, newPassword string) error
Deactivate(ctx context.Context, id uint) error
GetShowInHome(ctx context.Context, page, pageSize int, sort string) ([]*User, int64, error)
GetByTag(ctx context.Context, tag string, page, pageSize int, sort string) ([]*User, int64, error)
UpdateTags(ctx context.Context, id uint, opts UpdateTagsOptions) error
GetRandom(ctx context.Context, limit int) ([]*User, error)
GetRandomFresh(ctx context.Context, limit int) ([]*User, error)
GetHotTags(ctx context.Context, limit int) ([]string, error)
// 全站统计
CountAll(ctx context.Context) (int64, error)
UpdatePagePassword(ctx context.Context, id uint, password string) error
UpdateAskPassword(ctx context.Context, id uint, password string) error
// 私聊设置
UpdateChatSettings(ctx context.Context, id uint, allowChat bool, maxMessages int) error
// UpdateAllowChat 仅同步私聊开关(功能管理页统一开关时调用,不影响条数上限)
UpdateAllowChat(ctx context.Context, id uint, allowChat bool) error
// 拉黑相关
BlockUser(ctx context.Context, userID, blockedUserID uint) error
UnblockUser(ctx context.Context, userID, blockedUserID uint) error
IsUserBlocked(ctx context.Context, userID, targetID uint) (bool, error)
BlockedByUser(ctx context.Context, userID uint, targetIDs []uint) (map[uint]bool, error)
BlockedByAnyOf(ctx context.Context, blockerIDs []uint, targetID uint) (map[uint]bool, error)
// 每日限制相关
CountTodayMessagesFromUser(ctx context.Context, ownerID, askerID uint) (int64, error)
UpdateDailyMessageLimit(ctx context.Context, id uint, limit int) error
BanUserAndCleanup(ctx context.Context, userID uint) error
}
func NewUsersStore(db *gorm.DB) UsersStore {
return &users{db}
}
type users struct {
*gorm.DB
}
type User struct {
gorm.Model `json:"-"`
UID string `json:"-"`
Name string `json:"name"`
Password string `json:"-"`
Email string `json:"email" gorm:"uniqueIndex:idx_users_email_unique"`
Avatar string `json:"avatar"`
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
Background string `json:"background"`
Intro string `json:"intro"`
Introcolor string `json:"introcolor"`
Usernamecolor string `json:"usernamecolor"`
Qrcodebackcolor string `json:"qrcodebackcolor"`
Qrcodecolor string `json:"qrcodecolor"`
BackgroundImage string `json:"backgroundimage"`
Dotscale string `json:"dotscale"`
Acfunlink string `json:"acfunlink"`
Bililink string `json:"bililink"`
Weibolink string `json:"weibolink"`
Bloglink string `json:"bloglink"`
Xiaohongshulink string `json:"xiaohongshulink"`
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
Backgroundimagealpha string `json:"Backgroundimagealpha"`
Notify NotifyType `json:"notify"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting"`
BlockWords string `json:"-"`
Tags string `json:"tags" gorm:"type:text"`
ShowInHome bool `json:"show_in_home" gorm:"default:false;index"`
UseCustomQRColor bool `json:"use_custom_qr_color" gorm:"default:false"`
PagePassword string `json:"-" gorm:"column:page_password;type:varchar(255);default:''"`
AskPassword string `json:"-" gorm:"column:ask_password;type:varchar(255);default:''"`
// 私聊相关
AllowChat bool `json:"allow_chat" gorm:"default:false"`
// 每个私聊会话的消息条数上限(用户自设)。default:20 仅是 DB 层兜底;
// 应用层创建用户时按 conf.Limits.ChatMaxMessages([limits] 段)显式写入默认值,
// 新用户默认即系统硬顶,而非固定 20。
ChatMaxMessages int `json:"chat_max_messages" gorm:"default:20"`
// 每日私信条数上限
DailyMessageLimit int `gorm:"default:0" json:"daily_message_limit"`
// 界面语言(提问箱页面与后台页面展示语言,取值见 internal/i18n)
Language string `gorm:"type:varchar(16);default:''" json:"language"`
// PasswordVersion 用于在密码变更后使旧会话失效(会话中保存登录时的版本号)。
PasswordVersion uint `gorm:"default:0" json:"-"`
}
func (u *User) BeforeCreate(_ *gorm.DB) error {
u.UID = xid.New().String()
return nil
}
type NotifyType string
const (
NotifyTypeEmail NotifyType = "email"
NotifyTypeNone NotifyType = "none"
)
type HarassmentSettingType string
const (
HarassmentSettingNone HarassmentSettingType = "none"
HarassmentSettingTypeRegisterOnly HarassmentSettingType = "register_only"
)
func (u *User) EncodePassword() {
u.Password = encodePasswordBcrypt(u.Password)
}
func (u *User) Authenticate(password string) bool {
ok, _ := authenticatePassword(u.Password, password)
return ok
}
func (u *User) SetPagePassword(password string) {
if password == "" {
u.PagePassword = ""
} else {
u.PagePassword = encodePasswordBcrypt(password)
}
}
func (u *User) CheckPagePassword(password string) bool {
ok, _ := authenticatePassword(u.PagePassword, password)
return ok
}
func (u *User) SetAskPassword(password string) {
if password == "" {
u.AskPassword = ""
} else {
u.AskPassword = encodePasswordBcrypt(password)
}
}
func (u *User) CheckAskPassword(password string) bool {
ok, _ := authenticatePassword(u.AskPassword, password)
return ok
}
type CreateUserOptions struct {
Name string
Password string
Email string
Avatar string
Domain string
Background string
Intro string
Dotscale string
Qrcodebackcolor string
Qrcodecolor string
Qrcodepdpcolor string
BackgroundImage string
Backgroundimagealpha string
Usernamecolor string
Introcolor string
Acfunlink string
Bililink string
Weibolink string
Xiaohongshulink string
Bloglink string
Tags string
ShowInHome bool
Language string // 界面语言(i18n 规范代码,如 zh-CN / zh-TW / en / ja)
}
var (
ErrUserNotExists = errors.New("账号不存在")
ErrBadCredential = errors.New("邮箱或密码错误")
ErrDuplicateEmail = errors.New("这个邮箱已经注册过账号了!")
ErrDuplicateDomain = errors.New("个性域名重复了,换一个吧~")
)
func (db *users) Create(ctx context.Context, opts CreateUserOptions) error {
if err := db.validate(ctx, opts); err != nil {
return err
}
newUser := &User{
Name: opts.Name,
Password: opts.Password,
Email: opts.Email,
Avatar: opts.Avatar,
Domain: opts.Domain,
Background: opts.Background,
Intro: opts.Intro,
Qrcodebackcolor: opts.Qrcodebackcolor,
Qrcodecolor: opts.Qrcodecolor,
Dotscale: opts.Dotscale,
BackgroundImage: opts.BackgroundImage,
Backgroundimagealpha: opts.Backgroundimagealpha,
Qrcodepdpcolor: opts.Qrcodepdpcolor,
Notify: NotifyTypeEmail,
Usernamecolor: opts.Usernamecolor,
Introcolor: opts.Introcolor,
Acfunlink: opts.Acfunlink,
Bililink: opts.Bililink,
Weibolink: opts.Weibolink,
Xiaohongshulink: opts.Xiaohongshulink,
Bloglink: opts.Bloglink,
Tags: opts.Tags,
ShowInHome: opts.ShowInHome,
Language: opts.Language,
// 私聊会话条数上限:新用户默认取系统硬顶(conf.Limits.ChatMaxMessages,
// [limits] 配置段),用户可在 1..硬顶 之间下调。不要回退到 gorm default:20。
ChatMaxMessages: conf.Limits.ChatMaxMessages,
}
newUser.EncodePassword()
if err := db.WithContext(ctx).Create(newUser).Error; err != nil {
// 并发同值双过(TOCTOU):唯一索引兜底会返回重复键错误。
// 此时重新执行校验,给出与主动校验一致的友好错误,而非暴露底层数据库错误。
if verr := db.validate(ctx, opts); verr != nil {
return verr
}
return errors.Wrap(err, "create user")
}
return nil
}
func (db *users) getBy(ctx context.Context, where string, args ...interface{}) (*User, error) {
var user User
if err := db.WithContext(ctx).Where(where, args...).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotExists
}
return nil, errors.Wrap(err, "get user")
}
return &user, nil
}
func (db *users) GetByID(ctx context.Context, id uint) (*User, error) {
return db.getBy(ctx, "id = ?", id)
}
func (db *users) GetByEmail(ctx context.Context, email string) (*User, error) {
return db.getBy(ctx, "email = ?", email)
}
func (db *users) GetByDomain(ctx context.Context, domain string) (*User, error) {
return db.getBy(ctx, "domain = ?", domain)
}
// GetByIDs 批量按 ID 查询用户,返回顺序与数据库返回一致(不保证与入参顺序一致),
// 用于批量关联查询,避免循环 GetByID 造成 N+1。
func (db *users) GetByIDs(ctx context.Context, ids []uint) ([]*User, error) {
if len(ids) == 0 {
return []*User{}, nil
}
var users []*User
if err := db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, errors.Wrap(err, "get users by ids")
}
return users, nil
}
type UpdateUserOptions struct {
Name string
Avatar string
Background string
Intro string
Qrcodebackcolor string
Qrcodecolor string
Dotscale string
BackgroundImage string
Backgroundimagealpha string
Qrcodepdpcolor string
Usernamecolor string
Introcolor string
Acfunlink string
Bililink string
Weibolink string
Xiaohongshulink string
Bloglink string
UseCustomQRColor bool
Notify NotifyType
Language string
}
func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) error {
_, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
switch opts.Notify {
case NotifyTypeEmail, NotifyTypeNone:
default:
return errors.Errorf("unexpected notify type: %q", opts.Notify)
}
updates := map[string]interface{}{
"Name": opts.Name,
"Avatar": opts.Avatar,
"Background": opts.Background,
"Intro": opts.Intro,
"Qrcodebackcolor": opts.Qrcodebackcolor,
"Qrcodecolor": opts.Qrcodecolor,
"Dotscale": opts.Dotscale,
"BackgroundImage": opts.BackgroundImage,
"Backgroundimagealpha": opts.Backgroundimagealpha,
"Qrcodepdpcolor": opts.Qrcodepdpcolor,
"Usernamecolor": opts.Usernamecolor,
"Introcolor": opts.Introcolor,
"Acfunlink": opts.Acfunlink,
"Bililink": opts.Bililink,
"Weibolink": opts.Weibolink,
"Xiaohongshulink": opts.Xiaohongshulink,
"Bloglink": opts.Bloglink,
"UseCustomQRColor": opts.UseCustomQRColor,
"Notify": opts.Notify,
"Language": opts.Language,
}
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
}
// UpdateHarassmentType 仅更新骚扰防护类型(none / register_only)。
// 只写 harassment_setting 列,不动 block_words,避免与全局屏蔽词的独立保存互相覆盖。
func (db *users) UpdateHarassmentType(ctx context.Context, id uint, typ HarassmentSettingType) error {
switch typ {
case HarassmentSettingNone, HarassmentSettingTypeRegisterOnly:
default:
return errors.Errorf("unexpected harassment setting type: %q", typ)
}
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"HarassmentSetting": typ,
}).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
}
// UpdateBlockWords 仅更新全局屏蔽词表(逗号分隔,保存前已去重/去空白/限额)。
// 只写 block_words 列,不动 harassment_setting。
func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) error {
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"BlockWords": words,
}).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
}
func (db *users) Authenticate(ctx context.Context, email, password string) (*User, error) {
u, err := db.GetByEmail(ctx, email)
if err != nil {
return nil, ErrBadCredential
}
ok, newHash := authenticatePassword(u.Password, password)
if !ok {
return nil, ErrBadCredential
}
// 透明升级:如果密码是旧的 HMAC-SHA1 格式,自动升级为 bcrypt。
if newHash != "" {
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Update("password", newHash).Error; err != nil {
logrus.WithContext(ctx).WithError(err).Warn("Failed to auto-upgrade password to bcrypt")
} else {
u.Password = newHash
}
}
return u, nil
}
func (db *users) ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error {
u, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
ok, upgradeHash := authenticatePassword(u.Password, oldPassword)
if !ok {
return ErrBadCredential
}
// 如果旧密码触发升级,先升级
if upgradeHash != "" {
u.Password = upgradeHash
}
u.Password = newPassword
u.EncodePassword()
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Updates(map[string]interface{}{
"password": u.Password,
"password_version": gorm.Expr("password_version + 1"),
}).Error; err != nil {
return errors.Wrap(err, "change password")
}
return nil
}
func (db *users) UpdatePassword(ctx context.Context, id uint, newPassword string) error {
u, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
u.Password = newPassword
u.EncodePassword()
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Updates(map[string]interface{}{
"password": u.Password,
"password_version": gorm.Expr("password_version + 1"),
}).Error; err != nil {
return errors.Wrap(err, "change password")
}
return nil
}
func (db *users) Deactivate(ctx context.Context, id uint) error {
u, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
if err := db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID).Error; err != nil {
return errors.Wrap(err, "delete user")
}
return nil
}
func (db *users) validate(ctx context.Context, opts CreateUserOptions) error {
if err := db.WithContext(ctx).Model(&User{}).Where("email = ?", opts.Email).First(&User{}).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return errors.Wrap(err, "validate email")
}
} else {
return ErrDuplicateEmail
}
if err := db.WithContext(ctx).Model(&User{}).Where("domain = ?", opts.Domain).First(&User{}).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return errors.Wrap(err, "validate name")
}
} else {
return ErrDuplicateDomain
}
return nil
}
type UpdateTagsOptions struct {
Tags string
ShowInHome bool
}
func (db *users) UpdateTags(ctx context.Context, id uint, opts UpdateTagsOptions) error {
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"Tags": opts.Tags,
"ShowInHome": opts.ShowInHome,
}).Error; err != nil {
return errors.Wrap(err, "update user tags")
}
return nil
}
func (db *users) GetShowInHome(ctx context.Context, page, pageSize int, sort string) ([]*User, int64, error) {
var users []*User
var count int64
if err := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ?", true).Count(&count).Error; err != nil {
return nil, 0, errors.Wrap(err, "count show in home users")
}
query := db.WithContext(ctx).Where("show_in_home = ?", true)
switch sort {
case "oldest":
query = query.Order("created_at asc")
default:
query = query.Order("created_at desc")
}
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, errors.Wrap(err, "get show in home users")
}
return users, count, nil
}
func (db *users) GetByTag(ctx context.Context, tag string, page, pageSize int, sort string) ([]*User, int64, error) {
var users []*User
var count int64
// tags 以逗号分隔存储;按数据库类型使用不同的标签匹配语法:
// PostgreSQL: string_to_array + ANY;MySQL/SQLite: FIND_IN_SET
var tagCond string
if conf.Database.Type == "postgres" {
tagCond = "? = ANY(string_to_array(tags, ','))"
} else {
tagCond = "FIND_IN_SET(?, tags)"
}
baseQuery := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ? AND tags IS NOT NULL AND "+tagCond, true, tag)
if err := baseQuery.Count(&count).Error; err != nil {
return nil, 0, errors.Wrap(err, "count users by tag")
}
query := baseQuery
switch sort {
case "oldest":
query = query.Order("created_at asc")
default:
query = query.Order("created_at desc")
}
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, errors.Wrap(err, "get users by tag")
}
return users, count, nil
}
// 随机推荐列表缓存:ORDER BY RANDOM() 在大表上会全表扫描+排序,
// explore 页面高频调用,用 30 秒短缓存吸收并发,成本是列表最多滞后 30 秒。
// 缓存池大小 24(4 倍展示数),初始页面取前 6 个,
// "换一批"用 GetRandomFresh 绕过缓存,确保每次返回不同结果。
var (
randomUsersCacheMu sync.Mutex
randomUsersCache []*User
randomUsersCacheTime time.Time
)
const (
randomUsersCacheTTL = 30 * time.Second
randomCachePoolSize = 24
)
// GetRandom 获取随机用户(带缓存)。缓存池大小 24,返回前 limit 个。
// 返回的是切片副本,调用方修改返回值不会污染缓存。
func (db *users) GetRandom(ctx context.Context, limit int) ([]*User, error) {
randomUsersCacheMu.Lock()
if randomUsersCache != nil && time.Since(randomUsersCacheTime) < randomUsersCacheTTL {
cached := copyUsers(randomUsersCache)
randomUsersCacheMu.Unlock()
if limit < len(cached) {
cached = cached[:limit]
}
return cached, nil
}
randomUsersCacheMu.Unlock()
// 缓存未命中,查询一个较大的池以覆盖后续请求
users, err := db.getRandomUsers(ctx, randomCachePoolSize)
if err != nil {
return nil, err
}
// 更新缓存(缓存持有的是 DB 查询结果,与返回给调用方的副本分离)
randomUsersCacheMu.Lock()
randomUsersCache = users
randomUsersCacheTime = time.Now()
randomUsersCacheMu.Unlock()
copied := copyUsers(users)
if limit < len(copied) {
copied = copied[:limit]
}
return copied, nil
}
// copyUsers 深拷贝 User 切片,避免调用方修改(如给 User 字段赋值)污染缓存。
func copyUsers(src []*User) []*User {
dst := make([]*User, len(src))
for i, u := range src {
if u == nil {
continue
}
cp := *u
dst[i] = &cp
}
return dst
}
// GetRandomFresh 获取随机用户(绕过缓存),用于"换一批"按钮。
func (db *users) GetRandomFresh(ctx context.Context, limit int) ([]*User, error) {
return db.getRandomUsers(ctx, limit)
}
func (db *users) getRandomUsers(ctx context.Context, limit int) ([]*User, error) {
var users []*User
randomFunc := "RAND()" // MySQL / SQLite 默认
if conf.Database.Type == "postgres" {
randomFunc = "RANDOM()"
}
if err := db.WithContext(ctx).
Where("show_in_home = ?", true).
Order(randomFunc).
Limit(limit).
Find(&users).Error; err != nil {
return nil, errors.Wrap(err, "get random users")
}
return users, nil
}
// 热标签缓存:GetHotTags 每次 explore 都全表 Pluck tags 再内存聚合,
// 标签变化不频繁,用 60 秒进程内缓存吸收重复计算,避免每次翻页都触发全表扫描。
var (
hotTagsCacheMu sync.Mutex
hotTagsCache []string
hotTagsCacheTime time.Time
)
const hotTagsCacheTTL = 60 * time.Second
// CountAll 返回全站注册用户总数(首页统计用,不按单个用户过滤)。
func (db *users) CountAll(ctx context.Context) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&User{}).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count all users")
}
return count, nil
}
// GetHotTags 统计所有展示在首页的用户标签,返回出现次数最多的前 limit 个标签。
// 标签存于 users.tags(逗号分隔),此处取出后在内存中聚合统计,结果带短缓存。
func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) {
if limit <= 0 {
limit = 10
}
hotTagsCacheMu.Lock()
if hotTagsCache != nil && time.Since(hotTagsCacheTime) < hotTagsCacheTTL {
cached := append([]string(nil), hotTagsCache...)
hotTagsCacheMu.Unlock()
if limit < len(cached) {
cached = cached[:limit]
}
return cached, nil
}
hotTagsCacheMu.Unlock()
var tagsList []string
if err := db.WithContext(ctx).
Model(&User{}).
Where("show_in_home = ? AND tags IS NOT NULL AND tags != ''", true).
Pluck("tags", &tagsList).Error; err != nil {
return nil, errors.Wrap(err, "get user tags for hot tags")
}
counts := make(map[string]int)
for _, tags := range tagsList {
for _, tag := range strings.Split(tags, ",") {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
counts[tag]++
}
}
// 按出现次数降序
hot := make([]string, 0, len(counts))
for tag := range counts {
hot = append(hot, tag)
}
sort.Slice(hot, func(i, j int) bool {
if counts[hot[i]] == counts[hot[j]] {
return hot[i] < hot[j]
}
return counts[hot[i]] > counts[hot[j]]
})
// 缓存完整排序结果(不按 limit 截断),供不同 limit 的调用方复用。
hotTagsCacheMu.Lock()
hotTagsCache = append([]string(nil), hot...)
hotTagsCacheTime = time.Now()
hotTagsCacheMu.Unlock()
if len(hot) > limit {
hot = hot[:limit]
}
return hot, nil
}
func (db *users) UpdatePagePassword(ctx context.Context, id uint, password string) error {
user, err := db.GetByID(ctx, id)
if err != nil {
return err
}
user.SetPagePassword(password)
return db.WithContext(ctx).Model(user).Update("page_password", user.PagePassword).Error
}
func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string) error {
user, err := db.GetByID(ctx, id)
if err != nil {
return err
}
user.SetAskPassword(password)
return db.WithContext(ctx).Model(user).Update("ask_password", user.AskPassword).Error
}
// 新增私聊设置
func (db *users) UpdateChatSettings(ctx context.Context, id uint, allowChat bool, maxMessages int) error {
return db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"allow_chat": allowChat,
"chat_max_messages": maxMessages,
}).Error
}
// UpdateAllowChat 仅更新 users.allow_chat,用于与 feature_controls 的私聊开关保持同步
// (users.allow_chat 仍被邮件/导出等既有逻辑读取,保留双向一致)。
func (db *users) UpdateAllowChat(ctx context.Context, id uint, allowChat bool) error {
return db.WithContext(ctx).Model(&User{}).Where("id = ?", id).
Update("allow_chat", allowChat).Error
}
// ---------- 拉黑与每日限制 ----------
// BlockUser 拉黑用户。使用 (user_id, blocked_id) 唯一索引 + INSERT ... ON CONFLICT DO NOTHING,
// 幂等且并发安全(重复/并发拉黑不会互相覆盖丢失)。
func (db *users) BlockUser(ctx context.Context, userID, blockedUserID uint) error {
if userID == blockedUserID {
return errors.New("cannot block self")
}
block := &UserBlock{UserID: userID, BlockedID: blockedUserID}
if err := db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
Create(block).Error; err != nil {
return errors.Wrap(err, "block user")
}
return nil
}
// UnblockUser 取消拉黑(物理删除关联行)。
func (db *users) UnblockUser(ctx context.Context, userID, blockedUserID uint) error {
if err := db.WithContext(ctx).
Where("user_id = ? AND blocked_id = ?", userID, blockedUserID).
Delete(&UserBlock{}).Error; err != nil {
return errors.Wrap(err, "unblock user")
}
return nil
}
// IsUserBlocked 检查 userID 是否拉黑了 targetID(走唯一索引查询)。
func (db *users) IsUserBlocked(ctx context.Context, userID, targetID uint) (bool, error) {
var count int64
if err := db.WithContext(ctx).Model(&UserBlock{}).
Where("user_id = ? AND blocked_id = ?", userID, targetID).
Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check block status")
}
return count > 0, nil
}
// BlockedByUser 批量判断:返回 targetIDs 中被 userID 拉黑的子集(map[targetID]bool)。
func (db *users) BlockedByUser(ctx context.Context, userID uint, targetIDs []uint) (map[uint]bool, error) {
result := make(map[uint]bool, len(targetIDs))
if len(targetIDs) == 0 {
return result, nil
}
var blocks []UserBlock
if err := db.WithContext(ctx).
Where("user_id = ? AND blocked_id IN ?", userID, targetIDs).
Find(&blocks).Error; err != nil {
return nil, errors.Wrap(err, "get blocked users")
}
for _, b := range blocks {
result[b.BlockedID] = true
}
return result, nil
}
// BlockedByAnyOf 批量判断:返回 blockerIDs 中拉黑了 targetID 的子集(map[blockerID]bool)。
func (db *users) BlockedByAnyOf(ctx context.Context, blockerIDs []uint, targetID uint) (map[uint]bool, error) {
result := make(map[uint]bool, len(blockerIDs))
if len(blockerIDs) == 0 {
return result, nil
}
var blocks []UserBlock
if err := db.WithContext(ctx).
Where("user_id IN ? AND blocked_id = ?", blockerIDs, targetID).
Find(&blocks).Error; err != nil {
return nil, errors.Wrap(err, "get blockers")
}
for _, b := range blocks {
result[b.UserID] = true
}
return result, nil
}
// CountTodayMessagesFromUser 统计某提问者当天给某被提问者发送的私信总条数。
// 使用应用进程所在时区(time.Local)的"当日 00:00"作为窗口起点,
// 而非数据库服务器的 CURRENT_DATE,避免 DB 时区与应用时区不一致导致每日上限窗口偏移。
func (db *users) CountTodayMessagesFromUser(ctx context.Context, ownerID, askerID uint) (int64, error) {
now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var count int64
err := db.WithContext(ctx).Table("messages").
Joins("JOIN conversations ON conversations.id = messages.conversation_id").
Where("conversations.owner_id = ? AND messages.sender_id = ?", ownerID, askerID).
Where("messages.created_at >= ?", startOfDay).
Count(&count).Error
return count, err
}
// UpdateDailyMessageLimit 更新每日限制
func (db *users) UpdateDailyMessageLimit(ctx context.Context, id uint, limit int) error {
return db.WithContext(ctx).Model(&User{}).Where("id = ?", id).
Update("daily_message_limit", limit).Error
}
// BanUserAndCleanup 封禁用户并连带清理其全部数据(提问、追问、私聊、消息、图片关联),
// 最后物理删除用户记录。供管理 CLI 命令(tamabox ban)使用。
//
// 注意:该操作不可恢复,执行前请确认目标账号。
func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 追问:属于该用户提问下的追问
if err := tx.WithContext(ctx).
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups of banned user")
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
Delete(&Message{}).Error; err != nil {
return errors.Wrap(err, "delete messages of banned user")
}
// 消息图片
if err := tx.WithContext(ctx).
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of banned user")
}
// 私聊会话
if err := tx.WithContext(ctx).
Where("owner_id = ? OR asker_id = ?", userID, userID).
Delete(&Conversation{}).Error; err != nil {
return errors.Wrap(err, "delete conversations of banned user")
}
// 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留)
if err := tx.WithContext(ctx).
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete upload image links of banned user")
}
// 提问
if err := tx.WithContext(ctx).
Where("user_id = ?", userID).
Delete(&Question{}).Error; err != nil {
return errors.Wrap(err, "delete questions of banned user")
}
// 拉黑关系(该用户拉黑别人,或被别人拉黑)
if err := tx.WithContext(ctx).
Where("user_id = ? OR blocked_id = ?", userID, userID).
Delete(&UserBlock{}).Error; err != nil {
return errors.Wrap(err, "delete user blocks of banned user")
}
// 功能开关配置(提问 / 私聊 / 投票)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&FeatureControl{}).Error; err != nil {
return errors.Wrap(err, "delete feature controls of banned user")
}
// 物理删除用户
if err := tx.WithContext(ctx).Unscoped().Delete(&User{}, userID).Error; err != nil {
return errors.Wrap(err, "delete banned user")
}
return nil
})
}
+428
View File
@@ -0,0 +1,428 @@
// Copyright 2023 E99p1ant. 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"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestUsers(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
usersStore := NewUsersStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *users)
}{
{"Create", testUsersCreate},
{"GetByID", testUsersGetByID},
{"GetByEmail", testUsersGetByEmail},
{"GetByDomain", testUsersGetByDomain},
{"Update", testUsersUpdate},
{"UpdateHarassmentType", testUsersUpdateHarassmentType},
{"UpdateBlockWords", testUsersUpdateBlockWords},
{"Authenticate", testUsersAuthenticate},
{"ChangePassword", testUsersChangePassword},
{"UpdatePassword", testUsersUpdatePassword},
{"Deactivate", testUsersDeactivate},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("users"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, usersStore.(*users))
})
}
}
func testUsersCreate(t *testing.T, ctx context.Context, db *users) {
t.Run("normal", func(t *testing.T) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
})
t.Run("repeat email", func(t *testing.T) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99p1ant",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Equal(t, ErrDuplicateEmail, err)
})
t.Run("repeat domain", func(t *testing.T) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Equal(t, ErrDuplicateDomain, err)
})
}
func testUsersGetByID(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
want.EncodePassword()
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrUserNotExists, err)
})
}
func testUsersGetByEmail(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByEmail(ctx, "[email protected]")
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
want.EncodePassword()
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByEmail(ctx, "404")
require.Equal(t, ErrUserNotExists, err)
})
}
func testUsersGetByDomain(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByDomain(ctx, "e99")
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
want.EncodePassword()
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByDomain(ctx, "404")
require.Equal(t, ErrUserNotExists, err)
})
}
func testUsersUpdate(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.Update(ctx, 1, UpdateUserOptions{
Name: "e99",
Avatar: "new_avatar.png",
Background: "new_background.png",
Intro: "Be cool, but also be warm!!",
Notify: NotifyTypeNone,
})
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "e99",
Password: "super_secret",
Email: "[email protected]",
Avatar: "new_avatar.png",
Domain: "e99",
Background: "new_background.png",
Intro: "Be cool, but also be warm!!",
Notify: NotifyTypeNone,
}
want.EncodePassword()
require.Equal(t, want, got)
})
}
func testUsersUpdateHarassmentType(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.UpdateHarassmentType(ctx, 1, HarassmentSettingTypeRegisterOnly)
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, HarassmentSettingTypeRegisterOnly, got.HarassmentSetting)
// 只应更新类型,屏蔽词列不受影响
require.Equal(t, "", got.BlockWords)
})
t.Run("unexpected harassment setting", func(t *testing.T) {
err := db.UpdateHarassmentType(ctx, 1, HarassmentSettingType("not found"))
require.NotNil(t, err)
})
}
func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.UpdateBlockWords(ctx, 1, "广告,抽奖")
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "广告,抽奖", got.BlockWords)
// 只应更新屏蔽词,类型列不受影响
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting)
})
t.Run("empty clears words", func(t *testing.T) {
err := db.UpdateBlockWords(ctx, 1, "")
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "", got.BlockWords)
})
}
func testUsersAuthenticate(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
got, err := db.Authenticate(ctx, "[email protected]", "super_secret")
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
want.EncodePassword()
require.Equal(t, want, got)
}
func testUsersChangePassword(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.ChangePassword(ctx, 1, "super_secret", "new_password")
require.Nil(t, err)
})
t.Run("wrong password", func(t *testing.T) {
err := db.ChangePassword(ctx, 1, "wrong_password", "new_password")
require.Equal(t, ErrBadCredential, err)
})
}
func testUsersUpdatePassword(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.UpdatePassword(ctx, 1, "new_password")
require.Nil(t, err)
})
}
func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.Deactivate(ctx, 1)
require.Nil(t, err)
})
t.Run("not found", func(t *testing.T) {
err := db.Deactivate(ctx, 404)
require.NotNil(t, err)
})
}
+184
View File
@@ -0,0 +1,184 @@
// 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 VoteFormFields VoteFormFieldsStore
// CustomFieldMarker 参与端"其他/自定义选项"的保留标记值(radio/select/checkbox 字段)。
// 选中该值时,后端从 field_{id}_custom 读取参与者填写的文本;预设选项不允许使用该保留值。
const CustomFieldMarker = "__tb_custom_option__"
// 表单字段类型(type=form 时使用)
const (
VoteFormFieldTypeText = "text" // 单行文本
VoteFormFieldTypeTextarea = "textarea" // 多行文本
VoteFormFieldTypeSelect = "select" // 下拉单选
VoteFormFieldTypeRadio = "radio" // 单选
VoteFormFieldTypeCheckbox = "checkbox" // 多选
VoteFormFieldTypeNumber = "number" // 数字
VoteFormFieldTypeRating = "rating" // 评分
VoteFormFieldTypeRank = "rank" // 排序(对预置选项排 1..N)
)
type VoteFormFieldsStore interface {
Create(ctx context.Context, opts CreateVoteFormFieldOptions) (*VoteFormField, error)
BulkCreate(ctx context.Context, voteID uint, fields []CreateVoteFormFieldOptions) ([]*VoteFormField, error)
ListByVoteID(ctx context.Context, voteID uint) ([]*VoteFormField, error)
ReplaceByVoteID(ctx context.Context, voteID uint, fields []CreateVoteFormFieldOptions) error
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
DeleteByVoteID(ctx context.Context, voteID uint) error
DeleteByID(ctx context.Context, id uint) error
}
func NewVoteFormFieldsStore(db *gorm.DB) VoteFormFieldsStore {
return &voteFormFields{db}
}
type voteFormFields struct {
*gorm.DB
}
// VoteFormField 表单字段定义表:结构化表单(type=form)的字段配置。
type VoteFormField struct {
dbutil.Model
VoteID uint `gorm:"index:idx_form_field_vote" json:"vote_id"`
FieldType string `gorm:"size:20" json:"field_type"`
Label string `gorm:"size:100" json:"label"`
Required bool `gorm:"default:false" json:"required"`
MaxLength int `gorm:"default:0" json:"max_length"` // 0 = 不限制(字数限制)
Options string `gorm:"type:text" json:"options"` // JSON 数组(select/radio/checkbox 用)
SortOrder int `gorm:"default:0" json:"sort_order"`
AllowCustom bool `gorm:"default:false" json:"allow_custom"` // 允许参与者补充自定义选项(radio/select/checkbox)
}
type CreateVoteFormFieldOptions struct {
VoteID uint
FieldType string
Label string
Required bool
MaxLength int
Options string
SortOrder int
AllowCustom bool
}
func (db *voteFormFields) Create(ctx context.Context, opts CreateVoteFormFieldOptions) (*VoteFormField, error) {
field := &VoteFormField{
VoteID: opts.VoteID,
FieldType: opts.FieldType,
Label: opts.Label,
Required: opts.Required,
MaxLength: opts.MaxLength,
Options: opts.Options,
SortOrder: opts.SortOrder,
AllowCustom: opts.AllowCustom,
}
if err := db.WithContext(ctx).Create(field).Error; err != nil {
return nil, errors.Wrap(err, "create vote form field")
}
return field, nil
}
// BulkCreate 批量创建表单字段(箱主一次提交全部字段配置)。
func (db *voteFormFields) BulkCreate(ctx context.Context, voteID uint, fields []CreateVoteFormFieldOptions) ([]*VoteFormField, error) {
formFields := make([]*VoteFormField, 0, len(fields))
for _, f := range fields {
formFields = append(formFields, &VoteFormField{
VoteID: voteID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
if len(formFields) == 0 {
return formFields, nil
}
if err := db.WithContext(ctx).Create(&formFields).Error; err != nil {
return nil, errors.Wrap(err, "bulk create vote form fields")
}
return formFields, nil
}
// ListByVoteID 获取某投票的全部表单字段(按 SortOrder 升序)。
func (db *voteFormFields) ListByVoteID(ctx context.Context, voteID uint) ([]*VoteFormField, error) {
var fields []*VoteFormField
if err := db.WithContext(ctx).
Where("vote_id = ?", voteID).
Order("sort_order ASC, id ASC").
Find(&fields).Error; err != nil {
return nil, errors.Wrap(err, "list vote form fields by vote id")
}
return fields, nil
}
// CountByVoteID 统计某投票的表单字段数量(发布前完整性校验用)。
func (db *voteFormFields) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteFormField{}).Where("vote_id = ?", voteID).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote form fields by vote id")
}
return count, nil
}
// ReplaceByVoteID 在单个事务内替换投票的全部表单字段(先删后建),保证原子性。
// 仅在草稿状态编辑时调用。
func (db *voteFormFields) ReplaceByVoteID(ctx context.Context, voteID uint, fields []CreateVoteFormFieldOptions) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("vote_id = ?", voteID).Delete(&VoteFormField{}).Error; err != nil {
return err
}
if len(fields) == 0 {
return nil
}
formFields := make([]*VoteFormField, 0, len(fields))
for _, f := range fields {
formFields = append(formFields, &VoteFormField{
VoteID: voteID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
return tx.Create(&formFields).Error
})
}
// DeleteByVoteID 删除投票时级联清理全部表单字段。
func (db *voteFormFields) DeleteByVoteID(ctx context.Context, voteID uint) error {
if err := db.WithContext(ctx).Where("vote_id = ?", voteID).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields by vote id")
}
return nil
}
func (db *voteFormFields) DeleteByID(ctx context.Context, id uint) error {
res := db.WithContext(ctx).Delete(&VoteFormField{}, id)
if res.Error != nil {
return errors.Wrap(res.Error, "delete vote form field")
}
if res.RowsAffected == 0 {
return ErrVoteFormFieldNotExist
}
return nil
}
var ErrVoteFormFieldNotExist = errors.New("投票表单字段不存在")
+109
View File
@@ -0,0 +1,109 @@
// 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"
"testing"
"github.com/stretchr/testify/require"
)
func TestVoteFormFields(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewVoteFormFieldsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *voteFormFields)
}{
{"Create", testVoteFormFieldsCreate},
{"BulkCreate", testVoteFormFieldsBulkCreate},
{"ListByVoteID", testVoteFormFieldsListByVoteID},
{"DeleteByID", testVoteFormFieldsDeleteByID},
{"DeleteByVoteID", testVoteFormFieldsDeleteByVoteID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("vote_form_fields"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store.(*voteFormFields))
})
}
}
func testVoteFormFieldsCreate(t *testing.T, ctx context.Context, db *voteFormFields) {
got, err := db.Create(ctx, CreateVoteFormFieldOptions{
VoteID: 1,
FieldType: VoteFormFieldTypeText,
Label: "你的昵称",
Required: true,
MaxLength: 50,
})
require.Nil(t, err)
require.Equal(t, uint(1), got.ID)
require.True(t, got.Required)
require.Equal(t, 50, got.MaxLength)
}
func testVoteFormFieldsBulkCreate(t *testing.T, ctx context.Context, db *voteFormFields) {
got, err := db.BulkCreate(ctx, 1, []CreateVoteFormFieldOptions{
{FieldType: VoteFormFieldTypeText, Label: "昵称", Required: true, MaxLength: 20, SortOrder: 0},
{FieldType: VoteFormFieldTypeTextarea, Label: "建议", Required: false, MaxLength: 500, SortOrder: 1},
{FieldType: VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般","不满意"]`, SortOrder: 2},
})
require.Nil(t, err)
require.Len(t, got, 3)
require.Equal(t, VoteFormFieldTypeTextarea, got[1].FieldType)
require.Equal(t, `["满意","一般","不满意"]`, got[2].Options)
}
func testVoteFormFieldsListByVoteID(t *testing.T, ctx context.Context, db *voteFormFields) {
_, err := db.BulkCreate(ctx, 1, []CreateVoteFormFieldOptions{
{FieldType: VoteFormFieldTypeText, Label: "B", SortOrder: 1},
{FieldType: VoteFormFieldTypeText, Label: "A", SortOrder: 0},
})
require.Nil(t, err)
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 2)
require.Equal(t, "A", got[0].Label, "按 SortOrder 升序")
}
func testVoteFormFieldsDeleteByID(t *testing.T, ctx context.Context, db *voteFormFields) {
_, err := db.Create(ctx, CreateVoteFormFieldOptions{VoteID: 1, FieldType: VoteFormFieldTypeText, Label: "A"})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
require.Nil(t, db.DeleteByID(ctx, 1))
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 0)
})
t.Run("not found", func(t *testing.T) {
require.Equal(t, ErrVoteFormFieldNotExist, db.DeleteByID(ctx, 404))
})
}
func testVoteFormFieldsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteFormFields) {
_, err := db.BulkCreate(ctx, 1, []CreateVoteFormFieldOptions{
{FieldType: VoteFormFieldTypeText, Label: "A"},
{FieldType: VoteFormFieldTypeText, Label: "B"},
})
require.Nil(t, err)
require.Nil(t, db.DeleteByVoteID(ctx, 1))
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 0)
}
+163
View File
@@ -0,0 +1,163 @@
// 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
}
// VoteOption 选项表:单选/多选/评分/排序共用。
type VoteOption struct {
dbutil.Model
VoteID uint `gorm:"index:idx_option_vote" json:"vote_id"`
Content string `gorm:"size:200" 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
}
+116
View File
@@ -0,0 +1,116 @@
// 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"
"testing"
"github.com/stretchr/testify/require"
)
func TestVoteOptions(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewVoteOptionsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *voteOptions)
}{
{"Create", testVoteOptionsCreate},
{"BulkCreate", testVoteOptionsBulkCreate},
{"ListByVoteID", testVoteOptionsListByVoteID},
{"DeleteByID", testVoteOptionsDeleteByID},
{"DeleteByVoteID", testVoteOptionsDeleteByVoteID},
{"CountByVoteID", testVoteOptionsCountByVoteID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("vote_options"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store.(*voteOptions))
})
}
}
func testVoteOptionsCreate(t *testing.T, ctx context.Context, db *voteOptions) {
got, err := db.Create(ctx, CreateVoteOptionOptions{
VoteID: 1,
Content: "选项 A",
SortOrder: 0,
})
require.Nil(t, err)
require.Equal(t, uint(1), got.ID)
require.Equal(t, "选项 A", got.Content)
}
func testVoteOptionsBulkCreate(t *testing.T, ctx context.Context, db *voteOptions) {
got, err := db.BulkCreate(ctx, 1, []string{"A", "B", "C"})
require.Nil(t, err)
require.Len(t, got, 3)
// SortOrder 应按传入顺序从 0 递增
require.Equal(t, 0, got[0].SortOrder)
require.Equal(t, 2, got[2].SortOrder)
require.Equal(t, "C", got[2].Content)
}
func testVoteOptionsListByVoteID(t *testing.T, ctx context.Context, db *voteOptions) {
_, err := db.BulkCreate(ctx, 1, []string{"B", "A", "C"})
require.Nil(t, err)
_, err = db.BulkCreate(ctx, 2, []string{"X"})
require.Nil(t, err)
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 3)
require.Equal(t, "B", got[0].Content, "按 SortOrder 升序:B(sort 0), A(sort 1), C(sort 2)")
require.Equal(t, "C", got[2].Content)
}
func testVoteOptionsDeleteByID(t *testing.T, ctx context.Context, db *voteOptions) {
_, err := db.Create(ctx, CreateVoteOptionOptions{VoteID: 1, Content: "A"})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
require.Nil(t, db.DeleteByID(ctx, 1))
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 0)
})
t.Run("not found", func(t *testing.T) {
require.Equal(t, ErrVoteOptionNotExist, db.DeleteByID(ctx, 404))
})
}
func testVoteOptionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteOptions) {
_, err := db.BulkCreate(ctx, 1, []string{"A", "B", "C"})
require.Nil(t, err)
require.Nil(t, db.DeleteByVoteID(ctx, 1))
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 0)
}
func testVoteOptionsCountByVoteID(t *testing.T, ctx context.Context, db *voteOptions) {
_, err := db.BulkCreate(ctx, 1, []string{"A", "B"})
require.Nil(t, err)
got, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByVoteID(ctx, 404)
require.Nil(t, err)
require.Equal(t, int64(0), got)
}
+256
View File
@@ -0,0 +1,256 @@
// 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"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/jackc/pgconn"
"github.com/pkg/errors"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var VoteSubmissions VoteSubmissionsStore
type VoteSubmissionsStore interface {
Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error)
GetByID(ctx context.Context, id uint) (*VoteSubmission, error)
ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error)
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error)
CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error)
CountGroupByOptionID(ctx context.Context, voteID uint) (map[uint]int64, error)
CountByIP(ctx context.Context, voteID uint, ip string, since time.Time) (int64, error)
HasUserVoted(ctx context.Context, voteID, userID uint) (bool, error)
HasTokenVoted(ctx context.Context, voteID uint, token string) (bool, error)
DeleteByVoteID(ctx context.Context, voteID uint) error
}
func NewVoteSubmissionsStore(db *gorm.DB) VoteSubmissionsStore {
return &voteSubmissions{db}
}
type voteSubmissions struct {
*gorm.DB
}
// VoteSubmission 投票/提交记录(不记名:IP / token / user_id 仅用于防刷与审计,任何界面不展示)。
//
// 防一人一票的数据库硬保证(复合唯一索引 + NULL 不冲突,规避 MySQL 5.7 部分索引兼容问题):
// - UNIQUE(vote_id, user_id):登录用户一个账号对同一投票只能投一次(匿名行 user_id 为 NULL 不冲突)
// - UNIQUE(vote_id, voter_token):同一匿名指纹(服务端签发 Cookie)只能投一次(登录行 voter_token 为 NULL 不冲突)
type VoteSubmission struct {
dbutil.Model
VoteID uint `gorm:"index:idx_submission_vote;uniqueIndex:idx_submission_user;uniqueIndex:idx_submission_token" json:"vote_id"`
OptionID uint `gorm:"index:idx_submission_option" json:"option_id"`
UserID *uint `gorm:"uniqueIndex:idx_submission_user" json:"-"`
// HMAC token = 64-char hex nonce + dot + 43-char base64url signature (108 chars).
VoterToken *string `gorm:"size:128;uniqueIndex:idx_submission_token" json:"-"`
VoterIP string `gorm:"size:64;index:idx_submission_ip" json:"-"`
FormData datatypes.JSON `json:"form_data"`
CustomText string `gorm:"type:text" json:"custom_text"`
}
type CreateVoteSubmissionOptions struct {
VoteID uint
OptionID uint
UserID *uint
VoterToken *string
VoterIP string
FormData datatypes.JSON
CustomText string
}
var (
// ErrVoteAlreadyVoted 同一投票被同一身份重复提交(数据库唯一索引兜底返回)。
ErrVoteAlreadyVoted = errors.New("你已参与过该投票")
ErrVoteSubmissionNotExist = errors.New("投票记录不存在")
)
// Create 写入一条投票/提交记录。若命中复合唯一索引(同一 user 或同一 token 重复提交),
// 返回 ErrVoteAlreadyVoted。
func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error) {
submission := &VoteSubmission{
VoteID: opts.VoteID,
OptionID: opts.OptionID,
UserID: opts.UserID,
VoterToken: opts.VoterToken,
VoterIP: opts.VoterIP,
FormData: opts.FormData,
CustomText: opts.CustomText,
}
if err := db.WithContext(ctx).Create(submission).Error; err != nil {
if isDuplicateKeyError(err) {
return nil, ErrVoteAlreadyVoted
}
return nil, errors.Wrap(err, "create vote submission")
}
return submission, nil
}
func (db *voteSubmissions) GetByID(ctx context.Context, id uint) (*VoteSubmission, error) {
var submission VoteSubmission
if err := db.WithContext(ctx).First(&submission, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVoteSubmissionNotExist
}
return nil, errors.Wrap(err, "get vote submission by ID")
}
return &submission, nil
}
// ListByVoteID 分页获取某投票的提交记录(箱主查看表单回复用,新提交在前)。
func (db *voteSubmissions) ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error) {
q := db.WithContext(ctx).Model(&VoteSubmission{}).Where("vote_id = ?", voteID)
if cursor != nil && cursor.Value != nil {
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
cursorStr := strings.TrimSpace(cursorValueToString(cursor.Value))
if idVal, err := strconv.ParseUint(cursorStr, 10, 64); err == nil && idVal > 0 {
q = q.Where("id < ?", idVal)
}
}
if cursor != nil {
q = q.Limit(cursor.Limit())
}
q = q.Order("created_at DESC")
var submissions []*VoteSubmission
if err := q.Find(&submissions).Error; err != nil {
return nil, errors.Wrap(err, "list vote submissions by vote id")
}
return submissions, nil
}
// CountByVoteID 统计某投票的总参与人数。
func (db *voteSubmissions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).Where("vote_id = ?", voteID).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote submissions by vote id")
}
return count, nil
}
// CountByVoteIDs 一次查询批量统计多个投票的参与人数(替代逐投票 N 次 COUNT,消除 N+1)。
// 返回 map[voteID]count;未出现在结果中的 voteID 视为 0 参与。
func (db *voteSubmissions) 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(&VoteSubmission{}).
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 submissions by vote ids")
}
for _, r := range rows {
out[r.VoteID] = r.Cnt
}
return out, nil
}
// CountByOptionID 统计某选项的票数(结果页条形图数据源)。
func (db *voteSubmissions) CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Where("vote_id = ? AND option_id = ?", voteID, optionID).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote submissions by option id")
}
return count, nil
}
// CountGroupByOptionID 一次查询统计某投票下各选项的票数(替代逐选项 N 次 COUNT)。
func (db *voteSubmissions) CountGroupByOptionID(ctx context.Context, voteID uint) (map[uint]int64, error) {
type row struct {
OptionID uint
Cnt int64
}
var rows []row
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Select("option_id, COUNT(id) AS cnt").
Where("vote_id = ?", voteID).
Group("option_id").
Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "count vote submissions group by option id")
}
out := make(map[uint]int64, len(rows))
for _, r := range rows {
out[r.OptionID] = r.Cnt
}
return out, nil
}
// CountByIP 统计同一 IP 在 since 时间窗内的提交次数(匿名防刷兜底:清 Cookie 重投时按 IP 计数)。
func (db *voteSubmissions) CountByIP(ctx context.Context, voteID uint, ip string, since time.Time) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Where("vote_id = ? AND voter_ip = ? AND created_at >= ?", voteID, ip, since).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote submissions by ip")
}
return count, nil
}
// HasUserVoted 判断登录用户是否已投过该投票(应用层预检查,返回友好提示;最终防重由唯一索引兜底)。
func (db *voteSubmissions) HasUserVoted(ctx context.Context, voteID, userID uint) (bool, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Where("vote_id = ? AND user_id = ?", voteID, userID).
Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check user voted")
}
return count > 0, nil
}
// HasTokenVoted 判断匿名指纹是否已投过该投票。
func (db *voteSubmissions) HasTokenVoted(ctx context.Context, voteID uint, token string) (bool, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Where("vote_id = ? AND voter_token = ?", voteID, token).
Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check token voted")
}
return count > 0, nil
}
// DeleteByVoteID 删除投票时级联清理全部提交记录。
//
// 使用 Unscoped 物理删除而非软删除:VoteSubmission 上的复合唯一索引
// (vote_id, user_id)/(vote_id, voter_token) 不含 deleted_at,软删除会让被删行继续占据
// 唯一槽位,导致"统计归零却无法重投"。级联删除发生在整个投票被删时,物理删除安全且正确。
func (db *voteSubmissions) DeleteByVoteID(ctx context.Context, voteID uint) error {
if err := db.WithContext(ctx).Unscoped().Where("vote_id = ?", voteID).Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions by vote id")
}
return nil
}
// isDuplicateKeyError 判断数据库唯一索引冲突错误(MySQL 1062 / PostgreSQL 23505)。
// 不依赖 GORM 的 TranslateError 配置(全局未开启),手动映射以保证语义化错误。
func isDuplicateKeyError(err error) bool {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return true
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return true
}
return false
}
+308
View File
@@ -0,0 +1,308 @@
// 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"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
func TestVoteSubmissions(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewVoteSubmissionsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *voteSubmissions)
}{
{"Create", testVoteSubmissionsCreate},
{"UniqueUser", testVoteSubmissionsUniqueUser},
{"UniqueToken", testVoteSubmissionsUniqueToken},
{"UserAndAnonCoexist", testVoteSubmissionsUserAndAnonCoexist},
{"CrossVote", testVoteSubmissionsCrossVote},
{"Count", testVoteSubmissionsCount},
{"HasUserVoted", testVoteSubmissionsHasUserVoted},
{"HasTokenVoted", testVoteSubmissionsHasTokenVoted},
{"CountByIP", testVoteSubmissionsCountByIP},
{"ListByVoteID", testVoteSubmissionsListByVoteID},
{"DeleteByVoteID", testVoteSubmissionsDeleteByVoteID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("vote_submissions"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store.(*voteSubmissions))
})
}
}
func userIDPtr(id uint) *uint { return &id }
func tokenPtr(s string) *string { return &s }
func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmissions) {
got, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
require.Equal(t, uint(1), got.ID)
require.Equal(t, uint(10), *got.UserID)
}
// 核心防刷:同一登录用户对同一投票重复提交必须被数据库唯一索引拦截。
func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
t.Run("duplicate rejected", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Equal(t, ErrVoteAlreadyVoted, err)
})
t.Run("different user allowed", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(11),
VoterIP: "114.5.1.5",
})
require.Nil(t, err)
})
}
// 核心防刷:同一匿名指纹对同一投票重复提交必须被拦截。
func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoterToken: tokenPtr("fp-token-001"),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
t.Run("duplicate rejected", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoterToken: tokenPtr("fp-token-001"),
VoterIP: "114.5.1.4",
})
require.Equal(t, ErrVoteAlreadyVoted, err)
})
t.Run("different token allowed", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoterToken: tokenPtr("fp-token-002"),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
})
}
// 登录用户与匿名指纹可在同一投票下并存(user_id 为 NULL 与 voter_token 为 NULL 不冲突)。
func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoterToken: tokenPtr("fp-anon-001"),
VoterIP: "114.5.1.6",
})
require.Nil(t, err)
// 同一 user 换匿名 token 再来:user_id 相同仍应被拦截
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(10),
VoterToken: tokenPtr("fp-anon-002"),
VoterIP: "114.5.1.7",
})
require.Equal(t, ErrVoteAlreadyVoted, err)
count, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
// 同一用户 / 同一 token 对不同投票不受约束。
func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSubmissions) {
for voteID := uint(1); voteID <= 2; voteID++ {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: voteID,
OptionID: 1,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
}
// 同一 token 投两个不同投票也允许
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 2,
OptionID: 1,
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
count, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmissions) {
// 投票 1:选项 1 两票,选项 2 一票
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 2, UserID: userIDPtr(3), VoterIP: "c"})
require.Nil(t, err)
// 投票 2:一票
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(4), VoterIP: "d"})
require.Nil(t, err)
t.Run("total", func(t *testing.T) {
got, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(3), got)
})
t.Run("by option", func(t *testing.T) {
got, err := db.CountByOptionID(ctx, 1, 1)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByOptionID(ctx, 1, 2)
require.Nil(t, err)
require.Equal(t, int64(1), got)
})
t.Run("empty", func(t *testing.T) {
got, err := db.CountByVoteID(ctx, 404)
require.Nil(t, err)
require.Equal(t, int64(0), got)
})
}
func testVoteSubmissionsHasUserVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "a"})
require.Nil(t, err)
got, err := db.HasUserVoted(ctx, 1, 10)
require.Nil(t, err)
require.True(t, got)
got, err = db.HasUserVoted(ctx, 1, 11)
require.Nil(t, err)
require.False(t, got)
}
func testVoteSubmissionsHasTokenVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-1"), VoterIP: "a"})
require.Nil(t, err)
got, err := db.HasTokenVoted(ctx, 1, "fp-1")
require.Nil(t, err)
require.True(t, got)
got, err = db.HasTokenVoted(ctx, 1, "fp-2")
require.Nil(t, err)
require.False(t, got)
}
func testVoteSubmissionsCountByIP(t *testing.T, ctx context.Context, db *voteSubmissions) {
since := time.Now().Add(-1 * time.Hour)
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "1.2.3.4"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "1.2.3.4"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(3), VoterIP: "5.6.7.8"})
require.Nil(t, err)
got, err := db.CountByIP(ctx, 1, "1.2.3.4", since)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByIP(ctx, 1, "5.6.7.8", since)
require.Nil(t, err)
require.Equal(t, int64(1), got)
}
func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
for i := 0; i < 3; i++ {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(uint(100 + i)),
VoterIP: "x",
FormData: datatypes.JSON(`{"nickname":"user` + string(rune('0'+i)) + `"}`),
})
require.Nil(t, err)
}
got, err := db.ListByVoteID(ctx, 1, &dbutil.Cursor{Value: 2, PageSize: 10})
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, uint(1), got[0].ID, "游标取 id < 2 的记录")
}
func testVoteSubmissionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
require.Nil(t, err)
require.Nil(t, db.DeleteByVoteID(ctx, 1))
got, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(0), got)
got, err = db.CountByVoteID(ctx, 2)
require.Nil(t, err)
require.Equal(t, int64(1), got)
}
+377
View File
@@ -0,0 +1,377 @@
// 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"
"fmt"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/thanhpk/randstr"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Votes VotesStore
// 投票状态
const (
VoteStatusDraft = "draft" // 草稿(未发布,不可访问)
VoteStatusPublished = "published" // 进行中
VoteStatusEnded = "ended" // 已结束
VoteStatusArchived = "archived" // 归档
// VoteStatusNotStarted 仅作为管理列表的"有效展示状态"(published + 未到开始时间),不入库。
VoteStatusNotStarted = "not_started"
)
// 投票类型(收集方式)
const (
VoteTypeSingle = "single" // 单选
VoteTypeMultiple = "multiple" // 多选
VoteTypeRating = "rating" // 评分
VoteTypeRank = "rank" // 排序
VoteTypeForm = "form" // 结构化表单
)
// 展示可见性
const (
VoteVisibilityPublic = "public" // 探索页 + 提问箱页公开展示
VoteVisibilityLinkOnly = "link_only" // 仅通过链接分享
)
// 投票范围
const (
VoteVoterScopeAnyone = "anyone" // 完全匿名可投
VoteVoterScopeRegisteredOnly = "registered_only" // 仅注册用户可投
)
// 结果可见策略
const (
VoteShowResultAfterVote = "after_vote" // 投票后立即可见
VoteShowResultAfterEnd = "after_end" // 结束后可见
VoteShowResultOwnerOnly = "owner_only" // 仅主人可见
)
type VotesStore interface {
Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error)
GetByID(ctx context.Context, id uint) (*Vote, error)
GetByToken(ctx context.Context, token string) (*Vote, error)
ListByUserID(ctx context.Context, userID uint, opts ListVotesOptions) ([]*Vote, error)
ListPublishedPublicByUserID(ctx context.Context, userID uint, limit int) ([]*Vote, error)
ListPublishedPublic(ctx context.Context, limit int) ([]*Vote, error)
GetHot(ctx context.Context, limit int) ([]*Vote, error)
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(ctx context.Context, id uint) error
CountByUserID(ctx context.Context, userID uint) (int64, error)
}
func NewVotesStore(db *gorm.DB) VotesStore {
return &votes{db}
}
type votes struct {
*gorm.DB
}
type Vote struct {
dbutil.Model
UserID uint `gorm:"index:idx_vote_user" json:"-"`
Title string `gorm:"size:100" json:"title"`
Description string `gorm:"type:text" json:"description"`
Type string `gorm:"size:20;default:single" json:"type"`
Status string `gorm:"size:20;default:draft;index" json:"status"`
Visibility string `gorm:"size:20;default:public" json:"visibility"`
VoterScope string `gorm:"size:20;default:anyone" json:"voter_scope"`
ShowResult string `gorm:"size:20;default:after_vote" json:"show_result"`
AllowCustom bool `gorm:"default:false" json:"allow_custom"`
MaxChoices int `gorm:"default:1" json:"max_choices"`
StartAt *time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
LinkToken string `gorm:"size:16;uniqueIndex" json:"-"`
MaxVotesPerFingerprint int `gorm:"default:1" json:"-"`
// ⚠️ 不要给 RequireCaptcha 加 gorm:"default:true" 标签:GORM 在 Create 时会把零值 false
// 替换为字段默认值(true),导致「创建时不要求验证码」的配置被静默写成 true。
// 当前业务硬编码 requireCaptcha=true(决策 #3),去掉标签不影响现状,但消除同类陷阱。
RequireCaptcha bool `json:"-"`
CensorMetadata datatypes.JSON `json:"-"`
// QuotaLimit 定量收集上限:收集到该份数后自动结束投票(0 表示不限制)。
// 与 EndAt 互补——EndAt 按时间关闭,QuotaLimit 按数量关闭。
QuotaLimit int `gorm:"default:0" json:"quota_limit"`
}
// BeforeCreate 自动生成链接分享 token(与私信 Conversation.Token 同款模式,
// 不可枚举,防止通过数字 ID 扫描他人投票)。
func (v *Vote) BeforeCreate(tx *gorm.DB) error {
if v.LinkToken == "" {
v.LinkToken = randstr.String(12)
}
return nil
}
type CreateVoteOptions struct {
UserID uint
Title string
Description string
Type string
Visibility string
VoterScope string
ShowResult string
AllowCustom bool
MaxChoices int
StartAt *time.Time
EndAt *time.Time
MaxVotesPerFingerprint int
RequireCaptcha bool
CensorMetadata datatypes.JSON
QuotaLimit int
}
func (db *votes) Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error) {
vote := &Vote{
UserID: opts.UserID,
Title: opts.Title,
Description: opts.Description,
Type: opts.Type,
Visibility: opts.Visibility,
VoterScope: opts.VoterScope,
ShowResult: opts.ShowResult,
AllowCustom: opts.AllowCustom,
MaxChoices: opts.MaxChoices,
StartAt: opts.StartAt,
EndAt: opts.EndAt,
MaxVotesPerFingerprint: opts.MaxVotesPerFingerprint,
RequireCaptcha: opts.RequireCaptcha,
CensorMetadata: opts.CensorMetadata,
QuotaLimit: opts.QuotaLimit,
}
if err := db.WithContext(ctx).Create(vote).Error; err != nil {
return nil, errors.Wrap(err, "create vote")
}
return vote, nil
}
var ErrVoteNotExist = errors.New("投票不存在")
func (db *votes) GetByID(ctx context.Context, id uint) (*Vote, error) {
var vote Vote
if err := db.WithContext(ctx).First(&vote, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVoteNotExist
}
return nil, errors.Wrap(err, "get vote by ID")
}
return &vote, nil
}
func (db *votes) GetByToken(ctx context.Context, token string) (*Vote, error) {
var vote Vote
if err := db.WithContext(ctx).Where("link_token = ?", token).First(&vote).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVoteNotExist
}
return nil, errors.Wrap(err, "get vote by token")
}
return &vote, nil
}
type ListVotesOptions struct {
*dbutil.Cursor
Status string // 空 = 全部;否则按状态筛选(draft/published/ended/archived)
}
// ListByUserID 根据箱主 ID 获取投票列表,支持游标分页与状态筛选。
func (db *votes) ListByUserID(ctx context.Context, userID uint, opts ListVotesOptions) ([]*Vote, error) {
q := db.WithContext(ctx).Model(&Vote{}).Where("user_id = ?", userID)
if opts.Status != "" {
q = q.Where("status = ?", opts.Status)
}
if opts.Cursor != nil && opts.Cursor.Value != nil {
cursorStr := strings.TrimSpace(cursorValueToString(opts.Cursor.Value))
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
if idVal, err := strconv.ParseUint(cursorStr, 10, 64); err == nil && idVal > 0 {
q = q.Where("id < ?", idVal)
}
// 解析失败则忽略该游标(从首页重新开始),与提问列表游标行为一致。
}
if opts.Cursor != nil {
q = q.Limit(opts.Cursor.Limit())
}
q = q.Order("created_at DESC")
var votes []*Vote
if err := q.Find(&votes).Error; err != nil {
return nil, errors.Wrap(err, "list votes by user id")
}
return votes, nil
}
// ListPublishedPublicByUserID 获取箱主"进行中"的公开投票(提问箱页内嵌板块用):
// status=published 且 visibility=public,且在开始/结束时间窗内,按创建时间倒序。
func (db *votes) ListPublishedPublicByUserID(ctx context.Context, userID uint, limit int) ([]*Vote, error) {
if limit <= 0 {
limit = 3
}
now := time.Now()
q := db.WithContext(ctx).Model(&Vote{}).
Where("user_id = ? AND status = ? AND visibility = ?", userID, VoteStatusPublished, VoteVisibilityPublic).
Where("(start_at IS NULL OR start_at <= ?)", now).
Where("(end_at IS NULL OR end_at > ?)", now).
Order("created_at DESC").
Limit(limit)
var votes []*Vote
if err := q.Find(&votes).Error; err != nil {
return nil, errors.Wrap(err, "list published public votes by user id")
}
return votes, nil
}
// ListPublishedPublic 返回探索页可展示的公开投票,按创建时间倒序。
func (db *votes) ListPublishedPublic(ctx context.Context, limit int) ([]*Vote, error) {
if limit <= 0 {
limit = 20
}
now := time.Now()
var votes []*Vote
err := db.WithContext(ctx).Model(&Vote{}).
Where("status = ? AND visibility = ?", VoteStatusPublished, VoteVisibilityPublic).
Where("(start_at IS NULL OR start_at <= ?)", now).
Where("(end_at IS NULL OR end_at > ?)", now).
Order("created_at DESC").
Limit(limit).Find(&votes).Error
if err != nil {
return nil, errors.Wrap(err, "list published public votes")
}
return votes, nil
}
// GetHot 返回探索页"热门投票":进行中的公开投票,按参与人数倒序。
// 参与数通过子查询聚合(跨 MySQL/PostgreSQL 兼容,规避 GROUP BY 全列要求)。
func (db *votes) GetHot(ctx context.Context, limit int) ([]*Vote, error) {
if limit <= 0 {
limit = 6
}
now := time.Now()
sub := db.WithContext(ctx).Model(&VoteSubmission{}).
Select("vote_id, COUNT(id) AS cnt").
Where("deleted_at IS NULL").
Group("vote_id")
q := db.WithContext(ctx).Model(&Vote{}).
Select("votes.*").
Joins("LEFT JOIN (?) AS vote_stat ON vote_stat.vote_id = votes.id", sub).
Where("votes.status = ? AND votes.visibility = ?", VoteStatusPublished, VoteVisibilityPublic).
Where("(votes.start_at IS NULL OR votes.start_at <= ?)", now).
Where("(votes.end_at IS NULL OR votes.end_at > ?)", now).
Order("COALESCE(vote_stat.cnt, 0) DESC, votes.created_at DESC").
Limit(limit)
var votes []*Vote
if err := q.Find(&votes).Error; err != nil {
return nil, errors.Wrap(err, "get hot votes")
}
return votes, nil
}
type UpdateVoteOptions struct {
Title string
Description string
Type *string
Visibility string
VoterScope string
ShowResult string
AllowCustom bool
MaxChoices *int
StartAt *time.Time
EndAt *time.Time
MaxVotesPerFingerprint int
RequireCaptcha bool
CensorMetadata datatypes.JSON
QuotaLimit int
}
// Update 更新投票的可编辑字段(不含 Status,状态流转走 UpdateStatus)。
func (db *votes) Update(ctx context.Context, id uint, opts UpdateVoteOptions) error {
updates := map[string]interface{}{
"title": opts.Title,
"description": opts.Description,
"visibility": opts.Visibility,
"voter_scope": opts.VoterScope,
"show_result": opts.ShowResult,
"allow_custom": opts.AllowCustom,
"start_at": opts.StartAt,
"end_at": opts.EndAt,
"max_votes_per_fingerprint": opts.MaxVotesPerFingerprint,
"require_captcha": opts.RequireCaptcha,
"censor_metadata": opts.CensorMetadata,
"quota_limit": opts.QuotaLimit,
}
// Type / MaxChoices 用指针承载:nil 表示「不修改」,db 层跳过写入,避免调用方漏传时
// 被 map 更新写成零值(曾导致发布后编辑把 type 写成 "" 的同类 bug)。草稿阶段显式传 &值
// 以设置;已发布编辑传 nil 以保留原值。
if opts.Type != nil {
updates["type"] = *opts.Type
}
if opts.MaxChoices != nil {
updates["max_choices"] = *opts.MaxChoices
}
res := db.WithContext(ctx).Model(&Vote{}).Where("id = ?", id).Updates(updates)
if res.Error != nil {
return errors.Wrap(res.Error, "update vote")
}
if res.RowsAffected == 0 {
return ErrVoteNotExist
}
return nil
}
// UpdateStatus 更新投票状态(draft → published → ended → archived)。
func (db *votes) UpdateStatus(ctx context.Context, id uint, status string) error {
res := db.WithContext(ctx).Model(&Vote{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return errors.Wrap(res.Error, "update vote status")
}
if res.RowsAffected == 0 {
return ErrVoteNotExist
}
return nil
}
func (db *votes) DeleteByID(ctx context.Context, id uint) error {
var vote Vote
if err := db.WithContext(ctx).First(&vote, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrVoteNotExist
}
return errors.Wrap(err, "get vote by ID")
}
if err := db.WithContext(ctx).Delete(&Vote{}, id).Error; err != nil {
return errors.Wrap(err, "delete vote")
}
return nil
}
// CountByUserID 统计箱主的投票总数(全部状态)。
func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&Vote{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count votes by user id")
}
return count, nil
}
// cursorValueToString 将游标值转为字符串,兼容传入 uint / string。
func cursorValueToString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
+252
View File
@@ -0,0 +1,252 @@
// 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"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
func TestVotes(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewVotesStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *votes)
}{
{"Create", testVotesCreate},
{"GetByID", testVotesGetByID},
{"GetByToken", testVotesGetByToken},
{"ListByUserID", testVotesListByUserID},
{"Update", testVotesUpdate},
{"UpdateStatus", testVotesUpdateStatus},
{"DeleteByID", testVotesDeleteByID},
{"CountByUserID", testVotesCountByUserID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("votes"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store.(*votes))
})
}
}
func testVotesCreate(t *testing.T, ctx context.Context, db *votes) {
got, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "今天穿什么?",
Description: "单选题",
Type: VoteTypeSingle,
Visibility: VoteVisibilityPublic,
VoterScope: VoteVoterScopeAnyone,
ShowResult: VoteShowResultAfterVote,
MaxChoices: 1,
})
require.Nil(t, err)
require.Equal(t, uint(1), got.ID)
require.NotEmpty(t, got.LinkToken, "LinkToken 应自动生成")
require.Equal(t, VoteStatusDraft, got.Status, "默认状态应为草稿")
// RequireCaptcha 不再被 gorm:"default:true" 隐式默认(零值陷阱已修复):Create 未显式传入时按 false 落库。
// 业务层在创建/编辑投票时始终强制 requireCaptcha=true(决策 #3),真实投票行为不受影响。
require.False(t, got.RequireCaptcha, "底层 Create 不再隐式默认开启验证码")
}
func testVotesGetByID(t *testing.T, ctx context.Context, db *votes) {
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Title - 1",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Title - 1", got.Title)
require.Equal(t, uint(1), got.UserID)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrVoteNotExist, err)
})
}
func testVotesGetByToken(t *testing.T, ctx context.Context, db *votes) {
created, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Title - 1",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByToken(ctx, created.LinkToken)
require.Nil(t, err)
require.Equal(t, created.ID, got.ID)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByToken(ctx, "no-such-token")
require.Equal(t, ErrVoteNotExist, err)
})
}
func testVotesListByUserID(t *testing.T, ctx context.Context, db *votes) {
for i := 0; i < 3; i++ {
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Vote - 1",
})
require.Nil(t, err)
}
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 2,
Title: "Vote - 2",
})
require.Nil(t, err)
t.Run("all", func(t *testing.T) {
got, err := db.ListByUserID(ctx, 1, ListVotesOptions{})
require.Nil(t, err)
require.Len(t, got, 3)
})
t.Run("status filter", func(t *testing.T) {
err := db.UpdateStatus(ctx, 1, VoteStatusPublished)
require.Nil(t, err)
got, err := db.ListByUserID(ctx, 1, ListVotesOptions{Status: VoteStatusPublished})
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, uint(1), got[0].ID)
})
t.Run("cursor", func(t *testing.T) {
got, err := db.ListByUserID(ctx, 1, ListVotesOptions{
Cursor: &dbutil.Cursor{Value: 3, PageSize: 10},
})
require.Nil(t, err)
require.Len(t, got, 2)
})
}
func testVotesUpdate(t *testing.T, ctx context.Context, db *votes) {
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Title - 1",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
vt := VoteTypeMultiple
mc := 3
err := db.Update(ctx, 1, UpdateVoteOptions{
Title: "Title - updated",
Description: "desc",
Type: &vt,
Visibility: VoteVisibilityLinkOnly,
VoterScope: VoteVoterScopeRegisteredOnly,
ShowResult: VoteShowResultAfterEnd,
MaxChoices: &mc,
RequireCaptcha: false,
})
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Title - updated", got.Title)
require.Equal(t, VoteTypeMultiple, got.Type)
require.Equal(t, VoteVisibilityLinkOnly, got.Visibility)
require.Equal(t, VoteVoterScopeRegisteredOnly, got.VoterScope)
require.Equal(t, VoteShowResultAfterEnd, got.ShowResult)
require.Equal(t, 3, got.MaxChoices)
require.False(t, got.RequireCaptcha)
})
// 回归:调用方漏传 Type/MaxChoices(nil 指针)时,db.Update 必须跳过写入,
// 不得把 type 写成 ""(曾导致发布后编辑使公开页报「未知的投票类型」)。
t.Run("omit type preserves existing", func(t *testing.T) {
err := db.Update(ctx, 1, UpdateVoteOptions{Title: "Title - meta only"})
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Title - meta only", got.Title)
require.Equal(t, VoteTypeMultiple, got.Type, "type 不应被清零")
require.Equal(t, 3, got.MaxChoices, "max_choices 不应被清零")
})
t.Run("not found", func(t *testing.T) {
err := db.Update(ctx, 404, UpdateVoteOptions{Title: "x"})
require.Equal(t, ErrVoteNotExist, err)
})
}
func testVotesUpdateStatus(t *testing.T, ctx context.Context, db *votes) {
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Title - 1",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
require.Nil(t, db.UpdateStatus(ctx, 1, VoteStatusPublished))
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, VoteStatusPublished, got.Status)
require.Nil(t, db.UpdateStatus(ctx, 1, VoteStatusEnded))
got, err = db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, VoteStatusEnded, got.Status)
})
t.Run("not found", func(t *testing.T) {
require.Equal(t, ErrVoteNotExist, db.UpdateStatus(ctx, 404, VoteStatusEnded))
})
}
func testVotesDeleteByID(t *testing.T, ctx context.Context, db *votes) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteOptions{UserID: 1, Title: "Title - 1"})
require.Nil(t, err)
require.Nil(t, db.DeleteByID(ctx, 1))
_, err = db.GetByID(ctx, 1)
require.Equal(t, ErrVoteNotExist, err)
})
t.Run("not found", func(t *testing.T) {
require.Equal(t, ErrVoteNotExist, db.DeleteByID(ctx, 404))
})
}
func testVotesCountByUserID(t *testing.T, ctx context.Context, db *votes) {
_, err := db.Create(ctx, CreateVoteOptions{UserID: 1, Title: "A"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteOptions{UserID: 1, Title: "B"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteOptions{UserID: 2, Title: "C"})
require.Nil(t, err)
got, err := db.CountByUserID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByUserID(ctx, 404)
require.Nil(t, err)
require.Equal(t, int64(0), got)
}