Files
tamakyi 693b1102c2 fix: 封堵 ask-status domain 枚举、私聊写路径 fail-open、压缩兜底重复提交
三处小修 + 两个格式清理,均来自全盘复查:

1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素
   数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。
   改为箱主不存在时也回显 ok:false,四种失败响应完全同形。

2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值,
   与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭
   的巧合侥幸安全,现统一改 Strict。

3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前
   到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。

顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐
(gofmt 干净)。
2026-10-29 18:52:38 +08:00

235 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// BoxViewsStore 记录「提问箱主页被访问了多少次」。
//
// 为什么要自带一张表:全站数据页(internal/stats)只统计提问/回答/用户,
// 完全没有「有人来看过我的箱」这个维度 —— 箱主看不到自己的任何流量。
// 曾经用过外部计数服务(busuanzi),但它把统计放在第三方、且只能显示两个数,
// 既不可自控也无法按天看趋势,已移除。
//
// 口径与隐私:
// - 一行 = 一个访客在某一天访问了某个箱(唯一索引强制),`views` 是当天的次数;
// - PV = SUM(views),UV = 行数(因为每行已按访客去重);
// - 访客标识是 **带日盐的 IP HMAC**,不是 IP 本身,也无法跨天关联同一人。
// 代价是「近 7 天 UV」= 各日 UV 之和,同一人多天来访会被重复计入 —— 这是
// 隐私与准确性的取舍,页面上按「按日去重的独立访客」表述。
type BoxViewsStore interface {
// Record 累加一次访问。实现为单条 UPSERT,并发安全。
Record(ctx context.Context, opts RecordBoxViewOptions) error
// RecordBatch 批量累加(记录器的 flush 用)。单条 SQL,避免 N 次往返。
RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error
// Daily 返回 [from, to] 内每天的 PV / UV,日期按天升序,缺日不补(由上层补零)。
Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error)
// TopDays 取历史上 PV 最高的若干天,供「你的最好一天」这类结论使用。
TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error)
// FirstDay 返回最早有记录的一天;无记录时返回零值时间。
FirstDay(ctx context.Context, userID uint) (time.Time, error)
// Totals 返回全期累计访问量(PV / UV)。
Totals(ctx context.Context, userID uint) (pv, uv int64, err error)
// Purge 删除 before 之前的明细,控制表体积。统计窗口最长 30 天,
// 但「历史最好一天」要看全部,默认保留期由调用方决定(见 insights.Retention)。
Purge(ctx context.Context, before time.Time) (int64, error)
}
// BoxView 一次「访客 × 天 × 箱」的访问记录。
//
// 刻意不内嵌 dbutil.Model:统计流水不需要软删除,而软删除会让唯一索引
// (user_id, viewed_on, visitor) 失效 —— 被「删掉」的行仍然占着槽位,
// 同一访客当天再也插不进去(GORM 软删除 + 唯一索引的经典坑)。
type BoxView struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"uniqueIndex:idx_box_views_day_visitor,priority:1;index:idx_box_views_user_day,priority:1"`
ViewedOn time.Time `gorm:"type:date;uniqueIndex:idx_box_views_day_visitor,priority:2;index:idx_box_views_user_day,priority:2"`
Visitor string `gorm:"size:32;uniqueIndex:idx_box_views_day_visitor,priority:3"`
Views int64 `gorm:"not null;default:1"`
CreatedAt time.Time
UpdatedAt time.Time
}
// BoxViewDay 某一天聚合后的访问量。
type BoxViewDay struct {
// Day 当天 0 点(调用方传入的时区)。扫描出来是 date 列,loc 由数据库会话决定,
// 上层只取 Year/Month/Day 拼 key,不依赖时分秒。
Day time.Time `gorm:"column:viewed_on"`
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
type RecordBoxViewOptions struct {
UserID uint
ViewedOn time.Time // 只有年月日有意义,写入前会被截断到当天 0 点
Visitor string
}
var BoxViews BoxViewsStore
var _ BoxViewsStore = (*boxViews)(nil)
func NewBoxViewsStore(db *gorm.DB) BoxViewsStore {
return &boxViews{db}
}
type boxViews struct {
*gorm.DB
}
// dayOnly 把时间截断到当天 0 点。用传入时间的时区,不做 UTC 归一 ——
// 站点按本地时间切「今天」,跨时区归一会让日报表偏一天。
func dayOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
func (db *boxViews) Record(ctx context.Context, opts RecordBoxViewOptions) error {
return db.RecordBatch(ctx, []RecordBoxViewOptions{opts})
}
// RecordBatch 单条 INSERT ... ON CONFLICT DO UPDATE 累加。
//
// 冲突时只把 views 加一,visited_at 之外的列不动:同一访客当天第二次访问
// 只是「同一行还来了」,UV 不变、PV +1。
func (db *boxViews) RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error {
if len(opts) == 0 {
return nil
}
now := time.Now()
rows := make([]BoxView, 0, len(opts))
for _, o := range opts {
if o.UserID == 0 || o.Visitor == "" {
// 缺用户或访客标识的行没有统计价值,直接丢弃:
// 空 Visitor 会让同一箱所有匿名访问挤进同一行,把 UV 压成 1。
continue
}
rows = append(rows, BoxView{
UserID: o.UserID,
ViewedOn: dayOnly(o.ViewedOn),
Visitor: o.Visitor,
Views: 1,
CreatedAt: now,
UpdatedAt: now,
})
}
if len(rows) == 0 {
return nil
}
// 同一批里可能出现同一个 (user, day, visitor) 多次(并发请求攒到一次 flush):
// PostgreSQL 的 ON CONFLICT 不支持在同一条 INSERT 里两次命中同一行
// ("ON CONFLICT DO UPDATE command cannot affect row a second time")。
// 所以先在内存里按唯一键合并,把次数累加到 Views 上。
merged := make([]BoxView, 0, len(rows))
index := make(map[[3]interface{}]int, len(rows))
for _, r := range rows {
key := [3]interface{}{r.UserID, r.ViewedOn.Format("2006-01-02"), r.Visitor}
if i, ok := index[key]; ok {
merged[i].Views += r.Views
continue
}
index[key] = len(merged)
merged = append(merged, r)
}
err := db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "viewed_on"}, {Name: "visitor"}},
DoUpdates: clause.Assignments(map[string]interface{}{
// 表名限定:PG 里裸 views 在 INSERT ... ON CONFLICT 的 SET 子句里
// 可能被当成目标列的别名解析,加上表名前缀最稳。
"views": gorm.Expr("box_views.views + excluded.views"),
"updated_at": now,
}),
}).Create(&merged).Error
if err != nil {
return errors.Wrap(err, "upsert box views")
}
return nil
}
func (db *boxViews) Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error) {
var days []BoxViewDay
err := db.WithContext(ctx).Model(&BoxView{}).
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
Where("user_id = ? AND viewed_on >= ? AND viewed_on <= ?", userID, dayOnly(from), dayOnly(to)).
Group("viewed_on").
Order("viewed_on").
Scan(&days).Error
if err != nil {
return nil, errors.Wrap(err, "aggregate box view days")
}
return days, nil
}
func (db *boxViews) TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error) {
if limit <= 0 {
limit = 1
}
var days []BoxViewDay
err := db.WithContext(ctx).Model(&BoxView{}).
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
Where("user_id = ?", userID).
Group("viewed_on").
Order("pv DESC").
Limit(limit).
Scan(&days).Error
if err != nil {
return nil, errors.Wrap(err, "top box view days")
}
return days, nil
}
func (db *boxViews) FirstDay(ctx context.Context, userID uint) (time.Time, error) {
var day *time.Time
err := db.WithContext(ctx).Model(&BoxView{}).
Select("MIN(viewed_on)").
Where("user_id = ?", userID).
Scan(&day).Error
if err != nil {
return time.Time{}, errors.Wrap(err, "first box view day")
}
if day == nil {
return time.Time{}, nil
}
return *day, nil
}
func (db *boxViews) Totals(ctx context.Context, userID uint) (int64, int64, error) {
var row struct {
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
err := db.WithContext(ctx).Model(&BoxView{}).
Select("COALESCE(SUM(views), 0) AS pv, COUNT(*) AS uv").
Where("user_id = ?", userID).
Scan(&row).Error
if err != nil {
return 0, 0, errors.Wrap(err, "total box views")
}
return row.PV, row.UV, nil
}
// Purge 删除 before 之前的访问明细。返回删除行数。
//
// 用 Delete 而非 Unscoped 也没关系 —— BoxView 没有 DeletedAt 字段,
// GORM 看不到软删除标记,会直接发 DELETE。
func (db *boxViews) Purge(ctx context.Context, before time.Time) (int64, error) {
res := db.WithContext(ctx).
Where("viewed_on < ?", dayOnly(before)).
Delete(&BoxView{})
if res.Error != nil {
return 0, errors.Wrap(res.Error, "purge box views")
}
return res.RowsAffected, nil
}