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 干净)。
This commit is contained in:
@@ -195,3 +195,51 @@ gzip 实现质量高、后台移动端短标签方案做得细。
|
|||||||
把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,evaluate 永远等不到响应。修了 `_cdp.py`。
|
把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,evaluate 永远等不到响应。修了 `_cdp.py`。
|
||||||
- **awaitPromise 长轮询会挂死自写 WS 客户端**:验证异步结果改用「navigate + 非 await 轮询读 DOM」。
|
- **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/)再读。
|
||||||
|
|||||||
@@ -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 {
|
if !chatState.Open {
|
||||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
|
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
|
||||||
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
|
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)
|
owner, err := db.Users.GetByDomain(ctx.Request().Context(), req.Domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrUserNotExists) {
|
if errors.Is(err, db.ErrUserNotExists) {
|
||||||
// 不区分「箱主不存在」与「提问不属于他」:一律返回空结果。
|
// 箱主不存在时,也按请求的 items 原样回显 ok=false,与「箱主存在但
|
||||||
return ctx.JSON(map[string]interface{}{"items": []askStatusResult{}})
|
// 校验失败」保持完全同形。否则「空数组 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")
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get box owner for ask status")
|
||||||
return ctx.ServerError()
|
return ctx.ServerError()
|
||||||
|
|||||||
@@ -289,6 +289,10 @@
|
|||||||
// 兜底:极少数情况下用户选完图立刻提交(压缩还没跑完)。
|
// 兜底:极少数情况下用户选完图立刻提交(压缩还没跑完)。
|
||||||
// capture 阶段先拦下,等压缩结束再真正提交 —— 用 form.submit() 而不是
|
// capture 阶段先拦下,等压缩结束再真正提交 —— 用 form.submit() 而不是
|
||||||
// 再走一次 submit 事件,天然避开死循环,也不会二次触发上面的防重复逻辑。
|
// 再走一次 submit 事件,天然避开死循环,也不会二次触发上面的防重复逻辑。
|
||||||
|
//
|
||||||
|
// 注意 tbBeginSubmit(禁用按钮)必须在 preventDefault 的同一同步栈里立刻调用,
|
||||||
|
// 而不是推迟到 Promise 回调里:大图压缩要 0.5~2s,这期间按钮若仍可点,
|
||||||
|
// 用户双击会触发两次 submit,各等同一个压缩任务、各 form.submit() 一次。
|
||||||
document.addEventListener('submit', function (e) {
|
document.addEventListener('submit', function (e) {
|
||||||
var form = e.target;
|
var form = e.target;
|
||||||
if (!form || form.tagName !== 'FORM') return;
|
if (!form || form.tagName !== 'FORM') return;
|
||||||
@@ -301,8 +305,9 @@
|
|||||||
if (!waits.length) return;
|
if (!waits.length) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
Promise.all(waits).then(function () {
|
// 同步进入「提交中」:物理阻止压缩窗口内的重复提交。
|
||||||
if (window.tbBeginSubmit) window.tbBeginSubmit(form, e.submitter || null);
|
if (window.tbBeginSubmit) window.tbBeginSubmit(form, e.submitter || null);
|
||||||
|
Promise.all(waits).then(function () {
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
}, true);
|
}, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user