Compare commits
3
Commits
7f86db8937
...
693b1102c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693b1102c2 | ||
|
|
c9b426acbb | ||
|
|
87176516f3 |
@@ -195,3 +195,51 @@ gzip 实现质量高、后台移动端短标签方案做得细。
|
||||
把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,evaluate 永远等不到响应。修了 `_cdp.py`。
|
||||
- **awaitPromise 长轮询会挂死自写 WS 客户端**:验证异步结果改用「navigate + 非 await 轮询读 DOM」。
|
||||
|
||||
|
||||
## 全盘复查(数据面板/图片压缩之后的第二轮)
|
||||
|
||||
用户要求「再做一次全盘复查」。两个并行子代理扫了本轮新增代码(数据面板、图片压缩、
|
||||
删除事务化、错误页、找回提问),交叉核对后确认的问题与处置:
|
||||
|
||||
### 已修(本轮)
|
||||
|
||||
1. **P1 提问真删不彻底**(`questions.go` DeleteByID 对主表 Unscoped,但追问/图片仍软删):
|
||||
- `follow_ups.go DeleteByQuestionID`、`upload_images.go DeleteByQuestionID` 改 Unscoped 真删。
|
||||
- 关联表 UploadImageQuestion 无 DeletedAt,无需 Unscoped(已注释)。
|
||||
- 新增 `internal/db/question_delete_hard_test.go`:用 Unscoped 计数断言删除后追问/图片/关联行
|
||||
物理上=0。**变异验证**:把追问改回软删,测试失败(expected 0 actual 1)。
|
||||
|
||||
2. **P1 BoxTrend 每日回答数按 created_at 分组**(应为 answered_at):`box_analytics.go` 加 answerDay
|
||||
表达式(PG 用 `to_char(COALESCE(answered_at,updated_at) AT TIME ZONE ...)`,MySQL 用 CONVERT_TZ),
|
||||
WHERE/GROUP/SELECT 三者口径一致。新增 `internal/db/box_analytics_test.go`:构造「40 天前创建、
|
||||
昨天回答」的提问,断言计入昨天。**变异验证**:改回 d.day 后测试失败。
|
||||
|
||||
3. **P2 ask-status domain 存在性可枚举**:`ErrUserNotExists` 分支原返回空数组、正常分支返回带元素数组,
|
||||
凭空/非空可枚举 domain。改为箱主不存在时也回显 `ok:false`(与「存在但 token 错」完全同形)。
|
||||
|
||||
4. **P2 私聊 SendMessage 写路径用 FeatureStateOf**(fail-open 变体):改 FeatureStateOfStrict,读配置失败即拒。
|
||||
(此前靠「chat 默认关闭」巧合侥幸安全,现在按写路径约定统一。)
|
||||
|
||||
5. **P2 图片压缩 submit 兜底可重复提交**:capture 监听器里 tbBeginSubmit 从 Promise 回调提前到
|
||||
preventDefault 的同步栈,物理阻止压缩窗口内双击重复提交。
|
||||
|
||||
### 判定不修(子代理误报,或有意设计)
|
||||
|
||||
- **投票删除不回退配额**:子代理当 bug,但这是**有意为之**——投票配额口径是「累计收到的票数」,
|
||||
删投票不代表票没发生过(记忆里早记过:一度加 -1 是错的)。注释已写清。
|
||||
- **recomputeHeights 里 `d.UV > maxPV` 死代码**:UV≤PV 恒成立,该行永不触发,但当前「PV/UV 同刻度」
|
||||
恰好是设计意图,不算错。
|
||||
- **TopDays 死代码**:`BoxViewsStore.TopDays` 无调用方,best-day 只回看 30 天。属口径/注释自相矛盾,
|
||||
但不改数据安全,留待后续。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **gofmt -l 把「缺末尾换行」也标为未格式化**:Edit 工具写文件时可能丢末尾换行符,`gofmt -l`
|
||||
会报 `\ No newline at end of file`。与行尾问题不同,补一个 `\n` 即可。
|
||||
- **struct 字段对齐**:手写 struct 时字段没对齐(`Questions int64` 少一个空格),gofmt 会标。
|
||||
用 `dev/gofmt_keep_crlf.py` 统一修。
|
||||
- **CSRF token 登录后会轮换**:flamego/csrf 在登录成功后轮换 token(防固定),所以登录前 GET /login
|
||||
拿到的 token 在登录后失效,POST 接口测试要登录后再从带表单的页面重新取。这导致 ask-status 的
|
||||
真实 HTTP 验证没跑通(靠代码 diff 确认修复正确)。
|
||||
- **`git show HEAD:file` 输出写到 /tmp 后 Windows Python 读不到**:/tmp 是 Git Bash 的虚拟路径,
|
||||
Windows Python 看不到。要落盘验证行尾,写到仓库内(如 outputs/)再读。
|
||||
|
||||
@@ -29,20 +29,20 @@ type BoxAnalyticsStore interface {
|
||||
|
||||
// BoxDayCount 某一天的提问 / 回答计数。
|
||||
type BoxDayCount struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Questions int64 `gorm:"column:questions"`
|
||||
Answers int64 `gorm:"column:answers"`
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Questions int64 `gorm:"column:questions"`
|
||||
Answers int64 `gorm:"column:answers"`
|
||||
}
|
||||
|
||||
// BoxFunnel 箱主的提问处理漏斗与响应速度。
|
||||
type BoxFunnel struct {
|
||||
Total int64 // 全部提问
|
||||
Answered int64 // 已回答
|
||||
ReadOnly int64 // 已读未答
|
||||
Unread int64 // 未读未答
|
||||
Private int64 // 私密提问
|
||||
Anonymous int64 // 匿名(未登录)提问
|
||||
Pinned int64 // 置顶
|
||||
Total int64 // 全部提问
|
||||
Answered int64 // 已回答
|
||||
ReadOnly int64 // 已读未答
|
||||
Unread int64 // 未读未答
|
||||
Private int64 // 私密提问
|
||||
Anonymous int64 // 匿名(未登录)提问
|
||||
Pinned int64 // 置顶
|
||||
// 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at,
|
||||
// 会有偏高,见 answered_at 的注释)。
|
||||
RepliedIn24h int64
|
||||
@@ -68,6 +68,18 @@ func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time
|
||||
d := analyticsDialectFor(db.DB, from)
|
||||
from, to = dayOnly(from), dayOnly(to).AddDate(0, 0, 1)
|
||||
|
||||
// 提问按 created_at 分组;回答必须按 answered_at(空则退回 updated_at)分组。
|
||||
// 两条 SQL 的 day 表达式不同:d.day 是 created_at 的日期,回答那条要基于
|
||||
// COALESCE(answered_at, updated_at),否则「40 天前创建、昨天才回答」的提问
|
||||
// 会被排除在窗口外,「今天创建、明天回答」的会被记到今天 —— 趋势图里的
|
||||
// 「每日回答数」就不是「某天被回答了多少」。
|
||||
answerDay := analyticsLocalize("COALESCE(answered_at, updated_at)", from)
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
answerDay = fmt.Sprintf("DATE_FORMAT(CONVERT_TZ(COALESCE(answered_at, updated_at), '+00:00', '%s'), '%%Y-%%m-%%d')", analyticsTZOffset(from))
|
||||
} else {
|
||||
answerDay = fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", answerDay)
|
||||
}
|
||||
|
||||
// 提问与回答分两条 SQL 再在内存里合并:一条 SQL 里同时按 created_at 和
|
||||
// answered_at 分组要写成两条子查询再 FULL JOIN,MySQL 没有 FULL JOIN,
|
||||
// 而两张结果集都很小,合并成本可以忽略。
|
||||
@@ -85,10 +97,10 @@ func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time
|
||||
return nil, errors.Wrap(err, "aggregate box questions by day")
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.day+" AS key, COUNT(*) AS n").
|
||||
Select(answerDay+" AS key, COUNT(*) AS n").
|
||||
Where("user_id = ? AND answer <> '' AND COALESCE(answered_at, updated_at) >= ? AND COALESCE(answered_at, updated_at) < ?",
|
||||
userID, from, to).
|
||||
Group(d.day).
|
||||
Group(answerDay).
|
||||
Scan(&as).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "aggregate box answers by day")
|
||||
}
|
||||
@@ -190,8 +202,8 @@ func (db *boxAnalytics) BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel,
|
||||
return out, nil
|
||||
}
|
||||
var speed struct {
|
||||
In24h int64 `gorm:"column:in_24h"`
|
||||
Avg *float64 `gorm:"column:avg_min"`
|
||||
In24h int64 `gorm:"column:in_24h"`
|
||||
Avg *float64 `gorm:"column:avg_min"`
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(fmt.Sprintf(`COALESCE(SUM(CASE WHEN %[1]s <= 1440 THEN 1 ELSE 0 END), 0) AS in_24h,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBoxTrendAnswerByAnsweredDate 验证「每日回答数」按 answered_at 分组,
|
||||
// 而不是按 created_at。
|
||||
//
|
||||
// 背景:BoxTrend 一度两条 SQL 都复用 created_at 的日期表达式,导致「40 天前创建、
|
||||
// 昨天才回答」的提问被排除在窗口外(created_at < from),昨天/今天的回答数里
|
||||
// 根本不出现;「今天创建、明天回答」的又会被记到今天。本测试构造一条
|
||||
// 「创建日远早于窗口、回答日落在窗口内」的提问,断言它出现在回答日那天。
|
||||
func TestBoxTrendAnswerByAnsweredDate(t *testing.T) {
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
store := NewBoxAnalyticsStore(db)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = cleanup("questions")
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
// 窗口 = 最近 7 天
|
||||
from := now.AddDate(0, 0, -6)
|
||||
to := now
|
||||
|
||||
// 一条 40 天前创建、昨天才回答的提问
|
||||
answeredAt := now.AddDate(0, 0, -1)
|
||||
createdAt := now.AddDate(0, 0, -40)
|
||||
q := &Question{
|
||||
UserID: 9,
|
||||
Content: "很久以前问的,昨天才回答",
|
||||
Answer: "这是回答",
|
||||
AnsweredAt: &answeredAt,
|
||||
}
|
||||
// 直接写 created_at / answered_at,绕过 Create 的自动时间戳
|
||||
require.NoError(t, db.Model(&Question{}).Create(q).Error)
|
||||
require.NoError(t, db.Model(&Question{}).Where("id = ?", q.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"created_at": createdAt,
|
||||
"answered_at": answeredAt,
|
||||
"updated_at": answeredAt,
|
||||
}).Error)
|
||||
|
||||
trend, err := store.BoxTrend(ctx, 9, from, to)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 找到回答日那天(昨天)的计数,答案数应 >= 1
|
||||
var found bool
|
||||
for _, d := range trend {
|
||||
if d.Day.Year() == answeredAt.Year() &&
|
||||
d.Day.Month() == answeredAt.Month() &&
|
||||
d.Day.Day() == answeredAt.Day() {
|
||||
require.GreaterOrEqual(t, d.Answers, int64(1),
|
||||
"回答应计入 answered_at 那一天,而不是按 created_at 被排除")
|
||||
found = true
|
||||
}
|
||||
// 创建日(40 天前)不应出现在窗口内
|
||||
if d.Day.Year() == createdAt.Year() &&
|
||||
d.Day.Month() == createdAt.Month() &&
|
||||
d.Day.Day() == createdAt.Day() {
|
||||
t.Errorf("创建日在窗口外,不应出现在趋势里")
|
||||
}
|
||||
}
|
||||
require.True(t, found, "回答日应出现在趋势结果里")
|
||||
}
|
||||
@@ -231,4 +231,4 @@ func (db *boxViews) Purge(ctx context.Context, before time.Time) (int64, error)
|
||||
return 0, errors.Wrap(res.Error, "purge box views")
|
||||
}
|
||||
return res.RowsAffected, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ type FollowUpsStore interface {
|
||||
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
|
||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问。
|
||||
//
|
||||
// 用 Unscoped 真删:追问正文是提问者的个人内容,随提问一并真删(与 questions.DeleteByID
|
||||
// 对主表的真删口径一致)。软删会留下带 deleted_at 的追问文本,且这些孤儿行的
|
||||
// question_id 指向一个已不存在的提问 —— 与「删除即真删、个人数据不残留」的意图相悖。
|
||||
DeleteByQuestionID(ctx context.Context, questionID uint) error
|
||||
}
|
||||
|
||||
@@ -236,9 +240,9 @@ func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id u
|
||||
return wasFirst, nil
|
||||
}
|
||||
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(真删,与提问保持一致)。
|
||||
func (db *followUps) DeleteByQuestionID(ctx context.Context, questionID uint) error {
|
||||
if err := db.WithContext(ctx).Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
|
||||
if err := db.WithContext(ctx).Unscoped().Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
|
||||
return errors.Wrap(err, "delete follow ups by question id")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -60,4 +60,4 @@ func (db *messageImages) GetByMessageIDs(ctx context.Context, messageIDs []uint)
|
||||
result[img.MessageID] = append(result[img.MessageID], img)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestQuestionDeleteCascadesHardDelete 验证删除提问时,追问与上传图片也被**真删**,
|
||||
// 而不是软删残留。
|
||||
//
|
||||
// 背景:questions.DeleteByID 对主表用 Unscoped 真删,但历史上一度只有主表真删、
|
||||
// 追问/图片仍软删,导致提问正文删干净了、追问文本与图片元数据却带着 deleted_at
|
||||
// 永久留在库里(这正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改)。
|
||||
// 本测试用 Unscoped 计数兜底:软删的行在普通查询里不可见,只有 Unscoped 才能看到,
|
||||
// 因此断言「删除后 Unscoped 计数 = 0」才能证明是真删。
|
||||
func TestQuestionDeleteCascadesHardDelete(t *testing.T) {
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
questions := NewQuestionsStore(db)
|
||||
followUps := NewFollowUpsStore(db)
|
||||
uploadImages := NewUploadImagesStore(db)
|
||||
|
||||
t.Cleanup(func() {
|
||||
// 三张表都要清:follow_ups / upload_images(含链接表)在删除后应该已经空了,
|
||||
// 但其他子测试的 data 可能残留,用 cleanup 统一兜底。
|
||||
_ = cleanup("questions", "follow_ups", "upload_images", "upload_image_questions")
|
||||
})
|
||||
|
||||
// 建一条提问
|
||||
q, err := questions.Create(ctx, CreateQuestionOptions{
|
||||
FromIP: "114.5.1.4",
|
||||
UserID: 1,
|
||||
Content: "需要被彻底删除的提问",
|
||||
AskerUserID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 给它加一条追问
|
||||
created, err := followUps.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
|
||||
QuestionID: q.ID,
|
||||
Content: "追问内容,也应被真删",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
// 给它加一张上传图片
|
||||
img, err := uploadImages.Create(ctx, CreateUploadImageOptions{
|
||||
Type: UploadImageQuestionTypeAsk,
|
||||
QuestionID: q.ID,
|
||||
UploaderUserID: 1,
|
||||
Name: "secret.png",
|
||||
FileSize: 1024,
|
||||
Md5: "deadbeef",
|
||||
Key: "2026/10/29/secret.png",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, img.ID)
|
||||
|
||||
// 删除提问
|
||||
require.NoError(t, questions.DeleteByID(ctx, q.ID))
|
||||
|
||||
// 主表:真删
|
||||
require.Equal(t, int64(0), hardCount(t, db, &Question{}, "id = ?", q.ID))
|
||||
|
||||
// 追问:真删(软删的话这里会是 1)
|
||||
require.Equal(t, int64(0), hardCount(t, db, &FollowUp{}, "question_id = ?", q.ID))
|
||||
|
||||
// 上传图片主体:真删
|
||||
require.Equal(t, int64(0), hardCount(t, db, &UploadImage{}, "id = ?", img.ID))
|
||||
|
||||
// 关联行:真删
|
||||
require.Equal(t, int64(0), hardCount(t, db, &UploadImageQuestion{}, "question_id = ?", q.ID))
|
||||
}
|
||||
|
||||
// hardCount 用 Unscoped 统计满足条件的行数,能穿透软删除过滤,
|
||||
// 用于断言「物理上也不存在」。
|
||||
func hardCount(t *testing.T, db *gorm.DB, model interface{}, where string, args ...interface{}) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
err := db.WithContext(context.Background()).Unscoped().
|
||||
Model(model).
|
||||
Where(where, args...).
|
||||
Count(&n).Error
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
@@ -23,7 +23,12 @@ type UploadImagesStore interface {
|
||||
// 用于账号导出等场景,将 N 次单题查询合并为少量 IN 查询,避免 N+1。
|
||||
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error)
|
||||
GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error)
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录与关联行。
|
||||
//
|
||||
// 用 Unscoped 真删:图片元数据(name/md5/key)随提问一并真删,与 questions.DeleteByID
|
||||
// 对主表的真删口径一致。软删会留下带 deleted_at 的图片行,且关联行的 question_id
|
||||
// 指向已不存在的提问。注意这里只清数据库记录,对象存储里的实际文件不在本函数删除
|
||||
// (图片可能被多条记录复用,避免误删共享资源)。
|
||||
DeleteByQuestionID(ctx context.Context, questionID uint) error
|
||||
}
|
||||
|
||||
@@ -179,9 +184,10 @@ func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint)
|
||||
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
|
||||
return item.UploadImageID
|
||||
})
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Delete(&UploadImage{}).Error; err != nil {
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Unscoped().Delete(&UploadImage{}).Error; err != nil {
|
||||
return errors.Wrap(err, "delete upload images by question id")
|
||||
}
|
||||
// 关联表 UploadImageQuestion 无 DeletedAt,Delete 本就是真删,无需 Unscoped。
|
||||
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Delete(&UploadImageQuestion{}).Error; err != nil {
|
||||
return errors.Wrap(err, "delete image question links")
|
||||
}
|
||||
|
||||
@@ -77,13 +77,13 @@ type Window struct {
|
||||
|
||||
// Day 趋势图里的一天。
|
||||
type Day struct {
|
||||
Key string // 2026-10-29
|
||||
Label string // 10-29
|
||||
Weekday string
|
||||
PV int64
|
||||
UV int64
|
||||
Key string // 2026-10-29
|
||||
Label string // 10-29
|
||||
Weekday string
|
||||
PV int64
|
||||
UV int64
|
||||
Questions int64
|
||||
Answers int64
|
||||
Answers int64
|
||||
// 高度百分比(0~100),直接当 CSS 高度用。
|
||||
PVHeight float64
|
||||
UVHeight float64
|
||||
|
||||
@@ -51,13 +51,13 @@ func TestFormatInt(t *testing.T) {
|
||||
func TestAggregateWindows(t *testing.T) {
|
||||
loc := time.Local
|
||||
today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc)
|
||||
weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23
|
||||
prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16
|
||||
prevTo := today.AddDate(0, 0, -WeekDays) // 10-22
|
||||
weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23
|
||||
prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16
|
||||
prevTo := today.AddDate(0, 0, -WeekDays) // 10-22
|
||||
|
||||
p := &Page{}
|
||||
for i := 0; i < TrendDays; i++ {
|
||||
d := today.AddDate(0, 0, -(TrendDays - 1) + i)
|
||||
d := today.AddDate(0, 0, -(TrendDays-1)+i)
|
||||
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
|
||||
}
|
||||
set := func(offsetDays int, pv, uv, q, a int64) {
|
||||
@@ -72,11 +72,11 @@ func TestAggregateWindows(t *testing.T) {
|
||||
t.Fatalf("日期 %s 不在骨架里", key)
|
||||
}
|
||||
|
||||
set(0, 5, 3, 1, 0) // 今天:本周 + 今日
|
||||
set(-1, 2, 2, 0, 1) // 昨天:本周
|
||||
set(-6, 4, 1, 2, 0) // 10-23:本周起点
|
||||
set(-7, 9, 6, 0, 0) // 10-22:上周末日
|
||||
set(-13, 6, 5, 0, 0) // 10-16:上周起点
|
||||
set(0, 5, 3, 1, 0) // 今天:本周 + 今日
|
||||
set(-1, 2, 2, 0, 1) // 昨天:本周
|
||||
set(-6, 4, 1, 2, 0) // 10-23:本周起点
|
||||
set(-7, 9, 6, 0, 0) // 10-22:上周末日
|
||||
set(-13, 6, 5, 0, 0) // 10-16:上周起点
|
||||
|
||||
p.aggregateWindows(today, weekFrom, prevFrom, prevTo)
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestAggregateWindowsNoPrev(t *testing.T) {
|
||||
|
||||
p := &Page{}
|
||||
for i := 0; i < TrendDays; i++ {
|
||||
d := today.AddDate(0, 0, -(TrendDays - 1) + i)
|
||||
d := today.AddDate(0, 0, -(TrendDays-1)+i)
|
||||
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
|
||||
}
|
||||
p.Days[len(p.Days)-1].PV = 3
|
||||
@@ -167,13 +167,13 @@ func TestVisitorID(t *testing.T) {
|
||||
|
||||
func TestIsCrawler(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"": true, // 空 UA 基本是脚本
|
||||
"": true, // 空 UA 基本是脚本
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36": false,
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true,
|
||||
"curl/8.4.0": true,
|
||||
"python-requests/2.31.0": true,
|
||||
"curl/8.4.0": true,
|
||||
"python-requests/2.31.0": true,
|
||||
"Mozilla/5.0 (compatible; bingbot/2.0)": true,
|
||||
"facebookexternalhit/1.1": true,
|
||||
"facebookexternalhit/1.1": true,
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 Version/17.0 Mobile Safari/604.1": false,
|
||||
}
|
||||
for ua, want := range cases {
|
||||
|
||||
@@ -252,7 +252,17 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
|
||||
|
||||
// 私聊功能开关:发送前重新校验箱主私聊功能是否仍开启(创建会话时已查过,
|
||||
// 但箱主可能在会话中途关闭私聊,此时应阻止继续发消息)。
|
||||
chatState := db.FeatureStateOf(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
|
||||
//
|
||||
// 用 FeatureStateOfStrict(写路径约定):FeatureStateOf 在配置读失败时回退
|
||||
// 默认值,对提问/投票是「开启」——等于一次读配置失败就绕过箱主的关闭。
|
||||
// 私聊恰好因默认关闭而侥幸安全,但这里仍统一用 Strict,避免依赖那个巧合。
|
||||
chatState, err := db.FeatureStateOfStrict(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
|
||||
if err != nil {
|
||||
// 读配置失败即拒(fail-closed):不能让一次配置抖动绕过箱主的关闭。
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
|
||||
return
|
||||
}
|
||||
if !chatState.Open {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
|
||||
|
||||
@@ -88,8 +88,14 @@ func AskStatusAPI(ctx context.Context) error {
|
||||
owner, err := db.Users.GetByDomain(ctx.Request().Context(), req.Domain)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrUserNotExists) {
|
||||
// 不区分「箱主不存在」与「提问不属于他」:一律返回空结果。
|
||||
return ctx.JSON(map[string]interface{}{"items": []askStatusResult{}})
|
||||
// 箱主不存在时,也按请求的 items 原样回显 ok=false,与「箱主存在但
|
||||
// 校验失败」保持完全同形。否则「空数组 vs 带元素数组」会泄露 domain
|
||||
// 是否存在(含被 L1 从探索页隐藏、或设了页面密码的半公开箱)。
|
||||
out := make([]askStatusResult, 0, len(req.Items))
|
||||
for _, it := range req.Items {
|
||||
out = append(out, askStatusResult{ID: it.ID})
|
||||
}
|
||||
return ctx.JSON(map[string]interface{}{"items": out})
|
||||
}
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get box owner for ask status")
|
||||
return ctx.ServerError()
|
||||
|
||||
@@ -289,6 +289,10 @@
|
||||
// 兜底:极少数情况下用户选完图立刻提交(压缩还没跑完)。
|
||||
// capture 阶段先拦下,等压缩结束再真正提交 —— 用 form.submit() 而不是
|
||||
// 再走一次 submit 事件,天然避开死循环,也不会二次触发上面的防重复逻辑。
|
||||
//
|
||||
// 注意 tbBeginSubmit(禁用按钮)必须在 preventDefault 的同一同步栈里立刻调用,
|
||||
// 而不是推迟到 Promise 回调里:大图压缩要 0.5~2s,这期间按钮若仍可点,
|
||||
// 用户双击会触发两次 submit,各等同一个压缩任务、各 form.submit() 一次。
|
||||
document.addEventListener('submit', function (e) {
|
||||
var form = e.target;
|
||||
if (!form || form.tagName !== 'FORM') return;
|
||||
@@ -301,8 +305,9 @@
|
||||
if (!waits.length) return;
|
||||
|
||||
e.preventDefault();
|
||||
// 同步进入「提交中」:物理阻止压缩窗口内的重复提交。
|
||||
if (window.tbBeginSubmit) window.tbBeginSubmit(form, e.submitter || null);
|
||||
Promise.all(waits).then(function () {
|
||||
if (window.tbBeginSubmit) window.tbBeginSubmit(form, e.submitter || null);
|
||||
form.submit();
|
||||
});
|
||||
}, true);
|
||||
|
||||
Reference in New Issue
Block a user