@routerhub/agent-rules 1.5.151 → 1.5.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.base.md +57 -0
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/rules/global.md +46 -0
- package/rules/review-boundary.md +11 -0
- package/skills/deploy-test/SKILL.md +46 -16
- package/skills/pr-release-loop/SKILL.md +6 -2
- package/skills/screenshot-annotate/annotate.js +99 -5
package/AGENTS.base.md
CHANGED
|
@@ -188,6 +188,52 @@
|
|
|
188
188
|
- ⚠️ 提交前按「最坏情况」自检:边界值、空值、异常路径、并发/重复触发、重试、依赖不可用(Redis/DB/网络)时,行为是否仍正确、会不会出错或重复执行。
|
|
189
189
|
- ⚠️ 沉淀反模式:每次 review 暴露的设计问题(锁粒度、职责划分、重复实现等),提炼成通用的「反模式」记录,下次写同类代码时自发对照,避免重犯。
|
|
190
190
|
|
|
191
|
+
## ⚠️ 反模式清单(写代码前对照,防止「换件衣服又踩一遍」)
|
|
192
|
+
|
|
193
|
+
「代码质量 → 沉淀反模式」给出机制,本清单是已沉淀的可迁移反模式。写代码前对照一遍,命中即自查。每条都是「同一类坑换框架/换字段名再现」的通用模式,不局限于本例。发现新的坑,当场按此格式回填。
|
|
194
|
+
|
|
195
|
+
### ① 一次性资源消费陷阱:读了就不能再读
|
|
196
|
+
|
|
197
|
+
- ⚠️ 对「同一份输入」做两次读取(读取 + 再读取 / 读取 + 绑定 / 读取 + 解析),必须确认第一次读取不会把源头「消耗掉」。
|
|
198
|
+
- **类比:一瓶汽水只能喝一次。喝完后想再倒一杯,瓶子里已经空了。**
|
|
199
|
+
- 典型反例:Gin `ShouldBindJSON` 会消费 request body,之后再 `GetRawData()` 读到的是空——必须先 `GetRawData()` 把原样存下、还原 body 再 bind。
|
|
200
|
+
- 自查:凡对同一数据做了「先 X 再 Y 的两次读取」,先问 X 是否消费了源头;拿不准就把原样先存一份。
|
|
201
|
+
|
|
202
|
+
### ② 守卫旁路:校验只守了一个入口
|
|
203
|
+
|
|
204
|
+
- ⚠️ 状态变更/停用/删除等「有前置条件的写操作」,必须确保**所有能到达该状态变更的入口**都经过同一套守卫,不能只守 UI/常规路径。
|
|
205
|
+
- **类比:小区只有正门有保安,侧门没锁——坏人从侧门就进去了。**
|
|
206
|
+
- 典型反例:停用守卫只放在 `PATCH .../status`,但 `PUT /vendors/:id` 的请求结构体仍有 `status` 字段,裸 API 传 `status=disabled` 直接绕过守卫。
|
|
207
|
+
- 自查:给某个「状态/权限/开关」加守卫时,先列出**所有能改这个状态的写路径**(PUT/POST/PATCH/脚本/批量),逐个确认都过了守卫。
|
|
208
|
+
|
|
209
|
+
### ③ 字段语义分离:「不带」≠「传空」
|
|
210
|
+
|
|
211
|
+
- ⚠️ 区分「请求没带这个字段」(保持原值)和「带了字段但值为 null/空」(清空/置空)是两种不同的语义,必须分开处理。
|
|
212
|
+
- **类比:顾客没点饮料(维持现状)≠ 顾客点了「不要饮料」(明确要求不给)。**
|
|
213
|
+
- 典型反例:`vendor_id` 不带应保持原归属,显式传 `null` 才清空;若混为一谈,旧调用方不带字段时归属被静默清空。
|
|
214
|
+
- 自查:字段可选时,用「键是否存在」判断语义,而不是「值是否为 null」;两条路径各测一遍。
|
|
215
|
+
|
|
216
|
+
### ④ 缓存失效 ≠ 视图状态恢复:清缓存要连带恢复展开/选中态
|
|
217
|
+
|
|
218
|
+
- ⚠️ 清空缓存后,如果界面上仍有「基于旧缓存展开/选中」的视图状态,必须同步重拉或复位,否则出现「展开但空白」的假象。
|
|
219
|
+
- **类比:刷新冰箱时把饮料全部拿出来,但购物单还勾着「已补货」——你盯着空架子以为饮料没了,其实只是没放回去。**
|
|
220
|
+
- 典型反例:`refreshVendorSidebar` 清空 `vendorAccounts` 缓存但 `expandedVendors` 仍为 true,展开的分组不触发重拉,显示空白像「账户全没了」。
|
|
221
|
+
- 自查:任何「清空缓存」操作,顺手列一遍哪些 UI 状态依赖这份缓存,对激活中的(展开/选中/滚动位置)逐个重拉或复位。
|
|
222
|
+
|
|
223
|
+
### ⑤ 加载状态用独立标记,别拿「数组长度/结果为空」推断
|
|
224
|
+
|
|
225
|
+
- ⚠️ 「是否已加载」要单独用一个布尔标记记录,不要用「数据长度 === 0」推断「还没加载」——真的空数据会被误判成「未加载」,导致重复请求或永不刷新。
|
|
226
|
+
- **类比:柜台空着 ≠ 没营业。营业了但没顾客,和没开门,是两回事,看柜台空判断会搞混。**
|
|
227
|
+
- 典型反例:`if (list.length === 0) fetch()`——真实 0 条数据时每次展开都重复请求;反过来列表非空时切 tab 又永不刷新计数。
|
|
228
|
+
- 自查:用 `loaded` 布尔标记区分「加载过(含空)」与「未加载」;不要用数据本身的有无/长度推断。
|
|
229
|
+
|
|
230
|
+
### ⑥ 白名单优先于黑名单:状态判断写「只允许 active」而非「排除 disabled」
|
|
231
|
+
|
|
232
|
+
- ⚠️ 判断某状态是否可用时,用白名单(`status === 'active'`)而非黑名单(`status !== 'disabled'`)——状态枚举一扩展,黑名单就漏放新状态。
|
|
233
|
+
- **类比:安检只查「名单上列的违禁品」会漏掉新违禁品;「只放行持有效票的人」才兜得住。**
|
|
234
|
+
- 典型反例:`.filter(v => v.status !== 'disabled')` 在将来新增第三种状态(如 `suspended`)时会被误放行,保存必被后端拒绝。
|
|
235
|
+
- 自查:凡「可选/可用/合法」判定,写成「只保留允许的那些」而不是「排除不允许的那些」。
|
|
236
|
+
|
|
191
237
|
## ⚠️ 数据链路改动核对铁律
|
|
192
238
|
|
|
193
239
|
- ⚠️ **给一个数据结构(interface/struct/DTO)新增或修改字段后,必须顺着这份数据流转的每一个转发/序列化点逐一核对,不能只改了数据结构定义或链路两端就算完成**。典型漏改位置是中间层"手写请求体字面量"(如 `JSON.stringify({ a, b })`、手动拼接的 `params`/`payload`),新增字段不会自动带过去,也不会报错,只会表现为"下游一直是空的"这种沉默失败。
|
|
@@ -396,6 +442,7 @@
|
|
|
396
442
|
- 🔴 **会导致实际 bug / 数据丢失 / 数据错误 / 安全漏洞**:有明确触发路径(真实输入、真实调用链能复现),读 diff 能确认后果。
|
|
397
443
|
- 🟡 **真实风险**:有明确触发条件,且触发后后果严重(如并发竞态、错误被静默吞掉、未处理空值导致崩溃)。
|
|
398
444
|
- **违反本项目已固化规则**:AGENTS.base.md / AGENTS.private.md 里的 ⚠️ 铁律(DELETE 铁律、部署自包含、数据链路核对、环境配置禁止推断、测试环境验证前置等),diff 中明确违反 → 必须报。
|
|
445
|
+
- **外部事实核查确认的配置错误**:按「审查方法 → ④ 外部事实核查」查出的事实性错误(死域名、环境归属不一致、对外死值等),即使 diff 内部自洽也必须报——这正是「指向错误环境」类问题的唯一暴露途径(呼应「环境配置禁止推断」)。
|
|
399
446
|
|
|
400
447
|
### 禁止报(不是问题,报了只会拉长循环)
|
|
401
448
|
|
|
@@ -441,6 +488,16 @@
|
|
|
441
488
|
⚠️ PR 描述有「行为变更表 / 改前改后对比表」时,审查以它为靶心,逐行问「这条真的成立吗」,用 diff 逐行验证;PR 没列表的,review 意见至少覆盖「改了什么行为 / 不变的行为有没有被误伤」两个面。
|
|
442
489
|
⚠️ 对作者标注「不在本 PR 范围」的内容(如相关子系统计费、其它仓库同步):不要求作者修,但必须判断是否构成「已知缺口被静默放过」——已暴露的边界缺口,作者必须在 PR 里显式记录后续动作、或至少点明仍待处理,禁止「提了不等于了」。
|
|
443
490
|
|
|
491
|
+
**④ 外部事实核查(diff 之外的真实性验证)**
|
|
492
|
+
|
|
493
|
+
⚠️ diff 内逻辑审完了,对**部署脚本 / 配置 / 品牌 / 环境归属类改动**,还必须做一轮 diff 之外的「外部事实核查」——这类改动最常见的坑是「看着都对,其实指向了错误的环境」。核查不查 DNS / 不核对归属,纯看 diff 是发现不了的(PR #135 教训:AI review 漏掉的 namespace 归属、死域名、死邮箱,人工 reviewer 靠 `dig` + 读关联 PR 才发现)。按下面的清单逐项验证:
|
|
494
|
+
|
|
495
|
+
- **域名 / 子域名真实存在**:脚本或配置里出现的每个域名、派生子域名,逐个 `dig +short <域名>` 验证有解析记录;约定派生的(如 `doc.<domain>` / `api.<domain>`)不能只看主域成立就推断子域成立——不同子域可能有的解析有的无解析。无解析的死域名必须报(哪怕它只在注释里,也会误导后来者)。
|
|
496
|
+
- **环境归属一致(品牌 ↔ namespace ↔ 项目 ↔ 数据库 ↔ 域名)**:部署脚本里 `PROJECT` / `NACOS_NAMESPACE_ID` / Artifact Registry 仓库 / VPC / 数据库,和脚本服务的品牌、域名是否指向**同一个环境**。最危险的形态:脚本带了正确的域名和品牌,却指向**另一个品牌的库/namespace**——「看着是条正经路径」,实则从没跑过或指向错误环境。发现归属不一致,必须报并给出一致的目标配置。
|
|
497
|
+
- **跨 PR / 跨系统的共享与边界**:改动涉及「多系统共享」的配置(共用项目、共用 namespace、共用仓库)时,读关联 PR 描述、查配置中心真实归属,确认「刻意共用」还是「漏改」——禁止凭 diff 内自洽就默认成立。判定为刻意共用的,必须在脚本/注释里显式写明依据,否则视为漏配。
|
|
498
|
+
- **对外可见的真实值**:写进发布内容 / 对外页面 / 邮件 / 招聘启事等的邮箱、URL、联系方式,必须验证真实存在(邮箱查 MX 记录 `dig MX`、收件箱是否在域名邮件体系内;URL 查 DNS + HTTP 可达)。这类值一旦是死的,比控制台死链更难被发现(用户点了没反应,测试验收也难察觉)。
|
|
499
|
+
- **能查就查,禁止「推断即认可」**:能用一个命令查证的事实(`dig` / `gh pr view` / `curl` / 配置中心),必须查证后再下结论;查不到或需要人工确认的,明确写「待确认」并说明在哪查,禁止凭 diff 内自洽或惯性推断当结论。
|
|
500
|
+
|
|
444
501
|
**与审稿边界的配合**:本章找出的问题,是否上报仍按「必须报 / 禁止报」判断(真问题、真实风险、违反铁律 → 报;口味、防御、无法确认真假 → 不报)。每条意见必须带「新增量全局跟进」的链路证据(具体 diff 行 + 谁赋值谁读谁落库),禁止只给结论不给路径。
|
|
445
502
|
|
|
446
503
|
---
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
所有对 @routerhub/agent-rules 的重大更改都会记录在这个文件中。
|
|
4
4
|
|
|
5
|
+
## [1.5.152] - 2026-08-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 新增「审查方法 → ④ 外部事实核查」规则(审稿方必做):审查部署脚本 / 配置 / 品牌 / 环境归属类改动时,除 diff 内逻辑审查外,还必须做一轮 diff 之外的真实性核查——域名子域逐个 `dig` 验证(禁止主域成立就推断子域成立)、环境归属一致性(品牌 ↔ namespace ↔ 项目 ↔ 数据库 ↔ 域名是否指向同一环境)、跨 PR / 跨系统共享与边界(读关联 PR 描述、查配置中心真实归属,区分「刻意共用」与「漏改」)、对外可见真实值(邮箱查 MX 记录、URL 查 DNS + HTTP 可达)。原因:这类改动最常见的坑是「看着都对、其实指向了错误的环境」,纯看 diff 无法发现,只有人工靠 `dig` + 读关联 PR 才能暴露(PR #135 教训:AI review 漏掉的 namespace 归属、死域名、死邮箱,人工 reviewer 通过外部核查才发现)。
|
|
10
|
+
- 「审稿边界 → 必须报」清单新增一条:「外部事实核查确认的配置错误」,即使 diff 内部自洽也必须报——这是「指向错误环境」类问题的唯一暴露途径(呼应「环境配置禁止推断」铁律)。
|
|
11
|
+
|
|
5
12
|
## [1.5.124] - 2026-08-12
|
|
6
13
|
|
|
7
14
|
### Added
|
package/package.json
CHANGED
package/rules/global.md
CHANGED
|
@@ -188,6 +188,52 @@ name: "通用规则"
|
|
|
188
188
|
- ⚠️ 提交前按「最坏情况」自检:边界值、空值、异常路径、并发/重复触发、重试、依赖不可用(Redis/DB/网络)时,行为是否仍正确、会不会出错或重复执行。
|
|
189
189
|
- ⚠️ 沉淀反模式:每次 review 暴露的设计问题(锁粒度、职责划分、重复实现等),提炼成通用的「反模式」记录,下次写同类代码时自发对照,避免重犯。
|
|
190
190
|
|
|
191
|
+
## ⚠️ 反模式清单(写代码前对照,防止「换件衣服又踩一遍」)
|
|
192
|
+
|
|
193
|
+
「代码质量 → 沉淀反模式」给出机制,本清单是已沉淀的可迁移反模式。写代码前对照一遍,命中即自查。每条都是「同一类坑换框架/换字段名再现」的通用模式,不局限于本例。发现新的坑,当场按此格式回填。
|
|
194
|
+
|
|
195
|
+
### ① 一次性资源消费陷阱:读了就不能再读
|
|
196
|
+
|
|
197
|
+
- ⚠️ 对「同一份输入」做两次读取(读取 + 再读取 / 读取 + 绑定 / 读取 + 解析),必须确认第一次读取不会把源头「消耗掉」。
|
|
198
|
+
- **类比:一瓶汽水只能喝一次。喝完后想再倒一杯,瓶子里已经空了。**
|
|
199
|
+
- 典型反例:Gin `ShouldBindJSON` 会消费 request body,之后再 `GetRawData()` 读到的是空——必须先 `GetRawData()` 把原样存下、还原 body 再 bind。
|
|
200
|
+
- 自查:凡对同一数据做了「先 X 再 Y 的两次读取」,先问 X 是否消费了源头;拿不准就把原样先存一份。
|
|
201
|
+
|
|
202
|
+
### ② 守卫旁路:校验只守了一个入口
|
|
203
|
+
|
|
204
|
+
- ⚠️ 状态变更/停用/删除等「有前置条件的写操作」,必须确保**所有能到达该状态变更的入口**都经过同一套守卫,不能只守 UI/常规路径。
|
|
205
|
+
- **类比:小区只有正门有保安,侧门没锁——坏人从侧门就进去了。**
|
|
206
|
+
- 典型反例:停用守卫只放在 `PATCH .../status`,但 `PUT /vendors/:id` 的请求结构体仍有 `status` 字段,裸 API 传 `status=disabled` 直接绕过守卫。
|
|
207
|
+
- 自查:给某个「状态/权限/开关」加守卫时,先列出**所有能改这个状态的写路径**(PUT/POST/PATCH/脚本/批量),逐个确认都过了守卫。
|
|
208
|
+
|
|
209
|
+
### ③ 字段语义分离:「不带」≠「传空」
|
|
210
|
+
|
|
211
|
+
- ⚠️ 区分「请求没带这个字段」(保持原值)和「带了字段但值为 null/空」(清空/置空)是两种不同的语义,必须分开处理。
|
|
212
|
+
- **类比:顾客没点饮料(维持现状)≠ 顾客点了「不要饮料」(明确要求不给)。**
|
|
213
|
+
- 典型反例:`vendor_id` 不带应保持原归属,显式传 `null` 才清空;若混为一谈,旧调用方不带字段时归属被静默清空。
|
|
214
|
+
- 自查:字段可选时,用「键是否存在」判断语义,而不是「值是否为 null」;两条路径各测一遍。
|
|
215
|
+
|
|
216
|
+
### ④ 缓存失效 ≠ 视图状态恢复:清缓存要连带恢复展开/选中态
|
|
217
|
+
|
|
218
|
+
- ⚠️ 清空缓存后,如果界面上仍有「基于旧缓存展开/选中」的视图状态,必须同步重拉或复位,否则出现「展开但空白」的假象。
|
|
219
|
+
- **类比:刷新冰箱时把饮料全部拿出来,但购物单还勾着「已补货」——你盯着空架子以为饮料没了,其实只是没放回去。**
|
|
220
|
+
- 典型反例:`refreshVendorSidebar` 清空 `vendorAccounts` 缓存但 `expandedVendors` 仍为 true,展开的分组不触发重拉,显示空白像「账户全没了」。
|
|
221
|
+
- 自查:任何「清空缓存」操作,顺手列一遍哪些 UI 状态依赖这份缓存,对激活中的(展开/选中/滚动位置)逐个重拉或复位。
|
|
222
|
+
|
|
223
|
+
### ⑤ 加载状态用独立标记,别拿「数组长度/结果为空」推断
|
|
224
|
+
|
|
225
|
+
- ⚠️ 「是否已加载」要单独用一个布尔标记记录,不要用「数据长度 === 0」推断「还没加载」——真的空数据会被误判成「未加载」,导致重复请求或永不刷新。
|
|
226
|
+
- **类比:柜台空着 ≠ 没营业。营业了但没顾客,和没开门,是两回事,看柜台空判断会搞混。**
|
|
227
|
+
- 典型反例:`if (list.length === 0) fetch()`——真实 0 条数据时每次展开都重复请求;反过来列表非空时切 tab 又永不刷新计数。
|
|
228
|
+
- 自查:用 `loaded` 布尔标记区分「加载过(含空)」与「未加载」;不要用数据本身的有无/长度推断。
|
|
229
|
+
|
|
230
|
+
### ⑥ 白名单优先于黑名单:状态判断写「只允许 active」而非「排除 disabled」
|
|
231
|
+
|
|
232
|
+
- ⚠️ 判断某状态是否可用时,用白名单(`status === 'active'`)而非黑名单(`status !== 'disabled'`)——状态枚举一扩展,黑名单就漏放新状态。
|
|
233
|
+
- **类比:安检只查「名单上列的违禁品」会漏掉新违禁品;「只放行持有效票的人」才兜得住。**
|
|
234
|
+
- 典型反例:`.filter(v => v.status !== 'disabled')` 在将来新增第三种状态(如 `suspended`)时会被误放行,保存必被后端拒绝。
|
|
235
|
+
- 自查:凡「可选/可用/合法」判定,写成「只保留允许的那些」而不是「排除不允许的那些」。
|
|
236
|
+
|
|
191
237
|
## ⚠️ 数据链路改动核对铁律
|
|
192
238
|
|
|
193
239
|
- ⚠️ **给一个数据结构(interface/struct/DTO)新增或修改字段后,必须顺着这份数据流转的每一个转发/序列化点逐一核对,不能只改了数据结构定义或链路两端就算完成**。典型漏改位置是中间层"手写请求体字面量"(如 `JSON.stringify({ a, b })`、手动拼接的 `params`/`payload`),新增字段不会自动带过去,也不会报错,只会表现为"下游一直是空的"这种沉默失败。
|
package/rules/review-boundary.md
CHANGED
|
@@ -13,6 +13,7 @@ outputName: "review-boundary"
|
|
|
13
13
|
- 🔴 **会导致实际 bug / 数据丢失 / 数据错误 / 安全漏洞**:有明确触发路径(真实输入、真实调用链能复现),读 diff 能确认后果。
|
|
14
14
|
- 🟡 **真实风险**:有明确触发条件,且触发后后果严重(如并发竞态、错误被静默吞掉、未处理空值导致崩溃)。
|
|
15
15
|
- **违反本项目已固化规则**:AGENTS.base.md / AGENTS.private.md 里的 ⚠️ 铁律(DELETE 铁律、部署自包含、数据链路核对、环境配置禁止推断、测试环境验证前置等),diff 中明确违反 → 必须报。
|
|
16
|
+
- **外部事实核查确认的配置错误**:按「审查方法 → ④ 外部事实核查」查出的事实性错误(死域名、环境归属不一致、对外死值等),即使 diff 内部自洽也必须报——这正是「指向错误环境」类问题的唯一暴露途径(呼应「环境配置禁止推断」)。
|
|
16
17
|
|
|
17
18
|
### 禁止报(不是问题,报了只会拉长循环)
|
|
18
19
|
|
|
@@ -58,6 +59,16 @@ outputName: "review-boundary"
|
|
|
58
59
|
⚠️ PR 描述有「行为变更表 / 改前改后对比表」时,审查以它为靶心,逐行问「这条真的成立吗」,用 diff 逐行验证;PR 没列表的,review 意见至少覆盖「改了什么行为 / 不变的行为有没有被误伤」两个面。
|
|
59
60
|
⚠️ 对作者标注「不在本 PR 范围」的内容(如相关子系统计费、其它仓库同步):不要求作者修,但必须判断是否构成「已知缺口被静默放过」——已暴露的边界缺口,作者必须在 PR 里显式记录后续动作、或至少点明仍待处理,禁止「提了不等于了」。
|
|
60
61
|
|
|
62
|
+
**④ 外部事实核查(diff 之外的真实性验证)**
|
|
63
|
+
|
|
64
|
+
⚠️ diff 内逻辑审完了,对**部署脚本 / 配置 / 品牌 / 环境归属类改动**,还必须做一轮 diff 之外的「外部事实核查」——这类改动最常见的坑是「看着都对,其实指向了错误的环境」。核查不查 DNS / 不核对归属,纯看 diff 是发现不了的(PR #135 教训:AI review 漏掉的 namespace 归属、死域名、死邮箱,人工 reviewer 靠 `dig` + 读关联 PR 才发现)。按下面的清单逐项验证:
|
|
65
|
+
|
|
66
|
+
- **域名 / 子域名真实存在**:脚本或配置里出现的每个域名、派生子域名,逐个 `dig +short <域名>` 验证有解析记录;约定派生的(如 `doc.<domain>` / `api.<domain>`)不能只看主域成立就推断子域成立——不同子域可能有的解析有的无解析。无解析的死域名必须报(哪怕它只在注释里,也会误导后来者)。
|
|
67
|
+
- **环境归属一致(品牌 ↔ namespace ↔ 项目 ↔ 数据库 ↔ 域名)**:部署脚本里 `PROJECT` / `NACOS_NAMESPACE_ID` / Artifact Registry 仓库 / VPC / 数据库,和脚本服务的品牌、域名是否指向**同一个环境**。最危险的形态:脚本带了正确的域名和品牌,却指向**另一个品牌的库/namespace**——「看着是条正经路径」,实则从没跑过或指向错误环境。发现归属不一致,必须报并给出一致的目标配置。
|
|
68
|
+
- **跨 PR / 跨系统的共享与边界**:改动涉及「多系统共享」的配置(共用项目、共用 namespace、共用仓库)时,读关联 PR 描述、查配置中心真实归属,确认「刻意共用」还是「漏改」——禁止凭 diff 内自洽就默认成立。判定为刻意共用的,必须在脚本/注释里显式写明依据,否则视为漏配。
|
|
69
|
+
- **对外可见的真实值**:写进发布内容 / 对外页面 / 邮件 / 招聘启事等的邮箱、URL、联系方式,必须验证真实存在(邮箱查 MX 记录 `dig MX`、收件箱是否在域名邮件体系内;URL 查 DNS + HTTP 可达)。这类值一旦是死的,比控制台死链更难被发现(用户点了没反应,测试验收也难察觉)。
|
|
70
|
+
- **能查就查,禁止「推断即认可」**:能用一个命令查证的事实(`dig` / `gh pr view` / `curl` / 配置中心),必须查证后再下结论;查不到或需要人工确认的,明确写「待确认」并说明在哪查,禁止凭 diff 内自洽或惯性推断当结论。
|
|
71
|
+
|
|
61
72
|
**与审稿边界的配合**:本章找出的问题,是否上报仍按「必须报 / 禁止报」判断(真问题、真实风险、违反铁律 → 报;口味、防御、无法确认真假 → 不报)。每条意见必须带「新增量全局跟进」的链路证据(具体 diff 行 + 谁赋值谁读谁落库),禁止只给结论不给路径。
|
|
62
73
|
|
|
63
74
|
---
|
|
@@ -44,29 +44,59 @@ git push origin "$ORIGINAL_BRANCH"
|
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
# 确认本地功能分支与远程已同步(本地无未推送提交)
|
|
47
|
-
# 先 fetch 让 origin
|
|
48
|
-
# ⚠️
|
|
49
|
-
#
|
|
50
|
-
|
|
47
|
+
# 先 fetch 让 origin/* 引用全部最新,再用「分支的 upstream..HEAD」精确列举未推送提交
|
|
48
|
+
# ⚠️ 用全量 "git fetch origin" 而不是 "fetch origin <X>":单分支 fetch 只写 FETCH_HEAD/只更新该分支引用,
|
|
49
|
+
# ① 分支已 push 过但本地无远程跟踪引用(如 clone 后手动 push)时,后面 rev-parse --verify "origin/X" 会误判
|
|
50
|
+
# 「远程分支不存在」、把已推送提交误报为未推送;
|
|
51
|
+
# ② upstream 非同名分支(如 push -u origin other/branch)时,@{u} 指向的引用没被单分支 fetch 更新,
|
|
52
|
+
# git log @{u}..HEAD 基于过期引用计算,同样误判。
|
|
53
|
+
# 全量 fetch 一次更新 origin 下所有远程跟踪引用,以上两种场景都消除。
|
|
54
|
+
# ⚠️ 分支可能从未推送过(远程不存在该 ref),git fetch origin 对不存在的引用不会报错中断,
|
|
55
|
+
# 因此无需 || true 兜底;git status 在 fetch 后确认工作区状态
|
|
56
|
+
git fetch origin
|
|
51
57
|
git status
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
58
|
+
# 先选定「比对基准引用」,再分别取两个方向的差集:
|
|
59
|
+
# UNPUSHED = 本地有、基准没有(本地领先,未推送)
|
|
60
|
+
# UNPULLED = 基准有、本地没有(本地落后,未拉取)
|
|
61
|
+
# ⚠️ 基准优先级:origin/功能分支 > @{u} > origin/默认分支。
|
|
62
|
+
# 功能分支通常由 `git checkout -b feature/X origin/main` 创建(upstream=origin/main)、
|
|
63
|
+
# 再用 `git push origin feature/X` 推送(不设 upstream),此时 @{u}=origin/main,
|
|
64
|
+
# 用它做基准会把「功能分支相对 main 的全部新增提交」误报为未推送(前 26 轮就是这么误报的)。
|
|
65
|
+
# 全量 git fetch origin 之后 origin/功能分支 一定是最新,且它就是部署目标(PR 对应分支),
|
|
66
|
+
# 语义最准;@{u} 只在「功能分支从未以自己名字推送过」时作降级。
|
|
56
67
|
DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d: -f2 | tr -d ' ')
|
|
57
|
-
if git rev-parse --
|
|
58
|
-
|
|
59
|
-
elif git rev-parse --
|
|
60
|
-
|
|
68
|
+
if git rev-parse --verify "origin/$ORIGINAL_BRANCH" >/dev/null 2>&1; then
|
|
69
|
+
BASE_REF="origin/$ORIGINAL_BRANCH"
|
|
70
|
+
elif git rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then
|
|
71
|
+
BASE_REF="@{u}"
|
|
61
72
|
elif git rev-parse --verify "origin/$DEFAULT_BRANCH" >/dev/null 2>&1; then
|
|
62
|
-
|
|
73
|
+
BASE_REF="origin/$DEFAULT_BRANCH"
|
|
63
74
|
else
|
|
64
|
-
|
|
75
|
+
# 远程连功能分支与默认分支引用都没有(全新仓库/远程为空)→ 当前分支必然从未推送。
|
|
76
|
+
# ⚠️ 不能用 git log HEAD 兜底:会把 main 全部历史也算进来,输出上千行淹没真正关心的提交;直接阻断并提示。
|
|
77
|
+
echo "❌ 远程找不到 ${ORIGINAL_BRANCH} 与 ${DEFAULT_BRANCH} 分支引用,无法比对推送状态;请先 git push origin ${ORIGINAL_BRANCH} 再部署"
|
|
78
|
+
exit 1
|
|
65
79
|
fi
|
|
66
|
-
|
|
80
|
+
UNPUSHED=$(git log "$BASE_REF"..HEAD --oneline)
|
|
81
|
+
UNPULLED=$(git log HEAD.."$BASE_REF" --oneline)
|
|
82
|
+
# ⚠️ 存在未推送提交时必须 exit 1 阻断,不能只打警告继续走部署流程——否则未推送到 PR 分支的代码会被合入/部署到 test,
|
|
83
|
+
# 导致 PR 分支与测试环境代码不一致(违背上文「必须确认所有 review 修复已推送再进行 test 合并」的硬性要求)。
|
|
84
|
+
[ -z "$UNPUSHED" ] && echo "✅ 无未推送提交" || {
|
|
85
|
+
echo "❌ 存在未推送提交:"
|
|
86
|
+
echo "$UNPUSHED"
|
|
87
|
+
exit 1
|
|
88
|
+
}
|
|
89
|
+
# ⚠️ 反向检查本地是否落后于远程:只查 UNPUSHED 会漏掉「本地落后」——远程 PR 分支已有别人/其他环境推的新提交、
|
|
90
|
+
# 而本地还是旧 HEAD 时,git log @{u}..HEAD 为空 → 误报「✅ 无未推送提交」→ 把本地旧代码合进 test 部署,
|
|
91
|
+
# 测试环境跑的不是 PR 最新代码。必须同样阻断,先 git pull 再部署。
|
|
92
|
+
[ -z "$UNPULLED" ] && echo "✅ 本地不落后于远程" || {
|
|
93
|
+
echo "❌ 本地分支落后于远程(远程有本地没有的提交),请先 git pull 再部署:"
|
|
94
|
+
echo "$UNPULLED"
|
|
95
|
+
exit 1
|
|
96
|
+
}
|
|
67
97
|
```
|
|
68
98
|
|
|
69
|
-
⚠️
|
|
99
|
+
⚠️ 比对基准用 `origin/$ORIGINAL_BRANCH`(PR 对应分支的远程引用)而不是 `@{u}` 优先:功能分支通常由 `git checkout -b feature/X origin/main` 创建、再 `git push origin feature/X` 推送(无 `-u`),此时 `@{u}` 仍指向 origin/main,用它做基准会把「功能分支相对 main 的全部新增提交」误报为未推送;而全量 `git fetch origin` 之后 `origin/$ORIGINAL_BRANCH` 就是 PR 分支最新状态,用它对比双向差集(`origin/$ORIGINAL_BRANCH..HEAD` 未推送、`HEAD..origin/$ORIGINAL_BRANCH` 未拉取)语义最准。`@{u}` 仅在功能分支从未以自己名字推送过(origin/$ORIGINAL_BRANCH 引用不存在)时作降级。⚠️ `git log origin/X..HEAD` 对**不存在的引用会直接 fatal**(而不是返回空),因此先用 `git rev-parse --verify "origin/X"` 探测;功能分支、`@{u}` 与默认分支引用都不存在(全新仓库)时无法比对,直接阻断要求先推送。
|
|
70
100
|
|
|
71
101
|
- ⚠️ 若 review 修复还没合进当前功能分支 → 先把修复 commit 到功能分支再推送,禁止带着"修复只在 test 分支"的状态去部署。
|
|
72
102
|
- ⚠️ feature 分支与 test 分支两条线必须保持同步:**所有 review 修复代码必须先落 feature 分支(PR 分支),再合并到 test**,缺一不可。
|
|
@@ -43,9 +43,13 @@ echo "最新 review 审查 commit: $LATEST"
|
|
|
43
43
|
echo "当前 HEAD: $CUR"
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
`check-review.sh` 返回的 LATEST 只回答「最新一轮 AI review **审查了哪个 commit**」,它只证明「bot 已到达当前 HEAD」,**不**证明「该轮 review 里没有值得修的问题」。因此「LATEST = CUR」只是**必要条件**,不能单独作为「循环 review 已收敛」的判据。
|
|
47
47
|
|
|
48
|
-
⚠️
|
|
48
|
+
⚠️ 收敛判据必须是两条**同时满足**:
|
|
49
|
+
1. **review 已到达**:`LATEST` 等于 `CUR`(或等于 /loop-review 最后处理过的 commit),否则 bot 还没审到最新 push,循环 review 尚未完成;
|
|
50
|
+
2. **该轮已无值得修的新问题**:打开最新一条 review 的正文(`gh pr view $PR --json reviews -q '.reviews[-1].body'`,或 `/loop-review` 的抓取命令),逐条核对——所有值得修的问题都已修复,剩余问题均已有明确的 Won't fix / 误报 / 已处理回复。只要还有一条值得修而未处理的问题,就必须回到 `/loop-review` 修复后重新推送,**禁止**把它带进发布收尾闭环。
|
|
51
|
+
|
|
52
|
+
⚠️ 不要用 `gh pr view --json comments -q '.comments | length'`(评论总数)判断闭环——评论数与「循环 review 是否收敛」无对应关系,验证不了。判断依据是「最新 review 是否针对当前 HEAD + 该轮是否已无值得修的新问题」,即 `/loop-review` 步骤 1 的同一套逻辑。若循环 review 还没走完,**先补跑 `/loop-review`,不得跳过**。
|
|
49
53
|
|
|
50
54
|
### 2. 确保最新代码已推送
|
|
51
55
|
|
|
@@ -109,6 +109,13 @@ function jpegSize(buf) {
|
|
|
109
109
|
continue;
|
|
110
110
|
}
|
|
111
111
|
const marker = buf[pos + 1];
|
|
112
|
+
// JPEG 规范允许 marker 前缀 0xFF 与 marker 字节之间出现任意多个 0xFF 填充字节。
|
|
113
|
+
// 遇到 FF FF D8 这类序列时 marker === 0xff,若不跳过会把 marker 当段长读、pos 跳到随机偏移,
|
|
114
|
+
// 甚至可能在错误偏移上命中 0xC0..0xCF 返回假宽高,导致画布/标注坐标整体错位。这里 pos++ 继续,把第二个 FF 当新前缀。
|
|
115
|
+
if (marker === 0xff) {
|
|
116
|
+
pos++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
112
119
|
// 无长度字段的标记段:SOI(FFD8)/EOI(FFD9)/TEM(FF01)/RSTn(FFD0-D7) 等,跳过 2 字节即可。
|
|
113
120
|
// 若不跳过会把后续两字节误当段长,pos 跳到错误位置,甚至解析出错误的宽高。
|
|
114
121
|
if (
|
|
@@ -209,6 +216,30 @@ function detectSize(filePath, buf) {
|
|
|
209
216
|
);
|
|
210
217
|
}
|
|
211
218
|
|
|
219
|
+
// 按文件内容(magic bytes)判断 MIME,避免依赖扩展名——扩展名可能与实际内容不符
|
|
220
|
+
// (如 JPEG 内容误命名 .png),此时若按扩展名生成 data:image/png;base64 会渲染异常。
|
|
221
|
+
// 各魔数探测函数已按内容识别格式,直接复用它返回的类型;找不到魔数才回退扩展名(sips 兜底场景)。
|
|
222
|
+
function detectMime(buf, filePath) {
|
|
223
|
+
if (buf.length >= 8 && buf.readUInt32BE(0) === 0x89504e47) return "image/png";
|
|
224
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
|
|
225
|
+
if (
|
|
226
|
+
buf.length >= 12 &&
|
|
227
|
+
buf.toString("ascii", 0, 4) === "RIFF" &&
|
|
228
|
+
buf.toString("ascii", 8, 12) === "WEBP"
|
|
229
|
+
) {
|
|
230
|
+
return "image/webp";
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
buf.length >= 6 &&
|
|
234
|
+
(buf.toString("ascii", 0, 6) === "GIF87a" || buf.toString("ascii", 0, 6) === "GIF89a")
|
|
235
|
+
) {
|
|
236
|
+
return "image/gif";
|
|
237
|
+
}
|
|
238
|
+
if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "<svg") return "image/svg+xml";
|
|
239
|
+
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) return "image/bmp";
|
|
240
|
+
return extMime(filePath);
|
|
241
|
+
}
|
|
242
|
+
|
|
212
243
|
// ---------------------------------------------------------------- HTML 安全
|
|
213
244
|
function escapeHtml(s) {
|
|
214
245
|
return String(s)
|
|
@@ -233,13 +264,21 @@ function extMime(filePath) {
|
|
|
233
264
|
}
|
|
234
265
|
|
|
235
266
|
// ---------------------------------------------------------------- 布局工具(生成 SVG 常量)
|
|
236
|
-
//
|
|
267
|
+
// 文字排版:按字符近似估算宽度(汉字/全角≈1.0em,拉丁≈0.55em,常用符号/emoji≈全宽)
|
|
237
268
|
function estimateTextMetrics(text, fontSize) {
|
|
238
269
|
const chars = [...text];
|
|
239
270
|
let w = 0;
|
|
240
271
|
for (const ch of chars) {
|
|
241
272
|
const code = ch.codePointAt(0);
|
|
242
|
-
|
|
273
|
+
// 全宽判定:汉字/全角(≥0x2E80)按 1em;
|
|
274
|
+
// 常用符号/emoji 实际渲染接近全宽,也必须按 1em 估——按半宽会撑破文本框被 overflow:hidden 裁掉
|
|
275
|
+
// (SKILL.md 示例 --label "✅ 修复后" 就含这类字符):
|
|
276
|
+
// - 箭头/数学/杂项符号等 0x2190-0x2BFF(含 ✅ U+2705、⚠ U+26A0、→ U+2192)
|
|
277
|
+
// - 0x1F300+ 各 Emoji 区段
|
|
278
|
+
// 拉丁/数字/标点(<0x2190)才按半宽 0.55em。
|
|
279
|
+
const fullWidth =
|
|
280
|
+
code >= 0x2e80 || (code >= 0x2190 && code <= 0x2bff) || code >= 0x1f300;
|
|
281
|
+
const width = fullWidth ? fontSize : fontSize * 0.55;
|
|
243
282
|
w += width;
|
|
244
283
|
}
|
|
245
284
|
return { width: Math.ceil(w) + 2 * fontSize, height: Math.ceil(fontSize * 1.7) };
|
|
@@ -319,7 +358,18 @@ function buildHtml({
|
|
|
319
358
|
break;
|
|
320
359
|
}
|
|
321
360
|
}
|
|
322
|
-
|
|
361
|
+
// 目标靠近页面顶部/底部且文本较宽时,四个候选可能全部越界。此时不能直接丢出越界的 cands[0]
|
|
362
|
+
// (body overflow:hidden + 固定画布会把越界部分裁掉,文本框和箭头一起看不见——目标在首屏是最常见场景)。
|
|
363
|
+
// 兜底:取 up 候选并把 bx/by clamp 进【画布】(W×H,允许压到 margin 留白区,但绝不越出画布)。
|
|
364
|
+
// ⚠️ clamp 上界用画布尺寸 W/H 而非图片区(margin+imageW):文本框比图片还宽时,
|
|
365
|
+
// 用图片区上界 margin+imageW-bw 会算出负值,clamp 后仍越界;用 W-bw 保证至少放进画布。
|
|
366
|
+
// bw 比画布还宽的极端情况(超长文本),上界取 0,框左缘贴画布左缘,文字溢出由画布裁,但框和箭头至少可见。
|
|
367
|
+
if (!slot) {
|
|
368
|
+
const bx0 = cands[0].bx, by0 = cands[0].by;
|
|
369
|
+
const bxMax = Math.max(0, W - bw);
|
|
370
|
+
const byMax = Math.max(0, H - bh);
|
|
371
|
+
slot = { ...cands[0], bx: Math.min(Math.max(0, bx0), bxMax), by: Math.min(Math.max(0, by0), byMax) };
|
|
372
|
+
}
|
|
323
373
|
|
|
324
374
|
const hasTarget = a.w > 0 && a.h > 0;
|
|
325
375
|
const hasText = Boolean(a.text);
|
|
@@ -355,7 +405,11 @@ function buildHtml({
|
|
|
355
405
|
const dx = tx - sx;
|
|
356
406
|
const dy = ty - sy;
|
|
357
407
|
const len = Math.sqrt(dx * dx + dy * dy);
|
|
358
|
-
|
|
408
|
+
// ⚠️ 只在 hasText 时画箭头:text 是可选字段({x,y,w,h} 只想画高亮框的用法),
|
|
409
|
+
// 无 text 时 bw=bh=0、slot 落在目标中心,此时画箭头等于从目标中心画一条无来源说明的线,
|
|
410
|
+
// 且 slot.name 兜底为 "up" 会让箭头起点「避开文本框」的偏移逻辑指向一个不存在的框,
|
|
411
|
+
// 生成无标签来源的误导性标注。因此无 text 时只画高亮框、不画箭头。
|
|
412
|
+
if (hasText && len > 1) {
|
|
359
413
|
// 起点避开文本框:上/下方位沿垂直外移 bh/2,左/右方位沿水平外移 bw/2
|
|
360
414
|
const halfExtent = slot.name === "up" || slot.name === "down" ? bh * 0.5 : bw * 0.5;
|
|
361
415
|
const star = Math.min(1, Math.max(0, (halfExtent + a.lw * 2) / len));
|
|
@@ -468,6 +522,12 @@ function main() {
|
|
|
468
522
|
console.error("❌ --json 必须是标注数组");
|
|
469
523
|
process.exit(1);
|
|
470
524
|
}
|
|
525
|
+
// ⚠️ 校验元素类型:--json '[null]' 或含字符串/数字元素时,后面 a.x/a.y 会抛未捕获 TypeError(英文堆栈),
|
|
526
|
+
// 与其他参数错误的「❌ 中文提示 + exit 1」风格不一致。必须在换算前统一拦截。
|
|
527
|
+
if (annotations.some((a) => a === null || typeof a !== "object")) {
|
|
528
|
+
console.error("❌ --json 数组的每个元素都必须是对象({x,y,...}),不能含 null 或非对象元素");
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
471
531
|
}
|
|
472
532
|
|
|
473
533
|
// 坐标换算:CSS 坐标 → 设备像素
|
|
@@ -478,6 +538,26 @@ function main() {
|
|
|
478
538
|
// ⚠️ fullPage 下绝不能按 imageH/viewport.h 缩放(viewport.h 只是视口高,不是整页高,会把 Y 轴放错)。
|
|
479
539
|
let scaleX = 1;
|
|
480
540
|
let scaleY = 1;
|
|
541
|
+
// ⚠️ --viewport 被 parseArgs 置为 true(后面紧跟另一个 --xxx 或位于末尾 = 没带值)时,
|
|
542
|
+
// 必须报错而不是静默跳过换算——否则坐标按设备像素走、用户却以为按视口换算了,标注整体错位且无任何提示。
|
|
543
|
+
if (keyed.viewport === true) {
|
|
544
|
+
console.error('❌ --viewport 必须显式传值 "cssW,cssH"(如 --viewport "1440,900"),当前为无值参数');
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
// ⚠️ --fullpage 与 --viewport 是互斥的两种换算模式(DPR 等比 vs 截图/视口比例),同传时按谁走都会让另一方的坐标全歪且无提示。
|
|
548
|
+
// 必须在代码里 exit 1,不能靠文档约束(SKILL.md 里虽强调"禁止 fullPage 用 --viewport",但使用者仍可能同传)。
|
|
549
|
+
if (keyed.fullpage && keyed.viewport && keyed.viewport !== true) {
|
|
550
|
+
console.error("❌ --fullpage 与 --viewport 互斥,不能同时使用");
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
// ⚠️ --dpr 只对 --fullpage 模式生效(整页 CSS 坐标按 DPR 等比缩放)。默认/--viewport 模式下坐标已按
|
|
554
|
+
// 设备像素或「截图宽/视口宽」换算,--dpr 不参与计算;此时传 --dpr 会被静默忽略、scale 保持 1,
|
|
555
|
+
// 标注整体按 1 倍画出来且无任何提示——正是本脚本要根治的「静默错位」。与其他参数校验一致 fail-fast,
|
|
556
|
+
// 不能靠文档约束(SKILL.md 里 --fullpage --dpr N 连写,使用者漏打 --fullpage 就会命中)。
|
|
557
|
+
if (keyed.dpr !== undefined && !keyed.fullpage) {
|
|
558
|
+
console.error("❌ --dpr 只在 --fullpage 模式下生效;非 fullPage 模式坐标已按设备像素/视口换算,无需 --dpr。请移除 --dpr 或补上 --fullpage。");
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
481
561
|
if (keyed.fullpage) {
|
|
482
562
|
// fullPage 模式依赖 DPR 把整页 CSS 坐标换算成设备像素。缺 --dpr 时 DPR=1 是合法场景
|
|
483
563
|
// (fullPage 截图恰好 DPR=1),但显式传了非正数/非数字必须报错,禁止静默降级导致标注整体偏移。
|
|
@@ -509,6 +589,19 @@ function main() {
|
|
|
509
589
|
process.exit(1);
|
|
510
590
|
}
|
|
511
591
|
}
|
|
592
|
+
// 必填坐标校验:x/y 是标注定位的必填字段,缺失/非法值时若静默归零会把箭头/框画到左上角且无任何提示
|
|
593
|
+
// (正是本脚本要根治的「静默错位」)。w/h 是可选(缺省 0 = 只画点不画框),不在此列。
|
|
594
|
+
for (const a of annotations) {
|
|
595
|
+
for (const f of ["x", "y"]) {
|
|
596
|
+
const v = a[f];
|
|
597
|
+
if (v === undefined || v === null || v === "" || v === true || !Number.isFinite(Number(v))) {
|
|
598
|
+
console.error(
|
|
599
|
+
`❌ 标注数组第 ${annotations.indexOf(a) + 1} 项的 "${f}" 缺失或非法(收到: ${JSON.stringify(v)}),x/y 必须是有穷数字`,
|
|
600
|
+
);
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
512
605
|
annotations = annotations.map((a) => ({
|
|
513
606
|
...a,
|
|
514
607
|
x: num(a.x, 0) * scaleX,
|
|
@@ -520,7 +613,8 @@ function main() {
|
|
|
520
613
|
const marginFrac = num(keyed["margin-frac"], 0.05);
|
|
521
614
|
const html = buildHtml({
|
|
522
615
|
imagePath,
|
|
523
|
-
|
|
616
|
+
// MIME 由文件内容魔数决定而非扩展名,避免「JPEG 内容误命名 .png」生成 data:image/png;base64 渲染异常
|
|
617
|
+
imageMime: detectMime(buf, imagePath),
|
|
524
618
|
imageW,
|
|
525
619
|
imageH,
|
|
526
620
|
annotations,
|