diff --git a/.workbuddy/memory/2026-10-29.md b/.workbuddy/memory/2026-10-29.md index 2ae66b1..9267d5e 100644 --- a/.workbuddy/memory/2026-10-29.md +++ b/.workbuddy/memory/2026-10-29.md @@ -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/)再读。 diff --git a/internal/db/box_views.go b/internal/db/box_views.go index 27aa348..9ce03ef 100644 --- a/internal/db/box_views.go +++ b/internal/db/box_views.go @@ -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 -} \ No newline at end of file +} diff --git a/internal/db/message_image.go b/internal/db/message_image.go index ab3e8bd..9ad7c05 100644 --- a/internal/db/message_image.go +++ b/internal/db/message_image.go @@ -60,4 +60,4 @@ func (db *messageImages) GetByMessageIDs(ctx context.Context, messageIDs []uint) result[img.MessageID] = append(result[img.MessageID], img) } return result, nil -} \ No newline at end of file +} diff --git a/internal/insights/page.go b/internal/insights/page.go index 6235b41..67dda4d 100644 --- a/internal/insights/page.go +++ b/internal/insights/page.go @@ -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 diff --git a/internal/insights/page_test.go b/internal/insights/page_test.go index 05041c9..3dcd83d 100644 --- a/internal/insights/page_test.go +++ b/internal/insights/page_test.go @@ -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 { diff --git a/internal/route/chat/chat_handler.go b/internal/route/chat/chat_handler.go index 667f18d..40cb03a 100644 --- a/internal/route/chat/chat_handler.go +++ b/internal/route/chat/chat_handler.go @@ -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)) diff --git a/route/question/ask_history.go b/route/question/ask_history.go index 2bdc083..b52e1b9 100644 --- a/route/question/ask_history.go +++ b/route/question/ask_history.go @@ -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() diff --git a/templates/base/footer.html b/templates/base/footer.html index 3aee437..149d985 100644 --- a/templates/base/footer.html +++ b/templates/base/footer.html @@ -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);