Compare commits

...
4 Commits
Author SHA1 Message Date
tamakyi c6966138b4 docs(memory): 记录迁移系统/备份脚本/通知的实现与 R2 存储结构 2026-10-29 19:27:29 +08:00
tamakyi b9060c982b feat(notify): 新增站级 Webhook 与用户级 Telegram 通知通道
在现有邮件通知之外补两条广播/点对点通道:

- 站级 Webhook([notify] webhook_url):新提问 / 新回答 / 新追问 / 新私聊 /
  新投票回复统一 POST JSON 到通用 Webhook(Discord / Slack / 企业微信机器人等)。
  消息体同时带 content(Discord 直接可用)与结构化 event/title/body/link 字段。
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id):箱主
  自填 Chat ID,新提问 / 新投票回复点对点推送。

设计约束:通知是「尽力而为」旁路 —— 任一通道发送失败只记日志、不向上抛错,
绝不影响提问/回答的主流程;通道未配置时静默跳过。发送放在 shared.SafeGo 异步
执行,与现有邮件通知同一条路径。

6 处业务触发点接入 notify;私聊消息保留仅邮件(已有 5 分钟节流,避免广播刷屏)。
profile 页新增 Telegram Chat ID 输入框 + 四语言字典。app.sample.ini 加 [notify] 段。

测试覆盖 webhook 的 JSON 结构与未配置时的静默跳过。
2026-10-29 19:27:23 +08:00
tamakyi 1e9ffc0718 feat(ops): 新增 TamaBox backup 命令,同时备份数据库与 R2 图片
此前没有任何备份手段,而站点的图片全在 Cloudflare R2、数据库只存 key/URL ——
只备份 DB 的话,恢复后 key 指向的图片若已不在 R2(bucket 误删 / 账号丢失 /
图片被清理),页面就全是裂图。

新增 TamaBox backup --output <dir>:
- 数据库:pg_dump 全量导出(schema + data,标准恢复格式),密码经 PGPASSWORD
  环境变量传递避免出现在 ps 进程参数里
- 图片:从 R2 下载 tamabox 站全部图片,保持原 key 路径,并写 images-manifest.json
  (key → size → md5)用于恢复后完整性校验
- --db-only / --images-only 分开关

R2 访问方式的坑:storage.GetS3Client 的 endpoint 带 /<bucket> 后缀,对
GetObject/PutObject 恰好可用,但 ListObjectsV2 会把 URL 拼成 /<bucket>/<bucket>
而 404 NoSuchKey。备份脚本独立构造客户端(剥离 endpoint 的 bucket 后缀 +
UsePathStyle),List prefix="tamabox/" 后用完整 key 下载 —— 这是 R2 上 List
唯一稳定可用的姿势。

实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 下载的文件
大小与 R2 List 返回完全一致,路径保持 tamabox/ 结构。
2026-10-29 19:27:13 +08:00
tamakyi 62edf88029 feat(db): 版本化数据库迁移,改列名/删列不再静默丢数据
GORM 的 AutoMigrate 只会加表/加列/加索引,从不改列名/删列/改约束。此前这类
破坏性变更靠「HasColumn 判断 + 幂等补丁」硬撑,既没有账本记录「跑过什么」,
也把 5 个一次性回填(backfillAnsweredAt / backfillFeatureControls /
reconcileLegacyChatEnabled / normalizeChatQuota / normalizeHarassmentSetting)
留成了每次启动都全表扫一遍。

新增 schema_migrations 账本表 + runMigrations:
- 迁移按版本号升序执行,每个迁移在事务里「跑 up + 记版本」,任一失败阻断启动
- 那 5 个幂等补丁收编为 v1~v5,收编后只跑一次,之后启动直接跳过
- AutoMigrate 仍负责声明式的加表/加列/加索引,版本迁移负责它做不了的破坏性变更
  与一次性回填,两者分工互不替代

