chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
// 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 cache 提供进程内、并发安全的短 TTL 缓存。
//
// 适用场景:结果聚合统计等"非强一致"数据,用短 TTL 换取数据库实时重算压力的显著降低
// (投票结果页、探索页热门投票等)。需注意:进程内存缓存在多实例横向扩容时不会共享,
// 因此仅用于可接受短暂陈旧的数据;强一致数据请勿使用本缓存。
package cache
import (
"sync"
"time"
)
type ttlEntry struct {
value interface{}
expiresAt time.Time
}
// TTLCache 并发安全的带 TTL 进程内缓存。
type TTLCache struct {
mu sync.RWMutex
items map[string]ttlEntry
maxItems int
}
// NewTTLCache 创建缓存实例。maxItems<=0 时使用默认容量 256;达到容量上限时,
// 先淘汰过期项,若仍满则整表清空(避免内存无限增长,短 TTL 下过期项通常已占多数)。
func NewTTLCache(maxItems int) *TTLCache {
if maxItems <= 0 {
maxItems = 256
}
return &TTLCache{
items: make(map[string]ttlEntry, maxItems),
maxItems: maxItems,
}
}
// Get 读取缓存;命中且未过期返回值,否则返回 (nil, false)。
func (c *TTLCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
e, ok := c.items[key]
c.mu.RUnlock()
if !ok {
return nil, false
}
if time.Now().After(e.expiresAt) {
c.mu.Lock()
// 双重检查,避免并发重复删除(无影响,仅兜底)
if cur, still := c.items[key]; still && time.Now().After(cur.expiresAt) {
delete(c.items, key)
}
c.mu.Unlock()
return nil, false
}
return e.value, true
}
// Set 写入缓存并设定过期时间。
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.items) >= c.maxItems {
// 先淘汰过期项
now := time.Now()
for k, v := range c.items {
if now.After(v.expiresAt) {
delete(c.items, k)
}
}
// 仍满(多为短 TTL 下瞬时高频写入):整表重建,保证写入成功且不 OOM
if len(c.items) >= c.maxItems {
c.items = make(map[string]ttlEntry, c.maxItems)
}
}
c.items[key] = ttlEntry{value: value, expiresAt: time.Now().Add(ttl)}
}
// Delete 删除指定键的缓存项;键不存在时静默返回。
// 用于写穿式失效:数据变更(如新增投票提交)后立即清除对应缓存,避免结果页短暂陈旧。
func (c *TTLCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
+61
View File
@@ -0,0 +1,61 @@
// 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 cache
import (
"sync"
"testing"
"time"
)
func TestTTLCacheBasic(t *testing.T) {
c := NewTTLCache(8)
if _, ok := c.Get("missing"); ok {
t.Fatal("expected miss for empty cache")
}
c.Set("k", "v", time.Minute)
v, ok := c.Get("k")
if !ok || v.(string) != "v" {
t.Fatalf("expected hit with v, got %v %v", v, ok)
}
}
func TestTTLCacheExpiry(t *testing.T) {
c := NewTTLCache(8)
c.Set("k", "v", time.Nanosecond)
time.Sleep(2 * time.Millisecond)
if _, ok := c.Get("k"); ok {
t.Fatal("expected expired entry to miss")
}
}
func TestTTLCacheCapacityEviction(t *testing.T) {
c := NewTTLCache(4)
for i := 0; i < 100; i++ {
c.Set(string(rune('a'+i%26))+string(rune('0'+i%10)), i, time.Minute)
}
// 不 panic 且仍可读
if _, ok := c.Get("a0"); ok {
// 命中与否均可,关键是内存不失控且无 panic
_ = ok
}
}
func TestTTLCacheConcurrent(t *testing.T) {
c := NewTTLCache(16)
var wg sync.WaitGroup
for g := 0; g < 20; g++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for i := 0; i < 200; i++ {
key := string(rune('a' + (n+i)%26))
c.Set(key, n, time.Minute)
c.Get(key)
}
}(g)
}
wg.Wait()
}
+273
View File
@@ -0,0 +1,273 @@
// Copyright 2024 TamaBox. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package captcha
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strconv"
"strings"
"time"
"github.com/flamego/flamego"
"github.com/flamego/recaptcha"
"github.com/flamego/session"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/i18n"
)
const (
// Session key prefix for storing math captcha answers
sessionKeyPrefix = "math_captcha_"
// TTL for each captcha in session
captchaTTL = 5 * time.Minute
// maxCaptchaAttempts 同一条验证码被尝试(含模态框预校验)的最大次数,
// 超过即销毁该验证码,迫使刷新,防止对答案做无限轮询爆破。
maxCaptchaAttempts = 5
)
// MathProblem represents an arithmetic captcha problem.
type MathProblem struct {
ID string `json:"id"`
Question string `json:"question"`
}
// storedAnswer holds the expected answer and creation time.
type StoredAnswer struct {
Answer int
CreatedAt time.Time
// Attempts 累计校验尝试次数(含模态框预校验),用于达到上限后销毁验证码。
Attempts int
}
// GenerateMath creates a new arithmetic captcha problem and stores the answer in session.
func GenerateMath(s session.Session) (*MathProblem, error) {
// Generate random operands
op1, err := rand.Int(rand.Reader, big.NewInt(90))
if err != nil {
return nil, errors.Wrap(err, "generate operand 1")
}
op2, err := rand.Int(rand.Reader, big.NewInt(90))
if err != nil {
return nil, errors.Wrap(err, "generate operand 2")
}
a := int(op1.Int64()) + 1 // 1-90
b := int(op2.Int64()) + 1 // 1-90
// Pick a random operator (+, -, *)
opIdx, err := rand.Int(rand.Reader, big.NewInt(3))
if err != nil {
return nil, errors.Wrap(err, "generate operator")
}
var operator string
var answer int
switch opIdx.Int64() {
case 0:
operator = "+"
answer = a + b
case 1:
operator = "-"
// Ensure non-negative result
if a < b {
a, b = b, a
}
answer = a - b
default:
operator = "×"
// Keep numbers small for multiplication
a = a%10 + 1
b = b%10 + 1
answer = a * b
}
// 生成唯一 ID
idBytes := make([]byte, 8)
if _, err := rand.Read(idBytes); err != nil {
return nil, errors.Wrap(err, "generate captcha id")
}
id := hex.EncodeToString(idBytes)
// 清理策略说明:flamego session 接口不支持遍历,无法主动清理过期条目。
// 泄漏可控:验证码一次性消费(VerifyMath 校验后立即删除),
// 且每个用户同一时刻只生成 1 个新 key;session 本身过期后存储层整体清理。
// Store answer in session
key := sessionKeyPrefix + id
s.Set(key, StoredAnswer{
Answer: answer,
CreatedAt: time.Now(),
})
question := fmt.Sprintf("%d %s %d =", a, operator, b)
return &MathProblem{
ID: id,
Question: question,
}, nil
}
// VerifyMath checks if the given answer matches the stored captcha.
// Returns (true, nil) if correct, (false, nil) if incorrect, (false, error) on error.
func VerifyMath(s session.Session, token string, lang string) (bool, error) {
if token == "" {
return false, errors.New(i18n.T(lang, "captcha.errEmpty"))
}
// Token format: "captcha_id:answer"
parts := strings.SplitN(token, ":", 2)
if len(parts) != 2 {
return false, errors.New(i18n.T(lang, "captcha.errFormat"))
}
id := parts[0]
answerStr := parts[1]
answer, err := strconv.Atoi(strings.TrimSpace(answerStr))
if err != nil {
return false, errors.New(i18n.T(lang, "captcha.errAnswerFormat"))
}
// Retrieve stored answer
key := sessionKeyPrefix + id
stored, ok := s.Get(key).(StoredAnswer)
if !ok {
return false, errors.New(i18n.T(lang, "captcha.errExpired"))
}
// Check expiry
if time.Since(stored.CreatedAt) > captchaTTL {
s.Delete(key)
return false, errors.New(i18n.T(lang, "captcha.errExpired"))
}
// Delete after verification attempt (one-time use)
s.Delete(key)
// 常量时间比较,避免答案比对产生可观测的时序侧信道。
if subtle.ConstantTimeCompare([]byte(strconv.Itoa(answer)), []byte(strconv.Itoa(stored.Answer))) != 1 {
return false, errors.New(i18n.T(lang, "captcha.errAnswerWrong"))
}
return true, nil
}
// VerifyOnly checks if the answer is correct WITHOUT consuming the captcha.
// This is used by the modal to validate before actually submitting the form.
// 同一验证码的答案预校验次数计入 Attempts,达到上限即销毁,防止脚本无限轮询爆破。
func VerifyOnly(s session.Session, id string, answer int) bool {
key := sessionKeyPrefix + id
stored, ok := s.Get(key).(StoredAnswer)
if !ok {
return false
}
if time.Since(stored.CreatedAt) > captchaTTL {
s.Delete(key)
return false
}
// 尝试次数限制:超过上限即销毁,迫使刷新。
stored.Attempts++
if stored.Attempts > maxCaptchaAttempts {
s.Delete(key)
return false
}
s.Set(key, stored)
// 常量时间比较,避免答案比对产生可观测的时序侧信道。
return subtle.ConstantTimeCompare([]byte(strconv.Itoa(answer)), []byte(strconv.Itoa(stored.Answer))) == 1
}
// sessionLang 从会话读取匿名用户显式选择的语言(与 Contexter 一致,语言偏好现托管于
// session 而非独立 lang cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。
func sessionLang(s session.Session) string {
if v := s.Get("lang"); v != nil {
if str, ok := v.(string); ok && str != "" {
return i18n.Resolve(str)
}
}
return i18n.Default
}
// VerifyHandler is an HTTP handler that checks a captcha answer without consuming it.
// Used by the modal to validate before form submission.
func VerifyHandler(ctx flamego.Context, s session.Session) {
lang := sessionLang(s)
var req struct {
ID string `json:"id"`
Answer int `json:"answer"`
}
if err := json.NewDecoder(ctx.Request().Request.Body).Decode(&req); err != nil {
ctx.ResponseWriter().WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(ctx.ResponseWriter()).Encode(map[string]interface{}{
"code": 40000,
"message": i18n.T(lang, "captcha.json.badRequest"),
})
return
}
if req.ID == "" {
ctx.ResponseWriter().WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(ctx.ResponseWriter()).Encode(map[string]interface{}{
"code": 40000,
"message": i18n.T(lang, "captcha.json.idRequired"),
})
return
}
correct := VerifyOnly(s, req.ID, req.Answer)
ctx.ResponseWriter().Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(ctx.ResponseWriter()).Encode(map[string]interface{}{
"code": 0,
"correct": correct,
})
}
// GenerateHandler is an HTTP handler that generates a math captcha and returns it as JSON.
func GenerateHandler(ctx flamego.Context, s session.Session) {
lang := sessionLang(s)
problem, err := GenerateMath(s)
if err != nil {
ctx.ResponseWriter().WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(ctx.ResponseWriter()).Encode(map[string]interface{}{
"code": 50000,
"message": i18n.T(lang, "captcha.json.genFailed"),
})
return
}
ctx.ResponseWriter().Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(ctx.ResponseWriter()).Encode(map[string]interface{}{
"code": 0,
"data": problem,
})
}
// Verify is a unified captcha verification function.
// It automatically chooses between recaptcha/turnstile and math captcha based on config.
// Returns (true, nil) if verification passes, (false, nil) if it fails, (false, error) on error.
func Verify(token string, remoteIP string, s session.Session, r recaptcha.RecaptchaV3, lang string) (bool, error) {
if conf.Recaptcha.CaptchaType == "math" {
return VerifyMath(s, token, lang)
}
// reCAPTCHA / Turnstile
resp, err := r.Verify(token, remoteIP)
if err != nil {
logrus.WithError(err).Error("Failed to verify recaptcha/turnstile")
return false, errors.New(i18n.T(lang, "captcha.errService"))
}
if !resp.Success {
return false, errors.New(i18n.T(lang, "captcha.error"))
}
return true, nil
}
+105
View File
@@ -0,0 +1,105 @@
// 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 cmd
import (
"fmt"
"os"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
// Ban 封禁用户并连带清理其全部数据。
// 用法:TamaBox ban --email [email protected] 或 TamaBox ban --domain someone
var Ban = &cli.Command{
Name: "ban",
Usage: "Ban a user and clean up all their questions, follow-ups and chats",
Action: runBan,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "email",
Usage: "Email of the user to ban",
},
&cli.StringFlag{
Name: "domain",
Usage: "Custom domain of the user to ban",
},
&cli.BoolFlag{
Name: "yes",
Usage: "Skip confirmation",
},
},
}
func runBan(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
dbType := conf.Database.Type
if dbType == "postgres" {
conf.Database.DSN = conf.PostgresDsn()
} else {
conf.Database.DSN = conf.MySQLDsn()
}
database, err := db.Init(dbType, conf.Database.DSN)
if err != nil {
return errors.Wrap(err, "connect to database")
}
email := ctx.String("email")
domain := ctx.String("domain")
if email == "" && domain == "" {
return errors.New("must provide --email or --domain")
}
// 用 Unscoped 查找,支持封禁已软删除(自助停用)的账号
var user db.User
query := database.WithContext(ctx.Context).Unscoped()
if email != "" {
query = query.Where("email = ?", email)
} else {
query = query.Where("domain = ?", domain)
}
if err := query.First(&user).Error; err != nil {
return errors.Wrap(err, "find user to ban")
}
logrus.WithFields(logrus.Fields{
"id": user.ID,
"name": user.Name,
"email": user.Email,
"domain": user.Domain,
}).Warn("准备封禁以下账号,其提问/追问/私聊将一并删除,此操作不可恢复!")
if !ctx.Bool("yes") {
logrus.Warn("输入大写 YES 确认执行封禁:")
var answer string
if _, err := fmtScan(&answer); err != nil {
return errors.Wrap(err, "read confirmation")
}
if answer != "YES" {
return errors.New("已取消")
}
}
if err := db.Users.BanUserAndCleanup(ctx.Context, user.ID); err != nil {
return errors.Wrap(err, "ban user and cleanup")
}
logrus.WithField("user_id", user.ID).Info("用户已封禁,关联数据已清理")
return nil
}
// fmtScan 从标准输入读取一行,便于交互确认(隔离以便测试)。
var fmtScan = func(dst *string) (int, error) {
return fmt.Fscan(os.Stdin, dst)
}
+86
View File
@@ -0,0 +1,86 @@
// 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 cmd
import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/censor"
)
var Censor = &cli.Command{
Name: "censor",
Usage: "Censor the questions",
Action: runCensor,
}
func runCensor(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
if !conf.Security.EnableTextCensor {
return errors.New("text censor is disabled")
}
dbType := conf.Database.Type
if dbType == "postgres" {
conf.Database.DSN = conf.PostgresDsn()
} else {
conf.Database.DSN = conf.MySQLDsn()
}
database, err := db.Init(dbType, conf.Database.DSN)
if err != nil {
return errors.Wrap(err, "connect to database")
}
// Check all the unprocessed questions.
var questions []db.Question
if err := database.Raw(`SELECT * FROM questions WHERE content_censor_metadata IS NULL`).Find(&questions).Error; err != nil {
return errors.Wrap(err, "query questions")
}
logrus.WithContext(ctx.Context).WithField("count", len(questions)).Info("Found un-censor questions")
for i, question := range questions {
question := question
content := question.Content
answer := question.Answer
contentCensorResponse, err := censor.Text(ctx.Context, content)
if err != nil {
logrus.WithContext(ctx.Context).WithField("question_id", question.ID).WithError(err).Error("Failed to censor content")
} else {
// We don't want to update the `updated_at` field, so just execute the raw SQL.
if err := database.Debug().Exec(`UPDATE questions SET content_censor_metadata = ? WHERE id = ?`, contentCensorResponse.ToJSON(), question.ID).Error; err != nil {
logrus.WithContext(ctx.Context).WithField("question_id", question.ID).WithError(err).Error("Failed to update content censor metadata")
}
}
if answer != "" && question.AnswerCensorMetadata == nil {
answerCensorResponse, err := censor.Text(ctx.Context, answer)
if err != nil {
logrus.WithContext(ctx.Context).WithField("question_id", question.ID).WithError(err).Error("Failed to censor answer")
} else {
// We don't want to update the `updated_at` field, so just execute the raw SQL.
if err := database.Exec(`UPDATE questions SET answer_censor_metadata = ? WHERE id = ?`, answerCensorResponse.ToJSON(), question.ID).Error; err != nil {
logrus.WithContext(ctx.Context).WithField("question_id", question.ID).WithError(err).Error("Failed to update answer censor metadata")
}
}
}
if i%1000 == 0 {
logrus.WithContext(ctx.Context).WithField("count", i).Trace("Processed questions")
}
}
logrus.WithContext(ctx.Context).WithField("count", len(questions)).Info("Processed all questions")
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package cmd
import (
"fmt"
"github.com/pkg/errors"
"github.com/rs/xid"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
var Uid = &cli.Command{
Name: "uid",
Usage: "Create uid for user",
Action: runUid,
}
func runUid(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
dbType := conf.Database.Type
if dbType == "postgres" {
conf.Database.DSN = conf.PostgresDsn()
} else {
conf.Database.DSN = conf.MySQLDsn()
}
database, err := db.Init(dbType, conf.Database.DSN)
if err != nil {
return errors.Wrap(err, "connect to database")
}
var users []*db.User
if err := database.WithContext(ctx.Context).Unscoped().Find(&users).Error; err != nil {
return errors.Wrap(err, "query users")
}
for idx, user := range users {
user := user
if user.UID != "" {
continue
}
if idx%1000 == 0 {
fmt.Printf("Processing user %d/%d\n", idx, len(users))
}
uid := xid.NewWithTime(user.CreatedAt).String()
if err := database.WithContext(ctx.Context).Unscoped().
Model(&db.User{}).
Omit("updated_at").
Where("id = ?", user.ID).UpdateColumn("uid", uid).Error; err != nil {
return errors.Wrap(err, "update uid")
}
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
// 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 cmd
import (
"fmt"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/uptrace/opentelemetry-go-extra/otellogrus"
"github.com/uptrace/uptrace-go/uptrace"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/route"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/tracing"
)
var Web = &cli.Command{
Name: "web",
Usage: "Start web server",
Action: runWeb,
}
func runWeb(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
// 解析可信代理网段(用于客户端 IP 解析,避免维护 Cloudflare 庞大 CIDR 清单)。
if err := shared.InitTrustedProxies(); err != nil {
return errors.Wrap(err, "init trusted proxies")
}
if conf.App.UptraceDSN != "" {
uptrace.ConfigureOpentelemetry(
uptrace.WithDSN(conf.App.UptraceDSN),
uptrace.WithServiceName("tamabox"),
uptrace.WithServiceVersion(conf.BuildCommit),
)
logrus.WithContext(ctx.Context).Debug("Tracing enabled.")
}
logrus.AddHook(otellogrus.NewHook(otellogrus.WithLevels(
logrus.PanicLevel,
logrus.FatalLevel,
logrus.ErrorLevel,
logrus.WarnLevel,
)))
dbType := conf.Database.Type
var dsn string
switch dbType {
case "mysql", "":
dsn = conf.MySQLDsn()
case "postgres":
dsn = conf.PostgresDsn()
default:
return errors.Errorf("unknown database type: %q", dbType)
}
conf.Database.DSN = dsn
_, err := db.Init(dbType, dsn)
if err != nil {
return errors.Wrap(err, "connect to database")
}
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
shared.App.SetLang(conf.App.DefaultLang)
r := route.New()
r.Use(tracing.Middleware("TamaBox"))
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
// - ReadHeaderTimeout 防止慢速客户端占用连接
// - ReadTimeout / WriteTimeout 防止读写挂死
// - IdleTimeout 复用 keep-alive 连接
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
addr := conf.Server.Listen
if addr == "" {
addr = fmt.Sprintf(":%d", conf.Server.Port)
}
server := &http.Server{
Addr: addr,
Handler: route.GzipHandler(r),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return errors.Wrap(err, "start http server")
}
return nil
}
+105
View File
@@ -0,0 +1,105 @@
// 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 conf
import (
"os"
"strings"
"github.com/pkg/errors"
"gopkg.in/ini.v1"
)
// File is the configuration object.
var File *ini.File
func Init() error {
configFile := os.Getenv("NEKOBOX_CONFIG_PATH")
if configFile == "" {
configFile = "conf/app.ini"
}
var err error
File, err = ini.LoadSources(ini.LoadOptions{
IgnoreInlineComment: true,
}, configFile)
if err != nil {
return errors.Wrapf(err, "parse %q", configFile)
}
if err := File.Section("app").MapTo(&App); err != nil {
return errors.Wrap(err, "map 'server'")
}
if App.ExternalURL == "" {
return errors.New("app.external_url is required")
}
App.ExternalURL = strings.TrimRight(App.ExternalURL, "/")
if err := File.Section("security").MapTo(&Security); err != nil {
return errors.Wrap(err, "map 'security'")
}
if err := File.Section("server").MapTo(&Server); err != nil {
return errors.Wrap(err, "map 'server'")
}
// XSRFKey 为空会导致 CSRF token 以空密钥生成、可被预测,
// 使删题/置顶/拉黑/改密等所有 POST 操作的 CSRF 防护形同虚设。启动时强制校验。
if Server.XSRFKey == "" {
return errors.New("server.xsrf_key is required (used to generate CSRF tokens, must not be empty)")
}
// Salt 为空会让密码哈希以空盐计算(HmacSha1(password, "")),
// 相同密码产生相同哈希且可被离线字典/彩虹表攻击。启动时强制校验。
if Server.Salt == "" {
return errors.New("server.salt is required (used to hash user passwords, must not be empty)")
}
if err := File.Section("database").MapTo(&Database); err != nil {
return errors.Wrap(err, "map 'database'")
}
if err := File.Section("redis").MapTo(&Redis); err != nil {
return errors.Wrap(err, "map 'redis'")
}
if err := File.Section("recaptcha").MapTo(&Recaptcha); err != nil {
return errors.Wrap(err, "map 'recaptcha'")
}
if err := File.Section("pixel").MapTo(&Pixel); err != nil {
return errors.Wrap(err, "map 'pixel'")
}
if err := File.Section("upload").MapTo(&Upload); err != nil {
return errors.Wrap(err, "map 'upload'")
}
if err := File.Section("limits").MapTo(&Limits); err != nil {
return errors.Wrap(err, "map 'limits'")
}
// ini 键留空会被 int 解析为 0;<1 的值一律回退内置默认,防止上限被静默置零。
sanitizeLimits(&Limits)
if err := File.Section("mail").MapTo(&Mail); err != nil {
return errors.Wrap(err, "map 'mail'")
}
serviceSections := File.Section("service").ChildSections()
for _, serviceSection := range serviceSections {
serviceSection := serviceSection
var backend struct {
Prefix string `ini:"prefix"`
ForwardURL string `ini:"forward_url"`
}
if err := serviceSection.MapTo(&backend); err != nil {
return errors.Wrapf(err, "map 'service.%s'", serviceSection.Name())
}
Service.Backends = append(Service.Backends, backend)
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package conf
import "fmt"
func MySQLDsn() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
Database.User,
Database.Password,
Database.Host,
Database.Port,
Database.Name,
)
}
func PostgresDsn() string {
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai",
Database.Host,
Database.Port,
Database.User,
Database.Password,
Database.Name,
)
if Database.Schema != "" {
dsn += fmt.Sprintf(" search_path=%s", Database.Schema)
}
return dsn
}
+119
View File
@@ -0,0 +1,119 @@
// 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 conf
// 各项上限的内置默认值(与旧版硬编码一致)。既是未配置 [limits] 段时的兜底,
// 也是 conf.Init 后归一化(sanitizeLimits)的回退基准。
const (
defaultMaxTags = 5
defaultMaxTagLen = 5
defaultMaxBlockWords = 10
defaultMaxBlockWordLen = 10
defaultCustomAnswerLen = 200
defaultVoteOptionLen = 300
defaultQuestionContentLen = 1000
defaultAnswerContentLen = 2000
defaultFollowUpContentLen = 2000
defaultChatMessageLen = 2000
defaultChatMaxMessages = 200
defaultDailyMessageLimitMax = 999
)
// LimitsConfig 全站内容 / 数量上限([limits] 配置段)的字段集合。
//
// 过去这些上限以常量形式散落在 internal/db(MaxTags / MaxCustomTextLen 等)与
// 路由层硬编码(私聊单条 2000 字、会话条数 clamp 5~200 等)中,调整需改代码重编译,
// 且前后端、i18n 文案各自携带一份数值容易漂移。统一收口到 [limits] 段后:
// - 后端校验、模板注入、前端提示全部引用 conf.Limits 的同一份值;
// - 调整仅需修改 conf/app.ini 并重启进程,无需改代码;
// - 长度类上限统一按字符(rune / Unicode 码点)计数。
type LimitsConfig struct {
// ── 标签与屏蔽词(账号设置页)──
// 标签个数上限
MaxTags int `ini:"max_tags"`
// 单个标签最大字符数
MaxTagLen int `ini:"max_tag_len"`
// 屏蔽词个数上限
MaxBlockWords int `ini:"max_block_words"`
// 单个屏蔽词最大字符数
MaxBlockWordLen int `ini:"max_block_word_len"`
// ── 投票 ──
// 参与者填写的「自定义补充选项」文本最大字符数。
// 覆盖两类入口:单选/多选/排序的全局补充选项,以及表单收集字段的
// 「其他/自定义选项」(field_{id}_custom)。
CustomAnswerLen int `ini:"custom_answer_len"`
// 站长创建投票时单个「选项」文本的最大字符数。
// 覆盖单选/多选/排序的预设选项,以及表单收集字段(select/radio/checkbox/rank)
// 的预置选项。与 CustomAnswerLen 是两条独立规则(站长侧预设刻意放宽),可各自调参。
VoteOptionLen int `ini:"vote_option_len"`
// ── 提问 / 回答 / 追问 ──
// 提问正文最大字符数(访客向提问箱提交的问题内容)。
// 旧实现依赖 govalid 的 maxlen 标签(编译期写死 2000),无法随配置调整,
// 且与前端 maxlength=1000 不一致,现已移到路由层按本值做 rune 校验。
QuestionContentLen int `ini:"question_content_len"`
// 回答正文最大字符数:箱主对提问的正式回答,以及箱主对追问的回复(共用)。
AnswerContentLen int `ini:"answer_content_len"`
// 追问正文最大字符数(提问者在问题被回答后继续追问的内容)。
FollowUpContentLen int `ini:"followup_content_len"`
// ── 私聊 ──
// 单条消息(发送 / 回复共用)最大字符数
ChatMessageLen int `ini:"chat_message_len"`
// 每个私聊会话的消息条数上限(系统硬顶)。用户只能在 1..该值 之间设置自己的
// chat_max_messages;新注册用户的默认值即该硬顶(而非固定 20)。
ChatMaxMessages int `ini:"chat_max_messages"`
// 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)。
// 该值只是「用户可设置的上限」,不是站点强制的每日配额。
DailyMessageLimitMax int `ini:"daily_message_limit_max"`
}
// Limits 保存运行时的全部上限配置。conf.Init 中会先 MapTo [limits] 段覆盖显式键,
// 再调用 sanitizeLimits 把 <1 的值回退到内置默认,因此:
// - 缺失 [limits] 段或个别键 → 使用内置默认;
// - ini 键留空(int 解析为 0)或误填 0/负数 → 自动回退默认,不会把上限静默置零。
var Limits = LimitsConfig{
MaxTags: defaultMaxTags,
MaxTagLen: defaultMaxTagLen,
MaxBlockWords: defaultMaxBlockWords,
MaxBlockWordLen: defaultMaxBlockWordLen,
CustomAnswerLen: defaultCustomAnswerLen,
VoteOptionLen: defaultVoteOptionLen,
QuestionContentLen: defaultQuestionContentLen,
AnswerContentLen: defaultAnswerContentLen,
FollowUpContentLen: defaultFollowUpContentLen,
ChatMessageLen: defaultChatMessageLen,
ChatMaxMessages: defaultChatMaxMessages,
DailyMessageLimitMax: defaultDailyMessageLimitMax,
}
// sanitizeLimits 归一化 [limits] 配置:所有上限最小为 1(0 / 负数说明未配置或手滑,
// 一律回退内置默认,避免「留空=0」把标签全拒、消息全超长等隐性灾难)。
// 注意 DailyMessageLimitMax 同样要求 >=1:0 会让用户侧永远只能设为 0(=不限制),
// 语义上等于关闭每日配额功能,需显式使用该字段其余语义时请另行设计。
func sanitizeLimits(l *LimitsConfig) {
clamp1 := func(p *int, def int) {
if *p < 1 {
*p = def
}
}
clamp1(&l.MaxTags, defaultMaxTags)
clamp1(&l.MaxTagLen, defaultMaxTagLen)
clamp1(&l.MaxBlockWords, defaultMaxBlockWords)
clamp1(&l.MaxBlockWordLen, defaultMaxBlockWordLen)
clamp1(&l.CustomAnswerLen, defaultCustomAnswerLen)
clamp1(&l.VoteOptionLen, defaultVoteOptionLen)
clamp1(&l.QuestionContentLen, defaultQuestionContentLen)
clamp1(&l.AnswerContentLen, defaultAnswerContentLen)
clamp1(&l.FollowUpContentLen, defaultFollowUpContentLen)
clamp1(&l.ChatMessageLen, defaultChatMessageLen)
clamp1(&l.ChatMaxMessages, defaultChatMaxMessages)
clamp1(&l.DailyMessageLimitMax, defaultDailyMessageLimitMax)
}
+127
View File
@@ -0,0 +1,127 @@
// 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 conf
import (
"testing"
"gopkg.in/ini.v1"
)
// TestLimitsDefaults 保证内置默认值与旧版硬编码一致:未配置 [limits] 段(或 conf.Init
// 尚未调用)时,程序按这些默认值运行。
func TestLimitsDefaults(t *testing.T) {
cases := []struct {
name string
got int
want int
}{
{"MaxTags", Limits.MaxTags, 5},
{"MaxTagLen", Limits.MaxTagLen, 5},
{"MaxBlockWords", Limits.MaxBlockWords, 10},
{"MaxBlockWordLen", Limits.MaxBlockWordLen, 10},
{"CustomAnswerLen", Limits.CustomAnswerLen, 200},
{"VoteOptionLen", Limits.VoteOptionLen, 300},
{"QuestionContentLen", Limits.QuestionContentLen, 1000},
{"AnswerContentLen", Limits.AnswerContentLen, 2000},
{"FollowUpContentLen", Limits.FollowUpContentLen, 2000},
{"ChatMessageLen", Limits.ChatMessageLen, 2000},
{"ChatMaxMessages", Limits.ChatMaxMessages, 200},
{"DailyMessageLimitMax", Limits.DailyMessageLimitMax, 999},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
}
}
}
// TestLimitsMapTo 模拟 conf.Init 的映射行为:显式键被覆盖,缺失键保留默认。
// 启动逻辑是 File.Section("limits").MapTo(&Limits),与这里同一路径。
func TestLimitsMapTo(t *testing.T) {
src := `
[limits]
max_tags = 7
chat_message_len = 500
answer_content_len = 300
daily_message_limit_max = 0
`
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
l := Limits // 拷贝默认值,等价于启动前状态
if err := f.Section("limits").MapTo(&l); err != nil {
t.Fatalf("map [limits]: %v", err)
}
if l.MaxTags != 7 {
t.Errorf("MaxTags = %d, want 7 (ini 覆盖失败)", l.MaxTags)
}
if l.ChatMessageLen != 500 {
t.Errorf("ChatMessageLen = %d, want 500", l.ChatMessageLen)
}
if l.AnswerContentLen != 300 {
t.Errorf("AnswerContentLen = %d, want 300(ini 覆盖失败)", l.AnswerContentLen)
}
if l.DailyMessageLimitMax != 0 {
t.Errorf("DailyMessageLimitMax = %d, want 0(配置为 0 应生效)", l.DailyMessageLimitMax)
}
// 未显式出现的键保留内置默认
if l.MaxTagLen != 5 {
t.Errorf("MaxTagLen = %d, want 5(缺失键应保留默认)", l.MaxTagLen)
}
if l.VoteOptionLen != 300 {
t.Errorf("VoteOptionLen = %d, want 300", l.VoteOptionLen)
}
if l.QuestionContentLen != 1000 || l.FollowUpContentLen != 2000 {
t.Errorf("QuestionContentLen/FollowUpContentLen = %d/%d, want 1000/2000(缺失键应保留默认)",
l.QuestionContentLen, l.FollowUpContentLen)
}
}
// TestLimitsEmptyValueSanitized 验证「键留空」的防护:ini 会把空 int 解析为 0,
// sanitizeLimits 随后将其回退到内置默认 —— 防止留空把上限静默置零(标签全拒等)。
func TestLimitsEmptyValueSanitized(t *testing.T) {
src := `
[limits]
max_tags =
chat_message_len = 0
followup_content_len = -1
daily_message_limit_max = -3
`
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
l := Limits
if err := f.Section("limits").MapTo(&l); err != nil {
t.Fatalf("map [limits]: %v", err)
}
// 前提确认:空串对 int 是「保留原值」而非 0(gopkg.in/ini 行为);
// 显式 0 / 负数则照常映射。真正的 0 陷阱来自显式填 0 / 负数,由 sanitize 兜底。
if l.MaxTags != defaultMaxTags {
t.Fatalf("precondition broken: 空值 max_tags 应保留默认, got %d", l.MaxTags)
}
if l.ChatMessageLen != 0 || l.DailyMessageLimitMax != -3 {
t.Fatalf("precondition broken: got %d/%d, want 0/-3",
l.ChatMessageLen, l.DailyMessageLimitMax)
}
sanitizeLimits(&l)
if l.MaxTags != defaultMaxTags {
t.Errorf("MaxTags = %d, want 默认 %d", l.MaxTags, defaultMaxTags)
}
if l.ChatMessageLen != defaultChatMessageLen {
t.Errorf("ChatMessageLen = %d, want 默认 %d(0 应回退默认)", l.ChatMessageLen, defaultChatMessageLen)
}
if l.FollowUpContentLen != defaultFollowUpContentLen {
t.Errorf("FollowUpContentLen = %d, want 默认 %d(负数应回退默认)", l.FollowUpContentLen, defaultFollowUpContentLen)
}
if l.DailyMessageLimitMax != defaultDailyMessageLimitMax {
t.Errorf("DailyMessageLimitMax = %d, want 默认 %d(负数应回退默认)", l.DailyMessageLimitMax, defaultDailyMessageLimitMax)
}
if l.ChatMaxMessages != defaultChatMaxMessages {
t.Errorf("ChatMaxMessages = %d, want 默认 %d(缺失键不受影响)", l.ChatMaxMessages, defaultChatMaxMessages)
}
}
+133
View File
@@ -0,0 +1,133 @@
// 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 conf
// Build time and commit information.
//
// ⚠️ WARNING: should only be set by "-ldflags".
var (
BuildTime string
BuildCommit = "dev"
)
var (
App struct {
Production bool `ini:"production"`
ExternalURL string `ini:"external_url"`
ICP string `ini:"icp"`
DefaultLang string `ini:"default_lang"` // 站点默认语言(zh-CN/zh-TW/en/ja),留空回退简体中文
UptraceDSN string `ini:"uptrace_dsn"`
QiniuAccessKey string `ini:"qiniu_access_key"`
QiniuAccessSecret string `ini:"qiniu_access_secret"`
AliyunAccessKey string `ini:"aliyun_access_key"`
AliyunAccessKeySecret string `ini:"aliyun_access_key_secret"`
SentryDSN string `ini:"sentry_dsn"`
MaintenanceMode bool `ini:"maintenance_mode"`
ShareBackground string `ini:"share_background"`
FontPath string `ini:"font_path"`
}
Security struct {
EnableTextCensor bool `ini:"enable_text_censor"`
IPBlacklist string `ini:"ip_blacklist"`
IPBlacklistRedirectURL string `ini:"ip_blacklist_redirect_url"`
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律使用 RemoteAddr,
// 防止客户端伪造 header 绕过速率限制与 IP 黑名单。
TrustProxy bool `ini:"trust_proxy"`
// TrustedProxies 逗号分隔的 IP / CIDR 列表,仅限“你自己的”反向代理/负载均衡基础设施
// (例如本机 nginx 的 127.0.0.1、私有网络 LB)。仅当 TCP 对端落在该清单内时才会解析
// X-Forwarded-For / X-Real-IP。留空则这两个头永不被信任(CDN 权威头 CF-Connecting-IP /
// EO-Client-IP 仍会被采用,前提是源站已被对应 CDN 锁定)。
// 目的:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单——源站应配合
// Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源网段锁定,
// 使 CF-Connecting-IP / EO-Client-IP 成为权威客户端 IP。
TrustedProxies string `ini:"trusted_proxies"`
// 自动封禁(fail2ban 风格):检测批量扫描/探测特征后临时封禁来源 IP。
EnableAutoBan bool `ini:"enable_auto_ban"`
// BanTrackNotFound 是否把「未注册路径的 404」计入封禁统计。默认 false:正常业务
// (域名/题目不存在等均走 302 业务跳转,不产生计数)一律放行,仅命中后台扫描特征
// 路径(KindSensitive)才参与统计封禁。避免共享出口(CDN 边缘 / CGNAT NAT444)下
// 正常用户被 404 探测流量连坐误伤。如需恢复对纯 404 字典扫描的封禁再置 true。
BanTrackNotFound bool `ini:"ban_track_notfound"`
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
}
Server struct {
Port int `ini:"port"`
Listen string `ini:"listen"` // 绑定监听地址(含端口)。留空则默认 ":port"(监听所有网卡)。
Salt string `ini:"salt"`
XSRFKey string `ini:"xsrf_key"`
}
Database struct {
DSN string
Type string `ini:"type"`
User string `ini:"user"`
Password string `ini:"password"`
Host string `ini:"host"`
Port uint `ini:"port"`
Name string `ini:"name"`
Schema string `ini:"schema"`
DedupeUniqueFields bool `ini:"dedupe_unique_fields"`
}
Redis struct {
Addr string `ini:"addr"`
Password string `ini:"password"`
}
Recaptcha struct {
CaptchaType string `ini:"captcha_type"`
Domain string `ini:"domain"`
SiteKey string `ini:"site_key"`
ServerKey string `ini:"server_key"`
TurnstileStyle bool `ini:"turnstile_style"`
}
Pixel struct {
Host string `ini:"host"`
}
Upload struct {
DefaultAvatarURL string `ini:"default_avatar"`
DefaultBackground string `ini:"default_background"`
DefaultBackgroundImageURL string `ini:"default_qrbackgroundimage"`
AliyunEndpoint string `ini:"aliyun_endpoint"`
AliyunAccessID string `ini:"aliyun_access_id"`
AliyunAccessSecret string `ini:"aliyun_access_secret"`
AliyunBucket string `ini:"aliyun_bucket"`
AliyunBucketCDNHost string `ini:"aliyun_bucket_cdn_host"`
ImageEndpoint string `ini:"image_endpoint"`
ImageAccessID string `ini:"image_access_id"`
ImageAccessSecret string `ini:"image_access_secret"`
ImageBucket string `ini:"image_bucket"`
ImageBucketCDNHost string `ini:"image_bucket_cdn_host"`
}
Mail struct {
Account string `ini:"account"`
Password string `ini:"password"`
Port int `ini:"port"`
SMTP string `ini:"smtp"`
// SkipTLSVerify 跳过 SMTP TLS 证书校验(仅自签名/内网 SMTP 需要,默认 false 校验证书)
SkipTLSVerify bool `ini:"skip_tls_verify"`
}
Service struct {
Backends []struct {
Prefix string `ini:"prefix"`
ForwardURL string `ini:"forward_url"`
}
// TLSInsecureSkipVerify 跳过反向代理到后端服务的 TLS 证书校验
// (仅内网后端使用自签名证书时需要,默认 false 校验证书)
TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"`
}
)
+57
View File
@@ -0,0 +1,57 @@
// 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 context
import (
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// authenticatedUser returns the user object of the authenticated user.
func authenticatedUser(ctx flamego.Context, sess session.Session) *db.User {
uid, ok := sess.Get("uid").(uint)
if !ok {
return nil
}
user, err := db.Users.GetByID(ctx.Request().Context(), uid)
if err != nil || user == nil {
return nil
}
// 密码变更后,旧会话保存的版本号不匹配,强制重新登录。
ver, _ := sess.Get("pwd_ver").(uint)
if user.PasswordVersion != ver {
return nil
}
return user
}
type ToggleOptions struct {
UserSignInRequired bool
UserSignOutRequired bool
}
func Toggle(options *ToggleOptions) flamego.Handler {
return func(ctx Context, endpoint EndpointType) error {
if options.UserSignOutRequired && ctx.IsLogged {
ctx.Redirect("/")
return nil
}
if options.UserSignInRequired && !ctx.IsLogged {
if endpoint.IsAPI() {
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "auth.errLoginRequired"))
}
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errLoginRequired"))
ctx.Redirect("/login")
return nil
}
return nil
}
}
+327
View File
@@ -0,0 +1,327 @@
// 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 context
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/flamego/template"
"github.com/pkg/errors"
"github.com/unknwon/com"
"github.com/wuhan005/govalid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
type EndpointType string
const (
EndpointAPI EndpointType = "api"
EndpointWeb EndpointType = "web"
)
func (e EndpointType) IsAPI() bool {
return e == EndpointAPI
}
func (e EndpointType) IsWeb() bool {
return e == EndpointWeb
}
func APIEndpoint(ctx Context) {
ctx.Map(EndpointAPI)
}
// Context represents context of a request.
type Context struct {
flamego.Context
Data template.Data
Session session.Session
Template template.Template
User *db.User
IsLogged bool
}
// HasError returns true if error occurs in form validation.
func (c *Context) HasError() bool {
hasErr, ok := c.Data["HasError"]
if !ok {
return false
}
return hasErr.(bool)
}
func (c *Context) SetError(err error, f ...interface{}) {
// 防御性兜底:调用方可能传入 nil error(如从某个可能返回 nil 的函数直接透传),
// 此时下方 err.Error() 会触发 nil 指针 panic。统一兜底为「未知错误」,避免整页崩溃。
if err == nil {
err = errors.New("发生未知错误")
}
c.Data["HasError"] = true
// 表单校验(govalid)错误:其 Error() 在字段带 msg 标签时为 i18n 键,
// 在此按当前界面语言翻译;其余业务错误(errors.New(i18n.T(...)) 等)原样保留。
if ge, ok := err.(*govalid.ErrContext); ok {
c.Data["Error"] = i18n.T(c.Lang(), ge.Error())
} else {
c.Data["Error"] = err.Error()
}
// Set back the form data.
if len(f) > 0 {
form := f[0]
typ := reflect.TypeOf(form)
val := reflect.ValueOf(form)
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
val = val.Elem()
}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
fieldName := com.ToSnakeCase(field.Name)
c.Data[fieldName] = val.Field(i).Interface()
}
}
span := trace.SpanFromContext(c.Request().Context())
if span.IsRecording() {
span.SetAttributes(
attribute.String("tamabox.error", err.Error()),
)
}
}
func (c *Context) SetInternalError(f ...interface{}) {
span := trace.SpanFromContext(c.Request().Context())
traceID := span.SpanContext().TraceID()
c.Data["FlashTip"] = fmt.Sprintf("若问题一直出现,请带上该段字符 %s 提交反馈。", traceID.String())
c.SetError(errors.New(i18n.T(c.Lang(), "internal.errFlash")), f...)
}
// Success renders HTML template with given name with 200 OK status code.
func (c *Context) Success(templateName string) {
c.Template.HTML(http.StatusOK, templateName)
}
func (c *Context) SetTitle(title string) {
c.Data["Title"] = title
}
func (c *Context) Refresh() {
c.Redirect(c.Request().URL.Path)
}
func (c *Context) JSON(data interface{}) error {
resp := map[string]interface{}{
"code": 0,
"data": data,
"message": "success",
}
return json.NewEncoder(c.ResponseWriter()).Encode(resp)
}
func (c *Context) ServerError() error {
return c.JSONError(50000, i18n.T(c.Lang(), "internal.errServer"))
}
func (c *Context) JSONError(errorCode int, message string) error {
span := trace.SpanFromContext(c.Request().Context())
resp := map[string]interface{}{
"code": errorCode,
"message": message,
"trace_id": span.SpanContext().TraceID().String(),
}
statusCode := errorCode / 100
if statusCode < 100 || statusCode > 999 {
statusCode = http.StatusInternalServerError
}
c.ResponseWriter().WriteHeader(statusCode)
return json.NewEncoder(c.ResponseWriter()).Encode(resp)
}
// Contexter initializes a classic context for a request.
func Contexter() flamego.Handler {
return func(ctx flamego.Context, data template.Data, session session.Session, x csrf.CSRF, t template.Template, flash session.Flash) {
c := Context{
Context: ctx,
Data: data,
Session: session,
Template: t,
}
// POST 类请求统一校验 CSRF(像素追踪代理已改为 GET 且仅转发只读请求,
// 不再需要 CSRF 豁免,故此处不再对 /api/v1/pixel/ 做例外)。
if ctx.Request().Method == http.MethodPost {
x.Validate(ctx)
}
// Get user from session or header when possible
c.User = authenticatedUser(c.Context, c.Session)
var userID uint
if c.User != nil {
c.IsLogged = true
c.Data["IsLogged"] = c.IsLogged
c.Data["LoggedUser"] = c.User
c.Data["LoggedUserID"] = c.User.ID
c.Data["LoggedUserName"] = c.User.Name
userID = c.User.ID
} else {
c.Data["LoggedUserID"] = 0
c.Data["LoggedUserName"] = ""
}
c.Data["IsPixel"] = ctx.Request().URL.Path == "/pixel"
span := trace.SpanFromContext(ctx.Request().Context())
if span.IsRecording() {
span.SetAttributes(
attribute.Bool("tamabox.user.is-login", c.IsLogged),
attribute.Int("tamabox.user.id", int(userID)),
)
}
c.ResponseWriter().Header().Set("Trace-ID", span.SpanContext().TraceID().String())
if flash != nil {
flash, ok := flash.(Flash)
if ok {
switch flash.Type {
case "success":
c.Data["Success"] = flash.Message
case "error":
c.Data["HasError"] = true
c.Data["Error"] = flash.Message
case "info":
c.Data["Info"] = flash.Message
case "warning":
c.Data["Warning"] = flash.Message
}
c.Data["FlashTip"] = flash.FlashTip
}
}
// ---- i18n 语言注入 ----
// 解析优先级(按页面类型):
// - 个人页(/user 后台、/_/{domain} 提问箱):永远跟随主人 profile 语言,忽略访客 cookie。
// /user 后台即登录用户本人的配置,直接采用 c.User.Language;
// /_/{domain} 提问箱页由 question.Pager 中间件按 pageUser.Language 覆盖(见 route/question/page.go)。
// - 公开页(首页/探索/登录等):访客 lang cookie 优先;未设置 cookie 时登录用户资料语言次之;
// 再回退站点默认语言(shared.App)> i18n.Default。
path := ctx.Request().URL.Path
isUserPage := strings.HasPrefix(path, "/user") || strings.HasPrefix(path, "/_/")
c.Data["IsUserPage"] = isUserPage
// SEO:仅公开页(首页/注册/登录/explore)允许被搜索引擎收录;
// 提问内容(提问箱页、单题页)、密码页、私信、用户后台等隐私页一律 noindex。
isPublicPage := path == "/" || path == "/register" || path == "/login" || strings.HasPrefix(path, "/explore")
if !isPublicPage {
c.Data["NoIndex"] = true
}
lang := ""
if isUserPage {
// 个人页:永远跟随 profile,忽略 cookie(/user 直接采用本人配置;
// /_/{domain} 由 question.Pager 覆盖,此处留空交由其处理)。
if strings.HasPrefix(path, "/user") && c.IsLogged && c.User.Language != "" {
lang = i18n.Resolve(c.User.Language)
}
} else {
// 公开页:会话内显式选择的语言优先,其次登录用户资料语言,最后回退站点默认。
// 语言偏好存于 session(由 session 中间件托管 cookie);此前独立 lang cookie 会被
// session 中间件的 Set-Cookie 整体覆盖而失效,故此处改读 session。
if v := c.Session.Get("lang"); v != nil {
if s, ok := v.(string); ok && s != "" {
lang = i18n.Resolve(s)
}
}
if lang == "" && c.IsLogged && c.User.Language != "" {
lang = i18n.Resolve(c.User.Language)
}
}
if lang == "" {
lang = shared.App.Lang()
}
c.Data["Lang"] = lang
c.Data["LangCode"] = lang
c.Data["SupportedLangs"] = i18n.Supported()
c.SetTitle(i18n.T(lang, "site.title"))
c.Data["CSRFToken"] = x.Token()
c.Data["CSRFTokenHTML"] = templatepkg.Safe(`<input type="hidden" name="_csrf" value="` + x.Token() + `">`)
c.Data["RecaptchaDomain"] = conf.Recaptcha.Domain
c.Data["RecaptchaSiteKey"] = conf.Recaptcha.SiteKey
c.Data["RecaptchaTurnstileStyle"] = conf.Recaptcha.TurnstileStyle
c.Data["CaptchaType"] = conf.Recaptcha.CaptchaType
c.Data["CurrentURI"] = ctx.Request().Request.RequestURI
c.Data["ExternalURL"] = conf.App.ExternalURL
// 默认头像/背景图兜底:未设置自定义头像的箱主,其二维码 logo 应与页面头像一致回退到 default_avatar。
// 模板按原始 URL 直连(easy.qrcode 以 crossOrigin:'anonymous' 加载,配合对象存储桶 CORS
// 返回 Access-Control-Allow-Origin,保证 canvas 不被污染、可正常导出)。
c.Data["DefaultAvatar"] = conf.Upload.DefaultAvatarURL
c.Data["DefaultBackgroundImage"] = conf.Upload.DefaultBackgroundImageURL
// [limits] 段全站上限的模板同源入口:所有页面可直接引用
// {{.Limits.QuestionContentLen}} / {{.Limits.AnswerContentLen}} /
// {{.Limits.FollowUpContentLen}} / {{.Limits.ChatMessageLen}} 等,
// 作为输入框 maxlength 等与后端校验的同一数据源,避免各处写死数字漂移。
c.Data["Limits"] = conf.Limits
// ⚠️ VConsole can only be enabled for the first user for security reasons.
c.Data["VConsole"] = ctx.Query("debug") == "on" && c.IsLogged && c.User.ID == 1
// 🚨 SECURITY: Prevent MIME type sniffing in some browsers,
c.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff")
c.ResponseWriter().Header().Set("X-Frame-Options", "DENY")
ctx.Map(c)
ctx.Map(EndpointWeb)
if conf.App.MaintenanceMode {
c.ResponseWriter().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Success("maintenance-mode")
return
}
}
}
// Lang 返回当前请求解析出的界面语言代码(已规范化,必定是字典可查的规范代码)。
// 解析优先级见上方 Data["Lang"] 注入逻辑(个人页跟随箱主语言;公开页跟随 lang cookie /
// 登录用户资料语言 / 站点默认语言)。若中间件尚未注入(极少数非标准路径),回退 i18n.Default。
// 用于后端 flash / 错误信息按访客当前语言本地化,避免硬编码中文绕过 i18n。
func (c *Context) Lang() string {
if l, ok := c.Data["Lang"].(string); ok && l != "" {
return l
}
return i18n.Default
}
+52
View File
@@ -0,0 +1,52 @@
// 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 context
import (
"fmt"
"strings"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/i18n"
)
type FlashType string
const (
Info = "info"
Success = "success"
Warning = "warning"
Error = "error"
)
type Flash struct {
Type FlashType
Message string
FlashTip string
}
func (c Context) SetInfoFlash(message string, tip ...string) {
c.Session.SetFlash(Flash{Type: Info, Message: message, FlashTip: strings.Join(tip, "")})
}
func (c Context) SetSuccessFlash(message string, tip ...string) {
c.Session.SetFlash(Flash{Type: Success, Message: message, FlashTip: strings.Join(tip, "")})
}
func (c Context) SetWarningFlash(message string, tip ...string) {
c.Session.SetFlash(Flash{Type: Warning, Message: message, FlashTip: strings.Join(tip, "")})
}
func (c Context) SetErrorFlash(message string, tip ...string) {
c.Session.SetFlash(Flash{Type: Error, Message: message, FlashTip: strings.Join(tip, "")})
}
func (c Context) SetInternalErrorFlash() {
span := trace.SpanFromContext(c.Request().Context())
traceID := span.SpanContext().TraceID()
c.Session.SetFlash(Flash{Type: Error, Message: i18n.T(c.Lang(), "internal.errFlash"), FlashTip: fmt.Sprintf("若问题一直出现,请带上该段字符 %s 提交反馈。", traceID.String())})
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"encoding/json"
"time"
"github.com/pkg/errors"
"github.com/wuhan005/gadget"
"gorm.io/gorm"
)
var CensorLogs CensorLogsStore
type CensorLogsStore interface {
GetByText(ctx context.Context, sourceName, text string, noLongerThan ...time.Time) (*CensorLog, error)
Create(ctx context.Context, options CreateCensorLogOptions) error
}
func NewCensorLogsStore(db *gorm.DB) CensorLogsStore {
return &censorLogs{db}
}
type censorLogs struct {
*gorm.DB
}
type CensorLog struct {
gorm.Model
SourceName string
Input string
InputHash string `gorm:"index"`
Pass bool
RawResponse json.RawMessage
}
var ErrCensorLogsNotFound = errors.New("censor logs dose not exist")
func (db *censorLogs) GetByText(ctx context.Context, sourceName, text string, noLongerThan ...time.Time) (*CensorLog, error) {
hash := hashText(text)
var censorLog CensorLog
q := db.WithContext(ctx).Where("source_name = ? AND input_hash = ?", sourceName, hash)
if len(noLongerThan) > 0 && !noLongerThan[0].IsZero() {
q = q.Where("created_at > ?", noLongerThan[0])
}
if err := q.First(&censorLog).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCensorLogsNotFound
}
return nil, err
}
return &censorLog, nil
}
type CreateCensorLogOptions struct {
SourceName string
Input string
Pass bool
RawResponse json.RawMessage
}
func (db *censorLogs) Create(ctx context.Context, options CreateCensorLogOptions) error {
censorLog := CensorLog{
SourceName: options.SourceName,
Input: options.Input,
InputHash: hashText(options.Input),
Pass: options.Pass,
RawResponse: options.RawResponse,
}
return db.WithContext(ctx).Create(&censorLog).Error
}
func hashText(text string) string {
return gadget.Md5(text)
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2023 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestCensorLogs(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
censorLogsStore := NewCensorLogsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *censorLogs)
}{
{"Create", testCensorLogsCreate},
{"GetByText", testCensorLogsGetByText},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("censor_logs"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, censorLogsStore.(*censorLogs))
})
}
}
func testCensorLogsCreate(t *testing.T, ctx context.Context, db *censorLogs) {
err := db.Create(ctx, CreateCensorLogOptions{
SourceName: "aliyun",
Input: "hello world",
Pass: true,
RawResponse: []byte(`{}`),
})
require.Nil(t, err)
}
func testCensorLogsGetByText(t *testing.T, ctx context.Context, db *censorLogs) {
err := db.Create(ctx, CreateCensorLogOptions{
SourceName: "aliyun",
Input: "hello world",
Pass: true,
RawResponse: []byte(`{}`),
})
require.Nil(t, err)
t.Run("normal by cache", func(t *testing.T) {
got, err := db.GetByText(ctx, "aliyun", "hello world")
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &CensorLog{
Model: gorm.Model{
ID: 1,
},
SourceName: "aliyun",
Input: "hello world",
InputHash: hashText("hello world"),
Pass: true,
RawResponse: []byte(`{}`),
}
require.Equal(t, want, got)
})
t.Run("no longer than", func(t *testing.T) {
_, err := db.GetByText(ctx, "aliyun", "hello world", time.Now())
require.Equal(t, ErrCensorLogsNotFound, err)
})
}
+231
View File
@@ -0,0 +1,231 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Conversations ConversationStore
type ConversationStore interface {
Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error)
GetByID(ctx context.Context, id uint) (*Conversation, error)
GetByToken(ctx context.Context, token string) (*Conversation, error)
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error)
GetByIDAndAsker(ctx context.Context, id, askerID uint) (*Conversation, error)
IncrementMessageCount(ctx context.Context, id uint) error
IncrementMessageCountIfBelow(ctx context.Context, id uint, max int) (bool, error)
DecrementMessageCount(ctx context.Context, id uint) error
MaybeClose(ctx context.Context, id uint, max int) error
Close(ctx context.Context, id uint) error
// ListByOwner 返回某箱主拥有的会话。limit>0 时启用分页(取 limit 条、跳过 offset 条),
// 用于私聊列表分页;导出场景传 limit=0 取全量,行为与改造前一致。
ListByOwner(ctx context.Context, ownerID uint, limit, offset int) ([]*Conversation, error)
// CountByOwner 统计某箱主拥有的会话总数(分页看板与"加载更多"判定用)。
CountByOwner(ctx context.Context, ownerID uint) (int64, error)
// ListByAsker 返回某提问者发起的会话;分页语义同 ListByOwner。
ListByAsker(ctx context.Context, askerID uint, limit, offset int) ([]*Conversation, error)
// CountByAsker 统计某提问者发起的会话总数。
CountByAsker(ctx context.Context, askerID uint) (int64, error)
MarkDeletedByOwner(ctx context.Context, id uint) error
}
func NewConversationStore(db *gorm.DB) ConversationStore {
return &conversations{db}
}
type conversations struct {
*gorm.DB
}
type Conversation struct {
dbutil.Model
OwnerID uint `gorm:"index:idx_conversation_owner" json:"owner_id"`
AskerID uint `gorm:"index:idx_conversation_asker" json:"asker_id"`
Token string `gorm:"size:6;uniqueIndex" json:"token"`
MaxMessages int `gorm:"default:20" json:"max_messages"`
MessageCount int `gorm:"default:0" json:"message_count"`
IsClosed bool `gorm:"default:false" json:"is_closed"`
IsDeletedByOwner bool `gorm:"default:false" json:"is_deleted_by_owner"` // 新增
}
func (c *Conversation) BeforeCreate(tx *gorm.DB) error {
c.MessageCount = 0
c.Token = randstr.String(6)
return nil
}
type CreateConversationOptions struct {
OwnerID uint
AskerID uint
MaxMessages int
}
func (db *conversations) Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) {
conversation := &Conversation{
OwnerID: opts.OwnerID,
AskerID: opts.AskerID,
MaxMessages: opts.MaxMessages,
}
if err := db.WithContext(ctx).Create(conversation).Error; err != nil {
return nil, errors.Wrap(err, "create conversation")
}
return conversation, nil
}
func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
return nil, errors.Wrap(err, "get conversation")
}
return &conv, nil
}
func (db *conversations) GetByToken(ctx context.Context, token string) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("token = ?", token).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get conversation by token")
}
return &conv, nil
}
func (db *conversations) GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("id = ? AND owner_id = ?", id, ownerID).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get by owner")
}
return &conv, nil
}
func (db *conversations) GetByIDAndAsker(ctx context.Context, id, askerID uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("id = ? AND asker_id = ?", id, askerID).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get by asker")
}
return &conv, nil
}
func (db *conversations) IncrementMessageCount(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error
}
// IncrementMessageCountIfBelow 原子地检查并递增消息计数:
// 仅当 message_count < max 且未关闭时才 +1,返回 true。
// 并发场景下(同一对话两端同时发送)可防止消息数超出上限。
func (db *conversations) IncrementMessageCountIfBelow(ctx context.Context, id uint, max int) (bool, error) {
res := db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count < ? AND is_closed = ?", id, max, false).
UpdateColumn("message_count", gorm.Expr("message_count + 1"))
if res.Error != nil {
return false, errors.Wrap(res.Error, "increment message count if below")
}
return res.RowsAffected > 0, nil
}
// DecrementMessageCount 回滚一次消息计数(计数已递增但消息落库失败时调用)。
func (db *conversations) DecrementMessageCount(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count > 0", id).
UpdateColumn("message_count", gorm.Expr("message_count - 1")).Error
}
// MaybeClose 当消息数达到上限时关闭对话(幂等,供发送消息后调用)。
func (db *conversations) MaybeClose(ctx context.Context, id uint, max int) error {
return db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count >= ? AND is_closed = ?", id, max, false).
Update("is_closed", true).Error
}
func (db *conversations) Close(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_closed", true).Error
}
func (db *conversations) ListByOwner(ctx context.Context, ownerID uint, limit, offset int) ([]*Conversation, error) {
var convs []*Conversation
q := db.WithContext(ctx).Where("owner_id = ? AND is_deleted_by_owner = ? AND token != ''", ownerID, false).
Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit)
if offset > 0 {
q = q.Offset(offset)
}
}
if err := q.Find(&convs).Error; err != nil {
return nil, errors.Wrap(err, "list by owner")
}
return convs, nil
}
func (db *conversations) CountByOwner(ctx context.Context, ownerID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&Conversation{}).
Where("owner_id = ? AND is_deleted_by_owner = ? AND token != ''", ownerID, false).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count by owner")
}
return count, nil
}
func (db *conversations) ListByAsker(ctx context.Context, askerID uint, limit, offset int) ([]*Conversation, error) {
var convs []*Conversation
q := db.WithContext(ctx).Where("asker_id = ? AND is_deleted_by_owner = ? AND token != ''", askerID, false).
Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit)
if offset > 0 {
q = q.Offset(offset)
}
}
if err := q.Find(&convs).Error; err != nil {
return nil, errors.Wrap(err, "list by asker")
}
return convs, nil
}
func (db *conversations) CountByAsker(ctx context.Context, askerID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&Conversation{}).
Where("asker_id = ? AND is_deleted_by_owner = ? AND token != ''", askerID, false).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count by asker")
}
return count, nil
}
func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error {
var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 已删除或不存在:幂等,不影响调用方。
return nil
}
return errors.Wrap(err, "get conversation for delete")
}
if conv.IsDeletedByOwner {
// 已经是删除状态:幂等返回,避免重复请求多次回退配额计数。
return nil
}
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil {
return errors.Wrap(err, "mark conversation deleted by owner")
}
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
// 否则达上限后即便删掉旧会话,功能仍会显示 quota_reached 关闭,须手动重置计数。
if conv.OwnerID != 0 {
if err := FeatureControls.IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement chat quota for user %d", conv.OwnerID)
}
}
return nil
}
+234
View File
@@ -0,0 +1,234 @@
package db
import (
"fmt"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/uptrace/opentelemetry-go-extra/otelgorm"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
)
var AllTables = []interface{}{
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
// 匿名投票
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
&FeatureControl{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
var dialector gorm.Dialector
switch typ {
case "mysql", "":
dialector = mysql.Open(dsn)
case "postgres":
dialector = postgres.Open(dsn)
default:
return nil, errors.Errorf("unknown database type: %q", typ)
}
db, err := gorm.Open(dialector, &gorm.Config{
SkipDefaultTransaction: true,
})
if err != nil {
return nil, errors.Wrap(err, "connect to database")
}
// 配置连接池
sqlDB, err := db.DB()
if err != nil {
return nil, errors.Wrap(err, "get sql.DB from gorm")
}
// 1C1G 低配环境:调低连接池,避免每个 DB 连接占用过多内存。
// 提问箱是低并发场景,10 个打开连接绰绰有余。
// 最大打开连接数
sqlDB.SetMaxOpenConns(10)
// 最大空闲连接数
sqlDB.SetMaxIdleConns(4)
// 连接最大存活时间(避免长连接被数据库服务端断开)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
// 空闲连接最大存活时间
sqlDB.SetConnMaxIdleTime(3 * time.Minute)
// 仅在明确开启迁移清理开关时处理 users 表历史重复值,避免服务启动时静默改写账号数据。
// 正常部署应先离线检查并清理重复 email/domain,再执行 AutoMigrate。
if conf.Database.DedupeUniqueFields {
if err := dedupeUniqueFields(db); err != nil {
return nil, errors.Wrap(err, "dedupe unique fields before migrate")
}
}
if err := db.AutoMigrate(AllTables...); err != nil {
return nil, errors.Wrap(err, "auto migrate")
}
// 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。
// 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。
if err := backfillFeatureControls(db); err != nil {
logrus.WithError(err).Warn("初始化功能开关配置时异常(可在功能管理页手动保存一次以生成配置)")
}
// 私聊开关纠偏:旧版 backfill 曾把未开启私聊的账号写为开启(GORM 零值陷阱),
// 启动期幂等复位;同样不阻断启动,失败仅告警。
if err := reconcileLegacyChatEnabled(db); err != nil {
logrus.WithError(err).Warn("私聊开关历史数据纠偏时异常(可在功能管理页手动调整私聊开关)")
}
// 私聊收集上限下线:私聊不再支持收集上限(UI 已隐藏,保存时强制清零),
// 启动期把历史残留的上限/计数一并清零,避免私聊「莫名关闭」。幂等,失败仅告警。
if err := normalizeChatQuota(db); err != nil {
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
}
// 启动期补充高频查询复合索引(幂等,适合既有库)。
// 仅 PostgreSQL 需要:这些索引针对服务端真实查询模式(按归属用户+私密+时间、
// 按 asker、按展示/投票状态+可见性+时间、按会话/发送者+时间)。
// 本项目实际使用 PostgreSQL(详见 code-review-2026-08-28.md P1-4)。
if typ == "postgres" {
if err := ensureFrequentlyUsedIndexes(db); err != nil {
// 索引创建失败不应阻断启动,仅告警,便于在运维侧排查。
logrus.WithError(err).Warn("创建高频查询复合索引时异常(可能部分索引未建立,不影响服务启动)")
}
}
Users = NewUsersStore(db)
Questions = NewQuestionsStore(db)
FollowUps = NewFollowUpsStore(db)
CensorLogs = NewCensorLogsStore(db)
UploadImages = NewUploadImagesStore(db)
Conversations = NewConversationStore(db)
Messages = NewMessageStore(db)
MessageImages = NewMessageImageStore(db)
Votes = NewVotesStore(db)
VoteOptions = NewVoteOptionsStore(db)
VoteFormFields = NewVoteFormFieldsStore(db)
VoteSubmissions = NewVoteSubmissionsStore(db)
FeatureControls = NewFeatureControlsStore(db)
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
)); err != nil {
return nil, errors.Wrap(err, "register otelgorm plugin")
}
if typ == "postgres" {
if err := fixPostgresSequences(db); err != nil {
return nil, errors.Wrap(err, "fix postgres sequences")
}
}
return db, nil
}
// dedupeUniqueFields 在 AutoMigrate 之前清理 users 表中重复的 email / domain:
// 每个重复值仅保留 id 最小(最早注册)的一行,其余行该字段置为 NULL。
// NULL 在 MySQL / PostgreSQL / SQLite 的唯一索引中互不冲突,因此置空即可让唯一索引成功建立。
// 幂等:重复数据被清理后再次启动时无任何操作。跨数据库兼容(仅使用标准 SQL,无目标表子查询)。
func dedupeUniqueFields(db *gorm.DB) error {
for _, field := range []string{"email", "domain"} {
var keepIDs []uint
var dupVals []string
rows, err := db.Table("users").
Select("MIN(id) AS keep_id, " + field + " AS val").
Where(field + " IS NOT NULL").
Group(field).
Having("COUNT(*) > 1").
Rows()
if err != nil {
return errors.Wrapf(err, "query duplicate %s", field)
}
for rows.Next() {
var keepID uint
var val string
if err := rows.Scan(&keepID, &val); err != nil {
rows.Close()
return errors.Wrapf(err, "scan duplicate %s", field)
}
keepIDs = append(keepIDs, keepID)
dupVals = append(dupVals, val)
}
if err := rows.Close(); err != nil {
return errors.Wrapf(err, "close rows for %s", field)
}
if rows.Err() != nil {
return errors.Wrapf(rows.Err(), "iterate duplicate %s", field)
}
if len(keepIDs) == 0 {
continue
}
res := db.Table("users").
Where(field+" IS NOT NULL").
Where(field+" IN ?", dupVals).
Where("id NOT IN ?", keepIDs).
Update(field, nil)
if res.Error != nil {
return errors.Wrapf(res.Error, "clean duplicate %s", field)
}
if res.RowsAffected > 0 {
logrus.WithField("field", field).
Warnf("迁移前置清理:%d 行重复的 %s 已置空(保留最早注册的账号)", res.RowsAffected, field)
}
}
return nil
}
// ensureFrequentlyUsedIndexes 在 AutoMigrate 之后创建高频查询所需的复合索引。
// 全部使用 CREATE INDEX IF NOT EXISTS,幂等、可在既有库上安全运行,重复执行无副作用。
// 索引设计依据服务端真实查询模式(详见 code-review-2026-08-28.md P1-4):
// - questions: 按 user_id + is_private + created_at 过滤排序(提问箱列表);
// 按 asker_user_id 查询(我发起的提问)。
// - users: 按 show_in_home + created_at(首页展示箱列表)。
// - votes: 按 status + visibility + created_at(探索页/提问箱页公开投票)。
// - messages: 按 conversation_id + created_at、sender_id + created_at(私信列表/会话消息)。
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_users_show_in_home_created ON users(show_in_home, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_votes_status_visibility_created ON votes(status, visibility, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_messages_sender_created ON messages(sender_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at)`,
}
for _, ddl := range indexes {
if err := db.Exec(ddl).Error; err != nil {
return errors.Wrapf(err, "create index: %s", ddl)
}
}
return nil
}
func fixPostgresSequences(db *gorm.DB) error {
// 使用配置的 schema(未配置时回退 public),避免硬编码 nekobox。
schema := conf.Database.Schema
if schema == "" {
schema = "public"
}
tablesWithID := []string{"users", "questions", "follow_ups", "censor_logs", "upload_images", "conversations", "messages", "message_images", "votes", "vote_options", "vote_form_fields", "vote_submissions", "feature_controls"}
for _, table := range tablesWithID {
seqName := table + "_id_seq"
resetQuery := fmt.Sprintf(`
CREATE SEQUENCE IF NOT EXISTS %s.%s;
ALTER TABLE %s.%s
ALTER COLUMN id SET DEFAULT nextval('%s.%s');
SELECT setval('%s.%s',
COALESCE((SELECT MAX(id) FROM %s.%s), 1)::bigint,
true);
`, schema, seqName, schema, table, schema, seqName, schema, seqName, schema, table)
if err := db.Exec(resetQuery).Error; err != nil {
return fmt.Errorf("修复表 %s 序列时出错: %w", table, err)
}
}
return nil
}
+723
View File
@@ -0,0 +1,723 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var FeatureControls FeatureControlsStore
var _ FeatureControlsStore = (*featureControls)(nil)
// 受控功能标识(feature_controls.feature 取值)。
const (
FeatureQuestion = "question" // 提问
FeatureChat = "chat" // 私聊
FeatureVote = "vote" // 投票
)
// FeatureKeys 全部受控功能,顺序即功能管理页的展示顺序。
var FeatureKeys = []string{FeatureQuestion, FeatureChat, FeatureVote}
// 功能不可用原因(空串表示可用)。
const (
FeatureReasonDisabled = "disabled" // 手动关闭
FeatureReasonNotStarted = "not_started" // 未到开放时间
FeatureReasonEnded = "ended" // 已过关闭时间
FeatureReasonQuotaReached = "quota_reached" // 已达定量收集上限
)
// QuotaLimitMax 单个功能的收集上限最大值,防止误填极大值。
const QuotaLimitMax = 1_000_000
type FeatureControlsStore interface {
// Get 读取某功能的配置;无记录时返回该功能的默认配置(不落库)。
Get(ctx context.Context, userID uint, feature string) (*FeatureControl, error)
// ListByUserID 读取某用户全部功能的配置(无记录的功能不出现在结果中)。
ListByUserID(ctx context.Context, userID uint) ([]*FeatureControl, error)
// ListByUserIDs 批量读取多个用户同一功能的配置(探索页等批量场景用,避免 N+1)。
ListByUserIDs(ctx context.Context, userIDs []uint, feature string) (map[uint]*FeatureControl, error)
// Save 写入配置(不存在则创建,存在则更新开关/时间窗/上限,不影响已收集计数)。
Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error
// ResetQuota 清零收集计数,重新开始一轮定量收集。
ResetQuota(ctx context.Context, userID uint, feature string) error
// IncrementQuota 累加收集计数;无记录时按 delta 创建。
IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error
}
func NewFeatureControlsStore(db *gorm.DB) FeatureControlsStore {
return &featureControls{db}
}
type featureControls struct {
*gorm.DB
}
// FeatureControl 提问箱单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
//
// 三类控制互不覆盖,最终可用状态由 Evaluate 综合计算:
// - Enabled:手动总开关;
// - OpenAt / CloseAt:定时开放与定时关闭(nil 表示不限制该侧);
// - QuotaLimit / QuotaCount:定量收集,累计收集数达到上限后自动关闭(0 表示不限制)。
//
// 计数不做定时重置:一轮收集结束后由箱主在功能管理页手动「重置计数」开启下一轮,
// 避免与定时开关叠加产生难以预期的自动恢复行为。
type FeatureControl struct {
dbutil.Model
UserID uint `gorm:"uniqueIndex:idx_feature_control_user_feature;not null" json:"-"`
Feature string `gorm:"size:20;uniqueIndex:idx_feature_control_user_feature;not null" json:"feature"`
// ⚠️ 不要给 Enabled 加 gorm:"default:true" 标签:
// GORM 在 Create 时会把零值 false 替换为字段默认值(true),导致「取消勾选保存」后
// 数据库里仍是 true、复选框自动恢复为勾选。去掉标签后 GORM 会原样写入显式设置的值。
Enabled bool `json:"enabled"`
// OpenAt / CloseAt:一次性定时开放 / 关闭(nil 表示不限制该侧)。
OpenAt *time.Time `json:"open_at"`
CloseAt *time.Time `json:"close_at"`
// 循环定时(每周循环):ScheduleDays 为逗号分隔的周几(0=周日…6=周六,与 time.Weekday 一致),
// 空表示每天都开放;ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示该日全天)。
// 两者都为空时表示未启用循环定时。
ScheduleDays string `gorm:"size:31" json:"schedule_days"`
ScheduleStart string `gorm:"size:5" json:"schedule_start"`
ScheduleEnd string `gorm:"size:5" json:"schedule_end"`
QuotaLimit int64 `gorm:"default:0" json:"quota_limit"`
QuotaCount int64 `gorm:"default:0" json:"quota_count"`
QuotaResetAt *time.Time `json:"quota_reset_at"`
}
// FeatureState 某功能在某一时刻的综合状态。
type FeatureState struct {
Enabled bool // 手动开关
Open bool // 综合(开关 + 定时 + 定量)后的最终可用状态
Reason string // 不可用原因,空串表示可用(取值见 FeatureReason*)
QuotaLimit int64 // 收集上限(0 = 不限制)
QuotaCount int64 // 已收集数量
OpenAt *time.Time // 一次性定时开放时间
CloseAt *time.Time // 一次性定时关闭时间
NextOpenAt *time.Time // 下一次开放时间(可能来自一次性窗口或循环定时;永久关闭时为 nil)
LastCloseAt *time.Time // 最近一次关闭时间(从未开放过时为 nil)
}
// Evaluate 计算该功能在 now 时刻的综合状态。
// 判定优先级:手动关闭 > 未到一次性开放时间 > 已过一次性关闭时间 > 不在循环定时窗口内 > 达到收集上限。
func (fc *FeatureControl) Evaluate(now time.Time) FeatureState {
st := FeatureState{
Enabled: fc.Enabled,
QuotaLimit: fc.QuotaLimit,
QuotaCount: fc.QuotaCount,
OpenAt: fc.OpenAt,
CloseAt: fc.CloseAt,
}
// 先算好下次开放 / 最近关闭,供 reason 判定与展示复用。
st.NextOpenAt = fc.nextOpenAt(now)
st.LastCloseAt = fc.lastCloseAt(now)
switch {
case !fc.Enabled:
st.Reason = FeatureReasonDisabled
// 手动关闭与时间无关,不展示开放/关闭时间
st.NextOpenAt, st.LastCloseAt = nil, nil
case fc.OpenAt != nil && now.Before(*fc.OpenAt):
st.Reason = FeatureReasonNotStarted
case fc.CloseAt != nil && !now.Before(*fc.CloseAt):
// 已过一次性关闭时间:一次性窗口是硬性外边界,循环定时仅在窗口内生效,
// 因此之后不会再开放(nextOpenAt 已按 CloseAt 约束计算,返回 nil)。
st.Reason = FeatureReasonEnded
st.NextOpenAt = nil
default:
switch fc.scheduleSlotStatus(now) {
case scheduleBeforeStart:
st.Reason = FeatureReasonNotStarted
case scheduleAfterEnd:
if st.NextOpenAt != nil {
st.Reason = FeatureReasonNotStarted
} else {
st.Reason = FeatureReasonEnded
}
}
if st.Reason == "" && fc.QuotaLimit > 0 && fc.QuotaCount >= fc.QuotaLimit {
st.Reason = FeatureReasonQuotaReached
// 定量上限与时间无关,不展示开放/关闭时间
st.NextOpenAt, st.LastCloseAt = nil, nil
}
}
st.Open = st.Reason == ""
return st
}
// scheduleSlotStatus 表示 now 相对循环定时窗口的位置。
type scheduleSlotStatus int
const (
scheduleInWindow scheduleSlotStatus = iota // 在开放窗口内
scheduleBeforeStart // 今天开放,但未到开始时间
scheduleAfterEnd // 已过结束时间,或今天不在开放日
)
// scheduleActive 判断是否启用了循环定时。
// 规则:设置了开放日,或同时设置了开始/结束时间,即视为启用;
// 只设置单侧时间视为配置异常,由路由层校验拦截。
func (fc *FeatureControl) scheduleActive() bool {
return fc.ScheduleDays != "" || (fc.ScheduleStart != "" && fc.ScheduleEnd != "")
}
// scheduleDayOpen 判断某天是否为开放日(ScheduleDays 为空 = 每天都开放)。
func (fc *FeatureControl) scheduleDayOpen(day time.Time) bool {
return fc.ScheduleDays == "" || containsWeekday(fc.ScheduleDays, int(day.Weekday()))
}
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。
// 未设置时间段时视为全天 [0, 1440)。配置非法时返回错误(调用方按"不限制"处理)。
func (fc *FeatureControl) scheduleWindow() (startM, endM int, err error) {
if fc.ScheduleStart == "" && fc.ScheduleEnd == "" {
return 0, 24 * 60, nil
}
if startM, err = parseHHMM(fc.ScheduleStart); err != nil {
return 0, 0, errors.Wrap(err, "parse schedule start")
}
if endM, err = parseHHMM(fc.ScheduleEnd); err != nil {
return 0, 0, errors.Wrap(err, "parse schedule end")
}
if endM <= startM {
return 0, 0, errors.New("schedule end must be after start")
}
return startM, endM, nil
}
// scheduleSlotStatus 返回 now 相对循环定时窗口的位置;未启用循环定时时恒为"在窗口内"。
func (fc *FeatureControl) scheduleSlotStatus(now time.Time) scheduleSlotStatus {
if !fc.scheduleActive() {
return scheduleInWindow
}
if !fc.scheduleDayOpen(now) {
return scheduleAfterEnd
}
startM, endM, err := fc.scheduleWindow()
if err != nil {
return scheduleInWindow // 配置异常按不限制处理(路由层已校验,此处兜底)
}
mins := now.Hour()*60 + now.Minute()
switch {
case mins < startM:
return scheduleBeforeStart
case mins >= endM:
return scheduleAfterEnd
}
return scheduleInWindow
}
// nextOpenAt 计算下一次可开放的时间(严格晚于 now)。
// 同时考虑一次性窗口与循环定时,返回二者中最早的下一次开放时刻;永久关闭时返回 nil。
func (fc *FeatureControl) nextOpenAt(now time.Time) *time.Time {
var earliest *time.Time
// 未启用循环定时时,一次性开放时间即为下一次开放。
// 启用后由下方按日扫描统一处理(一次性开放时间也必须落在循环窗口内)。
if fc.OpenAt != nil && fc.OpenAt.After(now) && !fc.scheduleActive() {
t := *fc.OpenAt
earliest = &t
}
if fc.scheduleActive() {
startM, endM, err := fc.scheduleWindow()
if err == nil {
day := startOfDay(now)
// 扫描今天起的 8 天:7 天覆盖全部周几组合,多 1 天兜底边界。
for i := 0; i < 8; i++ {
d := day.AddDate(0, 0, i)
if !fc.scheduleDayOpen(d) {
continue
}
slotStart := d.Add(time.Duration(startM) * time.Minute)
slotEnd := d.Add(time.Duration(endM) * time.Minute)
// 与一次性窗口求交集:有效开放起点 = max(时段起点, 一次性开放时间),且须在时段与一次性关闭时间之前
effStart := slotStart
if fc.OpenAt != nil && fc.OpenAt.After(effStart) {
effStart = *fc.OpenAt
}
if !effStart.Before(slotEnd) {
continue // 有效开放起点晚于该日时段结束,该日无有效窗口
}
if fc.CloseAt != nil && !effStart.Before(*fc.CloseAt) {
continue // 有效开放起点已过一次性关闭时间
}
if effStart.After(now) {
if earliest == nil || effStart.Before(*earliest) {
t := effStart
earliest = &t
}
break // 该日找到最早有效开放点即可(同一日只有一个窗口)
}
}
}
}
return earliest
}
// lastCloseAt 计算最近一次关闭的时间(严格早于 now);从未开放过时返回 nil。
func (fc *FeatureControl) lastCloseAt(now time.Time) *time.Time {
var latest *time.Time
// 未启用循环定时时,一次性关闭时间即为最近一次关闭。
// 启用后由下方按日扫描统一处理(与时段结束取较早者)。
if fc.CloseAt != nil && fc.CloseAt.Before(now) && !fc.scheduleActive() {
t := *fc.CloseAt
latest = &t
}
if fc.scheduleActive() {
startM, endM, err := fc.scheduleWindow()
if err == nil {
day := startOfDay(now)
// 从今天向前扫描 8 天,找最近一次已结束的窗口。
for i := 0; i < 8; i++ {
d := day.AddDate(0, 0, -i)
if !fc.scheduleDayOpen(d) {
continue
}
slotStart := d.Add(time.Duration(startM) * time.Minute)
slotEnd := d.Add(time.Duration(endM) * time.Minute)
// 与一次性窗口求交集:有效关闭点 = min(时段结束, 一次性关闭时间)
effEnd := slotEnd
if fc.CloseAt != nil && fc.CloseAt.Before(effEnd) {
effEnd = *fc.CloseAt
}
if effEnd.Before(slotStart) {
continue // 一次性关闭时间早于该日时段起点,该日无有效窗口
}
if effEnd.Before(now) {
if latest == nil || effEnd.After(*latest) {
t := effEnd
latest = &t
}
break // 已找到最近一次关闭
}
}
}
}
return latest
}
// parseHHMM 解析 "HH:MM" 为当天第几分钟。
func parseHHMM(s string) (int, error) {
t, err := time.Parse("15:04", s)
if err != nil {
return 0, errors.Wrap(err, "parse HH:MM")
}
return t.Hour()*60 + t.Minute(), nil
}
// containsWeekday 判断逗号分隔的周几列表(0=周日…6=周六)是否包含 wd。
func containsWeekday(days string, wd int) bool {
for _, part := range strings.Split(days, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err == nil && n == wd {
return true
}
}
return false
}
// startOfDay 返回 t 所在日历日的零点(保持时区一致)。
func startOfDay(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。
// 提问与投票默认开启(与历史行为一致);私聊默认关闭,沿用 users.allow_chat 的默认值,
// 避免历史账号在功能上线后被动开启私聊。
func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
return &FeatureControl{
UserID: userID,
Feature: feature,
Enabled: feature != FeatureChat,
}
}
// FeatureStateOf 便捷入口:读取某用户某功能在当前的综合状态。
// 查询失败或无存储时回退到默认配置,保证单个依赖表异常不会让整站功能不可用。
func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureState {
if FeatureControls == nil {
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
}
fc, err := FeatureControls.Get(ctx, userID, feature)
if err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to get feature control %q of user %d", feature, userID)
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
}
return fc.Evaluate(time.Now())
}
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
return FeatureStateOf(ctx, userID, feature).Open
}
// IsValidFeature 判断功能标识是否合法(防越权写入未知功能)。
func IsValidFeature(feature string) bool {
for _, k := range FeatureKeys {
if k == feature {
return true
}
}
return false
}
func (db *featureControls) Get(ctx context.Context, userID uint, feature string) (*FeatureControl, error) {
var fc FeatureControl
err := db.WithContext(ctx).Where("user_id = ? AND feature = ?", userID, feature).First(&fc).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return DefaultFeatureControl(userID, feature), nil
}
return nil, errors.Wrap(err, "get feature control")
}
return &fc, nil
}
func (db *featureControls) ListByUserID(ctx context.Context, userID uint) ([]*FeatureControl, error) {
var list []*FeatureControl
err := db.WithContext(ctx).
Where("user_id = ?", userID).
Order("feature ASC").
Find(&list).Error
if err != nil {
return nil, errors.Wrap(err, "list feature controls by user id")
}
return list, nil
}
func (db *featureControls) ListByUserIDs(ctx context.Context, userIDs []uint, feature string) (map[uint]*FeatureControl, error) {
result := make(map[uint]*FeatureControl, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
var list []*FeatureControl
if err := db.WithContext(ctx).
Where("user_id IN ? AND feature = ?", userIDs, feature).
Find(&list).Error; err != nil {
return nil, errors.Wrap(err, "list feature controls by user ids")
}
for _, fc := range list {
result[fc.UserID] = fc
}
return result, nil
}
type UpdateFeatureControlOptions struct {
Enabled bool
OpenAt *time.Time
CloseAt *time.Time
ScheduleDays string
ScheduleStart string
ScheduleEnd string
QuotaLimit int64
}
// Save 写入功能配置。使用 ON CONFLICT 幂等写入,并显式列出更新列,
// 避免把 quota_count(已收集数量)误重置为 0。
func (db *featureControls) Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error {
now := time.Now()
fc := &FeatureControl{
// UpdatedAt 显式写入(gorm 的 on-conflict 更新列会用到);CreatedAt 留零值由 gorm 自动填充
Model: dbutil.Model{UpdatedAt: now},
UserID: userID,
Feature: feature,
Enabled: opts.Enabled,
OpenAt: opts.OpenAt,
CloseAt: opts.CloseAt,
ScheduleDays: opts.ScheduleDays,
ScheduleStart: opts.ScheduleStart,
ScheduleEnd: opts.ScheduleEnd,
QuotaLimit: opts.QuotaLimit,
}
err := db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "feature"}},
DoUpdates: clause.AssignmentColumns([]string{
"enabled", "open_at", "close_at",
"schedule_days", "schedule_start", "schedule_end",
"quota_limit", "updated_at",
}),
}).Create(fc).Error
if err != nil {
return errors.Wrap(err, "save feature control")
}
return nil
}
// ResetQuota 清零收集计数并记录重置时间。无记录时创建一条(沿用该功能的默认开关值)。
func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature string) error {
now := time.Now()
res := db.WithContext(ctx).Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{
"quota_count": 0,
"quota_reset_at": now,
"updated_at": now,
})
if res.Error != nil {
return errors.Wrap(res.Error, "reset feature quota")
}
if res.RowsAffected > 0 {
return nil
}
// 尚无记录:按默认配置创建(提问/投票默认开启,私聊默认关闭)
fc := DefaultFeatureControl(userID, feature)
fc.QuotaResetAt = &now
if err := db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
Create(fc).Error; err != nil {
return errors.Wrap(err, "create feature control on reset quota")
}
return nil
}
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
if delta == 0 {
return nil
}
now := time.Now()
q := db.WithContext(ctx).Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature)
if delta < 0 {
q = q.Where("quota_count > 0")
}
res := q.Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta),
"updated_at": now,
})
if res.Error != nil {
return errors.Wrap(res.Error, "increment feature quota")
}
if res.RowsAffected > 0 {
return nil
}
// 尚无配置行:建一条并把计数初始化为本条(负数按 0 起步,不产生负计数)。
fc := DefaultFeatureControl(userID, feature)
if delta > 0 {
fc.QuotaCount = delta
}
fc.QuotaResetAt = &now
created := db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
Create(fc)
if created.Error != nil {
return errors.Wrap(created.Error, "create feature control on increment quota")
}
if created.RowsAffected > 0 || delta <= 0 {
return nil
}
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
retry := db.WithContext(ctx).Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta),
"updated_at": time.Now(),
})
if retry.Error != nil {
return errors.Wrap(retry.Error, "increment feature quota (retry)")
}
return nil
}
// backfillFeatureControls 为尚未初始化的历史账号补齐功能配置行。
//
// 背景:私聊开关此前落在 users.allow_chat,提问/投票则一直默认开启。功能上线后若没有
// 对应的配置行,Get 会回退到默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭。
// 因此在 AutoMigrate 之后按 users.allow_chat 回填私聊开关,其余功能按"开启"回填。
//
// 幂等:每轮只挑出仍缺配置行的账号,写完后进入下一轮,直到没有遗漏。
// 分批 + ON CONFLICT DO NOTHING,避免"边插入边查同一张表"在不同数据库上的差异行为,
// 并保证并发启动(多实例同时启动)时不会重复插入。
func backfillFeatureControls(db *gorm.DB) error {
// backfillBatchSize 每轮处理的账号数,控制单次事务体积与内存占用。
const backfillBatchSize = 500
type backfillRow struct {
UserID uint
AllowChat bool
}
for _, feature := range FeatureKeys {
defaultEnabled := feature != FeatureChat
created := int64(0)
for {
var rows []backfillRow
err := db.Table("users").
Select("users.id AS user_id, users.allow_chat AS allow_chat").
Joins("LEFT JOIN feature_controls fc ON fc.user_id = users.id AND fc.feature = ?", feature).
Where("users.deleted_at IS NULL AND fc.id IS NULL").
Limit(backfillBatchSize).
Scan(&rows).Error
if err != nil {
return errors.Wrapf(err, "query users without feature control %s", feature)
}
if len(rows) == 0 {
break
}
batch := make([]FeatureControl, 0, len(rows))
for _, r := range rows {
enabled := defaultEnabled
if feature == FeatureChat {
enabled = r.AllowChat
}
batch = append(batch, FeatureControl{
UserID: r.UserID,
Feature: feature,
Enabled: enabled,
})
}
res := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&batch)
if res.Error != nil {
return errors.Wrapf(res.Error, "backfill feature controls: %s", feature)
}
// 本轮没有任何新行落库:说明并发实例已写完或数据异常,停止循环避免死循环。
if res.RowsAffected == 0 {
break
}
created += res.RowsAffected
if len(rows) < backfillBatchSize {
break
}
}
if created > 0 {
logrus.WithField("feature", feature).
Infof("功能开关初始化:为 %d 个历史账号补齐配置", created)
}
}
return nil
}
// reconcileLegacyChatEnabled 修复旧版 backfill 写入的私聊错误开关。
//
// 背景:早期版本 FeatureControl.Enabled 带 gorm:"default:true" 标签,backfill 为
// allow_chat=false 的用户补私聊行时,false 被 GORM 替换为默认值 true,导致私聊被被动开启;
// 且 UpdateChatSettings 会把 users.allow_chat 反向同步为 true,形成持久化。
//
// 纠偏条件(必须同时满足,只命中"纯 backfill 默认行",绝不碰箱主配置过的行):
// - feature = 'chat' 且 enabled = true;
// - 对应 users.allow_chat = false(箱主从未在面板开启私聊——面板开启会同步 allow_chat=true);
// - 该行无任何自定义配置(open_at / close_at 为空、quota_limit = 0、循环定时为空);
// - updated_at <= created_at(从未被面板保存过,updated_at 未变化)。
//
// 满足条件即复位 feature_controls.enabled = false,并同步 users.allow_chat = false。
// 幂等:每轮只处理仍满足条件的行,直到没有遗漏;已复位/已配置的行不会重复命中。
func reconcileLegacyChatEnabled(db *gorm.DB) error {
// reconcileBatchSize 每轮处理的账号数,控制单次事务体积。
const reconcileBatchSize = 500
// pureDefaultFilterFC / pureDefaultFilterPlain 只匹配纯默认配置的私聊行
// (无一次性窗口/循环定时/定量上限)。两者仅列前缀不同:
// - FC 版带 "fc." 前缀,用于 SELECT(feature_controls fc 别名场景);
// - 纯版不带前缀,用于 UPDATE(GORM 不会为 UPDATE 自动加别名,带前缀会报
// missing FROM-clause entry for table "fc")。
// 同时兼容旧行 schedule 列为 NULL 与新行空串两种情况。
pureDefaultFilterFC := "fc.open_at IS NULL AND fc.close_at IS NULL AND fc.quota_limit = 0" +
" AND (fc.schedule_days IS NULL OR fc.schedule_days = '')" +
" AND (fc.schedule_start IS NULL OR fc.schedule_start = '')" +
" AND (fc.schedule_end IS NULL OR fc.schedule_end = '')"
pureDefaultFilterPlain := "open_at IS NULL AND close_at IS NULL AND quota_limit = 0" +
" AND (schedule_days IS NULL OR schedule_days = '')" +
" AND (schedule_start IS NULL OR schedule_start = '')" +
" AND (schedule_end IS NULL OR schedule_end = '')"
fixed := int64(0)
for {
var rows []struct{ UserID uint }
err := db.Table("feature_controls fc").
Select("fc.user_id").
Joins("JOIN users u ON u.id = fc.user_id AND u.allow_chat = false").
Where("fc.feature = ? AND fc.enabled = true", FeatureChat).
Where(pureDefaultFilterFC).
Where("fc.updated_at <= fc.created_at").
Where("fc.deleted_at IS NULL AND u.deleted_at IS NULL").
Limit(reconcileBatchSize).
Scan(&rows).Error
if err != nil {
return errors.Wrap(err, "query legacy chat rows to reconcile")
}
if len(rows) == 0 {
break
}
userIDs := make([]uint, 0, len(rows))
for _, r := range rows {
userIDs = append(userIDs, r.UserID)
}
// 复位 feature_controls.enabled(同一批用户 + 同样的纯默认条件,避免误伤并发新增配置)。
res := db.Table("feature_controls").
Where("user_id IN ? AND feature = ? AND enabled = true", userIDs, FeatureChat).
Where(pureDefaultFilterPlain).
Where("deleted_at IS NULL").
Update("enabled", false)
if res.Error != nil {
return errors.Wrap(res.Error, "reset legacy chat enabled")
}
fixed += res.RowsAffected
// 同步 users.allow_chat,保证两处一致。
if err := db.Table("users").
Where("id IN ? AND allow_chat = true", userIDs).
Where("deleted_at IS NULL").
Update("allow_chat", false).Error; err != nil {
return errors.Wrap(err, "sync legacy users.allow_chat")
}
if int64(len(rows)) < reconcileBatchSize {
break
}
}
if fixed > 0 {
logrus.WithField("count", fixed).
Warn("私聊开关纠偏:旧版数据把未开启私聊的账号置为开启,已复位为关闭")
}
return nil
}
// normalizeChatQuota 清零私聊行上历史残留的收集上限与计数。
//
// 背景:私聊不提供收集上限能力(会话是双向对话,限制「新建会话数」意义不大),
// UI 已隐藏该字段、保存时强制清零;此处在启动期做一次性兜底,把旧版本
// 设置过的私聊上限/计数统一清零,避免达到残留上限后私聊「莫名关闭」。
// 幂等:重复执行不产生任何变更,失败由调用方告警(不阻断启动)。
func normalizeChatQuota(db *gorm.DB) error {
res := db.Model(&FeatureControl{}).
Where("feature = ? AND (quota_limit > 0 OR quota_count > 0)", FeatureChat).
Updates(map[string]interface{}{"quota_limit": 0, "quota_count": 0})
if res.Error != nil {
return errors.Wrap(res.Error, "normalize chat feature quota")
}
if res.RowsAffected > 0 {
logrus.WithField("count", res.RowsAffected).
Info("私聊收集上限已下线:已清零历史残留的私聊收集上限与计数")
}
return nil
}
+341
View File
@@ -0,0 +1,341 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"testing"
"time"
)
// TestFeatureControlEvaluate 覆盖开关 / 定时 / 定量三类控制的组合与优先级。
// 纯逻辑测试,不依赖外部数据库。
func TestFeatureControlEvaluate(t *testing.T) {
now := time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC)
past := now.Add(-time.Hour)
future := now.Add(time.Hour)
tests := []struct {
name string
fc FeatureControl
wantOk bool
want string
}{
{
name: "开启且无限制",
fc: FeatureControl{Enabled: true},
wantOk: true, want: "",
},
{
name: "手动关闭优先于其它条件",
fc: FeatureControl{Enabled: false, OpenAt: &past, CloseAt: &future, QuotaLimit: 1},
wantOk: false, want: FeatureReasonDisabled,
},
{
name: "未到开放时间",
fc: FeatureControl{Enabled: true, OpenAt: &future},
wantOk: false, want: FeatureReasonNotStarted,
},
{
name: "开放时间已过但功能仍开启",
fc: FeatureControl{Enabled: true, OpenAt: &past},
wantOk: true, want: "",
},
{
name: "已过关闭时间",
fc: FeatureControl{Enabled: true, OpenAt: &past, CloseAt: &past},
wantOk: false, want: FeatureReasonEnded,
},
{
name: "刚好到达关闭时间即关闭",
fc: FeatureControl{Enabled: true, CloseAt: &now},
wantOk: false, want: FeatureReasonEnded,
},
{
name: "未到收集上限",
fc: FeatureControl{Enabled: true, QuotaLimit: 10, QuotaCount: 9},
wantOk: true, want: "",
},
{
name: "恰好达到收集上限",
fc: FeatureControl{Enabled: true, QuotaLimit: 10, QuotaCount: 10},
wantOk: false, want: FeatureReasonQuotaReached,
},
{
name: "超过收集上限",
fc: FeatureControl{Enabled: true, QuotaLimit: 10, QuotaCount: 12},
wantOk: false, want: FeatureReasonQuotaReached,
},
{
name: "上限为 0 表示不限制",
fc: FeatureControl{Enabled: true, QuotaLimit: 0, QuotaCount: 9999},
wantOk: true, want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := tt.fc.Evaluate(now)
if st.Open != tt.wantOk || st.Reason != tt.want {
t.Fatalf("Evaluate() = (open=%v, reason=%q), want (open=%v, reason=%q)",
st.Open, st.Reason, tt.wantOk, tt.want)
}
})
}
}
// TestDefaultFeatureControl 私聊默认关闭(沿用 users.allow_chat 的历史默认),
// 提问与投票默认开启,保证功能上线不改变既有行为。
func TestDefaultFeatureControl(t *testing.T) {
cases := map[string]bool{
FeatureQuestion: true,
FeatureChat: false,
FeatureVote: true,
}
for feature, want := range cases {
if got := DefaultFeatureControl(1, feature).Enabled; got != want {
t.Fatalf("DefaultFeatureControl(%q).Enabled = %v, want %v", feature, got, want)
}
}
}
func TestIsValidFeature(t *testing.T) {
for _, k := range FeatureKeys {
if !IsValidFeature(k) {
t.Fatalf("IsValidFeature(%q) = false, want true", k)
}
}
for _, k := range []string{"", "chat ", "CHAT", "poll", "messages"} {
if IsValidFeature(k) {
t.Fatalf("IsValidFeature(%q) = true, want false", k)
}
}
}
// TestFeatureControlSchedule 覆盖循环定时(每周开放日 + 每日时间段)的 Evaluate 判定。
func TestFeatureControlSchedule(t *testing.T) {
// 固定在一个周四 12:00(time.Weekday: 0=周日…4=周四…6=周六)
now := time.Date(2026, 9, 3, 12, 0, 0, 0, time.UTC) // 2026-09-03 是周四
thursday := int(now.Weekday())
if thursday != 4 {
t.Fatalf("test precondition: 2026-09-03 should be Thursday, got %d", thursday)
}
tests := []struct {
name string
fc FeatureControl
wantOk bool
want string
}{
{
name: "无循环定时不影响判定",
fc: FeatureControl{Enabled: true},
wantOk: true, want: "",
},
{
name: "每日时间段内开放",
fc: FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: true, want: "",
},
{
name: "每日时间段开始前关闭",
fc: FeatureControl{Enabled: true, ScheduleStart: "13:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonNotStarted,
},
{
name: "每日时间段结束后关闭(次日会再开)",
fc: FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "11:00"},
wantOk: false, want: FeatureReasonNotStarted,
},
{
name: "当天不在开放日关闭(未来还会再开)",
fc: FeatureControl{Enabled: true, ScheduleDays: "0", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonNotStarted,
},
{
name: "只设开放日(不设时间)表示全天",
fc: FeatureControl{Enabled: true, ScheduleDays: "4"},
wantOk: true, want: "",
},
{
name: "只设时间(不设开放日)表示每天该时段",
fc: FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: true, want: "",
},
{
name: "开放日与时间段组合:非开放日关闭",
fc: FeatureControl{Enabled: true, ScheduleDays: "1,3,5", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonNotStarted, // 周四不在 1,3,5 中
},
{
name: "开放日与时间段组合:开放日+时段内开放",
fc: FeatureControl{Enabled: true, ScheduleDays: "2,4", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: true, want: "",
},
{
name: "循环定时达到收集上限",
fc: FeatureControl{Enabled: true, ScheduleDays: "4", ScheduleStart: "09:00", ScheduleEnd: "18:00", QuotaLimit: 5, QuotaCount: 5},
wantOk: false, want: FeatureReasonQuotaReached,
},
{
name: "手动关闭优先于循环定时",
fc: FeatureControl{Enabled: false, ScheduleDays: "4", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonDisabled,
},
{
name: "一次性窗口与循环定时叠加:窗口内+时段内开放",
fc: FeatureControl{Enabled: true, OpenAt: &[]time.Time{now.Add(-time.Hour)}[0], CloseAt: &[]time.Time{now.Add(24 * time.Hour)}[0], ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: true, want: "",
},
{
name: "一次性窗口已关闭后循环定时不再生效(硬边界)",
fc: FeatureControl{Enabled: true, OpenAt: &[]time.Time{now.Add(-48 * time.Hour)}[0], CloseAt: &[]time.Time{now.Add(-24 * time.Hour)}[0], ScheduleDays: "4", ScheduleStart: "09:00", ScheduleEnd: "18:00"},
wantOk: false, want: FeatureReasonEnded,
},
{
name: "一次性窗口已关闭且无循环定时",
fc: FeatureControl{Enabled: true, OpenAt: &[]time.Time{now.Add(-48 * time.Hour)}[0], CloseAt: &[]time.Time{now.Add(-24 * time.Hour)}[0]},
wantOk: false, want: FeatureReasonEnded,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := tt.fc.Evaluate(now)
if st.Open != tt.wantOk || st.Reason != tt.want {
t.Fatalf("Evaluate() = (open=%v, reason=%q), want (open=%v, reason=%q)",
st.Open, st.Reason, tt.wantOk, tt.want)
}
})
}
}
// TestFeatureControlNextOpenAtLastCloseAt 覆盖「下次开放时间 / 上次关闭时间」的计算。
func TestFeatureControlNextOpenAtLastCloseAt(t *testing.T) {
// 2026-09-03 12:00 UTC(周四)
now := time.Date(2026, 9, 3, 12, 0, 0, 0, time.UTC)
t.Run("循环定时与一次性窗口交集:当天时段内更晚开放", func(t *testing.T) {
// now=周四 12:00,循环每天 09:00-18:00;一次性开放时间 = 今天 14:00 → 今天 14:00 开放
openAt := time.Date(2026, 9, 3, 14, 0, 0, 0, time.UTC)
closeAt := time.Date(2026, 9, 3, 20, 0, 0, 0, time.UTC)
fc := FeatureControl{Enabled: true, OpenAt: &openAt, CloseAt: &closeAt,
ScheduleStart: "09:00", ScheduleEnd: "18:00"}
st := fc.Evaluate(now)
if st.Reason != FeatureReasonNotStarted {
t.Fatalf("reason = %q, want not_started", st.Reason)
}
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(openAt) {
t.Fatalf("NextOpenAt = %v, want %v(今天 14:00)", st.NextOpenAt, openAt)
}
})
t.Run("一次性窗口在循环时段结束后才开放则当天不开放", func(t *testing.T) {
// 一次性开放时间 20:00 晚于循环时段结束 18:00 → 今天无有效窗口,下次开放 = 明天 09:00
openAt := time.Date(2026, 9, 3, 20, 0, 0, 0, time.UTC)
closeAt := time.Date(2026, 9, 5, 20, 0, 0, 0, time.UTC)
fc := FeatureControl{Enabled: true, OpenAt: &openAt, CloseAt: &closeAt,
ScheduleStart: "09:00", ScheduleEnd: "18:00"}
st := fc.Evaluate(now)
if st.Reason != FeatureReasonNotStarted {
t.Fatalf("reason = %q, want not_started", st.Reason)
}
wantNext := time.Date(2026, 9, 4, 9, 0, 0, 0, time.UTC)
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(wantNext) {
t.Fatalf("NextOpenAt = %v, want %v(明天 09:00)", st.NextOpenAt, wantNext)
}
})
t.Run("手动关闭与定量上限不展示时间信息", func(t *testing.T) {
disabled := FeatureControl{Enabled: false, ScheduleStart: "09:00", ScheduleEnd: "18:00"}
st := disabled.Evaluate(now)
if st.Reason != FeatureReasonDisabled || st.NextOpenAt != nil || st.LastCloseAt != nil {
t.Fatalf("disabled state = (reason=%q, next=%v, last=%v), want nil times",
st.Reason, st.NextOpenAt, st.LastCloseAt)
}
quota := FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00", QuotaLimit: 1, QuotaCount: 1}
st = quota.Evaluate(now)
if st.Reason != FeatureReasonQuotaReached || st.NextOpenAt != nil || st.LastCloseAt != nil {
t.Fatalf("quota state = (reason=%q, next=%v, last=%v), want nil times",
st.Reason, st.NextOpenAt, st.LastCloseAt)
}
})
t.Run("一次性窗口未到开放时间", func(t *testing.T) {
openAt := now.Add(2 * time.Hour)
closeAt := now.Add(24 * time.Hour)
fc := FeatureControl{Enabled: true, OpenAt: &openAt, CloseAt: &closeAt}
st := fc.Evaluate(now)
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(openAt) {
t.Fatalf("NextOpenAt = %v, want %v", st.NextOpenAt, openAt)
}
if st.LastCloseAt != nil {
t.Fatalf("LastCloseAt = %v, want nil(从未开放过)", st.LastCloseAt)
}
})
t.Run("一次性窗口已结束", func(t *testing.T) {
openAt := now.Add(-48 * time.Hour)
closeAt := now.Add(-24 * time.Hour)
fc := FeatureControl{Enabled: true, OpenAt: &openAt, CloseAt: &closeAt}
st := fc.Evaluate(now)
if st.NextOpenAt != nil {
t.Fatalf("NextOpenAt = %v, want nil(无循环定时,永久关闭)", st.NextOpenAt)
}
if st.LastCloseAt == nil || !st.LastCloseAt.Equal(closeAt) {
t.Fatalf("LastCloseAt = %v, want %v", st.LastCloseAt, closeAt)
}
})
t.Run("循环定时:每天 09:00-18:00", func(t *testing.T) {
fc := FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00"}
// 12:00 在窗口内:下次开放 = 明天 09:00;上次关闭 = 今天 18:00 尚未结束 → 昨天 18:00
st := fc.Evaluate(now)
if st.Open != true {
t.Fatalf("should be open, got reason=%q", st.Reason)
}
wantNext := time.Date(2026, 9, 4, 9, 0, 0, 0, time.UTC)
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(wantNext) {
t.Fatalf("NextOpenAt = %v, want %v", st.NextOpenAt, wantNext)
}
wantLast := time.Date(2026, 9, 2, 18, 0, 0, 0, time.UTC)
if st.LastCloseAt == nil || !st.LastCloseAt.Equal(wantLast) {
t.Fatalf("LastCloseAt = %v, want %v", st.LastCloseAt, wantLast)
}
})
t.Run("循环定时:18:00 已过", func(t *testing.T) {
evening := time.Date(2026, 9, 3, 20, 0, 0, 0, time.UTC)
fc := FeatureControl{Enabled: true, ScheduleStart: "09:00", ScheduleEnd: "18:00"}
st := fc.Evaluate(evening)
if st.Open {
t.Fatal("should be closed after daily end")
}
wantNext := time.Date(2026, 9, 4, 9, 0, 0, 0, time.UTC)
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(wantNext) {
t.Fatalf("NextOpenAt = %v, want %v", st.NextOpenAt, wantNext)
}
wantLast := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
if st.LastCloseAt == nil || !st.LastCloseAt.Equal(wantLast) {
t.Fatalf("LastCloseAt = %v, want %v", st.LastCloseAt, wantLast)
}
})
t.Run("循环定时:仅周五开放(今天周四)", func(t *testing.T) {
fc := FeatureControl{Enabled: true, ScheduleDays: "5"} // 周五全天
st := fc.Evaluate(now)
if st.Open {
t.Fatal("should be closed on non-scheduled day")
}
wantNext := time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC) // 周五零点
if st.NextOpenAt == nil || !st.NextOpenAt.Equal(wantNext) {
t.Fatalf("NextOpenAt = %v, want %v", st.NextOpenAt, wantNext)
}
// 全天窗口 [周五 00:00, 周六 00:00),最近一次关闭 = 上周六零点
wantLast := time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC)
if st.LastCloseAt == nil || !st.LastCloseAt.Equal(wantLast) {
t.Fatalf("LastCloseAt = %v, want %v", st.LastCloseAt, wantLast)
}
})
}
+221
View File
@@ -0,0 +1,221 @@
package db
import (
"context"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// MaxFollowUpCount 每条提问允许的最大追问次数(防滥用)。
// 提问者被回答后,用 token(或登录账号)继续追问,达到上限后不可再追问。
const MaxFollowUpCount = 3
// 追问相关错误(供 handler 区分处理,避免把业务错误当 500)
var (
// ErrFollowUpLimitReached 追问次数已达上限
ErrFollowUpLimitReached = errors.New("follow up limit reached")
// ErrFollowUpNotFound 追问不存在或不属于该问题
ErrFollowUpNotFound = errors.New("follow up not found")
// ErrFollowUpAlreadyAnswered 追问已被回答(禁止覆盖)
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
)
var FollowUps FollowUpsStore
type FollowUpsStore interface {
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建。
// 返回 created=false 且 err==ErrFollowUpLimitReached 表示已达上限。
// 通过事务内锁定问题行,串行化同一问题的并发追问,消除 count-then-create 竞态。
CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (created bool, err error)
GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error)
// GetLatestByQuestionID 返回某条提问最新的一条追问;无追问时返回 (nil, nil)。
GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error)
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回。
// 用于账号导出等场景,将 N 次单题查询合并为 1 次 IN 查询,避免 N+1。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error)
CountByQuestionID(ctx context.Context, questionID uint) (int64, error)
// AnswerByID 回答某条追问;questionID 用于防止越权(必须属于该问题)。
// 仅允许回答尚未回答的追问(answer 为空),已回答的返回 ErrFollowUpAlreadyAnswered,
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 与 AnswerByID 不同,此处允许覆盖已回答的追问(供主人修改回复使用)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
func NewFollowUpsStore(db *gorm.DB) FollowUpsStore {
return &followUps{db}
}
type followUps struct {
*gorm.DB
}
// FollowUp 表示对某条提问的一轮追问。
type FollowUp struct {
dbutil.Model
QuestionID uint `gorm:"index:idx_follow_up_question" json:"question_id"`
Content string `gorm:"type:text" json:"content"`
Answer string `gorm:"type:text" json:"answer"`
}
type CreateFollowUpOptions struct {
QuestionID uint
Content string
}
func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (bool, error) {
created := false
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 锁定问题行,串行化同一问题的并发追问(MySQL/Postgres 均支持 FOR UPDATE)。
var q Question
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").
First(&q, opts.QuestionID).Error; err != nil {
return errors.Wrap(err, "lock question for follow up")
}
var count int64
if err := tx.Model(&FollowUp{}).
Where("question_id = ?", opts.QuestionID).
Count(&count).Error; err != nil {
return errors.Wrap(err, "count follow ups by question id")
}
if count >= MaxFollowUpCount {
return ErrFollowUpLimitReached
}
f := &FollowUp{
QuestionID: opts.QuestionID,
Content: opts.Content,
}
if err := tx.Create(f).Error; err != nil {
return errors.Wrap(err, "create follow up")
}
created = true
return nil
})
if err != nil {
return false, err
}
return created, nil
}
// GetByQuestionID 按时间正序返回某条提问的所有追问。
func (db *followUps) GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error) {
var followUps []*FollowUp
if err := db.WithContext(ctx).
Where("question_id = ?", questionID).
Order("created_at ASC").
Find(&followUps).Error; err != nil {
return nil, errors.Wrap(err, "get follow ups by question id")
}
return followUps, nil
}
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序取首条);
// 无追问时返回 (nil, nil)。供发布追问前校验"上一条是否已回复"。
func (db *followUps) GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) {
var fu FollowUp
err := db.WithContext(ctx).
Where("question_id = ?", questionID).
Order("created_at DESC").
Limit(1).
First(&fu).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, errors.Wrap(err, "get latest follow up by question id")
}
return &fu, nil
}
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(解决导出 N+1)。
func (db *followUps) GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error) {
result := make(map[uint][]*FollowUp)
if len(questionIDs) == 0 {
return result, nil
}
var items []*FollowUp
if err := db.WithContext(ctx).
Where("question_id IN ?", questionIDs).
Order("question_id ASC, created_at ASC").
Find(&items).Error; err != nil {
return nil, errors.Wrap(err, "get follow ups by question ids")
}
for _, fu := range items {
result[fu.QuestionID] = append(result[fu.QuestionID], fu)
}
return result, nil
}
// CountByQuestionID 统计某条提问的追问次数,用于限制追问上限。
func (db *followUps) CountByQuestionID(ctx context.Context, questionID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).
Model(&FollowUp{}).
Where("question_id = ?", questionID).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count follow ups by question id")
}
return count, nil
}
// AnswerByID 更新某条追问的回答内容(带 question_id 条件防止越权)。
// 原子更新条件含 answer=”:已回答的追问不会被覆盖(避免重复提交时邮件/回答被二次写入)。
// RowsAffected==0 时再查一次以区分"不存在"与"已回答",返回对应错误。
func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ? AND answer = ''", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "answer follow up")
}
if res.RowsAffected == 0 {
var cnt int64
if err := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Count(&cnt).Error; err != nil {
return errors.Wrap(err, "check follow up existence")
}
if cnt == 0 {
return ErrFollowUpNotFound
}
return ErrFollowUpAlreadyAnswered
}
return nil
}
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "update follow up answer")
}
if res.RowsAffected == 0 {
return ErrFollowUpNotFound
}
return nil
}
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
func (db *followUps) DeleteByQuestionID(ctx context.Context, questionID uint) error {
if err := db.WithContext(ctx).Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups by question id")
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2023 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"testing"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// newTestDB returns a test database instance with the cleanup function.
func newTestDB(t *testing.T) (*gorm.DB, func(...string) error) {
return dbutil.NewTestDB(t, AllTables...)
}
+132
View File
@@ -0,0 +1,132 @@
package db
import (
"context"
"fmt"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Messages MessageStore
type MessageStore interface {
Create(ctx context.Context, opts CreateMessageOptions) (*Message, error)
GetByConversationID(ctx context.Context, conversationID uint, cursor *dbutil.Cursor) ([]*Message, error)
// GetFirstMessagesByConversationIDs 批量获取多个会话的第一条消息(解决 N+1 查询)
GetFirstMessagesByConversationIDs(ctx context.Context, conversationIDs []uint) (map[uint]string, error)
// GetAllByConversationIDs 批量获取多个会话的全部消息(导出用,不分页),按 conversation_id 分组。
// 将每个会话原本的 1 次查询合并为 1 次 IN 查询,避免 N+1。
GetAllByConversationIDs(ctx context.Context, conversationIDs []uint) (map[uint][]*Message, error)
}
func NewMessageStore(db *gorm.DB) MessageStore {
return &messages{db}
}
type messages struct {
*gorm.DB
}
type Message struct {
dbutil.Model
ConversationID uint `gorm:"index:idx_message_conversation" json:"conversation_id"`
SenderID uint `json:"sender_id"`
Content string `gorm:"type:text" json:"content"`
Read bool `gorm:"default:false" json:"read"`
}
type CreateMessageOptions struct {
ConversationID uint
SenderID uint
Content string
}
func (db *messages) Create(ctx context.Context, opts CreateMessageOptions) (*Message, error) {
msg := &Message{
ConversationID: opts.ConversationID,
SenderID: opts.SenderID,
Content: opts.Content,
}
if err := db.WithContext(ctx).Create(msg).Error; err != nil {
return nil, errors.Wrap(err, "create message")
}
return msg, nil
}
func (db *messages) GetByConversationID(ctx context.Context, conversationID uint, cursor *dbutil.Cursor) ([]*Message, error) {
var messages []*Message
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID)
limit := 50
if cursor != nil {
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
q = q.Where("id < ?", cursor.Value)
}
limit = cursor.Limit()
}
q = q.Order("created_at ASC")
q = q.Limit(limit)
if err := q.Find(&messages).Error; err != nil {
return nil, errors.Wrap(err, "get messages")
}
return messages, nil
}
// GetFirstMessagesByConversationIDs 批量获取多个会话的第一条消息内容。
// 使用窗口函数 ROW_NUMBER() 选出每个会话最早的消息,避免 N+1 查询。
func (db *messages) GetFirstMessagesByConversationIDs(ctx context.Context, conversationIDs []uint) (map[uint]string, error) {
if len(conversationIDs) == 0 {
return make(map[uint]string), nil
}
// 使用子查询 + ROW_NUMBER 窗口函数获取每个 conversation 的第一条消息
// 兼容 MySQL 8+ / PostgreSQL / SQLite 3.25+
var results []struct {
ConversationID uint `gorm:"column:conversation_id"`
Content string `gorm:"column:content"`
}
err := db.WithContext(ctx).Raw(`
SELECT conversation_id, content FROM (
SELECT conversation_id, content,
ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at ASC) AS rn
FROM messages
WHERE conversation_id IN ?
) sub WHERE rn = 1
`, conversationIDs).Scan(&results).Error
if err != nil {
return nil, errors.Wrap(err, "get first messages batch")
}
resultMap := make(map[uint]string, len(results))
for _, r := range results {
resultMap[r.ConversationID] = r.Content
}
return resultMap, nil
}
// GetAllByConversationIDs 批量获取多个会话的全部消息(导出用,不分页),按 conversation_id 分组。
// 用于账号导出:将每个会话原本的 1 次查询合并为 1 次 IN 查询,避免 N+1。
func (db *messages) GetAllByConversationIDs(ctx context.Context, conversationIDs []uint) (map[uint][]*Message, error) {
result := make(map[uint][]*Message)
if len(conversationIDs) == 0 {
return result, nil
}
var items []*Message
if err := db.WithContext(ctx).
Where("conversation_id IN ?", conversationIDs).
Order("conversation_id ASC, created_at ASC").
Find(&items).Error; err != nil {
return nil, errors.Wrap(err, "get messages by conversation ids")
}
for _, m := range items {
result[m.ConversationID] = append(result[m.ConversationID], m)
}
return result, nil
}
+63
View File
@@ -0,0 +1,63 @@
package db
import (
"context"
"github.com/pkg/errors"
"gorm.io/gorm"
)
var MessageImages MessageImageStore
type MessageImageStore interface {
Create(ctx context.Context, opts CreateMessageImageOptions) (*MessageImage, error)
GetByMessageIDs(ctx context.Context, messageIDs []uint) (map[uint][]*MessageImage, error)
}
func NewMessageImageStore(db *gorm.DB) MessageImageStore {
return &messageImages{db}
}
type messageImages struct {
*gorm.DB
}
type MessageImage struct {
gorm.Model
MessageID uint `gorm:"index" json:"message_id"`
URL string `gorm:"size:500" json:"url"`
FileName string `gorm:"size:200" json:"file_name"`
FileSize int64 `json:"file_size"`
}
type CreateMessageImageOptions struct {
MessageID uint
URL string
FileName string
FileSize int64
}
func (db *messageImages) Create(ctx context.Context, opts CreateMessageImageOptions) (*MessageImage, error) {
img := &MessageImage{
MessageID: opts.MessageID,
URL: opts.URL,
FileName: opts.FileName,
FileSize: opts.FileSize,
}
if err := db.WithContext(ctx).Create(img).Error; err != nil {
return nil, errors.Wrap(err, "create message image")
}
return img, nil
}
func (db *messageImages) GetByMessageIDs(ctx context.Context, messageIDs []uint) (map[uint][]*MessageImage, error) {
var images []*MessageImage
if err := db.WithContext(ctx).Where("message_id IN ?", messageIDs).Find(&images).Error; err != nil {
return nil, errors.Wrap(err, "get message images")
}
result := make(map[uint][]*MessageImage)
for _, img := range images {
result[img.MessageID] = append(result[img.MessageID], img)
}
return result, nil
}
+85
View File
@@ -0,0 +1,85 @@
package db
import (
"crypto/subtle"
"encoding/hex"
"strings"
"github.com/sirupsen/logrus"
"github.com/wuhan005/gadget"
"golang.org/x/crypto/bcrypt"
"github.com/tamakyi/TamaBox/internal/conf"
)
// bcryptCost is the cost parameter for bcrypt (10 is a good default, 12 for higher security).
const bcryptCost = 10
// bcryptMaxPasswordLen is the effective input length of bcrypt (72 bytes).
// bcrypt 只使用前 72 字节;新版 x/crypto 对超长密码直接返回错误,
// 这里统一截断到 72 字节(与 bcrypt 原生语义一致),避免走错误分支。
const bcryptMaxPasswordLen = 72
// encodePasswordBcrypt hashes a password with bcrypt.
// 注意:失败时绝不能静默降级为 HMAC-SHA1 弱哈希(此前行为),
// 返回空串会让该账号所有密码校验失败并留下错误日志,比弱哈希更安全。
func encodePasswordBcrypt(password string) string {
if len(password) > bcryptMaxPasswordLen {
password = password[:bcryptMaxPasswordLen]
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
// cost 为常量 10,正常不会到这里;防御性记录,不降级为弱哈希。
logrus.WithError(err).Error("bcrypt generate failed")
return ""
}
return string(hash)
}
// isBcryptHash checks if the given string is a bcrypt hash.
func isBcryptHash(hash string) bool {
return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$")
}
// IsBcryptHash exported for use in other packages.
func IsBcryptHash(hash string) bool {
return isBcryptHash(hash)
}
// authenticatePassword verifies a password against either bcrypt or legacy HMAC-SHA1 hash.
// Returns (true, newHash) if the password matches. If the old HMAC-SHA1 format was used,
// newHash contains the upgraded bcrypt hash — caller should persist it.
func authenticatePassword(storedHash, password string) (bool, string) {
if storedHash == "" {
return false, ""
}
// Try bcrypt first (new format).
if isBcryptHash(storedHash) {
err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
return err == nil, ""
}
// Fall back to legacy HMAC-SHA1(常量时间比较,防时序侧信道)。
legacyHash := gadget.HmacSha1(password, conf.Server.Salt)
if subtle.ConstantTimeCompare([]byte(legacyHash), []byte(storedHash)) == 1 {
// Password matches legacy format — upgrade to bcrypt.
return true, encodePasswordBcrypt(password)
}
return false, ""
}
// needsRehash checks if a bcrypt hash should be rehashed (e.g., cost changed).
func needsRehash(hash string) bool {
cost, err := bcrypt.Cost([]byte(hash))
if err != nil {
return true
}
return cost != bcryptCost
}
// HexEncodeToString is a helper to encode bytes as hex for legacy hash comparison.
func HexEncodeToString(src []byte) string {
return hex.EncodeToString(src)
}
+546
View File
@@ -0,0 +1,546 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Questions QuestionsStore
type QuestionsStore interface {
Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error)
GetByID(ctx context.Context, id uint) (*Question, error)
GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) error
DeleteByID(ctx context.Context, id uint) error
UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error
Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, error)
SetPrivate(ctx context.Context, id uint) error
SetPublic(ctx context.Context, id uint) error
SetPinned(ctx context.Context, id uint, pinned bool) error
SetRead(ctx context.Context, id uint) error
SetUnread(ctx context.Context, id uint) error
SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error)
GetHotQuestions(ctx context.Context, limit int) ([]*Question, error)
// 全站统计
QuestionStats(ctx context.Context) (*QuestionStats, error)
}
// QuestionStats 全站提问聚合统计(不按单个用户过滤,供首页"提问箱信息统计"使用)。
type QuestionStats struct {
TotalQuestions int64
AnsweredQuestions int64
LastQuestionAt *time.Time // 最后一个提问发生时间(questions.created_at 最大值)
LastAnswerAt *time.Time // 最后一个回答发生时间(已回答问题的 updated_at 最大值)
}
func NewQuestionsStore(db *gorm.DB) QuestionsStore {
return &questions{db}
}
type questions struct {
*gorm.DB
}
type Question struct {
dbutil.Model
FromIP string `json:"-"`
UserID uint `gorm:"index:idx_question_user_id" json:"-"`
Content string `json:"content"`
ContentCensorMetadata datatypes.JSON `json:"-"`
Token string `json:"-"`
Answer string `json:"answer"`
AnswerCensorMetadata datatypes.JSON `json:"-"`
ReceiveReplyEmail string `json:"-"`
AskerUserID uint `json:"-"`
IsPrivate bool `gorm:"default: FALSE; NOT NULL" json:"-"`
IsPinned bool `gorm:"default: FALSE; NOT NULL" json:"is_pinned"`
// IsRead 标记箱主是否已读/处理过该提问。有效"已读" = IsRead=true 或 已有回答(answer != "")。
// 已回答的问题恒为已读且锁定,不可改回未读(见 SetUnread 的 answer='' 条件与上层校验)。
IsRead bool `gorm:"default: FALSE; NOT NULL" json:"is_read"`
}
type CreateQuestionOptions struct {
FromIP string
UserID uint
Content string
ReceiveReplyEmail string
AskerUserID uint
IsPrivate bool
}
func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) {
question := Question{
FromIP: opts.FromIP,
UserID: opts.UserID,
Token: randstr.String(6),
Content: opts.Content,
ReceiveReplyEmail: opts.ReceiveReplyEmail,
AskerUserID: opts.AskerUserID,
IsPrivate: opts.IsPrivate,
}
return &question, db.WithContext(ctx).Create(&question).Error
}
type UpdateQuestionCensorOptions struct {
ContentCensorMetadata json.RawMessage
AnswerCensorMetadata json.RawMessage
}
func (db *questions) UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error {
question, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get by ID")
}
contentCensorMetadata := question.ContentCensorMetadata
if checkTextCensorResponseValid(opts.ContentCensorMetadata) {
contentCensorMetadata = datatypes.JSON(opts.ContentCensorMetadata)
}
answerCensorMetadata := question.AnswerCensorMetadata
if checkTextCensorResponseValid(opts.AnswerCensorMetadata) {
answerCensorMetadata = datatypes.JSON(opts.AnswerCensorMetadata)
}
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Updates(&Question{
ContentCensorMetadata: contentCensorMetadata,
AnswerCensorMetadata: answerCensorMetadata,
}).Error
}
func checkTextCensorResponseValid(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
if bytes.EqualFold(raw, []byte("null")) {
return false
}
var response struct {
SourceName string `json:"source_name"`
}
if err := json.Unmarshal(raw, &response); err != nil {
return false
}
return response.SourceName != ""
}
var ErrQuestionNotExist = errors.New("提问不存在")
func (db *questions) GetByID(ctx context.Context, id uint) (*Question, error) {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrQuestionNotExist
}
return nil, errors.Wrap(err, "get question by ID")
}
return &question, nil
}
type GetQuestionsByUserIDOptions struct {
*dbutil.Cursor
FilterAnswered bool // 已废弃,推荐使用 AnsweredStatus
ShowPrivate bool // 已废弃,推荐使用 PrivateStatus
AnsweredStatus int // 0: 全部, 1: 已回答, 2: 未回答
PrivateStatus int // 0: 全部, 1: 仅公开(is_private=false), 2: 仅私密(is_private=true)
ReadStatus int // 0: 全部, 1: 已读(is_read=true 或已回答), 2: 未读
SortOrder string // "ASC" 或 "DESC",默认 "DESC"
PinFirst bool // 置顶优先:is_pinned 的提问排在最前(公开主页使用)
}
// GetByUserID 根据用户 ID 获取提问列表,支持游标分页
func (db *questions) GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error) {
q := db.WithContext(ctx).Model(&Question{}).Where("user_id = ?", userID)
// 回答状态筛选
if opts.AnsweredStatus == 1 {
q = q.Where("answer != ''")
} else if opts.AnsweredStatus == 2 {
q = q.Where("answer = ''")
} else if opts.FilterAnswered {
// 兼容旧调用
q = q.Where("answer != ''")
}
// 私密状态筛选
if opts.PrivateStatus == 1 {
q = q.Where("is_private = false")
} else if opts.PrivateStatus == 2 {
q = q.Where("is_private = true")
} else if !opts.ShowPrivate && opts.PrivateStatus == 0 {
// 兼容旧调用:如果 ShowPrivate=false 且未指定 PrivateStatus,则过滤私密
q = q.Where("is_private = false")
}
// 阅读状态筛选:已读 = is_read=true 或已回答;未读 = is_read=false 且未回答。
// 使用括号包裹 OR 条件,避免与前面的 user_id 等条件产生 AND/OR 优先级问题。
if opts.ReadStatus == 1 {
q = q.Where("(is_read = ? OR answer != '')", true)
} else if opts.ReadStatus == 2 {
q = q.Where("(is_read = ? AND answer = '')", false)
}
// 排序方向先规范化,游标方向依赖它(ASC=下一页 id 更大,DESC=下一页 id 更小)。
sortOrder := opts.SortOrder
if sortOrder == "" {
sortOrder = "DESC"
}
if sortOrder != "ASC" && sortOrder != "DESC" {
sortOrder = "DESC"
}
// 游标分页
// 关键语义:携带游标却无法解析时,不再“忽略游标、从首页重新开始”。
// 旧行为会导致「加载更多」重复返回第一页内容、按钮永不消失(内容重复的
// 直接根因之一,见 history-template 的复合游标改造历史)。改为直接返回
// 空列表:上层(ListAPI/Pager 等)据此判定 has_more=false,前端按钮收敛。
if opts.Cursor != nil && opts.Cursor.Value != nil {
cursorStr := fmt.Sprintf("%v", opts.Cursor.Value)
if cursorStr != "" {
if opts.PinFirst {
// 置顶优先场景下,排序为 (is_pinned DESC, created_at, id),
// 必须用"复合键集"(keyset)游标,否则置顶项与非置顶项 id 交错时
// 单纯 id < 游标会漏掉介于置顶项 id 之间、但排在更后面的非置顶项。
// 游标格式:"<isPinned>.<createdAtUnixNano>.<id>"。
isPinned, createdAt, idVal, ok := parsePinCursor(cursorStr)
if !ok {
return []*Question{}, nil
}
// 排序为 (is_pinned DESC, created_at DESC, id DESC),下一页 = 排序序位严格在游标之后的行。
// 注意:is_pinned 在 PG 下是 boolean,与整型比较(boolean < integer)无操作符会直接报错,
// 因此必须传 Go bool 而非 0/1 整型(MySQL 下 bool 亦兼容 tinyint)。
// 游标位于置顶段时,后续行 = 置顶段内 (created_at,id) 更小 + 全部非置顶段;
// 游标位于非置顶段时,后续行 = 非置顶段内 (created_at,id) 更小。
if isPinned == 1 {
q = q.Where(
"(is_pinned = ? AND created_at < ?) OR (is_pinned = ? AND created_at = ? AND id < ?) OR (is_pinned = ?)",
true, createdAt, true, createdAt, idVal, false,
)
} else {
q = q.Where(
"(is_pinned = ? AND created_at < ?) OR (is_pinned = ? AND created_at = ? AND id < ?)",
false, createdAt, false, createdAt, idVal,
)
}
} else {
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
// 且 ASC(旧在前)时下一页 id 更大,DESC(新在前)时下一页 id 更小。
idVal, err := strconv.ParseUint(cursorStr, 10, 64)
if err != nil {
// 同上:游标非法不再回退首页,直接收敛为空列表。
return []*Question{}, nil
}
if sortOrder == "ASC" {
q = q.Where("id > ?", idVal)
} else {
q = q.Where("id < ?", idVal)
}
}
}
}
if opts.Cursor != nil {
limit := opts.Cursor.Limit()
q = q.Limit(limit)
}
if opts.PinFirst {
// 公开主页:置顶优先;用 id 作为稳定 tiebreaker 保证排序完全确定。
q = q.Order("is_pinned DESC, created_at " + sortOrder + ", id " + sortOrder)
} else {
q = q.Order("created_at " + sortOrder + ", id " + sortOrder)
}
var questions []*Question
if err := q.Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get questions by user id")
}
return questions, nil
}
// parsePinCursor 解析 PinFirst 分页的复合键集游标,格式 "<isPinned>.<createdAtUnixNano>.<id>"。
// 返回 (isPinned, createdAt, id, ok);格式非法时 ok=false,调用方应忽略该游标(从首页重新开始)。
//
// 时区约定(务必保持,否则线上翻页会退化为首页死循环):
// EncodePinCursor 的 nano 取自 DB 读回的 CreatedAt.UnixNano(),生产列是
// timestamp WITHOUT time zone,读写按「墙钟即 UTC」对称,因此 Parse 侧必须把 nano
// 还原成 UTC 墙钟的 time.Time(time.Unix(0,nano).UTC()),再交给 GORM 与无时区列比较。
// 若直接返回 time.Unix(0, nano)(Location=服务器本地时区),当进程 TZ 非 UTC 时
// (如 Asia/Shanghai)墙钟会偏移 +8h,导致 keyset 条件 created_at < 偏移时间恒为真,
// 游标形同虚设,每次请求都退回第一页 → 「加载更多」无限重复且按钮永不消失。
func parsePinCursor(s string) (int, time.Time, uint, bool) {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return 0, time.Time{}, 0, false
}
isPinned, err := strconv.Atoi(parts[0])
if err != nil {
return 0, time.Time{}, 0, false
}
nano, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, time.Time{}, 0, false
}
idVal, err := strconv.ParseUint(parts[2], 10, 64)
if err != nil {
return 0, time.Time{}, 0, false
}
return isPinned, time.Unix(0, nano).UTC(), uint(idVal), true
}
// EncodePinCursor 生成 PinFirst 分页游标(供路由层把末条记录编码进下一页游标)。
func EncodePinCursor(isPinned bool, createdAt time.Time, id uint) string {
v := 0
if isPinned {
v = 1
}
return fmt.Sprintf("%d.%d.%d", v, createdAt.UnixNano(), id)
}
type GetQuestionsByAskUserIDOptions struct {
*dbutil.Cursor
FilterAnswered bool
ShowPrivate bool
}
func (db *questions) GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error) {
where := `asker_user_id = ?`
args := userID
if opts.FilterAnswered {
where = `asker_user_id = ? AND answer != ''`
}
if !opts.ShowPrivate {
where += ` AND is_private = false`
}
questions, err := db.getBy(ctx, opts.Cursor, where, args)
if err != nil {
return nil, errors.Wrap(err, "get by")
}
return questions, nil
}
// getBy 保留用于 GetByAskUserID 等旧逻辑
func (db *questions) getBy(ctx context.Context, cursor *dbutil.Cursor, whereQuery string, args ...interface{}) ([]*Question, error) {
var questions []*Question
q := db.WithContext(ctx).Where(whereQuery, args...)
if cursor != nil {
cursorID := cursor.Value
if cursorID != nil && fmt.Sprintf("%v", cursorID) != "" {
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
// 解析失败不再忽略游标回退首页(会导致「加载更多」重复首页内容),
// 直接收敛为空列表,令调用方按“无更多”处理。
idVal, err := strconv.ParseUint(fmt.Sprintf("%v", cursorID), 10, 64)
if err != nil {
return []*Question{}, nil
}
if idVal > 0 {
q = q.Where(`id < ?`, idVal)
}
}
limit := cursor.Limit()
q = q.Limit(limit)
}
q = q.Order("created_at DESC")
if err := q.Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get questions by page ID")
}
return questions, nil
}
func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
Updates(map[string]interface{}{"answer": answer, "is_read": true}).Error; err != nil {
return errors.Wrap(err, "update question answer")
}
return nil
}
func (db *questions) DeleteByID(ctx context.Context, id uint) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Delete(&Question{}, id).Error; err != nil {
return errors.Wrap(err, "delete question")
}
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
if err := FollowUps.DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
}
if err := UploadImages.DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id)
}
// 回退定量收集计数:删除提问等价于少一条已收集内容,
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
if question.UserID != 0 {
if err := FeatureControls.IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID)
}
}
return nil
}
type GetQuestionsCountOptions struct {
FilterAnswered bool
ShowPrivate bool
IsPrivate *bool // 非 nil 时按该值过滤 is_private
IsPinned *bool // 非 nil 时按该值过滤 is_pinned
ReadStatus int // 0: 全部, 1: 已读(is_read=true 或已回答), 2: 未读
}
func (db *questions) Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, error) {
q := db.WithContext(ctx).Model(&Question{})
if opts.FilterAnswered {
q = q.Where(`user_id = ? AND answer != ''`, userID)
} else {
q = q.Where(`user_id = ?`, userID)
}
if !opts.ShowPrivate {
q = q.Where(`is_private = ?`, false)
}
if opts.IsPrivate != nil {
q = q.Where(`is_private = ?`, *opts.IsPrivate)
}
if opts.IsPinned != nil {
q = q.Where(`is_pinned = ?`, *opts.IsPinned)
}
// 阅读状态筛选:已读 = is_read=true 或已回答;未读 = is_read=false 且未回答。
if opts.ReadStatus == 1 {
q = q.Where("(is_read = ? OR answer != '')", true)
} else if opts.ReadStatus == 2 {
q = q.Where("(is_read = ? AND answer = '')", false)
}
var count int64
return count, q.Count(&count).Error
}
func (db *questions) SetPrivate(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", true).Error
}
// SetPinned 设置/取消问题的置顶状态。
func (db *questions) SetPinned(ctx context.Context, id uint, pinned bool) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_pinned", pinned).Error
}
func (db *questions) SetPublic(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", false).Error
}
// SetRead 标记提问为已读(箱主侧手动标记,或作为其他语义化操作的前置状态)。
func (db *questions) SetRead(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_read", true).Error
}
// SetUnread 标记提问为未读。已回答的提问恒为已读且锁定,故追加 answer=” 条件:
// 即使上层遗漏校验,也不会把已回答的提问错误地改回未读(纵深防御)。
func (db *questions) SetUnread(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ? AND answer = ''", id).Update("is_read", false).Error
}
// SearchByUserID 全文搜索提问箱主人收到的提问(含私密,仅限本人管理后台使用)。
// 使用 LIKE 模糊匹配问题内容与回答;keyword 中的通配符 % _ \ 会被转义,避免模式滥用。
func (db *questions) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error) {
escaped := escapeLike(keyword)
var questions []*Question
if err := db.WithContext(ctx).
Where("user_id = ? AND (content LIKE ? OR answer LIKE ?)", userID, "%"+escaped+"%", "%"+escaped+"%").
Order("created_at DESC").
Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "search questions by user id")
}
return questions, nil
}
// escapeLike 转义 LIKE 模式中的特殊字符。
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// GetHotQuestions 返回"热门问题":最新活跃的公开已回答提问(按回答更新时间倒序)。
// 提问箱没有点赞数据,用"最近被回答"作为热度近似,适合主页/探索页展示。
func (db *questions) GetHotQuestions(ctx context.Context, limit int) ([]*Question, error) {
if limit <= 0 {
limit = 10
}
var questions []*Question
if err := db.WithContext(ctx).
Where("is_private = ? AND answer != ''", false).
Order("updated_at DESC").
Limit(limit).
Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get hot questions")
}
return questions, nil
}
// QuestionStats 返回全站提问相关聚合统计。
// 使用 gorm 查询构造器生成 COUNT / MAX,兼容 MySQL 与 PostgreSQL。
// 最后一个回答时间取"已回答问题"的 updated_at 最大值——回答动作会更新 updated_at,
// 与 crontab 旧逻辑(answer 非空记录中 updated_at 最大)一致。
func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error) {
var total, answered int64
if err := db.WithContext(ctx).Model(&Question{}).Count(&total).Error; err != nil {
return nil, errors.Wrap(err, "count questions")
}
if err := db.WithContext(ctx).Model(&Question{}).Where("answer != ''").Count(&answered).Error; err != nil {
return nil, errors.Wrap(err, "count answered questions")
}
var t struct {
LastQ *time.Time `gorm:"column:last_q"`
LastA *time.Time `gorm:"column:last_a"`
}
if err := db.WithContext(ctx).Model(&Question{}).
Select("MAX(created_at) AS last_q, MAX(CASE WHEN answer != '' THEN updated_at ELSE NULL END) AS last_a").
Scan(&t).Error; err != nil {
return nil, errors.Wrap(err, "get last question/answer time")
}
return &QuestionStats{
TotalQuestions: total,
AnsweredQuestions: answered,
LastQuestionAt: t.LastQ,
LastAnswerAt: t.LastA,
}, nil
}
+467
View File
@@ -0,0 +1,467 @@
// Copyright 2023 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
func TestQuestions(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
questionsStore := NewQuestionsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *questions)
}{
{"Create", testQuestionsCreate},
{"GetByID", testQuestionsGetByID},
{"GetByUserID", testQuestionsGetByUserID},
{"GetByAskUserID", testQuestionsGetByAskUserID},
{"AnswerByID", testQuestionsAnswerByID},
{"DeleteByID", testQuestionsDeleteByID},
{"UpdateCensor", testQuestionsUpdateCensor},
{"Count", testQuestionsCount},
{"SetPrivate", testQuestionsSetPrivate},
{"SetPublic", testQuestionsSetPublic},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("questions"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, questionsStore.(*questions))
})
}
}
func testQuestionsCreate(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
got, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Hello, world!",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
got.Token = ""
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &Question{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Hello, world!",
ReceiveReplyEmail: "[email protected]",
}
require.Equal(t, got, want)
})
}
func testQuestionsGetByID(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Hello, world!",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
got.Token = ""
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &Question{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Hello, world!",
ReceiveReplyEmail: "[email protected]",
}
require.Equal(t, got, want)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsGetByUserID(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 3",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
t.Run("all", func(t *testing.T) {
got, err := db.GetByUserID(ctx, 1, GetQuestionsByUserIDOptions{})
require.Nil(t, err)
for _, g := range got {
g.Token = ""
g.CreatedAt = time.Time{}
g.UpdatedAt = time.Time{}
}
want := []*Question{
{
Model: dbutil.Model{
ID: 3,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 3",
ReceiveReplyEmail: "[email protected]",
},
{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
},
}
require.Equal(t, want, got)
})
t.Run("cursor", func(t *testing.T) {
got, err := db.GetByUserID(ctx, 1, GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
Value: 3,
PageSize: 10,
},
})
require.Nil(t, err)
for _, g := range got {
g.Token = ""
g.CreatedAt = time.Time{}
g.UpdatedAt = time.Time{}
}
want := []*Question{
{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
},
}
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsGetByAskUserID(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 2,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 2,
})
require.Nil(t, err)
t.Run("all", func(t *testing.T) {
got, err := db.GetByAskUserID(ctx, 1, GetQuestionsByAskUserIDOptions{})
require.Nil(t, err)
for _, g := range got {
g.Token = ""
g.CreatedAt = time.Time{}
g.UpdatedAt = time.Time{}
}
want := []*Question{
{
Model: dbutil.Model{
ID: 1,
},
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
},
}
require.Equal(t, want, got)
})
t.Run("cursor", func(t *testing.T) {
got, err := db.GetByAskUserID(ctx, 2, GetQuestionsByAskUserIDOptions{
Cursor: &dbutil.Cursor{
Value: 3,
PageSize: 10,
},
})
require.Nil(t, err)
for _, g := range got {
g.Token = ""
g.CreatedAt = time.Time{}
g.UpdatedAt = time.Time{}
}
want := []*Question{
{
Model: dbutil.Model{
ID: 2,
},
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 2,
},
}
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsAnswerByID(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
err = db.AnswerByID(ctx, 1, "Answer - 1")
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Answer - 1", got.Answer)
})
t.Run("not found", func(t *testing.T) {
err := db.AnswerByID(ctx, 404, "Answer - 1")
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsDeleteByID(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
err = db.DeleteByID(ctx, 1)
require.Nil(t, err)
_, err = db.GetByID(ctx, 1)
require.Equal(t, ErrQuestionNotExist, err)
})
t.Run("not found", func(t *testing.T) {
err := db.DeleteByID(ctx, 404)
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsUpdateCensor(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
err = db.DeleteByID(ctx, 1)
require.Nil(t, err)
_, err = db.GetByID(ctx, 1)
require.Equal(t, ErrQuestionNotExist, err)
})
t.Run("not found", func(t *testing.T) {
err := db.UpdateCensor(ctx, 404, UpdateQuestionCensorOptions{})
require.NotNil(t, err)
})
}
func testQuestionsCount(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 2,
Content: "Content - 2",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 3",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 0,
})
require.Nil(t, err)
err = db.AnswerByID(ctx, 3, "Answer - 3")
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.Count(ctx, 1, GetQuestionsCountOptions{})
require.Nil(t, err)
want := int64(2)
require.Equal(t, want, got)
})
t.Run("filter", func(t *testing.T) {
got, err := db.Count(ctx, 1, GetQuestionsCountOptions{FilterAnswered: true})
require.Nil(t, err)
want := int64(1)
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
got, err := db.Count(ctx, 404, GetQuestionsCountOptions{})
require.Nil(t, err)
want := int64(0)
require.Equal(t, want, got)
})
}
func testQuestionsSetPrivate(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err = db.SetPrivate(ctx, 1)
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.True(t, got.IsPrivate)
})
}
func testQuestionsSetPublic(t *testing.T, ctx context.Context, db *questions) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
ReceiveReplyEmail: "[email protected]",
AskerUserID: 1,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err = db.SetPublic(ctx, 1)
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.False(t, got.IsPrivate)
})
}
+190
View File
@@ -0,0 +1,190 @@
// Copyright 2023 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"github.com/pkg/errors"
"github.com/samber/lo"
"gorm.io/gorm"
)
var UploadImages UploadImagesStore
var _ UploadImagesStore = (*uploadImages)(nil)
type UploadImagesStore interface {
Create(ctx context.Context, opts CreateUploadImageOptions) (*UploadImage, error)
GetByQuestionID(ctx context.Context, questionID uint) ([]*UploadImage, error)
// GetByQuestionIDs 批量获取多个提问的图片,按 question_id 分组返回。
// 用于账号导出等场景,将 N 次单题查询合并为少量 IN 查询,避免 N+1。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error)
GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error)
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
func NewUploadImagesStore(db *gorm.DB) UploadImagesStore {
return &uploadImages{db}
}
type uploadImages struct {
*gorm.DB
}
type UploadImage struct {
gorm.Model
UploaderUserID uint
Name string
FileSize int64
Md5 string
Key string
}
type UploadImageQuestionType string
const (
UploadImageQuestionTypeAsk UploadImageQuestionType = "ask"
UploadImageQuestionTypeAnswer UploadImageQuestionType = "answer"
)
type UploadImageQuestion struct {
Type UploadImageQuestionType
UploadImageID uint `gorm:"primaryKey"`
QuestionID uint `gorm:"primaryKey"`
}
type CreateUploadImageOptions struct {
Type UploadImageQuestionType
QuestionID uint
UploaderUserID uint
Name string
FileSize int64
Md5 string
Key string
IsDeletingPrevious bool
}
func (db *uploadImages) Create(ctx context.Context, opts CreateUploadImageOptions) (*UploadImage, error) {
image := &UploadImage{
UploaderUserID: opts.UploaderUserID,
Name: opts.Name,
FileSize: opts.FileSize,
Md5: opts.Md5,
Key: opts.Key,
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(image).Error; err != nil {
return errors.Wrap(err, "create image")
}
if opts.IsDeletingPrevious {
if err := tx.WithContext(ctx).Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete previous image question link")
}
}
if err := tx.WithContext(ctx).Create(&UploadImageQuestion{
Type: opts.Type,
UploadImageID: image.ID,
QuestionID: opts.QuestionID,
}).Error; err != nil {
return errors.Wrap(err, "create image question link")
}
return nil
}); err != nil {
return nil, err
}
return image, nil
}
func (db *uploadImages) getBy(ctx context.Context, where string, args ...interface{}) ([]*UploadImage, error) {
var uploadImageQuestions []*UploadImageQuestion
if err := db.WithContext(ctx).Model(&UploadImageQuestion{}).Where(where, args...).Find(&uploadImageQuestions).Error; err != nil {
return nil, errors.Wrap(err, "get image questions")
}
uploadImageIDs := lo.Map(uploadImageQuestions, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
var uploadImages []*UploadImage
if err := db.WithContext(ctx).Model(&UploadImage{}).Where("id IN ?", uploadImageIDs).Order("id ASC").Find(&uploadImages).Error; err != nil {
return nil, errors.Wrap(err, "get images")
}
return uploadImages, nil
}
func (db *uploadImages) GetByQuestionID(ctx context.Context, questionID uint) ([]*UploadImage, error) {
return db.getBy(ctx, "question_id = ?", questionID)
}
// GetByQuestionIDs 批量获取多个提问的图片,按 question_id 分组返回(解决导出 N+1)。
// 实现:先按 IN 批量取关联行,再按 IN 批量取图片主体,最后在内存中按 question_id 归组,
// 将每个提问原本的 2 次查询降为整体 2 次查询。
func (db *uploadImages) GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error) {
result := make(map[uint][]*UploadImage)
if len(questionIDs) == 0 {
return result, nil
}
var links []*UploadImageQuestion
if err := db.WithContext(ctx).Model(&UploadImageQuestion{}).Where("question_id IN ?", questionIDs).Find(&links).Error; err != nil {
return nil, errors.Wrap(err, "get image questions batch")
}
if len(links) == 0 {
return result, nil
}
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
var images []*UploadImage
if err := db.WithContext(ctx).Model(&UploadImage{}).Where("id IN ?", imageIDs).Order("id ASC").Find(&images).Error; err != nil {
return nil, errors.Wrap(err, "get images batch")
}
imageByID := lo.SliceToMap(images, func(img *UploadImage) (uint, *UploadImage) {
return img.ID, img
})
for _, link := range links {
if img, ok := imageByID[link.UploadImageID]; ok {
result[link.QuestionID] = append(result[link.QuestionID], img)
}
}
return result, nil
}
func (db *uploadImages) GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error) {
return db.getBy(ctx, "type = ? AND question_id = ?", typ, questionID)
}
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
// 注意:question_id 列在关联表 upload_image_questions 上,不在 upload_images 表上,
// 需先查关联行拿到 imageID 列表,再软删图片主体并删除关联行(同一事务内完成)。
// 注:仅清理数据库记录,对象存储中的实际文件不在此处删除(避免误删复用资源)。
func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint) error {
return db.Transaction(func(tx *gorm.DB) error {
var links []*UploadImageQuestion
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Find(&links).Error; err != nil {
return errors.Wrap(err, "get image questions for delete")
}
if len(links) == 0 {
return nil
}
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete upload images by question id")
}
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete image question links")
}
return nil
})
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2023 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestUploadImages(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
uploadImagesStore := NewUploadImagesStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *uploadImages)
}{
{"Create", testUploadImagesCreate},
{"GetByQuestionID", testUploadImagesGetByQuestionID},
{"GetByTypeQuestionID", testUploadImagesGetByTypeQuestionID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("upload_images"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, uploadImagesStore.(*uploadImages))
})
}
}
func testUploadImagesCreate(t *testing.T, ctx context.Context, db *uploadImages) {
got, err := db.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAsk,
QuestionID: 1,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
IsDeletingPrevious: false,
})
require.Nil(t, err)
want := &UploadImage{
Model: got.Model,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
}
require.Equal(t, want, got)
}
func testUploadImagesGetByQuestionID(t *testing.T, ctx context.Context, db *uploadImages) {
_, err := db.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAsk,
QuestionID: 1,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
IsDeletingPrevious: false,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByQuestionID(ctx, 1)
require.Nil(t, err)
want := []*UploadImage{{
Model: got[0].Model,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
}}
require.Equal(t, got, want)
})
t.Run("not found", func(t *testing.T) {
got, err := db.GetByQuestionID(ctx, 2)
require.Nil(t, err)
require.Equal(t, 0, len(got))
})
}
func testUploadImagesGetByTypeQuestionID(t *testing.T, ctx context.Context, db *uploadImages) {
_, err := db.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAsk,
QuestionID: 1,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
IsDeletingPrevious: false,
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByTypeQuestionID(ctx, UploadImageQuestionTypeAsk, 1)
require.Nil(t, err)
want := []*UploadImage{{
Model: got[0].Model,
UploaderUserID: 1,
Name: "test.png",
FileSize: 12345,
Md5: "d41d8cd98f00b204e9800998ecf8427e",
Key: "uploads/test.png",
}}
require.Equal(t, got, want)
})
t.Run("not found", func(t *testing.T) {
got, err := db.GetByTypeQuestionID(ctx, UploadImageQuestionTypeAsk, 2)
require.Nil(t, err)
require.Equal(t, 0, len(got))
})
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import "time"
// UserBlock 记录用户拉黑关系(UserID 拉黑了 BlockedID)。
// 使用独立关联表而非在 users 上存 JSON,以获得:
// - (user_id, blocked_id) 唯一索引保证幂等,并发拉黑不会互相覆盖;
// - 判断是否拉黑走索引查询,无需反序列化整串 JSON。
//
// 注意:本表采用物理删除(取消拉黑即删行),不使用 gorm.DeletedAt 软删除,
// 否则「拉黑 → 取消 → 再拉黑」会因软删记录仍占用唯一索引而插入冲突。
type UserBlock struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null;uniqueIndex:idx_user_block_unique"`
BlockedID uint `gorm:"not null;uniqueIndex:idx_user_block_unique"`
CreatedAt time.Time `json:"-"`
}
+921
View File
@@ -0,0 +1,921 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Modify by tamakyi
package db
import (
"context"
"sort"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/rs/xid"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/conf"
)
var Users UsersStore
var _ UsersStore = (*users)(nil)
type UsersStore interface {
Create(ctx context.Context, opts CreateUserOptions) error
GetByID(ctx context.Context, id uint) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
GetByDomain(ctx context.Context, domain string) (*User, error)
GetByIDs(ctx context.Context, ids []uint) ([]*User, error)
Update(ctx context.Context, id uint, opts UpdateUserOptions) error
// 防骚扰设置拆分为两个独立更新:仅允许注册用户(harassment_setting 列)与
// 全局屏蔽词(block_words 列)。两者设置入口现已分处不同面板(屏蔽词在
// 提问/私聊/投票三个面板共用一份),必须分开写,避免单侧保存误清空另一侧。
UpdateHarassmentType(ctx context.Context, id uint, typ HarassmentSettingType) error
UpdateBlockWords(ctx context.Context, id uint, words string) error
Authenticate(ctx context.Context, email, password string) (*User, error)
ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error
UpdatePassword(ctx context.Context, id uint, newPassword string) error
Deactivate(ctx context.Context, id uint) error
GetShowInHome(ctx context.Context, page, pageSize int, sort string) ([]*User, int64, error)
GetByTag(ctx context.Context, tag string, page, pageSize int, sort string) ([]*User, int64, error)
UpdateTags(ctx context.Context, id uint, opts UpdateTagsOptions) error
GetRandom(ctx context.Context, limit int) ([]*User, error)
GetRandomFresh(ctx context.Context, limit int) ([]*User, error)
GetHotTags(ctx context.Context, limit int) ([]string, error)
// 全站统计
CountAll(ctx context.Context) (int64, error)
UpdatePagePassword(ctx context.Context, id uint, password string) error
UpdateAskPassword(ctx context.Context, id uint, password string) error
// 私聊设置
UpdateChatSettings(ctx context.Context, id uint, allowChat bool, maxMessages int) error
// UpdateAllowChat 仅同步私聊开关(功能管理页统一开关时调用,不影响条数上限)
UpdateAllowChat(ctx context.Context, id uint, allowChat bool) error
// 拉黑相关
BlockUser(ctx context.Context, userID, blockedUserID uint) error
UnblockUser(ctx context.Context, userID, blockedUserID uint) error
IsUserBlocked(ctx context.Context, userID, targetID uint) (bool, error)
BlockedByUser(ctx context.Context, userID uint, targetIDs []uint) (map[uint]bool, error)
BlockedByAnyOf(ctx context.Context, blockerIDs []uint, targetID uint) (map[uint]bool, error)
// 每日限制相关
CountTodayMessagesFromUser(ctx context.Context, ownerID, askerID uint) (int64, error)
UpdateDailyMessageLimit(ctx context.Context, id uint, limit int) error
BanUserAndCleanup(ctx context.Context, userID uint) error
}
func NewUsersStore(db *gorm.DB) UsersStore {
return &users{db}
}
type users struct {
*gorm.DB
}
type User struct {
gorm.Model `json:"-"`
UID string `json:"-"`
Name string `json:"name"`
Password string `json:"-"`
Email string `json:"email" gorm:"uniqueIndex:idx_users_email_unique"`
Avatar string `json:"avatar"`
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
Background string `json:"background"`
Intro string `json:"intro"`
Introcolor string `json:"introcolor"`
Usernamecolor string `json:"usernamecolor"`
Qrcodebackcolor string `json:"qrcodebackcolor"`
Qrcodecolor string `json:"qrcodecolor"`
BackgroundImage string `json:"backgroundimage"`
Dotscale string `json:"dotscale"`
Acfunlink string `json:"acfunlink"`
Bililink string `json:"bililink"`
Weibolink string `json:"weibolink"`
Bloglink string `json:"bloglink"`
Xiaohongshulink string `json:"xiaohongshulink"`
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
Backgroundimagealpha string `json:"Backgroundimagealpha"`
Notify NotifyType `json:"notify"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting"`
BlockWords string `json:"-"`
Tags string `json:"tags" gorm:"type:text"`
ShowInHome bool `json:"show_in_home" gorm:"default:false;index"`
UseCustomQRColor bool `json:"use_custom_qr_color" gorm:"default:false"`
PagePassword string `json:"-" gorm:"column:page_password;type:varchar(255);default:''"`
AskPassword string `json:"-" gorm:"column:ask_password;type:varchar(255);default:''"`
// 私聊相关
AllowChat bool `json:"allow_chat" gorm:"default:false"`
// 每个私聊会话的消息条数上限(用户自设)。default:20 仅是 DB 层兜底;
// 应用层创建用户时按 conf.Limits.ChatMaxMessages([limits] 段)显式写入默认值,
// 新用户默认即系统硬顶,而非固定 20。
ChatMaxMessages int `json:"chat_max_messages" gorm:"default:20"`
// 每日私信条数上限
DailyMessageLimit int `gorm:"default:0" json:"daily_message_limit"`
// 界面语言(提问箱页面与后台页面展示语言,取值见 internal/i18n)
Language string `gorm:"type:varchar(16);default:''" json:"language"`
// PasswordVersion 用于在密码变更后使旧会话失效(会话中保存登录时的版本号)。
PasswordVersion uint `gorm:"default:0" json:"-"`
}
func (u *User) BeforeCreate(_ *gorm.DB) error {
u.UID = xid.New().String()
return nil
}
type NotifyType string
const (
NotifyTypeEmail NotifyType = "email"
NotifyTypeNone NotifyType = "none"
)
type HarassmentSettingType string
const (
HarassmentSettingNone HarassmentSettingType = "none"
HarassmentSettingTypeRegisterOnly HarassmentSettingType = "register_only"
)
func (u *User) EncodePassword() {
u.Password = encodePasswordBcrypt(u.Password)
}
func (u *User) Authenticate(password string) bool {
ok, _ := authenticatePassword(u.Password, password)
return ok
}
func (u *User) SetPagePassword(password string) {
if password == "" {
u.PagePassword = ""
} else {
u.PagePassword = encodePasswordBcrypt(password)
}
}
func (u *User) CheckPagePassword(password string) bool {
ok, _ := authenticatePassword(u.PagePassword, password)
return ok
}
func (u *User) SetAskPassword(password string) {
if password == "" {
u.AskPassword = ""
} else {
u.AskPassword = encodePasswordBcrypt(password)
}
}
func (u *User) CheckAskPassword(password string) bool {
ok, _ := authenticatePassword(u.AskPassword, password)
return ok
}
type CreateUserOptions struct {
Name string
Password string
Email string
Avatar string
Domain string
Background string
Intro string
Dotscale string
Qrcodebackcolor string
Qrcodecolor string
Qrcodepdpcolor string
BackgroundImage string
Backgroundimagealpha string
Usernamecolor string
Introcolor string
Acfunlink string
Bililink string
Weibolink string
Xiaohongshulink string
Bloglink string
Tags string
ShowInHome bool
Language string // 界面语言(i18n 规范代码,如 zh-CN / zh-TW / en / ja)
}
var (
ErrUserNotExists = errors.New("账号不存在")
ErrBadCredential = errors.New("邮箱或密码错误")
ErrDuplicateEmail = errors.New("这个邮箱已经注册过账号了!")
ErrDuplicateDomain = errors.New("个性域名重复了,换一个吧~")
)
func (db *users) Create(ctx context.Context, opts CreateUserOptions) error {
if err := db.validate(ctx, opts); err != nil {
return err
}
newUser := &User{
Name: opts.Name,
Password: opts.Password,
Email: opts.Email,
Avatar: opts.Avatar,
Domain: opts.Domain,
Background: opts.Background,
Intro: opts.Intro,
Qrcodebackcolor: opts.Qrcodebackcolor,
Qrcodecolor: opts.Qrcodecolor,
Dotscale: opts.Dotscale,
BackgroundImage: opts.BackgroundImage,
Backgroundimagealpha: opts.Backgroundimagealpha,
Qrcodepdpcolor: opts.Qrcodepdpcolor,
Notify: NotifyTypeEmail,
Usernamecolor: opts.Usernamecolor,
Introcolor: opts.Introcolor,
Acfunlink: opts.Acfunlink,
Bililink: opts.Bililink,
Weibolink: opts.Weibolink,
Xiaohongshulink: opts.Xiaohongshulink,
Bloglink: opts.Bloglink,
Tags: opts.Tags,
ShowInHome: opts.ShowInHome,
Language: opts.Language,
// 私聊会话条数上限:新用户默认取系统硬顶(conf.Limits.ChatMaxMessages,
// [limits] 配置段),用户可在 1..硬顶 之间下调。不要回退到 gorm default:20。
ChatMaxMessages: conf.Limits.ChatMaxMessages,
}
newUser.EncodePassword()
if err := db.WithContext(ctx).Create(newUser).Error; err != nil {
// 并发同值双过(TOCTOU):唯一索引兜底会返回重复键错误。
// 此时重新执行校验,给出与主动校验一致的友好错误,而非暴露底层数据库错误。
if verr := db.validate(ctx, opts); verr != nil {
return verr
}
return errors.Wrap(err, "create user")
}
return nil
}
func (db *users) getBy(ctx context.Context, where string, args ...interface{}) (*User, error) {
var user User
if err := db.WithContext(ctx).Where(where, args...).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotExists
}
return nil, errors.Wrap(err, "get user")
}
return &user, nil
}
func (db *users) GetByID(ctx context.Context, id uint) (*User, error) {
return db.getBy(ctx, "id = ?", id)
}
func (db *users) GetByEmail(ctx context.Context, email string) (*User, error) {
return db.getBy(ctx, "email = ?", email)
}
func (db *users) GetByDomain(ctx context.Context, domain string) (*User, error) {
return db.getBy(ctx, "domain = ?", domain)
}
// GetByIDs 批量按 ID 查询用户,返回顺序与数据库返回一致(不保证与入参顺序一致),
// 用于批量关联查询,避免循环 GetByID 造成 N+1。
func (db *users) GetByIDs(ctx context.Context, ids []uint) ([]*User, error) {
if len(ids) == 0 {
return []*User{}, nil
}
var users []*User
if err := db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, errors.Wrap(err, "get users by ids")
}
return users, nil
}
type UpdateUserOptions struct {
Name string
Avatar string
Background string
Intro string
Qrcodebackcolor string
Qrcodecolor string
Dotscale string
BackgroundImage string
Backgroundimagealpha string
Qrcodepdpcolor string
Usernamecolor string
Introcolor string
Acfunlink string
Bililink string
Weibolink string
Xiaohongshulink string
Bloglink string
UseCustomQRColor bool
Notify NotifyType
Language string
}
func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) error {
_, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
switch opts.Notify {
case NotifyTypeEmail, NotifyTypeNone:
default:
return errors.Errorf("unexpected notify type: %q", opts.Notify)
}
updates := map[string]interface{}{
"Name": opts.Name,
"Avatar": opts.Avatar,
"Background": opts.Background,
"Intro": opts.Intro,
"Qrcodebackcolor": opts.Qrcodebackcolor,
"Qrcodecolor": opts.Qrcodecolor,
"Dotscale": opts.Dotscale,
"BackgroundImage": opts.BackgroundImage,
"Backgroundimagealpha": opts.Backgroundimagealpha,
"Qrcodepdpcolor": opts.Qrcodepdpcolor,
"Usernamecolor": opts.Usernamecolor,
"Introcolor": opts.Introcolor,
"Acfunlink": opts.Acfunlink,
"Bililink": opts.Bililink,
"Weibolink": opts.Weibolink,
"Xiaohongshulink": opts.Xiaohongshulink,
"Bloglink": opts.Bloglink,
"UseCustomQRColor": opts.UseCustomQRColor,
"Notify": opts.Notify,
"Language": opts.Language,
}
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
}
// UpdateHarassmentType 仅更新骚扰防护类型(none / register_only)。
// 只写 harassment_setting 列,不动 block_words,避免与全局屏蔽词的独立保存互相覆盖。
func (db *users) UpdateHarassmentType(ctx context.Context, id uint, typ HarassmentSettingType) error {
switch typ {
case HarassmentSettingNone, HarassmentSettingTypeRegisterOnly:
default:
return errors.Errorf("unexpected harassment setting type: %q", typ)
}
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"HarassmentSetting": typ,
}).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
}
// UpdateBlockWords 仅更新全局屏蔽词表(逗号分隔,保存前已去重/去空白/限额)。
// 只写 block_words 列,不动 harassment_setting。
func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) error {
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"BlockWords": words,
}).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
}
func (db *users) Authenticate(ctx context.Context, email, password string) (*User, error) {
u, err := db.GetByEmail(ctx, email)
if err != nil {
return nil, ErrBadCredential
}
ok, newHash := authenticatePassword(u.Password, password)
if !ok {
return nil, ErrBadCredential
}
// 透明升级:如果密码是旧的 HMAC-SHA1 格式,自动升级为 bcrypt。
if newHash != "" {
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Update("password", newHash).Error; err != nil {
logrus.WithContext(ctx).WithError(err).Warn("Failed to auto-upgrade password to bcrypt")
} else {
u.Password = newHash
}
}
return u, nil
}
func (db *users) ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error {
u, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
ok, upgradeHash := authenticatePassword(u.Password, oldPassword)
if !ok {
return ErrBadCredential
}
// 如果旧密码触发升级,先升级
if upgradeHash != "" {
u.Password = upgradeHash
}
u.Password = newPassword
u.EncodePassword()
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Updates(map[string]interface{}{
"password": u.Password,
"password_version": gorm.Expr("password_version + 1"),
}).Error; err != nil {
return errors.Wrap(err, "change password")
}
return nil
}
func (db *users) UpdatePassword(ctx context.Context, id uint, newPassword string) error {
u, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
u.Password = newPassword
u.EncodePassword()
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", u.ID).Updates(map[string]interface{}{
"password": u.Password,
"password_version": gorm.Expr("password_version + 1"),
}).Error; err != nil {
return errors.Wrap(err, "change password")
}
return nil
}
func (db *users) Deactivate(ctx context.Context, id uint) error {
u, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
if err := db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID).Error; err != nil {
return errors.Wrap(err, "delete user")
}
return nil
}
func (db *users) validate(ctx context.Context, opts CreateUserOptions) error {
if err := db.WithContext(ctx).Model(&User{}).Where("email = ?", opts.Email).First(&User{}).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return errors.Wrap(err, "validate email")
}
} else {
return ErrDuplicateEmail
}
if err := db.WithContext(ctx).Model(&User{}).Where("domain = ?", opts.Domain).First(&User{}).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return errors.Wrap(err, "validate name")
}
} else {
return ErrDuplicateDomain
}
return nil
}
type UpdateTagsOptions struct {
Tags string
ShowInHome bool
}
func (db *users) UpdateTags(ctx context.Context, id uint, opts UpdateTagsOptions) error {
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"Tags": opts.Tags,
"ShowInHome": opts.ShowInHome,
}).Error; err != nil {
return errors.Wrap(err, "update user tags")
}
return nil
}
func (db *users) GetShowInHome(ctx context.Context, page, pageSize int, sort string) ([]*User, int64, error) {
var users []*User
var count int64
if err := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ?", true).Count(&count).Error; err != nil {
return nil, 0, errors.Wrap(err, "count show in home users")
}
query := db.WithContext(ctx).Where("show_in_home = ?", true)
switch sort {
case "oldest":
query = query.Order("created_at asc")
default:
query = query.Order("created_at desc")
}
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, errors.Wrap(err, "get show in home users")
}
return users, count, nil
}
func (db *users) GetByTag(ctx context.Context, tag string, page, pageSize int, sort string) ([]*User, int64, error) {
var users []*User
var count int64
// tags 以逗号分隔存储;按数据库类型使用不同的标签匹配语法:
// PostgreSQL: string_to_array + ANY;MySQL/SQLite: FIND_IN_SET
var tagCond string
if conf.Database.Type == "postgres" {
tagCond = "? = ANY(string_to_array(tags, ','))"
} else {
tagCond = "FIND_IN_SET(?, tags)"
}
baseQuery := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ? AND tags IS NOT NULL AND "+tagCond, true, tag)
if err := baseQuery.Count(&count).Error; err != nil {
return nil, 0, errors.Wrap(err, "count users by tag")
}
query := baseQuery
switch sort {
case "oldest":
query = query.Order("created_at asc")
default:
query = query.Order("created_at desc")
}
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, errors.Wrap(err, "get users by tag")
}
return users, count, nil
}
// 随机推荐列表缓存:ORDER BY RANDOM() 在大表上会全表扫描+排序,
// explore 页面高频调用,用 30 秒短缓存吸收并发,成本是列表最多滞后 30 秒。
// 缓存池大小 24(4 倍展示数),初始页面取前 6 个,
// "换一批"用 GetRandomFresh 绕过缓存,确保每次返回不同结果。
var (
randomUsersCacheMu sync.Mutex
randomUsersCache []*User
randomUsersCacheTime time.Time
)
const (
randomUsersCacheTTL = 30 * time.Second
randomCachePoolSize = 24
)
// GetRandom 获取随机用户(带缓存)。缓存池大小 24,返回前 limit 个。
// 返回的是切片副本,调用方修改返回值不会污染缓存。
func (db *users) GetRandom(ctx context.Context, limit int) ([]*User, error) {
randomUsersCacheMu.Lock()
if randomUsersCache != nil && time.Since(randomUsersCacheTime) < randomUsersCacheTTL {
cached := copyUsers(randomUsersCache)
randomUsersCacheMu.Unlock()
if limit < len(cached) {
cached = cached[:limit]
}
return cached, nil
}
randomUsersCacheMu.Unlock()
// 缓存未命中,查询一个较大的池以覆盖后续请求
users, err := db.getRandomUsers(ctx, randomCachePoolSize)
if err != nil {
return nil, err
}
// 更新缓存(缓存持有的是 DB 查询结果,与返回给调用方的副本分离)
randomUsersCacheMu.Lock()
randomUsersCache = users
randomUsersCacheTime = time.Now()
randomUsersCacheMu.Unlock()
copied := copyUsers(users)
if limit < len(copied) {
copied = copied[:limit]
}
return copied, nil
}
// copyUsers 深拷贝 User 切片,避免调用方修改(如给 User 字段赋值)污染缓存。
func copyUsers(src []*User) []*User {
dst := make([]*User, len(src))
for i, u := range src {
if u == nil {
continue
}
cp := *u
dst[i] = &cp
}
return dst
}
// GetRandomFresh 获取随机用户(绕过缓存),用于"换一批"按钮。
func (db *users) GetRandomFresh(ctx context.Context, limit int) ([]*User, error) {
return db.getRandomUsers(ctx, limit)
}
func (db *users) getRandomUsers(ctx context.Context, limit int) ([]*User, error) {
var users []*User
randomFunc := "RAND()" // MySQL / SQLite 默认
if conf.Database.Type == "postgres" {
randomFunc = "RANDOM()"
}
if err := db.WithContext(ctx).
Where("show_in_home = ?", true).
Order(randomFunc).
Limit(limit).
Find(&users).Error; err != nil {
return nil, errors.Wrap(err, "get random users")
}
return users, nil
}
// 热标签缓存:GetHotTags 每次 explore 都全表 Pluck tags 再内存聚合,
// 标签变化不频繁,用 60 秒进程内缓存吸收重复计算,避免每次翻页都触发全表扫描。
var (
hotTagsCacheMu sync.Mutex
hotTagsCache []string
hotTagsCacheTime time.Time
)
const hotTagsCacheTTL = 60 * time.Second
// CountAll 返回全站注册用户总数(首页统计用,不按单个用户过滤)。
func (db *users) CountAll(ctx context.Context) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&User{}).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count all users")
}
return count, nil
}
// GetHotTags 统计所有展示在首页的用户标签,返回出现次数最多的前 limit 个标签。
// 标签存于 users.tags(逗号分隔),此处取出后在内存中聚合统计,结果带短缓存。
func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) {
if limit <= 0 {
limit = 10
}
hotTagsCacheMu.Lock()
if hotTagsCache != nil && time.Since(hotTagsCacheTime) < hotTagsCacheTTL {
cached := append([]string(nil), hotTagsCache...)
hotTagsCacheMu.Unlock()
if limit < len(cached) {
cached = cached[:limit]
}
return cached, nil
}
hotTagsCacheMu.Unlock()
var tagsList []string
if err := db.WithContext(ctx).
Model(&User{}).
Where("show_in_home = ? AND tags IS NOT NULL AND tags != ''", true).
Pluck("tags", &tagsList).Error; err != nil {
return nil, errors.Wrap(err, "get user tags for hot tags")
}
counts := make(map[string]int)
for _, tags := range tagsList {
for _, tag := range strings.Split(tags, ",") {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
counts[tag]++
}
}
// 按出现次数降序
hot := make([]string, 0, len(counts))
for tag := range counts {
hot = append(hot, tag)
}
sort.Slice(hot, func(i, j int) bool {
if counts[hot[i]] == counts[hot[j]] {
return hot[i] < hot[j]
}
return counts[hot[i]] > counts[hot[j]]
})
// 缓存完整排序结果(不按 limit 截断),供不同 limit 的调用方复用。
hotTagsCacheMu.Lock()
hotTagsCache = append([]string(nil), hot...)
hotTagsCacheTime = time.Now()
hotTagsCacheMu.Unlock()
if len(hot) > limit {
hot = hot[:limit]
}
return hot, nil
}
func (db *users) UpdatePagePassword(ctx context.Context, id uint, password string) error {
user, err := db.GetByID(ctx, id)
if err != nil {
return err
}
user.SetPagePassword(password)
return db.WithContext(ctx).Model(user).Update("page_password", user.PagePassword).Error
}
func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string) error {
user, err := db.GetByID(ctx, id)
if err != nil {
return err
}
user.SetAskPassword(password)
return db.WithContext(ctx).Model(user).Update("ask_password", user.AskPassword).Error
}
// 新增私聊设置
func (db *users) UpdateChatSettings(ctx context.Context, id uint, allowChat bool, maxMessages int) error {
return db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"allow_chat": allowChat,
"chat_max_messages": maxMessages,
}).Error
}
// UpdateAllowChat 仅更新 users.allow_chat,用于与 feature_controls 的私聊开关保持同步
// (users.allow_chat 仍被邮件/导出等既有逻辑读取,保留双向一致)。
func (db *users) UpdateAllowChat(ctx context.Context, id uint, allowChat bool) error {
return db.WithContext(ctx).Model(&User{}).Where("id = ?", id).
Update("allow_chat", allowChat).Error
}
// ---------- 拉黑与每日限制 ----------
// BlockUser 拉黑用户。使用 (user_id, blocked_id) 唯一索引 + INSERT ... ON CONFLICT DO NOTHING,
// 幂等且并发安全(重复/并发拉黑不会互相覆盖丢失)。
func (db *users) BlockUser(ctx context.Context, userID, blockedUserID uint) error {
if userID == blockedUserID {
return errors.New("cannot block self")
}
block := &UserBlock{UserID: userID, BlockedID: blockedUserID}
if err := db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
Create(block).Error; err != nil {
return errors.Wrap(err, "block user")
}
return nil
}
// UnblockUser 取消拉黑(物理删除关联行)。
func (db *users) UnblockUser(ctx context.Context, userID, blockedUserID uint) error {
if err := db.WithContext(ctx).
Where("user_id = ? AND blocked_id = ?", userID, blockedUserID).
Delete(&UserBlock{}).Error; err != nil {
return errors.Wrap(err, "unblock user")
}
return nil
}
// IsUserBlocked 检查 userID 是否拉黑了 targetID(走唯一索引查询)。
func (db *users) IsUserBlocked(ctx context.Context, userID, targetID uint) (bool, error) {
var count int64
if err := db.WithContext(ctx).Model(&UserBlock{}).
Where("user_id = ? AND blocked_id = ?", userID, targetID).
Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check block status")
}
return count > 0, nil
}
// BlockedByUser 批量判断:返回 targetIDs 中被 userID 拉黑的子集(map[targetID]bool)。
func (db *users) BlockedByUser(ctx context.Context, userID uint, targetIDs []uint) (map[uint]bool, error) {
result := make(map[uint]bool, len(targetIDs))
if len(targetIDs) == 0 {
return result, nil
}
var blocks []UserBlock
if err := db.WithContext(ctx).
Where("user_id = ? AND blocked_id IN ?", userID, targetIDs).
Find(&blocks).Error; err != nil {
return nil, errors.Wrap(err, "get blocked users")
}
for _, b := range blocks {
result[b.BlockedID] = true
}
return result, nil
}
// BlockedByAnyOf 批量判断:返回 blockerIDs 中拉黑了 targetID 的子集(map[blockerID]bool)。
func (db *users) BlockedByAnyOf(ctx context.Context, blockerIDs []uint, targetID uint) (map[uint]bool, error) {
result := make(map[uint]bool, len(blockerIDs))
if len(blockerIDs) == 0 {
return result, nil
}
var blocks []UserBlock
if err := db.WithContext(ctx).
Where("user_id IN ? AND blocked_id = ?", blockerIDs, targetID).
Find(&blocks).Error; err != nil {
return nil, errors.Wrap(err, "get blockers")
}
for _, b := range blocks {
result[b.UserID] = true
}
return result, nil
}
// CountTodayMessagesFromUser 统计某提问者当天给某被提问者发送的私信总条数。
// 使用应用进程所在时区(time.Local)的"当日 00:00"作为窗口起点,
// 而非数据库服务器的 CURRENT_DATE,避免 DB 时区与应用时区不一致导致每日上限窗口偏移。
func (db *users) CountTodayMessagesFromUser(ctx context.Context, ownerID, askerID uint) (int64, error) {
now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var count int64
err := db.WithContext(ctx).Table("messages").
Joins("JOIN conversations ON conversations.id = messages.conversation_id").
Where("conversations.owner_id = ? AND messages.sender_id = ?", ownerID, askerID).
Where("messages.created_at >= ?", startOfDay).
Count(&count).Error
return count, err
}
// UpdateDailyMessageLimit 更新每日限制
func (db *users) UpdateDailyMessageLimit(ctx context.Context, id uint, limit int) error {
return db.WithContext(ctx).Model(&User{}).Where("id = ?", id).
Update("daily_message_limit", limit).Error
}
// BanUserAndCleanup 封禁用户并连带清理其全部数据(提问、追问、私聊、消息、图片关联),
// 最后物理删除用户记录。供管理 CLI 命令(tamabox ban)使用。
//
// 注意:该操作不可恢复,执行前请确认目标账号。
func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 追问:属于该用户提问下的追问
if err := tx.WithContext(ctx).
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups of banned user")
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
Delete(&Message{}).Error; err != nil {
return errors.Wrap(err, "delete messages of banned user")
}
// 消息图片
if err := tx.WithContext(ctx).
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of banned user")
}
// 私聊会话
if err := tx.WithContext(ctx).
Where("owner_id = ? OR asker_id = ?", userID, userID).
Delete(&Conversation{}).Error; err != nil {
return errors.Wrap(err, "delete conversations of banned user")
}
// 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留)
if err := tx.WithContext(ctx).
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete upload image links of banned user")
}
// 提问
if err := tx.WithContext(ctx).
Where("user_id = ?", userID).
Delete(&Question{}).Error; err != nil {
return errors.Wrap(err, "delete questions of banned user")
}
// 拉黑关系(该用户拉黑别人,或被别人拉黑)
if err := tx.WithContext(ctx).
Where("user_id = ? OR blocked_id = ?", userID, userID).
Delete(&UserBlock{}).Error; err != nil {
return errors.Wrap(err, "delete user blocks of banned user")
}
// 功能开关配置(提问 / 私聊 / 投票)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&FeatureControl{}).Error; err != nil {
return errors.Wrap(err, "delete feature controls of banned user")
}
// 物理删除用户
if err := tx.WithContext(ctx).Unscoped().Delete(&User{}, userID).Error; err != nil {
return errors.Wrap(err, "delete banned user")
}
return nil
})
}
+428
View File
@@ -0,0 +1,428 @@
// Copyright 2023 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestUsers(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
usersStore := NewUsersStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *users)
}{
{"Create", testUsersCreate},
{"GetByID", testUsersGetByID},
{"GetByEmail", testUsersGetByEmail},
{"GetByDomain", testUsersGetByDomain},
{"Update", testUsersUpdate},
{"UpdateHarassmentType", testUsersUpdateHarassmentType},
{"UpdateBlockWords", testUsersUpdateBlockWords},
{"Authenticate", testUsersAuthenticate},
{"ChangePassword", testUsersChangePassword},
{"UpdatePassword", testUsersUpdatePassword},
{"Deactivate", testUsersDeactivate},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("users"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, usersStore.(*users))
})
}
}
func testUsersCreate(t *testing.T, ctx context.Context, db *users) {
t.Run("normal", func(t *testing.T) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
})
t.Run("repeat email", func(t *testing.T) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99p1ant",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Equal(t, ErrDuplicateEmail, err)
})
t.Run("repeat domain", func(t *testing.T) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Equal(t, ErrDuplicateDomain, err)
})
}
func testUsersGetByID(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
want.EncodePassword()
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrUserNotExists, err)
})
}
func testUsersGetByEmail(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByEmail(ctx, "[email protected]")
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
want.EncodePassword()
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByEmail(ctx, "404")
require.Equal(t, ErrUserNotExists, err)
})
}
func testUsersGetByDomain(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByDomain(ctx, "e99")
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
want.EncodePassword()
require.Equal(t, want, got)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByDomain(ctx, "404")
require.Equal(t, ErrUserNotExists, err)
})
}
func testUsersUpdate(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.Update(ctx, 1, UpdateUserOptions{
Name: "e99",
Avatar: "new_avatar.png",
Background: "new_background.png",
Intro: "Be cool, but also be warm!!",
Notify: NotifyTypeNone,
})
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "e99",
Password: "super_secret",
Email: "[email protected]",
Avatar: "new_avatar.png",
Domain: "e99",
Background: "new_background.png",
Intro: "Be cool, but also be warm!!",
Notify: NotifyTypeNone,
}
want.EncodePassword()
require.Equal(t, want, got)
})
}
func testUsersUpdateHarassmentType(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.UpdateHarassmentType(ctx, 1, HarassmentSettingTypeRegisterOnly)
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, HarassmentSettingTypeRegisterOnly, got.HarassmentSetting)
// 只应更新类型,屏蔽词列不受影响
require.Equal(t, "", got.BlockWords)
})
t.Run("unexpected harassment setting", func(t *testing.T) {
err := db.UpdateHarassmentType(ctx, 1, HarassmentSettingType("not found"))
require.NotNil(t, err)
})
}
func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.UpdateBlockWords(ctx, 1, "广告,抽奖")
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "广告,抽奖", got.BlockWords)
// 只应更新屏蔽词,类型列不受影响
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting)
})
t.Run("empty clears words", func(t *testing.T) {
err := db.UpdateBlockWords(ctx, 1, "")
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "", got.BlockWords)
})
}
func testUsersAuthenticate(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
got, err := db.Authenticate(ctx, "[email protected]", "super_secret")
require.Nil(t, err)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
want := &User{
Model: gorm.Model{
ID: 1,
},
UID: got.UID,
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
Notify: NotifyTypeEmail,
}
want.EncodePassword()
require.Equal(t, want, got)
}
func testUsersChangePassword(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.ChangePassword(ctx, 1, "super_secret", "new_password")
require.Nil(t, err)
})
t.Run("wrong password", func(t *testing.T) {
err := db.ChangePassword(ctx, 1, "wrong_password", "new_password")
require.Equal(t, ErrBadCredential, err)
})
}
func testUsersUpdatePassword(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.UpdatePassword(ctx, 1, "new_password")
require.Nil(t, err)
})
}
func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "e99",
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
err := db.Deactivate(ctx, 1)
require.Nil(t, err)
})
t.Run("not found", func(t *testing.T) {
err := db.Deactivate(ctx, 404)
require.NotNil(t, err)
})
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var VoteFormFields VoteFormFieldsStore
// CustomFieldMarker 参与端"其他/自定义选项"的保留标记值(radio/select/checkbox 字段)。
// 选中该值时,后端从 field_{id}_custom 读取参与者填写的文本;预设选项不允许使用该保留值。
const CustomFieldMarker = "__tb_custom_option__"
// 表单字段类型(type=form 时使用)
const (
VoteFormFieldTypeText = "text" // 单行文本
VoteFormFieldTypeTextarea = "textarea" // 多行文本
VoteFormFieldTypeSelect = "select" // 下拉单选
VoteFormFieldTypeRadio = "radio" // 单选
VoteFormFieldTypeCheckbox = "checkbox" // 多选
VoteFormFieldTypeNumber = "number" // 数字
VoteFormFieldTypeRating = "rating" // 评分
VoteFormFieldTypeRank = "rank" // 排序(对预置选项排 1..N)
)
type VoteFormFieldsStore interface {
Create(ctx context.Context, opts CreateVoteFormFieldOptions) (*VoteFormField, error)
BulkCreate(ctx context.Context, voteID uint, fields []CreateVoteFormFieldOptions) ([]*VoteFormField, error)
ListByVoteID(ctx context.Context, voteID uint) ([]*VoteFormField, error)
ReplaceByVoteID(ctx context.Context, voteID uint, fields []CreateVoteFormFieldOptions) error
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
DeleteByVoteID(ctx context.Context, voteID uint) error
DeleteByID(ctx context.Context, id uint) error
}
func NewVoteFormFieldsStore(db *gorm.DB) VoteFormFieldsStore {
return &voteFormFields{db}
}
type voteFormFields struct {
*gorm.DB
}
// VoteFormField 表单字段定义表:结构化表单(type=form)的字段配置。
type VoteFormField struct {
dbutil.Model
VoteID uint `gorm:"index:idx_form_field_vote" json:"vote_id"`
FieldType string `gorm:"size:20" json:"field_type"`
Label string `gorm:"size:100" json:"label"`
Required bool `gorm:"default:false" json:"required"`
MaxLength int `gorm:"default:0" json:"max_length"` // 0 = 不限制(字数限制)
Options string `gorm:"type:text" json:"options"` // JSON 数组(select/radio/checkbox 用)
SortOrder int `gorm:"default:0" json:"sort_order"`
AllowCustom bool `gorm:"default:false" json:"allow_custom"` // 允许参与者补充自定义选项(radio/select/checkbox)
}
type CreateVoteFormFieldOptions struct {
VoteID uint
FieldType string
Label string
Required bool
MaxLength int
Options string
SortOrder int
AllowCustom bool
}
func (db *voteFormFields) Create(ctx context.Context, opts CreateVoteFormFieldOptions) (*VoteFormField, error) {
field := &VoteFormField{
VoteID: opts.VoteID,
FieldType: opts.FieldType,
Label: opts.Label,
Required: opts.Required,
MaxLength: opts.MaxLength,
Options: opts.Options,
SortOrder: opts.SortOrder,
AllowCustom: opts.AllowCustom,
}
if err := db.WithContext(ctx).Create(field).Error; err != nil {
return nil, errors.Wrap(err, "create vote form field")
}
return field, nil
}
// BulkCreate 批量创建表单字段(箱主一次提交全部字段配置)。
func (db *voteFormFields) BulkCreate(ctx context.Context, voteID uint, fields []CreateVoteFormFieldOptions) ([]*VoteFormField, error) {
formFields := make([]*VoteFormField, 0, len(fields))
for _, f := range fields {
formFields = append(formFields, &VoteFormField{
VoteID: voteID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
if len(formFields) == 0 {
return formFields, nil
}
if err := db.WithContext(ctx).Create(&formFields).Error; err != nil {
return nil, errors.Wrap(err, "bulk create vote form fields")
}
return formFields, nil
}
// ListByVoteID 获取某投票的全部表单字段(按 SortOrder 升序)。
func (db *voteFormFields) ListByVoteID(ctx context.Context, voteID uint) ([]*VoteFormField, error) {
var fields []*VoteFormField
if err := db.WithContext(ctx).
Where("vote_id = ?", voteID).
Order("sort_order ASC, id ASC").
Find(&fields).Error; err != nil {
return nil, errors.Wrap(err, "list vote form fields by vote id")
}
return fields, nil
}
// CountByVoteID 统计某投票的表单字段数量(发布前完整性校验用)。
func (db *voteFormFields) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteFormField{}).Where("vote_id = ?", voteID).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote form fields by vote id")
}
return count, nil
}
// ReplaceByVoteID 在单个事务内替换投票的全部表单字段(先删后建),保证原子性。
// 仅在草稿状态编辑时调用。
func (db *voteFormFields) ReplaceByVoteID(ctx context.Context, voteID uint, fields []CreateVoteFormFieldOptions) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("vote_id = ?", voteID).Delete(&VoteFormField{}).Error; err != nil {
return err
}
if len(fields) == 0 {
return nil
}
formFields := make([]*VoteFormField, 0, len(fields))
for _, f := range fields {
formFields = append(formFields, &VoteFormField{
VoteID: voteID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
return tx.Create(&formFields).Error
})
}
// DeleteByVoteID 删除投票时级联清理全部表单字段。
func (db *voteFormFields) DeleteByVoteID(ctx context.Context, voteID uint) error {
if err := db.WithContext(ctx).Where("vote_id = ?", voteID).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields by vote id")
}
return nil
}
func (db *voteFormFields) DeleteByID(ctx context.Context, id uint) error {
res := db.WithContext(ctx).Delete(&VoteFormField{}, id)
if res.Error != nil {
return errors.Wrap(res.Error, "delete vote form field")
}
if res.RowsAffected == 0 {
return ErrVoteFormFieldNotExist
}
return nil
}
var ErrVoteFormFieldNotExist = errors.New("投票表单字段不存在")
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestVoteFormFields(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewVoteFormFieldsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *voteFormFields)
}{
{"Create", testVoteFormFieldsCreate},
{"BulkCreate", testVoteFormFieldsBulkCreate},
{"ListByVoteID", testVoteFormFieldsListByVoteID},
{"DeleteByID", testVoteFormFieldsDeleteByID},
{"DeleteByVoteID", testVoteFormFieldsDeleteByVoteID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("vote_form_fields"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store.(*voteFormFields))
})
}
}
func testVoteFormFieldsCreate(t *testing.T, ctx context.Context, db *voteFormFields) {
got, err := db.Create(ctx, CreateVoteFormFieldOptions{
VoteID: 1,
FieldType: VoteFormFieldTypeText,
Label: "你的昵称",
Required: true,
MaxLength: 50,
})
require.Nil(t, err)
require.Equal(t, uint(1), got.ID)
require.True(t, got.Required)
require.Equal(t, 50, got.MaxLength)
}
func testVoteFormFieldsBulkCreate(t *testing.T, ctx context.Context, db *voteFormFields) {
got, err := db.BulkCreate(ctx, 1, []CreateVoteFormFieldOptions{
{FieldType: VoteFormFieldTypeText, Label: "昵称", Required: true, MaxLength: 20, SortOrder: 0},
{FieldType: VoteFormFieldTypeTextarea, Label: "建议", Required: false, MaxLength: 500, SortOrder: 1},
{FieldType: VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般","不满意"]`, SortOrder: 2},
})
require.Nil(t, err)
require.Len(t, got, 3)
require.Equal(t, VoteFormFieldTypeTextarea, got[1].FieldType)
require.Equal(t, `["满意","一般","不满意"]`, got[2].Options)
}
func testVoteFormFieldsListByVoteID(t *testing.T, ctx context.Context, db *voteFormFields) {
_, err := db.BulkCreate(ctx, 1, []CreateVoteFormFieldOptions{
{FieldType: VoteFormFieldTypeText, Label: "B", SortOrder: 1},
{FieldType: VoteFormFieldTypeText, Label: "A", SortOrder: 0},
})
require.Nil(t, err)
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 2)
require.Equal(t, "A", got[0].Label, "按 SortOrder 升序")
}
func testVoteFormFieldsDeleteByID(t *testing.T, ctx context.Context, db *voteFormFields) {
_, err := db.Create(ctx, CreateVoteFormFieldOptions{VoteID: 1, FieldType: VoteFormFieldTypeText, Label: "A"})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
require.Nil(t, db.DeleteByID(ctx, 1))
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 0)
})
t.Run("not found", func(t *testing.T) {
require.Equal(t, ErrVoteFormFieldNotExist, db.DeleteByID(ctx, 404))
})
}
func testVoteFormFieldsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteFormFields) {
_, err := db.BulkCreate(ctx, 1, []CreateVoteFormFieldOptions{
{FieldType: VoteFormFieldTypeText, Label: "A"},
{FieldType: VoteFormFieldTypeText, Label: "B"},
})
require.Nil(t, err)
require.Nil(t, db.DeleteByVoteID(ctx, 1))
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 0)
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var VoteOptions VoteOptionsStore
type VoteOptionsStore interface {
Create(ctx context.Context, opts CreateVoteOptionOptions) (*VoteOption, error)
BulkCreate(ctx context.Context, voteID uint, contents []string) ([]*VoteOption, error)
ListByVoteID(ctx context.Context, voteID uint) ([]*VoteOption, error)
ReplaceByVoteID(ctx context.Context, voteID uint, contents []string) error
DeleteByVoteID(ctx context.Context, voteID uint) error
DeleteByID(ctx context.Context, id uint) error
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error)
}
func NewVoteOptionsStore(db *gorm.DB) VoteOptionsStore {
return &voteOptions{db}
}
type voteOptions struct {
*gorm.DB
}
// VoteOption 选项表:单选/多选/评分/排序共用。
type VoteOption struct {
dbutil.Model
VoteID uint `gorm:"index:idx_option_vote" json:"vote_id"`
Content string `gorm:"size:200" json:"content"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
type CreateVoteOptionOptions struct {
VoteID uint
Content string
SortOrder int
}
func (db *voteOptions) Create(ctx context.Context, opts CreateVoteOptionOptions) (*VoteOption, error) {
option := &VoteOption{
VoteID: opts.VoteID,
Content: opts.Content,
SortOrder: opts.SortOrder,
}
if err := db.WithContext(ctx).Create(option).Error; err != nil {
return nil, errors.Wrap(err, "create vote option")
}
return option, nil
}
// BulkCreate 批量创建选项(用于箱主一次提交全部选项)。
func (db *voteOptions) BulkCreate(ctx context.Context, voteID uint, contents []string) ([]*VoteOption, error) {
options := make([]*VoteOption, 0, len(contents))
for i, content := range contents {
options = append(options, &VoteOption{
VoteID: voteID,
Content: content,
SortOrder: i,
})
}
if len(options) == 0 {
return options, nil
}
if err := db.WithContext(ctx).Create(&options).Error; err != nil {
return nil, errors.Wrap(err, "bulk create vote options")
}
return options, nil
}
// ListByVoteID 获取某投票的全部选项(按 SortOrder 升序,保证选项顺序稳定)。
func (db *voteOptions) ListByVoteID(ctx context.Context, voteID uint) ([]*VoteOption, error) {
var options []*VoteOption
if err := db.WithContext(ctx).
Where("vote_id = ?", voteID).
Order("sort_order ASC, id ASC").
Find(&options).Error; err != nil {
return nil, errors.Wrap(err, "list vote options by vote id")
}
return options, nil
}
// ReplaceByVoteID 在单个事务内替换投票的全部选项(先删后建),保证原子性。
// 仅在草稿状态编辑时调用(发布后不可改选项,避免破坏历史投票归因)。
func (db *voteOptions) ReplaceByVoteID(ctx context.Context, voteID uint, contents []string) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("vote_id = ?", voteID).Delete(&VoteOption{}).Error; err != nil {
return err
}
if len(contents) == 0 {
return nil
}
options := make([]*VoteOption, 0, len(contents))
for i, content := range contents {
options = append(options, &VoteOption{VoteID: voteID, Content: content, SortOrder: i})
}
return tx.Create(&options).Error
})
}
// DeleteByVoteID 删除投票时级联清理全部选项。
func (db *voteOptions) DeleteByVoteID(ctx context.Context, voteID uint) error {
if err := db.WithContext(ctx).Where("vote_id = ?", voteID).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options by vote id")
}
return nil
}
func (db *voteOptions) DeleteByID(ctx context.Context, id uint) error {
res := db.WithContext(ctx).Delete(&VoteOption{}, id)
if res.Error != nil {
return errors.Wrap(res.Error, "delete vote option")
}
if res.RowsAffected == 0 {
return ErrVoteOptionNotExist
}
return nil
}
var ErrVoteOptionNotExist = errors.New("投票选项不存在")
func (db *voteOptions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteOption{}).Where("vote_id = ?", voteID).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote options by vote id")
}
return count, nil
}
// CountByVoteIDs 一次查询批量统计多个投票的选项数(替代逐投票 N 次 COUNT,消除 N+1)。
func (db *voteOptions) CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error) {
out := make(map[uint]int64, len(voteIDs))
if len(voteIDs) == 0 {
return out, nil
}
type row struct {
VoteID uint
Cnt int64
}
var rows []row
if err := db.WithContext(ctx).Model(&VoteOption{}).
Select("vote_id, COUNT(id) AS cnt").
Where("vote_id IN ?", voteIDs).
Group("vote_id").
Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "count vote options by vote ids")
}
for _, r := range rows {
out[r.VoteID] = r.Cnt
}
return out, nil
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestVoteOptions(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewVoteOptionsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *voteOptions)
}{
{"Create", testVoteOptionsCreate},
{"BulkCreate", testVoteOptionsBulkCreate},
{"ListByVoteID", testVoteOptionsListByVoteID},
{"DeleteByID", testVoteOptionsDeleteByID},
{"DeleteByVoteID", testVoteOptionsDeleteByVoteID},
{"CountByVoteID", testVoteOptionsCountByVoteID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("vote_options"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store.(*voteOptions))
})
}
}
func testVoteOptionsCreate(t *testing.T, ctx context.Context, db *voteOptions) {
got, err := db.Create(ctx, CreateVoteOptionOptions{
VoteID: 1,
Content: "选项 A",
SortOrder: 0,
})
require.Nil(t, err)
require.Equal(t, uint(1), got.ID)
require.Equal(t, "选项 A", got.Content)
}
func testVoteOptionsBulkCreate(t *testing.T, ctx context.Context, db *voteOptions) {
got, err := db.BulkCreate(ctx, 1, []string{"A", "B", "C"})
require.Nil(t, err)
require.Len(t, got, 3)
// SortOrder 应按传入顺序从 0 递增
require.Equal(t, 0, got[0].SortOrder)
require.Equal(t, 2, got[2].SortOrder)
require.Equal(t, "C", got[2].Content)
}
func testVoteOptionsListByVoteID(t *testing.T, ctx context.Context, db *voteOptions) {
_, err := db.BulkCreate(ctx, 1, []string{"B", "A", "C"})
require.Nil(t, err)
_, err = db.BulkCreate(ctx, 2, []string{"X"})
require.Nil(t, err)
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 3)
require.Equal(t, "B", got[0].Content, "按 SortOrder 升序:B(sort 0), A(sort 1), C(sort 2)")
require.Equal(t, "C", got[2].Content)
}
func testVoteOptionsDeleteByID(t *testing.T, ctx context.Context, db *voteOptions) {
_, err := db.Create(ctx, CreateVoteOptionOptions{VoteID: 1, Content: "A"})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
require.Nil(t, db.DeleteByID(ctx, 1))
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 0)
})
t.Run("not found", func(t *testing.T) {
require.Equal(t, ErrVoteOptionNotExist, db.DeleteByID(ctx, 404))
})
}
func testVoteOptionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteOptions) {
_, err := db.BulkCreate(ctx, 1, []string{"A", "B", "C"})
require.Nil(t, err)
require.Nil(t, db.DeleteByVoteID(ctx, 1))
got, err := db.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 0)
}
func testVoteOptionsCountByVoteID(t *testing.T, ctx context.Context, db *voteOptions) {
_, err := db.BulkCreate(ctx, 1, []string{"A", "B"})
require.Nil(t, err)
got, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByVoteID(ctx, 404)
require.Nil(t, err)
require.Equal(t, int64(0), got)
}
+256
View File
@@ -0,0 +1,256 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/jackc/pgconn"
"github.com/pkg/errors"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var VoteSubmissions VoteSubmissionsStore
type VoteSubmissionsStore interface {
Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error)
GetByID(ctx context.Context, id uint) (*VoteSubmission, error)
ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error)
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error)
CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error)
CountGroupByOptionID(ctx context.Context, voteID uint) (map[uint]int64, error)
CountByIP(ctx context.Context, voteID uint, ip string, since time.Time) (int64, error)
HasUserVoted(ctx context.Context, voteID, userID uint) (bool, error)
HasTokenVoted(ctx context.Context, voteID uint, token string) (bool, error)
DeleteByVoteID(ctx context.Context, voteID uint) error
}
func NewVoteSubmissionsStore(db *gorm.DB) VoteSubmissionsStore {
return &voteSubmissions{db}
}
type voteSubmissions struct {
*gorm.DB
}
// VoteSubmission 投票/提交记录(不记名:IP / token / user_id 仅用于防刷与审计,任何界面不展示)。
//
// 防一人一票的数据库硬保证(复合唯一索引 + NULL 不冲突,规避 MySQL 5.7 部分索引兼容问题):
// - UNIQUE(vote_id, user_id):登录用户一个账号对同一投票只能投一次(匿名行 user_id 为 NULL 不冲突)
// - UNIQUE(vote_id, voter_token):同一匿名指纹(服务端签发 Cookie)只能投一次(登录行 voter_token 为 NULL 不冲突)
type VoteSubmission struct {
dbutil.Model
VoteID uint `gorm:"index:idx_submission_vote;uniqueIndex:idx_submission_user;uniqueIndex:idx_submission_token" json:"vote_id"`
OptionID uint `gorm:"index:idx_submission_option" json:"option_id"`
UserID *uint `gorm:"uniqueIndex:idx_submission_user" json:"-"`
// HMAC token = 64-char hex nonce + dot + 43-char base64url signature (108 chars).
VoterToken *string `gorm:"size:128;uniqueIndex:idx_submission_token" json:"-"`
VoterIP string `gorm:"size:64;index:idx_submission_ip" json:"-"`
FormData datatypes.JSON `json:"form_data"`
CustomText string `gorm:"type:text" json:"custom_text"`
}
type CreateVoteSubmissionOptions struct {
VoteID uint
OptionID uint
UserID *uint
VoterToken *string
VoterIP string
FormData datatypes.JSON
CustomText string
}
var (
// ErrVoteAlreadyVoted 同一投票被同一身份重复提交(数据库唯一索引兜底返回)。
ErrVoteAlreadyVoted = errors.New("你已参与过该投票")
ErrVoteSubmissionNotExist = errors.New("投票记录不存在")
)
// Create 写入一条投票/提交记录。若命中复合唯一索引(同一 user 或同一 token 重复提交),
// 返回 ErrVoteAlreadyVoted。
func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error) {
submission := &VoteSubmission{
VoteID: opts.VoteID,
OptionID: opts.OptionID,
UserID: opts.UserID,
VoterToken: opts.VoterToken,
VoterIP: opts.VoterIP,
FormData: opts.FormData,
CustomText: opts.CustomText,
}
if err := db.WithContext(ctx).Create(submission).Error; err != nil {
if isDuplicateKeyError(err) {
return nil, ErrVoteAlreadyVoted
}
return nil, errors.Wrap(err, "create vote submission")
}
return submission, nil
}
func (db *voteSubmissions) GetByID(ctx context.Context, id uint) (*VoteSubmission, error) {
var submission VoteSubmission
if err := db.WithContext(ctx).First(&submission, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVoteSubmissionNotExist
}
return nil, errors.Wrap(err, "get vote submission by ID")
}
return &submission, nil
}
// ListByVoteID 分页获取某投票的提交记录(箱主查看表单回复用,新提交在前)。
func (db *voteSubmissions) ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error) {
q := db.WithContext(ctx).Model(&VoteSubmission{}).Where("vote_id = ?", voteID)
if cursor != nil && cursor.Value != nil {
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
cursorStr := strings.TrimSpace(cursorValueToString(cursor.Value))
if idVal, err := strconv.ParseUint(cursorStr, 10, 64); err == nil && idVal > 0 {
q = q.Where("id < ?", idVal)
}
}
if cursor != nil {
q = q.Limit(cursor.Limit())
}
q = q.Order("created_at DESC")
var submissions []*VoteSubmission
if err := q.Find(&submissions).Error; err != nil {
return nil, errors.Wrap(err, "list vote submissions by vote id")
}
return submissions, nil
}
// CountByVoteID 统计某投票的总参与人数。
func (db *voteSubmissions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).Where("vote_id = ?", voteID).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote submissions by vote id")
}
return count, nil
}
// CountByVoteIDs 一次查询批量统计多个投票的参与人数(替代逐投票 N 次 COUNT,消除 N+1)。
// 返回 map[voteID]count;未出现在结果中的 voteID 视为 0 参与。
func (db *voteSubmissions) CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error) {
out := make(map[uint]int64, len(voteIDs))
if len(voteIDs) == 0 {
return out, nil
}
type row struct {
VoteID uint
Cnt int64
}
var rows []row
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Select("vote_id, COUNT(id) AS cnt").
Where("vote_id IN ?", voteIDs).
Group("vote_id").
Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "count vote submissions by vote ids")
}
for _, r := range rows {
out[r.VoteID] = r.Cnt
}
return out, nil
}
// CountByOptionID 统计某选项的票数(结果页条形图数据源)。
func (db *voteSubmissions) CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Where("vote_id = ? AND option_id = ?", voteID, optionID).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote submissions by option id")
}
return count, nil
}
// CountGroupByOptionID 一次查询统计某投票下各选项的票数(替代逐选项 N 次 COUNT)。
func (db *voteSubmissions) CountGroupByOptionID(ctx context.Context, voteID uint) (map[uint]int64, error) {
type row struct {
OptionID uint
Cnt int64
}
var rows []row
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Select("option_id, COUNT(id) AS cnt").
Where("vote_id = ?", voteID).
Group("option_id").
Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "count vote submissions group by option id")
}
out := make(map[uint]int64, len(rows))
for _, r := range rows {
out[r.OptionID] = r.Cnt
}
return out, nil
}
// CountByIP 统计同一 IP 在 since 时间窗内的提交次数(匿名防刷兜底:清 Cookie 重投时按 IP 计数)。
func (db *voteSubmissions) CountByIP(ctx context.Context, voteID uint, ip string, since time.Time) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Where("vote_id = ? AND voter_ip = ? AND created_at >= ?", voteID, ip, since).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count vote submissions by ip")
}
return count, nil
}
// HasUserVoted 判断登录用户是否已投过该投票(应用层预检查,返回友好提示;最终防重由唯一索引兜底)。
func (db *voteSubmissions) HasUserVoted(ctx context.Context, voteID, userID uint) (bool, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Where("vote_id = ? AND user_id = ?", voteID, userID).
Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check user voted")
}
return count > 0, nil
}
// HasTokenVoted 判断匿名指纹是否已投过该投票。
func (db *voteSubmissions) HasTokenVoted(ctx context.Context, voteID uint, token string) (bool, error) {
var count int64
if err := db.WithContext(ctx).Model(&VoteSubmission{}).
Where("vote_id = ? AND voter_token = ?", voteID, token).
Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check token voted")
}
return count > 0, nil
}
// DeleteByVoteID 删除投票时级联清理全部提交记录。
//
// 使用 Unscoped 物理删除而非软删除:VoteSubmission 上的复合唯一索引
// (vote_id, user_id)/(vote_id, voter_token) 不含 deleted_at,软删除会让被删行继续占据
// 唯一槽位,导致"统计归零却无法重投"。级联删除发生在整个投票被删时,物理删除安全且正确。
func (db *voteSubmissions) DeleteByVoteID(ctx context.Context, voteID uint) error {
if err := db.WithContext(ctx).Unscoped().Where("vote_id = ?", voteID).Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions by vote id")
}
return nil
}
// isDuplicateKeyError 判断数据库唯一索引冲突错误(MySQL 1062 / PostgreSQL 23505)。
// 不依赖 GORM 的 TranslateError 配置(全局未开启),手动映射以保证语义化错误。
func isDuplicateKeyError(err error) bool {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return true
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return true
}
return false
}
+308
View File
@@ -0,0 +1,308 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
func TestVoteSubmissions(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewVoteSubmissionsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *voteSubmissions)
}{
{"Create", testVoteSubmissionsCreate},
{"UniqueUser", testVoteSubmissionsUniqueUser},
{"UniqueToken", testVoteSubmissionsUniqueToken},
{"UserAndAnonCoexist", testVoteSubmissionsUserAndAnonCoexist},
{"CrossVote", testVoteSubmissionsCrossVote},
{"Count", testVoteSubmissionsCount},
{"HasUserVoted", testVoteSubmissionsHasUserVoted},
{"HasTokenVoted", testVoteSubmissionsHasTokenVoted},
{"CountByIP", testVoteSubmissionsCountByIP},
{"ListByVoteID", testVoteSubmissionsListByVoteID},
{"DeleteByVoteID", testVoteSubmissionsDeleteByVoteID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("vote_submissions"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store.(*voteSubmissions))
})
}
}
func userIDPtr(id uint) *uint { return &id }
func tokenPtr(s string) *string { return &s }
func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmissions) {
got, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
require.Equal(t, uint(1), got.ID)
require.Equal(t, uint(10), *got.UserID)
}
// 核心防刷:同一登录用户对同一投票重复提交必须被数据库唯一索引拦截。
func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
t.Run("duplicate rejected", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Equal(t, ErrVoteAlreadyVoted, err)
})
t.Run("different user allowed", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(11),
VoterIP: "114.5.1.5",
})
require.Nil(t, err)
})
}
// 核心防刷:同一匿名指纹对同一投票重复提交必须被拦截。
func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoterToken: tokenPtr("fp-token-001"),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
t.Run("duplicate rejected", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoterToken: tokenPtr("fp-token-001"),
VoterIP: "114.5.1.4",
})
require.Equal(t, ErrVoteAlreadyVoted, err)
})
t.Run("different token allowed", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoterToken: tokenPtr("fp-token-002"),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
})
}
// 登录用户与匿名指纹可在同一投票下并存(user_id 为 NULL 与 voter_token 为 NULL 不冲突)。
func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoterToken: tokenPtr("fp-anon-001"),
VoterIP: "114.5.1.6",
})
require.Nil(t, err)
// 同一 user 换匿名 token 再来:user_id 相同仍应被拦截
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(10),
VoterToken: tokenPtr("fp-anon-002"),
VoterIP: "114.5.1.7",
})
require.Equal(t, ErrVoteAlreadyVoted, err)
count, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
// 同一用户 / 同一 token 对不同投票不受约束。
func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSubmissions) {
for voteID := uint(1); voteID <= 2; voteID++ {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: voteID,
OptionID: 1,
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
}
// 同一 token 投两个不同投票也允许
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 2,
OptionID: 1,
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
count, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmissions) {
// 投票 1:选项 1 两票,选项 2 一票
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 2, UserID: userIDPtr(3), VoterIP: "c"})
require.Nil(t, err)
// 投票 2:一票
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(4), VoterIP: "d"})
require.Nil(t, err)
t.Run("total", func(t *testing.T) {
got, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(3), got)
})
t.Run("by option", func(t *testing.T) {
got, err := db.CountByOptionID(ctx, 1, 1)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByOptionID(ctx, 1, 2)
require.Nil(t, err)
require.Equal(t, int64(1), got)
})
t.Run("empty", func(t *testing.T) {
got, err := db.CountByVoteID(ctx, 404)
require.Nil(t, err)
require.Equal(t, int64(0), got)
})
}
func testVoteSubmissionsHasUserVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "a"})
require.Nil(t, err)
got, err := db.HasUserVoted(ctx, 1, 10)
require.Nil(t, err)
require.True(t, got)
got, err = db.HasUserVoted(ctx, 1, 11)
require.Nil(t, err)
require.False(t, got)
}
func testVoteSubmissionsHasTokenVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-1"), VoterIP: "a"})
require.Nil(t, err)
got, err := db.HasTokenVoted(ctx, 1, "fp-1")
require.Nil(t, err)
require.True(t, got)
got, err = db.HasTokenVoted(ctx, 1, "fp-2")
require.Nil(t, err)
require.False(t, got)
}
func testVoteSubmissionsCountByIP(t *testing.T, ctx context.Context, db *voteSubmissions) {
since := time.Now().Add(-1 * time.Hour)
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "1.2.3.4"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "1.2.3.4"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(3), VoterIP: "5.6.7.8"})
require.Nil(t, err)
got, err := db.CountByIP(ctx, 1, "1.2.3.4", since)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByIP(ctx, 1, "5.6.7.8", since)
require.Nil(t, err)
require.Equal(t, int64(1), got)
}
func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
for i := 0; i < 3; i++ {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
UserID: userIDPtr(uint(100 + i)),
VoterIP: "x",
FormData: datatypes.JSON(`{"nickname":"user` + string(rune('0'+i)) + `"}`),
})
require.Nil(t, err)
}
got, err := db.ListByVoteID(ctx, 1, &dbutil.Cursor{Value: 2, PageSize: 10})
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, uint(1), got[0].ID, "游标取 id < 2 的记录")
}
func testVoteSubmissionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
require.Nil(t, err)
require.Nil(t, db.DeleteByVoteID(ctx, 1))
got, err := db.CountByVoteID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(0), got)
got, err = db.CountByVoteID(ctx, 2)
require.Nil(t, err)
require.Equal(t, int64(1), got)
}
+377
View File
@@ -0,0 +1,377 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/thanhpk/randstr"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Votes VotesStore
// 投票状态
const (
VoteStatusDraft = "draft" // 草稿(未发布,不可访问)
VoteStatusPublished = "published" // 进行中
VoteStatusEnded = "ended" // 已结束
VoteStatusArchived = "archived" // 归档
// VoteStatusNotStarted 仅作为管理列表的"有效展示状态"(published + 未到开始时间),不入库。
VoteStatusNotStarted = "not_started"
)
// 投票类型(收集方式)
const (
VoteTypeSingle = "single" // 单选
VoteTypeMultiple = "multiple" // 多选
VoteTypeRating = "rating" // 评分
VoteTypeRank = "rank" // 排序
VoteTypeForm = "form" // 结构化表单
)
// 展示可见性
const (
VoteVisibilityPublic = "public" // 探索页 + 提问箱页公开展示
VoteVisibilityLinkOnly = "link_only" // 仅通过链接分享
)
// 投票范围
const (
VoteVoterScopeAnyone = "anyone" // 完全匿名可投
VoteVoterScopeRegisteredOnly = "registered_only" // 仅注册用户可投
)
// 结果可见策略
const (
VoteShowResultAfterVote = "after_vote" // 投票后立即可见
VoteShowResultAfterEnd = "after_end" // 结束后可见
VoteShowResultOwnerOnly = "owner_only" // 仅主人可见
)
type VotesStore interface {
Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error)
GetByID(ctx context.Context, id uint) (*Vote, error)
GetByToken(ctx context.Context, token string) (*Vote, error)
ListByUserID(ctx context.Context, userID uint, opts ListVotesOptions) ([]*Vote, error)
ListPublishedPublicByUserID(ctx context.Context, userID uint, limit int) ([]*Vote, error)
ListPublishedPublic(ctx context.Context, limit int) ([]*Vote, error)
GetHot(ctx context.Context, limit int) ([]*Vote, error)
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(ctx context.Context, id uint) error
CountByUserID(ctx context.Context, userID uint) (int64, error)
}
func NewVotesStore(db *gorm.DB) VotesStore {
return &votes{db}
}
type votes struct {
*gorm.DB
}
type Vote struct {
dbutil.Model
UserID uint `gorm:"index:idx_vote_user" json:"-"`
Title string `gorm:"size:100" json:"title"`
Description string `gorm:"type:text" json:"description"`
Type string `gorm:"size:20;default:single" json:"type"`
Status string `gorm:"size:20;default:draft;index" json:"status"`
Visibility string `gorm:"size:20;default:public" json:"visibility"`
VoterScope string `gorm:"size:20;default:anyone" json:"voter_scope"`
ShowResult string `gorm:"size:20;default:after_vote" json:"show_result"`
AllowCustom bool `gorm:"default:false" json:"allow_custom"`
MaxChoices int `gorm:"default:1" json:"max_choices"`
StartAt *time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
LinkToken string `gorm:"size:16;uniqueIndex" json:"-"`
MaxVotesPerFingerprint int `gorm:"default:1" json:"-"`
// ⚠️ 不要给 RequireCaptcha 加 gorm:"default:true" 标签:GORM 在 Create 时会把零值 false
// 替换为字段默认值(true),导致「创建时不要求验证码」的配置被静默写成 true。
// 当前业务硬编码 requireCaptcha=true(决策 #3),去掉标签不影响现状,但消除同类陷阱。
RequireCaptcha bool `json:"-"`
CensorMetadata datatypes.JSON `json:"-"`
// QuotaLimit 定量收集上限:收集到该份数后自动结束投票(0 表示不限制)。
// 与 EndAt 互补——EndAt 按时间关闭,QuotaLimit 按数量关闭。
QuotaLimit int `gorm:"default:0" json:"quota_limit"`
}
// BeforeCreate 自动生成链接分享 token(与私信 Conversation.Token 同款模式,
// 不可枚举,防止通过数字 ID 扫描他人投票)。
func (v *Vote) BeforeCreate(tx *gorm.DB) error {
if v.LinkToken == "" {
v.LinkToken = randstr.String(12)
}
return nil
}
type CreateVoteOptions struct {
UserID uint
Title string
Description string
Type string
Visibility string
VoterScope string
ShowResult string
AllowCustom bool
MaxChoices int
StartAt *time.Time
EndAt *time.Time
MaxVotesPerFingerprint int
RequireCaptcha bool
CensorMetadata datatypes.JSON
QuotaLimit int
}
func (db *votes) Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error) {
vote := &Vote{
UserID: opts.UserID,
Title: opts.Title,
Description: opts.Description,
Type: opts.Type,
Visibility: opts.Visibility,
VoterScope: opts.VoterScope,
ShowResult: opts.ShowResult,
AllowCustom: opts.AllowCustom,
MaxChoices: opts.MaxChoices,
StartAt: opts.StartAt,
EndAt: opts.EndAt,
MaxVotesPerFingerprint: opts.MaxVotesPerFingerprint,
RequireCaptcha: opts.RequireCaptcha,
CensorMetadata: opts.CensorMetadata,
QuotaLimit: opts.QuotaLimit,
}
if err := db.WithContext(ctx).Create(vote).Error; err != nil {
return nil, errors.Wrap(err, "create vote")
}
return vote, nil
}
var ErrVoteNotExist = errors.New("投票不存在")
func (db *votes) GetByID(ctx context.Context, id uint) (*Vote, error) {
var vote Vote
if err := db.WithContext(ctx).First(&vote, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVoteNotExist
}
return nil, errors.Wrap(err, "get vote by ID")
}
return &vote, nil
}
func (db *votes) GetByToken(ctx context.Context, token string) (*Vote, error) {
var vote Vote
if err := db.WithContext(ctx).Where("link_token = ?", token).First(&vote).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVoteNotExist
}
return nil, errors.Wrap(err, "get vote by token")
}
return &vote, nil
}
type ListVotesOptions struct {
*dbutil.Cursor
Status string // 空 = 全部;否则按状态筛选(draft/published/ended/archived)
}
// ListByUserID 根据箱主 ID 获取投票列表,支持游标分页与状态筛选。
func (db *votes) ListByUserID(ctx context.Context, userID uint, opts ListVotesOptions) ([]*Vote, error) {
q := db.WithContext(ctx).Model(&Vote{}).Where("user_id = ?", userID)
if opts.Status != "" {
q = q.Where("status = ?", opts.Status)
}
if opts.Cursor != nil && opts.Cursor.Value != nil {
cursorStr := strings.TrimSpace(cursorValueToString(opts.Cursor.Value))
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
if idVal, err := strconv.ParseUint(cursorStr, 10, 64); err == nil && idVal > 0 {
q = q.Where("id < ?", idVal)
}
// 解析失败则忽略该游标(从首页重新开始),与提问列表游标行为一致。
}
if opts.Cursor != nil {
q = q.Limit(opts.Cursor.Limit())
}
q = q.Order("created_at DESC")
var votes []*Vote
if err := q.Find(&votes).Error; err != nil {
return nil, errors.Wrap(err, "list votes by user id")
}
return votes, nil
}
// ListPublishedPublicByUserID 获取箱主"进行中"的公开投票(提问箱页内嵌板块用):
// status=published 且 visibility=public,且在开始/结束时间窗内,按创建时间倒序。
func (db *votes) ListPublishedPublicByUserID(ctx context.Context, userID uint, limit int) ([]*Vote, error) {
if limit <= 0 {
limit = 3
}
now := time.Now()
q := db.WithContext(ctx).Model(&Vote{}).
Where("user_id = ? AND status = ? AND visibility = ?", userID, VoteStatusPublished, VoteVisibilityPublic).
Where("(start_at IS NULL OR start_at <= ?)", now).
Where("(end_at IS NULL OR end_at > ?)", now).
Order("created_at DESC").
Limit(limit)
var votes []*Vote
if err := q.Find(&votes).Error; err != nil {
return nil, errors.Wrap(err, "list published public votes by user id")
}
return votes, nil
}
// ListPublishedPublic 返回探索页可展示的公开投票,按创建时间倒序。
func (db *votes) ListPublishedPublic(ctx context.Context, limit int) ([]*Vote, error) {
if limit <= 0 {
limit = 20
}
now := time.Now()
var votes []*Vote
err := db.WithContext(ctx).Model(&Vote{}).
Where("status = ? AND visibility = ?", VoteStatusPublished, VoteVisibilityPublic).
Where("(start_at IS NULL OR start_at <= ?)", now).
Where("(end_at IS NULL OR end_at > ?)", now).
Order("created_at DESC").
Limit(limit).Find(&votes).Error
if err != nil {
return nil, errors.Wrap(err, "list published public votes")
}
return votes, nil
}
// GetHot 返回探索页"热门投票":进行中的公开投票,按参与人数倒序。
// 参与数通过子查询聚合(跨 MySQL/PostgreSQL 兼容,规避 GROUP BY 全列要求)。
func (db *votes) GetHot(ctx context.Context, limit int) ([]*Vote, error) {
if limit <= 0 {
limit = 6
}
now := time.Now()
sub := db.WithContext(ctx).Model(&VoteSubmission{}).
Select("vote_id, COUNT(id) AS cnt").
Where("deleted_at IS NULL").
Group("vote_id")
q := db.WithContext(ctx).Model(&Vote{}).
Select("votes.*").
Joins("LEFT JOIN (?) AS vote_stat ON vote_stat.vote_id = votes.id", sub).
Where("votes.status = ? AND votes.visibility = ?", VoteStatusPublished, VoteVisibilityPublic).
Where("(votes.start_at IS NULL OR votes.start_at <= ?)", now).
Where("(votes.end_at IS NULL OR votes.end_at > ?)", now).
Order("COALESCE(vote_stat.cnt, 0) DESC, votes.created_at DESC").
Limit(limit)
var votes []*Vote
if err := q.Find(&votes).Error; err != nil {
return nil, errors.Wrap(err, "get hot votes")
}
return votes, nil
}
type UpdateVoteOptions struct {
Title string
Description string
Type *string
Visibility string
VoterScope string
ShowResult string
AllowCustom bool
MaxChoices *int
StartAt *time.Time
EndAt *time.Time
MaxVotesPerFingerprint int
RequireCaptcha bool
CensorMetadata datatypes.JSON
QuotaLimit int
}
// Update 更新投票的可编辑字段(不含 Status,状态流转走 UpdateStatus)。
func (db *votes) Update(ctx context.Context, id uint, opts UpdateVoteOptions) error {
updates := map[string]interface{}{
"title": opts.Title,
"description": opts.Description,
"visibility": opts.Visibility,
"voter_scope": opts.VoterScope,
"show_result": opts.ShowResult,
"allow_custom": opts.AllowCustom,
"start_at": opts.StartAt,
"end_at": opts.EndAt,
"max_votes_per_fingerprint": opts.MaxVotesPerFingerprint,
"require_captcha": opts.RequireCaptcha,
"censor_metadata": opts.CensorMetadata,
"quota_limit": opts.QuotaLimit,
}
// Type / MaxChoices 用指针承载:nil 表示「不修改」,db 层跳过写入,避免调用方漏传时
// 被 map 更新写成零值(曾导致发布后编辑把 type 写成 "" 的同类 bug)。草稿阶段显式传 &值
// 以设置;已发布编辑传 nil 以保留原值。
if opts.Type != nil {
updates["type"] = *opts.Type
}
if opts.MaxChoices != nil {
updates["max_choices"] = *opts.MaxChoices
}
res := db.WithContext(ctx).Model(&Vote{}).Where("id = ?", id).Updates(updates)
if res.Error != nil {
return errors.Wrap(res.Error, "update vote")
}
if res.RowsAffected == 0 {
return ErrVoteNotExist
}
return nil
}
// UpdateStatus 更新投票状态(draft → published → ended → archived)。
func (db *votes) UpdateStatus(ctx context.Context, id uint, status string) error {
res := db.WithContext(ctx).Model(&Vote{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return errors.Wrap(res.Error, "update vote status")
}
if res.RowsAffected == 0 {
return ErrVoteNotExist
}
return nil
}
func (db *votes) DeleteByID(ctx context.Context, id uint) error {
var vote Vote
if err := db.WithContext(ctx).First(&vote, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrVoteNotExist
}
return errors.Wrap(err, "get vote by ID")
}
if err := db.WithContext(ctx).Delete(&Vote{}, id).Error; err != nil {
return errors.Wrap(err, "delete vote")
}
return nil
}
// CountByUserID 统计箱主的投票总数(全部状态)。
func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&Vote{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count votes by user id")
}
return count, nil
}
// cursorValueToString 将游标值转为字符串,兼容传入 uint / string。
func cursorValueToString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
+252
View File
@@ -0,0 +1,252 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
func TestVotes(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewVotesStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, db *votes)
}{
{"Create", testVotesCreate},
{"GetByID", testVotesGetByID},
{"GetByToken", testVotesGetByToken},
{"ListByUserID", testVotesListByUserID},
{"Update", testVotesUpdate},
{"UpdateStatus", testVotesUpdateStatus},
{"DeleteByID", testVotesDeleteByID},
{"CountByUserID", testVotesCountByUserID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("votes"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store.(*votes))
})
}
}
func testVotesCreate(t *testing.T, ctx context.Context, db *votes) {
got, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "今天穿什么?",
Description: "单选题",
Type: VoteTypeSingle,
Visibility: VoteVisibilityPublic,
VoterScope: VoteVoterScopeAnyone,
ShowResult: VoteShowResultAfterVote,
MaxChoices: 1,
})
require.Nil(t, err)
require.Equal(t, uint(1), got.ID)
require.NotEmpty(t, got.LinkToken, "LinkToken 应自动生成")
require.Equal(t, VoteStatusDraft, got.Status, "默认状态应为草稿")
// RequireCaptcha 不再被 gorm:"default:true" 隐式默认(零值陷阱已修复):Create 未显式传入时按 false 落库。
// 业务层在创建/编辑投票时始终强制 requireCaptcha=true(决策 #3),真实投票行为不受影响。
require.False(t, got.RequireCaptcha, "底层 Create 不再隐式默认开启验证码")
}
func testVotesGetByID(t *testing.T, ctx context.Context, db *votes) {
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Title - 1",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Title - 1", got.Title)
require.Equal(t, uint(1), got.UserID)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByID(ctx, 404)
require.Equal(t, ErrVoteNotExist, err)
})
}
func testVotesGetByToken(t *testing.T, ctx context.Context, db *votes) {
created, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Title - 1",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
got, err := db.GetByToken(ctx, created.LinkToken)
require.Nil(t, err)
require.Equal(t, created.ID, got.ID)
})
t.Run("not found", func(t *testing.T) {
_, err := db.GetByToken(ctx, "no-such-token")
require.Equal(t, ErrVoteNotExist, err)
})
}
func testVotesListByUserID(t *testing.T, ctx context.Context, db *votes) {
for i := 0; i < 3; i++ {
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Vote - 1",
})
require.Nil(t, err)
}
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 2,
Title: "Vote - 2",
})
require.Nil(t, err)
t.Run("all", func(t *testing.T) {
got, err := db.ListByUserID(ctx, 1, ListVotesOptions{})
require.Nil(t, err)
require.Len(t, got, 3)
})
t.Run("status filter", func(t *testing.T) {
err := db.UpdateStatus(ctx, 1, VoteStatusPublished)
require.Nil(t, err)
got, err := db.ListByUserID(ctx, 1, ListVotesOptions{Status: VoteStatusPublished})
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, uint(1), got[0].ID)
})
t.Run("cursor", func(t *testing.T) {
got, err := db.ListByUserID(ctx, 1, ListVotesOptions{
Cursor: &dbutil.Cursor{Value: 3, PageSize: 10},
})
require.Nil(t, err)
require.Len(t, got, 2)
})
}
func testVotesUpdate(t *testing.T, ctx context.Context, db *votes) {
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Title - 1",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
vt := VoteTypeMultiple
mc := 3
err := db.Update(ctx, 1, UpdateVoteOptions{
Title: "Title - updated",
Description: "desc",
Type: &vt,
Visibility: VoteVisibilityLinkOnly,
VoterScope: VoteVoterScopeRegisteredOnly,
ShowResult: VoteShowResultAfterEnd,
MaxChoices: &mc,
RequireCaptcha: false,
})
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Title - updated", got.Title)
require.Equal(t, VoteTypeMultiple, got.Type)
require.Equal(t, VoteVisibilityLinkOnly, got.Visibility)
require.Equal(t, VoteVoterScopeRegisteredOnly, got.VoterScope)
require.Equal(t, VoteShowResultAfterEnd, got.ShowResult)
require.Equal(t, 3, got.MaxChoices)
require.False(t, got.RequireCaptcha)
})
// 回归:调用方漏传 Type/MaxChoices(nil 指针)时,db.Update 必须跳过写入,
// 不得把 type 写成 ""(曾导致发布后编辑使公开页报「未知的投票类型」)。
t.Run("omit type preserves existing", func(t *testing.T) {
err := db.Update(ctx, 1, UpdateVoteOptions{Title: "Title - meta only"})
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Title - meta only", got.Title)
require.Equal(t, VoteTypeMultiple, got.Type, "type 不应被清零")
require.Equal(t, 3, got.MaxChoices, "max_choices 不应被清零")
})
t.Run("not found", func(t *testing.T) {
err := db.Update(ctx, 404, UpdateVoteOptions{Title: "x"})
require.Equal(t, ErrVoteNotExist, err)
})
}
func testVotesUpdateStatus(t *testing.T, ctx context.Context, db *votes) {
_, err := db.Create(ctx, CreateVoteOptions{
UserID: 1,
Title: "Title - 1",
})
require.Nil(t, err)
t.Run("normal", func(t *testing.T) {
require.Nil(t, db.UpdateStatus(ctx, 1, VoteStatusPublished))
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, VoteStatusPublished, got.Status)
require.Nil(t, db.UpdateStatus(ctx, 1, VoteStatusEnded))
got, err = db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, VoteStatusEnded, got.Status)
})
t.Run("not found", func(t *testing.T) {
require.Equal(t, ErrVoteNotExist, db.UpdateStatus(ctx, 404, VoteStatusEnded))
})
}
func testVotesDeleteByID(t *testing.T, ctx context.Context, db *votes) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteOptions{UserID: 1, Title: "Title - 1"})
require.Nil(t, err)
require.Nil(t, db.DeleteByID(ctx, 1))
_, err = db.GetByID(ctx, 1)
require.Equal(t, ErrVoteNotExist, err)
})
t.Run("not found", func(t *testing.T) {
require.Equal(t, ErrVoteNotExist, db.DeleteByID(ctx, 404))
})
}
func testVotesCountByUserID(t *testing.T, ctx context.Context, db *votes) {
_, err := db.Create(ctx, CreateVoteOptions{UserID: 1, Title: "A"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteOptions{UserID: 1, Title: "B"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteOptions{UserID: 2, Title: "C"})
require.Nil(t, err)
got, err := db.CountByUserID(ctx, 1)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByUserID(ctx, 404)
require.Nil(t, err)
require.Equal(t, int64(0), got)
}
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2021 E99p1ant. All rights reserved.
package dbutil
import (
"time"
)
func Now() time.Time {
return time.Now().Truncate(time.Microsecond)
}
+24
View File
@@ -0,0 +1,24 @@
// 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 dbutil
import (
"time"
"gorm.io/gorm"
)
// Model a basic GoLang struct which includes the following fields: ID, CreatedAt, UpdatedAt, DeletedAt
// It may be embedded into your model or you may build your own model without it
//
// type User struct {
// gorm.Model
// }
type Model struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
+46
View File
@@ -0,0 +1,46 @@
// 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 dbutil
var (
DefaultPageSize = 20
MaxDefaultPageSize = 100
)
type Pagination struct {
Page int
PageSize int
}
// LimitOffset returns LIMIT and OFFSET parameter for SQL.
// The first page is page 0.
func (p Pagination) LimitOffset() (limit, offset int) {
page := p.Page
pageSize := p.PageSize
if page <= 0 {
page = 1
}
if pageSize < 1 {
pageSize = DefaultPageSize
}
return pageSize, (page - 1) * pageSize
}
type Cursor struct {
Value interface{}
PageSize int
}
func (p Cursor) Limit() int {
pageSize := p.PageSize
if pageSize < 1 {
pageSize = DefaultPageSize
}
if pageSize > MaxDefaultPageSize {
pageSize = MaxDefaultPageSize
}
return pageSize
}
+145
View File
@@ -0,0 +1,145 @@
// 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 dbutil
import (
"context"
"flag"
"math/rand"
"net/url"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var flagParseOnce sync.Once
func NewTestDB(t *testing.T, migrationTables ...interface{}) (testDB *gorm.DB, cleanup func(...string) error) {
dbType := os.Getenv("DB_TYPE")
var dsn string
var dialectFunc func(string) gorm.Dialector
switch dbType {
case "mysql":
dsn = os.ExpandEnv("$DB_USER:$DB_PASSWORD@tcp($DB_HOST:$DB_PORT)/$DB_DATABASE?charset=utf8mb4&parseTime=True&loc=Local")
dialectFunc = mysql.Open
case "postgres":
dsn = os.ExpandEnv("postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT?sslmode=disable")
dialectFunc = postgres.Open
default:
t.Fatalf("Unknown database type: %q", dbType)
}
db, err := gorm.Open(dialectFunc(dsn), &gorm.Config{
NowFunc: Now,
SkipDefaultTransaction: true,
})
if err != nil {
t.Fatalf("Failed to open connection: %v", err)
}
ctx := context.Background()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
dbname := "tamabox-test-" + strconv.FormatUint(rng.Uint64(), 10)
err = db.WithContext(ctx).Exec(`CREATE DATABASE ` + QuoteIdentifier(dbType, dbname)).Error
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
switch dbType {
case "mysql":
dsn = strings.ReplaceAll(dsn, os.Getenv("DB_DATABASE"), dbname)
case "postgres":
cfg, err := url.Parse(dsn)
if err != nil {
t.Fatalf("Failed to parse DSN")
}
cfg.Path = "/" + dbname
dsn = cfg.String()
}
flagParseOnce.Do(flag.Parse)
testDB, err = gorm.Open(dialectFunc(dsn), &gorm.Config{
NowFunc: Now,
SkipDefaultTransaction: true,
})
if err != nil {
t.Fatalf("Failed to open test connection: %v", err)
}
err = testDB.AutoMigrate(migrationTables...)
if err != nil {
t.Fatalf("Failed to auto migrate tables: %v", err)
}
t.Cleanup(func() {
defer func() {
if database, err := db.DB(); err == nil {
_ = database.Close()
}
}()
if t.Failed() {
t.Logf("DATABASE %s left intact for inspection", dbname)
return
}
database, err := testDB.DB()
if err != nil {
t.Fatalf("Failed to get currently open database: %v", err)
}
err = database.Close()
if err != nil {
t.Fatalf("Failed to close currently open database: %v", err)
}
err = db.WithContext(ctx).Exec(`DROP DATABASE ` + QuoteIdentifier(dbType, dbname)).Error
if err != nil {
t.Fatalf("Failed to drop test database: %v", err)
}
})
return testDB, func(tables ...string) error {
if t.Failed() {
return nil
}
for _, table := range tables {
var query string
switch dbType {
case "mysql":
query = `TRUNCATE TABLE ` + QuoteIdentifier(dbType, table)
case "postgres":
query = `TRUNCATE TABLE ` + QuoteIdentifier(dbType, table) + ` RESTART IDENTITY CASCADE`
}
err := testDB.WithContext(ctx).Exec(query).Error
if err != nil {
return err
}
}
return nil
}
}
// QuoteIdentifier quotes an "identifier" (e.g. a table or a column name) to be
// used as part of an SQL statement.
func QuoteIdentifier(typ, s string) string {
if typ == "postgres" {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
return "`" + strings.ReplaceAll(s, "`", "``") + "`"
}
+31
View File
@@ -0,0 +1,31 @@
// 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 form
type Register struct {
Email string `valid:"required;email;maxlen:100" label:"电子邮箱" msg:"form.register.email"`
Domain string `valid:"required;alphadash;minlen:3;maxlen:20" label:"个性域名" msg:"form.register.domain"`
Name string `valid:"required;maxlen:20" label:"昵称" msg:"form.register.name"`
Password string `valid:"required;minlen:8;maxlen:30" label:"密码" msg:"form.register.password"`
RepeatPassword string `valid:"required;equal:Password" label:"重复密码" msg:"form.register.repeatPassword"`
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"form.register.recaptcha"`
Language string `form:"language" label:"界面语言"` // 可选:注册时选择界面语言,留空使用站点默认
}
type Login struct {
Email string `valid:"required;email;maxlen:100" label:"电子邮箱" msg:"form.email.required"`
Password string `valid:"required" label:"密码" msg:"form.login.password"`
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"form.recaptcha"`
}
type ForgotPassword struct {
Email string `valid:"required;email;maxlen:100" label:"电子邮箱" msg:"form.email.required"`
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"form.recaptcha"`
}
type RecoverPassword struct {
NewPassword string `valid:"required;minlen:8;maxlen:30" label:"新密码" msg:"form.recover.newPassword"`
RepeatPassword string `valid:"required;equal:NewPassword" label:"重复密码" msg:"form.recover.repeatPassword"`
}
+172
View File
@@ -0,0 +1,172 @@
// 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 form
import (
"mime"
"mime/multipart"
"net/http"
"reflect"
"github.com/flamego/flamego"
"github.com/flamego/template"
"github.com/unknwon/com"
"github.com/wuhan005/govalid"
"github.com/tamakyi/TamaBox/internal/context"
)
type ErrorCategory string
const (
ErrorCategoryDeserialization ErrorCategory = "deserialization"
ErrorCategoryValidation ErrorCategory = "validation"
)
type Error struct {
Category ErrorCategory
Error error
}
const (
// multipartMemoryLimit 是 multipart 解析时驻留内存的上限,超出部分由
// mime/multipart 落盘到临时文件。
multipartMemoryLimit = 10 << 20 // 10 MiB
// MaxRequestBodySize 是单次请求体的**总**大小上限(含落盘部分)。
//
// ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限地
// 写入临时文件。匿名可达的提问接口即接受 multipart,若不加总量限制,攻击者可持续
// POST 超大 body 撑满磁盘(http.Server 的 ReadTimeout 在高带宽下不足以形成约束)。
// 略高于内存上限,为 multipart 边界与其他表单字段留出开销。
// 导出供 internal/route 的全局 requestBodyLimitMiddleware 复用同一上限:
// 仅靠本 Bind 内的 MaxBytesReader 不够——Contexter 组级 CSRF 校验先于 Bind 执行,
// 无 X-CSRF-Token 头时它会整包惰性解析 body,超大 multipart 会在 Bind 限制生效前
// 落盘;全局中间件把上限提前到一切解析之前(见 internal/route/bodylimit.go)。
MaxRequestBodySize = 12 << 20 // 12 MiB
)
func Bind(model interface{}) flamego.Handler {
// Ensure not pointer.
if reflect.TypeOf(model).Kind() == reflect.Ptr {
panic("form: pointer can not be accepted as binding model")
}
return func(c context.Context, data template.Data) {
obj := reflect.New(reflect.TypeOf(model))
defer func() { c.Map(obj.Elem().Interface()) }()
r := c.Request()
// 在任何解析之前封顶请求体总大小。
// 注意:c.Request() 返回 flamego 的包装类型,其 Body 是方法而非字段,
// 必须取内嵌的 *http.Request 才能替换 Body。
// MaxBytesReader 在超限时让后续 Read 返回错误,从而使 ParseForm /
// ParseMultipartForm 失败并走下方的反序列化错误分支,避免超大 body 落盘或读入内存。
httpReq := r.Request
httpReq.Body = http.MaxBytesReader(c.ResponseWriter(), httpReq.Body, MaxRequestBodySize)
contentType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
switch contentType {
case "application/x-www-form-urlencoded", "":
if err := r.ParseForm(); err != nil {
// ⚠️ 必须同时 SetError(写响应则在此提前中止):flamego 的 handler 链
// 只要响应未写入就会自动执行下一 handler,仅 Map(Error) 不中断链,
// 零值结构体会照常进入业务动作(如把用户资料/屏蔽词静默清空、解除页面密码)。
// SetError 置位 HasError,业务 action 开头若检查 ctx.HasError() 即会早退;
// 不检查的 action 也已在 route/user 各 handler 补了 abortOnFormError 守卫。
c.SetError(err)
c.Map(Error{Category: ErrorCategoryDeserialization, Error: err})
return
}
case "multipart/form-data":
if err := r.ParseMultipartForm(multipartMemoryLimit); err != nil {
c.SetError(err)
c.Map(Error{Category: ErrorCategoryDeserialization, Error: err})
return
}
}
// Bind the form data to the given struct.
typ := reflect.TypeOf(obj.Interface())
val := reflect.ValueOf(obj.Interface())
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
val = val.Elem()
}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
fieldName := typ.Field(i).Tag.Get("form")
if fieldName == "" {
fieldName = com.ToSnakeCase(field.Name)
}
if r.MultipartForm != nil {
fhType := reflect.TypeOf((*multipart.FileHeader)(nil))
value, ok := r.MultipartForm.Value[fieldName]
if ok {
if val.Field(i).Kind() == reflect.Slice && val.Field(i).Type().Elem() == fhType {
continue
}
// FIXME: We don't implement the slice type yet.
val.Field(i).Set(reflect.ValueOf(value[0]))
} else {
value, ok := r.MultipartForm.File[fieldName]
if ok {
numElems := len(value)
if val.Field(i).Kind() == reflect.Slice && numElems > 0 && val.Field(i).Type().Elem() == fhType {
slice := reflect.MakeSlice(val.Field(i).Type(), numElems, numElems)
for i := 0; i < numElems; i++ {
slice.Index(i).Set(reflect.ValueOf(value[i]))
}
val.Field(i).Set(slice)
} else if val.Field(i).Type() == fhType {
val.Field(i).Set(reflect.ValueOf(value[0]))
}
}
}
} else {
// 非 multipart 请求(application/x-www-form-urlencoded):仅绑定 string 字段。
// 其余类型(如 []*multipart.FileHeader 图片字段)保持零值,
// 否则 reflect.Set 会因类型不匹配 panic(string → []*multipart.FileHeader)。
// 此前提问表单因带 enctype="multipart/form-data" 走上面分支而从未触发;
// 独立私聊表单等纯文本提交会命中此路径,必须先做类型判断。
if val.Field(i).Kind() == reflect.String {
val.Field(i).Set(reflect.ValueOf(r.Form.Get(fieldName)))
}
}
}
errors, ok := govalid.Check(obj.Interface())
if !ok {
Assign(obj.Interface(), data)
c.SetError(errors[0])
c.Map(Error{Category: ErrorCategoryValidation, Error: errors[0]})
return
}
}
}
// Assign assigns form values back to the template data.
func Assign(form interface{}, data map[string]interface{}) {
typ := reflect.TypeOf(form)
val := reflect.ValueOf(form)
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
val = val.Elem()
}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
fieldName := com.ToSnakeCase(field.Name)
data[fieldName] = val.Field(i).Interface()
}
}
+94
View File
@@ -0,0 +1,94 @@
// 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 form
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/flamego/flamego"
"github.com/flamego/template"
"github.com/tamakyi/TamaBox/internal/context"
)
// TestBindURLEncodedSkipsFileHeaderField 回归测试:application/x-www-form-urlencoded 提交
// 到带 []*multipart.FileHeader 字段的结构体时不应 panic。
//
// 背景:Bind 的非 multipart 分支曾对所有字段无条件 Set(string),遇到 NewQuestion.Images
// ([]*multipart.FileHeader)触发 reflect.Set 类型不匹配 panic(提问表单因带
// enctype="multipart/form-data" 走 multipart 分支而从未触发;独立私聊表单等纯文本提交
// 会命中此路径,导致提问关闭时提交私聊 500)。
func TestBindURLEncodedSkipsFileHeaderField(t *testing.T) {
f := flamego.New()
// 最小依赖注入:提供项目 context.Context 与 template.Data(Bind 仅用到这两个参数)。
f.Use(func(ctx flamego.Context) {
data := template.Data{}
ctx.Map(context.Context{Context: ctx, Data: data})
ctx.Map(data)
})
var bound NewQuestion
f.Post("/", Bind(NewQuestion{}), func(q NewQuestion) {
bound = q
})
req := httptest.NewRequest(http.MethodPost, "/",
strings.NewReader("content=你好&start_chat_val=true"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
f.ServeHTTP(rec, req)
if rec.Code >= http.StatusInternalServerError {
t.Fatalf("unexpected status %d, want < 500 (reflect.Set panic 不应发生)", rec.Code)
}
if bound.Content != "你好" {
t.Fatalf("Content = %q, want %q", bound.Content, "你好")
}
if bound.StartChat != "true" {
t.Fatalf("StartChat = %q, want %q", bound.StartChat, "true")
}
if bound.Images != nil {
t.Fatalf("Images = %v, want nil(非 multipart 请求应跳过图片字段)", bound.Images)
}
}
// TestBindMultipartStillBindsImages 确认 multipart 分支(提问表单路径)不受影响:
// 文件字段仍能正常绑定。
func TestBindMultipartStillBindsImages(t *testing.T) {
f := flamego.New()
f.Use(func(ctx flamego.Context) {
data := template.Data{}
ctx.Map(context.Context{Context: ctx, Data: data})
ctx.Map(data)
})
var bound NewQuestion
f.Post("/", Bind(NewQuestion{}), func(q NewQuestion) {
bound = q
})
// multipart 请求,携带一个普通字段,无文件
body := "--BOUND\r\n" +
"Content-Disposition: form-data; name=\"content\"\r\n\r\n" +
"hello\r\n" +
"--BOUND--\r\n"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "multipart/form-data; boundary=BOUND")
rec := httptest.NewRecorder()
f.ServeHTTP(rec, req)
if rec.Code >= http.StatusInternalServerError {
t.Fatalf("unexpected status %d, want < 500", rec.Code)
}
if bound.Content != "hello" {
t.Fatalf("Content = %q, want %q", bound.Content, "hello")
}
if bound.Images != nil {
t.Fatalf("Images = %v, want nil", bound.Images)
}
}
+43
View File
@@ -0,0 +1,43 @@
// 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 form
import (
"mime/multipart"
)
// 注意:以下结构体的 Content / Answer 字段刻意不再带 govalid 的 maxlen 标签 ——
// struct tag 是编译期常量,无法跟随 conf/app.ini 的 [limits] 段调整(历史上写死 2000,
// 与前端 maxlength 不一致且无法调参)。长度上限改由各路由 handler 读取
// conf.Limits.QuestionContentLen / AnswerContentLen / FollowUpContentLen 做 rune 校验。
type NewQuestion struct {
Content string `form:"content" valid:"required" label:"问题内容" msg:"form.question.content"`
ReceiveReplyViaEmail string
ReceiveReplyEmail string `valid:"email;maxlen:254" label:"接收回复的电子邮箱" msg:"form.question.receiveReplyEmail"`
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"form.recaptcha"`
Images []*multipart.FileHeader `form:"images" label:"图片"`
IsPrivate string `form:"private"`
AskPassword string `form:"ask_password" label:"提问密码"`
StartChat string `form:"start_chat_val"`
}
type PublishAnswerQuestion struct {
Answer string `form:"answer" valid:"required" label:"回答内容" msg:"form.answer.content"`
Images []*multipart.FileHeader `form:"images" label:"图片"`
}
type UpdateAnswerQuestion struct {
Answer string `form:"answer" valid:"required" label:"回答内容" msg:"form.answer.content"`
Images []*multipart.FileHeader `form:"images" label:"图片"`
}
type PublishFollowUpQuestion struct {
Content string `form:"content" valid:"required" label:"追问内容" msg:"form.followup.content"`
Token string `form:"token"`
}
type AnswerFollowUpQuestion struct {
Answer string `form:"answer" valid:"required" label:"追问回答" msg:"form.followup.answer"`
}
+53
View File
@@ -0,0 +1,53 @@
// 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 form
type UpdateProfile struct {
Name string `valid:"required;maxlen:20" label:"昵称" msg:"form.profile.name"`
OldPassword string `label:"旧密码"`
NewPassword string `valid:"maxlen:30" label:"新密码" msg:"form.profile.passwordMax"`
Intro string `valid:"required;maxlen:100" label:"介绍" msg:"form.profile.intro"`
Usernamecolor string `valid:"required;minlen:7;maxlen:7" label:"用户名颜色" msg:"form.profile.colorHex"`
Introcolor string `valid:"required;minlen:7;maxlen:7" label:"个人说明颜色" msg:"form.profile.colorHex"`
Qrcodebackcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码背景色" msg:"form.profile.colorHex"`
Qrcodecolor string `valid:"required;minlen:7;maxlen:7" label:"二维码前景色" msg:"form.profile.colorHex"`
Dotscale string `valid:"required" label:"二维码点大小" msg:"form.profile.dotscale"`
Backgroundimagealpha string `valid:"required" label:"二维码背景透明度" msg:"form.profile.backgroundimagealpha"`
Qrcodepdpcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码探测图形颜色" msg:"form.profile.colorHex"`
NotifyEmail string `label:"开启邮箱通知"`
Acfunlink string `valid:"maxlen:30" label:"AcFun主页" msg:"form.profile.linkMax"`
Bililink string `valid:"maxlen:30" label:"Bili主页" msg:"form.profile.linkMax"`
Weibolink string `valid:"maxlen:30" label:"微博主页" msg:"form.profile.linkMax"`
Bloglink string `valid:"maxlen:30" label:"博客主页" msg:"form.profile.linkMax"`
Xiaohongshulink string `valid:"maxlen:30" label:"小红书主页" msg:"form.profile.linkMax"`
UseCustomQRColor string `form:"use_custom_qr_color"`
Language string `form:"language"`
}
// UpdateHarassment 仅「允许注册用户向我提问」开关(防骚扰类型)。
// 全局屏蔽词为独立入口 UpdateBlockWords:二者分处不同设置面板保存,
// 若仍共用一个结构体会互相覆盖对方字段(见 route/user/harassment.go 注释)。
type UpdateHarassment struct {
RegisterOnly string `label:"仅允许注册用户"`
}
type UpdateBlockWords struct {
BlockWords string `label:"屏蔽词"`
}
type UpdateTags struct {
Tags string `form:"tags"`
ShowInHome string `form:"show_in_home"`
}
// 新增:页面访问密码表单
type UpdatePagePassword struct {
Password string `valid:"maxlen:30" label:"页面访问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
}
// 新增:提问密码表单
type UpdateAskPassword struct {
Password string `valid:"maxlen:30" label:"提问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
}
+840
View File
@@ -0,0 +1,840 @@
// 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 i18n
// enDict English copy.
var enDict = map[string]string{
// Navigation
"nav.explore": "Explore",
"nav.mine": "Mine",
"nav.register": "Sign up",
"nav.login": "Log in",
"nav.ask": "Ask",
"nav.settings": "Settings",
"nav.lang": "Language",
// Common
"common.login": "Log in",
"common.forgot": "Forgot password",
"common.submit": "Submit",
"common.cancel": "Cancel",
"common.save": "Save",
"common.close": "Close",
"common.delete": "Delete",
"common.moveUp": "Move up",
"common.moveDown": "Move down",
"common.share": "Share",
"common.verifying": "Verifying...",
"common.submitting": "Submitting...",
"common.uploading": "Uploading...",
"common.lang": "Language",
"common.menu": "Menu",
"common.pleaseWait": "Please wait...",
"common.backHome": "Back to Home",
"common.theme": "Toggle dark/light mode",
// Site
"site.title": "Wolf's Question Box | Anonymous Question Box",
"site.description": "Wolf's Question Box is an anonymous question box — ask me anything without signing up, a safe space to share and respond.",
"site.keywords": "Anonymous Question Box,question box,anonymous ask,ask anonymously,Wolf's Question Box,anonymous Q&A",
"site.footer.runtime": "The box has been alive for",
"site.footer.admin": "Contact admin",
"site.footer.notice": "Announcements",
"site.footer.chat": "Chat room",
"site.footer.drive": "Drive",
"site.footer.status": "Status",
"site.footer.nav": "Links",
"site.maintenance": "The site is under maintenance. Please come back later ~",
// Auth
"auth.login.title": "User Log in",
"auth.login.email": "Email",
"auth.login.password": "Password",
"auth.login.submit": "Log in",
"auth.login.forgot": "Forgot password",
"auth.register.title": "User Sign up",
"auth.register.name": "Nickname",
"auth.register.email": "Email",
"auth.register.password": "Password",
"auth.register.submit": "Sign up",
"auth.forgot.title": "Forgot password",
"auth.forgot.email": "Email",
"auth.forgot.submit": "Send reset email",
"auth.forgot.submitBtn": "Recover password",
"auth.forgot.sent": "An email has been sent to %s, please check your inbox.",
"auth.recover.title": "Reset password",
"auth.recover.greeting": "%s, you are resetting your password:",
"auth.recover.password": "New password",
"auth.recover.confirm": "Confirm password",
"auth.recover.submit": "Reset password",
// Explore
"explore.title": "Explore User Tags",
"explore.notice": "Here you can find users who enabled <span class=\"uk-text-bold\">\"Show my tags on the homepage\"</span>. This feature is off by default. To get discovered here, go to <a href=\"/user/profile\" class=\"uk-text-bold\">your profile settings</a> and enable it.",
"explore.shuffle": "Shuffle",
"explore.empty": "No users to show yet. Enable it and be the first!",
"explore.hotVotes": "Hot polls",
"explore.questionsTab": "Questions",
"explore.votesTab": "Polls",
"explore.votesTitle": "Public polls",
"explore.votesDesc": "Browse active public polls and join the topics that interest you.",
"explore.randomVote": "Random pick",
"explore.allVotes": "All polls",
"explore.noVotes": "There are no public polls available right now.",
// Ask page
"ask.scan": "Scan to ask @%s",
"ask.anonymous": "Anyone can ask anonymously",
"ask.registerOnly": "Only registered users can ask",
"ask.nextOpenAt": "Next opening: %s",
"ask.lastCloseAt": "Last closed: %s",
"weekday.0": "Sun",
"weekday.1": "Mon",
"weekday.2": "Tue",
"weekday.3": "Wed",
"weekday.4": "Thu",
"weekday.5": "Fri",
"weekday.6": "Sat",
"ask.pinned": "Pinned",
"ask.share": "Share this box",
"ask.placeholder": "Write your question here...",
"ask.chatPlaceholder": "Write your chat message here...",
"ask.addImage": "Add image",
"ask.private": "Don't publish after reply",
"ask.notify": "Notify me",
"ask.emailPlaceholder": "Enter your email address",
"ask.submit": "Send question",
"ask.chat": "Start a chat",
"ask.chatDisabled": "Chat is disabled on this box",
"ask.chatLimit": "Message limit: %d",
"ask.history": "Previously answered by @%s",
"ask.loginRequired": "Log in required",
"ask.loginRequiredChat": "You need to log in to start a chat",
"ask.goLogin": "Go to login",
"ask.needPassword": "This box requires a password",
"ask.needLogin": "The owner only allows registered users to ask questions. Please log in first.",
"ask.pleaseEmail": "Please enter your email to receive notifications",
"ask.generating": "Generating share card…",
"ask.genFailed": "Failed to generate share card. Please try again later",
"ask.networkError": "Network error",
"ask.shareFilename": "share.png",
// Question item
"qitem.answer": "Answer",
"qitem.delete": "Delete",
"qitem.private": "Make private",
"qitem.public": "Make public",
"qitem.asked": "Asked",
"qitem.pending": "Pending reply",
"qitem.answeredBy": "Answer by @%s on %s",
"qitem.danger": "Danger!",
"qitem.deleteConfirm": "Are you sure you want to delete this question? This cannot be undone.",
"qitem.deleteConfirmBtn": "Yes, delete",
"qitem.backToBox": "Back to %s's question box",
"qitem.answerTitle": "Answer the question",
"qitem.answerPlaceholder": "Write your answer here...",
"qitem.emailNotifyHint": "The asker left their email. Once you answer this question for the first time, they will receive an email notification.",
"qitem.submitAnswer": "Answer",
"qitem.updateAnswer": "Update answer",
"qitem.askMore": "Ask something else?",
"qitem.markRead": "Mark as read",
"qitem.markUnread": "Mark as unread",
"qitem.readLocked": "Answered, locked as read",
// Admin / profile page
"admin.profile": "Profile",
"admin.lang": "Language",
"admin.langHint": "Choose the language visitors see when they open your question box (admin pages follow this too)",
"admin.email": "Email",
"admin.name": "Nickname",
"admin.nameColor": "Nickname color",
"admin.oldPassword": "Old password",
"admin.newPassword": "New password",
"admin.notify": "New question notifications",
"admin.personalize": "Customization",
"admin.intro": "Box introduction",
"admin.introColor": "Introduction color",
"admin.qrBgColor": "QR background color (light part)",
"admin.qrPdColor": "QR finder pattern color (corner squares)",
"admin.qrFgColor": "QR foreground color",
"admin.qrDotSize": "QR dot size (←small large→)",
"admin.qrBgAlpha": "QR background opacity (←opaque transparent→)",
"admin.avatar": "Avatar",
"admin.background": "Box background",
"admin.qrBackground": "QR background image",
"admin.social": "Social links",
"admin.harassment": "Anti-harassment",
"admin.askLimit": "Asking restriction",
"admin.blockWords": "Blocked words (press Enter or comma to add, up to 10 words, 10 characters each)",
"admin.chat": "Chat settings",
"admin.chatMaxMessages": "Max messages per chat",
"admin.chatDailyLimit": "Daily message limit per user",
"admin.tags": "Tags",
"admin.tagsHint": "Tags (up to 5, 5 characters each, press Enter or comma to add)",
"admin.tagsPlaceholder": "e.g. anime, music, games",
"admin.tagInputHint": "Type and press Enter or comma to add",
"admin.tagInputDuplicate": "Already exists, ignored",
"admin.pagePassword": "Page access password",
"admin.pagePasswordHint": "Set a page password (leave empty to disable)",
"admin.askPassword": "Ask password",
"admin.askPasswordHint": "Set a password for asking (leave empty to disable)",
"admin.saveProfile": "Save changes",
"admin.updated": "Profile updated successfully",
"admin.logout": "Log out",
"admin.export": "Export data",
// Admin settings (additional)
"admin.oldPasswordHint": "Only needed when changing password; leave empty to keep it unchanged",
"admin.newPasswordHint": "6-64 characters; leave empty to keep it unchanged",
"admin.notifyEmail": "Email",
"admin.useCustomFg": "Use custom foreground color (otherwise auto-matched)",
"admin.customFgHint": "Only takes effect when enabled; otherwise the color is auto-generated",
"admin.avatarPlaceholder": "Upload avatar (non-1:1 images will be squished)",
"admin.backgroundPlaceholder": "Upload box background",
"admin.qrBackgroundPlaceholder": "Upload QR background (a background that is too dark may make the QR code unreadable)",
"admin.qrPreviewHint": "The preview updates instantly after choosing an image",
"admin.preview": "Live preview",
"admin.themePreset": "Preset themes",
"admin.themeDefault": "Default",
"admin.themeDark": "Dark",
"admin.themeOcean": "Ocean",
"admin.themeSunset": "Sunset",
"admin.resetDefaults": "🔄 Reset to defaults",
"admin.previewHint": "Adjustments are for preview only; click \"Save changes\" to apply",
"admin.socialHint": "Check the box and fill in the profile URL (no https:// needed). Unchecked items won't be shown on your page.",
"admin.enableAcfun": "Enable AcFun",
"admin.enableBili": "Enable Bilibili",
"admin.enableWeibo": "Enable Weibo",
"admin.enableXiaohongshu": "Enable Xiaohongshu",
"admin.enableBlog": "Enable Personal Blog",
"admin.registerOnly": "Only allow registered users to ask me questions",
"admin.updateHarassment": "Update anti-harassment settings",
"admin.globalBlockWords": "Global blocked words",
"admin.globalBlockWordsHint": "Blocked words apply to questions, chat messages sent to you, and vote submissions. This single setting is shared across the question / chat / vote panels and takes effect immediately from any of them.",
"admin.updateBlockWords": "Save blocked words",
"admin.allowChat": "Allow registered users to start a chat with me",
"admin.dailyLimitHint": "0 means unlimited",
"admin.updateChat": "Update chat settings",
"admin.showInHome": "Show my tags on the home page (let other users find me by tags)",
"admin.updateTags": "Update tags",
"admin.updatePagePassword": "Update page password",
"admin.pagePasswordPlaceholder": "Enter page access password",
"admin.updateAskPassword": "Update ask password",
"admin.askPasswordPlaceholder": "Enter ask password",
"admin.account": "Account settings",
"admin.exportHint": "You can export all your personal data from %s, including your basic information, received questions and answers.",
"admin.exportJSON": "Export all data (JSON)",
"admin.exportJSONHint": "Includes questions, answers, follow-ups and chats for full backup or migration.",
"admin.deactivate": "Deactivate my account",
"admin.deactivateHint": "You can deactivate your account at any time. After deactivation, your account can no longer log in, your question box page and questions will be inaccessible, and others won't be able to send you new questions.<b>This action cannot be undone. Please be careful!</b>",
"admin.socialRequired": "Please fill in the link or uncheck the box",
// Question management
"qlist.title": "My Questions",
"qlist.answered": "Answered",
"qlist.pending": "Pending",
"qlist.private": "Private",
"qlist.empty": "No questions yet. Share your box and get some!",
"qlist.action": "Actions",
"qlist.answerNow": "Answer",
"qlist.sort": "Sort",
"qlist.status": "Status",
"qlist.type": "Type",
"qlist.latest": "Latest",
"qlist.oldest": "Oldest",
"qlist.all": "All",
"qlist.public": "Normal question",
"qlist.privateQuestion": "Private question",
"qlist.received": "Questions I received",
"qlist.myChats": "Chats I started",
"qlist.statsTotal": "Total questions",
"qlist.statsAnswered": "Answered",
"qlist.statsUnanswered": "Unanswered",
"qlist.statsPrivate": "Private",
"qlist.statsPinned": "Pinned",
"qlist.statsChats": "Chats",
"qlist.searchPlaceholder": "Search questions or answers...",
"qlist.searchBtn": "Search",
"qlist.searchClear": "Clear",
"qlist.questionsTab": "Questions",
"qlist.votesTab": "Votes",
"qlist.chatsTab": "Chats",
"qlist.receivedChats": "Chats I received",
"qlist.chatEmpty": "No chats yet. Share your question box to start chatting!",
"qlist.pinnedShort": "Pin",
"qlist.privateShort": "Pvt",
"qlist.chatStatsTotal": "Total chats",
"qlist.chatStatsReceived": "Received",
"qlist.chatStatsInitiated": "Initiated",
"qlist.chatStatsClosed": "Closed",
"qlist.unansweredShort": "Open",
"qlist.readShort": "Read",
"qlist.unreadShort": "Unread",
"qitem.markReadShort": "Mark",
"qlist.pinShort": "Pin",
"qlist.unpinShort": "Unpin",
"qlist.myVotes": "My votes",
"qlist.loadMore": "Load more",
"qlist.newVote": "New vote",
"qlist.edit": "Edit",
"qlist.untitledVote": "Untitled vote",
"qlist.blocked": "Blocked",
"qlist.unanswered": "Unanswered",
// Site logo suffix (random animal emoji + suffix)
"site.logoSuffix": "'s Question Box",
"site.stats": "Question Box Statistics",
// Captcha modal
"captcha.title": "Verification",
"captcha.hint": "Solve the math problem below to prove you are not a robot",
"captcha.refresh": "New problem",
"captcha.submit": "Verify & submit",
"captcha.loading": "Loading...",
"captcha.loadFailed": "Load failed",
"captcha.loadRetry": "Load failed, please retry",
"captcha.inputAnswer": "Please enter the answer",
"captcha.wrongAnswer": "Wrong answer, please recalculate",
"captcha.verifyFailed": "Verification failed, please retry",
// Register page extras
"auth.register.domain.prefix": "Custom domain (your box URL will be: %s + ",
"auth.register.domain.suffix": " + what you fill in below)",
"auth.register.confirm": "Confirm password",
"auth.register.language": "Language",
"auth.register.languageDefault": "Use site default",
"auth.register.success": "Registration successful, welcome to TamaBox!",
// Explore page extras
"explore.recommend": "Random picks",
"explore.search": "Search tags...",
"explore.sortLatest": "Newest",
"explore.sortOldest": "Oldest",
"explore.noTags": "No tags",
"explore.noRecommend": "No recommendations yet",
"explore.hotTags": "Hot Tags",
"explore.hotQuestions": "Hot Questions",
"explore.noResult": "No users match your criteria.",
"explore.lazy": "This user is too lazy to write anything~",
"explore.prevPage": "Previous",
"explore.nextPage": "Next",
"explore.loading": "Loading...",
"explore.loadFailed": "Load failed",
// Question box password page
"password.title": "This question box is password protected",
"password.desc": "This question box is password protected. Enter the password to view its contents.",
"password.input": "Enter the access password",
"password.enter": "Enter",
"password.submit": "Confirm",
// Question not found
"qitem.notFound": "Question not found or has been deleted",
// Chat page
"chat.title": "Chat",
"chat.input": "Type a message...",
"chat.send": "Send",
"chat.delete": "Delete conversation",
"chat.block": "Block user",
"chat.unblock": "Unblock user",
"chat.empty": "No messages yet. Say something~",
"chat.withAsker": "Chat with the asker",
"chat.withUser": "Chat with %s",
"chat.closed": "Conversation closed",
"chat.closedNoSend": "The conversation is closed. You cannot send new messages.",
"chat.remaining": "Messages left: %d (of %d total)",
"chat.me": "Me",
"chat.other": "Other",
"chat.userBlocked": "This user has been blocked",
"chat.addImage": "Add image (optional, max 5MB)",
"chat.confirm": "Confirm",
"chat.confirmBlock": "Block this user? They won't be able to send you messages anymore.",
"chat.confirmUnblock": "Unblock this user? They will be able to send you messages again.",
"chat.deleteConfirm": "Delete this conversation? The other party will no longer be able to see it, and this cannot be undone.",
"maintenance.title": "NekoBox is under maintenance...",
"maintenance.desc": "NekoBox is currently under maintenance and temporarily unavailable. Please come back later~",
// Single-question share (v4)
"qitem.shareCard": "Share this question",
// Follow-ups (v4)
"followup.title": "Follow-ups",
"followup.content": "Follow-up: ",
"followup.answerPlaceholder": "Answer this follow-up...",
"followup.answerBtn": "Answer",
"followup.waiting": "Waiting for answer...",
"followup.askMore": "Add a follow-up",
"followup.remaining": "%d / %d follow-ups remaining",
"followup.contentPlaceholder": "Add more details...",
"followup.waitingReply": "Your follow-up has been sent. Waiting for the box owner to reply...",
"followup.submit": "Submit",
// Pinning (v4)
"qlist.pinned": "Pinned",
"qlist.pin": "Pin",
"qlist.unpin": "Unpin",
"qlist.readStatus": "Read status",
"qlist.read": "Read",
"qlist.unread": "Unread",
"qlist.statsRead": "Read",
"qlist.statsUnread": "Unread",
// Question submitted page (v4)
"success.title": "Question sent successfully!",
"success.yourQuestion": "Your question:",
"success.emailSent": "A private link to this question was sent to your email. You can also use the link below to view the question, receive replies, and follow up.",
"success.saveLink": "<strong>Please save the private link below!</strong> Since you didn't leave an email, this link (including the token) is your <strong>only credential</strong> to view the question, receive replies, and follow up. If you lose it, it cannot be recovered.",
"success.privateLink": "Your private link (click to copy)",
"success.copy": "Copy",
"success.copied": "Copied ✓",
"success.copyFailed": "Copy failed",
"success.token": "Token: %s (the asker uses this token to access the private link)",
"success.viewQuestion": "View my question",
"success.backToBox": "Back to %s's box",
"success.pageTitle": "Question Sent - %s",
// Deactivate page
"deactivate.title": "Deactivate account",
"deactivate.desc": "After deactivation your question box will be inaccessible and all data will be deleted. This cannot be undone!",
"deactivate.confirm": "I confirm I want to deactivate my account",
"deactivate.submit": "Deactivate account",
// Anonymous voting
"nav.votes": "Polls",
"common.back": "Back",
"vote.listTitle": "Poll Management",
"vote.newTitle": "New Poll",
"vote.editTitle": "Edit Poll",
"vote.resultsTitle": "Poll Results",
"vote.statsTotal": "Total Polls",
"vote.statsPublished": "Active",
"vote.createNew": "New Poll",
"vote.typeSingle": "Single choice",
"vote.typeMultiple": "Multiple choice",
"vote.typeRating": "Rating",
"vote.typeRank": "Ranking",
"vote.typeForm": "Form",
"vote.statusNotStarted": "Not started",
"vote.statusDraft": "Draft",
"vote.statusPublished": "Active",
"vote.statusEnded": "Ended",
"vote.statusArchived": "Archived",
"vote.participants": "Participants",
"vote.remainLabel": "Remaining",
"vote.perpetual": "No deadline",
"vote.durationDay": "d",
"vote.durationHour": "h",
"vote.durationMinute": "m",
"vote.createdAt": "Created",
"vote.edit": "Edit",
"vote.results": "Results",
"vote.preview": "Preview",
"vote.page.mustComplete": "Please complete",
"vote.page.rankHintRequired": "Rank 1-N (required)",
"vote.page.rankHintOptional": "Rank 1-N (optional, partial OK)",
"vote.page.previewHint": "Preview mode: this is a sample of the live page; submitting is disabled",
"vote.page.previewSubmitHint": "Preview mode; submitting is enabled after publish",
"vote.publish": "Publish",
"vote.close": "End",
"vote.share": "Copy link",
"vote.copied": "Copied",
"vote.shareURL": "Poll share link",
"vote.confirmPublish": "Publish this poll? A shareable link will be generated.",
"vote.confirmClose": "End this poll? Participants will no longer be able to vote.",
"vote.confirmDelete": "Delete this poll? All vote data will be removed and cannot be recovered!",
"vote.manageEntry": "Manage polls",
"vote.manageTitle": "Manage polls",
"vote.manageDesc": "Create and manage your anonymous polls", "vote.empty": "No polls yet. Click \"New Poll\" in the top right to get started.",
"vote.formLockedHint": "This poll is live. Only basic info can be edited; type and options are locked to protect collected votes.",
"vote.formBasic": "Basic Settings",
"vote.formTitle": "Poll Title",
"vote.formTitlePlaceholder": "e.g. What should we eat today?",
"vote.formDescription": "Description",
"vote.formDescriptionPlaceholder": "Optional details",
"vote.formType": "Poll Type",
"vote.formVisibility": "Visibility",
"vote.formVisibilityPublic": "Public (Explore + question box)",
"vote.formVisibilityLinkOnly": "Link only",
"vote.formVoterScope": "Voting Scope",
"vote.formVoterScopeAnyone": "Anyone (including anonymous)",
"vote.formVoterScopeRegistered": "Registered users only",
"vote.formShowResult": "Result Visibility",
"vote.formShowResultAfterVote": "Visible right after voting",
"vote.formShowResultAfterEnd": "Visible after the poll ends",
"vote.formShowResultOwnerOnly": "Owner only",
"vote.formStartAt": "Start time (empty = now)",
"vote.formEndAt": "End time (empty = no limit)",
"vote.formMaxChoices": "Max choices",
"vote.formMaxVotesPerFingerprint": "One vote per device per poll; repeated submissions from the same browser are blocked.",
"vote.formMaxVotesPerAccount": "One vote per registered account per poll; repeated submissions from the same account are blocked.",
"vote.formAllowCustom": "Allow participants to add custom options",
"vote.formRequireCaptcha": "Require captcha for anonymous voting",
"vote.formOptions": "Poll Options",
"vote.formRatingHint": "Rating polls need no options (1-10).",
"vote.addOption": "Add option",
"vote.optionPlaceholder": "Option text",
"vote.formFields": "Form Fields",
"vote.addField": "Add field",
"vote.field": "Field",
"vote.formFieldType": "Field Type",
"vote.fieldTypeText": "Single-line text",
"vote.fieldTypeTextarea": "Multi-line text",
"vote.fieldTypeSelect": "Dropdown select",
"vote.fieldTypeRadio": "Single choice",
"vote.fieldTypeCheckbox": "Multiple choice",
"vote.fieldTypeNumber": "Number",
"vote.fieldTypeRank": "Ranking",
"vote.page.selectPlaceholder": "Select...",
"vote.page.avgNumber": "Average",
"vote.page.fieldFilled": "Filled",
"vote.fieldTypeRating": "Rating",
"vote.formFieldLabel": "Field Label",
"vote.formFieldRequired": "Required",
"vote.formFieldMaxLength": "Max length (0 = unlimited)",
"vote.formFieldOptions": "Options",
"vote.formFieldOptionsHint": "Click \"Add option\" to add each option; no comma needed",
"vote.fieldAllowCustom": "Allow participants to add a custom option",
"vote.formFieldOptionsPlaceholder": "e.g. Great,Okay,Poor",
"vote.createDraft": "Save draft",
"vote.builderHintOptions": "At least 2 options are required",
"vote.builderHintFields": "Add at least 1 field",
"vote.builderHintFieldOptions": "Choice/rank fields need options (rank needs at least 2)",
"vote.resultsAnswered": "Answered",
"vote.page.rankedTimes": "Ranked",
"vote.resultsTotal": "Total participants",
"vote.resultsSubmissions": "Form responses",
"vote.resultsViewTexts": "View text responses",
"vote.resultsToggleHint": "Click to expand or collapse text responses",
"vote.resultsOptions": "Poll results",
"vote.resultsVotes": "votes",
"vote.export": "Export CSV",
"vote.resultsEmpty": "No participation yet. Share the link!",
// Poll participation page
"vote.inlineTitle": "Polls",
"vote.page.by": "poll",
"vote.page.goVote": "Vote",
"vote.page.notStarted": "Not started",
"vote.page.notStartedHint": "This poll has not started yet.",
"vote.page.ended": "Ended",
"vote.page.endedHint": "This poll has ended.",
"vote.page.active": "Active",
"vote.page.participated": "Participated",
"vote.page.loginRequired": "This poll is limited to registered users. Please log in first.",
"vote.page.anonymousHint": "Anonymous participation is allowed and your identity is not recorded (max 1 per device).",
"vote.page.submit": "Submit vote",
"vote.page.alreadyVoted": "You have already participated in this poll. Thanks!",
"vote.page.resultsTitle": "Poll Results",
"vote.page.totalVotes": "Total",
"vote.page.avgRating": "Average",
"vote.page.avgRank": "Avg rank",
"vote.page.optionColumn": "Option",
"vote.page.ratingLabel": "Rate",
"vote.page.ratingUnit": "pts",
"vote.page.rankHint": "Rank each option 1-N (smaller = higher, no duplicates)",
"vote.page.rankPlaceholder": "Rank",
"vote.page.multipleHint": "Multiple choice",
"vote.page.customOption": "Custom option (optional)",
"vote.page.customPlaceholder": "Type your own option",
"vote.page.customOther": "Other",
"vote.page.customFieldPlaceholder": "Type a custom option...",
"vote.page.customStats": "Custom options",
"vote.page.formCount": "Responses received",
"vote.page.ownerManage": "Manage this poll (results visible only to you)",
// Per-poll quota collection
"vote.formQuotaLimit": "Collection quota (responses)",
"vote.formQuotaHint": "0 means unlimited; the poll ends automatically once the quota is reached.",
"vote.flash.notFound": "This poll does not exist or is no longer available",
"vote.flash.notPublished": "This poll has not been published yet",
"vote.flash.archived": "This poll has been archived and is closed",
"vote.flash.ended": "This poll has ended",
"vote.flash.notStarted": "This poll has not started yet",
"vote.flash.success": "Vote submitted. Thanks for participating!",
"vote.err.noChoice": "Please select at least one option",
"vote.err.tooManyChoices": "You can select at most %d options",
"vote.err.pickOne": "Please select an option",
"vote.err.invalidOption": "Invalid option",
"vote.err.optionDuplicate": "Options cannot be duplicated",
"vote.err.ratingRequired": "Please provide a rating from 1 to 10",
"vote.err.rankRequired": "Please rank every option from 1 to %d",
"vote.err.rankDuplicate": "Rankings must not repeat",
"vote.err.fieldRequired": "Please fill in \"%s\"",
"vote.err.fieldCustomRequired": "Please fill in the custom option for \"%s\"",
"vote.err.fieldCustomTooLong": "The custom option for \"%s\" must be at most %d characters",
"vote.err.fieldInvalidOption": "Invalid option for \"%s\"",
"vote.err.fieldConfigError": "Misconfigured options for \"%s\"",
"vote.err.fieldRankIncomplete": "Please provide a complete ranking for \"%s\"",
"vote.err.fieldRankRange": "The ranking for \"%s\" must be a number between 1 and %d",
"vote.err.fieldRankDuplicate": "Rankings for \"%s\" must not repeat",
"vote.err.fieldTooLong": "\"%s\" exceeds the length limit (max %d characters)",
"vote.err.fieldNotNumber": "\"%s\" must be a number",
"vote.err.fieldRating": "\"%s\" requires a rating from 1 to 10",
"vote.err.unknownType": "Unknown poll type",
"vote.err.customTooLong": "The custom option must be at most %d characters",
"vote.err.blockWords": "Your submission contains a blocked word set by the poll creator and could not be submitted",
"vote.err.serialize": "Failed to serialize the submission",
"captcha.error": "Captcha verification failed",
"captcha.errEmpty": "Captcha cannot be empty",
"captcha.errFormat": "Invalid captcha format, please refresh and try again",
"captcha.errAnswerFormat": "Invalid captcha answer format",
"captcha.errExpired": "Captcha expired, please refresh and try again",
"captcha.errService": "Captcha service error, please try again later",
"captcha.errAnswerWrong": "Incorrect captcha answer",
"censor.unavailable": "The content moderation service is temporarily unavailable. Please try again later.",
// Feature management: switch / schedule / quota for questions, chats and polls
"admin.tabAccount": "Settings",
"admin.featuresHint": "Manage the switch, opening hours and collection quota of each feature on the Questions / Chats / Polls panels. A feature closes automatically once its quota is reached.",
"admin.featuresEntry": "Settings",
"admin.featureHint.question": "Visitors cannot submit new questions while closed; collection stops automatically once the quota is reached (private questions included).",
"admin.featureHint.chat": "Visitors cannot start new chats while closed; existing conversations are unaffected.",
"admin.featureHint.vote": "Your polls are hidden from your box page and cannot be answered while closed. Each poll can also define its own quota on the edit page.",
"admin.featureEnabled": "Enable this feature",
"admin.featureOpenAt": "Open at",
"admin.featureCloseAt": "Close at",
"admin.featureTimeHint": "Leave blank for no limit; the feature is unavailable before the open time and after the close time.",
"admin.featureSchedule": "Recurring schedule (weekly)",
"admin.featureScheduleStart": "Daily start",
"admin.featureScheduleEnd": "Daily end",
"admin.featureScheduleHint": "Select the weekdays and a daily time window to open every week. Leave all empty to disable. Days without a time window open all day; a time window with no weekday selected opens every day.",
"admin.featureMode": "Schedule mode",
"admin.featureModeAlways": "Always open",
"admin.featureModeWindow": "One-time window",
"admin.featureModeRecurring": "Weekly schedule",
"admin.featureModeHint": "Always open: no time limit until manually disabled. One-time window: a single open/close time window. Weekly schedule: recurring weekly open days and hours. Modes are mutually exclusive; settings of unselected modes are cleared on save.",
"admin.featureQuotaLimit": "Collection quota (items)",
"admin.featureQuotaHint": "0 means unlimited; the feature closes automatically once the quota is reached. Reset the counter to start a new round.",
"admin.featureQuotaCount": "Collected",
"admin.featureQuotaUnlimited": "Unlimited",
"admin.featureQuotaResetAt": "Last reset: ",
"admin.featureResetQuota": "Reset counter",
"admin.featureSave": "Save this feature",
// Feature management: states and messages
"feature.name.question": "Questions",
"feature.name.chat": "Chats",
"feature.name.vote": "Polls",
"feature.statusOpen": "Open",
"feature.status.disabled": "Closed",
"feature.status.not_started": "Not started",
"feature.status.ended": "Ended",
"feature.status.quota_reached": "Quota reached",
"feature.saved": "Feature settings saved",
"feature.errTime": "Invalid date for %s, please use the date-time picker",
"feature.errTimeRange": "The close time of %s must be later than the open time",
"feature.errQuota": "The collection quota of %s must be an integer between 0 and 1000000",
"feature.errInvalid": "Unknown feature",
"feature.quotaReset": "The collection counter of %s has been reset",
"feature.errScheduleHalf": "The recurring start and end times of %s must be filled in together",
"feature.errScheduleTime": "Invalid recurring time for %s, please use the time picker",
"feature.errScheduleRange": "The recurring end time of %s must be later than the start time",
// Messages shown to visitors when a feature is closed
"feature.disabled.question": "The box owner has closed questions",
"feature.disabled.chat": "This box does not accept chats",
"feature.disabled.vote": "This box has closed polls",
"feature.not_started.question": "Questions are not open yet",
"feature.not_started.chat": "Chats are not open yet",
"feature.not_started.vote": "Polls are not open yet",
"feature.ended.question": "Question collection has ended",
"feature.ended.chat": "Chats have ended",
"feature.ended.vote": "Polls have ended",
"feature.quota_reached.question": "The question quota has been reached",
"feature.quota_reached.chat": "The chat quota has been reached",
"feature.quota_reached.vote": "The poll quota has been reached",
// Auth: visitor-visible messages (P0)
"auth.errBadCredential": "Incorrect email or password",
"auth.errUserNotFound": "User does not exist",
"auth.errEmailFrequent": "Emails are sent too frequently, please try again later",
"auth.errEmailFailed": "Failed to send email, please try again later",
"auth.errCodeExpired": "The verification code has expired",
"auth.errPasswordChanged": "Password changed successfully",
"auth.errLoginRequired": "Please log in first",
"auth.errDuplicateEmail": "This email is already registered",
"auth.errDuplicateDomain": "This custom domain is already taken",
// Question box page: visitor-visible (P0)
"question.errWrongPassword": "Wrong password",
"question.errAskPasswordWrong": "Wrong ask password",
"question.errLoginRequired": "The owner only allows registered users to ask questions. Please log in first.",
"question.errSelfChat": "You cannot start a chat with yourself",
"question.errBlockedChat": "You have been blocked by the other party and cannot start a chat",
"question.errChatDailyLimit": "You have reached today's message limit (%d). Please try again tomorrow",
"question.errDuplicateSubmit": "Duplicate submission detected, please try again later",
"question.errImageTooLarge": "Image file size cannot exceed 5Mb",
"question.errUploadImageFailed": "Failed to upload image",
"question.errBlockWords": "The question contains a blocked word set by the box owner, and could not be sent",
"question.errAnsweredLocked": "An answered question cannot be marked as unread",
"question.flash.chatSent": "Chat request sent! You can view the conversation via this link: %s",
"question.flash.answered": "Answer published successfully!",
"question.flash.operated": "Operation successful",
"question.flash.markedRead": "Marked as read",
"question.flash.markedUnread": "Marked as unread",
// Follow-ups: visitor/owner visible (P0)
"followup.errNoPermission": "Permission denied",
"followup.errNotAnswered": "This question has not been answered yet, so you cannot follow up",
"followup.errLimit": "Each question allows at most %d follow-ups, and the limit has been reached",
"followup.errWaitReply": "Please wait for the owner to reply to your previous follow-up before continuing",
"followup.errNotFound": "The follow-up does not exist",
"followup.flash.sent": "Follow-up sent successfully!",
"followup.flash.answered": "Answer posted successfully!",
// Admin settings: owner visible (P1)
"admin.flash.profileUpdated": "Profile updated successfully",
"admin.flash.chatUpdated": "Chat settings updated successfully",
"admin.flash.pagePwdOff": "Page password access disabled",
"admin.flash.pagePwdUpdated": "Page access password updated",
"admin.flash.askPwdOff": "Ask password disabled",
"admin.flash.askPwdUpdated": "Ask password updated",
"admin.flash.tagsTooMany": "You can have at most 5 tags",
"admin.flash.tagsUpdated": "Tags updated successfully",
"admin.flash.tagTooLong": "Each tag cannot exceed 5 characters: %s",
"admin.flash.blockWordsTooMany": "You can have at most 10 blocked words",
"admin.flash.blockWordTooLong": "A blocked word cannot exceed 10 characters: %s",
"admin.flash.harassmentUpdated": "Anti-harassment settings updated successfully",
"admin.flash.blockWordsUpdated": "Blocked words updated",
"admin.flash.deactivated": "Your account has been deactivated. Thank you for using Wolf's Question Box. We hope to see you again 👋🏻",
"admin.export.errUser": "Export failed: could not get user information",
"admin.export.errQuestion": "Export failed: could not get question information",
// Vote management: owner visible (P1)
"vote.err.invalidID": "Invalid vote ID",
"vote.err.notExist": "The vote does not exist",
"vote.err.noPermission": "You are not allowed to operate this vote",
"vote.err.type": "Unknown poll type",
"vote.err.titleRequired": "Please fill in the poll title",
"vote.err.titleTooLong": "The poll title cannot exceed 100 characters",
"vote.err.visibility": "Unknown visibility setting",
"vote.err.voterScope": "Unknown voting scope setting",
"vote.err.showResult": "Unknown result visibility setting",
"vote.err.timeRange": "The end time must be later than the start time",
"vote.err.quota": "The collection quota must be an integer between 0 and 1000000",
"vote.err.optionsTooFew": "Please add at least 2 options",
"vote.err.optionsFormat": "Invalid options data format",
"vote.err.optionTooLong": "A single option cannot exceed %d characters",
"vote.err.maxChoicesExceed": "The max choices cannot exceed the total number of options",
"vote.err.fieldsTooFew": "Please add at least 1 form field",
"vote.err.fieldsFormat": "Invalid form fields data format",
"vote.err.fieldsTooMany": "You can have at most 50 form fields",
"vote.err.fieldType": "Unknown form field type",
"vote.err.fieldLabelMissing": "Field #%d is missing a label",
"vote.err.fieldLabelTooLong": "Field #%d label cannot exceed 100 characters",
"vote.err.fieldLabelDuplicate": "The field label \"%s\" is duplicated, please use a different label",
"vote.err.fieldMaxLength": "Field #%d max length must be between 0 and 2000",
"vote.err.fieldReservedOption": "Field #%d (%s) contains a reserved option value, please change it",
"vote.err.fieldOptionsTooFew": "Field #%d (%s) needs at least 1 option",
"vote.err.fieldRankTooFew": "Field #%d (%s) ranking questions need at least 2 options",
"vote.err.fieldSerialize": "Failed to serialize field options",
"vote.err.editClosed": "Votes that are ended or archived cannot be edited",
"vote.err.publishDraftOnly": "Only draft polls can be published",
"vote.err.closePublishedOnly": "Only active polls can be closed",
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
"vote.err.publishOptionsRequired": "Choice polls need at least 2 options",
"vote.err.optionUpdateFailed": "Failed to update options, please retry",
"vote.err.fieldUpdateFailed": "Failed to update form fields, please retry",
"vote.flash.published": "Poll published! Share link: %s",
"vote.flash.updated": "Poll info updated (active polls cannot change type or options)",
"vote.flash.draftSaved": "Saved to draft",
"vote.flash.closed": "Poll ended",
"vote.flash.deleted": "Poll deleted",
"vote.err.timeFormat": "Invalid time format, please use the datetime picker",
"vote.err.maxChoicesLimit": "Maximum number of choices cannot exceed 20",
"vote.err.descriptionTooLong": "Vote description cannot exceed 5000 characters",
"profile.err.avatarTooLarge": "Avatar file is too large, maximum 2MB",
"profile.err.backgroundTooLarge": "Background image is too large, maximum 2MB",
"profile.err.newPasswordTooShort": "New password must be at least 8 characters",
"profile.err.badOldPassword": "Incorrect old password",
"admin.export.errCreateExcel": "Export failed: could not create Excel file",
"admin.export.errWriteExcel": "Export failed: could not write Excel file",
// Profile page UI rework: card sections / avatar & background cropping / force re-login after password change
"profile.card.basic": "Basic info",
"profile.card.avatarBg": "Avatar & background",
"profile.card.qr": "QR code style",
"profile.card.password": "Change password",
"profile.avatarCropHint": "Crop frame is locked to 1:1 (square)",
"profile.backgroundCropHint": "Crop frame is locked to 16:9",
"profile.cropTitle": "Crop image",
"profile.cropConfirm": "Apply crop",
"profile.err.avatarRatio": "Avatar image must be a 1:1 square. Please crop it and upload again.",
"profile.err.backgroundRatio": "Background image must have a 16:9 aspect ratio. Please crop it and upload again.",
"profile.flash.passwordChanged": "Password changed. You have been signed out; please sign in again with your new password.",
"profile.pwdNote": "After saving, your current session will end immediately and you will need to sign in again with the new password.",
"profile.pickAvatar": "Change avatar",
"profile.pickBackground": "Change background",
"profile.saveBasic": "Save basic info",
"profile.saveMedia": "Save avatar & background",
"profile.savePassword": "Update password",
"profile.saveQR": "Save QR code style",
"profile.saveSocial": "Save social links",
"profile.err.cropLibUnavailable": "The image cropper failed to load. Please refresh the page and try again.",
// Captcha JSON endpoint: consumed by frontend (P2)
"captcha.json.badRequest": "Invalid request format",
"captcha.json.idRequired": "Captcha ID cannot be empty",
"captcha.json.genFailed": "Failed to generate captcha",
"chat.errDeletedByOwner": "This private conversation has been deleted by the other party.",
"chat.errBlockedView": "You have been blocked by the other party and cannot view this chat",
"chat.errEmpty": "Message cannot be empty",
"chat.errTooLong": "Message is too long, please keep it within %d characters",
"chat.errConvDeletedSend": "This conversation has been deleted and messages cannot be sent",
"chat.errBlockedSend": "You have been blocked by the other party and cannot send messages",
"chat.errBlockWords": "Your message contains a blocked word set by the box owner and could not be sent",
"chat.errDailyLimit": "You have reached today's message limit (%d), please try again tomorrow",
"chat.errLimitReached": "The conversation has reached its limit and no new messages can be sent",
"chat.errChatClosed": "The box owner has disabled private chat and messages cannot be sent",
"chat.errImageTooLarge": "Image size cannot exceed %dMB",
"chat.errUploadFailed": "Image upload failed, please try again later",
"chat.errNoPermission": "You are not authorized to perform this action",
"chat.flash.sent": "Message sent successfully",
"chat.flash.deleted": "Private conversation deleted",
"chat.flash.operated": "Operation successful",
"image.errTooLarge": "Image is too large, maximum supported size is %d×%d pixels, please compress and re-upload",
"internal.errFlash": "Internal server error, please try again later.",
"internal.errServer": "Internal server error",
"home.errStats": "Failed to retrieve statistics",
"pixel.errPath": "Invalid request path",
"proxy.errNotFound": "Page not found",
"proxy.errInternal": "Service gateway internal error",
"explore.errRandomUser": "Failed to retrieve random users",
// 表单校验
"form.answer.content": "Answer content is required",
"form.answer.contentTooLong": "Answer content must be within %d characters",
"form.email.required": "Please enter a valid email address",
"form.followup.answer": "Follow-up answer is required",
"form.followup.answerTooLong": "Follow-up answer must be within %d characters",
"form.followup.content": "Follow-up content is required",
"form.followup.contentTooLong": "Follow-up content must be within %d characters",
"form.login.password": "Please enter your password",
"form.profile.backgroundimagealpha": "Please enter the QR code background opacity",
"form.profile.colorHex": "Please enter a 7-character hex color code (including #)",
"form.profile.dotscale": "Please enter the QR code dot size",
"form.profile.intro": "Bio is required and must be at most 100 characters",
"form.profile.linkMax": "Profile link must be at most 30 characters",
"form.profile.name": "Nickname is required and must be at most 20 characters",
"form.profile.newPassword": "New password must be at most 30 characters",
"form.profile.passwordMax": "Password must be at most 30 characters",
"form.question.content": "Question content is required",
"form.question.contentTooLong": "Question content must be within %d characters",
"form.question.receiveReplyEmail": "Please enter a valid email address to receive replies",
"form.recaptcha": "Please complete the CAPTCHA verification",
"form.recover.newPassword": "Please set a new password of 8–30 characters",
"form.recover.repeatPassword": "The two new passwords do not match",
"form.register.domain": "The custom domain may only contain letters, digits and underscores, and must be 3–20 characters long",
"form.register.email": "Please enter a valid email address (max 100 characters)",
"form.register.name": "Nickname is required and must be at most 20 characters",
"form.register.password": "Please set a password of 8–30 characters",
"form.register.recaptcha": "Unable to load the CAPTCHA. Please refresh the page or switch your network and try again",
"form.register.repeatPassword": "The two passwords do not match",
}
+840
View File
@@ -0,0 +1,840 @@
// 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 i18n
// jaDict 日本語文案。
var jaDict = map[string]string{
// ナビゲーション
"nav.explore": "探索",
"nav.mine": "マイ",
"nav.register": "登録",
"nav.login": "ログイン",
"nav.ask": "質問",
"nav.settings": "設定",
"nav.lang": "言語",
// 共通
"common.login": "ログイン",
"common.forgot": "パスワードを忘れた",
"common.submit": "送信",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.close": "閉じる",
"common.delete": "削除",
"common.moveUp": "上へ移動",
"common.moveDown": "下へ移動",
"common.share": "共有",
"common.verifying": "認証中...",
"common.submitting": "送信中...",
"common.uploading": "アップロード中...",
"common.lang": "言語",
"common.menu": "メニュー",
"common.pleaseWait": "お待ちください...",
"common.backHome": "ホームに戻る",
"common.theme": "ダーク/ライトモード切替",
// サイト
"site.title": "狼の質問箱 | 匿名質問箱",
"site.description": "狼の質問箱は登録不要で使える匿名質問箱。誰でも匿名で質問でき、安心してやり取りできる場所です。",
"site.keywords": "匿名質問箱,質問箱,匿名質問,狼の質問箱,匿名で質問,匿名Q&A",
"site.footer.runtime": "質問箱は稼働中",
"site.footer.admin": "管理者へ",
"site.footer.notice": "お知らせ",
"site.footer.chat": "チャット",
"site.footer.drive": "ドライブ",
"site.footer.status": "ステータス",
"site.footer.nav": "リンク",
"site.maintenance": "メンテナンス中です。しばらくお待ちください~",
// 認証
"auth.login.title": "ログイン",
"auth.login.email": "メールアドレス",
"auth.login.password": "パスワード",
"auth.login.submit": "ログイン",
"auth.login.forgot": "パスワードを忘れた",
"auth.register.title": "ユーザー登録",
"auth.register.name": "ニックネーム",
"auth.register.email": "メールアドレス",
"auth.register.password": "パスワード",
"auth.register.submit": "登録",
"auth.forgot.title": "パスワードを忘れた",
"auth.forgot.email": "メールアドレス",
"auth.forgot.submit": "再設定メールを送信",
"auth.forgot.submitBtn": "パスワードを再取得",
"auth.forgot.sent": "%s にメールを送信しました。ご確認ください。",
"auth.recover.title": "パスワード再設定",
"auth.recover.greeting": "%s さん、パスワードを再設定しています:",
"auth.recover.password": "新しいパスワード",
"auth.recover.confirm": "パスワード(確認)",
"auth.recover.submit": "パスワードを再設定",
// 探索ページ
"explore.title": "ユーザータグを探索",
"explore.notice": "ここでは<span class=\"uk-text-bold\">「ホームでタグを表示」</span>を有効にしたユーザーを紹介しています。この機能は初期状態でオフです。自分の質問箱を見つけてもらいたい場合は <a href=\"/user/profile\" class=\"uk-text-bold\">個人設定</a> から有効にしてください。",
"explore.shuffle": "シャッフル",
"explore.empty": "表示できるユーザーがいません。有効にしてみましょう~",
"explore.hotVotes": "人気の投票",
"explore.questionsTab": "質問",
"explore.votesTab": "投票",
"explore.votesTitle": "公開投票",
"explore.votesDesc": "開催中の公開投票を見て、興味のあるテーマに参加できます。",
"explore.randomVote": "ランダムおすすめ",
"explore.allVotes": "すべての投票",
"explore.noVotes": "現在、参加できる公開投票はありません。",
// 質問ページ
"ask.scan": "QRコードで@%sに質問",
"ask.anonymous": "誰でも匿名で質問できます",
"ask.registerOnly": "登録ユーザーのみ質問できます",
"ask.nextOpenAt": "次回開放:%s",
"ask.lastCloseAt": "前回終了:%s",
"weekday.0": "日",
"weekday.1": "月",
"weekday.2": "火",
"weekday.3": "水",
"weekday.4": "木",
"weekday.5": "金",
"weekday.6": "土",
"ask.pinned": "ピン留め済み",
"ask.share": "質問箱を共有",
"ask.placeholder": "ここに質問を書いてください...",
"ask.chatPlaceholder": "ここにチャットメッセージを書いてください...",
"ask.addImage": "画像を追加",
"ask.private": "返信後に公開しない",
"ask.notify": "通知を受け取る",
"ask.emailPlaceholder": "メールアドレスを入力してください",
"ask.submit": "質問を送信",
"ask.chat": "チャットを開始",
"ask.chatDisabled": "この質問箱ではチャットが無効です",
"ask.chatLimit": "メッセージ上限:%d件",
"ask.history": "@%s が以前回答した質問",
"ask.loginRequired": "ログインが必要",
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
"ask.goLogin": "ログインへ",
"ask.needPassword": "この質問箱にはパスワードが必要です",
"ask.needLogin": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"ask.pleaseEmail": "通知を受け取るメールアドレスを入力してください",
"ask.generating": "共有カードを生成中…",
"ask.genFailed": "共有カードの生成に失敗しました。しばらくしてからお試しください",
"ask.networkError": "ネットワークエラー",
"ask.shareFilename": "share.png",
// 質問アイテム
"qitem.answer": "回答",
"qitem.delete": "削除",
"qitem.private": "非公開にする",
"qitem.public": "公開にする",
"qitem.asked": "質問",
"qitem.pending": "回答待ち",
"qitem.answeredBy": "@%s による %s の回答",
"qitem.danger": "危険!",
"qitem.deleteConfirm": "この質問を削除してもよろしいですか?この操作は取り消せません。",
"qitem.deleteConfirmBtn": "削除する",
"qitem.backToBox": "%sの質問箱に戻る",
"qitem.answerTitle": "質問に回答する",
"qitem.answerPlaceholder": "ここに回答を入力...",
"qitem.emailNotifyHint": "質問者はメールアドレスを残しています。最初に回答すると、質問者にメール通知が送信されます。",
"qitem.submitAnswer": "回答",
"qitem.updateAnswer": "回答を更新",
"qitem.askMore": "ほかの質問は?",
"qitem.markRead": "既読にする",
"qitem.markUnread": "未読にする",
"qitem.readLocked": "回答済みのため既読に固定",
// 個人設定ページ
"admin.profile": "プロフィール",
"admin.lang": "表示言語",
"admin.langHint": "訪問者があなたの質問箱を開いたときに表示する言語を選択します(管理画面もこの設定に従います)",
"admin.email": "メールアドレス",
"admin.name": "ニックネーム",
"admin.nameColor": "ニックネームの色",
"admin.oldPassword": "現在のパスワード",
"admin.newPassword": "新しいパスワード",
"admin.notify": "新着質問の通知",
"admin.personalize": "カスタマイズ",
"admin.intro": "質問箱の紹介文",
"admin.introColor": "紹介文の色",
"admin.qrBgColor": "QRコードの背景色(明るい部分)",
"admin.qrPdColor": "QRコードのファインダーパターン色(角の四角)",
"admin.qrFgColor": "QRコードの前景色",
"admin.qrDotSize": "QRコードのドットサイズ (←小 大→)",
"admin.qrBgAlpha": "QRコードの背景透明度 (←不透明 透明→)",
"admin.avatar": "アバター",
"admin.background": "質問箱の背景",
"admin.qrBackground": "QRコードの背景画像",
"admin.social": "ソーシャルリンク",
"admin.harassment": "迷惑行為対策",
"admin.askLimit": "質問制限",
"admin.blockWords": "ブロックワード設定(Enter またはカンマ区切り、最大10個、各10文字まで)",
"admin.chat": "チャット設定",
"admin.chatMaxMessages": "チャットのメッセージ上限",
"admin.chatDailyLimit": "1ユーザーあたりの1日のメッセージ上限",
"admin.tags": "タグ設定",
"admin.tagsHint": "タグ(最大5個、各5文字まで、Enter またはカンマ区切り)",
"admin.tagsPlaceholder": "例:アニメ,音楽,ゲーム",
"admin.tagInputHint": "入力して Enter またはカンマで追加",
"admin.tagInputDuplicate": "既に存在するため無視しました",
"admin.pagePassword": "ページアクセスパスワード",
"admin.pagePasswordHint": "アクセスパスワードを設定(空欄で無効)",
"admin.askPassword": "質問パスワード",
"admin.askPasswordHint": "質問用パスワードを設定(空欄で無効)",
"admin.saveProfile": "変更を保存",
"admin.updated": "プロフィールを更新しました",
"admin.logout": "ログアウト",
"admin.export": "データをエクスポート",
// 管理画面設定(追加)
"admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません",
"admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません",
"admin.notifyEmail": "メール",
"admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)",
"admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます",
"admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)",
"admin.backgroundPlaceholder": "質問箱の背景をアップロード",
"admin.qrBackgroundPlaceholder": "QRコードの背景画像をアップロード(暗すぎる背景はQRコードが読み取れなくなることがあります)",
"admin.qrPreviewHint": "画像を選択するとプレビューが即時更新されます",
"admin.preview": "ライブプレビュー",
"admin.themePreset": "プリセットテーマ",
"admin.themeDefault": "デフォルト",
"admin.themeDark": "ダーク",
"admin.themeOcean": "オーシャン",
"admin.themeSunset": "サンセット",
"admin.resetDefaults": "🔄 デフォルトに戻す",
"admin.previewHint": "調整はプレビューのみです。「変更を保存」をクリックすると反映されます",
"admin.socialHint": "チェックを入れてプロフィールURLを入力してください(https:// は不要)。チェックのない項目は表示されません。",
"admin.enableAcfun": "AcFun を有効にする",
"admin.enableBili": "Bilibili を有効にする",
"admin.enableWeibo": "Weibo を有効にする",
"admin.enableXiaohongshu": "小紅書 を有効にする",
"admin.enableBlog": "個人ブログを有効にする",
"admin.registerOnly": "登録ユーザーのみ質問を許可",
"admin.updateHarassment": "迷惑行為対策を更新",
"admin.globalBlockWords": "共通ブロックワード",
"admin.globalBlockWordsHint": "ブロックワードは質問・チャット(相手からあなたへのメッセージ)・投票の投稿内容に適用されます。この設定は質問/チャット/投票の3つのタブで共有され、どこで変更してもすぐに反映されます。",
"admin.updateBlockWords": "ブロックワードを保存",
"admin.allowChat": "登録ユーザーからのチャットを許可",
"admin.dailyLimitHint": "0 は無制限",
"admin.updateChat": "チャット設定を更新",
"admin.showInHome": "ホームページでタグを表示(タグで検索されることを許可)",
"admin.updateTags": "タグを更新",
"admin.updatePagePassword": "ページパスワードを更新",
"admin.pagePasswordPlaceholder": "ページアクセスパスワードを入力",
"admin.updateAskPassword": "質問パスワードを更新",
"admin.askPasswordPlaceholder": "質問パスワードを入力",
"admin.account": "アカウント設定",
"admin.exportHint": "%s のすべての個人データ(基本情報、受け取った質問と回答を含む)をエクスポートできます。",
"admin.exportJSON": "全データをエクスポート(JSON)",
"admin.exportJSONHint": "質問・回答・追加質問・チャットを含む完全バックアップ。",
"admin.deactivate": "アカウントを停止",
"admin.deactivateHint": "いつでもアカウントを停止できます。停止後はログインできなくなり、質問箱のページや質問にもアクセスできなくなります。新しい質問も受け付けられません。<b>この操作は取り消せません。ご注意ください!</b>",
"admin.socialRequired": "リンクを入力するか、チェックを外してください",
// 質問管理ページ
"qlist.title": "マイ質問",
"qlist.answered": "回答済み",
"qlist.pending": "回答待ち",
"qlist.private": "非公開",
"qlist.empty": "質問はまだありません。質問箱を共有しましょう~",
"qlist.action": "操作",
"qlist.answerNow": "回答する",
"qlist.sort": "並び替え",
"qlist.status": "状態",
"qlist.type": "種類",
"qlist.latest": "最新",
"qlist.oldest": "最古",
"qlist.all": "すべて",
"qlist.public": "通常の質問",
"qlist.privateQuestion": "非公開の質問",
"qlist.received": "受け取った質問",
"qlist.myChats": "開始したチャット",
"qlist.statsTotal": "質問総数",
"qlist.statsAnswered": "回答済み",
"qlist.statsUnanswered": "未回答",
"qlist.statsPrivate": "非公開質問",
"qlist.statsPinned": "ピン留め",
"qlist.statsChats": "チャット",
"qlist.searchPlaceholder": "質問や回答を検索...",
"qlist.searchBtn": "検索",
"qlist.searchClear": "クリア",
"qlist.questionsTab": "質問",
"qlist.votesTab": "投票",
"qlist.chatsTab": "チャット",
"qlist.receivedChats": "受け取ったチャット",
"qlist.chatEmpty": "まだチャットがありません。質問箱を共有してチャットを始めましょう!",
"qlist.pinnedShort": "ピン",
"qlist.privateShort": "非公開",
"qlist.chatStatsTotal": "チャット総数",
"qlist.chatStatsReceived": "受け取った",
"qlist.chatStatsInitiated": "開始した",
"qlist.chatStatsClosed": "終了",
"qlist.unansweredShort": "未答",
"qlist.readShort": "既読",
"qlist.unreadShort": "未読",
"qitem.markReadShort": "既読に",
"qlist.pinShort": "ピン",
"qlist.unpinShort": "解除",
"qlist.myVotes": "作成した投票",
"qlist.loadMore": "もっと読み込む",
"qlist.newVote": "投票を作成",
"qlist.edit": "編集",
"qlist.untitledVote": "無題の投票",
"qlist.blocked": "ブロック済み",
"qlist.unanswered": "未回答",
// サイトロゴの接尾辞
"site.logoSuffix": "の質問箱",
"site.stats": "質問箱の統計",
// キャプチャモーダル
"captcha.title": "人機認証",
"captcha.hint": "以下の計算問題を解いて、ロボットでないことを証明してください",
"captcha.refresh": "別の問題",
"captcha.submit": "認証して送信",
"captcha.loading": "読み込み中...",
"captcha.loadFailed": "読み込み失敗",
"captcha.loadRetry": "読み込み失敗、再試行してください",
"captcha.inputAnswer": "答えを入力してください",
"captcha.wrongAnswer": "答えが違います。もう一度計算してください",
"captcha.verifyFailed": "認証失敗、再試行してください",
// 登録ページ補足
"auth.register.domain.prefix": "カスタムドメイン(あなたの質問箱URLは %s + ",
"auth.register.domain.suffix": " + 下記に入力した内容になります)",
"auth.register.confirm": "パスワード(確認)",
"auth.register.language": "表示言語",
"auth.register.languageDefault": "サイト既定に従う",
"auth.register.success": "登録が完了しました。狼の質問箱へようこそ!",
// 探索ページ補足
"explore.recommend": "ランダムおすすめ",
"explore.search": "タグを検索...",
"explore.sortLatest": "新着順",
"explore.sortOldest": "古い順",
"explore.noTags": "タグなし",
"explore.noRecommend": "おすすめユーザーはいません",
"explore.hotTags": "人気タグ",
"explore.hotQuestions": "人気質問",
"explore.noResult": "条件に一致するユーザーはいません。",
"explore.lazy": "このユーザーは何も書いていません~",
"explore.prevPage": "前へ",
"explore.nextPage": "次へ",
"explore.loading": "読み込み中...",
"explore.loadFailed": "読み込み失敗",
// 質問箱パスワードページ
"password.title": "この質問箱はパスワード保護されています",
"password.desc": "この質問箱はパスワード保護されています。パスワードを入力して内容を表示してください",
"password.input": "アクセスパスワードを入力してください",
"password.enter": "認証して入る",
"password.submit": "確認",
// 質問が見つかりません
"qitem.notFound": "質問が見つからないか、削除されました",
// チャットページ
"chat.title": "チャット",
"chat.input": "メッセージを入力...",
"chat.send": "送信",
"chat.delete": "会話を削除",
"chat.block": "相手をブロック",
"chat.unblock": "ブロック解除",
"chat.empty": "メッセージはまだありません。何か書いてみましょう~",
"chat.withAsker": "質問者とのチャット",
"chat.withUser": "%s とのチャット",
"chat.closed": "会話は終了しました",
"chat.closedNoSend": "会話は終了したため、新しいメッセージを送信できません",
"chat.remaining": "残りメッセージ:%d 件(全 %d 件)",
"chat.me": "自分",
"chat.other": "相手",
"chat.userBlocked": "このユーザーはブロックされています",
"chat.addImage": "画像を追加(任意、最大5MB)",
"chat.confirm": "確認",
"chat.confirmBlock": "このユーザーをブロックしますか?ブロック後はメッセージを送信できなくなります。",
"chat.confirmUnblock": "このユーザーのブロックを解除しますか?再度メッセージを送信できるようになります。",
"chat.deleteConfirm": "このチャットを削除しますか?相手はこの会話を見られなくなり、この操作は取り消せません。",
"maintenance.title": "NekoBox メンテナンス中...",
"maintenance.desc": "NekoBox はメンテナンス中のため、一時的にご利用いただけません。また後でお越しください~",
// 質問の個別シェア(v4)
"qitem.shareCard": "この質問をシェア",
// 追加質問(v4)
"followup.title": "追加質問",
"followup.content": "追加質問:",
"followup.answerPlaceholder": "この追加質問に回答...",
"followup.answerBtn": "回答する",
"followup.waiting": "回答待ち...",
"followup.askMore": "追加で質問する",
"followup.remaining": "追加質問可能: %d / %d 回",
"followup.contentPlaceholder": "質問を補足...",
"followup.submit": "送信",
"followup.waitingReply": "追記を送信しました。ボックス所有者の返信をお待ちください...",
// ピン留め(v4)
"qlist.pinned": "ピン留め中",
"qlist.pin": "ピン留め",
"qlist.unpin": "ピン留め解除",
"qlist.readStatus": "既読ステータス",
"qlist.read": "既読",
"qlist.unread": "未読",
"qlist.statsRead": "既読",
"qlist.statsUnread": "未読",
// 質問送信完了ページ(v4)
"success.title": "質問を送信しました!",
"success.yourQuestion": "あなたの質問:",
"success.emailSent": "この質問のプライベートリンクをメールで送信しました。以下のリンクからいつでも質問の確認、回答の受信、追加質問ができます。",
"success.saveLink": "<strong>以下のプライベートリンクを必ず保存してください!</strong>メールアドレスを入力しなかったため、このリンク(トークンを含む)が質問の確認・回答の受信・追加質問のための<strong>唯一の証拠</strong>です。失くすと復元できません。",
"success.privateLink": "あなたのプライベートリンク(クリックしてコピー)",
"success.copy": "コピー",
"success.copied": "コピーしました ✓",
"success.copyFailed": "コピーに失敗",
"success.token": "トークン: %s(質問者はこのトークンでプライベートリンクにアクセス)",
"success.viewQuestion": "質問を確認する",
"success.backToBox": "%sの質問箱に戻る",
"success.pageTitle": "質問送信完了 - %s",
// アカウント停止ページ
"deactivate.title": "アカウント停止",
"deactivate.desc": "停止後、あなたの質問箱にはアクセスできなくなり、すべてのデータが削除されます。この操作は取り消せません!",
"deactivate.confirm": "アカウントを停止することに同意します",
"deactivate.submit": "アカウントを停止",
// 匿名投票
"nav.votes": "投票",
"common.back": "戻る",
"vote.listTitle": "投票管理",
"vote.newTitle": "投票を作成",
"vote.editTitle": "投票を編集",
"vote.resultsTitle": "投票結果",
"vote.statsTotal": "投票総数",
"vote.statsPublished": "進行中",
"vote.createNew": "投票を作成",
"vote.typeSingle": "単一選択",
"vote.typeMultiple": "複数選択",
"vote.typeRating": "評価",
"vote.typeRank": "順位付け",
"vote.typeForm": "フォーム",
"vote.statusNotStarted": "未開始",
"vote.statusDraft": "下書き",
"vote.statusPublished": "進行中",
"vote.statusEnded": "終了",
"vote.statusArchived": "アーカイブ",
"vote.participants": "参加者",
"vote.remainLabel": "残り",
"vote.perpetual": "期限なし",
"vote.durationDay": "日",
"vote.durationHour": "時間",
"vote.durationMinute": "分",
"vote.createdAt": "作成日時",
"vote.edit": "編集",
"vote.results": "結果",
"vote.preview": "プレビュー",
"vote.page.mustComplete": "入力してください",
"vote.page.rankHintRequired": "順位 1-N(必須)",
"vote.page.rankHintOptional": "順位 1-N(任意、一部のみ可)",
"vote.page.previewHint": "プレビューモード:公開前のサンプルです。送信は無効です",
"vote.page.previewSubmitHint": "プレビューモード。公開後に送信できます",
"vote.publish": "公開",
"vote.close": "終了",
"vote.share": "リンクをコピー",
"vote.copied": "コピーしました",
"vote.shareURL": "投票の共有リンク",
"vote.confirmPublish": "この投票を公開しますか?公開後、共有リンクが生成されます。",
"vote.confirmClose": "この投票を終了しますか?終了後、参加者は投票できなくなります。",
"vote.confirmDelete": "この投票を削除しますか?投票データもすべて削除され、元に戻せません!",
"vote.manageEntry": "投票を管理",
"vote.manageTitle": "投票管理",
"vote.manageDesc": "匿名投票の作成と管理", "vote.empty": "投票はまだありません。右上の\"投票を作成\"から始めましょう。",
"vote.formLockedHint": "この投票は公開中です。基本情報のみ編集可能です(収集済みデータを守るため、タイプと選択肢はロックされています)",
"vote.formBasic": "基本設定",
"vote.formTitle": "投票タイトル",
"vote.formTitlePlaceholder": "例:今日の昼ごはんは?",
"vote.formDescription": "説明",
"vote.formDescriptionPlaceholder": "補足説明(任意)",
"vote.formType": "投票タイプ",
"vote.formVisibility": "表示方法",
"vote.formVisibilityPublic": "公開(探索ページ + 質問箱ページ)",
"vote.formVisibilityLinkOnly": "リンクのみ",
"vote.formVoterScope": "投票範囲",
"vote.formVoterScopeAnyone": "誰でも(匿名含む)",
"vote.formVoterScopeRegistered": "登録ユーザーのみ",
"vote.formShowResult": "結果の表示タイミング",
"vote.formShowResultAfterVote": "投票後すぐに表示",
"vote.formShowResultAfterEnd": "終了後に表示",
"vote.formShowResultOwnerOnly": "本人のみ",
"vote.formStartAt": "開始時間(空欄=すぐ開始)",
"vote.formEndAt": "終了時間(空欄=制限なし)",
"vote.formMaxChoices": "最大選択数",
"vote.formMaxVotesPerFingerprint": "投票ごとに1台の端末につき1回まで。同じブラウザからの再送信は拒否されます。",
"vote.formMaxVotesPerAccount": "投票ごとに1つの登録アカウントにつき1回まで。同じアカウントからの再送信は拒否されます。",
"vote.formAllowCustom": "参加者がカスタム選択肢を追加できる",
"vote.formRequireCaptcha": "匿名投票にキャプチャを要求",
"vote.formOptions": "投票の選択肢",
"vote.formRatingHint": "評価投票は選択肢不要(1-10点)",
"vote.addOption": "選択肢を追加",
"vote.optionPlaceholder": "選択肢の内容",
"vote.formFields": "フォーム項目",
"vote.addField": "項目を追加",
"vote.field": "項目",
"vote.formFieldType": "項目タイプ",
"vote.fieldTypeText": "一行テキスト",
"vote.fieldTypeTextarea": "複数行テキスト",
"vote.fieldTypeSelect": "ドロップダウン選択",
"vote.fieldTypeRadio": "単一選択",
"vote.fieldTypeCheckbox": "複数選択",
"vote.fieldTypeNumber": "数値",
"vote.fieldTypeRank": "順位",
"vote.page.selectPlaceholder": "選択してください",
"vote.page.avgNumber": "平均",
"vote.page.fieldFilled": "回答数",
"vote.fieldTypeRating": "評価",
"vote.formFieldLabel": "項目ラベル",
"vote.formFieldRequired": "必須",
"vote.formFieldMaxLength": "文字数上限(0=制限なし)",
"vote.formFieldOptions": "選択肢",
"vote.formFieldOptionsHint": "下の「選択肢を追加」で一つずつ追加してください(カンマ不要)",
"vote.fieldAllowCustom": "参加者がカスタム選択肢を追加できる",
"vote.formFieldOptionsPlaceholder": "例:良い,普通,悪い",
"vote.createDraft": "下書きを保存",
"vote.builderHintOptions": "選択肢は最低 2 つ必要です",
"vote.builderHintFields": "フィールドを最低 1 つ追加してください",
"vote.builderHintFieldOptions": "選択・順位題には選択肢が必要です(順位は最低 2 つ)",
"vote.resultsAnswered": "回答数",
"vote.page.rankedTimes": "順位付け数",
"vote.resultsTotal": "総参加人数",
"vote.resultsSubmissions": "フォームの回答",
"vote.resultsViewTexts": "テキスト回答を表示",
"vote.resultsToggleHint": "クリックでテキスト回答の表示・非表示を切り替え",
"vote.resultsOptions": "投票結果",
"vote.resultsVotes": "票",
"vote.export": "CSV エクスポート",
"vote.resultsEmpty": "まだ参加者がいません。リンクを共有しましょう!",
// 投票参加ページ
"vote.inlineTitle": "投票",
"vote.page.by": "の投票",
"vote.page.goVote": "投票する",
"vote.page.notStarted": "未開始",
"vote.page.notStartedHint": "この投票はまだ始まっていません。",
"vote.page.ended": "終了",
"vote.page.endedHint": "この投票は終了しました。",
"vote.page.active": "進行中",
"vote.page.participated": "参加済み",
"vote.page.loginRequired": "この投票は登録ユーザーのみ参加できます。ログインしてください。",
"vote.page.anonymousHint": "匿名での参加が可能で、あなたの身元は記録されません(同一端末で最大1回)。",
"vote.page.submit": "投票する",
"vote.page.alreadyVoted": "この投票にはすでに参加済みです。ありがとうございます!",
"vote.page.resultsTitle": "投票結果",
"vote.page.totalVotes": "総参加",
"vote.page.avgRating": "平均点",
"vote.page.avgRank": "平均順位",
"vote.page.optionColumn": "選択肢",
"vote.page.ratingLabel": "評価",
"vote.page.ratingUnit": "点",
"vote.page.rankHint": "各選択肢に 1-N の順位を入力してください(小さいほど上位、重複不可)",
"vote.page.rankPlaceholder": "順位",
"vote.page.multipleHint": "複数選択可",
"vote.page.customOption": "カスタム選択肢(任意)",
"vote.page.customPlaceholder": "あなたの選択肢を入力",
"vote.page.customOther": "その他",
"vote.page.customFieldPlaceholder": "カスタム選択肢を入力…",
"vote.page.customStats": "カスタム選択肢",
"vote.page.formCount": "回答受付数",
"vote.page.ownerManage": "この投票を管理(結果は本人のみ閲覧可能)",
// 投票ごとの定量収集
"vote.formQuotaLimit": "収集上限(件)",
"vote.formQuotaHint": "0 は無制限。指定件数に達すると自動的に投票を終了します。",
"vote.flash.notFound": "この投票は存在しないか、既に無効です",
"vote.flash.notPublished": "この投票はまだ公開されていません",
"vote.flash.archived": "この投票はアーカイブ済みのため参加できません",
"vote.flash.ended": "この投票は終了しました",
"vote.flash.notStarted": "この投票はまだ開始していません",
"vote.flash.success": "投票が完了しました。ご参加ありがとうございます!",
"vote.err.noChoice": "少なくとも 1 つの選択肢を選んでください",
"vote.err.tooManyChoices": "選択できるのは最大 %d 個までです",
"vote.err.pickOne": "選択肢を 1 つ選んでください",
"vote.err.invalidOption": "無効な選択肢です",
"vote.err.optionDuplicate": "選択肢は重複できません",
"vote.err.ratingRequired": "1〜10 点の評価を入力してください",
"vote.err.rankRequired": "すべての選択肢に 1〜%d の順位を入力してください",
"vote.err.rankDuplicate": "順位が重複しています",
"vote.err.fieldRequired": "「%s」を入力してください",
"vote.err.fieldCustomRequired": "「%s」の自由記入欄を入力してください",
"vote.err.fieldCustomTooLong": "「%s」の自由記入は %d 字以内にしてください",
"vote.err.fieldInvalidOption": "「%s」の選択肢が無効です",
"vote.err.fieldConfigError": "「%s」の選択肢の設定に誤りがあります",
"vote.err.fieldRankIncomplete": "「%s」の順位をすべて入力してください",
"vote.err.fieldRankRange": "「%s」の順位は 1〜%d の数字で入力してください",
"vote.err.fieldRankDuplicate": "「%s」の順位が重複しています",
"vote.err.fieldTooLong": "「%s」が文字数上限(最大 %d 字)を超えています",
"vote.err.fieldNotNumber": "「%s」は数値で入力してください",
"vote.err.fieldRating": "「%s」は 1〜10 点で評価してください",
"vote.err.unknownType": "不明な投票タイプです",
"vote.err.customTooLong": "自由記入オプションは %d 字以内にしてください",
"vote.err.blockWords": "投稿内容に投票作成者が設定したブロックワードが含まれているため、送信できませんでした",
"vote.err.serialize": "送信内容の保存に失敗しました",
"captcha.error": "認証コードが正しくありません",
"captcha.errEmpty": "認証コードを入力してください",
"captcha.errFormat": "認証コードの形式が正しくありません。再読み込みしてお試しください",
"captcha.errAnswerFormat": "認証コードの回答形式が正しくありません",
"captcha.errExpired": "認証コードの有効期限が切れました。再読み込みしてお試しください",
"captcha.errService": "認証コードサービスでエラーが発生しました。後ほどお試しください",
"captcha.errAnswerWrong": "認証コードの回答が正しくありません",
"censor.unavailable": "コンテンツ審査サービスが一時的に利用できません。後ほどお試しください",
// 機能管理:質問 / チャット / 投票 の ON・OFF、期間指定、定量収集
"admin.tabAccount": "ユーザー設定",
"admin.featuresHint": "「質問 / チャット / 投票」パネルで各機能の ON/OFF、公開期間、収集上限を管理します。上限に達すると対象機能は自動的に閉じられます。",
"admin.featuresEntry": "設定",
"admin.featureHint.question": "OFF にすると新しい質問を投稿できません。上限に達すると自動的に受付を停止します(非公開質問を含む)。",
"admin.featureHint.chat": "OFF にすると新しいチャットを開始できません。既存の会話には影響しません。",
"admin.featureHint.vote": "OFF にすると質問箱ページに投票が表示されず、回答もできなくなります。投票ごとの上限は編集ページで設定できます。",
"admin.featureEnabled": "この機能を有効にする",
"admin.featureOpenAt": "公開開始",
"admin.featureCloseAt": "公開終了",
"admin.featureTimeHint": "空欄は制限なし。開始前と終了後はその機能を利用できません。",
"admin.featureSchedule": "定期公開(毎週)",
"admin.featureScheduleStart": "毎日の開始",
"admin.featureScheduleEnd": "毎日の終了",
"admin.featureScheduleHint": "毎週の公開曜日と毎日の時間帯を設定すると、毎週繰り返し公開されます。すべて空欄で無効。曜日のみ(時間なし)は終日公開、時間のみ(曜日なし)は毎日その時間帯に公開。",
"admin.featureMode": "収集モード",
"admin.featureModeAlways": "常時開放",
"admin.featureModeWindow": "期間限定",
"admin.featureModeRecurring": "曜日スケジュール",
"admin.featureModeHint": "常時開放:手動でオフにするまで開放し続けます。期間限定:一回限りの開始/終了時刻で開放します。曜日スケジュール:毎週の開放日と時間帯を繰り返し適用します。モードは保存時に排他となり、未選択モードの設定はクリアされます。",
"admin.featureQuotaLimit": "収集上限(件)",
"admin.featureQuotaHint": "0 は無制限。上限に達すると自動的に閉じられます。カウントをリセットすると次の回を開始できます。",
"admin.featureQuotaCount": "収集済み",
"admin.featureQuotaUnlimited": "無制限",
"admin.featureQuotaResetAt": "最終リセット:",
"admin.featureResetQuota": "カウントをリセット",
"admin.featureSave": "この機能を保存",
// 機能管理:状態とメッセージ
"feature.name.question": "質問",
"feature.name.chat": "チャット",
"feature.name.vote": "投票",
"feature.statusOpen": "受付中",
"feature.status.disabled": "停止中",
"feature.status.not_started": "未開始",
"feature.status.ended": "終了",
"feature.status.quota_reached": "上限到達",
"feature.saved": "機能設定を保存しました",
"feature.errTime": "%s の日時形式が正しくありません。日時選択ツールを使用してください",
"feature.errTimeRange": "%s の公開終了時刻は公開開始時刻より後にしてください",
"feature.errQuota": "%s の収集上限は 0〜1000000 の整数で入力してください",
"feature.errInvalid": "不明な機能です",
"feature.quotaReset": "%s の収集カウントをリセットしました",
"feature.errScheduleHalf": "%s の定期公開の開始時間と終了時間は両方入力してください",
"feature.errScheduleTime": "%s の定期公開の時刻形式が正しくありません。時刻選択ツールを使用してください",
"feature.errScheduleRange": "%s の定期公開の終了時刻は開始時刻より後にしてください",
// 機能が無効なときに訪問者へ表示するメッセージ
"feature.disabled.question": "この質問箱の管理者は質問の受付を停止しています",
"feature.disabled.chat": "この質問箱ではチャットを受け付けていません",
"feature.disabled.vote": "この質問箱では投票を受け付けていません",
"feature.not_started.question": "質問の受付はまだ開始していません",
"feature.not_started.chat": "チャットの受付はまだ開始していません",
"feature.not_started.vote": "投票の受付はまだ開始していません",
"feature.ended.question": "質問の受付は終了しました",
"feature.ended.chat": "チャットの受付は終了しました",
"feature.ended.vote": "投票の受付は終了しました",
"feature.quota_reached.question": "質問の収集上限に達しました",
"feature.quota_reached.chat": "チャットの収集上限に達しました",
"feature.quota_reached.vote": "投票の収集上限に達しました",
// 認証:訪問者に表示されるメッセージ(P0)
"auth.errBadCredential": "メールアドレスまたはパスワードが正しくありません",
"auth.errUserNotFound": "ユーザーが存在しません",
"auth.errEmailFrequent": "メールの送信頻度が高すぎます。しばらくしてからお試しください",
"auth.errEmailFailed": "メールの送信に失敗しました。しばらくしてからお試しください",
"auth.errCodeExpired": "認証コードの有効期限が切れました",
"auth.errPasswordChanged": "パスワードを変更しました",
"auth.errLoginRequired": "ログインしてください",
"auth.errDuplicateEmail": "このメールアドレスは既に登録されています",
"auth.errDuplicateDomain": "このカスタムドメインは既に使用されています",
// 質問箱ページ:訪問者に表示(P0)
"question.errWrongPassword": "パスワードが正しくありません",
"question.errAskPasswordWrong": "質問用パスワードが正しくありません",
"question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"question.errSelfChat": "自分自身とのチャットは開始できません",
"question.errBlockedChat": "相手にブロックされているため、チャットを開始できません",
"question.errChatDailyLimit": "本日の送信上限(%d件)に達しました。明日お試しください",
"question.errDuplicateSubmit": "重複送信が検出されました。しばらくしてからお試しください",
"question.errImageTooLarge": "画像ファイルのサイズは 5Mb 以下である必要があります",
"question.errUploadImageFailed": "画像のアップロードに失敗しました",
"question.errBlockWords": "質問に所有者が設定したブロックワードが含まれているため、送信できませんでした",
"question.errAnsweredLocked": "回答済みの質問は未読にできません",
"question.flash.chatSent": "チャットリクエストを送信しました!以下のリンクから会話を確認できます:%s",
"question.flash.answered": "回答を公開しました!",
"question.flash.operated": "操作が完了しました",
"question.flash.markedRead": "既読にしました",
"question.flash.markedUnread": "未読にしました",
// 追加質問:訪問者/所有者に表示(P0)
"followup.errNoPermission": "権限がありません",
"followup.errNotAnswered": "この質問にはまだ回答がありません。追加質問はできません",
"followup.errLimit": "各質問の追加質問は最大 %d 回までで、上限に達しました",
"followup.errWaitReply": "前の追加質問への返信を待ってから続けてください",
"followup.errNotFound": "追加質問が存在しません",
"followup.flash.sent": "追加質問を送信しました!",
"followup.flash.answered": "回答を投稿しました!",
// 管理設定:所有者に表示(P1)
"admin.flash.profileUpdated": "プロフィールを更新しました",
"admin.flash.chatUpdated": "チャット設定を更新しました",
"admin.flash.pagePwdOff": "ページパスワード認証を無効にしました",
"admin.flash.pagePwdUpdated": "ページアクセスパスワードを更新しました",
"admin.flash.askPwdOff": "質問パスワードを無効にしました",
"admin.flash.askPwdUpdated": "質問パスワードを更新しました",
"admin.flash.tagsTooMany": "タグは最大 5 個までです",
"admin.flash.tagsUpdated": "タグを更新しました",
"admin.flash.tagTooLong": "各タグは 5 文字以内である必要があります:%s",
"admin.flash.blockWordsTooMany": "ブロックワードは最大 10 個までです",
"admin.flash.blockWordTooLong": "ブロックワードの長さは 10 文字以内である必要があります:%s",
"admin.flash.harassmentUpdated": "迷惑行為対策を更新しました",
"admin.flash.blockWordsUpdated": "ブロックワードを更新しました",
"admin.flash.deactivated": "アカウントを停止しました。狼の質問箱をご利用いただきありがとうございます。またのお越しをお待ちしています 👋🏻",
"admin.export.errUser": "エクスポート失敗:ユーザー情報の取得に失敗しました",
"admin.export.errQuestion": "エクスポート失敗:質問情報の取得に失敗しました",
// 投票管理:所有者に表示(P1)
"vote.err.invalidID": "無効な投票 ID",
"vote.err.notExist": "投票が存在しません",
"vote.err.noPermission": "この投票を操作する権限がありません",
"vote.err.type": "不明な投票タイプです",
"vote.err.titleRequired": "投票のタイトルを入力してください",
"vote.err.titleTooLong": "投票のタイトルは 100 文字以内である必要があります",
"vote.err.visibility": "不明な表示設定です",
"vote.err.voterScope": "不明な投票範囲設定です",
"vote.err.showResult": "不明な結果表示設定です",
"vote.err.timeRange": "終了時刻は開始時刻より後にしてください",
"vote.err.quota": "収集上限は 0〜1000000 の整数で入力してください",
"vote.err.optionsTooFew": "選択肢を最低 2 つ追加してください",
"vote.err.optionsFormat": "選択肢データの形式が正しくありません",
"vote.err.optionTooLong": "1 つの選択肢は %d 文字以内である必要があります",
"vote.err.maxChoicesExceed": "最大選択数は選択肢の総数を超えられません",
"vote.err.fieldsTooFew": "フォーム項目を最低 1 つ追加してください",
"vote.err.fieldsFormat": "フォーム項目データの形式が正しくありません",
"vote.err.fieldsTooMany": "フォーム項目は最大 50 個までです",
"vote.err.fieldType": "不明なフォーム項目タイプです",
"vote.err.fieldLabelMissing": "%d 番目の項目にラベルがありません",
"vote.err.fieldLabelTooLong": "%d 番目の項目のラベルは 100 文字以内である必要があります",
"vote.err.fieldLabelDuplicate": "項目ラベル「%s」が重複しています。別のラベルを使用してください",
"vote.err.fieldMaxLength": "%d 番目の項目の文字数上限は 0〜2000 の間である必要があります",
"vote.err.fieldReservedOption": "%d 番目の項目(%s)の選択肢に予約値が含まれています。変更してください",
"vote.err.fieldOptionsTooFew": "%d 番目の項目(%s)は選択肢を最低 1 つ入力してください",
"vote.err.fieldRankTooFew": "%d 番目の項目(%s)の順位付けには最低 2 つの選択肢が必要です",
"vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました",
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
"vote.err.publishOptionsRequired": "選択型投票には最低 2 つの選択肢が必要です",
"vote.err.optionUpdateFailed": "選択肢の更新に失敗しました。再試行してください",
"vote.err.fieldUpdateFailed": "フォーム項目の更新に失敗しました。再試行してください",
"vote.flash.published": "投票を公開しました!共有リンク:%s",
"vote.flash.updated": "投票情報を更新しました(進行中の投票はタイプと選択肢を変更できません)",
"vote.flash.draftSaved": "下書きに保存しました",
"vote.flash.closed": "投票を終了しました",
"vote.flash.deleted": "投票を削除しました",
"vote.err.timeFormat": "日時の形式が正しくありません。日時選択器を使用してください",
"vote.err.maxChoicesLimit": "選択可能数は20を超えられません",
"vote.err.descriptionTooLong": "投票の説明は5000文字を超えられません",
"profile.err.avatarTooLarge": "アイコンファイルが大きすぎます。最大2MB",
"profile.err.backgroundTooLarge": "背景画像が大きすぎます。最大2MB",
"profile.err.newPasswordTooShort": "新しいパスワードは8文字以上である必要があります",
"profile.err.badOldPassword": "古いパスワードが間違っています",
"admin.export.errCreateExcel": "エクスポート失敗:Excelファイルの作成に失敗しました",
"admin.export.errWriteExcel": "エクスポート失敗:Excelファイルの書き込みに失敗しました",
// プロフィール設定ページ UI リニューアル:カード区切り/アイコン・背景の切り取り/パスワード変更後の強制ログアウト
"profile.card.basic": "基本情報",
"profile.card.avatarBg": "アイコンと背景",
"profile.card.qr": "QRコードのスタイル",
"profile.card.password": "パスワード変更",
"profile.avatarCropHint": "切り取り枠は 1:1(正方形)に固定されています",
"profile.backgroundCropHint": "切り取り枠は 16:9 に固定されています",
"profile.cropTitle": "画像を切り取る",
"profile.cropConfirm": "切り取りを確定",
"profile.err.avatarRatio": "アイコン画像は 1:1 の正方形である必要があります。切り取ってから再アップロードしてください",
"profile.err.backgroundRatio": "背景画像は 16:9 の縦横比である必要があります。切り取ってから再アップロードしてください",
"profile.flash.passwordChanged": "パスワードを変更しました。ログアウトしましたので、新しいパスワードで再度ログインしてください",
"profile.pwdNote": "保存後、現在のセッションはすぐに無効になり、新しいパスワードで再ログインする必要があります",
"profile.pickAvatar": "アイコンを変更",
"profile.pickBackground": "背景を変更",
"profile.saveBasic": "基本情報を保存",
"profile.saveMedia": "アイコンと背景を保存",
"profile.savePassword": "パスワードを更新",
"profile.saveQR": "QRコードのスタイルを保存",
"profile.saveSocial": "SNSリンクを保存",
"profile.err.cropLibUnavailable": "画像切り取り機能の読み込みに失敗しました。ページを更新してもう一度お試しください",
// 認証コード JSON インターフェース:フロントエンドが消費(P2)
"captcha.json.badRequest": "リクエスト形式が正しくありません",
"captcha.json.idRequired": "認証コード ID を入力してください",
"captcha.json.genFailed": "認証コードの生成に失敗しました",
"chat.errDeletedByOwner": "このプライベート会話は相手によって削除されました。",
"chat.errBlockedView": "あなたは相手にブロックされ、このチャットを表示できません",
"chat.errEmpty": "メッセージを入力してください",
"chat.errTooLong": "メッセージが長すぎます。%d文字以内でお願いします",
"chat.errConvDeletedSend": "この会話は削除されたため、メッセージを送信できません",
"chat.errBlockedSend": "あなたは相手にブロックされ、メッセージを送信できません",
"chat.errBlockWords": "メッセージにボックス所有者が設定したブロックワードが含まれているため、送信できませんでした",
"chat.errDailyLimit": "本日の送信上限(%d件)に達しました。明日再度お試しください",
"chat.errLimitReached": "会話が上限に達したため、新しいメッセージを送信できません",
"chat.errChatClosed": "箱の所有者がプライベートチャットを無効にしたため、メッセージを送信できません",
"chat.errImageTooLarge": "画像サイズは%dMBを超えることはできません",
"chat.errUploadFailed": "画像のアップロードに失敗しました。後でもう一度お試しください",
"chat.errNoPermission": "この操作を実行する権限がありません",
"chat.flash.sent": "メッセージを送信しました",
"chat.flash.deleted": "プライベート会話を削除しました",
"chat.flash.operated": "操作が完了しました",
"image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください",
"internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。",
"internal.errServer": "サーバー内部エラー",
"home.errStats": "統計情報の取得に失敗しました",
"pixel.errPath": "不正なリクエストパス",
"proxy.errNotFound": "ページが見つかりません",
"proxy.errInternal": "サービスゲートウェイ内部エラー",
"explore.errRandomUser": "ランダムユーザーの取得に失敗しました",
// 表单校验
"form.answer.content": "回答内容を入力してください",
"form.answer.contentTooLong": "回答内容は%d文字以内にしてください",
"form.email.required": "有効なメールアドレスを入力してください",
"form.followup.answer": "フォローアップの回答を入力してください",
"form.followup.answerTooLong": "フォローアップの回答は%d文字以内にしてください",
"form.followup.content": "フォローアップ内容を入力してください",
"form.followup.contentTooLong": "フォローアップ内容は%d文字以内にしてください",
"form.login.password": "パスワードを入力してください",
"form.profile.backgroundimagealpha": "QRコードの背景の不透明度を入力してください",
"form.profile.colorHex": "7文字の16進数カラーコード(#を含む)を入力してください",
"form.profile.dotscale": "QRコードの点の大きさを入力してください",
"form.profile.intro": "自己紹介は必須で、100文字以内である必要があります",
"form.profile.linkMax": "プロフィールリンクは30文字以内である必要があります",
"form.profile.name": "ニックネームは必須で、20文字以内である必要があります",
"form.profile.newPassword": "新しいパスワードは30文字以内である必要があります",
"form.profile.passwordMax": "パスワードは30文字以内である必要があります",
"form.question.content": "質問内容を入力してください",
"form.question.contentTooLong": "質問内容は%d文字以内にしてください",
"form.question.receiveReplyEmail": "返信を受け取るための有効なメールアドレスを入力してください",
"form.recaptcha": "CAPTCHA認証を完了してください",
"form.recover.newPassword": "8〜30文字の新しいパスワードを設定してください",
"form.recover.repeatPassword": "入力した2つの新しいパスワードが一致しません",
"form.register.domain": "カスタムドメインは半角英数字とアンダースコアのみ使用でき、3〜20文字である必要があります",
"form.register.email": "有効なメールアドレスを入力してください(100文字以内)",
"form.register.name": "ニックネームは必須で、20文字以内である必要があります",
"form.register.password": "8〜30文字のパスワードを設定してください",
"form.register.recaptcha": "CAPTCHAを読み込めません。ページを再読み込みするか、ネットワークを変更してお試しください",
"form.register.repeatPassword": "入力した2つのパスワードが一致しません",
}
+840
View File
@@ -0,0 +1,840 @@
// 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 i18n
// zhCNDict 简体中文文案。
var zhCNDict = map[string]string{
// 导航
"nav.explore": "探索",
"nav.mine": "我的",
"nav.register": "注册",
"nav.login": "登录",
"nav.ask": "提问",
"nav.settings": "设置",
"nav.lang": "语言",
// 通用
"common.login": "登录",
"common.forgot": "忘记密码",
"common.submit": "提交",
"common.cancel": "取消",
"common.save": "保存",
"common.close": "关闭",
"common.delete": "删除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "验证中...",
"common.submitting": "提交中...",
"common.uploading": "上传中...",
"common.lang": "语言",
"common.menu": "菜单",
"common.pleaseWait": "请稍候...",
"common.backHome": "返回首页",
"common.theme": "切换深色/浅色模式",
// 站点
"site.title": "狼的提问箱 | 匿名提问箱",
"site.description": "狼的提问箱——一个无需注册、可匿名提问的匿名提问箱,安全地倾诉与回应。",
"site.keywords": "匿名提问箱,提问箱,匿名提问,狼的提问箱,匿名问答,匿名提问网站",
"site.footer.runtime": "提问箱已成功存活",
"site.footer.admin": "找站长",
"site.footer.notice": "公告板",
"site.footer.chat": "聊天室",
"site.footer.drive": "盘",
"site.footer.status": "监控板",
"site.footer.nav": "导航页",
"site.maintenance": "本站正在维护中,请稍后再来~",
// 认证
"auth.login.title": "用户登录",
"auth.login.email": "电子邮箱",
"auth.login.password": "密码",
"auth.login.submit": "登录",
"auth.login.forgot": "忘记密码",
"auth.register.title": "用户注册",
"auth.register.name": "昵称",
"auth.register.email": "电子邮箱",
"auth.register.password": "密码",
"auth.register.submit": "注册",
"auth.forgot.title": "忘记密码",
"auth.forgot.email": "电子邮箱",
"auth.forgot.submit": "发送重置邮件",
"auth.forgot.submitBtn": "找回密码",
"auth.forgot.sent": "邮件已发送至 %s,请查收。",
"auth.recover.title": "重置密码",
"auth.recover.greeting": "%s,您正在重置您的密码:",
"auth.recover.password": "新密码",
"auth.recover.confirm": "确认密码",
"auth.recover.submit": "重置密码",
// 探索页
"explore.title": "探索用户标签",
"explore.notice": "此处展示已开启<span class=\"uk-text-bold\">“在首页展示我的标签”</span>的用户。该功能默认关闭,若希望自己的提问箱在这里被发现,请前往 <a href=\"/user/profile\" class=\"uk-text-bold\">个人设置</a> 手动开启。",
"explore.shuffle": "换一批",
"explore.empty": "暂时没有发现可展示的用户,快去开启吧~",
"explore.hotVotes": "热门投票",
"explore.questionsTab": "提问",
"explore.votesTab": "投票",
"explore.votesTitle": "公开投票",
"explore.votesDesc": "浏览正在进行的公开投票,选择你感兴趣的主题参与。",
"explore.randomVote": "随机推荐",
"explore.allVotes": "全部投票",
"explore.noVotes": "暂时没有可参与的公开投票。",
// 提问页
"ask.scan": "扫码向@%s提问",
"ask.anonymous": "谁都可以以匿名的形式提问",
"ask.registerOnly": "仅允许注册用户向TA提问",
"ask.nextOpenAt": "下次开放时间:%s",
"ask.lastCloseAt": "上次关闭时间:%s",
"weekday.0": "周日",
"weekday.1": "周一",
"weekday.2": "周二",
"weekday.3": "周三",
"weekday.4": "周四",
"weekday.5": "周五",
"weekday.6": "周六",
"ask.pinned": "已置顶",
"ask.share": "分享提问箱",
"ask.placeholder": "在此处撰写你的问题...",
"ask.chatPlaceholder": "在此处撰写你的私聊消息...",
"ask.addImage": "添加图片",
"ask.private": "回复后不公开",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "请输入你的邮箱地址",
"ask.submit": "发送提问",
"ask.chat": "发起私聊",
"ask.chatDisabled": "该提问箱未开启私聊",
"ask.chatLimit": "消息上限:%d条",
"ask.history": "@%s 以前回答过的问题",
"ask.loginRequired": "需要登录",
"ask.loginRequiredChat": "发起私聊需要登录账号",
"ask.goLogin": "去登录",
"ask.needPassword": "此提问箱需要密码",
"ask.needLogin": "提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。",
"ask.pleaseEmail": "请填写接收通知的邮箱地址",
"ask.generating": "正在生成分享图…",
"ask.genFailed": "生成分享图失败,请稍后重试",
"ask.networkError": "网络错误",
"ask.shareFilename": "share.png",
// 问题条目
"qitem.answer": "回答",
"qitem.delete": "删除",
"qitem.private": "设为私密",
"qitem.public": "设为公开",
"qitem.asked": "提问",
"qitem.pending": "等待回答",
"qitem.answeredBy": "@%s 于 %s 的回答",
"qitem.danger": "危险!",
"qitem.deleteConfirm": "你确定要删除这个提问吗?该操作不可恢复,请谨慎操作。",
"qitem.deleteConfirmBtn": "确认删除",
"qitem.backToBox": "返回%s的提问箱",
"qitem.answerTitle": "回答问题",
"qitem.answerPlaceholder": "在此处撰写你的回答...",
"qitem.emailNotifyHint": "提问人留下了自己的电子邮箱,在你第一次回复该问题后,提问人将会收到一封邮件通知。",
"qitem.submitAnswer": "回答",
"qitem.updateAnswer": "更新回答",
"qitem.askMore": "再问点别的问题?",
"qitem.markRead": "标记为已读",
"qitem.markUnread": "标记为未读",
"qitem.readLocked": "已回答,已锁定为已读",
// 个人设置页
"admin.profile": "个人信息",
"admin.lang": "界面语言",
"admin.langHint": "选择访客访问你的提问箱时显示的语言(后台页面跟随此设置)",
"admin.email": "电子邮箱",
"admin.name": "昵称",
"admin.nameColor": "昵称颜色",
"admin.oldPassword": "旧密码",
"admin.newPassword": "新密码",
"admin.notify": "新提问通知",
"admin.personalize": "个性化设置",
"admin.intro": "提问箱介绍",
"admin.introColor": "提问箱介绍颜色",
"admin.qrBgColor": "二维码背景色(亮色部分)",
"admin.qrPdColor": "二维码探测图形颜色(边角方形)",
"admin.qrFgColor": "二维码前景色",
"admin.qrDotSize": "二维码点大小 (←小 大→)",
"admin.qrBgAlpha": "二维码背景透明度 (←不透明 很透明→)",
"admin.avatar": "个人头像",
"admin.background": "提问箱背景",
"admin.qrBackground": "二维码背景图",
"admin.social": "社交信息设置",
"admin.harassment": "防骚扰设置",
"admin.askLimit": "提问限制",
"admin.blockWords": "屏蔽词设置(回车或逗号分隔,最多支持 10 个屏蔽词,每个屏蔽词最大长度为 10)",
"admin.chat": "私聊设置",
"admin.chatMaxMessages": "私聊消息条数上限",
"admin.chatDailyLimit": "单用户每日私信条数上限",
"admin.tags": "标签设置",
"admin.tagsHint": "标签(最多5个,每个最多5字,回车或逗号分隔)",
"admin.tagsPlaceholder": "例如:动漫,音乐,游戏",
"admin.tagInputHint": "输入后按回车或逗号添加",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "页面访问密码",
"admin.pagePasswordHint": "设置访问密码(留空则关闭)",
"admin.askPassword": "提问密码",
"admin.askPasswordHint": "设置提问密码(留空则关闭)",
"admin.saveProfile": "保存修改",
"admin.updated": "更新个人信息成功",
"admin.logout": "退出登录",
"admin.export": "导出数据",
// 后台设置(补充)
"admin.oldPasswordHint": "修改密码使用,留空则不修改",
"admin.newPasswordHint": "6-64 位,留空则不修改",
"admin.notifyEmail": "邮件",
"admin.useCustomFg": "启用自定义前景色(否则自动匹配)",
"admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成",
"admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)",
"admin.backgroundPlaceholder": "上传提问箱背景",
"admin.qrBackgroundPlaceholder": "上传二维码背景(颜色过深的背景会导致二维码无法识别)",
"admin.qrPreviewHint": "选择图片后预览将即时更新",
"admin.preview": "实时预览",
"admin.themePreset": "预设主题",
"admin.themeDefault": "默认",
"admin.themeDark": "暗夜",
"admin.themeOcean": "海洋",
"admin.themeSunset": "日落",
"admin.resetDefaults": "🔄 恢复默认值",
"admin.previewHint": "调整仅用于预览,最终效果点击「保存修改」后生效",
"admin.socialHint": "勾选后填写主页地址(无需 https://),未勾选的项将不会在前台显示。",
"admin.enableAcfun": "启用 AcFun",
"admin.enableBili": "启用 Bilibili",
"admin.enableWeibo": "启用 微博",
"admin.enableXiaohongshu": "启用 小红书",
"admin.enableBlog": "启用 个人博客",
"admin.registerOnly": "仅允许注册用户向我提问",
"admin.updateHarassment": "更新防骚扰设置",
"admin.globalBlockWords": "全局屏蔽词",
"admin.globalBlockWordsHint": "屏蔽词对提问、私聊(对方发给你的消息)与投票提交内容生效;三个设置页同步显示同一份设置,任意一处修改即时生效。",
"admin.updateBlockWords": "保存屏蔽词",
"admin.allowChat": "允许注册用户向我发起私聊",
"admin.dailyLimitHint": "0 表示不限制",
"admin.updateChat": "更新私聊设置",
"admin.showInHome": "在首页展示我的标签(允许其他用户通过标签搜索到我)",
"admin.updateTags": "更新标签",
"admin.updatePagePassword": "更新页面密码",
"admin.pagePasswordPlaceholder": "输入页面访问密码",
"admin.updateAskPassword": "更新提问密码",
"admin.askPasswordPlaceholder": "输入提问密码",
"admin.account": "账号设置",
"admin.exportHint": "您可以导出您在 %s 中的所有个人数据,包括你的基本信息、收到的问题以及回答。",
"admin.exportJSON": "导出全部数据(JSON)",
"admin.exportJSONHint": "包含提问、回答、追问与私聊记录,可完整备份或迁移。",
"admin.deactivate": "停用我的账号",
"admin.deactivateHint": "您随时可以选择停用您的账号。停用后,您的账号将无法登录,您的提问箱页面以及提问将无法访问,其他人也无法再给您发送新的提问。<b>该操作无法撤销!请谨慎操作!</b>",
"admin.socialRequired": "请填写链接或取消勾选",
// 问题管理页
"qlist.title": "我的提问",
"qlist.answered": "已回答",
"qlist.pending": "待回答",
"qlist.private": "私密",
"qlist.empty": "还没有提问,快把提问箱分享出去吧~",
"qlist.action": "操作",
"qlist.answerNow": "去回答",
"qlist.sort": "排序",
"qlist.status": "状态",
"qlist.type": "类型",
"qlist.latest": "最新",
"qlist.oldest": "最早",
"qlist.all": "全部",
"qlist.public": "一般提问",
"qlist.privateQuestion": "私密提问",
"qlist.received": "我收到的提问",
"qlist.myChats": "我发起的私聊",
"qlist.statsTotal": "总提问数",
"qlist.statsAnswered": "已回答",
"qlist.statsUnanswered": "未回答",
"qlist.statsPrivate": "私密提问",
"qlist.statsPinned": "置顶",
"qlist.statsChats": "私聊会话",
"qlist.searchPlaceholder": "搜索问题或回答内容...",
"qlist.searchBtn": "搜索",
"qlist.searchClear": "清除搜索",
"qlist.questionsTab": "提问",
"qlist.votesTab": "投票",
"qlist.chatsTab": "私聊",
"qlist.receivedChats": "我收到的私聊",
"qlist.chatEmpty": "还没有私聊,把提问箱分享出去让大家来私聊吧~",
"qlist.pinnedShort": "置顶",
"qlist.privateShort": "私密",
"qlist.chatStatsTotal": "总私聊",
"qlist.chatStatsReceived": "我收到的",
"qlist.chatStatsInitiated": "我发起的",
"qlist.chatStatsClosed": "已关闭",
"qlist.unansweredShort": "待答",
"qlist.readShort": "已读",
"qlist.unreadShort": "未读",
"qitem.markReadShort": "标记",
"qlist.pinShort": "置顶",
"qlist.unpinShort": "取消置顶",
"qlist.myVotes": "我创建的投票",
"qlist.loadMore": "加载更多",
"qlist.newVote": "新建投票",
"qlist.edit": "编辑",
"qlist.untitledVote": "未命名投票",
"qlist.blocked": "已拉黑",
"qlist.unanswered": "未回答",
// 站点 Logo 后缀(emoji 随机动物 + 后缀组成,如 🐺 的提问箱)
"site.logoSuffix": "的提问箱",
"site.stats": "提问箱信息统计",
// 验证码弹窗
"captcha.title": "人机验证",
"captcha.hint": "请计算下方的算术题以验证你不是机器人",
"captcha.refresh": "换一题",
"captcha.submit": "验证并提交",
"captcha.loading": "加载中...",
"captcha.loadFailed": "加载失败",
"captcha.loadRetry": "加载失败,请重试",
"captcha.inputAnswer": "请输入答案",
"captcha.wrongAnswer": "答案错误,请重新计算",
"captcha.verifyFailed": "验证失败,请重试",
// 注册页补充
"auth.register.domain.prefix": "个性域名(你的问答箱网址将会是:%s + ",
"auth.register.domain.suffix": " + 你在下面文本框中填写的内容)",
"auth.register.confirm": "确认密码",
"auth.register.language": "界面语言",
"auth.register.languageDefault": "跟随站点默认",
"auth.register.success": "注册成功,欢迎来到 狼的提问箱!",
// 探索页补充
"explore.recommend": "随机推荐",
"explore.search": "搜索标签...",
"explore.sortLatest": "最新加入",
"explore.sortOldest": "最早加入",
"explore.noTags": "无标签",
"explore.noRecommend": "暂无推荐用户",
"explore.hotTags": "热门标签",
"explore.hotQuestions": "热门问题",
"explore.noResult": "暂无符合条件的用户。",
"explore.lazy": "这个人很懒,什么都没写~",
"explore.prevPage": "上一页",
"explore.nextPage": "下一页",
"explore.loading": "加载中...",
"explore.loadFailed": "加载失败",
// 提问箱密码页
"password.title": "此提问箱需要密码",
"password.desc": "此提问箱已设置访问密码,请输入密码查看内容",
"password.input": "请输入访问密码",
"password.enter": "验证进入",
"password.submit": "确认",
// 问题不存在
"qitem.notFound": "问题不存在或已被删除",
// 聊天页
"chat.title": "私聊",
"chat.input": "输入消息...",
"chat.send": "发送",
"chat.delete": "删除对话",
"chat.block": "拉黑对方",
"chat.unblock": "取消拉黑",
"chat.empty": "还没有消息,说点什么吧~",
"chat.withAsker": "与提问者的私聊",
"chat.withUser": "与 %s 的私聊",
"chat.closed": "对话已关闭",
"chat.closedNoSend": "对话已关闭,无法发送新消息",
"chat.remaining": "消息剩余:%d 条(共 %d 条)",
"chat.me": "我",
"chat.other": "对方",
"chat.userBlocked": "该用户已被拉黑",
"chat.addImage": "添加图片(可选,最大5MB)",
"chat.confirm": "确认",
"chat.confirmBlock": "确定要拉黑该用户吗?他将无法再发送私信。",
"chat.confirmUnblock": "确定要取消屏蔽该用户吗?他将可以再次发送私信。",
"chat.deleteConfirm": "确定要删除这条私聊对话吗?对方将无法再看到此对话,且操作不可恢复。",
"maintenance.title": "NekoBox 维护中...",
"maintenance.desc": "NekoBox 正在进行维护,服务暂时不可用。过一会再来吧~",
// 单题分享(v4)
"qitem.shareCard": "分享此提问",
// 追问(v4)
"followup.title": "追问记录",
"followup.content": "追问:",
"followup.answerPlaceholder": "回答这条追问...",
"followup.answerBtn": "回答追问",
"followup.waiting": "等待回答中...",
"followup.askMore": "继续追问",
"followup.remaining": "还可追问 %d / %d 次",
"followup.contentPlaceholder": "补充你的问题...",
"followup.waitingReply": "你的追加提问已发出,等待提问箱主人回复中...",
"followup.submit": "提交追问",
// 置顶(v4)
"qlist.pinned": "置顶中",
"qlist.pin": "置顶",
"qlist.unpin": "取消置顶",
"qlist.readStatus": "阅读状态",
"qlist.read": "已读",
"qlist.unread": "未读",
"qlist.statsRead": "已读",
"qlist.statsUnread": "未读",
// 提问成功页(v4)
"success.title": "提问发送成功!",
"success.yourQuestion": "你的提问:",
"success.emailSent": "已向你的邮箱发送了该提问的私密链接,也可以使用下方链接随时查看提问、接收回答与继续追问。",
"success.saveLink": "<strong>请务必保存下面的私密链接!</strong>由于你没有留下邮箱,此链接(含口令 Token)是你查看提问、接收回答与继续追问的<strong>唯一凭证</strong>,丢失后将无法找回。",
"success.privateLink": "你的提问私密链接(点击复制保存)",
"success.copy": "复制",
"success.copied": "已复制 ✓",
"success.copyFailed": "复制失败",
"success.token": "口令 Token:%s(提问人凭 Token 访问私密链接)",
"success.viewQuestion": "查看我的提问",
"success.backToBox": "返回%s的提问箱",
"success.pageTitle": "提问成功 - %s",
// 停用页
"deactivate.title": "账号停用",
"deactivate.desc": "停用后你的提问箱将无法访问,且所有数据将被删除,此操作不可恢复!",
"deactivate.confirm": "我确认要停用账号",
"deactivate.submit": "停用账号",
// 匿名投票
"nav.votes": "投票",
"common.back": "返回",
"vote.listTitle": "投票管理",
"vote.newTitle": "新建投票",
"vote.editTitle": "编辑投票",
"vote.resultsTitle": "投票结果",
"vote.statsTotal": "投票总数",
"vote.statsPublished": "进行中",
"vote.createNew": "新建投票",
"vote.typeSingle": "单选",
"vote.typeMultiple": "多选",
"vote.typeRating": "评分",
"vote.typeRank": "排序",
"vote.typeForm": "表单收集",
"vote.statusNotStarted": "未开始",
"vote.statusDraft": "草稿",
"vote.statusPublished": "进行中",
"vote.statusEnded": "已结束",
"vote.statusArchived": "已归档",
"vote.participants": "参与",
"vote.remainLabel": "剩余",
"vote.perpetual": "无限期",
"vote.durationDay": "天",
"vote.durationHour": "小时",
"vote.durationMinute": "分钟",
"vote.createdAt": "创建于",
"vote.edit": "编辑",
"vote.results": "结果",
"vote.preview": "预览",
"vote.page.mustComplete": "请完成",
"vote.page.rankHintRequired": "排名 1-N(必填)",
"vote.page.rankHintOptional": "排名 1-N(可选,可只排部分)",
"vote.page.previewHint": "预览模式:这是发布前的页面样例,提交已禁用",
"vote.page.previewSubmitHint": "预览模式,发布后即可提交",
"vote.publish": "发布",
"vote.close": "结束",
"vote.share": "复制链接",
"vote.copied": "已复制",
"vote.shareURL": "投票分享链接",
"vote.confirmPublish": "确定发布该投票吗?发布后即生成可分享链接。",
"vote.confirmClose": "确定结束该投票吗?结束后参与者将无法再投票。",
"vote.confirmDelete": "确定删除该投票吗?投票数据将一并删除,不可恢复!",
"vote.manageEntry": "管理投票",
"vote.manageTitle": "管理投票",
"vote.manageDesc": "创建与管理你的匿名投票", "vote.empty": "还没有投票,点击右上角\"新建投票\"开始吧~",
"vote.formLockedHint": "该投票已发布,仅可修改基本信息;类型与选项已锁定(避免破坏已收集的投票数据)",
"vote.formBasic": "基本设置",
"vote.formTitle": "投票标题",
"vote.formTitlePlaceholder": "例如:今天吃什么?",
"vote.formDescription": "描述 / 说明",
"vote.formDescriptionPlaceholder": "补充说明(可选)",
"vote.formType": "投票类型",
"vote.formVisibility": "展示方式",
"vote.formVisibilityPublic": "公开展示(探索页 + 提问箱页)",
"vote.formVisibilityLinkOnly": "仅链接分享",
"vote.formVoterScope": "投票范围",
"vote.formVoterScopeAnyone": "任何人(含匿名)",
"vote.formVoterScopeRegistered": "仅注册用户",
"vote.formShowResult": "结果可见时机",
"vote.formShowResultAfterVote": "投票后立即可见",
"vote.formShowResultAfterEnd": "结束后可见",
"vote.formShowResultOwnerOnly": "仅我可见",
"vote.formStartAt": "开始时间(留空立即开始)",
"vote.formEndAt": "结束时间(留空不限制)",
"vote.formMaxChoices": "最多可选数量",
"vote.formMaxVotesPerFingerprint": "每个投票每个设备限 1 次,使用同一浏览器重复提交将被拦截。",
"vote.formMaxVotesPerAccount": "每个投票每个注册账号限 1 次,使用同一账号重复提交将被拦截。",
"vote.formAllowCustom": "允许参与者补充自定义选项",
"vote.formRequireCaptcha": "匿名投票需通过验证码",
"vote.formOptions": "投票选项",
"vote.formRatingHint": "评分题无需配置选项(1-10 分)",
"vote.addOption": "添加选项",
"vote.optionPlaceholder": "选项内容",
"vote.formFields": "表单字段",
"vote.addField": "添加字段",
"vote.field": "字段",
"vote.formFieldType": "字段类型",
"vote.fieldTypeText": "单行文本",
"vote.fieldTypeTextarea": "多行文本",
"vote.fieldTypeSelect": "下拉选择",
"vote.fieldTypeRadio": "单选",
"vote.fieldTypeCheckbox": "多选",
"vote.fieldTypeNumber": "数字",
"vote.fieldTypeRank": "排序",
"vote.page.selectPlaceholder": "请选择",
"vote.page.avgNumber": "平均",
"vote.page.fieldFilled": "已填写",
"vote.fieldTypeRating": "评分",
"vote.formFieldLabel": "字段标签",
"vote.formFieldRequired": "必填",
"vote.formFieldMaxLength": "字数上限(0 不限制)",
"vote.formFieldOptions": "选项",
"vote.formFieldOptionsHint": "点击下方「添加选项」逐条添加,无需用逗号分隔",
"vote.fieldAllowCustom": "允许补充自定义选项",
"vote.formFieldOptionsPlaceholder": "如:满意,一般,不满意",
"vote.createDraft": "保存草稿",
"vote.builderHintOptions": "至少需要 2 个选项",
"vote.builderHintFields": "请至少添加 1 个字段",
"vote.builderHintFieldOptions": "选择题/排序题请填写选项(排序题至少 2 个)",
"vote.resultsAnswered": "回答数",
"vote.page.rankedTimes": "被排次数",
"vote.resultsTotal": "总参与人数",
"vote.resultsSubmissions": "表单回复",
"vote.resultsViewTexts": "查看文本提交",
"vote.resultsToggleHint": "点击展开或收起文本回复明细",
"vote.resultsOptions": "投票结果",
"vote.resultsVotes": "票",
"vote.export": "导出 CSV",
"vote.resultsEmpty": "还没有人参与,快来分享吧~",
// 匿名投票参与页
"vote.inlineTitle": "投票",
"vote.page.by": "发起的投票",
"vote.page.goVote": "去投票",
"vote.page.notStarted": "未开始",
"vote.page.notStartedHint": "该投票尚未开始,敬请期待~",
"vote.page.ended": "已结束",
"vote.page.endedHint": "该投票已结束",
"vote.page.active": "进行中",
"vote.page.participated": "已参与",
"vote.page.loginRequired": "该投票仅限注册用户参与,请先登录。",
"vote.page.anonymousHint": "该投票允许匿名参与,你的身份不会被记录(同一设备最多可参与 1 次)。",
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已参与过该投票,感谢参与!",
"vote.page.resultsTitle": "投票结果",
"vote.page.totalVotes": "总参与",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
"vote.page.optionColumn": "选项",
"vote.page.ratingLabel": "请评分",
"vote.page.ratingUnit": "分",
"vote.page.rankHint": "请为每个选项填写 1-N 的排名(数字越小越靠前,不可重复)",
"vote.page.rankPlaceholder": "排名",
"vote.page.multipleHint": "可多选",
"vote.page.customOption": "补充选项(可选)",
"vote.page.customPlaceholder": "填写你心中的其他选项",
"vote.page.customOther": "其他",
"vote.page.customFieldPlaceholder": "填写自定义选项…",
"vote.page.customStats": "自定义补充选项",
"vote.page.formCount": "已收到回复",
"vote.page.ownerManage": "管理此投票(仅你可见结果)",
// 单份投票的定量收集
"vote.formQuotaLimit": "收集上限(份)",
"vote.formQuotaHint": "0 表示不限制;收集到指定份数后自动结束该投票。",
"vote.flash.notFound": "投票不存在或已失效",
"vote.flash.notPublished": "该投票尚未发布",
"vote.flash.archived": "该投票已归档,无法参与",
"vote.flash.ended": "该投票已结束",
"vote.flash.notStarted": "该投票尚未开始",
"vote.flash.success": "投票成功!感谢参与",
"vote.err.noChoice": "请至少选择一个选项",
"vote.err.tooManyChoices": "最多只能选择 %d 个选项",
"vote.err.pickOne": "请选择一个选项",
"vote.err.invalidOption": "存在无效的选项",
"vote.err.optionDuplicate": "选项不能重复选择",
"vote.err.ratingRequired": "请给出 1-10 分的评分",
"vote.err.rankRequired": "请为每个选项填写 1-%d 的排名",
"vote.err.rankDuplicate": "排名不能重复",
"vote.err.fieldRequired": "请填写「%s」",
"vote.err.fieldCustomRequired": "请填写「%s」的自定义选项",
"vote.err.fieldCustomTooLong": "「%s」的自定义选项不能超过 %d 字",
"vote.err.fieldInvalidOption": "「%s」的选项无效",
"vote.err.fieldConfigError": "「%s」选项配置错误",
"vote.err.fieldRankIncomplete": "请填写「%s」的完整排名",
"vote.err.fieldRankRange": "「%s」排名必须是 1-%d 之间的数字",
"vote.err.fieldRankDuplicate": "「%s」排名不能重复",
"vote.err.fieldTooLong": "「%s」超出字数限制(最多 %d 字)",
"vote.err.fieldNotNumber": "「%s」必须是数字",
"vote.err.fieldRating": "「%s」请给出 1-10 分的评分",
"vote.err.unknownType": "未知的投票类型",
"vote.err.customTooLong": "自定义选项不能超过 %d 字",
"vote.err.blockWords": "提交内容中包含了投票发起人设置的屏蔽词,无法提交",
"vote.err.serialize": "提交内容序列化失败",
"captcha.error": "验证码错误",
"captcha.errEmpty": "验证码不能为空",
"captcha.errFormat": "验证码格式错误,请刷新后重试",
"captcha.errAnswerFormat": "验证码答案格式错误",
"captcha.errExpired": "验证码已过期,请刷新后重试",
"captcha.errService": "验证码服务异常,请稍后重试",
"captcha.errAnswerWrong": "验证码答案错误",
"censor.unavailable": "内容审核服务暂时不可用,请稍后再试",
// 功能管理:提问 / 私聊 / 投票 的开关、定时开关与定量收集
"admin.tabAccount": "用户设置",
"admin.featuresHint": "在「提问 / 私聊 / 投票」面板中管理各功能的开关、开放时段与收集上限;达到上限后对应功能自动关闭。",
"admin.featuresEntry": "设置",
"admin.featureHint.question": "关闭后访客无法提交新提问;达到收集上限后自动停止接收(含私密提问)。",
"admin.featureHint.chat": "关闭后访客无法发起新的私聊;已有会话不受影响。",
"admin.featureHint.vote": "关闭后提问箱页不再展示投票板块,访客也无法参与你的投票。单份投票可在编辑页单独设置收集上限。",
"admin.featureEnabled": "开启该功能",
"admin.featureOpenAt": "定时开放",
"admin.featureCloseAt": "定时关闭",
"admin.featureTimeHint": "留空表示不限制;开放时间之前、关闭时间之后该功能均不可用。",
"admin.featureSchedule": "循环定时(每周循环)",
"admin.featureScheduleStart": "每日开始",
"admin.featureScheduleEnd": "每日结束",
"admin.featureScheduleHint": "勾选每周固定开放日并设置每日时间段,按周循环生效;全部留空表示不启用。只填开放日(不填时间)表示该天全天开放;只填时间(不勾选任何日)表示每天该时段开放。",
"admin.featureMode": "收集模式",
"admin.featureModeAlways": "长期开放",
"admin.featureModeWindow": "定时收集",
"admin.featureModeRecurring": "循环定时",
"admin.featureModeHint": "长期开放:不限时间,保持开放直到手动关闭;定时收集:一次性开放 / 关闭时间窗口;循环定时:每周固定开放日循环生效。三种模式互斥,保存时未选模式的设置将被清空。",
"admin.featureQuotaLimit": "收集上限(条 / 份)",
"admin.featureQuotaHint": "0 表示不限制;累计收集达到上限后自动关闭,重置计数后可开启下一轮。",
"admin.featureQuotaCount": "已收集",
"admin.featureQuotaUnlimited": "不限制",
"admin.featureQuotaResetAt": "上次重置:",
"admin.featureResetQuota": "重置计数",
"admin.featureSave": "保存该功能设置",
// 功能管理:状态与提示
"feature.name.question": "提问",
"feature.name.chat": "私聊",
"feature.name.vote": "投票",
"feature.statusOpen": "进行中",
"feature.status.disabled": "已关闭",
"feature.status.not_started": "未开始",
"feature.status.ended": "已结束",
"feature.status.quota_reached": "已达上限",
"feature.saved": "功能设置已保存",
"feature.errTime": "%s 的时间格式不正确,请使用日期时间选择器",
"feature.errTimeRange": "%s 的定时关闭时间必须晚于定时开放时间",
"feature.errQuota": "%s 的收集上限需为 0 到 1000000 之间的整数",
"feature.errInvalid": "未知的功能类型",
"feature.quotaReset": "%s 的收集计数已重置",
"feature.errScheduleHalf": "%s 的循环定时开始与结束时间必须同时填写",
"feature.errScheduleTime": "%s 的循环定时时间格式不正确,请使用时间选择器",
"feature.errScheduleRange": "%s 的循环定时结束时间必须晚于开始时间",
// 功能关闭时对访客的提示
"feature.disabled.question": "提问箱主人已关闭提问功能",
"feature.disabled.chat": "该提问箱未开启私聊功能",
"feature.disabled.vote": "该提问箱已关闭投票功能",
"feature.not_started.question": "提问功能尚未开放",
"feature.not_started.chat": "私聊功能尚未开放",
"feature.not_started.vote": "投票功能尚未开放",
"feature.ended.question": "提问功能已结束收集",
"feature.ended.chat": "私聊功能已结束",
"feature.ended.vote": "投票功能已结束",
"feature.quota_reached.question": "提问功能已达到收集上限",
"feature.quota_reached.chat": "私聊功能已达到收集上限",
"feature.quota_reached.vote": "投票功能已达到收集上限",
// 认证:访客可见的提示(P0)
"auth.errBadCredential": "邮箱或密码错误",
"auth.errUserNotFound": "用户不存在",
"auth.errEmailFrequent": "邮件发送太频繁,请稍后再试",
"auth.errEmailFailed": "邮件发送失败,请稍后再试",
"auth.errCodeExpired": "验证码已过期",
"auth.errPasswordChanged": "密码修改成功",
"auth.errLoginRequired": "请先登录",
"auth.errDuplicateEmail": "该邮箱已被注册",
"auth.errDuplicateDomain": "该个性域名已被使用",
// 提问箱页:访客可见(P0)
"question.errWrongPassword": "密码错误",
"question.errAskPasswordWrong": "提问密码错误",
"question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。",
"question.errSelfChat": "不能给自己发起私聊",
"question.errBlockedChat": "你已被对方拉黑,无法发起私聊",
"question.errChatDailyLimit": "你今日已达发送上限(%d条),请明天再试",
"question.errDuplicateSubmit": "检测到重复提交,请稍后再试",
"question.errImageTooLarge": "图片文件大小不能大于 5Mb",
"question.errUploadImageFailed": "上传图片失败",
"question.errBlockWords": "提问内容中包含了提问箱主人设置的屏蔽词,发送失败",
"question.errAnsweredLocked": "已回答的问题不能标记为未读",
"question.flash.chatSent": "私聊请求已发送!你可以通过以下链接查看对话:%s",
"question.flash.answered": "回答发布成功!",
"question.flash.operated": "操作成功",
"question.flash.markedRead": "已标记为已读",
"question.flash.markedUnread": "已标记为未读",
// 追问:访客/箱主可见(P0)
"followup.errNoPermission": "无权操作",
"followup.errNotAnswered": "问题尚未被回答,暂时不能追问",
"followup.errLimit": "每条提问最多追问 %d 次,已达上限",
"followup.errWaitReply": "请等待主人回复上一条追问后再继续追问",
"followup.errNotFound": "追问不存在",
"followup.flash.sent": "追问发送成功!",
"followup.flash.answered": "回答成功!",
// 后台设置:箱主可见(P1)
"admin.flash.profileUpdated": "更新个人信息成功",
"admin.flash.chatUpdated": "私聊设置更新成功",
"admin.flash.pagePwdOff": "已关闭页面密码访问",
"admin.flash.pagePwdUpdated": "页面访问密码已更新",
"admin.flash.askPwdOff": "已关闭提问密码",
"admin.flash.askPwdUpdated": "提问密码已更新",
"admin.flash.tagsTooMany": "标签不能超过 5 个",
"admin.flash.tagsUpdated": "更新标签成功",
"admin.flash.tagTooLong": "每个标签不能超过 5 个字符:%s",
"admin.flash.blockWordsTooMany": "屏蔽词不能超过 10 个",
"admin.flash.blockWordTooLong": "屏蔽词长度不能超过 10 个字符:%s",
"admin.flash.harassmentUpdated": "更新防骚扰设置成功",
"admin.flash.blockWordsUpdated": "屏蔽词已更新",
"admin.flash.deactivated": "您的账号已停用,感谢您使用 狼的提问箱。期待未来还能再见 👋🏻",
"admin.export.errUser": "导出失败:获取用户信息失败",
"admin.export.errQuestion": "导出失败:获取问题信息失败",
// 投票管理:箱主可见(P1)
"vote.err.invalidID": "无效的投票 ID",
"vote.err.notExist": "投票不存在",
"vote.err.noPermission": "无权操作该投票",
"vote.err.type": "未知的投票类型",
"vote.err.titleRequired": "请填写投票标题",
"vote.err.titleTooLong": "投票标题不能超过 100 字",
"vote.err.visibility": "未知的可见性设置",
"vote.err.voterScope": "未知的投票范围设置",
"vote.err.showResult": "未知的结果展示设置",
"vote.err.timeRange": "结束时间必须晚于开始时间",
"vote.err.quota": "收集上限需为 0 到 1000000 之间的整数",
"vote.err.optionsTooFew": "请至少添加 2 个选项",
"vote.err.optionsFormat": "选项数据格式错误",
"vote.err.optionTooLong": "单个选项内容不能超过 %d 字",
"vote.err.maxChoicesExceed": "最多可选数量不能超过选项总数",
"vote.err.fieldsTooFew": "请至少添加 1 个表单字段",
"vote.err.fieldsFormat": "表单字段数据格式错误",
"vote.err.fieldsTooMany": "表单字段不能超过 50 个",
"vote.err.fieldType": "存在未知的表单字段类型",
"vote.err.fieldLabelMissing": "第 %d 个字段缺少标签",
"vote.err.fieldLabelTooLong": "第 %d 个字段的标签不能超过 100 字",
"vote.err.fieldLabelDuplicate": "字段标签「%s」重复,请使用不同的标签",
"vote.err.fieldMaxLength": "第 %d 个字段的字数上限需在 0-2000 之间",
"vote.err.fieldReservedOption": "第 %d 个字段(%s)的选项内容包含保留值,请更换",
"vote.err.fieldOptionsTooFew": "第 %d 个字段(%s)请至少填写 1 个选项",
"vote.err.fieldRankTooFew": "第 %d 个字段(%s)排序题至少需要 2 个选项",
"vote.err.fieldSerialize": "字段选项序列化失败",
"vote.err.editClosed": "已结束或归档的投票不允许编辑",
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
"vote.err.closePublishedOnly": "只有进行中的投票可以结束",
"vote.err.publishTitleRequired": "请先填写投票标题再发布",
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
"vote.err.publishOptionsRequired": "选项投票至少需要 2 个选项",
"vote.err.optionUpdateFailed": "选项更新失败,请重试",
"vote.err.fieldUpdateFailed": "表单字段更新失败,请重试",
"vote.flash.published": "投票已发布!分享链接:%s",
"vote.flash.updated": "投票信息已更新(进行中的投票不可修改类型与选项)",
"vote.flash.draftSaved": "已保存到草稿",
"vote.flash.closed": "投票已结束",
"vote.flash.deleted": "投票已删除",
"vote.err.timeFormat": "时间格式不正确,请使用日期时间选择器",
"vote.err.maxChoicesLimit": "最多可选数量不能超过 20",
"vote.err.descriptionTooLong": "投票描述不能超过 5000 字",
"profile.err.avatarTooLarge": "头像文件太大,最大支持 2MB",
"profile.err.backgroundTooLarge": "背景图文件太大,最大支持 2MB",
"profile.err.newPasswordTooShort": "新密码长度不能少于 8 位",
"profile.err.badOldPassword": "旧密码输入错误",
"admin.export.errCreateExcel": "导出失败:创建Excel文件失败",
"admin.export.errWriteExcel": "导出失败:写入Excel文件失败",
// profile 设置页 UI 重构:卡片分区 / 头像背景裁剪 / 改密强制登出
"profile.card.basic": "基础资料",
"profile.card.avatarBg": "头像与背景",
"profile.card.qr": "二维码样式",
"profile.card.password": "修改密码",
"profile.avatarCropHint": "裁剪框已锁定为 1:1 正方形",
"profile.backgroundCropHint": "裁剪框已锁定为 16:9 宽屏",
"profile.cropTitle": "裁剪图片",
"profile.cropConfirm": "确认裁剪",
"profile.err.avatarRatio": "头像图片必须为 1:1 正方形,请裁剪后再上传",
"profile.err.backgroundRatio": "背景图片必须为 16:9 宽高比,请裁剪后再上传",
"profile.flash.passwordChanged": "密码修改成功,已退出登录,请使用新密码重新登录",
"profile.pwdNote": "保存后当前会话立即失效,需使用新密码重新登录",
"profile.pickAvatar": "更换头像",
"profile.pickBackground": "更换背景",
"profile.saveBasic": "保存基础资料",
"profile.saveMedia": "保存头像与背景",
"profile.savePassword": "确认修改密码",
"profile.saveQR": "保存二维码样式",
"profile.saveSocial": "保存社交链接",
"profile.err.cropLibUnavailable": "图片裁剪组件加载失败,请刷新页面后重试",
// 验证码 JSON 接口:前端消费(P2)
"captcha.json.badRequest": "请求格式错误",
"captcha.json.idRequired": "验证码 ID 不能为空",
"captcha.json.genFailed": "生成验证码失败",
"chat.errDeletedByOwner": "该私聊对话已被对方删除。",
"chat.errBlockedView": "你已被对方拉黑,无法查看该私聊",
"chat.errEmpty": "消息不能为空",
"chat.errTooLong": "消息内容过长,请控制在 %d 字以内",
"chat.errConvDeletedSend": "该对话已被删除,无法发送消息",
"chat.errBlockedSend": "你已被对方拉黑,无法发送私信",
"chat.errBlockWords": "消息中包含了提问箱主人设置的屏蔽词,无法发送",
"chat.errDailyLimit": "你今日已达发送上限(%d条),请明天再试",
"chat.errLimitReached": "对话已达到上限,无法发送新消息",
"chat.errChatClosed": "该箱主已关闭私聊功能,无法发送消息",
"chat.errImageTooLarge": "图片大小不能超过 %dMB",
"chat.errUploadFailed": "图片上传失败,请稍后重试",
"chat.errNoPermission": "无权执行此操作",
"chat.flash.sent": "消息发送成功",
"chat.flash.deleted": "私聊对话已删除",
"chat.flash.operated": "操作成功",
"image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
"internal.errFlash": "服务内部错误,请稍后重试。",
"internal.errServer": "服务器内部错误",
"home.errStats": "获取统计信息失败",
"pixel.errPath": "非法的请求路径",
"proxy.errNotFound": "页面不存在",
"proxy.errInternal": "服务网关内部错误",
"explore.errRandomUser": "获取随机用户失败",
// 表单校验
"form.answer.content": "回答内容不能为空",
"form.answer.contentTooLong": "回答内容不能超过 %d 个字符",
"form.email.required": "请填写有效的电子邮箱地址",
"form.followup.answer": "追问回答不能为空",
"form.followup.answerTooLong": "追问回答不能超过 %d 个字符",
"form.followup.content": "追问内容不能为空",
"form.followup.contentTooLong": "追问内容不能超过 %d 个字符",
"form.login.password": "请输入密码",
"form.profile.backgroundimagealpha": "请填写二维码背景透明度",
"form.profile.colorHex": "请填写 7 位十六进制颜色代码(含 #)",
"form.profile.dotscale": "请填写二维码点大小",
"form.profile.intro": "个人介绍不能为空,且不超过 100 个字符",
"form.profile.linkMax": "主页链接长度不能超过 30 个字符",
"form.profile.name": "昵称不能为空,且不超过 20 个字符",
"form.profile.newPassword": "新密码长度不能超过 30 个字符",
"form.profile.passwordMax": "密码长度不能超过 30 个字符",
"form.question.content": "问题内容不能为空",
"form.question.contentTooLong": "问题内容不能超过 %d 个字符",
"form.question.receiveReplyEmail": "请填写有效的电子邮箱地址以接收回复",
"form.recaptcha": "请完成人机验证",
"form.recover.newPassword": "请设置 8 至 30 位的新密码",
"form.recover.repeatPassword": "两次输入的新密码不一致",
"form.register.domain": "个性域名只能包含字母、数字和下划线,长度为 3 至 20 位",
"form.register.email": "请填写有效的电子邮箱地址(不超过 100 个字符)",
"form.register.name": "昵称不能为空,且不超过 20 个字符",
"form.register.password": "请设置 8 至 30 位的密码",
"form.register.recaptcha": "无法加载人机验证,请刷新页面或切换网络后重试",
"form.register.repeatPassword": "两次输入的密码不一致",
}
+840
View File
@@ -0,0 +1,840 @@
// 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 i18n
// zhTWDict 繁體中文文案。
var zhTWDict = map[string]string{
// 導覽
"nav.explore": "探索",
"nav.mine": "我的",
"nav.register": "註冊",
"nav.login": "登入",
"nav.ask": "提問",
"nav.settings": "設定",
"nav.lang": "語言",
// 通用
"common.login": "登入",
"common.forgot": "忘記密碼",
"common.submit": "送出",
"common.cancel": "取消",
"common.save": "儲存",
"common.close": "關閉",
"common.delete": "刪除",
"common.moveUp": "上移",
"common.moveDown": "下移",
"common.share": "分享",
"common.verifying": "驗證中...",
"common.submitting": "提交中...",
"common.uploading": "上傳中...",
"common.lang": "語言",
"common.menu": "選單",
"common.pleaseWait": "請稍候...",
"common.backHome": "返回首頁",
"common.theme": "切換深色/淺色模式",
// 站點
"site.title": "狼的提問箱 | 匿名提問箱",
"site.description": "狼的提問箱——一個無需註冊、可匿名提問的匿名提問箱,安全地傾訴與回應。",
"site.keywords": "匿名提問箱,提問箱,匿名提問,狼的提問箱,匿名問答,匿名提問網站",
"site.footer.runtime": "提問箱已成功存活",
"site.footer.admin": "找站長",
"site.footer.notice": "公告板",
"site.footer.chat": "聊天室",
"site.footer.drive": "盤",
"site.footer.status": "監控板",
"site.footer.nav": "導航頁",
"site.maintenance": "本站正在維護中,請稍後再來~",
// 認證
"auth.login.title": "使用者登入",
"auth.login.email": "電子郵件",
"auth.login.password": "密碼",
"auth.login.submit": "登入",
"auth.login.forgot": "忘記密碼",
"auth.register.title": "使用者註冊",
"auth.register.name": "暱稱",
"auth.register.email": "電子郵件",
"auth.register.password": "密碼",
"auth.register.submit": "註冊",
"auth.forgot.title": "忘記密碼",
"auth.forgot.email": "電子郵件",
"auth.forgot.submit": "傳送重設郵件",
"auth.forgot.submitBtn": "找回密碼",
"auth.forgot.sent": "郵件已傳送至 %s,請查收。",
"auth.recover.title": "重設密碼",
"auth.recover.greeting": "%s,您正在重設您的密碼:",
"auth.recover.password": "新密碼",
"auth.recover.confirm": "確認密碼",
"auth.recover.submit": "重設密碼",
// 探索頁
"explore.title": "探索使用者標籤",
"explore.notice": "此處展示已開啟<span class=\"uk-text-bold\">「在首頁展示我的標籤」</span>的使用者。該功能預設關閉,若希望自己的提問箱在這裡被發現,請前往 <a href=\"/user/profile\" class=\"uk-text-bold\">個人設定</a> 手動開啟。",
"explore.shuffle": "換一批",
"explore.empty": "暫時沒有可展示的使用者,快去開啟吧~",
"explore.hotVotes": "熱門投票",
"explore.questionsTab": "提問",
"explore.votesTab": "投票",
"explore.votesTitle": "公開投票",
"explore.votesDesc": "瀏覽進行中的公開投票,選擇你感興趣的主題參與。",
"explore.randomVote": "隨機推薦",
"explore.allVotes": "全部投票",
"explore.noVotes": "暫時沒有可參與的公開投票。",
// 提問頁
"ask.scan": "掃碼向@%s提問",
"ask.anonymous": "誰都可以以匿名的方式提問",
"ask.registerOnly": "僅允許註冊使用者向TA提問",
"ask.nextOpenAt": "下次開放時間:%s",
"ask.lastCloseAt": "上次關閉時間:%s",
"weekday.0": "週日",
"weekday.1": "週一",
"weekday.2": "週二",
"weekday.3": "週三",
"weekday.4": "週四",
"weekday.5": "週五",
"weekday.6": "週六",
"ask.pinned": "已置頂",
"ask.share": "分享提問箱",
"ask.placeholder": "在此處撰寫你的問題...",
"ask.chatPlaceholder": "在此處撰寫你的私聊訊息...",
"ask.addImage": "新增圖片",
"ask.private": "回覆後不公開",
"ask.notify": "接收通知",
"ask.emailPlaceholder": "請輸入你的信箱地址",
"ask.submit": "傳送提問",
"ask.chat": "發起私聊",
"ask.chatDisabled": "該提問箱未開啟私聊",
"ask.chatLimit": "訊息上限:%d則",
"ask.history": "@%s 以前回答過的問題",
"ask.loginRequired": "需要登入",
"ask.loginRequiredChat": "發起私聊需要登入帳號",
"ask.goLogin": "去登入",
"ask.needPassword": "此提問箱需要密碼",
"ask.needLogin": "提問箱的主人設定了僅註冊使用者才能提問,你需要先登入才能向他提問。",
"ask.pleaseEmail": "請填寫接收通知的信箱地址",
"ask.generating": "正在產生分享圖…",
"ask.genFailed": "產生分享圖失敗,請稍後重試",
"ask.networkError": "網路錯誤",
"ask.shareFilename": "share.png",
// 問題條目
"qitem.answer": "回答",
"qitem.delete": "刪除",
"qitem.private": "設為私密",
"qitem.public": "設為公開",
"qitem.asked": "提問",
"qitem.pending": "等待回答",
"qitem.answeredBy": "@%s 於 %s 的回答",
"qitem.danger": "危險!",
"qitem.deleteConfirm": "你確定要刪除這個提問嗎?該操作不可恢復,請謹慎操作。",
"qitem.deleteConfirmBtn": "確認刪除",
"qitem.backToBox": "返回%s的提問箱",
"qitem.answerTitle": "回答問題",
"qitem.answerPlaceholder": "在此處撰寫你的回答...",
"qitem.emailNotifyHint": "提問人留下了自己的電子郵箱,在你第一次回覆該問題後,提問人將會收到一封郵件通知。",
"qitem.submitAnswer": "回答",
"qitem.updateAnswer": "更新回答",
"qitem.askMore": "再問點別的問題?",
"qitem.markRead": "標記為已讀",
"qitem.markUnread": "標記為未讀",
"qitem.readLocked": "已回答,已鎖定為已讀",
// 個人設定頁
"admin.profile": "個人資訊",
"admin.lang": "介面語言",
"admin.langHint": "選擇訪客訪問你的提問箱時顯示的語言(後台頁面跟隨此設定)",
"admin.email": "電子郵件",
"admin.name": "暱稱",
"admin.nameColor": "暱稱顏色",
"admin.oldPassword": "舊密碼",
"admin.newPassword": "新密碼",
"admin.notify": "新提問通知",
"admin.personalize": "個人化設定",
"admin.intro": "提問箱介紹",
"admin.introColor": "提問箱介紹顏色",
"admin.qrBgColor": "QR Code 背景色(亮色部分)",
"admin.qrPdColor": "QR Code 探測圖形顏色(邊角方形)",
"admin.qrFgColor": "QR Code 前景色",
"admin.qrDotSize": "QR Code 點大小 (←小 大→)",
"admin.qrBgAlpha": "QR Code 背景透明度 (←不透明 很透明→)",
"admin.avatar": "個人頭像",
"admin.background": "提問箱背景",
"admin.qrBackground": "QR Code 背景圖",
"admin.social": "社交資訊設定",
"admin.harassment": "防騷擾設定",
"admin.askLimit": "提問限制",
"admin.blockWords": "屏蔽詞設定(Enter 或逗號分隔,最多支援 10 個屏蔽詞,每個屏蔽詞最大長度為 10)",
"admin.chat": "私聊設定",
"admin.chatMaxMessages": "私聊訊息條數上限",
"admin.chatDailyLimit": "單一使用者每日私訊條數上限",
"admin.tags": "標籤設定",
"admin.tagsHint": "標籤(最多5個,每個最多5字,Enter 或逗號分隔)",
"admin.tagsPlaceholder": "例如:動漫,音樂,遊戲",
"admin.tagInputHint": "輸入後按 Enter 或逗號新增",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "頁面訪問密碼",
"admin.pagePasswordHint": "設定訪問密碼(留空則關閉)",
"admin.askPassword": "提問密碼",
"admin.askPasswordHint": "設定提問密碼(留空則關閉)",
"admin.saveProfile": "儲存修改",
"admin.updated": "更新個人資訊成功",
"admin.logout": "登出",
"admin.export": "匯出資料",
// 後台設定(補充)
"admin.oldPasswordHint": "修改密碼使用,留空則不修改",
"admin.newPasswordHint": "6-64 位,留空則不修改",
"admin.notifyEmail": "郵件",
"admin.useCustomFg": "啟用自訂前景色(否則自動比對)",
"admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生",
"admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)",
"admin.backgroundPlaceholder": "上傳提問箱背景",
"admin.qrBackgroundPlaceholder": "上傳QR Code背景(顏色過深的背景會導致QR Code無法辨識)",
"admin.qrPreviewHint": "選擇圖片後預覽將即時更新",
"admin.preview": "即時預覽",
"admin.themePreset": "預設主題",
"admin.themeDefault": "預設",
"admin.themeDark": "暗夜",
"admin.themeOcean": "海洋",
"admin.themeSunset": "日落",
"admin.resetDefaults": "🔄 恢復預設值",
"admin.previewHint": "調整僅用於預覽,最終效果點擊「儲存修改」後生效",
"admin.socialHint": "勾選後填寫主頁地址(無需 https://),未勾選的項目將不會在前台顯示。",
"admin.enableAcfun": "啟用 AcFun",
"admin.enableBili": "啟用 Bilibili",
"admin.enableWeibo": "啟用 微博",
"admin.enableXiaohongshu": "啟用 小紅書",
"admin.enableBlog": "啟用 個人部落格",
"admin.registerOnly": "僅允許註冊使用者向我提問",
"admin.updateHarassment": "更新防騷擾設定",
"admin.globalBlockWords": "全局屏蔽詞",
"admin.globalBlockWordsHint": "屏蔽詞對提問、私聊(對方傳送給你的訊息)與投票提交內容生效;三個設定頁同步顯示同一份設定,任意一處修改即時生效。",
"admin.updateBlockWords": "儲存屏蔽詞",
"admin.allowChat": "允許註冊使用者向我發起私聊",
"admin.dailyLimitHint": "0 表示不限制",
"admin.updateChat": "更新私聊設定",
"admin.showInHome": "在首頁展示我的標籤(允許其他使用者透過標籤搜尋到我)",
"admin.updateTags": "更新標籤",
"admin.updatePagePassword": "更新頁面密碼",
"admin.pagePasswordPlaceholder": "輸入頁面訪問密碼",
"admin.updateAskPassword": "更新提問密碼",
"admin.askPasswordPlaceholder": "輸入提問密碼",
"admin.account": "帳號設定",
"admin.exportHint": "您可以匯出您在 %s 中的所有個人資料,包括你的基本資訊、收到的問題以及回答。",
"admin.exportJSON": "導出全部資料(JSON)",
"admin.exportJSONHint": "包含提問、回答、追問與私聊記錄,可完整備份或遷移。",
"admin.deactivate": "停用我的帳號",
"admin.deactivateHint": "您隨時可以選擇停用您的帳號。停用後,您的帳號將無法登入,您的提問箱頁面以及提問將無法訪問,其他人也無法再給您發送新的提問。<b>該操作無法撤銷!請謹慎操作!</b>",
"admin.socialRequired": "請填寫連結或取消勾選",
// 問題管理頁
"qlist.title": "我的提問",
"qlist.answered": "已回答",
"qlist.pending": "待回答",
"qlist.private": "私密",
"qlist.empty": "還沒有提問,快把提問箱分享出去吧~",
"qlist.action": "操作",
"qlist.answerNow": "去回答",
"qlist.sort": "排序",
"qlist.status": "狀態",
"qlist.type": "類型",
"qlist.latest": "最新",
"qlist.oldest": "最早",
"qlist.all": "全部",
"qlist.public": "一般提問",
"qlist.privateQuestion": "私密提問",
"qlist.received": "我收到的提問",
"qlist.myChats": "我發起的私聊",
"qlist.statsTotal": "總提問數",
"qlist.statsAnswered": "已回答",
"qlist.statsUnanswered": "未回答",
"qlist.statsPrivate": "私密提問",
"qlist.statsPinned": "置頂",
"qlist.statsChats": "私聊會話",
"qlist.searchPlaceholder": "搜尋問題或回答內容...",
"qlist.searchBtn": "搜尋",
"qlist.searchClear": "清除搜尋",
"qlist.questionsTab": "提問",
"qlist.votesTab": "投票",
"qlist.chatsTab": "私聊",
"qlist.receivedChats": "我收到的私聊",
"qlist.chatEmpty": "還沒有私聊,把提問箱分享出去讓大家來私聊吧~",
"qlist.pinnedShort": "置頂",
"qlist.privateShort": "私密",
"qlist.chatStatsTotal": "總私聊",
"qlist.chatStatsReceived": "我收到的",
"qlist.chatStatsInitiated": "我發起的",
"qlist.chatStatsClosed": "已關閉",
"qlist.unansweredShort": "待答",
"qlist.readShort": "已讀",
"qlist.unreadShort": "未讀",
"qitem.markReadShort": "標記",
"qlist.pinShort": "置頂",
"qlist.unpinShort": "取消置頂",
"qlist.myVotes": "我建立的投票",
"qlist.loadMore": "載入更多",
"qlist.newVote": "新建投票",
"qlist.edit": "編輯",
"qlist.untitledVote": "未命名投票",
"qlist.blocked": "已拉黑",
"qlist.unanswered": "未回答",
// 站點 Logo 後綴
"site.logoSuffix": "的提問箱",
"site.stats": "提問箱資訊統計",
// 驗證碼彈窗
"captcha.title": "人機驗證",
"captcha.hint": "請計算下方的算術題以驗證你不是機器人",
"captcha.refresh": "換一題",
"captcha.submit": "驗證並送出",
"captcha.loading": "載入中...",
"captcha.loadFailed": "載入失敗",
"captcha.loadRetry": "載入失敗,請重試",
"captcha.inputAnswer": "請輸入答案",
"captcha.wrongAnswer": "答案錯誤,請重新計算",
"captcha.verifyFailed": "驗證失敗,請重試",
// 註冊頁補充
"auth.register.domain.prefix": "個性網域(你的問答箱網址將會是:%s + ",
"auth.register.domain.suffix": " + 你在下面文字框中填寫的內容)",
"auth.register.confirm": "確認密碼",
"auth.register.language": "介面語言",
"auth.register.languageDefault": "跟隨站點預設",
"auth.register.success": "註冊成功,歡迎來到 狼的提問箱!",
// 探索頁補充
"explore.recommend": "隨機推薦",
"explore.search": "搜尋標籤...",
"explore.sortLatest": "最新加入",
"explore.sortOldest": "最早加入",
"explore.noTags": "無標籤",
"explore.noRecommend": "暫無推薦用戶",
"explore.hotTags": "熱門標籤",
"explore.hotQuestions": "熱門問題",
"explore.noResult": "暫無符合條件的用戶。",
"explore.lazy": "這個人很懶,什麼都沒寫~",
"explore.prevPage": "上一頁",
"explore.nextPage": "下一頁",
"explore.loading": "載入中...",
"explore.loadFailed": "載入失敗",
// 提問箱密碼頁
"password.title": "此提問箱需要密碼",
"password.desc": "此提問箱已設定存取密碼,請輸入密碼查看內容",
"password.input": "請輸入存取密碼",
"password.enter": "驗證進入",
"password.submit": "確認",
// 問題不存在
"qitem.notFound": "問題不存在或已被刪除",
// 聊天頁
"chat.title": "私聊",
"chat.input": "輸入訊息...",
"chat.send": "傳送",
"chat.delete": "刪除對話",
"chat.block": "封鎖對方",
"chat.unblock": "取消封鎖",
"chat.empty": "還沒有訊息,說點什麼吧~",
"chat.withAsker": "與提問者的私聊",
"chat.withUser": "與 %s 的私聊",
"chat.closed": "對話已關閉",
"chat.closedNoSend": "對話已關閉,無法發送新訊息",
"chat.remaining": "訊息剩餘:%d 條(共 %d 條)",
"chat.me": "我",
"chat.other": "對方",
"chat.userBlocked": "該使用者已被拉黑",
"chat.addImage": "新增圖片(可選,最大5MB)",
"chat.confirm": "確認",
"chat.confirmBlock": "確定要拉黑該使用者嗎?他將無法再發送私訊。",
"chat.confirmUnblock": "確定要取消封鎖該使用者嗎?他將可以再次發送私訊。",
"chat.deleteConfirm": "確定要刪除這條私聊對話嗎?對方將無法再看到此對話,且操作不可恢復。",
"maintenance.title": "NekoBox 維護中...",
"maintenance.desc": "NekoBox 正在進行維護,服務暫時不可用。過一會再來吧~",
// 單題分享(v4)
"qitem.shareCard": "分享此提問",
// 追問(v4)
"followup.title": "追問記錄",
"followup.content": "追問:",
"followup.answerPlaceholder": "回答這條追問...",
"followup.answerBtn": "回答追問",
"followup.waiting": "等待回答中...",
"followup.askMore": "繼續追問",
"followup.remaining": "還可追問 %d / %d 次",
"followup.contentPlaceholder": "補充你的問題...",
"followup.waitingReply": "你的追加提問已發出,等待提問箱主人回覆中...",
"followup.submit": "提交追問",
// 置頂(v4)
"qlist.pinned": "置頂中",
"qlist.pin": "置頂",
"qlist.unpin": "取消置頂",
"qlist.readStatus": "閱讀狀態",
"qlist.read": "已讀",
"qlist.unread": "未讀",
"qlist.statsRead": "已讀",
"qlist.statsUnread": "未讀",
// 提問成功頁(v4)
"success.title": "提問發送成功!",
"success.yourQuestion": "你的提問:",
"success.emailSent": "已向你的信箱發送了該提問的私密連結,也可以使用下方連結隨時查看提問、接收回答與繼續追問。",
"success.saveLink": "<strong>請務必保存下面的私密連結!</strong>由於你沒有留下信箱,此連結(含口令 Token)是你查看提問、接收回答與繼續追問的<strong>唯一憑證</strong>,遺失後將無法找回。",
"success.privateLink": "你的提問私密連結(點擊複製保存)",
"success.copy": "複製",
"success.copied": "已複製 ✓",
"success.copyFailed": "複製失敗",
"success.token": "口令 Token:%s(提問人憑 Token 存取私密連結)",
"success.viewQuestion": "查看我的提問",
"success.backToBox": "返回%s的提問箱",
"success.pageTitle": "提問成功 - %s",
// 停用頁
"deactivate.title": "帳號停用",
"deactivate.desc": "停用後你的提問箱將無法存取,且所有資料將被刪除,此操作不可恢復!",
"deactivate.confirm": "我確認要停用帳號",
"deactivate.submit": "停用帳號",
// 匿名投票
"nav.votes": "投票",
"common.back": "返回",
"vote.listTitle": "投票管理",
"vote.newTitle": "新建投票",
"vote.editTitle": "編輯投票",
"vote.resultsTitle": "投票結果",
"vote.statsTotal": "投票總數",
"vote.statsPublished": "進行中",
"vote.createNew": "新建投票",
"vote.typeSingle": "單選",
"vote.typeMultiple": "多選",
"vote.typeRating": "評分",
"vote.typeRank": "排序",
"vote.typeForm": "表單收集",
"vote.statusNotStarted": "未開始",
"vote.statusDraft": "草稿",
"vote.statusPublished": "進行中",
"vote.statusEnded": "已結束",
"vote.statusArchived": "已歸檔",
"vote.participants": "參與",
"vote.remainLabel": "剩餘",
"vote.perpetual": "無限期",
"vote.durationDay": "天",
"vote.durationHour": "小時",
"vote.durationMinute": "分鐘",
"vote.createdAt": "建立於",
"vote.edit": "編輯",
"vote.results": "結果",
"vote.preview": "預覽",
"vote.page.mustComplete": "請完成",
"vote.page.rankHintRequired": "排名 1-N(必填)",
"vote.page.rankHintOptional": "排名 1-N(可選,可只排部分)",
"vote.page.previewHint": "預覽模式:這是發布前的頁面範例,提交已停用",
"vote.page.previewSubmitHint": "預覽模式,發布後即可提交",
"vote.publish": "發布",
"vote.close": "結束",
"vote.share": "複製連結",
"vote.copied": "已複製",
"vote.shareURL": "投票分享連結",
"vote.confirmPublish": "確定發布該投票嗎?發布後即產生可分享連結。",
"vote.confirmClose": "確定結束該投票嗎?結束後參與者將無法再投票。",
"vote.confirmDelete": "確定刪除該投票嗎?投票資料將一併刪除,不可復原!",
"vote.manageEntry": "管理投票",
"vote.manageTitle": "管理投票",
"vote.manageDesc": "建立與管理你的匿名投票", "vote.empty": "還沒有投票,點擊右上角\"新建投票\"開始吧~",
"vote.formLockedHint": "該投票已發布,僅可修改基本資訊;類型與選項已鎖定(避免破壞已收集的投票資料)",
"vote.formBasic": "基本設定",
"vote.formTitle": "投票標題",
"vote.formTitlePlaceholder": "例如:今天吃什麼?",
"vote.formDescription": "描述 / 說明",
"vote.formDescriptionPlaceholder": "補充說明(可選)",
"vote.formType": "投票類型",
"vote.formVisibility": "展示方式",
"vote.formVisibilityPublic": "公開展示(探索頁 + 提問箱頁)",
"vote.formVisibilityLinkOnly": "僅連結分享",
"vote.formVoterScope": "投票範圍",
"vote.formVoterScopeAnyone": "任何人(含匿名)",
"vote.formVoterScopeRegistered": "僅註冊用戶",
"vote.formShowResult": "結果可見時機",
"vote.formShowResultAfterVote": "投票後立即可見",
"vote.formShowResultAfterEnd": "結束後可見",
"vote.formShowResultOwnerOnly": "僅我可見",
"vote.formStartAt": "開始時間(留空立即開始)",
"vote.formEndAt": "結束時間(留空不限制)",
"vote.formMaxChoices": "最多可選數量",
"vote.formMaxVotesPerFingerprint": "每個投票每個裝置限 1 次,使用同一瀏覽器重複提交將被攔截。",
"vote.formMaxVotesPerAccount": "每個投票每個註冊帳號限 1 次,使用同一帳號重複提交將被攔截。",
"vote.formAllowCustom": "允許參與者補充自訂選項",
"vote.formRequireCaptcha": "匿名投票需通過驗證碼",
"vote.formOptions": "投票選項",
"vote.formRatingHint": "評分題無需設定選項(1-10 分)",
"vote.addOption": "新增選項",
"vote.optionPlaceholder": "選項內容",
"vote.formFields": "表單欄位",
"vote.addField": "新增欄位",
"vote.field": "欄位",
"vote.formFieldType": "欄位類型",
"vote.fieldTypeText": "單行文字",
"vote.fieldTypeTextarea": "多行文字",
"vote.fieldTypeSelect": "下拉選擇",
"vote.fieldTypeRadio": "單選",
"vote.fieldTypeCheckbox": "多選",
"vote.fieldTypeNumber": "數字",
"vote.fieldTypeRank": "排序",
"vote.page.selectPlaceholder": "請選擇",
"vote.page.avgNumber": "平均",
"vote.page.fieldFilled": "已填寫",
"vote.fieldTypeRating": "評分",
"vote.formFieldLabel": "欄位標籤",
"vote.formFieldRequired": "必填",
"vote.formFieldMaxLength": "字數上限(0 不限制)",
"vote.formFieldOptions": "選項",
"vote.formFieldOptionsHint": "點擊下方「新增選項」逐條新增,不需用逗號分隔",
"vote.fieldAllowCustom": "允許補充自訂選項",
"vote.formFieldOptionsPlaceholder": "如:滿意,一般,不滿意",
"vote.createDraft": "儲存草稿",
"vote.builderHintOptions": "至少需要 2 個選項",
"vote.builderHintFields": "請至少新增 1 個欄位",
"vote.builderHintFieldOptions": "選擇題/排序題請填寫選項(排序題至少 2 個)",
"vote.resultsAnswered": "回答數",
"vote.page.rankedTimes": "被排次數",
"vote.resultsTotal": "總參與人數",
"vote.resultsSubmissions": "表單回覆",
"vote.resultsViewTexts": "查看文字提交",
"vote.resultsToggleHint": "點擊展開或收起文字回覆明細",
"vote.resultsOptions": "投票結果",
"vote.resultsVotes": "票",
"vote.export": "匯出 CSV",
"vote.resultsEmpty": "還沒有人參與,快來分享吧~",
// 匿名投票參與頁
"vote.inlineTitle": "投票",
"vote.page.by": "發起的投票",
"vote.page.goVote": "去投票",
"vote.page.notStarted": "未開始",
"vote.page.notStartedHint": "該投票尚未開始,敬請期待~",
"vote.page.ended": "已結束",
"vote.page.endedHint": "該投票已結束",
"vote.page.active": "進行中",
"vote.page.participated": "已參與",
"vote.page.loginRequired": "該投票僅限註冊用戶參與,請先登入。",
"vote.page.anonymousHint": "該投票允許匿名參與,你的身分不會被記錄(同一裝置最多可參與 1 次)。",
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已參與過該投票,感謝參與!",
"vote.page.resultsTitle": "投票結果",
"vote.page.totalVotes": "總參與",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
"vote.page.optionColumn": "選項",
"vote.page.ratingLabel": "請評分",
"vote.page.ratingUnit": "分",
"vote.page.rankHint": "請為每個選項填寫 1-N 的排名(數字越小越靠前,不可重複)",
"vote.page.rankPlaceholder": "排名",
"vote.page.multipleHint": "可多選",
"vote.page.customOption": "補充選項(可選)",
"vote.page.customPlaceholder": "填寫你心中的其他選項",
"vote.page.customOther": "其他",
"vote.page.customFieldPlaceholder": "填寫自訂選項…",
"vote.page.customStats": "自訂補充選項",
"vote.page.formCount": "已收到回覆",
"vote.page.ownerManage": "管理此投票(僅你可見結果)",
// 單份投票的定量收集
"vote.formQuotaLimit": "收集上限(份)",
"vote.formQuotaHint": "0 表示不限制;收集到指定份數後自動結束該投票。",
"vote.flash.notFound": "該投票不存在或已失效",
"vote.flash.notPublished": "該投票尚未發布",
"vote.flash.archived": "該投票已歸檔,無法參與",
"vote.flash.ended": "該投票已結束",
"vote.flash.notStarted": "該投票尚未開始",
"vote.flash.success": "投票成功!感謝參與",
"vote.err.noChoice": "請至少選擇一個選項",
"vote.err.tooManyChoices": "最多只能選擇 %d 個選項",
"vote.err.pickOne": "請選擇一個選項",
"vote.err.invalidOption": "存在無效的選項",
"vote.err.optionDuplicate": "選項不能重複選擇",
"vote.err.ratingRequired": "請給出 1-10 分的評分",
"vote.err.rankRequired": "請為每個選項填寫 1-%d 的排名",
"vote.err.rankDuplicate": "排名不能重複",
"vote.err.fieldRequired": "請填寫「%s」",
"vote.err.fieldCustomRequired": "請填寫「%s」的自訂選項",
"vote.err.fieldCustomTooLong": "「%s」的自訂選項不能超過 %d 字",
"vote.err.fieldInvalidOption": "「%s」的選項無效",
"vote.err.fieldConfigError": "「%s」選項配置錯誤",
"vote.err.fieldRankIncomplete": "請填寫「%s」的完整排名",
"vote.err.fieldRankRange": "「%s」排名必須是 1-%d 之間的數字",
"vote.err.fieldRankDuplicate": "「%s」排名不能重複",
"vote.err.fieldTooLong": "「%s」超出字數限制(最多 %d 字)",
"vote.err.fieldNotNumber": "「%s」必須是數字",
"vote.err.fieldRating": "「%s」請給出 1-10 分的評分",
"vote.err.unknownType": "未知的投票類型",
"vote.err.customTooLong": "自訂選項不能超過 %d 字",
"vote.err.blockWords": "提交內容中包含了投票發起人設定的屏蔽詞,無法提交",
"vote.err.serialize": "提交內容序列化失敗",
"captcha.error": "驗證碼錯誤",
"captcha.errEmpty": "驗證碼不能為空",
"captcha.errFormat": "驗證碼格式錯誤,請重新整理後重試",
"captcha.errAnswerFormat": "驗證碼答案格式錯誤",
"captcha.errExpired": "驗證碼已過期,請重新整理後重試",
"captcha.errService": "驗證碼服務異常,請稍後再試",
"captcha.errAnswerWrong": "驗證碼答案錯誤",
"censor.unavailable": "內容審核服務暫時不可用,請稍後再試",
// 功能管理:提問 / 私聊 / 投票 的開關、定時開關與定量收集
"admin.tabAccount": "用戶設定",
"admin.featuresHint": "在「提問 / 私聊 / 投票」面板中管理各功能的開關、開放時段與收集上限;達到上限後對應功能自動關閉。",
"admin.featuresEntry": "設定",
"admin.featureHint.question": "關閉後訪客無法提交新提問;達到收集上限後自動停止接收(含私密提問)。",
"admin.featureHint.chat": "關閉後訪客無法發起新的私聊;已有會話不受影響。",
"admin.featureHint.vote": "關閉後提問箱頁不再展示投票板塊,訪客也無法參與你的投票。單份投票可在編輯頁單獨設定收集上限。",
"admin.featureEnabled": "啟用該功能",
"admin.featureOpenAt": "定時開放",
"admin.featureCloseAt": "定時關閉",
"admin.featureTimeHint": "留空表示不限制;開放時間之前、關閉時間之後該功能均不可用。",
"admin.featureSchedule": "循環定時(每週循環)",
"admin.featureScheduleStart": "每日開始",
"admin.featureScheduleEnd": "每日結束",
"admin.featureScheduleHint": "勾選每週固定開放日並設定每日時間段,按週循環生效;全部留空表示不啟用。只填開放日(不填時間)表示該天全天開放;只填時間(不勾選任何日)表示每天該時段開放。",
"admin.featureMode": "收集模式",
"admin.featureModeAlways": "長期開放",
"admin.featureModeWindow": "定時收集",
"admin.featureModeRecurring": "循環定時",
"admin.featureModeHint": "長期開放:不限時間,保持開放直到手動關閉;定時收集:一次性開放 / 關閉時間視窗;循環定時:每週固定開放日循環生效。三種模式互斥,儲存時未選模式的設定將被清空。",
"admin.featureQuotaLimit": "收集上限(條 / 份)",
"admin.featureQuotaHint": "0 表示不限制;累計收集達到上限後自動關閉,重設計數後可開啟下一輪。",
"admin.featureQuotaCount": "已收集",
"admin.featureQuotaUnlimited": "不限制",
"admin.featureQuotaResetAt": "上次重設:",
"admin.featureResetQuota": "重設計數",
"admin.featureSave": "儲存該功能設定",
// 功能管理:狀態與提示
"feature.name.question": "提問",
"feature.name.chat": "私聊",
"feature.name.vote": "投票",
"feature.statusOpen": "進行中",
"feature.status.disabled": "已關閉",
"feature.status.not_started": "未開始",
"feature.status.ended": "已結束",
"feature.status.quota_reached": "已達上限",
"feature.saved": "功能設定已儲存",
"feature.errTime": "%s 的時間格式不正確,請使用日期時間選擇器",
"feature.errTimeRange": "%s 的定時關閉時間必須晚於定時開放時間",
"feature.errQuota": "%s 的收集上限需為 0 到 1000000 之間的整數",
"feature.errInvalid": "未知的功能類型",
"feature.quotaReset": "%s 的收集計數已重設",
"feature.errScheduleHalf": "%s 的循環定時開始與結束時間必須同時填寫",
"feature.errScheduleTime": "%s 的循環定時時間格式不正確,請使用時間選擇器",
"feature.errScheduleRange": "%s 的循環定時結束時間必須晚於開始時間",
// 功能關閉時對訪客的提示
"feature.disabled.question": "提問箱主人已關閉提問功能",
"feature.disabled.chat": "該提問箱未開啟私聊功能",
"feature.disabled.vote": "該提問箱已關閉投票功能",
"feature.not_started.question": "提問功能尚未開放",
"feature.not_started.chat": "私聊功能尚未開放",
"feature.not_started.vote": "投票功能尚未開放",
"feature.ended.question": "提問功能已結束收集",
"feature.ended.chat": "私聊功能已結束",
"feature.ended.vote": "投票功能已結束",
"feature.quota_reached.question": "提問功能已達到收集上限",
"feature.quota_reached.chat": "私聊功能已達到收集上限",
"feature.quota_reached.vote": "投票功能已達到收集上限",
// 認證:訪客可見的提示(P0)
"auth.errBadCredential": "郵箱或密碼錯誤",
"auth.errUserNotFound": "使用者不存在",
"auth.errEmailFrequent": "郵件發送太頻繁,請稍後再試",
"auth.errEmailFailed": "郵件發送失敗,請稍後再試",
"auth.errCodeExpired": "驗證碼已過期",
"auth.errPasswordChanged": "密碼修改成功",
"auth.errLoginRequired": "請先登入",
"auth.errDuplicateEmail": "該郵箱已被註冊",
"auth.errDuplicateDomain": "該個性網域已被使用",
// 提問箱頁:訪客可見(P0)
"question.errWrongPassword": "密碼錯誤",
"question.errAskPasswordWrong": "提問密碼錯誤",
"question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。",
"question.errSelfChat": "不能給自己發起私聊",
"question.errBlockedChat": "你已被對方封鎖,無法發起私聊",
"question.errChatDailyLimit": "你今日已達發送上限(%d則),請明天再試",
"question.errDuplicateSubmit": "偵測到重複提交,請稍後再試",
"question.errImageTooLarge": "圖片檔案大小不能大於 5Mb",
"question.errUploadImageFailed": "上傳圖片失敗",
"question.errBlockWords": "提問內容包含了提問箱主人設定的屏蔽詞,發送失敗",
"question.errAnsweredLocked": "已回答的問題不能標記為未讀",
"question.flash.chatSent": "私聊請求已發送!你可以透過以下連結查看對話:%s",
"question.flash.answered": "回答發布成功!",
"question.flash.operated": "操作成功",
"question.flash.markedRead": "已標記為已讀",
"question.flash.markedUnread": "已標記為未讀",
// 追問:訪客/箱主可見(P0)
"followup.errNoPermission": "無權操作",
"followup.errNotAnswered": "問題尚未被回答,暫時不能追問",
"followup.errLimit": "每條提問最多追問 %d 次,已達上限",
"followup.errWaitReply": "請等待主人回覆上一條追問後再繼續追問",
"followup.errNotFound": "追問不存在",
"followup.flash.sent": "追問發送成功!",
"followup.flash.answered": "回答成功!",
// 後台設定:箱主可見(P1)
"admin.flash.profileUpdated": "更新個人資訊成功",
"admin.flash.chatUpdated": "私聊設定更新成功",
"admin.flash.pagePwdOff": "已關閉頁面密碼存取",
"admin.flash.pagePwdUpdated": "頁面存取密碼已更新",
"admin.flash.askPwdOff": "已關閉提問密碼",
"admin.flash.askPwdUpdated": "提問密碼已更新",
"admin.flash.tagsTooMany": "標籤不能超過 5 個",
"admin.flash.tagsUpdated": "更新標籤成功",
"admin.flash.tagTooLong": "每個標籤不能超過 5 個字元:%s",
"admin.flash.blockWordsTooMany": "屏蔽詞不能超過 10 個",
"admin.flash.blockWordTooLong": "屏蔽詞長度不能超過 10 個字元:%s",
"admin.flash.harassmentUpdated": "更新防騷擾設定成功",
"admin.flash.blockWordsUpdated": "屏蔽詞已更新",
"admin.flash.deactivated": "您的帳號已停用,感謝您使用 狼的提問箱。期待未來還能再見 👋🏻",
"admin.export.errUser": "匯出失敗:獲取使用者資訊失敗",
"admin.export.errQuestion": "匯出失敗:獲取問題資訊失敗",
// 投票管理:箱主可見(P1)
"vote.err.invalidID": "無效的投票 ID",
"vote.err.notExist": "投票不存在",
"vote.err.noPermission": "無權操作該投票",
"vote.err.type": "未知的投票類型",
"vote.err.titleRequired": "請填寫投票標題",
"vote.err.titleTooLong": "投票標題不能超過 100 字",
"vote.err.visibility": "未知的可見性設定",
"vote.err.voterScope": "未知的投票範圍設定",
"vote.err.showResult": "未知的結果展示設定",
"vote.err.timeRange": "結束時間必須晚於開始時間",
"vote.err.quota": "收集上限需為 0 到 1000000 之間的整數",
"vote.err.optionsTooFew": "請至少新增 2 個選項",
"vote.err.optionsFormat": "選項資料格式錯誤",
"vote.err.optionTooLong": "單個選項內容不能超過 %d 字",
"vote.err.maxChoicesExceed": "最多可選數量不能超過選項總數",
"vote.err.fieldsTooFew": "請至少新增 1 個表單欄位",
"vote.err.fieldsFormat": "表單欄位資料格式錯誤",
"vote.err.fieldsTooMany": "表單欄位不能超過 50 個",
"vote.err.fieldType": "存在未知的表單欄位類型",
"vote.err.fieldLabelMissing": "第 %d 個欄位缺少標籤",
"vote.err.fieldLabelTooLong": "第 %d 個欄位的標籤不能超過 100 字",
"vote.err.fieldLabelDuplicate": "欄位標籤「%s」重複,請使用不同的標籤",
"vote.err.fieldMaxLength": "第 %d 個欄位的字數上限需在 0-2000 之間",
"vote.err.fieldReservedOption": "第 %d 個欄位(%s)的選項內容包含保留值,請更換",
"vote.err.fieldOptionsTooFew": "第 %d 個欄位(%s)請至少填寫 1 個選項",
"vote.err.fieldRankTooFew": "第 %d 個欄位(%s)排序題至少需要 2 個選項",
"vote.err.fieldSerialize": "欄位選項序列化失敗",
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
"vote.err.closePublishedOnly": "只有進行中的投票可以結束",
"vote.err.publishTitleRequired": "請先填寫投票標題再發布",
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
"vote.err.publishOptionsRequired": "選項投票至少需要 2 個選項",
"vote.err.optionUpdateFailed": "選項更新失敗,請重試",
"vote.err.fieldUpdateFailed": "表單欄位更新失敗,請重試",
"vote.flash.published": "投票已發布!分享連結:%s",
"vote.flash.updated": "投票資訊已更新(進行中的投票不可修改類型與選項)",
"vote.flash.draftSaved": "已儲存到草稿",
"vote.flash.closed": "投票已結束",
"vote.flash.deleted": "投票已刪除",
"vote.err.timeFormat": "時間格式不正確,請使用日期時間選擇器",
"vote.err.maxChoicesLimit": "最多可選數量不能超過 20",
"vote.err.descriptionTooLong": "投票描述不能超過 5000 字",
"profile.err.avatarTooLarge": "頭像檔案太大,最大支援 2MB",
"profile.err.backgroundTooLarge": "背景圖檔案太大,最大支援 2MB",
"profile.err.newPasswordTooShort": "新密碼長度不能少於 8 位",
"profile.err.badOldPassword": "舊密碼輸入錯誤",
"admin.export.errCreateExcel": "匯出失敗:建立Excel檔案失敗",
"admin.export.errWriteExcel": "匯出失敗:寫入Excel檔案失敗",
// profile 設定頁 UI 重構:卡片分區 / 頭像背景裁切 / 改密強制登出
"profile.card.basic": "基本資料",
"profile.card.avatarBg": "頭像與背景",
"profile.card.qr": "二維碼樣式",
"profile.card.password": "修改密碼",
"profile.avatarCropHint": "裁切框已鎖定為 1:1 正方形",
"profile.backgroundCropHint": "裁切框已鎖定為 16:9 寬螢幕",
"profile.cropTitle": "裁切圖片",
"profile.cropConfirm": "確認裁切",
"profile.err.avatarRatio": "頭像圖片必須為 1:1 正方形,請裁切後再上傳",
"profile.err.backgroundRatio": "背景圖片必須為 16:9 寬高比,請裁切後再上傳",
"profile.flash.passwordChanged": "密碼修改成功,已登出,請使用新密碼重新登入",
"profile.pwdNote": "儲存後目前工作階段立即失效,需使用新密碼重新登入",
"profile.pickAvatar": "更換頭像",
"profile.pickBackground": "更換背景",
"profile.saveBasic": "儲存基本資料",
"profile.saveMedia": "儲存頭像與背景",
"profile.savePassword": "確認修改密碼",
"profile.saveQR": "儲存 QR Code 樣式",
"profile.saveSocial": "儲存社交連結",
"profile.err.cropLibUnavailable": "圖片裁切元件載入失敗,請重新整理頁面後重試",
// 驗證碼 JSON 介面:前端消費(P2)
"captcha.json.badRequest": "請求格式錯誤",
"captcha.json.idRequired": "驗證碼 ID 不能為空",
"captcha.json.genFailed": "生成驗證碼失敗",
"chat.errDeletedByOwner": "該私聊對話已被對方刪除。",
"chat.errBlockedView": "你已被對方拉黑,無法查看該私聊",
"chat.errEmpty": "消息不能為空",
"chat.errTooLong": "消息內容過長,請控制在 %d 字以內",
"chat.errConvDeletedSend": "該對話已被刪除,無法發送消息",
"chat.errBlockedSend": "你已被對方拉黑,無法發送私信",
"chat.errBlockWords": "訊息中包含了提問箱主人設定的屏蔽詞,無法傳送",
"chat.errDailyLimit": "你今日已達發送上限(%d條),請明天再試",
"chat.errLimitReached": "對話已達到上限,無法發送新消息",
"chat.errChatClosed": "該箱主已關閉私聊功能,無法發送消息",
"chat.errImageTooLarge": "圖片大小不能超過 %dMB",
"chat.errUploadFailed": "圖片上傳失敗,請稍後重試",
"chat.errNoPermission": "無權執行此操作",
"chat.flash.sent": "消息發送成功",
"chat.flash.deleted": "私聊對話已刪除",
"chat.flash.operated": "操作成功",
"image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳",
"internal.errFlash": "服務內部錯誤,請稍後重試。",
"internal.errServer": "伺服器內部錯誤",
"home.errStats": "獲取統計資訊失敗",
"pixel.errPath": "非法的請求路徑",
"proxy.errNotFound": "頁面不存在",
"proxy.errInternal": "服務網關內部錯誤",
"explore.errRandomUser": "獲取隨機用戶失敗",
// 表单校验
"form.answer.content": "回答內容不能為空",
"form.answer.contentTooLong": "回答內容不能超過 %d 個字元",
"form.email.required": "請填寫有效的電子郵件地址",
"form.followup.answer": "追問回答不能為空",
"form.followup.answerTooLong": "追問回答不能超過 %d 個字元",
"form.followup.content": "追問內容不能為空",
"form.followup.contentTooLong": "追問內容不能超過 %d 個字元",
"form.login.password": "請輸入密碼",
"form.profile.backgroundimagealpha": "請填寫 QR Code 背景透明度",
"form.profile.colorHex": "請填寫 7 位十六進制顏色代碼(含 #)",
"form.profile.dotscale": "請填寫 QR Code 點大小",
"form.profile.intro": "個人介紹不能為空,且不超過 100 個字元",
"form.profile.linkMax": "主頁連結長度不能超過 30 個字元",
"form.profile.name": "暱稱不能為空,且不超過 20 個字元",
"form.profile.newPassword": "新密碼長度不能超過 30 個字元",
"form.profile.passwordMax": "密碼長度不能超過 30 個字元",
"form.question.content": "問題內容不能為空",
"form.question.contentTooLong": "問題內容不能超過 %d 個字元",
"form.question.receiveReplyEmail": "請填寫有效的電子郵件地址以接收回覆",
"form.recaptcha": "請完成人機驗證",
"form.recover.newPassword": "請設定 8 至 30 位的新密碼",
"form.recover.repeatPassword": "兩次輸入的新密碼不一致",
"form.register.domain": "個性網域只能包含字母、數字與底線,長度為 3 至 20 位",
"form.register.email": "請填寫有效的電子郵件地址(不超過 100 個字元)",
"form.register.name": "暱稱不能為空,且不超過 20 個字元",
"form.register.password": "請設定 8 至 30 位的密碼",
"form.register.recaptcha": "無法人機驗證,請重新整理頁面或更換網路後重試",
"form.register.repeatPassword": "兩次輸入的密碼不一致",
}
+133
View File
@@ -0,0 +1,133 @@
// 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 i18n 提供多语言翻译支持,支持简体中文、繁体中文、英文、日语四种语言。
package i18n
import (
"fmt"
"strings"
)
// Language 语言代码常量。
const (
LangZhCN = "zh-CN" // 简体中文
LangZhTW = "zh-TW" // 繁體中文
LangEn = "en" // English
LangJa = "ja" // 日本語
Default = LangZhCN
)
// LangInfo 描述一种支持的语言。
type LangInfo struct {
Code string // 语言代码,如 "zh-CN"
Name string // 语言自身名称,如 "简体中文" / "繁體中文" / "English" / "日本語"
}
// supported 支持的语语言列表,顺序即切换按钮展示顺序。
var supported = []LangInfo{
{Code: LangZhCN, Name: "简体中文"},
{Code: LangZhTW, Name: "繁體中文"},
{Code: LangEn, Name: "English"},
{Code: LangJa, Name: "日本語"},
}
// Supported 返回全部支持的语言列表。
func Supported() []LangInfo {
out := make([]LangInfo, len(supported))
copy(out, supported)
return out
}
// Resolve 规范化语言代码:支持返回对应代码,未知代码回退到默认语言。
func Resolve(lang string) string {
lang = strings.TrimSpace(lang)
if lang == "" {
return Default
}
switch strings.ToLower(lang) {
case "zh-cn", "zh_cn", "zh":
return LangZhCN
case "zh-tw", "zh_tw", "zh-hant", "zh_hant", "zh-hk", "zh_hk", "zh_mo", "tw":
return LangZhTW
case "en", "en-us", "en_us", "en-gb":
return LangEn
case "ja", "ja-jp", "ja_jp":
return LangJa
}
return Default
}
// Name 返回语言代码对应的显示名。
func Name(lang string) string {
lang = Resolve(lang)
for _, l := range supported {
if l.Code == lang {
return l.Name
}
}
return "简体中文"
}
// dictionary 翻译字典:语言代码 -> (key -> 文案)。
// 文案中的 %s 等占位符由 T 函数按传入参数填充。
var dictionary = map[string]map[string]string{
LangZhCN: zhCNDict,
LangZhTW: zhTWDict,
LangEn: enDict,
LangJa: jaDict,
}
// T 返回指定语言下 key 对应的翻译文案,支持 fmt 风格参数填充。
// 若 key 不存在,回退顺序:指定语言 -> 简体中文 -> 返回 key 本身,
// 方便开发期发现缺失词条。
func T(lang, key string, args ...interface{}) string {
lang = Resolve(lang)
text := lookup(lang, key)
if text == "" {
text = lookup(LangZhCN, key)
}
if text == "" {
return key
}
if len(args) > 0 {
text = fmt.Sprintf(text, args...)
}
return text
}
// FeatureClosedMessage 返回功能被关闭时面向访客的提示文案。
// feature 取值见 db.FeatureQuestion / db.FeatureChat / db.FeatureVote;
// reason 为空串表示功能可用,此时返回空串(调用方应先用 Reason 判断是否可用)。
//
// 放在 i18n 而非 db 包,避免 i18n 反向依赖数据层。
func FeatureClosedMessage(lang, feature, reason string) string {
if reason == "" {
return ""
}
return T(lang, "feature."+reason+"."+feature)
}
// FeatureName 返回功能的显示名称。
func FeatureName(lang, feature string) string {
return T(lang, "feature.name."+feature)
}
// FeatureStatusText 返回管理页展示的功能状态文案。
func FeatureStatusText(lang, reason string) string {
if reason == "" {
return T(lang, "feature.statusOpen")
}
return T(lang, "feature.status."+reason)
}
func lookup(lang, key string) string {
if dict, ok := dictionary[lang]; ok {
if text, ok := dict[key]; ok {
return text
}
}
return ""
}
+76
View File
@@ -0,0 +1,76 @@
// 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 i18n
import (
"testing"
)
// TestDictionaryComplete 校验四种语言的字典 key 完全一致,防止漏翻译。
func TestDictionaryComplete(t *testing.T) {
ref := dictionary[LangZhCN]
langs := []string{LangZhTW, LangEn, LangJa}
for _, lang := range langs {
dict := dictionary[lang]
if len(dict) != len(ref) {
t.Errorf("%s: 词条数量 %d != 简体中文 %d", lang, len(dict), len(ref))
}
for key := range ref {
if _, ok := dict[key]; !ok {
t.Errorf("%s: 缺少词条 %q", lang, key)
}
}
for key := range dict {
if _, ok := ref[key]; !ok {
t.Errorf("%s: 多出简体中文没有的词条 %q", lang, key)
}
}
}
}
func TestResolve(t *testing.T) {
cases := map[string]string{
"": LangZhCN,
"zh-cn": LangZhCN,
"zh": LangZhCN,
"zh-TW": LangZhTW,
"zh-Hant": LangZhTW,
"en": LangEn,
"en-US": LangEn,
"ja": LangJa,
"ja-JP": LangJa,
"fr": LangZhCN, // 未知回退
" En ": LangEn, // 自动去除首尾空白
"zh_cn": LangZhCN,
"zh_tw": LangZhTW,
"zh_hk": LangZhTW, // 香港繁体(下划线变体)
}
for in, want := range cases {
if got := Resolve(in); got != want {
t.Errorf("Resolve(%q) = %q, want %q", in, got, want)
}
}
}
func TestT(t *testing.T) {
if got := T(LangZhCN, "nav.explore"); got != "探索" {
t.Errorf("zh-CN nav.explore = %q", got)
}
if got := T(LangEn, "nav.explore"); got != "Explore" {
t.Errorf("en nav.explore = %q", got)
}
if got := T(LangJa, "nav.explore"); got != "探索" {
t.Errorf("ja nav.explore = %q", got)
}
// 参数填充
if got := T(LangEn, "auth.recover.greeting", "admin"); got != "admin, you are resetting your password:" {
t.Errorf("en auth.recover.greeting with arg = %q", got)
}
// 未知 key 回退返回 key 本身
if got := T(LangEn, "no.such.key"); got != "no.such.key" {
t.Errorf("unknown key = %q", got)
}
}
+81
View File
@@ -0,0 +1,81 @@
// Package imagecheck 提供图片尺寸校验,用于在上传/解码前拦截"解压炸弹"。
package imagecheck
import (
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"math"
)
// maxImageSide 是单边最大像素,MaxImagePixels 为其平方。
const maxImageSide = 4096
// MaxImageSide 导出单边最大像素,供上层在错误提示中向用户展示具体限制。
const MaxImageSide = maxImageSide
// MaxImagePixels 限制解码后的最大像素总量(4096×4096),用于拦截"解压炸弹":
// 超大纯色 PNG/GIF 压缩后体积很小,但 image.Decode 会按宽×高分配内存导致 OOM。
const MaxImagePixels = maxImageSide * maxImageSide
// ErrImageTooLarge 表示图片像素总量超过 MaxImagePixels,
// 是所有"图片尺寸超限"的统一错误,供各上传入口识别并展示统一文案。
var ErrImageTooLarge = errors.New(fmt.Sprintf(
"图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
maxImageSide, maxImageSide,
))
// CheckPixelLimit 仅读取图片头部(image.DecodeConfig)校验宽高与像素总量,
// 在完整解码前拦截超大图片,避免内存耗尽。
//
// 说明:对 GIF 同样适用——image.DecodeConfig 对 GIF 只读取逻辑屏幕宽高(不解码帧),
// 成本极低;但大逻辑屏幕的 GIF 在后续 image.Decode 时仍会按屏幕尺寸分配内存,故必须校验。
//
// 注意:该方法会读取(消耗)传入的 reader,调用方需自行重建/复位 reader。
func CheckPixelLimit(r io.Reader) error {
cfg, _, err := image.DecodeConfig(r)
if err != nil {
return err
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil
}
if int64(cfg.Width)*int64(cfg.Height) > MaxImagePixels {
return ErrImageTooLarge
}
return nil
}
// ErrBadAspectRatio 表示图片宽高比不符合用途要求(如头像必须 1:1、背景必须 16:9)。
// 由 storage 在头像/背景上传入口校验后返回,route 层负责映射为对应语言的提示文案。
var ErrBadAspectRatio = errors.New("image aspect ratio does not meet the requirement")
// DefaultAspectTolerance 是宽高比允许的绝对偏差。
// 客户端裁剪输出即为精确比例,此处仅用于拦截绕过裁剪直传的明显不符图片,
// 因此使用窄容差(±0.01);宽高比越界(如 4:3 传成 16:9)会稳定触发拒绝。
const DefaultAspectTolerance = 0.01
// CheckAspectRatio 仅读取图片头部校验宽高比是否接近 wantW:wantH(绝对偏差 ≤ tolerance)。
// 与 CheckPixelLimit 一致:只做 DecodeConfig 级轻量检查,不解码全图。
// 注意:该方法会读取(消耗)传入的 reader,调用方需自行重建/复位 reader。
func CheckAspectRatio(r io.Reader, wantW, wantH float64, tolerance float64) error {
if wantW <= 0 || wantH <= 0 {
return nil
}
cfg, _, err := image.DecodeConfig(r)
if err != nil {
return err
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil
}
actual := float64(cfg.Width) / float64(cfg.Height)
if math.Abs(actual-wantW/wantH) > tolerance {
return ErrBadAspectRatio
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
package imagecheck
import (
"bytes"
"encoding/binary"
"errors"
"image"
"image/color"
"image/png"
"testing"
)
// buildGIFHeader 构造仅含文件头与逻辑屏幕描述符的最小 GIF(无像素数据),
// 用于让 image.DecodeConfig 读取出宽高。GIF 无 CRC,构造简单可靠。
func buildGIFHeader(t *testing.T, width, height uint16) []byte {
t.Helper()
var buf bytes.Buffer
buf.WriteString("GIF89a")
// Logical Screen Descriptor: width(2 LE) + height(2 LE) + packed(1) + bgcolor(1) + aspect(1)
if err := binary.Write(&buf, binary.LittleEndian, width); err != nil {
t.Fatalf("write width: %v", err)
}
if err := binary.Write(&buf, binary.LittleEndian, height); err != nil {
t.Fatalf("write height: %v", err)
}
buf.Write([]byte{0x00, 0x00, 0x00}) // 无全局颜色表
return buf.Bytes()
}
func TestCheckPixelLimit_NormalPNG(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
for x := 0; x < 100; x++ {
for y := 0; y < 100; y++ {
img.Set(x, y, color.RGBA{R: 255, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
if err := CheckPixelLimit(bytes.NewReader(buf.Bytes())); err != nil {
t.Fatalf("normal image should pass, got %v", err)
}
}
func TestCheckPixelLimit_AtLimit(t *testing.T) {
// 恰好等于上限 4096×4096,应通过(校验为严格大于才拒绝)。
data := buildGIFHeader(t, 4096, 4096)
if err := CheckPixelLimit(bytes.NewReader(data)); err != nil {
t.Fatalf("4096×4096 should pass, got %v", err)
}
}
func TestCheckPixelLimit_TooLarge(t *testing.T) {
// 4096×4097 超过上限,应拒绝并返回统一错误。
data := buildGIFHeader(t, 4096, 4097)
err := CheckPixelLimit(bytes.NewReader(data))
if err == nil {
t.Fatal("oversized image should be rejected, got nil")
}
if !errors.Is(err, ErrImageTooLarge) {
t.Fatalf("expected ErrImageTooLarge, got %v", err)
}
}
func TestCheckPixelLimit_InvalidData(t *testing.T) {
err := CheckPixelLimit(bytes.NewReader([]byte("this is not an image")))
if err == nil {
t.Fatal("invalid data should error, got nil")
}
if errors.Is(err, ErrImageTooLarge) {
t.Fatal("invalid data should not be classified as ErrImageTooLarge")
}
}
// encodePNG 生成指定宽高的纯色 PNG,供比例校验测试使用。
func encodePNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func TestCheckAspectRatio_Pass(t *testing.T) {
// 1920×1080 精确 16:9
if err := CheckAspectRatio(bytes.NewReader(encodePNG(t, 1920, 1080)), 16, 9, DefaultAspectTolerance); err != nil {
t.Fatalf("1920×1080 should pass 16:9, got %v", err)
}
// 1:1 正方形
if err := CheckAspectRatio(bytes.NewReader(encodePNG(t, 512, 512)), 1, 1, DefaultAspectTolerance); err != nil {
t.Fatalf("512×512 should pass 1:1, got %v", err)
}
// 容差内的微小偏差(1281×720 ≈ 1.779,仍属 16:9)
if err := CheckAspectRatio(bytes.NewReader(encodePNG(t, 1281, 720)), 16, 9, DefaultAspectTolerance); err != nil {
t.Fatalf("1281×720 should pass within tolerance, got %v", err)
}
}
func TestCheckAspectRatio_Reject(t *testing.T) {
// 4:3 直传背景图应被拒绝
err := CheckAspectRatio(bytes.NewReader(encodePNG(t, 800, 600)), 16, 9, DefaultAspectTolerance)
if err == nil {
t.Fatal("800×600 should be rejected as 16:9")
}
if !errors.Is(err, ErrBadAspectRatio) {
t.Fatalf("expected ErrBadAspectRatio, got %v", err)
}
// 竖长条不能当头像
if err := CheckAspectRatio(bytes.NewReader(encodePNG(t, 300, 500)), 1, 1, DefaultAspectTolerance); err == nil {
t.Fatal("300×500 should be rejected as 1:1")
}
}
func TestCheckAspectRatio_InvalidData(t *testing.T) {
// 无法解码的数据不应误判为比例不符
err := CheckAspectRatio(bytes.NewReader([]byte("this is not an image")), 1, 1, DefaultAspectTolerance)
if err == nil {
t.Fatal("invalid data should error, got nil")
}
if errors.Is(err, ErrBadAspectRatio) {
t.Fatal("invalid data should not be classified as ErrBadAspectRatio")
}
}
+146
View File
@@ -0,0 +1,146 @@
// 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 mail
import (
"bytes"
"crypto/tls"
"embed"
"fmt"
"html/template"
"strings"
"time"
"github.com/pkg/errors"
"gopkg.in/gomail.v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/templates"
)
func SendNewQuestionMail(email, domain string, questionID uint, questionContent string, token string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
"question": questionContent,
}
return sendTemplateMail(email, "【狼的提问箱】您有一个新的提问", templates.FS, "mail/new-question.html", params)
}
func SendNewQuestionMailToUser(email, domain string, questionID uint, questionContent string, token string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
"question": questionContent,
}
return sendTemplateMail(email, "【狼的提问箱】您发送了一个新的提问", templates.FS, "mail/new-question-to-user.html", params)
}
func SendNewAnswerMail(email, domain string, questionID uint, question, answer string, token string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token),
"question": question,
"answer": answer,
}
return sendTemplateMail(email, "【狼的提问箱】您的提问有了回复", templates.FS, "mail/new-answer.html", params)
}
func SendPasswordRecoveryMail(email, code string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/recover-password?code=%s", conf.App.ExternalURL, code),
"email": email,
}
return sendTemplateMail(email, "【狼的提问箱】账号密码找回", templates.FS, "mail/password-recovery.html", params)
}
// SendNewFollowUpMail 通知提问箱主人:有人对某条已回答的提问进行了追问。
// 链接无需 token,主人登录后即可查看。
func SendNewFollowUpMail(email, domain string, questionID uint, questionContent string, followUpContent string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, domain, questionID),
"question": questionContent,
"followup": followUpContent,
}
return sendTemplateMail(email, "【狼的提问箱】您的提问有了追问", templates.FS, "mail/new-follow-up.html", params)
}
// SendNewConversationMail 通知有新私聊发起(使用 token 而非 conversationID)
func SendNewConversationMail(email, askerName, domain string, token string, firstMessage string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/chat/%s", conf.App.ExternalURL, domain, token),
"asker": askerName,
"content": firstMessage,
}
return sendTemplateMail(email, "【狼的提问箱】有人发起了私聊", templates.FS, "mail/new-conversation.html", params)
}
// SendNewMessageMail 通知私聊中有新消息(使用 token 而非 conversationID)
func SendNewMessageMail(email, senderName, domain string, token string, content string) error {
params := map[string]string{
"link": fmt.Sprintf("%s/_/%s/chat/%s", conf.App.ExternalURL, domain, token),
"sender": senderName,
"content": content,
}
return sendTemplateMail(email, "【狼的提问箱】私聊新消息", templates.FS, "mail/new-message.html", params)
}
// SendNewVoteSubmissionMail 通知箱主:表单投票收到新回复(链接指向管理后台结果页)。
func SendNewVoteSubmissionMail(email, voteTitle string, voteID uint) error {
params := map[string]string{
"link": fmt.Sprintf("%s/user/votes/%d/results", conf.App.ExternalURL, voteID),
"title": voteTitle,
}
return sendTemplateMail(email, "【狼的提问箱】您的投票收到了新回复", templates.FS, "mail/new-vote.html", params)
}
// SendSecurityAlertMail 发送安全告警:检测到疑似批量扫描/探测并触发自动封禁时通知管理员。
// provider 为来源 IP 的网络归属标签(见 internal/security/ipinfo),proxied 表示
// IP 属于 CDN/WAF/代理网络(此时封禁出口 IP 可能无法拦截真实攻击者,邮件中额外提示)。
func SendSecurityAlertMail(ip, provider string, proxied bool, samplePaths []string, durationMinutes int) error {
proxyHint := ""
if proxied {
proxyHint = "1"
}
params := map[string]string{
"ip": ip,
"provider": provider,
"proxy_hint": proxyHint,
"paths": strings.Join(samplePaths, "、"),
"duration": fmt.Sprintf("%d", durationMinutes),
"time": time.Now().Format("2006-01-02 15:04:05"),
}
return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】检测到疑似批量扫描,已自动封禁 IP", templates.FS, "mail/security-alert.html", params)
}
func sendTemplateMail(email, title string, templateFS embed.FS, templatePath string, params map[string]string) error {
var content bytes.Buffer
t, err := template.ParseFS(templateFS, templatePath)
if err != nil {
return errors.Wrap(err, "parse template file")
}
params["year"] = time.Now().Format("2006")
if err := t.Execute(&content, params); err != nil {
return errors.Wrap(err, "execute template")
}
return sendMail(email, title, content.String())
}
func sendMail(to, title, content string) error {
m := gomail.NewMessage()
m.SetHeader("From", fmt.Sprintf("TamaBox <%s>", conf.Mail.Account))
m.SetHeader("To", to)
m.SetHeader("Subject", title)
m.SetBody("text/html", content)
d := gomail.NewDialer(
conf.Mail.SMTP,
conf.Mail.Port,
conf.Mail.Account,
conf.Mail.Password,
)
// 默认校验证书;仅当配置了 skip_tls_verify=true(自签名/内网 SMTP)时跳过。
if conf.Mail.SkipTLSVerify {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
return d.DialAndSend(m)
}
+59
View File
@@ -0,0 +1,59 @@
package mail
import (
"bytes"
"html/template"
"testing"
"github.com/tamakyi/TamaBox/templates"
)
// TestSecurityAlertTemplateRenders 保证 security-alert.html 模板可解析,
// 且新增的「网络归属 / 代理网络提示」字段在参数齐全时正确渲染、
// proxy_hint 为空时不渲染提示块。
func TestSecurityAlertTemplateRenders(t *testing.T) {
tmpl, err := template.ParseFS(templates.FS, "mail/security-alert.html")
if err != nil {
t.Fatalf("parse template: %v", err)
}
base := map[string]string{
"ip": "203.0.113.7",
"provider": "Cloudflare(CDN)",
"paths": "/admin、/.env",
"duration": "15",
"time": "2026-09-08 12:00:00",
"year": "2026",
}
// 1. 代理网络命中:应有归属行 + 代理提示
var withHint bytes.Buffer
params := cloneParams(base)
params["proxy_hint"] = "1"
if err := tmpl.Execute(&withHint, params); err != nil {
t.Fatalf("execute with proxy_hint: %v", err)
}
out := withHint.String()
for _, want := range []string{"203.0.113.7", "网络归属", "Cloudflare(CDN)", "代理网络"} {
if !bytes.Contains([]byte(out), []byte(want)) {
t.Errorf("rendered mail missing %q", want)
}
}
// 2. 普通网络:不应出现代理提示块
var noHint bytes.Buffer
if err := tmpl.Execute(&noHint, cloneParams(base)); err != nil {
t.Fatalf("execute without proxy_hint: %v", err)
}
if bytes.Contains(noHint.Bytes(), []byte("代理网络")) {
t.Errorf("proxy hint rendered without proxy_hint param")
}
}
func cloneParams(src map[string]string) map[string]string {
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
+45
View File
@@ -0,0 +1,45 @@
package route
import (
"net/http"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/shared"
)
// autobanMiddleware 自动封禁中间件:挂载在静态黑名单之后、业务路由之前。
// - 已封禁 IP 直接 403,不再进入业务
// - 敏感路径命中 → 计入窗口统计(达到阈值触发临时封禁 + 邮件告警)
// - 真正的 404 探测由 f.NotFound 处理器单独计数(见 route.go),
// 不再把业务性的"302 → /"(语言切换 / 登出 / 投票不存在 / 黑名单跳转等)误判为扫描。
// - 核心检测逻辑见 internal/security/autoban(可独立测试);告警发送函数由本层注入。
func autobanMiddleware(b *autoban.Banner) flamego.Handler {
return func(ctx flamego.Context) {
// 经 shared.LimitKey 归一化后再计数与判定:IPv4 原样,IPv6 聚合到 /64。
// 否则攻击者可在单个 /64 内轮换地址,使每个键只命中一次而永不触发阈值。
// 记录与判定必须使用同一个键,否则封禁将无法命中。
ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request))
if ip == "" {
return
}
if b.IsBanned(ip) {
ctx.ResponseWriter().WriteHeader(http.StatusForbidden)
_, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden"))
return
}
path := ctx.Request().URL.Path
kind := autoban.KindNone
if autoban.IsSensitivePath(path) {
kind = autoban.KindSensitive
}
if kind != autoban.KindNone {
defer b.Record(ip, path, kind)
}
// 让下游业务路由先执行,敏感路径的计数延后到响应返回后记录。
ctx.Next()
}
}
+54
View File
@@ -0,0 +1,54 @@
// 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 route
import (
"net/http"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/form"
)
// requestBodyLimitMiddleware 在任何业务代码读取请求体之前,全局封顶单次请求体总大小,
// 上限与 form.Bind 共用 form.MaxRequestBodySize(12 MiB),单一事实来源。
//
// 为什么需要全局层(而不是只依赖 form.Bind 内部限制):
// 1. 走 form.Bind 的路由中,Bind 内 MaxBytesReader 的执行顺序晚于 Contexter 组级
// CSRF 校验 —— flamego/csrf 的 Validate 在请求缺少 X-CSRF-Token 头时通过
// FormValue("_csrf") 惰性解析整包 body(multipart 超过 32 MiB 内存阈值后无上限
// 落盘临时文件),校验失败写出的 400 又发生在路由级限流之前。攻击者无需有效
// CSRF token、也不受限流约束,即可持续 POST 超大 multipart 打满磁盘。
// 2. chat /send、vote /submit 等不走 form.Bind 的入口此前完全没有请求体上限。
//
// 挂载于 internal/route/route.go 的 f.Use 链(autoban 之后、所有 Group 之前),
// 因此任何后续解析(CSRF FormValue、form.Bind、业务 FormValue)都发生在受限 reader 上。
// 合法最大单请求约为 8 MiB 单图(storage.MaxUploadBytes)+ 表单开销,12 MiB 不会误伤。
func requestBodyLimitMiddleware() flamego.Handler {
return func(ctx flamego.Context) {
req := ctx.Request()
// 无 body 的方法不处理(避免对静态资源等无谓开销)。
switch req.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
default:
return
}
// Content-Length 明确超限时直接 413 快速失败,不进入解析流程。
// (chunked 或未知长度请求的 ContentLength 为 0/-1,走下方 MaxBytesReader 惰性封顶。)
if req.ContentLength > form.MaxRequestBodySize {
http.Error(ctx.ResponseWriter(), http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}
// 其余情况:用 MaxBytesReader 包一层。MaxBytesReader 在读取超过上限时令后续
// Read 报错,使 ParseForm / ParseMultipartForm 失败 —— 超大 body 不会落盘或读入内存。
// 注意:c.Request() 返回 flamego 的包装类型,其 Body 是方法而非字段,
// 必须取内嵌的 *http.Request 才能替换 Body(与 internal/form/form.go 同款写法)。
httpReq := req.Request
httpReq.Body = http.MaxBytesReader(ctx.ResponseWriter(), httpReq.Body, form.MaxRequestBodySize)
}
}
+81
View File
@@ -0,0 +1,81 @@
// 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 route
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/form"
)
// TestRequestBodyLimitEnforced 是 P1-1(请求体 12MiB 上限必须在任何解析之前全局生效)
// 的回归测试。
//
// 背景:此前 MaxBytesReader 只装在 form.Bind(路由级),但 Contexter 组级 CSRF 校验
// (无 X-CSRF-Token 头时经 FormValue("_csrf") 惰性解析整包 body)先于 Bind 执行,
// 超大 multipart 会在路由级限制生效前无上限落盘临时文件耗尽磁盘;chat /send、
// vote /submit 等不走 Bind 的入口更是完全没有上限。修复为全局中间件
// requestBodyLimitMiddleware(挂载于 f.Use 链,先于所有 Group/CSRF)。
//
// 本测试验证三件事(用内存 flamego 实例,无需 DB/Redis):
// 1. 合法请求(≤8MiB 单图上限 + 表单开销)不被误伤 → 200;
// 2. Content-Length 明确超限 → 中间件直接 413 快速失败,不进入 handler;
// 3. chunked/未知长度超限(绕过 Content-Length 预检)→ MaxBytesReader 在读超限时报错,
// 服务端拒绝,而非默默读入/落盘后 200。
func TestRequestBodyLimitEnforced(t *testing.T) {
f := flamego.New()
// 与 internal/route/route.go 一致:全局 Use 挂载先于任何路由/组 handler。
f.Use(requestBodyLimitMiddleware())
f.Post("/echo", func(ctx flamego.Context) {
body, err := io.ReadAll(ctx.Request().Request.Body)
if err != nil {
// MaxBytesReader 超限读报错:服务端拒绝,不得返回 200。
ctx.ResponseWriter().WriteHeader(http.StatusRequestEntityTooLarge)
return
}
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write(body)
})
ts := httptest.NewServer(f)
defer ts.Close()
post := func(payload []byte, chunked bool) int {
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/echo", bytes.NewReader(payload))
req.ContentLength = int64(len(payload))
if chunked {
req.ContentLength = -1 // 强制 chunked,绕过 Content-Length 预检
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
// 1. 合法:8MiB(≈单图上限 storage.MaxUploadBytes + 表单开销)→ 必须放行。
legal := bytes.Repeat([]byte{'a'}, 8<<20)
if code := post(legal, false); code != http.StatusOK {
t.Errorf("合法 8MiB 请求被拒绝:got %d, want 200", code)
}
// 2. Content-Length 明确超限 → 413(中间件预检,不进入 handler)。
huge := bytes.Repeat([]byte{'b'}, form.MaxRequestBodySize+1)
if code := post(huge, false); code != http.StatusRequestEntityTooLarge {
t.Errorf("Content-Length 超限请求未被 413:got %d, want 413", code)
}
// 3. chunked 超限(无 Content-Length,必须靠 MaxBytesReader 惰性封顶)→ 413。
if code := post(huge, true); code != http.StatusRequestEntityTooLarge {
t.Errorf("chunked 超限请求未被拒绝:got %d, want 413", code)
}
}
+412
View File
@@ -0,0 +1,412 @@
package chat
import (
stdctx "context"
"fmt"
"mime/multipart"
"net/http"
"time"
"unicode/utf8"
"github.com/flamego/cache"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
)
const (
MaxMessageImageSize = 5 * 1024 * 1024 // 5MB
)
func ConversationPage(ctx context.Context) {
domain := ctx.Param("domain")
token := ctx.Param("token")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/")
return
}
if conv.IsDeletedByOwner {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDeletedByOwner"))
ctx.Redirect("/_/" + domain)
return
}
// 纵深防御:私聊对话只能由其所属提问箱(domain 对应的真实属主)的页面访问。
// 否则攻击者可用其它 domain 构造访问,使下方拉黑判定用到错误的主体,绕过真实属主的拉黑。
if conv.OwnerID != pageUser.ID {
ctx.Redirect("/")
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
ctx.Redirect("/")
return
}
// 提问者检查是否被对方(对话真实属主)拉黑
if isAsker {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status for asker")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockedView"))
ctx.Redirect("/_/" + domain)
return
}
}
// 检查是否已拉黑对方(仅对被提问者有效,以对话真实属主为准)
var isBlocked bool
if isOwner {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status")
}
isBlocked = blocked
}
messages, err := db.Messages.GetByConversationID(ctx.Request().Context(), conv.ID, nil)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get messages")
ctx.SetInternalError()
return
}
// 获取所有消息的图片(被拉黑时不显示对方图片)
var messageImageMap map[uint][]*db.MessageImage
if !(isOwner && isBlocked) {
msgIDs := make([]uint, 0, len(messages))
for _, msg := range messages {
msgIDs = append(msgIDs, msg.ID)
}
messageImageMap, err = db.MessageImages.GetByMessageIDs(ctx.Request().Context(), msgIDs)
if err != nil {
logrus.WithError(err).Error("Failed to get message images")
messageImageMap = make(map[uint][]*db.MessageImage)
}
} else {
messageImageMap = make(map[uint][]*db.MessageImage)
}
ctx.Data["PageUser"] = pageUser
ctx.Data["Conversation"] = conv
ctx.Data["Messages"] = messages
ctx.Data["MessageImages"] = messageImageMap
ctx.Data["IsOwner"] = isOwner
ctx.Data["IsAsker"] = isAsker
ctx.Data["IsBlocked"] = isBlocked
// 单条消息字数上限,供发送 / 回复 textarea 的 maxlength 与后端校验同源
// (conf.Limits.ChatMessageLen,[limits] 配置段)
ctx.Data["ChatMessageLimit"] = conf.Limits.ChatMessageLen
ctx.Data["CanSend"] = !conv.IsClosed && !conv.IsDeletedByOwner &&
((isOwner && conv.MessageCount < conv.MaxMessages) || (isAsker && conv.MessageCount < conv.MaxMessages && !isBlocked))
ctx.Success("chat/conversation")
}
func SendMessage(ctx context.Context, cache cache.Cache) {
domain := ctx.Param("domain")
token := ctx.Param("token")
content := ctx.Request().FormValue("content")
// 处理图片上传
var imageFile multipart.File
var imageHeader *multipart.FileHeader
imageFile, imageHeader, err := ctx.Request().FormFile("image")
if err != nil && err != http.ErrMissingFile {
logrus.WithError(err).Error("Failed to read uploaded image")
}
if imageFile != nil {
defer imageFile.Close()
}
// 内容与图片至少有一个
if content == "" && imageFile == nil {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errEmpty"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 内容长度限制(上限取自 conf.Limits.ChatMessageLen,[limits] 配置段)
if utf8.RuneCountInString(content) > conf.Limits.ChatMessageLen {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errTooLong", conf.Limits.ChatMessageLen))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/")
return
}
if conv.IsDeletedByOwner {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errConvDeletedSend"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
ctx.Redirect("/")
return
}
// 提问者发送时检查拉黑与每日限制
if isAsker {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to check block")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockedSend"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 箱主全局屏蔽词(提问/私聊/投票共用一份词表):对方发给我的消息命中即拦截。
// 仅拦对方 → 箱主方向:我回复对方的内容不受自己设置的屏蔽词限制。
if content != "" {
if _, hit := shared.BlockWordHit(pageUser.BlockWords, content); hit {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockWords"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
if pageUser.DailyMessageLimit > 0 {
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to count today messages")
}
if todayCount >= int64(pageUser.DailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
}
if conv.IsClosed || conv.MessageCount >= conv.MaxMessages {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 私聊功能开关:发送前重新校验箱主私聊功能是否仍开启(创建会话时已查过,
// 但箱主可能在会话中途关闭私聊,此时应阻止继续发消息)。
chatState := db.FeatureStateOf(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
if !chatState.Open {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 图片大小限制
if imageHeader != nil && imageHeader.Size > MaxMessageImageSize {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errImageTooLarge", MaxMessageImageSize/(1024*1024)))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 上传图片(如有)
var imageURL string
if imageFile != nil {
uploadedURL, err := storage.UploadPictureToS3(imageFile, imageHeader)
if err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "image.errTooLarge", imagecheck.MaxImageSide, imagecheck.MaxImageSide))
} else {
logrus.WithError(err).Error("Failed to upload image")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errUploadFailed"))
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
imageURL = uploadedURL
}
// 内容安全检测(仅文本)
if content != "" {
censorResponse, err := censor.Text(ctx.Request().Context(), content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发送而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !censorResponse.Pass {
ctx.SetErrorFlash(censorResponse.ErrorMessage())
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
// 原子地递增消息计数:真正的上限检查点(先于消息落库,防止超额消息写入)。
// 并发场景(对话双方同时发送)下,仅当 message_count < max 时才 +1 成功。
ok, err := db.Conversations.IncrementMessageCountIfBelow(ctx.Request().Context(), conv.ID, conv.MaxMessages)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 创建消息
msg, err := db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: content,
})
if err != nil {
// 计数已递增但消息落库失败:回滚计数,避免占用配额
if rerr := db.Conversations.DecrementMessageCount(ctx.Request().Context(), conv.ID); rerr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(rerr).Error("Failed to rollback message count")
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create message")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 保存图片记录(如有)
if imageURL != "" {
if _, err := db.MessageImages.Create(ctx.Request().Context(), db.CreateMessageImageOptions{
MessageID: msg.ID,
URL: imageURL,
FileName: imageHeader.Filename,
FileSize: imageHeader.Size,
}); err != nil {
logrus.WithError(err).Error("Failed to save message image")
}
}
// 达到上限时关闭对话(幂等,多实例同时执行也安全)
if err := db.Conversations.MaybeClose(ctx.Request().Context(), conv.ID, conv.MaxMessages); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to maybe close conversation")
}
senderName := ctx.User.Name
shared.SafeGo(func() {
var recipientEmail string
if isOwner {
asker, err := db.Users.GetByID(stdctx.Background(), conv.AskerID)
if err == nil {
recipientEmail = asker.Email
}
} else {
owner, err := db.Users.GetByID(stdctx.Background(), conv.OwnerID)
if err == nil {
recipientEmail = owner.Email
}
}
if recipientEmail == "" {
return
}
// 邮件节流:同一收件人 5 分钟内最多一封通知,防止刷屏导致邮件轰炸。
notifyKey := "chat-notify:" + recipientEmail
if _, err := cache.Get(stdctx.Background(), notifyKey); err == nil {
return
}
if err := mail.SendNewMessageMail(recipientEmail, senderName, pageUser.Domain, token, content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new message mail")
return
}
_ = cache.Set(stdctx.Background(), notifyKey, time.Now(), 5*time.Minute)
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.sent"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
}
func DeleteConversation(ctx context.Context) {
token := ctx.Param("token")
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/user/questions")
return
}
if !ctx.IsLogged || ctx.User.ID != conv.OwnerID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errNoPermission"))
ctx.Redirect("/user/questions")
return
}
if err := db.Conversations.MarkDeletedByOwner(ctx.Request().Context(), conv.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark conversation deleted")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.deleted"))
ctx.Redirect("/user/questions")
}
// ToggleBlockUser 拉黑/取消屏蔽
func ToggleBlockUser(ctx context.Context) {
token := ctx.Param("token")
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/user/questions")
return
}
if !ctx.IsLogged || ctx.User.ID != conv.OwnerID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errNoPermission"))
ctx.Redirect("/user/questions")
return
}
blocked, _ := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if blocked {
err = db.Users.UnblockUser(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
} else {
err = db.Users.BlockUser(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
}
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle block")
ctx.SetInternalErrorFlash()
} else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.operated"))
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", ctx.Param("domain"), token))
}
+120
View File
@@ -0,0 +1,120 @@
package route
import (
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/session"
)
// TestCSRFTokenBoundToUserSession 是 P1-1 的回归测试:CSRF token 必须绑定到登录用户的
// 会话键(CSRFUserSessionKey = "uid"),而非 flamego/csrf 默认的 "userID"。
//
// 背景:flamego/csrf 的校验是无状态 HMAC 重算(token = HMAC(secret, id:action:ts),不与
// session 内存储的 token 比对)。一旦 SessionKey 与登录时写入的会话键(login.go 中的
// Session.Set("uid", ...))不一致,token 内绑定的 id 恒为 "0",任意匿名访客从公开 /login
// 页面取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站请求伪造可自由发起。
//
// 本测试用内存会话(无需数据库)重放「会话 B 携带会话 A 的 token 提交」这一跨会话复用攻击:
// - 会话 A(已登录 uid=42)携带自己的 token 提交 → 必须通过(200);
// - 会话 B(匿名)携带会话 A 的 token 跨会话提交 → 必须被拒绝(400)。
//
// 若 CSRFUserSessionKey 被误改回 "userID",第二项断言会失败,CI 即报警。
func TestCSRFTokenBoundToUserSession(t *testing.T) {
const secret = "test-secret-for-csrf-regression"
f := flamego.New()
f.Use(session.Sessioner(session.Options{
Initer: session.MemoryIniter(),
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
},
}))
// 仅当请求带 X-Test-Login 头时才把 uid=42 写入会话,模拟「已登录用户」。
// 该中间件必须排在 csrf 之前,使 token 在生成时即绑定到 uid。
f.Use(func(ctx flamego.Context, s session.Session) {
if ctx.Request().Header.Get("X-Test-Login") != "" {
s.Set(CSRFUserSessionKey, uint(42))
}
})
f.Use(csrf.Csrfer(csrf.Options{
Secret: secret,
Header: "X-CSRF-Token",
SessionKey: CSRFUserSessionKey,
SetHeader: true, // 便于测试从响应头取出 token
}))
// 受保护路由:仅当 CSRF 校验通过才返回 200;失败由 csrf.Error 写 400。
f.Post("/protected", func(ctx flamego.Context, x csrf.CSRF) {
x.Validate(ctx)
// 校验失败时 x.Validate 已写入 400;此处 Write 在已写 400 时仅追加 body,
// 状态保持 400;校验通过时此处隐式返回 200。
ctx.ResponseWriter().Write([]byte("ok"))
})
// 生成 token 的 GET 路由(token 在 GET 时生成,与真实应用一致)。
f.Get("/token", func(ctx flamego.Context) {
ctx.ResponseWriter().Write([]byte("ok"))
})
ts := httptest.NewServer(f)
defer ts.Close()
newClient := func() *http.Client {
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("cookiejar.New: %v", err)
}
return &http.Client{Jar: jar}
}
getToken := func(client *http.Client, login bool) string {
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/token", nil)
if login {
req.Header.Set("X-Test-Login", "1")
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET /token: %v", err)
}
defer resp.Body.Close()
tok := resp.Header.Get("X-CSRF-Token")
if tok == "" {
t.Fatalf("GET /token 未返回 X-CSRF-Token(Csrfer SetHeader 应已启用)")
}
return tok
}
postProtected := func(client *http.Client, token string) int {
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/protected", nil)
if token != "" {
req.Header.Set("X-CSRF-Token", token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("POST /protected: %v", err)
}
defer resp.Body.Close()
return resp.StatusCode
}
// 会话 A:已登录用户(uid=42)。
clientA := newClient()
tokenA := getToken(clientA, true)
// 同一会话携带自己的 token 提交 —— 必须通过(200)。
if code := postProtected(clientA, tokenA); code != http.StatusOK {
t.Fatalf("会话 A 携带自身 token 提交,期望 200,实际 %d(修复后不应被拒绝)", code)
}
// 会话 B:匿名访客,跨会话复用会话 A 的 token 提交 —— 必须被拒绝(400)。
// 这是 P1-1 的核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配。
clientB := newClient()
if code := postProtected(clientB, tokenA); code != http.StatusBadRequest {
t.Fatalf("会话 B 跨会话复用会话 A 的 token,期望 400,实际 %d(CSRF 绑定失效!)", code)
}
}
+150
View File
@@ -0,0 +1,150 @@
// 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 route
import (
"bufio"
"compress/gzip"
"errors"
"net"
"net/http"
"strings"
)
// GzipHandler 返回一个压缩中间件,用于包装 http.Handler。
// 它对可压缩的响应(HTML / CSS / JS / JSON 等文本类型)做 gzip 压缩,
// 自动跳过图片、视频、字体等已经压缩过的格式。
//
// 由于 flamego 的 Context 无法替换底层 ResponseWriter,这里采用在
// http.Server 最外层包装 Handler 的方式,保证对所有响应(含静态文件)生效。
func GzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 客户端不支持 gzip 时直接透传
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
gw := &gzipResponseWriter{
ResponseWriter: w,
gz: gzip.NewWriter(w),
}
defer func() {
// 只有真正写入过 body 才关闭 gzip 流,避免 302/204 等
// 无 body 响应被追加空的 gzip 尾块
if gw.compress && gw.wrote {
_ = gw.gz.Close()
}
}()
next.ServeHTTP(gw, r)
})
}
// gzipResponseWriter 包装 http.ResponseWriter,在 WriteHeader 时根据
// Content-Type 决定是否启用 gzip,保持接口兼容(Flusher / Hijacker / Pusher)。
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
compress bool
wrote bool
wroteHeader bool
}
// WriteHeader 记录状态码,并根据 Content-Type 决定是否压缩。
// 由于 Content-Encoding 改变后 Content-Length 不再准确,需要删除该头。
func (w *gzipResponseWriter) WriteHeader(status int) {
if w.wroteHeader {
return
}
w.wroteHeader = true
// 无实体响应(204 No Content / 304 Not Modified)不应附加 Content-Encoding。
if status == http.StatusNoContent || status == http.StatusNotModified {
w.ResponseWriter.WriteHeader(status)
return
}
if shouldCompressContentType(w.Header().Get("Content-Type")) {
w.compress = true
w.Header().Del("Content-Length")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Add("Vary", "Accept-Encoding")
}
w.ResponseWriter.WriteHeader(status)
}
// Write 写入响应体。若尚未 WriteHeader(handler 依赖 net/http 自动嗅探
// Content-Type 的情况),先用首个字节块探测类型再决策。
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
ct := w.Header().Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(b)
// 将嗅探到的类型写回响应头,使 WriteHeader 的压缩判定能正确生效
// (否则空 Content-Type 会被判定为"不压缩",导致本应压缩的纯文本响应未压缩)。
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(http.StatusOK)
}
if w.compress {
w.wrote = true
return w.gz.Write(b)
}
return w.ResponseWriter.Write(b)
}
// Flush 转发底层 Flusher,并在压缩模式下先刷新 gzip 缓冲。
func (w *gzipResponseWriter) Flush() {
if w.compress && w.gz != nil {
_ = w.gz.Flush()
}
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Hijack 转发底层 Hijacker,保持对 WebSocket 等升级协议兼容。
func (w *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errors.New("gzipResponseWriter: hijacking not supported")
}
// Push 转发 HTTP/2 Server Push(如果底层支持)。
func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
if p, ok := w.ResponseWriter.(http.Pusher); ok {
return p.Push(target, opts)
}
return http.ErrNotSupported
}
// shouldCompressContentType 判断响应 Content-Type 是否值得压缩。
// 空类型、二进制图片/视频/音频/字体等已压缩格式直接跳过。
func shouldCompressContentType(ct string) bool {
ct = strings.ToLower(strings.TrimSpace(ct))
if ct == "" {
return false
}
// SVG 是文本格式,值得压缩;其余图片格式已是压缩格式
if strings.HasPrefix(ct, "image/") {
return strings.Contains(ct, "svg")
}
// 视频 / 音频 / 字体已是压缩格式
if strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") ||
strings.HasPrefix(ct, "font/") {
return false
}
// 已知压缩或二进制格式
switch ct {
case "application/zip", "application/gzip", "application/x-7z-compressed",
"application/x-rar-compressed", "application/pdf",
"application/vnd.ms-fontobject", "application/wasm",
"application/octet-stream", "application/x-protobuf", "application/grpc":
return false
}
return true
}
+63
View File
@@ -0,0 +1,63 @@
package route
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/static"
)
// TestIndexNowKeyFileRoute 验证 IndexNow 密钥文件路由:能匹配含点号的路径段并返回 embed 内容,
// 同时拒绝路径穿越与非法文件名。
//
// 背景:flamego 的 Static 中间件 Prefix 为 "/static",不会接管 /index-now/,若缺少本路由,
// 请求会落到 f.NotFound 被 302 重定向到首页,搜索引擎的密钥校验必然失败。
func TestIndexNowKeyFileRoute(t *testing.T) {
f := flamego.NewWithLogger(io.Discard)
f.Get("/index-now/{key}", func(c flamego.Context) {
key := c.Param("key")
if !indexNowKeyPattern.MatchString(key) {
c.ResponseWriter().WriteHeader(http.StatusNotFound)
return
}
fs, err := static.FS.Open("index-now/" + key)
if err != nil {
c.ResponseWriter().WriteHeader(http.StatusNotFound)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
cases := []struct {
path string
want int
wantKey bool
}{
{"/index-now/66e5e0911b0941f2ba7dbfbbd961e9be.txt", 200, true},
{"/index-now/..%2frobots.txt", 404, false}, // 路径穿越(编码斜杠)
{"/index-now/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.txt", 404, false}, // 合法格式但文件不存在
{"/index-now/../../robots.txt", 404, false}, // 路径穿越(原始)
{"/index-now/66e5e0911b0941f2ba7dbfbbd961e9be.txt.exe", 404, false}, // 后缀不符
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
f.ServeHTTP(w, req)
body := w.Body.String()
got := w.Code
if got != tc.want {
t.Errorf("%s -> status %d, want %d (body=%q)", tc.path, got, tc.want, body)
continue
}
if tc.wantKey && body != "66e5e0911b0941f2ba7dbfbbd961e9be" {
t.Errorf("%s -> body %q, want key content", tc.path, body)
}
t.Logf("%-56s -> %d %q", tc.path, got, body)
}
}
+87
View File
@@ -0,0 +1,87 @@
package route
import (
"net"
"net/http"
"strings"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/shared"
)
// ipBlacklistMiddleware returns a Flamego handler that checks the client's IP
// against a configurable blacklist. When a blacklisted IP is detected, the
// request is redirected (HTTP 302) to the configured redirect URL.
//
// Supports exact IP matching and CIDR notation (e.g. "192.168.1.0/24").
// The client IP is resolved by checking X-Real-IP, X-Forwarded-For, then
// falling back to the raw RemoteAddr.
func ipBlacklistMiddleware() flamego.Handler {
raw := strings.TrimSpace(conf.Security.IPBlacklist)
if raw == "" {
return func(ctx flamego.Context) {}
}
// Pre-parse all entries into exact IPs and CIDR networks.
type cidrEntry struct{ ipNet *net.IPNet }
exact := make(map[string]bool)
cidrs := make([]cidrEntry, 0)
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
// Try CIDR first.
if _, ipNet, err := net.ParseCIDR(entry); err == nil {
cidrs = append(cidrs, cidrEntry{ipNet: ipNet})
logrus.Infof("IP blacklist: CIDR %s added", entry)
continue
}
// Fall back to exact IP.
if ip := net.ParseIP(entry); ip != nil {
exact[ip.String()] = true
logrus.Infof("IP blacklist: exact IP %s added", ip.String())
continue
}
logrus.Warnf("IP blacklist: skipping invalid entry %q", entry)
}
if len(exact) == 0 && len(cidrs) == 0 {
return func(ctx flamego.Context) {}
}
redirectURL := strings.TrimSpace(conf.Security.IPBlacklistRedirectURL)
if redirectURL == "" {
logrus.Warn("IP blacklist is configured but ip_blacklist_redirect_url is empty")
}
return func(ctx flamego.Context) {
clientIP := shared.ResolveClientIP(ctx.Request().Request)
if clientIP == "" {
return
}
// Exact match first (O(1)).
if exact[clientIP] {
logrus.WithField("ip", clientIP).Info("IP blacklist: exact match, redirecting")
ctx.Redirect(redirectURL, http.StatusFound)
return
}
// CIDR match (O(n), n is typically small).
parsed := net.ParseIP(clientIP)
if parsed != nil {
for _, e := range cidrs {
if e.ipNet.Contains(parsed) {
logrus.WithField("ip", clientIP).Infof("IP blacklist: CIDR %s match, redirecting", e.ipNet)
ctx.Redirect(redirectURL, http.StatusFound)
return
}
}
}
}
}
+183
View File
@@ -0,0 +1,183 @@
package route
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/flamego/flamego"
"github.com/flamego/session"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/route/vote"
)
// rateLimiter provides a simple in-memory rate limiter using a sliding window.
type rateLimiter struct {
mu sync.Mutex
visitors map[string][]time.Time
limit int
window time.Duration
}
// ---------- 全局清理 ----------
//
// 所有 rateLimiter 实例注册到全局注册表,由单个常驻 goroutine 统一清理,
// 避免每个中间件实例各自启动一个永久 goroutine(旧实现会随路由数量线性泄漏 goroutine)。
var (
rateLimitersMu sync.Mutex
rateLimiters = make(map[*rateLimiter]struct{})
rateLimiterClean sync.Once
)
func registerRateLimiter(rl *rateLimiter) {
rateLimitersMu.Lock()
rateLimiters[rl] = struct{}{}
rateLimitersMu.Unlock()
// 仅启动一次全局清理循环
rateLimiterClean.Do(func() {
go func() {
for {
time.Sleep(5 * time.Minute)
rateLimitersMu.Lock()
for rl := range rateLimiters {
rl.cleanup()
}
rateLimitersMu.Unlock()
}
}()
})
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
rl := &rateLimiter{
visitors: make(map[string][]time.Time),
limit: limit,
window: window,
}
registerRateLimiter(rl)
return rl
}
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
// 恶意伪造 X-Forwarded-For 可撑爆 visitors map,超过阈值时快速清理
// 窗口内无活动的 key(其最新时间戳已过期)
if len(rl.visitors) > 10000 {
cutoff2 := time.Now().Add(-rl.window)
for ip, ts := range rl.visitors {
if len(ts) == 0 || ts[len(ts)-1].Before(cutoff2) {
delete(rl.visitors, ip)
}
}
}
now := time.Now()
cutoff := now.Add(-rl.window)
times := rl.visitors[ip]
// Remove expired entries.
valid := times[:0]
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= rl.limit {
rl.visitors[ip] = valid
return false
}
valid = append(valid, now)
rl.visitors[ip] = valid
return true
}
func (rl *rateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-rl.window)
for ip, times := range rl.visitors {
valid := times[:0]
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(rl.visitors, ip)
} else {
rl.visitors[ip] = valid
}
}
}
// rateLimitMiddleware returns a Flamego handler that applies rate limiting.
// limit: max requests per window; window: time window duration.
//
// 匿名 / 登录分级限流:登录用户(session 中存在 uid)使用 3 倍额度,
// 未登录访客使用基础额度。分级使用独立限流器统计,互不污染。
func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
anonymousRL := newRateLimiter(limit, window)
loggedRL := newRateLimiter(limit*3, window)
return func(ctx flamego.Context, s session.Session) {
// 统一通过 shared.ResolveClientIP 解析,trust_proxy=false 时忽略伪造的代理头。
// 再经 shared.LimitKey 归一化:IPv4 原样,IPv6 聚合到 /64,
// 防止在单个 /64 内轮换海量地址绕过阈值(详见 LimitKey 注释)。
ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request))
rl := anonymousRL
if _, ok := s.Get("uid").(uint); ok {
rl = loggedRL
}
if !rl.allow(ip) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
return
}
}
}
// voteSubmitRateLimitMiddleware 对投票提交同时执行两层限流:
// 匿名身份层按 token 计数,IP 层使用更高阈值作为批量换 token 的风控兜底。
// 这样共享公网出口的多个匿名浏览器不会共享低阈值,同时仍能限制异常突发流量。
func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
anonymousTokenRL := newRateLimiter(limit, window)
loggedUserRL := newRateLimiter(limit*3, window)
anonymousIPRL := newRateLimiter(limit*10, window)
tooMany := func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
}
return func(ctx flamego.Context, s session.Session) {
r := ctx.Request().Request
if uid, ok := s.Get("uid").(uint); ok && uid > 0 {
if !loggedUserRL.allow(fmt.Sprintf("user:%d", uid)) {
tooMany(ctx)
}
return
}
// 匿名请求始终经过高阈值 IP 兜底;共享出口下每个有效 token 仍独立计数。
// IP 键同样经 LimitKey 聚合,避免 IPv6 轮换绕过兜底阈值。
if !anonymousIPRL.allow("ip:" + shared.LimitKey(shared.ResolveClientIP(r))) {
tooMany(ctx)
return
}
key := vote.VoterTokenRateLimitKey(r)
if key != "" && !anonymousTokenRL.allow("token:"+key) {
tooMany(ctx)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
package route
import (
"testing"
"time"
)
func TestVoteSubmitRateLimitKeysSeparateSharedIPUsers(t *testing.T) {
tokenRL := newRateLimiter(1, time.Minute)
ipRL := newRateLimiter(2, time.Minute)
// Two devices behind one public IP use different signed token keys.
if !tokenRL.allow("token:device-a") || !tokenRL.allow("token:device-b") {
t.Fatal("different voter tokens should have independent limits")
}
if tokenRL.allow("token:device-a") {
t.Fatal("the same voter token should be limited independently")
}
// The shared IP is only a high-threshold fallback, not a one-vote identity.
if !ipRL.allow("ip:203.0.113.10") || !ipRL.allow("ip:203.0.113.10") {
t.Fatal("shared IP should allow multiple normal submissions")
}
if ipRL.allow("ip:203.0.113.10") {
t.Fatal("IP fallback should still stop an abnormal burst")
}
}
+447
View File
@@ -0,0 +1,447 @@
// 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 route
import (
"encoding/gob"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/flamego/cache"
cacheRedis "github.com/flamego/cache/redis"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/recaptcha"
"github.com/flamego/session"
"github.com/flamego/session/mysql"
sessionRedis "github.com/flamego/session/redis"
"github.com/flamego/template"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
"github.com/tamakyi/TamaBox/internal/shared"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
"github.com/tamakyi/TamaBox/route"
"github.com/tamakyi/TamaBox/route/auth"
"github.com/tamakyi/TamaBox/route/pixel"
"github.com/tamakyi/TamaBox/route/question"
"github.com/tamakyi/TamaBox/route/service"
"github.com/tamakyi/TamaBox/route/user"
"github.com/tamakyi/TamaBox/route/vote"
"github.com/tamakyi/TamaBox/static"
"github.com/tamakyi/TamaBox/templates"
)
// indexNowKeyPattern 限制 IndexNow 密钥文件名的合法形态:32 位小写十六进制 + ".txt"。
// 用于防止把路由参数直接拼进 embed FS 路径时发生路径穿越(如 "../robots.txt")。
var indexNowKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}\.txt$`)
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
//
// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
// CI 失败,避免修复悄悄倒退。
const CSRFUserSessionKey = "uid"
// securityHeaders adds security-related HTTP headers to every response.
func securityHeaders() flamego.Handler {
return func(ctx flamego.Context) {
w := ctx.ResponseWriter()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
if strings.HasPrefix(conf.App.ExternalURL, "https") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}
}
func New() *flamego.Flame {
f := flamego.Classic()
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
// 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
// 404 是否参与统计由 ban_track_notfound 控制(默认 false:正常业务一律放行,
// 仅敏感扫描路径计数封禁,见 conf.Security.BanTrackNotFound)。
ab := autoban.New(autoban.Options{
Enabled: conf.Security.EnableAutoBan,
TrackNotFound: conf.Security.BanTrackNotFound,
ThresholdSensitive: conf.Security.BanThresholdSensitive,
Threshold404: conf.Security.BanThreshold404,
Window: time.Duration(conf.Security.BanWindowSeconds) * time.Second,
Duration: time.Duration(conf.Security.BanDurationMinutes) * time.Minute,
AlertEmail: conf.Security.SecurityAlertEmail,
// 告警发送前先离线识别来源 IP 的网络归属(CDN/云厂商/WAF,零外部请求),
// 识别失败不影响告警发送(ipinfo.Lookup 内部已折叠错误)。
SendAlert: func(ip string, paths []string, durationMinutes int) error {
provider, proxied := ipinfo.Lookup(ip)
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
},
})
// 需要输出详细日志时配置如下
// if conf.App.Production {
// flamego.SetEnv(flamego.EnvTypeProd)
// }
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
if err != nil {
logrus.WithError(err).Fatal("Failed to embed templates file system")
}
gob.Register(time.Time{})
gob.Register(context.Flash{})
gob.Register(captcha.StoredAnswer{})
var sessionStorage interface{}
initer := session.MemoryIniter()
if conf.Database.DSN != "" {
initer = mysql.Initer()
sessionStorage = mysql.Config{
DSN: conf.Database.DSN,
Lifetime: 7 * 24 * time.Hour,
}
}
if conf.Redis.Addr != "" {
initer = sessionRedis.Initer()
sessionStorage = sessionRedis.Config{
Options: &sessionRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 1,
},
Lifetime: 7 * 24 * time.Hour,
}
}
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
// 显式声明 cookie 安全属性,不依赖库默认值:
// - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
// - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
// - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
// 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
},
})
f.Use(flamego.Static(flamego.StaticOptions{
FileSystem: http.FS(static.FS),
Prefix: "/static",
// 静态资源随二进制发布,内容变更需重新部署;1 天缓存是安全折中
CacheControl: func() string {
return "public, max-age=86400"
},
}))
f.Use(securityHeaders())
f.Use(ipBlacklistMiddleware())
f.Use(autobanMiddleware(ab))
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
f.Use(requestBodyLimitMiddleware())
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
f.Group("", func() {
f.Get("/", route.Home)
f.Get("/pixel", reqUserSignIn, pixel.Index)
f.Get("/sponsor", route.Sponsor)
f.Get("/change-logs", route.ChangeLogs)
f.Get("/about-me", route.AboutMe)
f.Get("/robots.txt", func(c context.Context) {
fs, err := static.FS.Open("robots.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/favicon.ico", func(c context.Context) {
fs, _ := static.FS.Open("favicon.ico")
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "image/x-icon")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/sitemap.xml", func(c context.Context) {
fs, err := static.FS.Open("sitemap.xml")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "application/xml")
c.ResponseWriter().Header().Set("Content-Type", "text/xml; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/llms.txt", func(c context.Context) {
fs, err := static.FS.Open("llms.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/llms-full.txt", func(c context.Context) {
fs, err := static.FS.Open("llms-full.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
// IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex / Seznam
// 等支持 IndexNow 的搜索引擎校验站点所有权(提交 URL 时的 keyLocation 指向此处)。
// ⚠️ flamego 的 Static 中间件 Prefix 为 "/static",不会接管本路径,必须在此显式注册,
// 否则请求会落到 f.NotFound 被 302 重定向到首页,搜索引擎校验必然失败。
// 文件名用 indexNowKeyPattern 严格白名单校验,避免路径穿越读取 embed FS 内其它文件。
f.Get("/index-now/{key}", func(c context.Context) {
key := c.Param("key")
if !indexNowKeyPattern.MatchString(key) {
c.ResponseWriter().WriteHeader(http.StatusNotFound)
return
}
fs, err := static.FS.Open("index-now/" + key)
if err != nil {
c.ResponseWriter().WriteHeader(http.StatusNotFound)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Group("", func() {
f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), rateLimitMiddleware(10, 1*time.Minute), auth.RegisterAction)
f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), rateLimitMiddleware(20, 1*time.Minute), auth.LoginAction)
f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), rateLimitMiddleware(5, 5*time.Minute), auth.ForgotPasswordAction)
f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction)
}, reqUserSignOut)
// 页面密码验证路由(独立于密码保护中间件);限流防爆破
f.Combo("/_/{domain}/password").Get(question.PasswordPage).Post(rateLimitMiddleware(10, 1*time.Minute), question.VerifyPagePassword)
// 独立的分享图路由(不经过 Pager 中间件,直接返回图片)
// 注意:仍挂载 question.Pager,页面设置了密码时必须先通过密码验证,
// 否则分享图会绕过页面密码保护(与单题分享图行为一致)。
f.Group("/_/{domain}", func() {
f.Get("/share-card", question.ShareCard)
}, question.Pager)
f.Group("/_/{domain}", func() {
f.Combo("").Get(question.List).Post(form.Bind(form.NewQuestion{}), rateLimitMiddleware(30, 1*time.Minute), question.New)
f.Group("/{questionID}", func() {
f.Get("", question.Item)
f.Get("/share-card", question.QuestionShareCard)
f.Post("/delete", question.Delete)
f.Post("/set-private", question.SetPrivate)
f.Post("/set-public", question.SetPublic)
f.Post("/pin", question.TogglePin)
f.Post("/read", question.SetRead)
f.Post("/unread", question.SetUnread)
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
f.Post("/follow-up", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp)
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
}, question.Questioner)
// 新增:私聊路由(需要登录,使用 Token 标识对话)
f.Group("/chat/{token}", func() {
f.Get("", chat.ConversationPage)
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
f.Post("/delete", chat.DeleteConversation)
f.Post("/block", chat.ToggleBlockUser)
}, reqUserSignIn)
}, question.Pager) // Pager 中间件已内置页面密码检查
f.Get("/explore", user.Explore)
// 匿名投票参与端(公开):token 链接入口
f.Group("/vote/{token}", func() {
f.Get("", vote.Page)
// 提交限流优先按登录用户/签名 vote_fp 计数,IP 仅作高阈值兜底,避免共享出口误伤。
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
})
// 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
// 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
// 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
f.Get("/i18n/{lang}", func(c context.Context) {
lang := i18n.Resolve(c.Param("lang"))
// 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
c.Session.Set("lang", lang)
to := c.Query("to")
// 仅允许站内相对路径,防开放重定向:
// 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
if to == "" || !strings.HasPrefix(to, "/") ||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
to = "/"
}
c.Redirect(to)
})
// 算数验证码 API(仅在 captcha_type = "math" 时生效,但路由本身不依赖中间件)
// 加限流:防止匿名脚本无限生成/轮询爆破(答案本身对客户端可见,限流是主要节流手段)。
f.Get("/api/captcha/generate", rateLimitMiddleware(20, 1*time.Minute), captcha.GenerateHandler)
f.Post("/api/captcha/verify", rateLimitMiddleware(20, 1*time.Minute), captcha.VerifyHandler)
f.Group("/user", func() {
f.Get("/questions", user.QuestionList)
// 匿名投票管理(箱主侧)
f.Group("/votes", func() {
f.Get("", user.VoteList)
f.Get("/new", user.VoteNew)
f.Post("", rateLimitMiddleware(10, 1*time.Minute), user.VoteCreate)
f.Get("/{voteID}/edit", user.VoteEdit)
f.Post("/{voteID}", rateLimitMiddleware(10, 1*time.Minute), user.VoteUpdate)
f.Post("/{voteID}/publish", user.VotePublish)
f.Post("/{voteID}/close", user.VoteClose)
f.Post("/{voteID}/delete", user.VoteDelete)
f.Get("/{voteID}/results", user.VoteResults)
f.Get("/{voteID}/preview", user.VotePreview)
// 导出为只读操作(幂等、无副作用),用 GET 链接下载,避免表单提交
f.Get("/{voteID}/export", user.VoteExportCSV)
})
f.Group("/profile", func() {
f.Get("", user.Profile)
f.Post("/update", form.Bind(form.UpdateProfile{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateProfile)
f.Post("/export", user.ExportProfile)
f.Post("/export-json", user.ExportProfileJSON)
f.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
f.Post("/update-ask-password", form.Bind(form.UpdateAskPassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateAskPassword)
f.Post("/update-chat-settings", user.UpdateChatSettings)
// 功能管理(提问 / 私聊 / 投票 的开关、定时开关与定量收集):
// 不单独开页面,作为账号设置页的三个面板,保存后回跳到对应面板。
f.Group("/features", func() {
f.Post("/update", rateLimitMiddleware(20, 1*time.Minute), user.FeaturesUpdate)
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
})
})
// 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
// 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
// ?tab= 参数回跳到来源面板。
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
f.Post("/logout", auth.Logout)
}, reqUserSignIn)
f.Group("/api/v1", func() {
f.Group("/user", func() {
f.Get("", reqUserSignIn, user.ProfileAPI)
f.Group("/{domain}", func() {
f.Group("/questions", func() {
f.Get("", rateLimitMiddleware(60, 1*time.Minute), question.ListAPI)
})
})
})
// 像素追踪代理:仅暴露只读 GET(f.Any 会放行写方法,曾有 CSRF 豁免 + 写方法放行的风险),
// 并加限流防止被用作高频打点通道。
f.Get("/pixel/{**}", reqUserSignIn, rateLimitMiddleware(30, 1*time.Minute), pixel.Proxy)
// 服务反代仅暴露只读 GET:本网关是把已登录用户的请求转发到配置好的内部服务,
// 不应允许任意写方法,避免被用作对内网后端的越权操作入口。
f.Get("/service/{**}", reqUserSignIn, service.Proxy)
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
}, context.APIEndpoint)
},
cache.Cacher(cache.Options{
Initer: cacheRedis.Initer(),
Config: cacheRedis.Config{
Options: &cacheRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 0,
},
},
}),
recaptcha.V3(
recaptcha.Options{
Secret: conf.Recaptcha.ServerKey,
VerifyURL: func() recaptcha.VerifyURL {
if conf.Recaptcha.TurnstileStyle {
return "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
return recaptcha.VerifyURLGlobal
}(),
},
),
sessioner,
csrf.Csrfer(csrf.Options{
Secret: conf.Server.XSRFKey,
Header: "X-CSRF-Token",
// ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
// flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
// 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
// (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
// /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
// 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
SessionKey: CSRFUserSessionKey,
}),
template.Templater(template.Options{
FileSystem: templateFS,
FuncMaps: templatepkg.FuncMap(),
}),
context.Contexter(),
)
f.NotFound(func(ctx flamego.Context) {
// 真正的 404 探测在此记录(仅此处,不把业务 302→/ 误判为扫描)。
// 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
// 因此这里在 Redirect 之前记录,确保状态为 404。
// 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
// 否则 404 计数会记在另一个键上,导致封禁永不生效。
// ⚠️ 该 404 计数受 ban_track_notfound 配置控制,默认关闭(正常业务一律放行,
// 仅敏感扫描路径计数封禁);Record 内部对未开启的 KindNotFound 直接忽略。
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
}
ctx.Redirect("/")
})
return f
}
+298
View File
@@ -0,0 +1,298 @@
// Package autoban 提供 fail2ban 风格的自动临时封禁:检测批量扫描/探测特征后
// 临时封禁来源 IP,并可回调发送告警。纯标准库实现,便于独立测试与复用。
package autoban
import (
"strings"
"sync"
"time"
)
// 探测事件类型
const (
KindNone = 0 // 无探测特征
KindSensitive = 1 // 命中敏感路径
KindNotFound = 2 // 404 响应(未命中敏感路径)
)
// 默认阈值/窗口(Options 未配置或为 0 时兜底)。
const (
DefaultWindowSeconds = 60
DefaultDurationMinutes = 15
DefaultThresholdSensitive = 10
DefaultThreshold404 = 20
pathSampleMax = 6
)
// Options 自动封禁配置。
type Options struct {
Enabled bool
// TrackNotFound 是否把 KindNotFound(未注册路径的 404)计入封禁统计。
// 默认 false:正常业务路径一律放行,仅敏感扫描特征路径参与计数封禁,
// 避免共享出口下正常访问被 404 探测连坐误伤(详见 conf.Security.BanTrackNotFound)。
TrackNotFound bool
ThresholdSensitive int // 窗口内敏感路径命中次数
Threshold404 int // 窗口内 404 次数
Window time.Duration // 统计窗口
Duration time.Duration // 封禁时长
AlertEmail string // 告警收件人(留空不发送)
SendAlert func(ip string, paths []string, durationMinutes int) error // 告警发送函数(nil 则不发送)
}
// sensitivePaths 常见后台/配置/漏洞探测路径特征(忽略大小写,完整段前缀匹配)。
// 注意:前缀匹配抓不到 //xxx/wp-includes/wlwmanifest.xml 这类"目录枚举变体"(敏感段
// 出现在路径中部),需要与 sensitiveFragments(任意位置子串)配合使用。
var sensitivePaths = []string{
"/admin", "/wp-admin", "/wp-login.php", "/wp-content", "/wordpress",
"/administrator", "/manager", "/cpanel", "/plesk",
"/.env", "/.git", "/.aws", "/.ssh", "/.svn", "/.hg", "/.htaccess",
"/config", "/configuration", "/backup", "/database", "/db",
"/actuator", "/swagger", "/api-docs", "/openapi", "/v2/api-docs",
"/phpmyadmin", "/pma", "/mysql", "/server-status", "/server-info",
"/shell", "/webshell", "/cmd", "/eval", "/hack", "/shell.php",
"/login.php", "/admin.php", "/index.php", "/console", "/vendor",
}
// sensitiveFragments 任意位置命中即算敏感的"强指纹"(忽略大小写,子串匹配)。
// 与 sensitivePaths 互补:捕获 /<枚举前缀>/wp-includes/wlwmanifest.xml、//wp/xmlrpc.php
// 等前缀匹配失效的变体。仅收录业务 URL 空间(/、/_/、/user、/api/v1、/vote、/explore、
// /i18n、/static…)绝不可能出现的 WordPress 特征,确保不误伤正常路径。
var sensitiveFragments = []string{
"/wp-includes/", "/wp-json/", "/wp-cron.php", "/wp-trackback.php",
"/wp-admin", "/wp-content", "/wp-login.php", "/wlwmanifest.xml",
"/xmlrpc.php", "/wordpress",
}
type probeEvent struct {
at time.Time
kind int
}
type probeStat struct {
events []probeEvent
sensitive int
notFound int
paths []string // 命中路径样本(告警用)
}
// Banner 自动封禁器。方法并发安全。
type Banner struct {
mu sync.Mutex
probes map[string]*probeStat
banned map[string]time.Time
alerted map[string]bool
thresholdSensitive int
threshold404 int
trackNotFound bool
window time.Duration
duration time.Duration
alertEmail string
sendAlert func(ip string, paths []string, durationMinutes int) error
enabled bool
}
// New 按配置创建封禁器并注册全局清理。
func New(opts Options) *Banner {
window := opts.Window
if window <= 0 {
window = DefaultWindowSeconds * time.Second
}
duration := opts.Duration
if duration <= 0 {
duration = DefaultDurationMinutes * time.Minute
}
sensitive := opts.ThresholdSensitive
if sensitive <= 0 {
sensitive = DefaultThresholdSensitive
}
notFound := opts.Threshold404
if notFound <= 0 {
notFound = DefaultThreshold404
}
b := &Banner{
probes: make(map[string]*probeStat),
banned: make(map[string]time.Time),
alerted: make(map[string]bool),
thresholdSensitive: sensitive,
threshold404: notFound,
trackNotFound: opts.TrackNotFound,
window: window,
duration: duration,
alertEmail: strings.TrimSpace(opts.AlertEmail),
sendAlert: opts.SendAlert,
enabled: opts.Enabled,
}
register(b)
return b
}
// Record 记录一次探测事件,达到阈值时封禁 IP 并触发一次告警。
func (b *Banner) Record(ip, path string, kind int) {
if !b.enabled || ip == "" || kind == KindNone {
return
}
// 404 计数默认关闭(正常业务路径一律放行,仅敏感扫描特征参与封禁):
// 只有显式开启 TrackNotFound 时才把未注册路径的 404 计入统计。
if kind == KindNotFound && !b.trackNotFound {
return
}
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
cutoff := now.Add(-b.window)
st := b.probes[ip]
if st == nil {
st = &probeStat{}
b.probes[ip] = st
}
// 滚动清理过期事件并重算计数
valid := st.events[:0]
sensitive, notFound := 0, 0
for _, e := range st.events {
if e.at.After(cutoff) {
valid = append(valid, e)
if e.kind == KindSensitive {
sensitive++
} else if e.kind == KindNotFound {
notFound++
}
}
}
valid = append(valid, probeEvent{at: now, kind: kind})
if kind == KindSensitive {
sensitive++
} else {
notFound++
}
st.events = valid
st.sensitive = sensitive
st.notFound = notFound
if len(st.paths) < pathSampleMax && path != "" {
st.paths = append(st.paths, path)
}
if sensitive < b.thresholdSensitive && notFound < b.threshold404 {
return
}
// 已达阈值:封禁(已在封禁中则跳过)
if until, ok := b.banned[ip]; ok && until.After(now) {
return
}
until := now.Add(b.duration)
b.banned[ip] = until
// 告警(每 IP 仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
b.alerted[ip] = true
sample := append([]string{}, st.paths...)
dur := int(b.duration.Minutes())
go func() {
_ = b.sendAlert(ip, sample, dur)
}()
}
}
// IsBanned 判断 IP 是否处于封禁期;到期自动解除。
func (b *Banner) IsBanned(ip string) bool {
if !b.enabled || ip == "" {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
if until, ok := b.banned[ip]; ok {
if until.After(time.Now()) {
return true
}
delete(b.banned, ip)
}
return false
}
// IsSensitivePath 判断路径是否命中敏感探测特征(忽略大小写)。
// 命中条件:① 与 sensitivePaths 完整段前缀匹配(/admin、/.env 等);
// ② 包含 sensitiveFragments 强指纹子串(wp-includes/xmlrpc.php 等 WordPress 探测面,
//
// 覆盖 // 双斜杠与目录枚举变体)。
func IsSensitivePath(p string) bool {
lower := strings.ToLower(p)
for _, s := range sensitivePaths {
if lower == s || strings.HasPrefix(lower, s+"/") {
return true
}
}
for _, f := range sensitiveFragments {
if strings.Contains(lower, f) {
return true
}
}
return false
}
// ---------- 全局注册与清理 ----------
var (
bannersMu sync.Mutex
banners = make(map[*Banner]struct{})
bannersOnce sync.Once
)
func register(b *Banner) {
bannersMu.Lock()
banners[b] = struct{}{}
bannersMu.Unlock()
bannersOnce.Do(func() {
go func() {
for {
time.Sleep(2 * time.Minute)
bannersMu.Lock()
for b := range banners {
b.cleanup()
}
bannersMu.Unlock()
}
}()
})
}
// cleanup 定期清理过期探测记录与已到期封禁(防止内存膨胀)。
func (b *Banner) cleanup() {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
cutoff := now.Add(-b.window)
for ip, st := range b.probes {
if len(st.events) == 0 || st.events[len(st.events)-1].at.Before(cutoff) {
delete(b.probes, ip)
continue
}
valid := st.events[:0]
sensitive, notFound := 0, 0
for _, e := range st.events {
if e.at.After(cutoff) {
valid = append(valid, e)
if e.kind == KindSensitive {
sensitive++
} else if e.kind == KindNotFound {
notFound++
}
}
}
st.events = valid
st.sensitive = sensitive
st.notFound = notFound
}
for ip, until := range b.banned {
if !until.After(now) {
delete(b.banned, ip)
}
}
}
+157
View File
@@ -0,0 +1,157 @@
package autoban
import (
"sync/atomic"
"testing"
"time"
)
func newTestBanner(thresholdSensitive, threshold404 int, window, duration time.Duration, alertEmail string, alertCalls *int64) *Banner {
return New(Options{
Enabled: true,
TrackNotFound: true, // 测试 helper 显式开启 404 跟踪,覆盖两种计数的既有用例
ThresholdSensitive: thresholdSensitive,
Threshold404: threshold404,
Window: window,
Duration: duration,
AlertEmail: alertEmail,
SendAlert: func(ip string, paths []string, dur int) error {
if alertCalls != nil {
atomic.AddInt64(alertCalls, 1)
}
return nil
},
})
}
func TestSensitiveThreshold(t *testing.T) {
b := newTestBanner(2, 5, time.Minute, time.Minute, "", nil)
b.Record("1.2.3.4", "/admin", KindSensitive)
if b.IsBanned("1.2.3.4") {
t.Fatal("should not be banned before reaching threshold")
}
b.Record("1.2.3.4", "/.env", KindSensitive)
if !b.IsBanned("1.2.3.4") {
t.Fatal("expected ban after reaching sensitive threshold")
}
}
func TestNotFoundThreshold(t *testing.T) {
b := newTestBanner(10, 3, time.Minute, time.Minute, "", nil)
for i := 0; i < 3; i++ {
b.Record("5.6.7.8", "/random-"+string(rune('a'+i)), KindNotFound)
}
if !b.IsBanned("5.6.7.8") {
t.Fatal("expected ban after reaching 404 threshold")
}
}
func TestNotFoundTrackDisabledByDefault(t *testing.T) {
// 默认(TrackNotFound=false)下 404 不参与统计:正常业务路径一律放行,
// 仅敏感扫描路径仍计数封禁——防止共享出口下正常访问被 404 探测连坐误伤。
b := New(Options{Enabled: true, ThresholdSensitive: 1, Threshold404: 1, Window: time.Minute, Duration: time.Minute})
for i := 0; i < 5; i++ {
b.Record("7.7.7.7", "/random-"+string(rune('a'+i)), KindNotFound)
}
if b.IsBanned("7.7.7.7") {
t.Fatal("404 events must not accumulate when TrackNotFound is false")
}
b.Record("7.7.7.7", "/.env", KindSensitive)
if !b.IsBanned("7.7.7.7") {
t.Fatal("sensitive events must still ban even when 404 tracking is off")
}
}
func TestWindowExpiry(t *testing.T) {
b := newTestBanner(2, 5, 50*time.Millisecond, time.Minute, "", nil)
b.Record("1.1.1.1", "/admin", KindSensitive)
time.Sleep(80 * time.Millisecond) // 事件滑出窗口
b.Record("1.1.1.1", "/.env", KindSensitive)
if b.IsBanned("1.1.1.1") {
t.Fatal("events outside the window must not accumulate")
}
}
func TestDurationExpiry(t *testing.T) {
b := newTestBanner(1, 5, time.Minute, 60*time.Millisecond, "", nil)
b.Record("2.2.2.2", "/admin", KindSensitive)
if !b.IsBanned("2.2.2.2") {
t.Fatal("expected ban")
}
time.Sleep(100 * time.Millisecond) // 封禁到期
if b.IsBanned("2.2.2.2") {
t.Fatal("ban should expire after duration")
}
}
func TestAlertOncePerIP(t *testing.T) {
var calls int64
b := newTestBanner(1, 5, time.Minute, time.Minute, "[email protected]", &calls)
b.Record("9.9.9.9", "/wp-admin", KindSensitive)
b.Record("9.9.9.9", "/.git", KindSensitive) // 已在封禁中,不应重复告警
b.Record("9.9.9.9", "/config", KindSensitive)
// sendAlert 异步调用,轮询等待最多 500ms
deadline := time.Now().Add(500 * time.Millisecond)
for atomic.LoadInt64(&calls) == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if got := atomic.LoadInt64(&calls); got != 1 {
t.Fatalf("expected exactly 1 alert, got %d", got)
}
}
func TestDisabled(t *testing.T) {
b := New(Options{Enabled: false})
b.Record("3.3.3.3", "/admin", KindSensitive)
if b.IsBanned("3.3.3.3") {
t.Fatal("disabled auto-ban must not ban")
}
}
func TestDefaults(t *testing.T) {
b := New(Options{Enabled: true}) // 全部走默认值
if b.thresholdSensitive != DefaultThresholdSensitive || b.threshold404 != DefaultThreshold404 {
t.Fatalf("default thresholds wrong: %d/%d", b.thresholdSensitive, b.threshold404)
}
if b.window != DefaultWindowSeconds*time.Second || b.duration != DefaultDurationMinutes*time.Minute {
t.Fatalf("default window/duration wrong: %v/%v", b.window, b.duration)
}
if b.trackNotFound {
t.Fatal("404 tracking must be disabled by default")
}
}
func TestIsSensitivePath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"/admin", true},
{"/admin/panel", true},
{"/WP-ADMIN", true},
{"/.env", true},
{"/user/questions", false},
{"/vote/abc123", false},
{"/administratorx", false}, // 前缀必须完整段匹配,避免误伤
// 目录枚举 / 双斜杠变体(wlwmanifest 扫描):前缀匹配漏网,片段匹配兜底
{"//wp/wp-includes/wlwmanifest.xml", true},
{"//news/wp-includes/wlwmanifest.xml", true},
{"//2018/wp-includes/wlwmanifest.xml", true},
{"//shop/wp-includes/wlwmanifest.xml", true},
{"//test/xmlrpc.php", true},
{"/blog/wp-json/", true},
{"/wp-content/uploads/evil.php", true},
// 业务路径不得被片段匹配误伤
{"/_/tamakyi", false},
{"/_/admin6", false},
{"/api/v1/question/abc123", false},
{"/explore/tags/golang", false},
{"/i18n/zh-CN", false},
}
for _, c := range cases {
if got := IsSensitivePath(c.path); got != c.want {
t.Errorf("IsSensitivePath(%q) = %v, want %v", c.path, got, c.want)
}
}
}
+194
View File
@@ -0,0 +1,194 @@
// 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 censor
import (
"context"
"encoding/json"
"sync"
"time"
"github.com/aliyun/alibaba-cloud-sdk-go/services/green"
"github.com/pkg/errors"
)
type AliyunTextCensor struct {
accessKey, accessKeySecret string
}
func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor {
return &AliyunTextCensor{
accessKey: accessKey,
accessKeySecret: accessKeySecret,
}
}
// 包级复用的 green.Client:审核请求高频且密钥在启动后不变,
// 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。
var (
aliyunGreenOnce sync.Once
aliyunGreenClient *green.Client
aliyunGreenErr error
)
func (c *AliyunTextCensor) getClient() (*green.Client, error) {
aliyunGreenOnce.Do(func() {
aliyunGreenClient, aliyunGreenErr = green.NewClientWithAccessKey("cn-shanghai", c.accessKey, c.accessKeySecret)
if aliyunGreenErr == nil {
aliyunGreenClient.SetConnectTimeout(5 * time.Second)
aliyunGreenClient.SetReadTimeout(10 * time.Second)
}
})
return aliyunGreenClient, aliyunGreenErr
}
type AliyunTextCensorResponse struct {
Code int `json:"code"`
Data []struct {
Code int `json:"code"`
Content string `json:"content"`
FilteredContent string `json:"filteredContent"`
Msg string `json:"msg"`
Results []struct {
Details []struct {
Contexts []struct {
Context string `json:"context"`
Positions []struct {
EndPos int `json:"endPos"`
StartPos int `json:"startPos"`
} `json:"positions"`
} `json:"contexts"`
Label string `json:"label"`
} `json:"details"`
Label string `json:"label"`
Rate float64 `json:"rate"`
Scene string `json:"scene"`
Suggestion string `json:"suggestion"`
} `json:"results"`
TaskId string `json:"taskId"`
} `json:"data"`
Msg string `json:"msg"`
RequestId string `json:"requestId"`
}
func (r *AliyunTextCensorResponse) IsPass() bool {
if len(r.Data) == 0 || len(r.Data[0].Results) == 0 {
return false
}
// ⚠️ Right now, we allow `review` and `pass` to pass the censor.
// 任一 scene 判定为 block 即不通过(此前只检查首个 result,存在漏审)。
for _, result := range r.Data[0].Results {
if result.Suggestion == "block" {
return false
}
}
return true
}
// Censor censors text with Aliyun API.
// https://developer.qiniu.com/censor/7260/api-text-censor
func (c *AliyunTextCensor) Censor(_ context.Context, text string) (*TextCensorResponse, error) {
client, err := c.getClient()
if err != nil {
return nil, errors.Wrap(err, "new client with access key")
}
content, err := json.Marshal(
map[string]interface{}{
"scenes": []string{"antispam"},
"tasks": []map[string]interface{}{
{"content": text},
},
},
)
if err != nil {
return nil, errors.Wrap(err, "marshal content")
}
req := green.CreateTextScanRequest()
req.SetContent(content)
resp, err := client.TextScan(req)
if err != nil {
return nil, errors.Wrap(err, "text scan")
}
if !resp.IsSuccess() {
return nil, errors.New("response is not success")
}
return AliyunTextCensorParser(resp.GetHttpContentBytes())
}
func (*AliyunTextCensor) String() string {
return "aliyun"
}
func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
var responseJSON AliyunTextCensorResponse
if err := json.Unmarshal(raw, &responseJSON); err != nil {
return nil, errors.Wrap(err, "unmarshal response")
}
if len(responseJSON.Data) == 0 {
return nil, errors.New("response data is empty")
}
var hint string
var label string
var confidence float64
for _, result := range responseJSON.Data[0].Results {
if result.Label == "normal" {
continue
}
// Get the first context as the hint, forbidden type, confidence.
for _, detail := range result.Details {
for _, context := range detail.Contexts {
hint = context.Context
}
}
label = result.Label
confidence = result.Rate
break
}
return &TextCensorResponse{
SourceName: "aliyun",
Pass: responseJSON.IsPass(),
ForbiddenType: formatAliyunForbiddenType(label),
Hint: hint,
Confidence: confidence,
RawResponse: raw,
}, nil
}
func formatAliyunForbiddenType(typ string) ForbiddenType {
switch typ {
case "spam":
return ForbiddenTypeSpam
case "ad":
return ForbiddenTypeAd
case "politics":
return ForbiddenTypePolitics
case "terrorism":
return ForbiddenTypeTerrorism
case "abuse":
return ForbiddenTypeAbuse
case "porn":
return ForbiddenTypePorn
case "flood":
return ForbiddenTypeFlood
case "contraband":
return ForbiddenTypeContraband
case "meaningless":
return ForbiddenTypeMeaningless
case "harmful":
return ForbiddenTypeHarmful
default:
return ""
}
}
+96
View File
@@ -0,0 +1,96 @@
// 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 censor
import (
"context"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
var (
censorCacheNoMoreThan = 31 * 24 * time.Hour // 1 month
)
// Text checks the text for sensitive content.
// It will save the censor log to the database and invoke the callback function.
func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
if !conf.Security.EnableTextCensor {
return &TextCensorResponse{Pass: true}, nil
}
var responses []*TextCensorResponse
for _, censor := range []TextCensor{
//No, we need to use qiniu's API.
NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret),
NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret),
} {
sourceName := censor.String()
// Try to get the censor from the database log.
censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, text, time.Now().Add(-censorCacheNoMoreThan))
if err != nil {
if !errors.Is(err, db.ErrCensorLogsNotFound) {
logrus.WithContext(ctx).WithError(err).Error("Failed to get censor log")
}
} else {
raw := censorLog.RawResponse
// We got the previous censor log cache, seems like we saved money for the API call.
switch censorLog.SourceName {
case "qiniu":
// HACK: Qiniu's API response is not accurate, so we need to check the text again with aliyun API.
response, err := QiniuTextCensorParser(raw)
if err == nil {
if response.Pass {
return response, nil
}
responses = append(responses, response) // If the text is not passed, we need to check it with aliyun API.
continue
}
case "aliyun":
return AliyunTextCensorParser(raw)
default:
logrus.WithContext(ctx).WithField("censor_source", censorLog.SourceName).Error("Unknown censor source")
}
}
// response, err := censor.Censor(ctx, text)
censorText := RemoveTrustedURL(text)
response, err := censor.Censor(ctx, censorText)
if err != nil {
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).Error("Failed to censor text")
continue
}
responses = append(responses, response)
// Save the censor log to the database.
if err := db.CensorLogs.Create(ctx, db.CreateCensorLogOptions{
SourceName: sourceName,
Input: text,
Pass: response.Pass,
RawResponse: response.RawResponse,
}); err != nil {
logrus.WithContext(ctx).WithError(err).Error("Failed to save censor log")
}
if response.Pass {
return response, nil
}
}
if len(responses) == 0 {
return nil, errors.New("no censor source available")
}
return responses[len(responses)-1], nil
}
+178
View File
@@ -0,0 +1,178 @@
// 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 censor
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/qiniu/go-sdk/v7/auth"
"github.com/samber/lo"
)
type QiniuTextCensor struct {
accessKey, accessSecret string
}
func NewQiniuTextCensor(accessKey, accessSecret string) *QiniuTextCensor {
return &QiniuTextCensor{
accessKey: accessKey,
accessSecret: accessSecret,
}
}
type QiniuTextCensorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Result struct {
Suggestion string `json:"suggestion"`
Scenes struct {
Antispam struct {
Suggestion string `json:"suggestion"`
Details []struct {
Label string `json:"label"`
Score float64 `json:"score"`
Contexts []struct {
Context string `json:"context"`
Positions []struct {
StartPos int `json:"startPos"`
EndPos int `json:"endPos"`
} `json:"positions"`
} `json:"contexts"`
} `json:"details"`
} `json:"antispam"`
} `json:"scenes"`
} `json:"result"`
}
func (r *QiniuTextCensorResponse) IsPass() bool {
for _, detail := range r.Result.Scenes.Antispam.Details {
label := detail.Label
// These are absolutely not allowed to display to users.
blockedLabels := []string{"politics", "terrorism", "porn"}
if lo.Contains(blockedLabels, label) {
return false
}
}
// ⚠️ Right now, we allow `review` and `pass` to pass the censor.
return r.Result.Scenes.Antispam.Suggestion != "block"
}
// Censor censors text with Qiniu API.
// https://developer.qiniu.com/censor/7260/api-text-censor
func (c *QiniuTextCensor) Censor(ctx context.Context, text string) (*TextCensorResponse, error) {
var bodyBuffer bytes.Buffer
if err := json.NewEncoder(&bodyBuffer).Encode(map[string]interface{}{
"data": map[string]interface{}{
"text": text,
},
"params": map[string]interface{}{
"scenes": []string{"antispam"},
},
}); err != nil {
return nil, errors.Wrap(err, "encode request body")
}
request, err := http.NewRequest(http.MethodPost, "https://ai.qiniuapi.com/v3/text/censor", &bodyBuffer)
if err != nil {
return nil, errors.Wrap(err, "new request")
}
request = request.WithContext(ctx)
credentials := auth.New(c.accessKey, c.accessSecret)
token, err := credentials.SignRequestV2(request)
if err != nil {
return nil, errors.Wrap(err, "sign request")
}
request.Header.Set("Authorization", "Qiniu "+token)
// 带超时:避免七牛审核服务无响应时请求永久挂起,拖垮提问/回答流程
client := http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(request)
if err != nil {
return nil, errors.Wrap(err, "do request")
}
defer func() { _ = resp.Body.Close() }()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "read body")
}
if resp.StatusCode/100 != 2 {
return nil, errors.Errorf("unexpected error code: %d, response body: %s", resp.StatusCode, string(bodyBytes))
}
return QiniuTextCensorParser(bodyBytes)
}
func (*QiniuTextCensor) String() string {
return "qiniu"
}
func QiniuTextCensorParser(raw []byte) (*TextCensorResponse, error) {
var responseJSON QiniuTextCensorResponse
if err := json.Unmarshal(raw, &responseJSON); err != nil {
return nil, errors.Wrap(err, "decode response body")
}
var hint string
var detailKey string
var confidence float64
for _, detail := range responseJSON.Result.Scenes.Antispam.Details {
if detail.Label == "normal" {
continue
}
// Get the first context as the hint, forbidden type, confidence.
for _, context := range detail.Contexts {
hint = context.Context
}
detailKey = detail.Label
confidence = detail.Score
break
}
return &TextCensorResponse{
SourceName: "qiniu",
Pass: responseJSON.IsPass(),
ForbiddenType: formatQiniuForbiddenType(detailKey),
Hint: hint,
Confidence: confidence,
RawResponse: raw,
}, nil
}
func formatQiniuForbiddenType(typ string) ForbiddenType {
switch typ {
case "spam":
return ForbiddenTypeSpam
case "ad":
return ForbiddenTypeAd
case "politics":
return ForbiddenTypePolitics
case "terrorism":
return ForbiddenTypeTerrorism
case "abuse":
return ForbiddenTypeAbuse
case "porn":
return ForbiddenTypePorn
case "flood":
return ForbiddenTypeFlood
case "contraband":
return ForbiddenTypeContraband
case "meaningless":
return ForbiddenTypeMeaningless
default:
return ""
}
}
+121
View File
@@ -0,0 +1,121 @@
// 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 censor
import (
"context"
"encoding/json"
"fmt"
"strings"
)
type ForbiddenType string
const (
ForbiddenTypeSpam ForbiddenType = "spam"
ForbiddenTypeAd ForbiddenType = "ad"
ForbiddenTypePolitics ForbiddenType = "politics"
ForbiddenTypeTerrorism ForbiddenType = "terrorism"
ForbiddenTypeAbuse ForbiddenType = "abuse"
ForbiddenTypePorn ForbiddenType = "porn"
ForbiddenTypeFlood ForbiddenType = "flood"
ForbiddenTypeContraband ForbiddenType = "contraband"
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
ForbiddenTypeHarmful ForbiddenType = "harmful"
)
func (f ForbiddenType) String() string {
return map[ForbiddenType]string{
ForbiddenTypeSpam: "含垃圾信息",
ForbiddenTypeAd: "广告",
ForbiddenTypePolitics: "涉政",
ForbiddenTypeTerrorism: "暴恐",
ForbiddenTypeAbuse: "辱骂",
ForbiddenTypePorn: "色情",
ForbiddenTypeFlood: "灌水",
ForbiddenTypeContraband: "违禁",
ForbiddenTypeMeaningless: "无意义",
ForbiddenTypeHarmful: "不良场景",
}[f]
}
type TextCensorResponse struct {
SourceName string `json:"source_name"`
Pass bool `json:"pass"`
ForbiddenType ForbiddenType `json:"forbidden_type"`
Hint string `json:"hint"`
Confidence float64 `json:"confidence"`
RawResponse json.RawMessage `json:"raw_response"`
}
func (r *TextCensorResponse) ToJSON() []byte {
jsonBytes, _ := json.Marshal(r)
return jsonBytes
}
func (r *TextCensorResponse) ErrorMessage() string {
errorMessage := "内容安全检查不通过"
if r.ForbiddenType != "" {
errorMessage += fmt.Sprintf(" [%s]", r.ForbiddenType.String())
}
if r.Hint != "" {
errorMessage += fmt.Sprintf(",相关标签:%q", r.Hint)
}
return errorMessage
}
type TextCensor interface {
Censor(ctx context.Context, text string) (*TextCensorResponse, error)
String() string
}
var TrustedHosts = []string{
"xhslink.com", // 小红书
"weibo.com", // 微博
"b23.tv", // Bilibili
"acfun.cn",
"aixifan.com",
"github.com",
// 自有域名:旧域 tama.guru 保留至 2026-12 到期前的过渡期结束,
// 期间用户分享的旧链接仍需视为可信(不被判为广告);新域 shiroko.one 为主域。
"tama.guru",
"shiroko.one",
"337845818.best",
"x.com",
"bilibili.com",
}
// RemoveTrustedURL removes the trusted URL from the text,
// in order to bypass the URL censoring.
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信,
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。
func RemoveTrustedURL(str string) string {
for _, host := range TrustedHosts {
for _, protocol := range []string{"http://", "https://"} {
needle := protocol + host
var b strings.Builder
rest := str
for {
idx := strings.Index(rest, needle)
if idx < 0 {
b.WriteString(rest)
break
}
b.WriteString(rest[:idx])
after := rest[idx+len(needle):]
if len(after) == 0 || after[0] == '/' || after[0] == '?' || after[0] == '#' {
// 可信域名边界:跳过该前缀,从域名之后继续(域名本身被移除,不进入审核)。
rest = after
} else {
// 非可信域名边界(如 github.com.evil):保留前缀,从其后继续扫描。
b.WriteString(needle)
rest = after
}
}
str = b.String()
}
}
return str
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2025 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 censor
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRemoveTrustedURL(t *testing.T) {
for _, tc := range []struct {
name string
text string
want string
}{
{name: "empty string", text: "", want: ""},
{name: "normal", text: "你喜欢看什么书?", want: "你喜欢看什么书?"},
{name: "xiaohongshu", text: `59 茄酱发布了一篇小红书笔记,快来看吧! 😆 jk14SBf2G75FTW2 😆 http://xhslink.com/a/WYaQ6Tjn86Hab,复制本条信息,打开【小红书】App查看精彩内容!`, want: `59 茄酱发布了一篇小红书笔记,快来看吧! 😆 jk14SBf2G75FTW2 😆 /a/WYaQ6Tjn86Hab,复制本条信息,打开【小红书】App查看精彩内容!`},
{name: "weibo", text: `TK 发微博啦~ https://weibo.com/1401527553/5144129806795225 真好看!`, want: `TK 发微博啦~ /1401527553/5144129806795225 真好看!`},
} {
t.Run(tc.name, func(t *testing.T) {
got := RemoveTrustedURL(tc.text)
assert.Equal(t, tc.want, got)
})
}
}
+84
View File
@@ -0,0 +1,84 @@
// Package ipinfo 识别 IP 的网络归属(CDN / 云厂商 / WAF)。
// 基于 projectdiscovery/cdncheck 的离线签名(编译期嵌入,上游每周更新指纹),
// 判定过程零外部网络请求,适合 1C1G 小型服务器与告警链路使用。
// 当前用于安全告警邮件:展示扫描来源的网络归属,帮助管理员判断告警严重性。
package ipinfo
import (
"fmt"
"net"
"strings"
"sync"
"github.com/projectdiscovery/cdncheck"
)
var (
clientOnce sync.Once
client *cdncheck.Client
)
// getClient 懒加载判定客户端(签名数据编译期嵌入,New 无错误返回)。
func getClient() *cdncheck.Client {
clientOnce.Do(func() {
client = cdncheck.New()
})
return client
}
// Lookup 判定 IP 的网络归属。
// 返回:
// - label:展示用标签,如 "Cloudflare(CDN)"、"亚马逊(云厂商)";
// 未命中返回 "未识别(普通网络)",输入非法或判定出错返回 "识别失败"。
// - proxied:IP 是否属于 CDN/WAF/代理网络(用于提示"封禁可能无法拦截真实来源")。
//
// 任何内部错误都被折叠为展示标签,不向调用方传播——告警链路不应因归属识别失败而中断。
func Lookup(ipStr string) (label string, proxied bool) {
ip := net.ParseIP(strings.TrimSpace(ipStr))
if ip == nil {
return "识别失败(IP 格式异常)", false
}
matched, value, itemType, err := getClient().Check(ip)
if err != nil {
return "识别失败", false
}
if !matched || value == "" {
return "未识别(普通网络)", false
}
return fmt.Sprintf("%s(%s)", title(value), typeLabel(itemType)), true
}
// typeLabel 将 cdncheck 的 itemType 翻译为中文展示。
func typeLabel(itemType string) string {
switch itemType {
case "cdn":
return "CDN"
case "waf":
return "WAF"
case "cloud":
return "云厂商"
default:
return "未知类型"
}
}
// acronymProviders 需要保持全大写的提供商缩写(cdncheck 返回全小写,如 aws → AWS)。
var acronymProviders = map[string]bool{
"aws": true, "gcp": true, "oci": true, "gds": true, "ovh": true,
}
// title 将提供商名转为展示形式:缩写全大写(aws → AWS),其余首字母大写
// (cloudflare → Cloudflare)。
func title(s string) string {
if s == "" {
return s
}
lower := strings.ToLower(s)
if acronymProviders[lower] {
return strings.ToUpper(lower)
}
r := []rune(lower)
return strings.ToUpper(string(r[0])) + string(r[1:])
}
+27
View File
@@ -0,0 +1,27 @@
package ipinfo
import "testing"
// TestLookupInvalidIP 非法输入必须折叠为展示标签且不 panic,proxied 恒为 false。
func TestLookupInvalidIP(t *testing.T) {
for _, in := range []string{"", "not-an-ip", "999.1.1.1", " "} {
label, proxied := Lookup(in)
if proxied {
t.Errorf("Lookup(%q) proxied = true, want false", in)
}
if label == "" {
t.Errorf("Lookup(%q) label is empty, want a fallback label", in)
}
}
}
// TestLookupReturnsLabel 合法 IP 应返回非空标签且不 panic(不锁定具体归属,
// 避免上游签名快照更新导致测试抖动)。
func TestLookupReturnsLabel(t *testing.T) {
for _, in := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
label, _ := Lookup(in)
if label == "" {
t.Errorf("Lookup(%q) label is empty", in)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package shared
import "strings"
// BlockWordHit 检查文本是否命中箱主设置的屏蔽词表(逗号分隔),返回首个命中的词。
//
// 屏蔽词语义为「箱主防骚扰词表」:提问、私聊(对方发给我的消息)与投票提交的
// 开放文本都应经过同一份词表过滤,因此把判定收敛到本函数供各入口复用,
// 与账号设置页保存时使用的同一 CSV 格式(半角逗号分隔、已去重、去空白)。
//
// 存储层可能残留历史脏数据(多余空白 / 空项),这里解析时统一 TrimSpace 并跳过空项。
func BlockWordHit(blockWordsCSV, text string) (word string, hit bool) {
if blockWordsCSV == "" || text == "" {
return "", false
}
for _, w := range strings.Split(blockWordsCSV, ",") {
w = strings.TrimSpace(w)
if w == "" {
continue
}
if strings.Contains(text, w) {
return w, true
}
}
return "", false
}
+32
View File
@@ -0,0 +1,32 @@
package shared
import "testing"
func TestBlockWordHit(t *testing.T) {
cases := []struct {
name string
words string // CSV 词表
text string
word string
hit bool
}{
{name: "empty list", words: "", text: "广告", word: "", hit: false},
{name: "empty text", words: "广告", text: "", word: "", hit: false},
{name: "single hit", words: "广告,spam", text: "请勿发广告", word: "广告", hit: true},
{name: "second word hit", words: "广告,spam", text: "buy now spam!", word: "spam", hit: true},
{name: "no hit", words: "广告,spam", text: "你好呀", word: "", hit: false},
{name: "dirty csv trims space", words: " 广告 , spam ", text: "这是广告", word: "广告", hit: true},
{name: "dirty csv empty items skipped", words: "广告,,,spam", text: "spam again", word: "spam", hit: true},
{name: "substring semantics", words: "抽奖", text: "参与抽奖活动", word: "抽奖", hit: true},
{name: "chinese word bytes safe", words: "屏蔽", text: "你被屏蔽了吗", word: "屏蔽", hit: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotWord, gotHit := BlockWordHit(c.words, c.text)
if gotHit != c.hit || gotWord != c.word {
t.Fatalf("BlockWordHit(%q, %q) = (%q, %v), want (%q, %v)",
c.words, c.text, gotWord, gotHit, c.word, c.hit)
}
})
}
}
+94
View File
@@ -0,0 +1,94 @@
package shared
import (
"crypto/md5"
"encoding/hex"
"strings"
"sync"
"time"
)
// DuplicateBlocker 阻止同一来源(IP + 内容指纹)在窗口期内的重复提交,
// 用于拦截刷屏 / 重复轰炸提问。
//
// 实现:内存 map,key = normalizedIP + "|" + contentHash,值为首次提交时间。
// 窗口期内再次提交相同内容返回 false(拒绝)。
//
// 内存安全:条数超过 50000 时先清理过期项;窗口外的 key 由后台清理循环定时移除。
type DuplicateBlocker struct {
mu sync.Mutex
items map[string]time.Time
window time.Duration
maxItem int
}
// NewDuplicateBlocker 创建拦截器并启动后台清理循环。
// 注意:每个实例会启动一个常驻 goroutine,应用内应使用包级单例 DuplicateGuard,
// 不要为每次请求创建新实例。
func NewDuplicateBlocker(window time.Duration) *DuplicateBlocker {
d := &DuplicateBlocker{
items: make(map[string]time.Time),
window: window,
maxItem: 50000,
}
go func() {
for {
time.Sleep(10 * time.Minute)
d.cleanup()
}
}()
return d
}
// Allow 判断 key(IP+内容)是否允许提交:
// - 窗口期内已存在相同 key → 返回 false(重复,拒绝)
// - 否则记录并返回 true
func (d *DuplicateBlocker) Allow(key string) bool {
now := time.Now()
cutoff := now.Add(-d.window)
d.mu.Lock()
defer d.mu.Unlock()
if t, ok := d.items[key]; ok && t.After(cutoff) {
return false
}
// 超限时先清理过期项,避免恶意构造大量 key 撑爆内存
if len(d.items) >= d.maxItem {
for k, t := range d.items {
if !t.After(cutoff) {
delete(d.items, k)
}
}
// 仍超限则整体放弃写入(宁可放行也不撑爆内存)
if len(d.items) >= d.maxItem {
return true
}
}
d.items[key] = now
return true
}
func (d *DuplicateBlocker) cleanup() {
cutoff := time.Now().Add(-d.window)
d.mu.Lock()
for k, t := range d.items {
if !t.After(cutoff) {
delete(d.items, k)
}
}
d.mu.Unlock()
}
// DuplicateKey 生成拦截 key:规范化 IP + 内容 MD5。
func DuplicateKey(ip, content string) string {
ip = NormalizeIP(ip)
sum := md5.Sum([]byte(strings.TrimSpace(content)))
return ip + "|" + hex.EncodeToString(sum[:])
}
// DuplicateGuard 全局限流实例:同 IP 在 5 分钟内提交相同内容会被拒绝。
// 阈值合理:正常用户几乎不可能在 5 分钟内提交两条完全一致的提问。
var DuplicateGuard = NewDuplicateBlocker(5 * time.Minute)
+20
View File
@@ -0,0 +1,20 @@
package shared
import (
"github.com/sirupsen/logrus"
)
// SafeGo 在独立 goroutine 中执行 fn 并捕获 panic,防止任一 goroutine
// panic 未恢复导致整个进程崩溃(Go 默认行为:未恢复的 panic 终止进程)。
//
// 用法:shared.SafeGo(func() { ... })
func SafeGo(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
logrus.WithField("panic", r).Error("goroutine panic recovered")
}
}()
fn()
}()
}
+176
View File
@@ -0,0 +1,176 @@
package shared
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/tamakyi/TamaBox/internal/conf"
)
// NormalizeIP 规范化 IP 字符串:
// - 去除两端空白
// - 剥离端口("1.2.3.4:8080" / "[::1]:8080")
// - 校验合法性,非法返回空串
// - IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单/限流精确匹配
func NormalizeIP(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if h, _, err := net.SplitHostPort(s); err == nil {
s = h
}
ip := net.ParseIP(s)
if ip == nil {
return ""
}
if v4 := ip.To4(); v4 != nil {
return v4.String()
}
return ip.String()
}
// LimitKey 把客户端 IP 归一化为「限流 / 自动封禁」用的聚合键。
//
// - IPv4:原样返回(每个地址独立计数)。
// - IPv6:掩码到 /64 前缀后返回,形如 "2001:db8:1:2::/64"。
//
// 为什么 IPv6 必须聚合:单个住宅或隧道 IPv6 分配通常就是一个 /64(2^64 个地址)。
// 若以完整地址作键,攻击者每次请求换一个源地址即可让每个键只命中一次,
// 使限流与 autoban 的阈值永远无法触发,同时海量键还会持续膨胀内存。
// /64 是 IPv6 的最小实际分配单位,按此聚合可在不误伤正常用户的前提下堵住绕过。
//
// ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续使用精确匹配(见 ipblacklist.go),
// 不要套用本函数,否则会把整个 /64 段一并拉黑。
func LimitKey(ip string) string {
parsed := net.ParseIP(ip)
if parsed == nil {
// 无法解析时原样返回,保持调用方行为可预期(空串由调用方自行判断)。
return ip
}
if v4 := parsed.To4(); v4 != nil {
return v4.String()
}
return parsed.Mask(net.CIDRMask(64, 128)).String() + "/64"
}
// trustedProxyNets 缓存解析后的可信代理网段(conf.Init 之后由 InitTrustedProxies 填充)。
var trustedProxyNets []*net.IPNet
// InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔的 IP / CIDR 列表),
// 仅限“你自己的”反向代理/负载均衡基础设施(例如本机 nginx 的 127.0.0.1、私有网络 LB),
// 在 conf.Init 之后调用一次。列表为空时,X-Forwarded-For / X-Real-IP 永不被信任。
//
// 设计目标:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单。
// 站点若架在 CDN 后,应配合 Cloudflare Tunnel / Authenticated Origin Pull(Cloudflare)或
// 防火墙仅放行回源网段(Cloudflare / EdgeOne)锁定源站只接受 CDN 连接,此时
// CF-Connecting-IP / EO-Client-IP 即为权威客户端 IP,无需任何 CIDR 白名单。
func InitTrustedProxies() error {
trustedProxyNets = nil
for _, raw := range strings.Split(conf.Security.TrustedProxies, ",") {
item := strings.TrimSpace(raw)
if item == "" {
continue
}
ipnet, err := parseTrustedProxy(item)
if err != nil {
return fmt.Errorf("parse trusted_proxies entry %q: %w", item, err)
}
trustedProxyNets = append(trustedProxyNets, ipnet)
}
return nil
}
// parseTrustedProxy 接受单 IP(自动补 /32 或 /128)或 CIDR。
func parseTrustedProxy(s string) (*net.IPNet, error) {
if ip := net.ParseIP(s); ip != nil {
if ip4 := ip.To4(); ip4 != nil {
return &net.IPNet{IP: ip4, Mask: net.CIDRMask(32, 32)}, nil
}
return &net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)}, nil
}
_, ipnet, err := net.ParseCIDR(s)
if err != nil {
return nil, err
}
return ipnet, nil
}
// isTrustedProxy 判断给定 IP 是否落在可信代理清单内。
func isTrustedProxy(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, n := range trustedProxyNets {
if n.Contains(ip) {
return true
}
}
return false
}
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
//
// 解析策略(不依赖上游 CDN 的庞大 CIDR 清单):
// 1. 未开启 TrustProxy:一切以 TCP 对端 RemoteAddr 为准(不可伪造)。
// 2. 开启 TrustProxy:
// a. 优先信任 CDN 权威客户端头:CF-Connecting-IP(Cloudflare)→ EO-Client-IP
// (腾讯 EdgeOne)。这类头只能由对应 CDN 边缘按真实客户端写入,前提是源站已锁定
// 为仅接受这些 CDN 的回源连接(攻击者无法直连源站伪造)。
// b. 仅当 TCP 对端落在 trusted_proxies(你自己的基础设施)内时,才解析
// X-Forwarded-For / X-Real-IP,并从右向左剥离已知可信跳数取原始客户端。
// c. 否则回退到 RemoteAddr,绝不信任客户端自行提供的头。
//
// ⚠️ 若 trust_proxy = true 但源站仍可被人直连(未用 Tunnel / 未锁源站),攻击者可自行
// 发送 CF-Connecting-IP / EO-Client-IP 头,此时本函数会信任之。因此生产开启 trust_proxy
// 时,务必配合「Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源
// 网段」等手段锁定源站,使伪造头无法抵达应用。
//
// 双 CDN 部署(境内 EdgeOne + 境外 Cloudflare)的额外约束:
// 若回源请求携带对方 CDN 的权威头(经 EdgeOne 回源的请求带 CF-Connecting-IP、经
// Cloudflare 回源的请求带 EO-Client-IP),攻击者即可借道另一家伪造身份,绕过限流 /
// 自动封禁 / 单 IP 投票限制,或嫁祸他人。因此必须:① 防火墙仅放行两家 CDN 的回源网段;
// ② 在各家 CDN 的规则引擎中删除入站请求里「对方 CDN」的权威头,使每个请求最终只携带
// 自己所在 CDN 的头(操作步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md)。
// Cloudflare 侧因 CF-Connecting-IP 恒被覆盖,且用 Transform Rule 删掉 EO-Client-IP 即可;
// EdgeOne 侧用规则引擎删掉 CF-Connecting-IP 即可。
func ResolveClientIP(r *http.Request) string {
if !conf.Security.TrustProxy {
return NormalizeIP(r.RemoteAddr)
}
// (a) CDN 权威客户端头:CF-Connecting-IP(Cloudflare)优先,其次 EO-Client-IP(腾讯
// EdgeOne,等效于 CF-Connecting-IP 的权威头,回源时由 EdgeOne 边缘写入客户端真实 IP)。
if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
return cfIP
}
if eoIP := NormalizeIP(r.Header.Get("EO-Client-IP")); eoIP != "" {
return eoIP
}
// (b)/(c) 仅当 TCP 对端是受信代理时才解析 XFF/X-Real-IP。
remote := NormalizeIP(r.RemoteAddr)
if isTrustedProxy(remote) {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
// 从右向左取第一个非可信代理跳:客户端的真实 IP。
parts := strings.Split(forwarded, ",")
for i := len(parts) - 1; i >= 0; i-- {
hop := NormalizeIP(parts[i])
if hop == "" {
continue
}
if !isTrustedProxy(hop) {
return hop
}
}
}
if realIP := NormalizeIP(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
}
return remote
}
+127
View File
@@ -0,0 +1,127 @@
package shared
import (
"net/http/httptest"
"testing"
"github.com/tamakyi/TamaBox/internal/conf"
)
func TestResolveClientIPCloudflareHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "192.0.2.10:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("X-Real-IP", "198.51.100.30")
if got := ResolveClientIP(r); got != "198.51.100.20" {
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
}
}
func TestResolveClientIPIgnoresHeadersWithoutTrust(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = false
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "192.0.2.10:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "192.0.2.10" {
t.Fatalf("ResolveClientIP() = %q, want remote address", got)
}
}
// TestResolveClientIPEdgeOneHeader 验证腾讯 EdgeOne 的权威客户端头 EO-Client-IP
// 在 TrustProxy=true 时被信任(双 CDN 部署:境内 EdgeOne 回源不带 CF-Connecting-IP)。
func TestResolveClientIPEdgeOneHeader(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443" // 模拟 EdgeOne 边缘回源
r.Header.Set("EO-Client-IP", "198.51.100.99")
r.Header.Set("X-Forwarded-For", "203.0.113.99")
if got := ResolveClientIP(r); got != "198.51.100.99" {
t.Fatalf("ResolveClientIP() = %q, want EdgeOne client IP", got)
}
}
// TestResolveClientIPPrefersCloudflareOverEdgeOne 锁定权威头的优先级:
// 两个头并存(配置遗漏剥离规则时的异常态)时以 CF-Connecting-IP 为准,行为可预期。
func TestResolveClientIPPrefersCloudflareOverEdgeOne(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443"
r.Header.Set("CF-Connecting-IP", "198.51.100.20")
r.Header.Set("EO-Client-IP", "198.51.100.99")
if got := ResolveClientIP(r); got != "198.51.100.20" {
t.Fatalf("ResolveClientIP() = %q, want Cloudflare client IP", got)
}
}
// TestResolveClientIPEdgeOneInvalidFallsBack 验证 EO-Client-IP 为非法值时
// 不 panic 且回退到 RemoteAddr(与 CF-Connecting-IP 的健壮性一致)。
func TestResolveClientIPEdgeOneInvalidFallsBack(t *testing.T) {
oldTrustProxy := conf.Security.TrustProxy
defer func() { conf.Security.TrustProxy = oldTrustProxy }()
conf.Security.TrustProxy = true
r := httptest.NewRequest("GET", "https://example.com/", nil)
r.RemoteAddr = "203.0.113.44:443"
r.Header.Set("EO-Client-IP", "not-an-ip")
if got := ResolveClientIP(r); got != "203.0.113.44" {
t.Fatalf("ResolveClientIP() = %q, want remote address fallback", got)
}
}
// TestLimitKey 是 P1-4 的回归测试:限流 / 自动封禁用的聚合键必须
// - IPv4:原样返回(每地址独立计数);
// - IPv6:掩码到 /64(防止单 /64 内轮换海量地址绕过阈值、并避免键无限膨胀);
// - 非法输入:原样返回,保持调用方行为可预期。
func TestLimitKey(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"ipv4 passthrough", "203.0.113.10", "203.0.113.10"},
{"ipv4-mapped collapses to v4", "::ffff:203.0.113.10", "203.0.113.10"},
{"ipv6 aggregated to /64", "2001:db8:1:2:3:4:5:6", "2001:db8:1:2::/64"},
// LimitKey 假定输入已由 ResolveClientIP 归一化(已剥离端口)。若误传入带端口的
// 字符串,ParseIP 失败会原样透传 —— 这是防御性约定,需锁定以免行为漂移。
{"ipv4 with port passes through", "203.0.113.10:8080", "203.0.113.10:8080"},
{"ipv6 with port passes through", "[2001:db8:1:2:3:4:5:6]:443", "[2001:db8:1:2:3:4:5:6]:443"},
{"invalid input passthrough", "not-an-ip", "not-an-ip"},
{"empty passthrough", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := LimitKey(tc.in); got != tc.want {
t.Fatalf("LimitKey(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestLimitKeyIPv6SamePrefixCollapses 验证:同一 /64 内任意两台主机应聚合到同一键,
// 不同 /64 必须落到不同键 —— 这是限速 / 封禁抗 IPv6 轮换绕过的核心不变量。
func TestLimitKeyIPv6SamePrefixCollapses(t *testing.T) {
a := LimitKey("2001:db8:1:2:3:4:5:6")
b := LimitKey("2001:db8:1:2:ff:fe::1") // 同一 /64,仅主机位不同
if a != b {
t.Fatalf("同一 /64 内地址应聚合到同一键,got %q vs %q", a, b)
}
c := LimitKey("2001:db8:1:3:3:4:5:6") // 不同 /64
if a == c {
t.Fatalf("不同 /64 应落到不同键,两者均为 %q", a)
}
}
+61
View File
@@ -0,0 +1,61 @@
package shared
import (
"context"
"errors"
"net"
)
// DialFirstReachable 对一组已通过安全校验的候选 IP 并发拨号,返回最先成功建立的连接。
//
// 适用场景:SSRF 防护型出站客户端。调用方先解析域名并过滤内网地址,再以 IP 字面量拨号
// (无二次解析,免疫 DNS rebinding),本函数负责多 IP 容错——避免固定拨单一 IP 时,
// 恰好抽中不可达或被劣化的边缘节点(如 Cloudflare anycast 的个别边缘)导致请求稳定超时;
// curl 等客户端默认会尝试多个 IP,因此这里保持同等的多 IP 能力。
//
// 成功路径会取消其余拨号并关闭任何已建立但落选的连接,避免泄漏;全部失败时返回第一个错误。
// maxCandidates<=0 或大于候选数时按全部候选处理;ips 为空返回错误。
func DialFirstReachable(ctx context.Context, dialer *net.Dialer, network string, ips []net.IP, port string, maxCandidates int) (net.Conn, error) {
if len(ips) == 0 {
return nil, errors.New("no candidates to dial")
}
if maxCandidates <= 0 || maxCandidates > len(ips) {
maxCandidates = len(ips)
}
dialCtx, cancel := context.WithCancel(ctx)
defer cancel()
type dialResult struct {
conn net.Conn
err error
}
results := make(chan dialResult, maxCandidates)
for _, ip := range ips[:maxCandidates] {
ip := ip
go func() {
conn, err := dialer.DialContext(dialCtx, network, net.JoinHostPort(ip.String(), port))
results <- dialResult{conn: conn, err: err}
}()
}
var firstErr error
remaining := maxCandidates
for remaining > 0 {
r := <-results
remaining--
if r.err == nil {
cancel() // 通知其余拨号尽快退出
for ; remaining > 0; remaining-- {
if extra := <-results; extra.err == nil && extra.conn != nil {
_ = extra.conn.Close()
}
}
return r.conn, nil
}
if firstErr == nil {
firstErr = r.err
}
}
return nil, firstErr
}
+127
View File
@@ -0,0 +1,127 @@
package shared
import (
"context"
"net"
"strconv"
"strings"
"testing"
"time"
)
// startLoopbackListener 在 127.0.0.1 上开一个监听并返回其端口(关闭由 t.Cleanup 托管)。
func startLoopbackListener(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
return ln.Addr().(*net.TCPAddr).Port
}
func testDialer() *net.Dialer {
return &net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}
}
// TestDialFirstReachablePicksReachableIP 验证多候选并发拨号会跳过不可达 IP、
// 选中可达的那个(顺序上不可达在前也不影响结果)。
func TestDialFirstReachablePicksReachableIP(t *testing.T) {
port := startLoopbackListener(t)
// 127.0.0.2 上没有任何监听 → 该候选必然拨号失败;
// 127.0.0.1:port 有监听 → 可达。把坏 IP 放前面,验证不受顺序影响。
conn, err := DialFirstReachable(context.Background(), testDialer(), "tcp",
[]net.IP{net.ParseIP("127.0.0.2"), net.ParseIP("127.0.0.1")}, strconv.Itoa(port), 6)
if err != nil {
t.Fatalf("DialFirstReachable returned error: %v", err)
}
if conn == nil {
t.Fatal("DialFirstReachable returned nil conn without error")
}
// 连接应落在可达的 127.0.0.1 上
remote := conn.RemoteAddr().(*net.TCPAddr)
if !remote.IP.Equal(net.ParseIP("127.0.0.1")) {
t.Fatalf("expected connection to 127.0.0.1, got %s", remote)
}
_ = conn.Close()
}
// TestDialFirstReachableAllFail 验证全部候选不可达时返回错误而非死等或空结果。
func TestDialFirstReachableAllFail(t *testing.T) {
// 先开再关,拿到一个确定无监听的端口
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
closedPort := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
start := time.Now()
conn, err := DialFirstReachable(context.Background(), testDialer(), "tcp",
[]net.IP{net.ParseIP("127.0.0.2"), net.ParseIP("127.0.0.1")}, strconv.Itoa(closedPort), 6)
if err == nil {
if conn != nil {
_ = conn.Close()
}
t.Fatal("expected error when all candidates unreachable, got nil")
}
if conn != nil {
_ = conn.Close()
t.Fatal("expected nil conn on total failure")
}
if elapsed := time.Since(start); elapsed > 15*time.Second {
t.Fatalf("all-fail path took too long: %v", elapsed)
}
}
// TestDialFirstReachableCtxCancel 验证父 context 取消后拨号立即返回(不泄漏、不死锁)。
func TestDialFirstReachableCtxCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // 拨号前已取消
start := time.Now()
conn, err := DialFirstReachable(ctx, testDialer(), "tcp",
[]net.IP{net.ParseIP("127.0.0.1")}, "81", 6)
if err == nil {
if conn != nil {
_ = conn.Close()
}
t.Fatal("expected error after context cancel, got nil")
}
if conn != nil {
_ = conn.Close()
t.Fatal("expected nil conn on cancelled context")
}
if !strings.Contains(err.Error(), "canceled") && !strings.Contains(err.Error(), "deadline") {
t.Fatalf("expected context cancellation error, got: %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("context cancel path took too long: %v", elapsed)
}
}
// TestDialFirstReachableEmptyCandidates 验证空候选返回错误而非 panic。
func TestDialFirstReachableEmptyCandidates(t *testing.T) {
conn, err := DialFirstReachable(context.Background(), testDialer(), "tcp", nil, "80", 6)
if err == nil {
if conn != nil {
_ = conn.Close()
}
t.Fatal("expected error for empty candidates, got nil")
}
}
// TestDialFirstReachableMaxCandidatesCapped 验证 maxCandidates 截断候选数不会 panic。
func TestDialFirstReachableMaxCandidatesCapped(t *testing.T) {
port := startLoopbackListener(t)
conn, err := DialFirstReachable(context.Background(), testDialer(), "tcp",
[]net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("127.0.0.1")}, strconv.Itoa(port), 1)
if err != nil {
t.Fatalf("DialFirstReachable with capped candidates returned error: %v", err)
}
if conn == nil {
t.Fatal("DialFirstReachable returned nil conn without error")
}
_ = conn.Close()
}
+44
View File
@@ -0,0 +1,44 @@
// Package shared 提供跨包共享的应用级状态。
package shared
import (
"sync"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// AppState 保存应用级共享状态。
//
// 多语言体系(internal/i18n)对每个请求按 cookie / 用户配置逐请求解析语言,
// 本类型只承担一个职责:站点默认语言(兜底值)。
// 访客既无 lang cookie、登录用户也未配置语言时,使用 AppState 中的默认语言。
type AppState struct {
mu sync.RWMutex
lang string
}
// NewAppState 返回默认语言为 i18n.Default(简体中文)的应用状态。
func NewAppState() *AppState {
return &AppState{lang: i18n.Default}
}
// SetLang 设置站点默认语言。
// 内部统一走 i18n.Resolve 归一化(zh_hk / zh-hant / en-us 等变体都会映射到规范代码),
// 未知语言代码自动回退到 i18n.Default,保证 lang 始终是字典可查的规范代码。
func (s *AppState) SetLang(lang string) {
s.mu.Lock()
defer s.mu.Unlock()
s.lang = i18n.Resolve(lang)
}
// Lang 返回当前站点默认语言(始终为规范语言代码,如 "zh-CN" / "zh-TW" / "en" / "ja")。
func (s *AppState) Lang() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.lang
}
// App 是应用级共享状态单例。
// 服务启动时(cmd/web.go)依据配置 default_lang 调用 App.SetLang 初始化;
// 运行期也可由管理功能调用 SetLang 动态调整站点默认语言。
var App = NewAppState()
+77
View File
@@ -0,0 +1,77 @@
package shared
import (
"testing"
"time"
"github.com/tamakyi/TamaBox/internal/i18n"
)
func TestAppState_Default(t *testing.T) {
s := NewAppState()
if got := s.Lang(); got != i18n.Default {
t.Errorf("默认语言 = %q, 期望 %q", got, i18n.Default)
}
}
func TestAppState_SetLang_Normalize(t *testing.T) {
cases := []struct {
in string
want string
}{
{"zh-CN", i18n.LangZhCN},
{"zh_CN", i18n.LangZhCN}, // 下划线变体
{"zh", i18n.LangZhCN},
{"zh-TW", i18n.LangZhTW},
{"zh_hk", i18n.LangZhTW}, // 港繁统一映射到繁体中文
{"zh-hant", i18n.LangZhTW},
{"en", i18n.LangEn},
{"en-US", i18n.LangEn},
{"ja", i18n.LangJa},
{"ja_JP", i18n.LangJa},
{" En ", i18n.LangEn}, // 自动去空白
{"fr", i18n.Default}, // 未知回退
{"", i18n.Default}, // 空值回退
}
for _, tc := range cases {
s := NewAppState()
s.SetLang(tc.in)
if got := s.Lang(); got != tc.want {
t.Errorf("SetLang(%q) 后 Lang() = %q, 期望 %q", tc.in, got, tc.want)
}
}
}
func TestAppState_Concurrent(t *testing.T) {
s := NewAppState()
const n = 100
// 并发读写,覆盖 RWMutex 的多读单写路径。
// 用 channel 计数替代 WaitGroup(部分受限环境对 WaitGroup 信号量支持异常)。
done := make(chan struct{}, n)
for i := 0; i < n; i++ {
go func(i int) {
defer func() { done <- struct{}{} }()
if i%2 == 0 {
s.SetLang("en")
} else {
_ = s.Lang()
}
}(i)
}
for i := 0; i < n; i++ {
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatalf("并发测试超时:仅 %d/%d 完成", i, n)
}
}
}
func TestApp_Singleton(t *testing.T) {
// 单例默认语言为 zh-CN
if App.Lang() != i18n.Default {
t.Errorf("App.Lang() = %q, 期望默认 %q", App.Lang(), i18n.Default)
}
}
+100
View File
@@ -0,0 +1,100 @@
// Package stats 提供首页"提问箱信息统计"所需的全站公开聚合数据。
//
// 原本这些数据由 crontab 定时查询数据库、生成 question.count.txt 静态文件、
// 再由前端 fetch 外部 URL 展示。改为程序内直接计算后,这里用进程内 30 分钟缓存
// 吸收请求(节奏与原 crontab 一致),避免每个首页请求都打聚合查询到数据库。
package stats
import (
"context"
"fmt"
"sync"
"time"
"github.com/tamakyi/TamaBox/internal/db"
)
// View 全站公开统计展示模型。
// 同时提供原始数值字段与已排版的多行文本 Text,便于服务端模板渲染与
// 客户端定时刷新(/api/v1/site-stats)复用同一份内容。
type View struct {
RegisteredUsers int64 `json:"registered_users"`
TotalQuestions int64 `json:"total_questions"`
AnsweredQuestions int64 `json:"answered_questions"`
LastQuestionAt string `json:"last_question_at"`
LastAnswerAt string `json:"last_answer_at"`
UpdatedAt string `json:"updated_at"`
// Text 已按原 question.count.txt 格式排版好的多行文本,直接展示即可。
Text string `json:"text"`
}
// cacheTTL 缓存有效期,与原 crontab 生成文件的节奏保持一致(半小时)。
const cacheTTL = 30 * time.Minute
var (
mu sync.Mutex
cached *View
cachedAt time.Time
)
// Get 返回全站统计。进程内缓存 cacheTTL,过期后才重新查询数据库。
func Get(ctx context.Context) (*View, error) {
mu.Lock()
if cached != nil && time.Since(cachedAt) < cacheTTL {
v := cached
mu.Unlock()
return v, nil
}
mu.Unlock()
userCount, err := db.Users.CountAll(ctx)
if err != nil {
return nil, err
}
qs, err := db.Questions.QuestionStats(ctx)
if err != nil {
return nil, err
}
v := &View{
RegisteredUsers: userCount,
TotalQuestions: qs.TotalQuestions,
AnsweredQuestions: qs.AnsweredQuestions,
}
if qs.LastQuestionAt != nil {
v.LastQuestionAt = qs.LastQuestionAt.Format("2006-01-02 15:04:05")
}
if qs.LastAnswerAt != nil {
v.LastAnswerAt = qs.LastAnswerAt.Format("2006-01-02 15:04:05")
}
v.UpdatedAt = time.Now().Format("2006-01-02 15:04:05")
v.Text = formatText(v)
mu.Lock()
cached = v
cachedAt = time.Now()
mu.Unlock()
return v, nil
}
// formatText 生成与原 question.count.txt 一致的多行展示文本。
func formatText(v *View) string {
s := fmt.Sprintf("注册用户总数: %d\n全站问题总数: %d\n已获得回答的问题总数: %d",
v.RegisteredUsers, v.TotalQuestions, v.AnsweredQuestions)
if v.LastQuestionAt != "" {
s += "\n最后一个提问发生在: " + v.LastQuestionAt
}
if v.LastAnswerAt != "" {
s += "\n最后一个回答发生在: " + v.LastAnswerAt
}
s += "\n数据更新于:" + v.UpdatedAt
return s
}
// Invalidate 清空缓存,供需要立即刷新统计的场景调用(如管理后台手动触发)。
func Invalidate() {
mu.Lock()
cached = nil
cachedAt = time.Time{}
mu.Unlock()
}
+81
View File
@@ -0,0 +1,81 @@
package storage
import (
"context"
"sync"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/pkg/errors"
"github.com/tamakyi/TamaBox/internal/conf"
)
var (
s3ClientOnce sync.Once
s3Client *s3.Client
s3ClientErr error
ossClientOnce sync.Once
ossBucket *oss.Bucket
ossClientErr error
)
// GetS3Client 返回共享的 S3 客户端(单例),避免每次上传重复创建。
func GetS3Client() (*s3.Client, error) {
s3ClientOnce.Do(func() {
if conf.Upload.ImageEndpoint == "" {
s3ClientErr = errors.New("S3 endpoint not configured")
return
}
r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: conf.Upload.ImageEndpoint,
HostnameImmutable: true,
Source: aws.EndpointSourceCustom,
}, nil
})
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithEndpointResolverWithOptions(r2Resolver),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
"",
)),
config.WithRegion("auto"),
)
if err != nil {
s3ClientErr = errors.Wrap(err, "load S3 config")
return
}
s3Client = s3.NewFromConfig(cfg)
})
return s3Client, s3ClientErr
}
// GetOSSBucket 返回共享的 OSS Bucket(单例)。
func GetOSSBucket() (*oss.Bucket, error) {
ossClientOnce.Do(func() {
if conf.Upload.AliyunEndpoint == "" {
ossClientErr = errors.New("OSS endpoint not configured")
return
}
client, err := oss.New(conf.Upload.AliyunEndpoint, conf.Upload.AliyunAccessID, conf.Upload.AliyunAccessSecret)
if err != nil {
ossClientErr = errors.Wrap(err, "new OSS client")
return
}
bucket, err := client.Bucket(conf.Upload.AliyunBucket)
if err != nil {
ossClientErr = errors.Wrap(err, "get OSS bucket")
return
}
ossBucket = bucket
})
return ossBucket, ossClientErr
}
+66
View File
@@ -0,0 +1,66 @@
// 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 storage
import (
"fmt"
"io"
"mime/multipart"
"time"
"github.com/pkg/errors"
"github.com/thanhpk/randstr"
"github.com/wuhan005/gadget"
"github.com/tamakyi/TamaBox/internal/conf"
)
// UploadPictureToOSS upload user's avatar or background image to OSS.
// It returns the uploaded asset URL.
func UploadPictureToOSS(file multipart.File, _ *multipart.FileHeader) (string, error) {
// 读取文件头,校验是否为图片
header := make([]byte, 512)
n, err := file.Read(header)
if err != nil && err != io.EOF {
return "", errors.Wrap(err, "read file header")
}
if n == 0 {
return "", errors.New("empty file")
}
if !isSupportedImage(header[:n]) {
return "", errors.New("不支持的文件格式,仅允许 JPEG、PNG、GIF、WebP 图片")
}
// 重置文件指针
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", errors.Wrap(err, "seek file")
}
// 获取共享 OSS bucket
bucket, err := GetOSSBucket()
if err != nil {
return "", errors.Wrap(err, "get OSS bucket")
}
now := time.Now()
year := now.Year()
month := int(now.Month())
day := now.Day()
key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15))
if err := gadget.Retry(5, func() error {
if err := bucket.PutObject(key, file); err != nil {
return errors.Wrap(err, "put object")
}
return nil
}); err != nil {
return "", errors.Wrap(err, "retry 5 times")
}
if conf.Upload.AliyunBucketCDNHost != "" {
return fmt.Sprintf("https://%s/%s", conf.Upload.AliyunBucketCDNHost, key), nil
}
return fmt.Sprintf("https://%s.%s/%s", conf.Upload.AliyunBucket, conf.Upload.AliyunEndpoint, key), nil
}
+13
View File
@@ -0,0 +1,13 @@
// 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 storage
const PictureKeyPrefix = "picture/"
// MaxAvatarSize is the max avatar size which is 2MB.
const MaxAvatarSize = 2 * 1024 * 1024
// MaxBackgroundSize is the max background size which is 2MB.
const MaxBackgroundSize = 2 * 1024 * 1024
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2024 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 storage
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime/multipart"
"os"
"os/exec"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/chai2010/webp"
"github.com/disintegration/imaging"
"github.com/pkg/errors"
"github.com/thanhpk/randstr"
"github.com/wuhan005/gadget"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/imagecheck"
)
// MaxUploadBytes 是单次图片上传的内存读取上限(8MB)。
// 调用方虽在 HTTP 层对 fileHeader.Size 做了校验,但该值可被客户端伪造,
// 因此在读取前用 io.LimitReader 兜底,避免超大文件将内存撑爆(DoS)。
const MaxUploadBytes = 8 * 1024 * 1024
// aspect 约束一次上传的宽高比(wantW:wantH,容差 DefaultAspectTolerance)。
// 头像固定 1:1、页面背景固定 16:9:客户端裁剪后即为精确比例,
// 服务端复校验用于拦截绕过前端直接 POST 的越界图片。
type aspect struct {
W, H float64
}
func (a *aspect) check(data []byte) error {
if a == nil {
return nil
}
return imagecheck.CheckAspectRatio(bytes.NewReader(data), a.W, a.H, imagecheck.DefaultAspectTolerance)
}
// UploadAvatarToS3 上传头像:除通用校验/转换外强制 1:1 宽高比。
func UploadAvatarToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return uploadPictureToS3(file, fileHeader, &aspect{W: 1, H: 1})
}
// UploadBackgroundToS3 上传页面背景:除通用校验/转换外强制 16:9 宽高比。
func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return uploadPictureToS3(file, fileHeader, &aspect{W: 16, H: 9})
}
// UploadPictureToS3 上传通用图片到 Cloudflare R2,自动转换为 WebP(动画 GIF 可选转换)
func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return uploadPictureToS3(file, fileHeader, nil)
}
// uploadPictureToS3 是上传管线实现:读入 → 魔数 → (可选)比例校验 → 像素总量校验
// → 解码 → WebP 转换 → 落盘 R2。avatar/background 的固定比例校验复用同一管线,
// 仅通过 aspect 参数区分,避免为一种用途复制一套上传代码。
func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, asp *aspect) (string, error) {
// 1. 限制读取大小,防止超大上传撑爆内存(DoS)。
// 先用 LimitReader 截断到 MaxUploadBytes+1 字节;若实际读到的字节数超过上限则直接拒绝。
limited := io.LimitReader(file, MaxUploadBytes+1)
fileData, err := io.ReadAll(limited)
if err != nil {
return "", errors.Wrap(err, "read file data")
}
if int64(len(fileData)) > MaxUploadBytes {
return "", errors.New("uploaded file is too large")
}
// 2. 校验文件魔数,仅允许 JPEG / PNG / GIF / WebP
if !isSupportedImage(fileData) {
return "", errors.New("unsupported file type: not a valid image")
}
// 2.5 用途宽高比校验(头像 1:1 / 背景 16:9),绕过前端裁剪的直传在此被拦截
if err := asp.check(fileData); err != nil {
return "", err
}
fileReader := bytes.NewReader(fileData)
// 3. 解码前先校验像素总量(防解压炸弹),再解码图片获取格式;解码失败则直接返回错误。
if err := imagecheck.CheckPixelLimit(bytes.NewReader(fileData)); err != nil {
return "", err
}
_, format, err := image.Decode(fileReader)
if err != nil {
return "", errors.Wrap(err, "image decode failed, file may be corrupted")
}
var uploadData []byte
var contentType string
// 重置 reader,以便后续可能的 resize 操作
if _, err := fileReader.Seek(0, io.SeekStart); err != nil {
return "", errors.Wrap(err, "reset reader")
}
// 4. 根据格式进行转换或直接使用原图
switch format {
case "gif":
webpData, err := convertGifToAnimatedWebP(fileData)
if err != nil {
// 转换失败则回退到原始 GIF
uploadData = fileData
contentType = "image/gif"
} else {
uploadData = webpData
contentType = "image/webp"
}
default:
// 静态图片(JPEG / PNG / WebP 等)转换为静态 WebP
webpData, err := ConvertStaticImageToWebP(fileReader)
if err != nil {
// 转换失败时回退到原始图片
uploadData = fileData
contentType = mimeTypeFromFormat(format)
} else {
uploadData = webpData
contentType = "image/webp"
}
}
// 5. 生成存储路径(无后缀名)
now := time.Now()
year := now.Year()
month := int(now.Month())
day := now.Day()
key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15))
// 6. 获取共享 S3 客户端
client, err := GetS3Client()
if err != nil {
return "", errors.Wrap(err, "get S3 client")
}
// 7. 上传到 R2
if err := gadget.Retry(5, func() error {
_, err := client.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(key),
Body: bytes.NewReader(uploadData),
ContentLength: aws.Int64(int64(len(uploadData))),
ContentType: aws.String(contentType),
// key 为 picture/日期/随机hex,内容不可变 → 可安全启用 immutable 强缓存,
// 避免每次页面加载都回源校验(曾导致头像/二维码 logo 每次重新下载)。
CacheControl: aws.String("public, max-age=31536000, immutable"),
})
return err
}); err != nil {
return "", errors.Wrap(err, "put object after retry")
}
// 8. 返回 CDN URL
return fmt.Sprintf("https://%s/%s", conf.Upload.ImageBucketCDNHost, key), nil
}
// ConvertStaticImageToWebP 将静态图片(JPEG/PNG等)转换为 WebP 格式
// 限制最大宽度为 1920px,保持比例
func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) {
// 先整体读入并校验像素总量(防解压炸弹),再完整解码。
data, err := io.ReadAll(r)
if err != nil {
return nil, errors.Wrap(err, "read image")
}
if err := imagecheck.CheckPixelLimit(bytes.NewReader(data)); err != nil {
return nil, errors.Wrap(err, "check image pixel limit")
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, errors.Wrap(err, "decode image")
}
maxWidth := 1920
if img.Bounds().Dx() > maxWidth {
img = imaging.Resize(img, maxWidth, 0, imaging.Lanczos)
}
buf := new(bytes.Buffer)
if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil {
return nil, errors.Wrap(err, "webp encode")
}
return buf.Bytes(), nil
}
// convertGifToAnimatedWebP 将 GIF 文件数据转换为动画 WebP(需要系统安装 cwebp 工具)
// 如果 cwebp 不可用,返回错误,调用方应回退到原始 GIF
func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
_, err := exec.LookPath("cwebp")
if err != nil {
return nil, errors.New("cwebp not found in PATH")
}
tmpGif, err := os.CreateTemp("", "input_*.gif")
if err != nil {
return nil, errors.Wrap(err, "create temp gif")
}
defer os.Remove(tmpGif.Name())
if _, err := tmpGif.Write(gifData); err != nil {
return nil, errors.Wrap(err, "write temp gif")
}
tmpGif.Close()
tmpWebp, err := os.CreateTemp("", "output_*.webp")
if err != nil {
return nil, errors.Wrap(err, "create temp webp")
}
defer os.Remove(tmpWebp.Name())
tmpWebp.Close()
cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name())
if err := cmd.Run(); err != nil {
return nil, errors.Wrap(err, "run cwebp")
}
webpData, err := os.ReadFile(tmpWebp.Name())
if err != nil {
return nil, errors.Wrap(err, "read webp output")
}
return webpData, nil
}
// mimeTypeFromFormat 根据图片格式返回 MIME 类型
func mimeTypeFromFormat(format string) string {
switch format {
case "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "webp":
return "image/webp"
default:
return "application/octet-stream"
}
}
// isSupportedImage 通过文件头魔数判断是否为支持的图片格式
func isSupportedImage(data []byte) bool {
if len(data) < 4 {
return false
}
// JPEG: FF D8 FF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return true
}
// PNG: 89 50 4E 47
if bytes.Equal(data[:4], []byte{0x89, 0x50, 0x4E, 0x47}) {
return true
}
// GIF: GIF87a / GIF89a
if len(data) >= 6 {
if bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a")) {
return true
}
}
// WebP: RIFF ... WEBP
if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")) {
return true
}
return false
}
+50
View File
@@ -0,0 +1,50 @@
// 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 template
import (
"time"
)
// DateFormat pattern rules.
var datePatterns = []string{
// year
"Y", "2006", // A full numeric representation of a year, 4 digits Examples: 1999 or 2003
"y", "06", // A two digit representation of a year Examples: 99 or 03
// month
"m", "01", // Numeric representation of a month, with leading zeros 01 through 12
"n", "1", // Numeric representation of a month, without leading zeros 1 through 12
"M", "Jan", // A short textual representation of a month, three letters Jan through Dec
"F", "January", // A full textual representation of a month, such as January or March January through December
// day
"d", "02", // Day of the month, 2 digits with leading zeros 01 to 31
"j", "2", // Day of the month without leading zeros 1 to 31
// week
"D", "Mon", // A textual representation of a day, three letters Mon through Sun
"l", "Monday", // A full textual representation of the day of the week Sunday through Saturday
// time
"g", "3", // 12-hour format of an hour without leading zeros 1 through 12
"G", "15", // 24-hour format of an hour without leading zeros 0 through 23
"h", "03", // 12-hour format of an hour with leading zeros 01 through 12
"H", "15", // 24-hour format of an hour with leading zeros 00 through 23
"a", "pm", // Lowercase Ante meridiem and Post meridiem am or pm
"A", "PM", // Uppercase Ante meridiem and Post meridiem AM or PM
"i", "04", // Minutes with leading zeros 00 to 59
"s", "05", // Seconds, with leading zeros 00 through 59
// time zone
"T", "MST",
"P", "-07:00",
"O", "-0700",
// RFC 2822
"r", time.RFC1123Z,
}
+265
View File
@@ -0,0 +1,265 @@
// 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 template
import (
"bytes"
stdhtml "html"
"html/template"
"regexp"
"strings"
"github.com/russross/blackfriday/v2"
"golang.org/x/net/html"
)
// blockEndTags 与 reBlockEndBrList:删除块级元素闭合标签紧前的 <br>。
// HardLineBreak 会把块级元素末尾的换行也渲染成 <br>,产生多余空行;
// 这里仅清除末尾的 <br>(可含中间空白),行内真正的换行保留。
// 注意:RE2 不支持 lookahead,故对每个闭合标签单独编译。
var blockEndTags = []string{
"</li>", "</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>",
"</blockquote>", "</pre>", "</div>",
}
var reBlockEndBrList = func() []*regexp.Regexp {
res := make([]*regexp.Regexp, 0, len(blockEndTags))
for _, tag := range blockEndTags {
// 捕获闭合标签本身,替换时用 $1 保留
res = append(res, regexp.MustCompile(`<br>[ \t\r\n]*(`+regexp.QuoteMeta(tag)+`)`))
}
return res
}()
// reBlankLines 压缩连续换行为单个换行(MarkdownToPlainText 提取时块级/br 会产生多个 \n)。
var reBlankLines = regexp.MustCompile(`\n{2,}`)
// PlainText 渲染 Markdown 为纯文本并压缩连续空行为单个换行,
// 供列表/摘要等紧凑展示场景使用(保留文字与换行,不显示 Markdown 符号与排版)。
func PlainText(markdown string) string {
if strings.TrimSpace(markdown) == "" {
return ""
}
return reBlankLines.ReplaceAllString(MarkdownToPlainText(markdown), "\n")
}
// MarkdownToHTML 将 Markdown 渲染为安全的 HTML 片段(供模板直接输出)。
//
// 安全性:先用 blackfriday 渲染,再经白名单过滤器清洗:
// - 只保留常用排版标签与属性(a[href]/img[src,alt,title] 等)
// - 丢弃 script / style / iframe / object / embed / form 等危险标签
// - href/src 仅允许 http/https/mailto,其余一律去除
// - 剥离所有 on* 事件属性
//
// 这样既支持 Markdown 排版,又不会引入存储型 XSS。
func MarkdownToHTML(markdown string) template.HTML {
if strings.TrimSpace(markdown) == "" {
return ""
}
raw := blackfriday.Run([]byte(markdown),
// CommonExtensions 提供标准 Markdown;HardLineBreak 让「回车」直接换行(<br>),
// 无需行尾双空格(对非技术用户更友好)。
blackfriday.WithExtensions(blackfriday.CommonExtensions|blackfriday.HardLineBreak),
)
cleaned := sanitizeHTML(raw)
// 清除块级元素末尾的冗余 <br>(保留闭合标签,见 reBlockEndBrList 注释)
for _, re := range reBlockEndBrList {
cleaned = re.ReplaceAllString(cleaned, "$1")
}
return template.HTML(cleaned)
}
// MarkdownToPlainText 将 Markdown 渲染为纯文本(剥离所有标记符号与 HTML 标签),
// 供分享图绘制、摘要等纯文本场景使用。块级元素之间以换行分隔(调用方可按需折叠)。
func MarkdownToPlainText(markdown string) string {
if strings.TrimSpace(markdown) == "" {
return ""
}
raw := blackfriday.Run([]byte(markdown),
blackfriday.WithExtensions(blackfriday.CommonExtensions|blackfriday.HardLineBreak),
)
return extractPlainText(raw)
}
// blockElements 提取纯文本时需在其后补换行的块级元素。
var blockElements = map[string]bool{
"p": true, "div": true, "h1": true, "h2": true, "h3": true, "h4": true,
"h5": true, "h6": true, "ul": true, "ol": true, "li": true,
"blockquote": true, "pre": true, "table": true, "tr": true,
}
// extractPlainText 遍历 HTML token,只保留文本内容,块级元素与 <br> 后补换行。
func extractPlainText(raw []byte) string {
var out strings.Builder
tokenizer := html.NewTokenizer(bytes.NewReader(raw))
for {
tt := tokenizer.Next()
switch tt {
case html.ErrorToken:
return out.String()
case html.TextToken:
out.WriteString(string(tokenizer.Text()))
case html.StartTagToken, html.SelfClosingTagToken:
name := strings.ToLower(tokenizer.Token().Data)
if name == "br" {
out.WriteString("\n")
}
case html.EndTagToken:
name := strings.ToLower(tokenizer.Token().Data)
if blockElements[name] {
out.WriteString("\n")
}
}
}
}
// 允许的标签集合。
var allowedTags = map[string]bool{
"p": true, "br": true, "hr": true, "div": true, "span": true,
"strong": true, "em": true, "b": true, "i": true, "u": true, "s": true,
"code": true, "pre": true, "blockquote": true,
"ul": true, "ol": true, "li": true,
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
"a": true, "img": true, "table": true, "thead": true, "tbody": true,
"tr": true, "th": true, "td": true, "del": true, "ins": true,
// font 仅用于彩色文字(color 属性单独校验),无脚本能力,已废弃但浏览器仍支持
"font": true,
}
// sanitizeHTML 白名单过滤:遍历 token,重建安全 HTML。
func sanitizeHTML(raw []byte) string {
var out bytes.Buffer
tokenizer := html.NewTokenizer(bytes.NewReader(raw))
for {
tt := tokenizer.Next()
switch tt {
case html.ErrorToken:
return out.String()
case html.TextToken:
// Tokenizer.Text returns unescaped text. Escape it again before
// rebuilding HTML, otherwise input such as &lt;script&gt; can become
// executable markup in the template.HTML result.
out.WriteString(stdhtml.EscapeString(string(tokenizer.Text())))
case html.StartTagToken, html.SelfClosingTagToken:
// SelfClosingTagToken 覆盖 `<br />` 这类自闭合写法(blackfriday 的硬换行即此形式),
// 漏掉会导致 <br> 被丢弃、换行失效。
writeSanitizedTag(&out, tokenizer.Token(), false)
case html.EndTagToken:
writeSanitizedTag(&out, tokenizer.Token(), true)
}
}
}
func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
name := strings.ToLower(tok.Data)
if !allowedTags[name] {
return
}
if isEnd {
out.WriteString("</" + name + ">")
return
}
// 反向 tabnabbing 防护:a 标签若使用 target="_blank",强制附带 rel="noopener noreferrer"。
hasTargetBlank := false
if name == "a" {
for _, attr := range tok.Attr {
if strings.ToLower(attr.Key) == "target" && strings.TrimSpace(attr.Val) == "_blank" {
hasTargetBlank = true
break
}
}
}
out.WriteString("<" + name)
for _, attr := range tok.Attr {
key := strings.ToLower(attr.Key)
val := strings.TrimSpace(attr.Val)
// 过滤事件属性与危险属性
if strings.HasPrefix(key, "on") {
continue
}
// font 标签仅允许 color 属性,且值需通过颜色校验(防属性注入)
if name == "font" {
if key != "color" || !safeColorValue(val) {
continue
}
out.WriteString(` color="` + stdhtml.EscapeString(val) + `"`)
continue
}
switch key {
case "href", "src":
if !safeURL(val) {
continue
}
case "target":
// target 仅对 a 标签允许,且仅允许 _blank
if name != "a" || val != "_blank" {
continue
}
case "rel":
// 若存在 target="_blank",忽略用户提供的 rel,统一由下方输出 noopener noreferrer
if hasTargetBlank {
continue
}
case "class", "alt", "title", "width", "height", "colspan", "rowspan", "start":
// 保留有限的白名单属性(已移除 style,防止 CSS 注入 / 外联跟踪)
default:
continue
}
out.WriteString(" " + stdhtml.EscapeString(key) + `="` + stdhtml.EscapeString(val) + `"`)
}
if hasTargetBlank {
out.WriteString(` rel="noopener noreferrer"`)
}
out.WriteString(">")
}
// safeColorValue 校验颜色值:仅允许颜色名 / #hex / rgb() / rgba() / hsl() 等形式,
// 拒绝分号、引号、尖括号、斜杠、冒号等可能用于属性注入或 CSS 逃逸的字符。
func safeColorValue(v string) bool {
if v == "" || len(v) > 40 {
return false
}
for _, r := range v {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
continue
case strings.ContainsRune("#,() .%", r):
continue
default:
return false
}
}
return true
}
// safeURL 仅允许 http/https/mailto 协议(含站内相对路径),拒绝 javascript: 等危险协议。
func safeURL(u string) bool {
lower := strings.ToLower(strings.TrimSpace(u))
// 必须先于下面的 "/" 前缀判断:协议相对 URL(//evil.com)同样以 "/" 开头,
// 若不单独拦截会被误判为站内相对路径,从而绕过"仅 http/https/mailto"的白名单本意,
// 使 markdown 中的 [x](//evil.com) 渲染成指向外站的链接(钓鱼 / 外链埋点)。
// 反斜杠变体(\\evil.com、/\evil.com)在部分浏览器中等价于 //,一并拒绝。
if strings.HasPrefix(lower, "//") || strings.HasPrefix(lower, `\\`) ||
strings.HasPrefix(lower, `/\`) {
return false
}
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") ||
strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "/") ||
strings.HasPrefix(lower, "./") || strings.HasPrefix(lower, "../") ||
strings.HasPrefix(lower, "#") {
return true
}
return false
}
+171
View File
@@ -0,0 +1,171 @@
package template
import (
"strings"
"testing"
)
func TestMarkdownToHTML_Basic(t *testing.T) {
cases := []struct {
name string
in string
want string // 必须包含
}{
{"bold", "**bold**", "<strong>bold</strong>"},
{"italic", "*italic*", "<em>italic</em>"},
{"link", "[baidu](https://baidu.com)", `href="https://baidu.com"`},
{"list", "- item1\n- item2", "<li>item1</li>"},
{"code", "`code`", "<code>code</code>"},
{"newline", "line1\nline2", "<br>"},
{"font-red", `<font color="red">文本</font>`, `<font color="red">文本</font>`},
{"font-hex", `<font color="#ff0000">红</font>`, `<font color="#ff0000">红</font>`},
{"font-rgb", `<font color="rgb(255,0,0)">红</font>`, `<font color="rgb(255,0,0)">红</font>`},
{"font-mixed", `**粗体** 和 <font color="blue">蓝</font>`, `<font color="blue">蓝</font>`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(MarkdownToHTML(tc.in))
if !strings.Contains(got, tc.want) {
t.Errorf("MarkdownToHTML(%q) = %q, 期望包含 %q", tc.in, got, tc.want)
}
})
}
}
func TestMarkdownToHTML_XSS(t *testing.T) {
cases := []string{
`&lt;script&gt;alert(1)&lt;/script&gt;`,
`&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;`,
`<script>alert(1)</script>`,
`<img src=x onerror=alert(1)>`,
`[x](javascript:alert(1))`,
`<a href="javascript:alert(1)">x</a>`,
`<iframe src="https://evil.com"></iframe>`,
"<svg onload=alert(1)>",
`<div onclick="alert(1)">x</div>`,
`<style>body{display:none}</style>`,
}
for _, in := range cases {
got := string(MarkdownToHTML(in))
if strings.Contains(got, "<script") || strings.Contains(got, " onerror") ||
strings.Contains(got, " onclick") || strings.Contains(got, " onload") ||
strings.Contains(got, "<iframe") || strings.Contains(got, "javascript:") ||
strings.Contains(got, "<style") || strings.Contains(got, "<svg") {
t.Errorf("XSS 防护失败: 输入 %q 输出 %q", in, got)
}
}
}
// TestMarkdownToHTML_FontColorInjection font 的 color 属性只放行合法颜色值,
// 注入尝试必须被丢弃(标签保留但 color 属性被剥离,且不产生新属性)。
func TestMarkdownToHTML_FontColorInjection(t *testing.T) {
cases := []struct {
name string
in string
want string // 输出必须包含(剥离后仅剩文本)
}{
{"css-inject", `<font color="red;position:fixed">x</font>`, "x"},
{"quote-inject", `<font color="red" onclick="alert(1)">x</font>`, "x"},
{"url-inject", `<font color="url(javascript:alert(1))">x</font>`, "x"},
{"onattr-inject", `<font color="x" style="background:url(javascript:1)">x</font>`, "x"},
{"long-value", `<font color="` + strings.Repeat("a", 50) + `">x</font>`, "x"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(MarkdownToHTML(tc.in))
if !strings.Contains(got, tc.want) {
t.Errorf("输出 %q 应包含 %q", got, tc.want)
}
// 注入的 color 值不得出现
for _, evil := range []string{";", "position:", "onclick", "javascript:", "<font color=\"red\" onclick", "<font color=\"x\" style"} {
if strings.Contains(got, evil) {
t.Errorf("注入残留: %q 包含 %q", got, evil)
}
}
})
}
}
func TestMarkdownToHTML_Empty(t *testing.T) {
if got := string(MarkdownToHTML("")); got != "" {
t.Errorf("空输入应输出空串, got %q", got)
}
}
func TestMarkdownToPlainText(t *testing.T) {
cases := []struct {
name string
in string
want string // 必须包含(忽略换行折叠)
}{
{"plain", "你好世界", "你好世界"},
{"bold", "**粗体**", "粗体"},
{"font", `<font color="red">红字</font>`, "红字"},
{"link", "[百度](https://baidu.com)", "百度"},
{"code", "`code`", "code"},
{"list", "- 第一项\n- 第二项", "第一项"},
{"block", "# 标题\n内容", "标题"},
{"mixed", "问题:**重要** 详情 <font color=\"blue\">蓝</font>", "重要"},
{"empty", "", ""},
// 危险标签被剥离:script 的内容是纯文本(绘制无风险),img 自闭合无文本则输出空
{"xss-script", "<script>alert(1)</script>", "alert(1)"},
{"xss-img", `<img src=x onerror=alert(1)>`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := strings.Join(strings.Fields(MarkdownToPlainText(tc.in)), " ")
if tc.want == "" {
if got != "" {
t.Errorf("MarkdownToPlainText(%q) = %q, 期望空", tc.in, got)
}
return
}
if !strings.Contains(got, tc.want) {
t.Errorf("MarkdownToPlainText(%q) = %q, 期望包含 %q", tc.in, got, tc.want)
}
// 输出不得残留任何标签(< 必须被剥离)
if strings.Contains(got, "<") {
t.Errorf("MarkdownToPlainText(%q) = %q, 不应包含标签字符", tc.in, got)
}
})
}
}
// TestSafeURL 是 P2-1 的回归测试:markdown 链接白名单必须拒绝协议相对 URL
// (//evil.com 等),否则会被误判为站内相对路径而渲染成指向外站的钓鱼 / 外链埋点链接。
// 同时验证 javascript:/data:/ftp: 等危险协议与大小写变体均被拒绝,http/https/mailto
// 与站内相对路径被放行。
func TestSafeURL(t *testing.T) {
allow := []string{
"https://example.com",
"http://example.com",
"HTTPS://EXAMPLE.COM", // 大小写不敏感
"mailto:[email protected]",
"/path/to/page",
"./relative",
"../up",
"#fragment",
}
for _, u := range allow {
if !safeURL(u) {
t.Errorf("safeURL(%q) = false, 期望放行", u)
}
}
deny := []string{
"//evil.com", // 协议相对 URL(核心回归点)
`\\evil.com`, // 反斜杠变体
`/\evil.com`, // 反斜杠变体
"javascript:alert(1)", // 危险协议
"JavaScript:alert(1)", // 大小写变体
"data:text/html,<script>",
"ftp://example.com",
"", // 空串
}
for _, u := range deny {
if safeURL(u) {
t.Errorf("safeURL(%q) = true, 期望拒绝", u)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
// 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 template
import (
"bytes"
"embed"
"io/fs"
"path/filepath"
"regexp"
"github.com/flamego/template"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/html"
"github.com/tdewolff/minify/v2/js"
)
var _ template.FileSystem = (*FileSystem)(nil)
type file struct {
name string
data []byte
ext string
}
func (f *file) Name() string { return f.name }
func (f *file) Data() ([]byte, error) { return f.data, nil }
func (f *file) Ext() string { return f.ext }
type FileSystem struct {
files []template.File
}
func (fs *FileSystem) Files() []template.File { return fs.files }
// getExt returns the extension of given name, prefixed with the dot (".").
// 必须用 filepath.Ext:取最后一个点之后的部分。
// 旧的 strings.Index 实现会把 "foo.bar.html" 的扩展名误判为 ".bar.html",
// 导致模板名截取错误(如 "foo" 丢失子目录结构)。
func getExt(name string) string {
return filepath.Ext(name)
}
func Minify(embedFS embed.FS, dir string, allowedExtensions []string) (*FileSystem, error) {
var beforeSize, afterSize int64
var files []template.File
m := minify.New()
m.AddFunc("text/html", html.Minify)
m.AddFunc("text/css", css.Minify)
m.AddFuncRegexp(regexp.MustCompile(`^(application|text)/(x-)?(java|ecma)script$`), js.Minify)
err := fs.WalkDir(embedFS, dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
relpath, err := filepath.Rel(dir, path)
if err != nil {
return errors.Wrap(err, "get relative path")
}
ext := getExt(relpath)
for _, allowed := range allowedExtensions {
if ext != allowed {
continue
}
data, err := embedFS.ReadFile(path)
if err != nil {
return errors.Wrap(err, "read")
}
beforeSize += int64(len(data))
var buf bytes.Buffer
switch filepath.Ext(path) {
case ".html":
if err := html.Minify(m, &buf, bytes.NewReader(data), nil); err != nil {
return errors.Wrap(err, "minify html")
}
default:
buf = *bytes.NewBuffer(data)
}
afterSize += int64(buf.Len())
name := filepath.ToSlash(relpath[:len(relpath)-len(ext)])
files = append(files,
&file{
name: name,
data: buf.Bytes(),
ext: ext,
},
)
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "walk")
}
logrus.WithFields(logrus.Fields{
"before_size": beforeSize,
"after_size": afterSize,
"ratio": float64(afterSize) / float64(beforeSize),
}).Info("Minify succeeded")
return &FileSystem{
files: files,
}, nil
}
+220
View File
@@ -0,0 +1,220 @@
// 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 template
import (
"encoding/json"
"fmt"
"html"
"html/template"
"strconv"
"strings"
"sync"
"time"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/i18n"
)
var (
funcMap []template.FuncMap
funcMapOnce sync.Once
)
func FuncMap() []template.FuncMap {
funcMapOnce.Do(func() {
funcMap = []template.FuncMap{map[string]interface{}{
"ICP": func() string {
return conf.App.ICP
},
"CommitSHA": func() string {
return conf.BuildCommit
},
"CommitSHAShort": func() string {
if len(conf.BuildCommit) > 7 {
return conf.BuildCommit[:7]
}
return conf.BuildCommit
},
"Date": func(t time.Time, format string) string {
replacer := strings.NewReplacer(datePatterns...)
format = replacer.Replace(format)
return t.Format(format)
},
"QuestionFormat": func(input string) template.HTML {
input = html.EscapeString(input)
input = strings.ReplaceAll(input, "\n", "</br>")
return template.HTML(input)
},
"AnswerFormat": func(input string) template.HTML {
input = html.EscapeString(input)
input = strings.ReplaceAll(input, "\n", "</br>")
return template.HTML(input)
},
// MarkdownFormat 将回答按 Markdown 渲染为安全 HTML(含白名单过滤,防 XSS)。
"MarkdownFormat": MarkdownToHTML,
// MarkdownPlainText 将 Markdown 渲染为纯文本(去标记符号、保留文字与换行、压缩空行),
// 供列表页等不希望展示大字号/彩色排版的场景使用。
"MarkdownPlainText": PlainText,
"SentryDSN": func() string {
return conf.App.SentryDSN
},
"ParsePublicURLs": func(input datatypes.JSON) string {
urls := make(map[string]string)
if err := json.Unmarshal(input, &urls); err != nil {
return ""
}
for _, url := range urls {
return url
}
return ""
},
"ImageBucketCDNHost": func() string {
return conf.Upload.ImageBucketCDNHost
},
"sub": func(a, b int) int {
return a - b
},
// dict 将键值对打包为 map,供 {{template "partial" (dict "Key" val ...)}} 向部分模板传参。
// 参数必须成对且键为字符串,否则返回错误(html/template 会将该错误作为执行错误抛出)。
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict: odd number of arguments: %d", len(values))
}
d := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key at index %d is not a string", i)
}
d[key] = values[i+1]
}
return d, nil
},
"mod": func(a, b int) int {
if b == 0 {
return 0
}
return a % b
},
// loop 生成 1..n 的整数切片,用于模板中按数量 range(如评分 1-10)。
"loop": func(n int) []int {
if n <= 0 {
return []int{}
}
out := make([]int, n)
for i := 1; i <= n; i++ {
out[i-1] = i
}
return out
},
// hasDay 判断逗号分隔的周几列表(0=周日…6=周六,与 time.Weekday 一致)是否包含 wd。
// 用于功能管理页渲染循环定时的开放日复选框。
"hasDay": func(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
},
// parseOptions 解析投票表单字段的 JSON 选项数组(select/radio/checkbox)。
"parseOptions": func(raw string) []string {
if raw == "" {
return []string{}
}
var out []string
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return []string{}
}
return out
},
// percentOf 计算百分比(模板内无法做除法)。
"percentOf": func(part, total int64) int {
if total <= 0 {
return 0
}
return int(part * 100 / total)
},
// add 加法(模板序号 1-based 等场景)。
"add": func(a, b int) int {
return a + b
},
// fieldTypeLabel 返回表单字段类型的本地化名称(参与端题型标签)。
"fieldTypeLabel": func(lang, fieldType string) string {
key := map[string]string{
"text": "vote.fieldTypeText",
"textarea": "vote.fieldTypeTextarea",
"select": "vote.fieldTypeSelect",
"radio": "vote.fieldTypeRadio",
"checkbox": "vote.fieldTypeCheckbox",
"number": "vote.fieldTypeNumber",
"rating": "vote.fieldTypeRating",
"rank": "vote.fieldTypeRank",
}[fieldType]
if key == "" {
return ""
}
return i18n.T(lang, key)
},
// fieldTypeLabelClass 返回表单字段类型的标签配色(题型视觉区分)。
"fieldTypeLabelClass": func(fieldType string) string {
switch fieldType {
case "text", "textarea":
return "" // 默认灰
case "radio", "select":
return "uk-label-primary"
case "checkbox":
return "uk-label-success"
case "rating":
return "uk-label-warning"
case "rank":
return "uk-label-danger"
case "number":
return "uk-label-secondary"
default:
return ""
}
},
// toJSON 将值序列化为 JSON 并标记为安全的 JS 字面量。
// 必须用于 <script> 上下文中的 JSON 注入:template.HTML 会在 script 上下文中
// 被 html/template 按 JS 规则转义(引号被转义导致 JS 语法错误/值变成字符串),
// template.JS 则原样输出。
"toJSON": func(v interface{}) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("[]")
}
return template.JS(b)
},
"Safe": Safe,
// 兼容小写别名:模板中历史遗留 {{safe ...}} 调用
"safe": Safe,
// i18n 模板函数:
// 模板用法:{{t .LangCode "key"}} 或 {{t .LangCode "key" .Arg}}
// 语言名:{{langName "zh-CN"}} → 简体中文
"t": func(lang, key string, args ...interface{}) string {
return i18n.T(lang, key, args...)
},
"langName": func(lang string) string {
return i18n.Name(lang)
},
}}
})
return funcMap
}
func Safe(raw string) template.HTML {
return template.HTML(raw)
}

Some files were not shown because too many files have changed in this diff Show More