From e9e6445fdc9a124bdccaf8d71607fee4c8ef25c2 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 17 九月 2026 11:10:14 +0800
Subject: [PATCH] docs(scratch): 记录首页交易总额与真机核销码图片的排查结论
---
.scratch/order-payment-display/README.md | 26 ++++++
.scratch/order-payment-display/H2Probe.java | 26 ++++++
.scratch/order-payment-display/issues/bug-1-merchant-dashboard-total.md | 83 ++++++++++++++++++++
.scratch/order-payment-display/issues/bug-2-verification-code-image.md | 64 ++++++++++++++++
4 files changed, 199 insertions(+), 0 deletions(-)
diff --git a/.scratch/order-payment-display/H2Probe.java b/.scratch/order-payment-display/H2Probe.java
new file mode 100644
index 0000000..571faa3
--- /dev/null
+++ b/.scratch/order-payment-display/H2Probe.java
@@ -0,0 +1,26 @@
+import java.sql.*;
+
+/** 一次性只读探针:确认 H2(MySQL 模式) 支持哪些函数,决定方言测试需要补哪些别名。 */
+public class H2Probe {
+ public static void main(String[] args) throws Exception {
+ try (Connection c = DriverManager.getConnection("jdbc:h2:mem:probe;MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
+ Statement s = c.createStatement()) {
+ probe(s, "if(1 = 1, 'a', 'b')");
+ probe(s, "json_extract('{\"a\":1}', '$.a')");
+ probe(s, "json_unquote('\"a\"')");
+ probe(s, "lower(replace(cast('\"TRUE\"' as char), '\"', '')) in ('true')");
+ probe(s, "greatest(3, 0)");
+ probe(s, "coalesce(null, 1)");
+ probe(s, "date_sub(now(), interval 20 second)");
+ }
+ }
+
+ private static void probe(Statement s, String expression) {
+ try (ResultSet r = s.executeQuery("select " + expression)) {
+ r.next();
+ System.out.println("OK " + expression + " => " + r.getObject(1));
+ } catch (SQLException e) {
+ System.out.println("FAIL " + expression + " => " + e.getMessage().split("\n")[0]);
+ }
+ }
+}
diff --git a/.scratch/order-payment-display/README.md b/.scratch/order-payment-display/README.md
new file mode 100644
index 0000000..8220ef2
--- /dev/null
+++ b/.scratch/order-payment-display/README.md
@@ -0,0 +1,26 @@
+# 商户端/人才端订单支付流程两处显示问题(2026-09-17)
+
+本次会话处理用户报的两个问题,结论、证据与遗留都写在 `issues/`:
+
+| 问题 | 结论 | 状态 |
+| --- | --- | --- |
+| 商户端首页「交易总额」把未核销、退款中的订单也算进去(像缓存) | 首页卡片 SQL 漏了核销条件;退款中的订单靠「必然未核销」被连带挡掉,不是缓存 | 已修 + 回归测试(`issues/bug-1-merchant-dashboard-total.md`) |
+| 人才端待使用页核销码图片真机不显示 | 该页动态图片缺 `@error` 兜底(P1-006 同款漏项),已补;图片取不回来的原因还差一份真机抓包 | 兜底已修,根因待证据(`issues/bug-2-verification-code-image.md`) |
+
+## 回归怎么跑的
+
+- `talent-card-server/scripts/run-server-tests.ps1 -Test MerchantSettlementStatisticsServiceTests`
+ —— 新增的首页统计口径测试,先看它红(`expected: 1000L but was: 7000L`)再改 SQL,改后绿。
+- 同一条链路的 8 个测试类(商户工作台、手动消费、核销、退款收敛、核销撤销、订单资金边界)一起跑:
+ 只有 `MerchantWorkServiceTests.filtersOrdersByCategoryStatusAndDateWithoutExposingFullMobile`
+ 失败,即 `docs/agents/testing.md` 记的已知基线失败,与本次改动无关。
+- 人才端小程序:`E:\HBuilderX\cli.exe launch mp-weixin --project rencaika --compile true` 编译通过。
+- 开发库只读复核:按新口径算商户 631 的交易总额由 ¥0.13 变 ¥0.12(那 ¥0.01 是订单 515,
+ 已支付未核销),商户 632 不变(它的三笔退款单已收敛成 `order_status = 2`)。
+
+## 目录
+
+- `issues/bug-1-merchant-dashboard-total.md`、`issues/bug-2-verification-code-image.md`:现象、根因、证据、遗留。
+- `H2Probe.java`:一次性探针,用来确认 H2 2.4.240(MODE=MySQL)没有 `json_extract`、`if()`、`date_sub`,
+ 说明首页统计的回归测试为什么只能写在 MySQL 测试库上。运行:
+ `java -cp <h2.jar> .scratch/order-payment-display/H2Probe.java`
diff --git a/.scratch/order-payment-display/issues/bug-1-merchant-dashboard-total.md b/.scratch/order-payment-display/issues/bug-1-merchant-dashboard-total.md
new file mode 100644
index 0000000..4953725
--- /dev/null
+++ b/.scratch/order-payment-display/issues/bug-1-merchant-dashboard-total.md
@@ -0,0 +1,83 @@
+# 商户端首页「交易总额」把未核销、退款中的订单也算进去
+
+状态:主缺陷已修(交易总额口径 + 回归测试);另有 3 项遗留待决策,见文末。
+
+## 现象
+
+1. 人才端下单一笔订单后,商户端首页「交易总额」立刻多出这一笔的金额。
+2. 人才端退款后,商户端再打开首页,那笔金额还在,看起来像缓存。
+3. 用户口径:交易总额只应统计**核销过**的订单,未核销的不算。
+
+## 根因
+
+`StatisticsQuerySql.selectDashboardCards` 的 `transactionAmountCents` 只过滤了
+`order_status = 1`(已支付),**没有核销条件**:
+
+```sql
+select sum(o.pay_amount) from benefit_order o
+where o.deleted = 0 and o.order_status = 1 and o.merchant_id = #{merchantId}
+```
+
+- 现象 1 是这条 SQL 的直接结果:已支付未核销的订单也被求和。
+- 现象 2 是它的推论,不是缓存:退款受理成功只把订单置为「退款中」
+ (`OrderFundBoundaryService.markRefundInProgress` → `refund_status = 1`),
+ `order_status` 仍是 1,通道最终结果要等对账任务(最长 10 分钟)或下一次读订单才收敛。
+ 这段时间里订单仍满足 `order_status = 1`,所以一直计入。
+ 读路径收敛只加在人才端读订单/列表与商户端订单列表(`HuifuRefundService.confirmPendingRefunds` /
+ `confirmPendingRefundsOfMerchant`),**商户首页统计的读路径没有收敛**。
+
+因此「清数据 + 重新编译 + 重新登录之后才消失」是时间上的巧合:那几分钟里对账任务已经把这笔退款收敛成
+`order_status = 2`,金额随之回落。客户端没有缓存嫌疑(见「排除项」)。
+
+## 证据
+
+- 开发库 `benefit_order` 实测(2026-09-17):商户 632 的 522/524/525 三笔都是
+ `order_status = 2, refund_status = 2`,退款从受理到收敛耗时 21 分钟 / 2 分钟 / 1 分钟
+ (`order_refund.refund_time` 与订单 `created_at` 相差),期间它们都满足 `order_status = 1`。
+ 同商户 526 为 `order_status = 1, verify_status = 1`(真核销),515 为
+ `order_status = 1, verify_status = 0`(已支付未核销,就是被错算进来的那一类)。
+- 同一模块的结算法口径本来就是「已支付且已核销」:`selectPeriodSummaries`、
+ `AdminWorkbenchStatisticsMapper`、`MerchantSettlementStatisticsController` 的接口描述都用
+ `order_status = 1 and verify_status = 1`;只有首页卡片这条漏了核销条件。
+- 回归测试(新增 `MerchantSettlementStatisticsServiceTests`,真 MySQL + 真 SQL)在修前是红的:
+ `expected: 1000L but was: 7000L` —— 三笔订单(已核销 10 元、未核销 20 元、退款中 40 元)被全额计入。
+
+## 已改
+
+- `StatisticsQuerySql.selectDashboardCards`:`transactionAmountCents` 增加 `and o.verify_status = 1`。
+ 未核销的订单不再计入,退款中的订单因为必然未核销而被同时挡住,首页在退款受理的那一刻就回落,
+ 不再依赖通道结果收敛。
+- `MerchantSettlementStatisticsService.dashboardCards`:`statisticsBasis` 文案由「已支付订单实付金额」
+ 改为「已支付且已核销订单的实付金额」。
+- 新增 `talent-card-server/src/test/java/com/talentcard/server/service/MerchantSettlementStatisticsServiceTests.java`
+ 锁住这个口径。
+
+## 排除项(不是缓存)
+
+- 商户端小程序首页每次 `onShow` 都发 `GET /api/merchant/dashboard/summary`,`services/storage.uts`
+ 只存会话与进件草稿,没有缓存统计结果。
+- 服务端没有 `@Cacheable`/CacheManager,`selectDashboardCards` 是实时聚合。
+- `pages/index/index.uvue` 只在请求失败且已有旧数据时保留旧值(并显示「点击重新加载」小字),
+ 不是持续展示旧值的来源。
+
+## 遗留(需决策,本轮未动)
+
+1. **补贴总额是同一类缺陷**:`subsidyAmountCents` 的订单部分同样只有 `order_status = 1`,
+ 所以「未核销就算、退款后不回落」对它一样成立。只改交易总额会让两个卡片口径不一致
+ (一个要求核销、一个不要求)。`GovernmentSubsidySql` 的类注释写的是「工作台按已支付订单计入,
+ 不要求核销」,所以这是有意的还是漏的,需要产品和结算口径确认。
+2. **`DashboardSummaryService.summary()` 是同一个错 SQL 的死副本**:`MerchantWorkController` 注入了它
+ 但 `/dashboard/summary` 实际调的是 `MerchantSettlementStatisticsService.dashboardCards`,全仓没有调用方。
+ 谁把它接上去,这个 bug 就会回来。建议删掉或同样加 `verify_status = 1`。
+3. **商户首页统计的读路径不做退款收敛**:本轮靠口径修好了症状;如果以后口径又依赖 `order_status`,
+ 缺收敛会让首页比人才端晚最多 10 分钟。见 `HuifuRefundService.confirmPendingRefunds*` 的注释,
+ 那里已写明「读订单/列表之前收敛」的设计意图。
+
+## 另一件值得记的事:H2 缝跑不了这条 SQL
+
+`MerchantSettlementStatisticsMapperTests` 用 H2 内存库,而 `selectDashboardCards` 与
+`selectPeriodSummaries` 都内联 `GovernmentSubsidySql.AMOUNT_CENTS`,其中用到 MySQL 的
+`json_extract` 与 `if()`。H2 2.4.240(MODE=MySQL)两者都没有,`if()` 更是语法错误、无法用
+`create alias` 绕开(探针见 `../H2Probe.java`),所以该测试类在 HEAD 上就红
+(`docs/agents/testing.md` 的已知基线失败之一,且不能在这个缝上修好)。
+首页统计的回归测试因此写在 MySQL 测试库上,而不是 H2。
diff --git a/.scratch/order-payment-display/issues/bug-2-verification-code-image.md b/.scratch/order-payment-display/issues/bug-2-verification-code-image.md
new file mode 100644
index 0000000..6f5f1e3
--- /dev/null
+++ b/.scratch/order-payment-display/issues/bug-2-verification-code-image.md
@@ -0,0 +1,64 @@
+# 人才端待使用页核销码图片真机不显示
+
+状态:已修一项确定的代码缺陷(失败无兜底)。
+**决定(2026-09-17,用户):图片取不回来的原因不再单独排期**——后台域名与服务器随后会切换到 HTTPS 合法域名,
+`<image>` 的真机取图限制随之消失,届时本问题迎刃而解。下面保留两个候选以便届时复核。
+
+## 现象
+
+微信开发者工具的手机端真机调试下:人才端下单支付后,进「使用记录」→ 待核销记录 → 订单详情,
+核销二维码位置一片空白,没有任何提示,也没有重试入口。
+
+## 已确定的代码缺陷(已修)
+
+`rencaika/pages/pending-use/pending-use.uvue` 的核销码 `<image>` 没有 `@error` 兜底:
+
+```html
+<image v-if="codeImage != ''" class="code-image" :src="codeImage" mode="aspectFit"></image>
+<text v-else ...>{{codeError ? '核销码加载失败,点击重试' : '正在生成核销码'}}</text>
+```
+
+图片加载失败时 `codeImage` 仍是非空字符串,`v-if` 依旧成立,`v-else` 的失败文案永远显示不出来 ——
+于是页面只剩空白。这正是本项目自己记录过的缺陷类:`rencaika/docs/7.22遗留问题列表.md` 的
+**P1-006「远程图片没有加载失败兜底 / 图片失败时会直接留白 / 以 @error 回退」**,当时覆盖了权益专题、
+商户列表、商户详情、商品详情,后续 `talent-code.uvue`(人才码)与 `free-benefit.uvue`(免费券码)
+也都按此做了 `@error` 回退,**待使用页被漏掉**。
+
+已改(与上述两页同样的做法,`E:\HBuilderX\cli.exe launch mp-weixin --compile true` 编译通过):
+
+- 核销码图片加 `@error="handleCodeImageError"`:收回 `codeImage`、置 `codeError`,
+ 让失败文案与「点击重新生成」重试入口显出来(重试入口同时也挂到占位文案上)。
+- 同页商品封面图同样加 `@error`,回退到本地占位图(P1-006 对商品图的要求)。
+
+## 图片取不回来的原因:两个候选,需要一个真机证据
+
+同一个 URL 在模拟器里能显示,说明服务端没问题(两边请求的是同一台后台、同一个
+`/api/app/pending-use-orders/verification-codes/{token}/image`)。变量只在真机侧,候选:
+
+1. **http 图片在真机被微信拦掉(最可能)**。开发环境后台是 `http://192.168.110.26:8080`
+ (`rencaika/config/development-server.uts`,本机当前 IP 确认就是 .26),而微信从 2021 年起
+ 在真机上屏蔽 http 图片;开发者工具的「不校验合法域名」对 `wx.request` 有效,对 `<image>`
+ 取图不生效。本项目的发布清单里本来就挂着这件事:
+ `rencaika_merchant/docs/项目接口对接说明.md`「微信真机发布前必须替换为 HTTPS 合法请求域名」。
+ 同页商品封面是 `/uploads/...jpg`(同一台 http 主机),按这个解释它也应该显示不出来。
+2. **图片请求 404**:核销码 token 有效期 60 秒、前端每 54 秒重新签发且**签发时会把上一个 token 置为失效**
+ (`PendingUsePortalService.issueCode` 的 `update ... set status = 2 where status = 0`)。
+ 如果某次取图比签发晚超过 54 秒(弱网、渲染层重试),拿到的就是「核销码已失效」。
+
+要定死是哪一个,只需要一份真机证据:
+
+- 真机调试下打开该页,看**图片请求 `/api/app/pending-use-orders/verification-codes/*/image` 的状态码**:
+ 4xx(尤其 404)就是候选 2;请求根本没发出去、或报域名/协议类错误就是候选 1;
+- 或者一张真机截图 + 微信开发者工具「真机调试」控制台里该请求的那一行。
+
+候选 1 的两种修法(都不改口径,可任选):
+
+- 按发布清单把后台换成 HTTPS 合法域名 —— 一次性解决全 App 的远程图片(含商品封面、专题图);
+- 开发期临时绕过:把二维码字节用 `uni.request(..., responseType: 'arraybuffer')` 取回来
+ (和业务请求同一条通道,真机可用),转 base64 交给 `<image src="data:image/png;base64,...">`。
+ 代价是人才码、免费券码三处都要同步改,且需要一次真机验证。
+
+## 不能复现的原因
+
+真机取图失败发生在微信客户端/设备侧,仓库里没有可跑的等价环境:模拟器走的是开发者工具的取图通道,
+不受同一限制,所以这个缺陷无法用本地循环复现,只能靠上面那份真机证据定位。
--
Gitblit v1.7.1