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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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("投票表单字段不存在")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user