测试覆盖:幂等(跑两遍不重复)、失败阻断后续、清单严格升序。实测真实库首次
启动建账本 + v1~v5 落库,二次重启账本不变。
2026-10-29 19:27:03 +08:00
25 changed files with 1117 additions and 256 deletions
+54
View File
@@ -243,3 +243,57 @@ gzip 实现质量高、后台移动端短标签方案做得细。
真实 HTTP 验证没跑通(靠代码 diff 确认修复正确)。
- **`git show HEAD:file` 输出写到 /tmp 后 Windows Python 读不到**:/tmp 是 Git Bash 的虚拟路径,
Windows Python 看不到。要落盘验证行尾,写到仓库内(如 outputs/)再读。
## 三项待办落地:AutoMigrate 版本化 + 备份脚本 + Telegram/Webhook 通知
用户「先按1-2-3的顺序做」,期间两次纠正 R2 存储结构认知。
### 1. AutoMigrate 版本化迁移(internal/db/migrations.go)
- 新增 `schema_migrations` 表(version 主键 + name + applied_at),`runMigrations(db)` 按版本升序
执行未应用的迁移,每个迁移在事务里「跑 up + 记版本」。
- 把 db.Init 里散落的 5 个幂等补丁收编为版本迁移 v1~v5:
backfillAnsweredAt / backfillFeatureControls / reconcileLegacyChatEnabled /
normalizeChatQuota / normalizeHarassmentSetting。收编后**只跑一次**(原来每次启动都跑)。
- AutoMigrate 仍负责「加表/加列/加索引」(声明式、幂等、无数据丢失),版本迁移负责
AutoMigrate 做不了的破坏性变更(改列名/删列)与一次性回填。两者分工,互不替代。
- 迁移失败阻断启动(半途而废比静默跳过更危险)。
- 测试:migrations_test.go(幂等跑两遍不重复 / 失败阻断后续 / 清单严格升序)。
- 实测:真实库首次启动建表 + v1~v5 落账本,二次重启账本仍 5 行(幂等)。
### 2. 备份脚本(internal/cmd/backup.go,TamaBox backup)
- `--output <dir>` 输出;`--db-only` / `--images-only` 分开关。
- DB:调 pg_dump(PGPASSWORD 环境变量传密码避免 ps 泄漏),输出 db-<ts>.sql(标准恢复格式)。
- 图片:从 R2 下载 tamabox 站全部图片 + 写 images-manifest.json(key→size→md5)。
- 两个 CLI 入口(根 tamabox.go + cmd/tamabox.go)都注册了 Backup。
- 实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 30 秒下 15 个文件、
路径保持 tamabox/ 结构、大小与 List 一致。
### 3. Telegram/Webhook 通知(internal/notify/notify.go)
- 站级 Webhook([notify] webhook_url):新提问/新回答/新追问/新私聊/新投票回复统一 POST JSON。
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id 字段):新提问/新投票点对点推送。
- 通知是「尽力而为」旁路:任一通道失败只记日志不抛错,不影响业务主流程;未配置则静默跳过。
- 6 处业务触发点接入 notify:page.go(新提问+新私聊)、question.go(新回答)、
follow_up.go(新追问+追问回答)、vote.go(新投票)。私聊消息保留仅邮件(已有节流,避免刷屏)。
- UI:profile 加 telegram_chat_id 输入框 + 四语言字典(admin.notifyTelegram* 3 条)。
- 测试:notify_test.go(webhook JSON 结构 / 未配置跳过)。
### 踩坑(本轮新)
- **R2 存储结构(用户两次纠正,重要!)**:图片实际存在 Cloudflare R2,CDN URL 形如
`https://r2.337845818.best/tamabox/2025/12/1767082504316655685.jpg`(bucket=tamabox,
key=年月/时间戳.扩展名,**无 picture/ 前缀**)。但 R2 API 侧(ListObjectsV2)返回的 key
**带 bucket 名作前缀**(`tamabox/2025/12/...jpg`)——这是 path-style bucket 的 R2 行为。
- 关键:`storage.GetS3Client` 的 endpoint 配置(image_endpoint 带 `/tamabox` 后缀 +
Bucket 参数 tamabox)对 GetObject/PutObject 恰好可用(aws-sdk 拼出 /tamabox/tamabox/<key>,
R2 容错),但 **ListObjectsV2 会拼成 /tamabox/tamabox?list-type=2 → 404 NoSuchKey**。
- 备份脚本的正确姿势:剥离 endpoint 的 `/tamabox` 后缀 + UsePathStyle=true + Bucket=tamabox,
List prefix="tamabox/",GetObject 直接用 List 返回的完整 key(含 tamabox/ 前缀)。
- 同一 R2 account(0c99...)下还有 blog/ 前缀的另一个站点内容(共享 account)。
- **代码的 PictureKeyPrefix="picture/" 与线上不一致**:mainline 代码上传 key 用 `picture/` 前缀,
但线上历史图片 key 是 `tamabox/` 前缀(即实际 key 无前缀、靠 bucket 隔离)。这是待澄清的
「代码 vs 线上」不一致,本轮未动,需用户确认是否要统一。
- **Windows Edit 又把 3 个 LF 文件转成 CRLF**(conf.go / users.go / form/user.go):
老坑复发,用 Python `b.replace(b'\r\n', b'\n')` 统一转回 LF 解决。
+7 -1
View File
@@ -41,7 +41,13 @@
## 审核 / 处置 / 举报(topics/review.md)
- 线上通道 = 七牛;裁决在 `censor/policy.go` + `moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
- **图片存储 = Cloudflare R2**(S3 兼容协议,非七牛):`conf.Upload.ImageEndpoint`/`ImageBucket`/
`ImageBucketCDNHost`,客户端见 `internal/storage/client.go`(R2 的 endpoint resolver 写法,
`HostnameImmutable` + `region=auto`);key = `picture/YYYY/MM/DD/<15hex>`。代码里还留了一套
阿里 OSS 客户端(`GetOSSBucket`),是历史遗留,实际在用的是 S3/R2 这条。
- **文本审核通道 = 七牛/阿里云**(`[security] text_censor_source = qiniu|aliyun|auto`,七牛优先)——
这是内容审核 API,与图片存储是两码事,别搞混。线上通道 = 七牛;裁决在 `censor/policy.go` +
`moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
- 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。
- 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。
- 写路径查功能开关用 `db.FeatureStateOfStrict`(失败即拒);`FeatureStateOf` 的
+1
View File
@@ -29,6 +29,7 @@ func main() {
cmd.Unrestrict,
cmd.HidePublic,
cmd.Honeypot,
cmd.Backup,
}
if err := app.Run(os.Args); err != nil {
logrus.WithError(err).Fatal("Failed to start application")
+13
View File
@@ -274,6 +274,19 @@ password = ""
port = 465
smtp = ""
[notify]
; ── 站级广播式通知通道(可选,均留空则只走 [mail] 点对点邮件)──────────────
; webhook_url:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
; (新提问 / 新回答 / 新追问 / 新私聊 / 新投票回复)统一 POST JSON 推送。
; 留空 = 不推送站级 Webhook。
; 注意:不同平台的 webhook 格式不同,这里发送统一 JSON(含 content/event/title/
; body/link/time 字段),Discord 可直接用 content 字段展示;其他平台建议在
; webhook 前端加一层转发做格式适配。
; webhook_url = ""
; telegram_bot_token:Telegram Bot token,配合每个箱主自填的 Chat ID 实现
; 「新提问」点对点通知。留空 = 禁用 Telegram 通知。
; telegram_bot_token = ""
[limits]
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
+316
View File
@@ -0,0 +1,316 @@
// 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 (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"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/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
)
// Backup 全量备份数据库与对象存储(Cloudflare R2)图片到本地目录。
//
// 为什么必须同时备份图片:站点的所有图片(头像 / 背景 / 二维码 logo / 提问图 / 回答图)
// 都以 picture/YYYY/MM/DD/<hex>(新)或 tamabox/YYYY/MM/<hex>.<ext>(历史)的形式存在
// R2 上,数据库里只存 key(upload_images.key)或 URL(users.avatar/background 里的完整
// CDN 地址)。只备份数据库的话,恢复后这些引用指向的图片若已不在 R2(bucket 误删 /
// 账号丢失 / 图片被清理),页面就全是裂图。因此备份必须把 R2 上的图片一并落盘。
//
// 输出目录结构:
//
// <output>/
// db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复)
// images/<key> # R2 对象,保持原 key 路径
// images-manifest.json # key → md5 / size 清单,用于恢复后完整性校验
//
// 用法:TamaBox backup --output /var/backups/tamabox
//
// TamaBox backup --output . --db-only # 只备份数据库
// TamaBox backup --output . --images-only # 只备份图片
var Backup = &cli.Command{
Name: "backup",
Usage: "Back up the database and R2 images to a local directory",
Action: runBackup,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Value: ".",
Usage: "Output directory for the backup files",
},
&cli.BoolFlag{
Name: "db-only",
Usage: "Only back up the database (skip R2 images)",
},
&cli.BoolFlag{
Name: "images-only",
Usage: "Only back up R2 images (skip the database)",
},
},
}
func runBackup(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
outDir := ctx.String("output")
if outDir == "" {
return errors.New("--output must not be empty")
}
if err := os.MkdirAll(outDir, 0o700); err != nil {
return errors.Wrapf(err, "create output directory %q", outDir)
}
dbOnly := ctx.Bool("db-only")
imagesOnly := ctx.Bool("images-only")
if !imagesOnly {
if err := backupDatabase(ctx.Context, outDir); err != nil {
return err
}
}
if !dbOnly {
if err := backupImages(ctx.Context, outDir); err != nil {
return err
}
}
logrus.WithField("dir", outDir).Info("备份完成")
return nil
}
// backupDatabase 用 pg_dump 导出 PostgreSQL 全量数据。
//
// 为什么用外部 pg_dump 而不是在进程内逐表 SELECT:pg_dump 输出的 .sql 是标准恢复格式,
// 直接用 psql 恢复,且能正确处理序列、外键、软删除行、大对象等进程内手写导出极易漏掉的
// 细节。项目实际使用 PostgreSQL(见 code-review-2026-08-28.md P1-4)。
func backupDatabase(ctx context.Context, outDir string) error {
if conf.Database.Type != "" && conf.Database.Type != "postgres" {
return errors.Errorf("备份当前仅支持 PostgreSQL,配置为 %q", conf.Database.Type)
}
pgDump, err := exec.LookPath("pg_dump")
if err != nil {
return errors.Wrap(err, "未找到 pg_dump(PostgreSQL 客户端工具),请安装 postgresql-client")
}
ts := time.Now().Format("20060102-150405")
outFile := filepath.Join(outDir, fmt.Sprintf("db-%s.sql", ts))
// 密码经环境变量传递,避免出现在进程参数里(ps 可见)。
cmd := exec.CommandContext(ctx, pgDump,
"--host", conf.Database.Host,
"--port", fmt.Sprintf("%d", conf.Database.Port),
"--username", conf.Database.User,
"--dbname", conf.Database.Name,
"--no-owner", "--no-privileges",
"--format", "plain",
)
cmd.Env = append(os.Environ(), "PGPASSWORD="+conf.Database.Password)
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return errors.Wrapf(err, "create backup file %q", outFile)
}
defer f.Close()
var dumpErr strings.Builder
cmd.Stdout = f
cmd.Stderr = &dumpErr
if err := cmd.Run(); err != nil {
return errors.Wrapf(err, "pg_dump failed: %s", strings.TrimSpace(dumpErr.String()))
}
logrus.WithField("file", outFile).Info("数据库已备份")
return nil
}
// imageManifestEntry 图片备份清单里的一条:key → 大小 → md5(用于恢复后校验完整性)。
type imageManifestEntry struct {
Key string `json:"key"`
Size int64 `json:"size"`
MD5 string `json:"md5,omitempty"`
}
// backupImages 从 R2 下载 tamabox 站的全部图片对象到本地,并生成 manifest。
//
// R2 里 tamabox 站图片的 key 都带 `tamabox/` 前缀(如 tamabox/2025/12/<ts>.jpg),
// 这是「bucket 用 path-style 访问时 List 返回的 key 带 bucket 名作前缀」这一 R2 行为。
// 因此只需列出 `tamabox/` 前缀即可覆盖全部图片。blog/ 是同一 R2 account 下另一个站点
// 的内容,不在 tamabox 的备份范围。
func backupImages(ctx context.Context, outDir string) error {
client, err := backupS3Client()
if err != nil {
return errors.Wrap(err, "get R2 client for backup")
}
imageDir := filepath.Join(outDir, "images")
if err := os.MkdirAll(imageDir, 0o700); err != nil {
return errors.Wrap(err, "create images directory")
}
var manifest []imageManifestEntry
var count int64
var totalBytes int64
// 见函数注释:tamabox/ 前缀即覆盖全部图片(含新旧 key 格式)。
if err := backupPrefix(ctx, client, imageDir, "tamabox/", &manifest, &count, &totalBytes); err != nil {
return err
}
// 写 manifest:记录 key → 大小 → md5,恢复后可用于校验完整性。
mf, err := os.Create(filepath.Join(outDir, "images-manifest.json"))
if err != nil {
return errors.Wrap(err, "create manifest file")
}
defer mf.Close()
enc := json.NewEncoder(mf)
enc.SetIndent("", " ")
if err := enc.Encode(manifest); err != nil {
return errors.Wrap(err, "write manifest")
}
logrus.WithFields(logrus.Fields{
"objects": count,
"bytes": totalBytes,
"dir": imageDir,
}).Info("图片已备份")
return nil
}
// backupPrefix 分页列出并下载指定前缀下的全部对象。
func backupPrefix(ctx context.Context, client *s3.Client, imageDir, prefix string, manifest *[]imageManifestEntry, count, totalBytes *int64) error {
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: aws.String(conf.Upload.ImageBucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return errors.Wrapf(err, "list R2 objects prefix %q", prefix)
}
for _, obj := range page.Contents {
if obj.Key == nil {
continue
}
key := *obj.Key
// 落盘路径 = imageDir + "/" + key,保持对象 key 结构(含 tamabox/ 前缀),
// 恢复时可直接用同一 key 回传 R2。
dst := filepath.Join(imageDir, filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return errors.Wrapf(err, "create dir for %q", key)
}
if err := downloadObject(ctx, client, conf.Upload.ImageBucket, key, dst); err != nil {
return err
}
size := int64(0)
if obj.Size != nil {
size = *obj.Size
}
md5 := ""
if obj.ETag != nil {
md5 = strings.Trim(*obj.ETag, `"`)
}
*manifest = append(*manifest, imageManifestEntry{Key: key, Size: size, MD5: md5})
*count++
*totalBytes += size
}
}
return nil
}
// backupS3Client 构造用于备份的 R2 客户端。
//
// 不能直接复用 storage.GetS3Client 做 List:那个客户端把 bucket 路径拼进了 endpoint
// (image_endpoint 形如 https://<account>.r2.cloudflarestorage.com/<bucket>),
// 它对 GetObject/PutObject 恰好可用(R2 容错),但 ListObjectsV2 会把 URL 拼成
// /<bucket>/<bucket>?list-type=2,R2 找不到这个 key 前缀而返回 404 NoSuchKey。
//
// 这里剥离 endpoint 的 bucket 后缀,得到纯 account endpoint,并启用 path-style:
// List/Get 都用 Bucket 参数 + 完整 key(List 返回的 key 自带 bucket 名作前缀,
// 如 tamabox/2025/12/<ts>.jpg),这是 R2 上唯一稳定可用的姿势。
func backupS3Client() (*s3.Client, error) {
endpoint := strings.TrimRight(conf.Upload.ImageEndpoint, "/")
// 剥离最后一个路径段(bucket 名)。endpoint 形如
// https://<account>.r2.cloudflarestorage.com/<bucket>,去掉 /<bucket>。
if i := strings.LastIndex(endpoint, "/"); i > len("https://") {
endpoint = endpoint[:i]
}
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: endpoint,
HostnameImmutable: true,
Source: aws.EndpointSourceCustom,
}, nil
})
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithEndpointResolverWithOptions(resolver),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
"",
)),
config.WithRegion("auto"),
)
if err != nil {
return nil, errors.Wrap(err, "load R2 config")
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
}), nil
}
// downloadObject 下载单个对象到本地文件,失败时删除半成品避免留下损坏文件。
func downloadObject(ctx context.Context, client *s3.Client, bucket, key, dst string) error {
out, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return errors.Wrapf(err, "get R2 object %q", key)
}
defer out.Body.Close()
tmp := dst + ".part"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return errors.Wrapf(err, "create %q", tmp)
}
if _, err := io.Copy(f, out.Body); err != nil {
f.Close()
os.Remove(tmp)
return errors.Wrapf(err, "download %q", key)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return errors.Wrapf(err, "close %q", tmp)
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return errors.Wrapf(err, "finalize %q", dst)
}
return nil
}
+4
View File
@@ -111,6 +111,10 @@ func Init() error {
return errors.Wrap(err, "map 'mail'")
}
if err := File.Section("notify").MapTo(&Notify); err != nil {
return errors.Wrap(err, "map 'notify'")
}
serviceSections := File.Section("service").ChildSections()
for _, serviceSection := range serviceSections {
serviceSection := serviceSection
+12
View File
@@ -215,6 +215,18 @@ var (
SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要
}
// Notify 站级通知通道配置([notify] 段)。与 [mail] 的「邮件给具体收件人」不同,
// 这两个是「广播式」通道:
// - WebhookURL:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
// (新提问 / 新回答 / 新追问 / 新私聊 / 新投票 / 安全告警 / 举报)统一推送。
// - TelegramBotToken:Telegram Bot token,配合每个箱主自填的 chat_id 实现
// 「新提问」点对点通知。
// 两者都可留空:留空的通道对应发送静默跳过(不影响邮件等其他通道)。
Notify struct {
WebhookURL string `ini:"webhook_url"`
TelegramBotToken string `ini:"telegram_bot_token"`
}
Service struct {
Backends []struct {
Prefix string `ini:"prefix"`
+6 -37
View File
@@ -90,47 +90,16 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
}
// questions.answered_at 是 2026-10 新增的列(首次回答时间,用于算「回答耗时」)。
// 加列之前已回答的提问没有这个值,只能事后拿 updated_at 近似。
// 必须在 AutoMigrate 之前判断列是否存在 —— 迁移之后大家都有列了,就分不出谁是历史行。
hasAnsweredAt := db.Migrator().HasColumn(&Question{}, "answered_at")
// AutoMigrate 只负责声明式的「加表 / 加列 / 加索引」——这些变更幂等、无数据丢失风险。
if err := db.AutoMigrate(AllTables...); err != nil {
return nil, errors.Wrap(err, "auto migrate")
}
// 回填只在「这次刚加上列」时做一次。
// 为什么必须回填:这些行的 updated_at 之后还会被已读/置顶继续往前推,
// 不回填的话历史「回答耗时」会一天天变大,看起来像统计出错。
// 失败不阻断启动,页面会退回 updated_at 计算。
if !hasAnsweredAt {
if err := backfillAnsweredAt(db); err != nil {
logrus.WithError(err).Warn("回填 questions.answered_at 时异常(历史回答耗时将退回 updated_at)")
}
}
// 功能开关初始化:为历史账号补齐 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("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
}
// 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价
// 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。
if err := normalizeHarassmentSetting(db); err != nil {
logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)")
// 版本化迁移:AutoMigrate 做不了的破坏性变更(改列名/删列/改约束)与一次性数据回填
// 都收编进这里,靠 schema_migrations 账本保证「只跑一次、顺序固定、可审计」。
// 迁移失败会阻断启动 —— 半途而废的迁移比静默跳过更危险(见 runMigrations 注释)。
if err := runMigrations(db); err != nil {
return nil, errors.Wrap(err, "run versioned migrations")
}
// 启动期补充高频查询复合索引(幂等,适合既有库)。
+142
View File
@@ -0,0 +1,142 @@
package db
import (
"sort"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// SchemaMigration 记录一次已应用的数据库迁移。一行 = 一个已跑过的版本。
// 这是迁移机制的账本:启动时按版本号升序执行「账本里还没有」的迁移,已应用的跳过。
//
// 为什么需要它:GORM 的 AutoMigrate 只会「加表 / 加列 / 加索引」,从不「改列名 /
// 删列 / 改约束」。任何破坏性 schema 变更若靠 AutoMigrate 的 HasColumn 判断或幂等补丁
// 硬撑,都会在「改列名 → 新旧两列并存、新列恒空」这种形态下静默丢数据,而且没有任何
// 地方记录「这个库已经跑过哪些变更」。版本表把「跑过什么、顺序如何」固化下来,
// 让迁移可解释、可回看、可审计。
type SchemaMigration struct {
// Version 单调递增的版本号,迁移按它升序执行。
Version int `gorm:"primaryKey"`
// Name 迁移的简短描述,用于日志与人工排查。
Name string `gorm:"type:varchar(128)"`
// AppliedAt 应用时间,仅供审计。
AppliedAt time.Time
}
// Migration 一个数据库迁移步骤。
type Migration struct {
Version int
Name string
// Up 执行迁移。应当尽量幂等,因为早期版本在收编进版本表之前可能已在旧流程里
// 跑过一次;靠版本账本保证「收编后只跑一次」。
Up func(db *gorm.DB) error
}
// migrations 按版本号升序排列的迁移清单。新增迁移时追加到末尾并分配一个更大的版本号。
//
// 收编说明:下面这些迁移对应的函数原本散落在 db.Init 里,靠 HasColumn 判断或幂等硬撑,
// 每次启动都会重新执行一遍。收编进版本表后,它们只在「该版本号尚未记录」时执行一次,
// 之后启动直接跳过 —— 既省掉无谓的全表扫描,也消除了「幂等补丁某天不小心变得不幂等」
// 时对历史数据的重复改写风险。
var migrations = []Migration{
{
Version: 1,
Name: "backfill questions.answered_at",
Up: backfillAnsweredAt,
},
{
Version: 2,
Name: "backfill feature_controls",
Up: backfillFeatureControls,
},
{
Version: 3,
Name: "reconcile legacy chat enabled",
Up: reconcileLegacyChatEnabled,
},
{
Version: 4,
Name: "normalize chat quota",
Up: normalizeChatQuota,
},
{
Version: 5,
Name: "normalize harassment setting",
Up: normalizeHarassmentSetting,
},
}
// runMigrations 按版本号升序执行尚未应用的迁移。
//
// 执行模型:先建账本表(若无),读取已应用版本集合;对每个未应用的迁移,
// 在事务里跑 Up 并写入账本行。任一步失败即返回错误并终止后续迁移 —— 迁移是有序依赖的,
// 半途而废比硬着头皮跳过更安全(调用方会阻断启动,让运维看到明确的失败版本)。
//
// 与 AutoMigrate 的分工:AutoMigrate 仍负责「新增表 / 加列 / 加索引」这类声明式、
// 幂等、无数据丢失风险的变更(它本来就擅长且安全);版本迁移负责 AutoMigrate
// 做不了的破坏性变更与一次性数据回填。两者在 db.Init 里顺序执行,互不替代。
func runMigrations(db *gorm.DB) error {
if err := db.AutoMigrate(&SchemaMigration{}); err != nil {
return errors.Wrap(err, "create schema_migrations table")
}
var applied []SchemaMigration
if err := db.Order("version ASC").Find(&applied).Error; err != nil {
return errors.Wrap(err, "load applied migrations")
}
appliedSet := make(map[int]bool, len(applied))
for _, m := range applied {
appliedSet[m.Version] = true
}
// 防御:清单必须按版本号严格升序且无重复,否则顺序执行会出错、账本会混乱。
ordered := make([]Migration, len(migrations))
copy(ordered, migrations)
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Version < ordered[j].Version })
for i := 1; i < len(ordered); i++ {
if ordered[i].Version == ordered[i-1].Version {
return errors.Errorf("duplicate migration version %d", ordered[i].Version)
}
}
for _, m := range ordered {
if appliedSet[m.Version] {
continue
}
logrus.WithField("version", m.Version).WithField("name", m.Name).
Info("应用数据库迁移")
// 迁移必须在一个事务里完成:Up 成功 + 记版本要么同时生效,要么都不生效。
// 否则进程在「Up 成功、账本未记」之间崩溃,重启后会重复执行一次迁移。
err := db.Transaction(func(tx *gorm.DB) error {
if err := m.Up(tx); err != nil {
return errors.Wrapf(err, "run migration %d (%s)", m.Version, m.Name)
}
rec := SchemaMigration{Version: m.Version, Name: m.Name, AppliedAt: time.Now()}
if err := tx.Create(&rec).Error; err != nil {
return errors.Wrapf(err, "record migration %d", m.Version)
}
return nil
})
if err != nil {
return err
}
}
return nil
}
// AppliedMigrations 返回已应用的迁移版本号集合,供测试与运维查询。
func AppliedMigrations(db *gorm.DB) (map[int]bool, error) {
var rows []SchemaMigration
if err := db.Order("version ASC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "load applied migrations")
}
set := make(map[int]bool, len(rows))
for _, r := range rows {
set[r.Version] = true
}
return set, nil
}
+71
View File
@@ -0,0 +1,71 @@
package db
import (
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TestRunMigrationsIdempotent 迁移账本幂等:同一组迁移跑两遍,第二遍不重复执行。
func TestRunMigrationsIdempotent(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("schema_migrations", "users", "feature_controls")
// 第一遍:全部迁移都未应用,应执行并落账本。
require.NoError(t, runMigrations(conn))
applied, err := AppliedMigrations(conn)
require.NoError(t, err)
require.Len(t, applied, len(migrations), "第一遍后所有版本都应已记录")
// 第二遍:账本齐全,runMigrations 应立即返回且不报错。
// 若已应用版本被重复执行,迁移事务会因主键冲突报错 —— 无错跑完即证明跳过了。
require.NoError(t, runMigrations(conn))
applied2, err := AppliedMigrations(conn)
require.NoError(t, err)
require.Equal(t, applied, applied2, "重复执行不应改变账本")
}
// TestRunMigrationsStopsOnFailure 迁移失败必须阻断:失败版本不落账本、后续版本不执行。
func TestRunMigrationsStopsOnFailure(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("schema_migrations", "users", "feature_controls")
// 先建账本表(runMigrations 内部会建,但这里手动跑一次无迁移场景需要显式建表)。
require.NoError(t, conn.AutoMigrate(&SchemaMigration{}))
// 用一套独立的迁移清单模拟「第二个迁移失败」:
// v100 成功,v101 失败。预期:v100 落账本,v101 不落,且整体返回错误。
saved := migrations
defer func() { migrations = saved }()
migrations = []Migration{
{Version: 100, Name: "ok", Up: func(*gorm.DB) error { return nil }},
{Version: 101, Name: "boom", Up: func(*gorm.DB) error { return errors.New("intentional") }},
{Version: 102, Name: "never-runs", Up: func(*gorm.DB) error {
t.Error("v102 不应执行,因为 v101 已失败")
return nil
}},
}
err := runMigrations(conn)
require.Error(t, err, "迁移失败必须返回错误")
applied, aErr := AppliedMigrations(conn)
require.NoError(t, aErr)
require.True(t, applied[100], "成功的 v100 应已落账本")
require.False(t, applied[101], "失败的 v101 不应落账本")
require.False(t, applied[102], "被阻断的 v102 不应落账本")
}
// TestMigrationsOrdered 迁移清单必须按版本号严格升序且无重复。
func TestMigrationsOrdered(t *testing.T) {
for i := 1; i < len(migrations); i++ {
require.Less(t, migrations[i-1].Version, migrations[i].Version,
"迁移清单必须按版本号严格升序,第 %d 个版本 %d 不小于第 %d 个版本 %d",
i, migrations[i].Version, i-1, migrations[i-1].Version)
}
}
+7 -2
View File
@@ -109,8 +109,11 @@ type User struct {
Backgroundimagealpha string `json:"Backgroundimagealpha"`
// Theme 箱主选择的主题 slug(空串=跟随站点默认,default=基础外观)。
// 取值合法性由 route/user 在写入前校验,读取侧由 internal/theme 回退。
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
Notify NotifyType `json:"notify"`
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
Notify NotifyType `json:"notify"`
// TelegramChatID 箱主自填的 Telegram chat_id,配合站级 [notify] telegram_bot_token
// 实现「新提问」点对点通知。空串 = 未开启 Telegram 通知。
TelegramChatID string `json:"-" gorm:"column:telegram_chat_id;type:varchar(64);default:''"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
BlockWords string `json:"-"`
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
@@ -343,6 +346,7 @@ type UpdateUserOptions struct {
Bloglink string
UseCustomQRColor bool
Notify NotifyType
TelegramChatID string
Language string
}
@@ -378,6 +382,7 @@ func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) er
"Bloglink": opts.Bloglink,
"UseCustomQRColor": opts.UseCustomQRColor,
"Notify": opts.Notify,
"TelegramChatID": opts.TelegramChatID,
"Language": opts.Language,
}
+1
View File
@@ -17,6 +17,7 @@ type UpdateProfile struct {
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:"开启邮箱通知"`
TelegramChatID string `form:"telegram_chat_id" valid:"maxlen:64" label:"Telegram Chat ID" msg:"form.profile.telegramChatId"`
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"`
+57 -54
View File
@@ -200,34 +200,37 @@ var enDict = map[string]string{
"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.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.notifyTelegram": "Telegram",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID (empty = disabled)",
"admin.notifyTelegramHint": "Send any message to @userinfobot to get your Chat ID. Requires a site-configured Telegram Bot.",
"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",
// Moderation strictness: shared by questions / polls / chat. Decision rules live in internal/security/censor/policy.go.
"admin.moderationStrictness": "Moderation strictness",
@@ -1055,36 +1058,36 @@ var enDict = map[string]string{
"stats.weekday.7": "Sunday",
// Owner insights panel (/user/insights)
"insights.title": "Insights",
"insights.heading": "Your insights",
"insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.",
"insights.back": "Back to settings",
"insights.entryTitle": "Insights",
"insights.entryDesc": "See how many people visit your box and how your questions are doing",
"insights.summary": "At a glance",
"insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.",
"insights.today": "Today",
"insights.week": "Last 7 days",
"insights.month": "Last 30 days",
"insights.newQuestions": "new questions",
"insights.pv": "views",
"insights.uv": "visitors",
"insights.trendTitle": "Views over the last 30 days",
"insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.",
"insights.legendPV": "Page views",
"insights.legendUV": "Unique visitors (per day)",
"insights.legendHover": "Hover for daily details",
"insights.hourTitle": "When people show up",
"insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.",
"insights.funnelTitle": "Question handling",
"insights.funnelNote": "All questions split into answered / read but unanswered / untouched.",
"insights.title": "Insights",
"insights.heading": "Your insights",
"insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.",
"insights.back": "Back to settings",
"insights.entryTitle": "Insights",
"insights.entryDesc": "See how many people visit your box and how your questions are doing",
"insights.summary": "At a glance",
"insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.",
"insights.today": "Today",
"insights.week": "Last 7 days",
"insights.month": "Last 30 days",
"insights.newQuestions": "new questions",
"insights.pv": "views",
"insights.uv": "visitors",
"insights.trendTitle": "Views over the last 30 days",
"insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.",
"insights.legendPV": "Page views",
"insights.legendUV": "Unique visitors (per day)",
"insights.legendHover": "Hover for daily details",
"insights.hourTitle": "When people show up",
"insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.",
"insights.funnelTitle": "Question handling",
"insights.funnelNote": "All questions split into answered / read but unanswered / untouched.",
"insights.funnelTotal": "All questions",
"insights.funnelAnswered": "Answered",
"insights.funnelReadOnly": "Read, not answered",
"insights.funnelUnread": "Not read",
"insights.answerRate": "Answer rate: answered ÷ all questions",
"insights.privacyTitle": "About these numbers",
"insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.",
"insights.answerRate": "Answer rate: answered ÷ all questions",
"insights.privacyTitle": "About these numbers",
"insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.",
"insights.insight.empty": "No data yet. This page comes alive once you get your first question or someone opens your box.",
"insights.insight.weekViews": "%s views from %s visitors in the last 7 days.",
+57 -54
View File
@@ -200,34 +200,37 @@ var jaDict = map[string]string{
"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.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません",
"admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません",
"admin.notifyEmail": "メール",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(空欄なら送信しない)",
"admin.notifyTelegramHint": "@userinfobot に何か送ると Chat ID を取得できます。サイト側で Telegram Bot の設定が必要です。",
"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": "ブロックワードを保存",
// 審核の厳格度:質問 / 投票 / チャットで共通。判定規則は internal/security/censor/policy.go。
"admin.moderationStrictness": "審核の厳格度",
@@ -1054,36 +1057,36 @@ var jaDict = map[string]string{
"stats.weekday.7": "日曜日",
// 質問箱オーナーのインサイト(/user/insights)
"insights.title": "インサイト",
"insights.heading": "あなたのインサイト",
"insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。",
"insights.back": "設定に戻る",
"insights.entryTitle": "インサイト",
"insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る",
"insights.summary": "ひと目で",
"insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。",
"insights.today": "今日",
"insights.week": "過去 7 日間",
"insights.month": "過去 30 日間",
"insights.newQuestions": "新着の質問",
"insights.pv": "回表示",
"insights.uv": "人",
"insights.trendTitle": "過去 30 日間の閲覧推移",
"insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。",
"insights.legendPV": "閲覧数(PV)",
"insights.legendUV": "ユニーク訪問者(日ごとに重複排除)",
"insights.legendHover": "カーソルで当日の内訳",
"insights.hourTitle": "いつ来ているか",
"insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。",
"insights.funnelTitle": "質問の対応状況",
"insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。",
"insights.title": "インサイト",
"insights.heading": "あなたのインサイト",
"insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。",
"insights.back": "設定に戻る",
"insights.entryTitle": "インサイト",
"insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る",
"insights.summary": "ひと目で",
"insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。",
"insights.today": "今日",
"insights.week": "過去 7 日間",
"insights.month": "過去 30 日間",
"insights.newQuestions": "新着の質問",
"insights.pv": "回表示",
"insights.uv": "人",
"insights.trendTitle": "過去 30 日間の閲覧推移",
"insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。",
"insights.legendPV": "閲覧数(PV)",
"insights.legendUV": "ユニーク訪問者(日ごとに重複排除)",
"insights.legendHover": "カーソルで当日の内訳",
"insights.hourTitle": "いつ来ているか",
"insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。",
"insights.funnelTitle": "質問の対応状況",
"insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。",
"insights.funnelTotal": "すべての質問",
"insights.funnelAnswered": "回答済み",
"insights.funnelReadOnly": "既読・未回答",
"insights.funnelUnread": "未読",
"insights.answerRate": "回答率:回答済み ÷ 全質問",
"insights.privacyTitle": "この数字について",
"insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。",
"insights.answerRate": "回答率:回答済み ÷ 全質問",
"insights.privacyTitle": "この数字について",
"insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。",
"insights.insight.empty": "まだデータがありません。最初の質問が届くか、誰かが質問箱を開くと表示されはじめます。",
"insights.insight.weekViews": "過去 7 日間に %s 回の閲覧、%s 人の訪問者。",
+57 -54
View File
@@ -200,34 +200,37 @@ var zhCNDict = map[string]string{
"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.oldPasswordHint": "修改密码使用,留空则不修改",
"admin.newPasswordHint": "6-64 位,留空则不修改",
"admin.notifyEmail": "邮件",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空则不发送)",
"admin.notifyTelegramHint": "向 @userinfobot 发送任意消息即可获得你的 Chat ID。需站方配置了 Telegram Bot。",
"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": "保存屏蔽词",
// 审核严格度:提问 / 投票 / 私聊共用一份,判定规则见 internal/security/censor/policy.go
"admin.moderationStrictness": "审核严格度",
@@ -1055,36 +1058,36 @@ var zhCNDict = map[string]string{
"stats.weekday.7": "周日",
// 箱主数据面板(/user/insights)
"insights.title": "数据面板",
"insights.heading": "你的数据面板",
"insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。",
"insights.back": "返回设置",
"insights.entryTitle": "数据面板",
"insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样",
"insights.summary": "一眼看懂",
"insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提问",
"insights.pv": "次浏览",
"insights.uv": "位访客",
"insights.trendTitle": "近 30 天浏览趋势",
"insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。",
"insights.legendPV": "浏览量(PV)",
"insights.legendUV": "独立访客(按日去重)",
"insights.legendHover": "悬停查看当天明细",
"insights.hourTitle": "大家什么时候来",
"insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。",
"insights.funnelTitle": "提问处理情况",
"insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。",
"insights.title": "数据面板",
"insights.heading": "你的数据面板",
"insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。",
"insights.back": "返回设置",
"insights.entryTitle": "数据面板",
"insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样",
"insights.summary": "一眼看懂",
"insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提问",
"insights.pv": "次浏览",
"insights.uv": "位访客",
"insights.trendTitle": "近 30 天浏览趋势",
"insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。",
"insights.legendPV": "浏览量(PV)",
"insights.legendUV": "独立访客(按日去重)",
"insights.legendHover": "悬停查看当天明细",
"insights.hourTitle": "大家什么时候来",
"insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。",
"insights.funnelTitle": "提问处理情况",
"insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。",
"insights.funnelTotal": "全部提问",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已读未答",
"insights.funnelUnread": "未读未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提问",
"insights.privacyTitle": "关于这些数字",
"insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。",
"insights.answerRate": "回答率:已回答 ÷ 全部提问",
"insights.privacyTitle": "关于这些数字",
"insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。",
"insights.insight.empty": "还没有任何数据。等你收到第一条提问,或者有人打开过你的提问箱,这里就会有变化。",
"insights.insight.weekViews": "近 7 天有 %s 次浏览,%s 位访客。",
+57 -54
View File
@@ -200,34 +200,37 @@ var zhTWDict = map[string]string{
"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.oldPasswordHint": "修改密碼使用,留空則不修改",
"admin.newPasswordHint": "6-64 位,留空則不修改",
"admin.notifyEmail": "郵件",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空則不發送)",
"admin.notifyTelegramHint": "向 @userinfobot 發送任意訊息即可取得你的 Chat ID。需站方配置了 Telegram Bot。",
"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": "儲存屏蔽詞",
// 審核嚴格度:提問 / 投票 / 私聊共用一份,判定規則見 internal/security/censor/policy.go
"admin.moderationStrictness": "審核嚴格度",
@@ -1054,36 +1057,36 @@ var zhTWDict = map[string]string{
"stats.weekday.7": "週日",
// 箱主數據面板(/user/insights)
"insights.title": "數據面板",
"insights.heading": "你的數據面板",
"insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。",
"insights.back": "返回設定",
"insights.entryTitle": "數據面板",
"insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣",
"insights.summary": "一眼看懂",
"insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提問",
"insights.pv": "次瀏覽",
"insights.uv": "位訪客",
"insights.trendTitle": "近 30 天瀏覽趨勢",
"insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。",
"insights.legendPV": "瀏覽量(PV)",
"insights.legendUV": "獨立訪客(按日去重)",
"insights.legendHover": "停留查看當天明細",
"insights.hourTitle": "大家什麼時候來",
"insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。",
"insights.funnelTitle": "提問處理情況",
"insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。",
"insights.title": "數據面板",
"insights.heading": "你的數據面板",
"insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。",
"insights.back": "返回設定",
"insights.entryTitle": "數據面板",
"insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣",
"insights.summary": "一眼看懂",
"insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提問",
"insights.pv": "次瀏覽",
"insights.uv": "位訪客",
"insights.trendTitle": "近 30 天瀏覽趨勢",
"insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。",
"insights.legendPV": "瀏覽量(PV)",
"insights.legendUV": "獨立訪客(按日去重)",
"insights.legendHover": "停留查看當天明細",
"insights.hourTitle": "大家什麼時候來",
"insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。",
"insights.funnelTitle": "提問處理情況",
"insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。",
"insights.funnelTotal": "全部提問",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已讀未答",
"insights.funnelUnread": "未讀未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提問",
"insights.privacyTitle": "關於這些數字",
"insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。",
"insights.answerRate": "回答率:已回答 ÷ 全部提問",
"insights.privacyTitle": "關於這些數字",
"insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。",
"insights.insight.empty": "還沒有任何資料。等你收到第一條提問,或有人開啟過你的提問箱,這裡就會有變化。",
"insights.insight.weekViews": "近 7 天有 %s 次瀏覽,%s 位訪客。",
+147
View File
@@ -0,0 +1,147 @@
// 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 notify 封装站级通知通道:通用 Webhook 与 Telegram Bot。
//
// 与 internal/mail(点对点邮件)的分工:mail 负责「给某个具体邮箱发」,notify 负责
// 「广播式」通知 —— Webhook 推给一个 URL(Discord/Slack/企业微信机器人等),
// Telegram 推给某个 chat_id(配合箱主自填的 chat_id 实现点对点)。
//
// 设计约束:任一通道发送失败都只记日志、不向上抛错,也绝不影响业务主流程 ——
// 通知是「尽力而为」的旁路,一条通知发不出去不该让提问/回答写不进去。
// 通道未配置(URL/token 为空)时对应发送静默跳过。
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
)
const httpTimeout = 5 * time.Second
// event 一条站级通知事件的载荷。Webhook 与 Telegram 都用它(字段名通用)。
type event struct {
Event string `json:"event"` // 事件名,如 new_question / new_answer / ...
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link,omitempty"`
Time string `json:"time"`
}
// Send 推送一条站级事件到所有已配置的广播通道(Webhook + Telegram)。
// 各通道独立发送、互不影响;失败仅记日志。
func Send(ctx context.Context, eventName, title, body, link string) {
ev := event{
Event: eventName,
Title: title,
Body: body,
Link: link,
Time: time.Now().Format("2006-01-02 15:04:05"),
}
if conf.Notify.WebhookURL != "" {
sendWebhook(ctx, conf.Notify.WebhookURL, ev)
}
// Telegram 站级广播需要一个全局 chat_id;当前设计里 Telegram 是「用户级点对点」
// (见 SendToTelegram),站级不额外广播,避免重复打扰。这里留空即不推。
}
// SendToTelegram 推送一条通知到指定 chat_id(箱主自填,配合站级 bot token)。
// chatID 为空或 bot token 未配置时静默跳过。
func SendToTelegram(ctx context.Context, chatID, text string) {
if chatID == "" || conf.Notify.TelegramBotToken == "" {
return
}
if err := sendTelegram(ctx, conf.Notify.TelegramBotToken, chatID, text); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("Telegram 通知发送失败")
}
}
// sendWebhook POST 一条 JSON 到通用 Webhook URL。
//
// 适配 Discord / Slack 的格式差异:这里发送的是统一的 JSON 对象。Discord 的 webhook
// 期望 {"content": "..."},Slack 期望 {"text": "..."},企业微信期望 {"msgtype":"text",
// "text":{"content":"..."}} —— 无法用一种格式同时适配所有平台。因此这里发送「通用 JSON」,
// 运营方在 webhook 前端(如自己的一层转发)做格式适配;对只接受特定格式的机器人,
// 请用能转发的中间服务(如 Discord 支持自定义 message,Slack incoming webhook 也接受 text 字段)。
//
// 实际上为最大化开箱可用,这里优先兼容 Discord 的 content 字段 + 通用 event 字段并存:
// 既保留结构化信息,又让 Discord 机器人能直接读出人话。
func sendWebhook(ctx context.Context, url string, ev event) {
payload := map[string]interface{}{
"content": fmt.Sprintf("【%s】%s\n%s", ev.Title, ev.Body, ev.Link),
"event": ev.Event,
"title": ev.Title,
"body": ev.Body,
"link": ev.Link,
"time": ev.Time,
}
data, err := json.Marshal(payload)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("序列化 webhook 通知失败")
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("构造 webhook 请求失败")
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("webhook 通知发送失败")
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
logrus.WithContext(ctx).WithField("status", resp.StatusCode).
WithField("resp", string(body)).Warn("webhook 通知返回非 2xx")
}
}
// sendTelegram 通过 Telegram Bot API 发送一条消息到指定 chat_id。
func sendTelegram(ctx context.Context, botToken, chatID, text string) error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
payload, err := json.Marshal(map[string]string{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
})
if err != nil {
return errors.Wrap(err, "marshal telegram payload")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return errors.Wrap(err, "build telegram request")
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
return errors.Wrap(err, "telegram request")
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return errors.Errorf("telegram api status %d: %s", resp.StatusCode, string(body))
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package notify
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
// TestSendWebhookPostsJSON 验证 webhook 发送的是带 content/event/title 的 JSON,
// 且能正确 POST 到目标 URL。
func TestSendWebhookPostsJSON(t *testing.T) {
var got map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
sendWebhook(t.Context(), srv.URL, event{
Event: "new_question",
Title: "新的提问",
Body: "你好",
Link: "https://example.com/_/box/1",
Time: "2026-10-29 19:00:00",
})
require.Equal(t, "new_question", got["event"])
require.Equal(t, "新的提问", got["title"])
require.Equal(t, "你好", got["body"])
require.NotEmpty(t, got["content"], "Discord 兼容的 content 字段必须有")
}
// TestSendWebhookSkipsWhenUnconfigured 未配置 webhook_url 时 Send 静默跳过(不 panic)。
func TestSendWebhookSkipsWhenUnconfigured(t *testing.T) {
conf.Notify.WebhookURL = ""
defer func() { conf.Notify.WebhookURL = "" }()
// 不应 panic,也不应发起任何请求。
Send(t.Context(), "new_question", "t", "b", "l")
}
// TestSendToTelegramSkipsWhenUnconfigured chatID 或 bot token 为空时静默跳过。
func TestSendToTelegramSkipsWhenUnconfigured(t *testing.T) {
conf.Notify.TelegramBotToken = ""
defer func() { conf.Notify.TelegramBotToken = "" }()
SendToTelegram(t.Context(), "", "hello") // 空 chatID
SendToTelegram(t.Context(), "123", "hello") // 空 token
}
+9
View File
@@ -15,6 +15,7 @@ import (
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/notify"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
)
@@ -146,6 +147,9 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user")
}
}
notify.Send(stdctx.Background(), "new_follow_up",
"新的追问", f.Content,
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.sent"))
@@ -222,6 +226,11 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
}
}
if wasFirst {
notify.Send(stdctx.Background(), "follow_up_answer",
"追问的回答", f.Answer,
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
}
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.answered"))
+14
View File
@@ -37,6 +37,7 @@ import (
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/notify"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
@@ -575,6 +576,9 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to asker")
}
}
notify.Send(stdctx.Background(), "new_conversation",
"新的私聊", f.Content,
fmt.Sprintf("%s/_/%s/chat/%s", conf.App.ExternalURL, pageUser.Domain, conv.Token))
})
chatURL := fmt.Sprintf("/_/%s/chat/%s", pageUser.Domain, conv.Token)
@@ -733,6 +737,16 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to questioner")
}
}
// 站级 Webhook:新提问是运营最关心的事件之一,推给配置的广播通道。
notify.Send(stdctx.Background(), "new_question",
"新的提问", question.Content,
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
// 用户级 Telegram:箱主自填 chat_id 时点对点推送。
if pageUser.TelegramChatID != "" {
notify.SendToTelegram(stdctx.Background(), pageUser.TelegramChatID,
fmt.Sprintf("📬 新的提问\n\n%s\n\n%s/_/%s/%d",
question.Content, conf.App.ExternalURL, pageUser.Domain, question.ID))
}
})
questionPrivateURL := fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)
+7
View File
@@ -21,6 +21,7 @@ import (
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/notify"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
)
@@ -265,6 +266,12 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send receive reply mail to questioner")
}
}
// 站级 Webhook:新回答(回答发布时推送)。
if !forcedPrivate {
notify.Send(stdctx.Background(), "new_answer",
"新的回答", f.Answer,
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
}
})
// L2 下必须明确告知「已转为私密」:单题页对箱主本人仍是 200、内容照常显示,
+1
View File
@@ -254,6 +254,7 @@ func UpdateProfile(ctx context.Context, f form.UpdateProfile) {
Bloglink: f.Bloglink,
UseCustomQRColor: useCustomQRColor,
Notify: notify,
TelegramChatID: f.TelegramChatID,
Language: i18n.Resolve(f.Language),
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user profile")
+15
View File
@@ -30,6 +30,7 @@ import (
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/notify"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
@@ -761,6 +762,20 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
}
})
}
if vote.Type == db.VoteTypeForm {
title := vote.Title
voteID := vote.ID
shared.SafeGo(func() {
notify.Send(stdctx.Background(), "new_vote_submission",
"新的投票回复", title,
fmt.Sprintf("%s/user/votes/%d/results", conf.App.ExternalURL, voteID))
if owner.TelegramChatID != "" {
notify.SendToTelegram(stdctx.Background(), owner.TelegramChatID,
fmt.Sprintf("🗳️ 投票「%s」收到新回复\n%s/user/votes/%d/results",
title, conf.App.ExternalURL, voteID))
}
})
}
ctx.SetSuccessFlash(i18n.T(lang, "vote.flash.success"))
ctx.Redirect("/vote/" + vote.LinkToken)
+1
View File
@@ -29,6 +29,7 @@ func main() {
cmd.Unrestrict,
cmd.HidePublic,
cmd.Honeypot,
cmd.Backup,
}
if err := app.Run(os.Args); err != nil {
logrus.WithError(err).Fatal("Failed to start application")
+7
View File
@@ -595,6 +595,13 @@
<span class="uk-text-small"> {{t .LangCode "admin.notifyEmail"}}</span>
</label>
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.notifyTelegram"}}</label>
<input name="telegram_chat_id" class="uk-input uk-form-width-medium" type="text"
placeholder="{{t .LangCode "admin.notifyTelegramPlaceholder"}}"
value="{{.LoggedUser.TelegramChatID}}">
<div class="uk-text-small uk-text-muted">{{t .LangCode "admin.notifyTelegramHint"}}</div>
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.saveBasic"}}</button>
</div>