Compare commits
4
Commits
693b1102c2
...
c6966138b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6966138b4 | ||
|
|
b9060c982b | ||
|
|
1e9ffc0718 | ||
|
|
62edf88029 |
@@ -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 解决。
|
||||
|
||||
@@ -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` 的
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)计数。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
// 启动期补充高频查询复合索引(幂等,适合既有库)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 位訪客。",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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、内容照常显示,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user