Compare commits

...

119 Commits

Author SHA1 Message Date
huangzd1997 5b8105ec2b feat(后台管理): 实体管理列表统一展示创建时间/更新时间
用户管理、菜单管理、不符合ASIN、数据去重总数据、查询ASIN、最低价ASIN、
商品类目、密钥管理共 8 个实体管理列表补齐两列。分组管理/店铺密钥/店铺管理
此前已带创建+修改时间,任务列表与统计报表(撞款监控、密钥用量、日志、
记录与版本)不含实体更新语义,均未改动。

关键点——时间列必须由数据库维护,否则新列是假的:
这些表的更新走 selectById → 改字段 → updateById,实体带着读出的旧
updated_at 一起写回。MySQL 规则是「UPDATE 显式给某列赋值时不触发该列的
ON UPDATE 自动更新」,不禁写就会把旧值写回去,更新时间永远冻结在首次写入
时刻。按 V125(biz_file_result)既有样板,给 7 个实体标注
@TableField(insertStrategy=NEVER, updateStrategy=NEVER)。

- V131:users / columns 补 updated_at(幂等 ADD COLUMN,仿 V125 写法)。
  存量行被回填为迁移执行时刻,非真实历史变更时间(历史上无记录,无法还原)
- 实体/VO:AdminUserEntity、PermissionMenuEntity、InvalidAsinDataEntity、
  DedupeTotalDataEntity、ProductCategoryEntity、QueryAsinEntity、
  SkipPriceAsinEntity 加/改写 updatedAt;AdminUserItemVo、PermissionMenuItemVo、
  InvalidAsinDataItemVo、DedupeTotalDataItemVo 补 updatedAt;
  AdminUserSecretRowVo 补 createdAt(行级首次配置时间 = 三模块最早)
- 查询ASIN/最低价ASIN 后端 VO 与前端 model 本就有两字段,仅补渲染
- 前端 8 页表格加列,同步修正空态/加载行的 colspan(手写表格,不同步会错位)
- 测试:align-query-asin / align-skip-price 原断言「不允许有更新时间列」
  (像素复刻旧版),按新需求改为断言两列存在;新增 e2e list-time-columns
  覆盖 8 页表头与真实时间值渲染
2026-09-19 15:53:01 +08:00
huangzd1997 5fcb449946 chore(客户端更新日志): 补 4.0.31 条目(购物车未知时最低价也生效) 2026-09-19 15:39:20 +08:00
huangzd1997 ff68426c69 chore(客户端更新日志): 补 4.0.30 条目(跟价最低价跌破自动调回) 2026-09-19 15:24:30 +08:00
huangzd1997 46be044121 fix(实例路由): 转发失败不再伪装成「任务不存活」
归属节点滚动重启时跨节点转发会连接失败,原来返回 ApiResponse.fail(40903) ——
HTTP 200 + data:null。客户端把「拿不到数据」解析成 alive=false,于是把健康的长
任务主动停掉(2026-09-18 任务 28616:跑到 66/253 被自杀,只跑到第 2/6 页)。

改为 503 + 空 body:新客户端按状态码判未知继续跑;老客户端因 body 不是 JSON、
resp.json() 抛异常也落到未知——两边都不会再把未知当成死。顺带让这类故障在
HTTP 指标里可见(原先记成 200,滚动重启期间丢了多少心跳监控完全看不到)。
2026-09-18 16:25:33 +08:00
huangzd1997 e0303f9cba feat(上架): 店铺互斥改为按(设备,店铺),同店可在不同客户端并行跑不同国家
背景:紫鸟浏览器会话是每台客户端一份,同一家店可以在两台机器上并行;
国家是会话内的可切换状态(SwitchingCountries,断线重连还会再切),
所以真正必须串行的只有"同一台设备上的同一家店"。原来的全局按店铺互斥
把跨机器的合法并行也挡了(2026-09-18 任务 28624 被 28616 误挡)。

- V130:biz_publish_file 增加 device_id 列
- AdminAuthSupport.currentDeviceId():只读 JWT 签名的 deviceId claim,
  不信任客户端可控的 X-Device-Id 请求头
- activateFile 互斥键改为 (device_id, shop_name);device 为空(旧客户端
  token 无该 claim / 内部令牌调用)时退回全局店铺互斥,保守不放宽;
  存量 RUNNING 行的 NULL device 视为"来源不明",同样保守拦到跑完为止
- 前端与客户端无需改动:页面 axios 对 /newApi 已带 Authorization Bearer
2026-09-18 16:08:08 +08:00
huangzd1997 9d39705c77 fix(采集数据): 品牌检测移出任务锁 + 失败任务仍产出可下载的部分结果
taskId 28599 事故根因:分片回传在任务锁内同步做品牌检测,上游 16890 卡死时
单次持锁 103.5 秒(costMs=103556),期间该任务的心跳与客户端重试全部撞
40902「任务正在处理」,客户端 5 次重试预算耗尽后中止了整个采集。

- submitResult:行归一化 / 去重过滤 / 品牌检测移到任务锁外,锁内只保留落库与终态
- BrandCheckClient:整批加 90 秒总耗时上限(超时按查询失败降级,首轮始终执行),
  单次回传时延从「无上界」收敛到约 90 秒 + 一次读超时
- 失败且已收到分片时照常组装部分结果;任务已 FAILED 则保留失败态与真实失败原因,
  只补结果文件(乐观写结果行 + 条件更新不匹配再补偿写回,避免崩溃时留下「成功但无下载」)
- productrisk / shopmatch 按 pricetrack 的 preserveFailure 样板补齐,含组装落地时
  不再把失败行「洗成成功」
- 前端:采集 / 跟价 / 产品风险 / 店铺匹配的下载按钮放宽到失败任务
2026-09-18 15:53:15 +08:00
huangzd1997 986df86e89 fix(任务恢复): 停止意图优先于自动续跑 + 未覆盖模块的中断可见
自查上一提交时发现两处遗漏:

- 用户点了「停止循环」而子任务恰好因客户端中断失败时,续派逻辑跑在停止检查之前,
  会把循环从停止意图里拉回 RUNNING 自己转起来。改为停止请求优先:直接 STOPPED。
- 模块不支持自动续跑的中断任务此前完全无痕迹(用户只看到失败)。
  这类模块(上架/改价/审批/商品管理采集/跟价非循环任务)的续跑载荷需要用户在页面上
  选的执行参数(ziniao_version 等),而这份选择只存在于派发那一刻的浏览器里、没落到
  request_json —— 自动重排队会用错参数,所以它们**不纳入**续跑(白名单的设计原则就是
  「执行参数全在服务端」)。现补一条计数查询把这类中断数量带进 stale-check summary
  的 resume(u=N),运维据此人工重跑;将来把参数回写落库后即可纳入白名单。

测试:PriceTrackLoopRunServiceTest 新增「已请求停止时中断失败不续派而是停止」,
TaskResumeServiceTest 新增「统计不支持续跑的中断任务数」。
2026-09-18 15:48:33 +08:00
huangzd1997 bd359411a9 feat(任务恢复): 客户端中断的任务自动续跑(保留失败记录 + 重新排队)
2026-09-18 任务 28587(跟价,uid 977 店铺「张美莺」)在客户端 09:51/09:57 被更新脚本
重启后中断,任务一直挂 RUNNING 到 stale 兜底;跟价循环还有第二重问题——子任务失败
即整个循环判 FAILED,一次客户端更新就让无限循环彻底停下。

契约:中断任务**保留失败记录**(用户看得到「因客户端重启中断」),同时由服务端自动
重新排队续跑,长任务不再因为一次更新整个白跑。

- V129:biz_file_task 加 resume_of_task_id / resume_attempt,
  biz_price_track_loop_run 加 resume_attempt(代数封顶用);
- 新增 TaskResumeService:扫描最近 30 分钟内因客户端中断而失败、未续跑过、代数未超限的
  任务,复制请求参数重新排队成 PENDING,交给已有兜底拉取通道(客户端每分钟 pull-pending)
  领走执行——不新造第二套派发机制。只对注册了 ClientTaskPullSpi 的模块生效
  (相似ASIN/采集/外观专利),上架/改价等写操作模块刻意排除,避免盲目重跑;
- 幂等:按 resume_of_task_id 反查,同一原任务不会重复排队;
- 挂在 stale-check 巡检线(每 2 分钟、有分布式锁),并用隔离壳包住异常:
  续跑失败绝不拖垮判死主流程(判死优先级更高);
- PriceTrackLoopRunService:子任务因「客户端异常中断」失败时不终止循环,
  清 active_task_id 后保持 RUNNING,客户端下次 dispatchNext 拿到同一店铺/同一轮,
  即原地续跑(页内已处理 ASIN 由服务端 skip_asins 去重,不会重复改价);
  连续重派超过 3 次才真正判失败,避免会话持续不可用时无限重开浏览器
  (28587 就是这样白烧了 5 小时)。子任务成功一轮后计数归零;
- application.yml:aiimage.task-resume.enabled 默认跟随 client-task-pull 开关
  (兜底拉取关着时续跑任务无人领取,只会积压被告死)。

测试:TaskResumeServiceTest 6 例(排队/幂等/开关/无实现/插入失败/无归属用户)、
PriceTrackLoopRunServiceTest 新增 4 例(中断续跑/达上限/非中断仍终止/成功归零),
连带更新 DeleteBrandStaleTaskServiceTest 与 TaskModuleCoverageTest 的构造参数。
2026-09-18 15:42:07 +08:00
huangzd1997 3634ea1d62 feat(撞款): 采集明细落库后自动触发重扫,重复检查无需等次日 00:00
- shopdatacrawl 新增 DuplicateCheckRefreshPort 端口,TaskService 在明细落库成功后请求重扫
- shopduplicatecheck 新增 DuplicateCheckRefreshScheduler:10s 合并窗口去抖、单飞、
  扫描锁忙自动重试(最多 6 次),失败不影响采集归档
- 复用现有 SCAN_LOCK,与定时扫描、手动「重新分析」互斥;新增 5 个调度器单测
2026-09-17 23:08:15 +08:00
huangzd1997 3ce0569c59 fix(提示语): 失败提示优先展示后端真实原因,修正 10+ 处误导文案
上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台:
- 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗)
- 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING
- 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案
- dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
2026-09-17 23:02:15 +08:00
huangzd1997 d2d95f0b71 feat(上架): 激活时按店铺互斥,同一店铺不允许两个任务同时跑
2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个
任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。

- PublishTaskService.activateFile:激活前查是否已有其他任务在同一店铺 RUNNING,
  有则拒绝并带出占用中的任务号(激活是任务真正开跑的唯一入口,能最早拦住);
  校验与更新之间仍有极小竞态窗口,真正串行由客户端店铺锁保证,这层负责尽早提示;
- 前端 BrandPublishTab:文件派发失败的原因回显给用户。原来错误只写进日志、提示
  固定为"已记录并继续",用户会误以为是文件问题而反复重传,看不到真正原因;
- 新增 3 个后端单测:同店铺被占用则拒绝、无占用则放行、店铺名为空跳过校验。
2026-09-17 15:48:51 +08:00
huangzd1997 2a51006888 feat(前端防连点): 工具页提交按钮加全局冷却,并精简 4.0.28 更新日志
各工具页的「开始上架 / 启动任务 / 匹配店铺」按钮在提交逻辑跑完后立刻恢复可点,
手快连点会重复发起(2026-09-17 上架事故:同一店铺被接连提交三次,服务端并存多个
同店铺任务,客户端并发打开同一店铺时全部失败)。

- 新增 shared/utils/submit-guard.ts:冷却按按钮元素各自计算(WeakMap,点 A 不影响
  B),在捕获阶段拦截、抢在 Vue 的 @click 之前;冷却自首次有效点击起算,被拦的
  点击不会把冷却越拖越长;
- main.ts 全局安装 installSubmitGuard(),覆盖所有 .btn-run 主按钮,页面零改动;
- 13 个单测覆盖边界:不同按钮互不影响、被拦不延长冷却、非法时长回落默认等;
- client-changelog 每条精简到 40 字以内(超出面板显示不下,已有守护测试)。
2026-09-17 15:41:41 +08:00
huangzd1997 a89de129ea chore(客户端更新日志): 追加 4.0.28 条目
4.0.28 修复两个用户可感知的问题:
- 多个任务同时操作同一店铺导致「打开店铺失败」→ 同一店铺改排队执行;
- 紫鸟更新内核期间直接报「打开店铺失败」→ 改为等待更新完成并显示进度。
2026-09-17 15:32:47 +08:00
huangzd1997 ddefcbed56 feat(任务恢复): 外观专利接上「补传恢复」;合并冲突保留兜底对象并可诊断
28459 的两个遗留项:
1) 外观专利缺「补传恢复」入口——分片缺失致组装 job 重试耗尽后,客户端补传缺口
   也无法自动重跑,只能人工重置 job。TaskFileJobService 早有
   resetTerminalFailedForRecovery,但只被删除品牌模块接了。现按同一口径在分片
   提交成功后检查并恢复;best-effort,恢复失败不影响补传本身。
2) 合并 CAS 冲突不可诊断且会删掉唯一的兜底对象——原实现每次冲突都删掉刚写入的
   版本化对象,重试耗尽即抛异常、行仍指向旧指针;一旦旧对象也不在,该分片永久
   读不到(28459 的 chunk-462/473/484 正是这个形态)。现在冲突时读回行上当前
   哈希并写进异常与日志;终局失败保留最后一个对象,作为读路径「同槽位兄弟对象」
   兜底的恢复源。外观专利与相似ASIN 同一口径。

测试:新增 17 个用例(补传恢复 8 / 外观专利合并冲突 5 / 相似ASIN 合并冲突 4),
RED 均已确认;全量 mvn test 3099 个 0 失败。
2026-09-17 11:04:38 +08:00
huangzd1997 3137299bfe fix(临时载荷): 收口删除守卫 + chunk 读兜底,修「对象被误删→任务永久失败」
线上任务 28459(外观专利)三个分片的载荷对象被删、DB 行仍指向已删的确定性
key,组装读不到 → 整单 FAILED。数据其实还在同槽位的版本化对象里,是行指错了。

三条收口:
- 上传补偿删除加准入:只有末段带 UUID 的版本化 key(本次写入独占)才允许入队。
  确定性 key 会被重传复用,删它就删掉了行仍在引用的对象;而删除队列
  (deleteObjectFromRetry -> removeObject)本身不做引用反查,准入必须卡在入队处。
- 引用守卫阈值 >1 收紧为 >0:原来把「恰好 1 行引用」当作调用方自己那行而放行,
  但调用方无法证明归属。物理删除本就约定在 DB 行删除之后执行,正常路径引用数
  必为 0;宁可留孤儿(有保留期清理兜底),也不删可能仍被引用的对象。
- chunk 读兜底:指针对象确已缺失(NoSuchKey)时回退同槽位版本化对象 chunk-N-*。
  仅限 chunk 槽位 + 确为缺失两个条件,避免误配无关对象或掩盖真实故障。

测试:新增 31 个用例(补偿删除准入 / 引用守卫 / 兄弟对象兜底),均先确认 RED
再实现;另更新 3 个断言旧行为的既有用例。全量 mvn test 3082 个 0 失败。
2026-09-17 10:42:53 +08:00
huangzd1997 1fe3368c5a perf(rustfs): 重试基础退避 500ms→200ms
线上高频的重试诱因是 unexpected end of stream——那是**立即失败**(RustFS 重置连接后
OkHttp 读响应即报错),不是等超时,500ms 基本是白等;每天上千次累计十几分钟。
降到 200ms 保留退避语义(真遇服务端过载仍会让步),又不让用户多等。
2026-09-17 01:55:35 +08:00
huangzd1997 7aea3a0a50 fix(rustfs): 修 unexpected end of stream 根因——零长度请求体缺 Content-Length
抓包定位(90 秒内 515 次 HTTP 411 Length Required):OkHttp 对长度为 0 的请求体
不写 Content-Length,请求于是既无 Content-Length 也无 Transfer-Encoding——HTTP/1.1
不允许这样,RustFS 直接回 411;客户端读到不完整响应就报 unexpected end of stream
再重试。业务里上传空内容(content 为 null/空)是常态,所以每天上千次。

修复用 network interceptor 在协议层补 Content-Length: 0:普通 interceptor 里加的
头会被 BridgeInterceptor 按 body 长度覆盖,加了不生效。

实测:部署后同样 90 秒抓包,411 从 515 次降到 0,两节点「确定性错误不重试」归零。
此前排除的公网链路、keepAlive、连接复用三项确实都不是原因——抓包才是对的入口。
2026-09-17 01:36:55 +08:00
huangzd1997 188aedec84 fix(任务收尾): 打破「数据永久缺失 → 卡死恢复无限重建 job」的死循环
线上任务 28459(appearance_patent)卡在 RUNNING:chunk 462/473/484 的 payload
对象已不存在,组装失败 → stale recovery 每 30 秒重建一次 assemble job 再失败,
而恢复过程又会刷新任务心跳、任务永远判不出「卡死」,48 分钟空转近百轮。

- appearance-patent:读 chunk 时若 payload 对象已不存在(RustFS NoSuchKey),
  跳过该分片让任务按已有分片出部分结果,不再抛异常把组装永久拖死;
  similar-asin 在原有 typeMismatch 跳过分支旁补同款处理
- TaskFileJobService.hasExhaustedAssembleJob + stale recovery:已有「重试耗尽且
  已终态收尾」的 assemble job 时放弃恢复,交给 finalizeStaleTask 按失败收尾
  —— 用户看到明确失败,而不是无限 RUNNING

实测:部署后两节点 28459 相关日志、rustfs 确定性错误、stale recovery 重建全部归零。
2026-09-17 01:19:14 +08:00
huangzd1997 d5952945dd fix(rustfs): 确定性错误不再重试;回退两处经实测无效的连接池实验
- 新增 isNonRetryable:NoSuchKey / AccessDenied / 签名错误等确定性错误立即失败,
  不再白打两次请求(线上 read 一个已清理的 chunk 会连打 3 次并留下一条 ERROR)
- connectionPoolMaxIdle 0→5、keepAlive 30000→300000 回退:
  实测证明 unexpected end of stream 与连接复用无关——禁用复用后新容器起来的
  第一次请求照样中招。至此已排除公网链路、keepAlive 过长、连接复用三项;
  另用 mc 并发压 200 个小对象全部成功,说明 RustFS 服务端无问题。
  剩余方向指向 MinIO SDK(8.5.17,已是最新)/OkHttp 与 RustFS 的协议细节,
  需抓包对比 mc 与 Java 的请求才能定位。数据不丢(重试都能成功),影响是每次 +600ms。
2026-09-17 00:58:01 +08:00
huangzd1997 6e1689dfe5 docs(更新日志): 追加客户端 4.0.27 条目 2026-09-17 00:33:29 +08:00
huangzd1997 4f16a02658 fix(稳定性): 修任务锁超时/实例转发/扫描降噪,补 rustfs 结论日志
线上日志扫描发现的缺陷批量修复:
- TaskFileJobService.resetStuckRunningJobsDetailed 去掉跨行长事务:两节点
  @Scheduled 各取 200 行在同一事务里逐行 UPDATE,互等行锁导致 biz_task_file_job
  每天上百次 Lock wait timeout。改逐行独立提交(本就有 CAS 条件,幂等)
- TaskOwnerForwardService 连接类失败重试 3 次:两节点滚动重启窗口内的
  Connection refused 会直接丢掉用户提交的结果(读超时不重放,避免重复提交)
- AdminApiGuardFilter 把 401/被顶下线降为 debug(线上单节点一天近 3000 条噪声)
- NoResourceFoundException 单独处理:如实返回 404,不再刷 ERROR 堆栈、不再伪装
  200(每天 580 条,绝大多数是外部扫描 /.env、/credentials、aliyun.json 等)
- RustfsObjectStorageService 补「重试后成功 / 重试耗尽」结论日志:一天上千条
  retrying 却无结论,无法判断数据是否落盘
- 连接池 keep-alive 300s→30s,减少复用已被对端关闭的空闲连接
- SimilarAsin 提交结果遇到已删除任务时带 TASK_NOT_FOUND(40401),客户端据此
  停止每 30 秒一轮的无谓重试
- 修 SimilarAsinResultRowDto 的 GBK 乱码 JsonAlias("浠锋牸" → "价格")
2026-09-17 00:32:48 +08:00
huangzd1997 5e9a59b327 test(鉴权): 兜底过滤器用例改用不存在的样例豁免前缀
exemptPrefixes 是配置化操作出口(aiimage.security.admin-guard-exempt-prefixes,
默认空、生产未配置任何值),原用例拿已下线的 /api/admin/shop-credential-checks
当样例路径,容易被误读成「该端点已配好豁免」——那条路径是 2026-09-04 引入
豁免机制时随手取的,并非真的配过。换成显然不存在的 /api/admin/example-exempt,
并注明不要改成真实业务端点。
2026-09-17 00:13:27 +08:00
huangzd1997 ab09cff427 docs(更新日志): 追加客户端 4.0.26 条目 2026-09-16 22:55:31 +08:00
huangzd1997 47a9520a82 fix(跟价): 失败任务也产出并保留部分结果文件,不再只剩一条失败记录
会话掉线这类「跑到第 N 页才断」的场景,任务本就该是 FAILED,但已经跑出来的行
(哪些 ASIN 真实改价过)必须随任务一起交付,否则用户无从对账、也无法只重跑漏掉的。

- submitResult 的 error / success=false 分支改走 finalizeFailedShop:先按失败落库
  (success=0 + 原因),若合并后仍有可用行就排队组装部分结果文件;一行可用数据都没有时
  才退化成原来的纯失败(并清掉合并缓存)
- markResultFilePending / assembleShopResult 增加 preserveFailure 语义:失败行只挂文件,
  不把 success 洗成 1、也不清 errorMessage
- updateTaskStatusFromLatestRows:失败行正在组装文件时不让任务提前终态,
  否则前端一停轮询、下载按钮永不出现(失败任务的结果文件同样要能下载)

PriceTrackTaskServiceTest 新增 2 条契约测试(部分结果排队 + 组装完成后仍失败且可下载),
跟价模块测试 20/20 通过。
2026-09-16 20:33:31 +08:00
huangzd1997 7a6c3c3fa3 fix(菜单权限): 授权树按操作者置灰不可授予项 + 放行保留既有授权
普通管理员勾到无权授予的菜单后,ensureGrantable 抛 403,而 createUser/updateUser
都是 @Transactional,整笔回滚——线上表现就是「后台不能保存权限,也不能创建用户」
(2026-09-16 周丽娥账号创建用户与保存 uid=45 权限双双失败,库里查无新用户)。
根因是校验 2026-07-28 就加了,但授权树一直拿全量菜单,两边规则不一致。

- permission-menus 按操作者标记 grantable;授权树据此置灰(不隐藏:授权是整树替换,
  隐藏会把超管授予过、操作者自己没有的菜单当取消勾选删掉,与 09-13「权限自己没掉」同类)
- ensureGrantable 放行目标已持有的授权,只拦新增,不构成提权
- GlobalExceptionHandler 补业务异常日志:此前普通业务异常一行都不记,本次排查只能靠
  反推响应体字节数(nginx body_bytes_sent 含 chunked 开销)才定位到根因
2026-09-16 18:46:21 +08:00
huangzd1997 803b5d583d docs(更新日志): 追加客户端 4.0.25 条目
跟价「指定ASIN模式」搜索打到屏外的导航搜索框,客户端 4.0.25 已修(app_client 29c12f2)。
2026-09-16 17:15:06 +08:00
huangzd1997 1c52cd529b fix(去重): 历史任务弹层补下载/删除按钮(漏了 history-item-actions 插槽)
任务面板的 #item-actions 只作用于「当前任务」列表,历史结果全部只在
#history-item-actions 弹层里渲染。16 个工具页里唯独去重页只给了前者,
表现为用户反馈「历史任务里压根没下载按钮」(既无下载也无删除)。
接口数据(success/resultId/downloadUrl)完整、类型检查与单测均不报错,
只能靠点开历史弹层才能发现。
2026-09-16 17:05:11 +08:00
huangzd1997 ab28b168ec fix(去重): 主链接行位于其子链接行之后时未被丢弃 + 导出表头与数据行错列
线上任务 28422(文件 新数据变体完.xlsx)结果中 16 个主ID同时保留了主链接与
子链接,违反所选规则(keepIntegerIds=false / keepUnderscoreIds=true /
keepIntegerMainIdsWhenNoSubIds=true)。

根因:旧实现把整数主链接行暂存,依赖「后到的同主ID子链接行」把它丢弃。
当顺序为「子链接在前、主链接在后」时,子行到来时暂存区尚空,无人记录该主ID
已有子链接,主链接一路存活到 flush。改用 IdRuleRowPicker 先收集、收尾统一按
「该主ID是否出现过子链接行」判定,与源文件顺序无关;补 subRows/mainRows/
droppedMainRows 排查日志。

同时修一处潜在错列:表头按 selectedColumns 原顺序写、数据行按
orderedSelectedColumns(id/ASIN/国家/价格/品牌 提前)写,两者不同序时整体
错列;本任务所选列恰为导出优先级顺序故未暴露。

实测:用线上源文件重算,输出 7273 → 7257 行(正好少掉那 16 个主/子并存的主ID)。
2026-09-16 16:33:04 +08:00
huangzd1997 367b4b7553 chore(更新日志): 补 4.0.24 条目(跟价改版为行内输入+底部「全部保存」提交) 2026-09-16 16:03:42 +08:00
huangzd1997 25c7323c47 chore(更新日志): 补 4.0.23 条目(跟价适配亚马逊新版商品页) 2026-09-16 13:40:59 +08:00
huangzd1997 aea0e16279 feat(站内通知): 铃铛支持按类型大类筛选(系统异常/任务异常/配置异常/系统通知)
原来铃铛只能按关键字和日期筛,任务失败、密钥欠费、麦象异常全混在一起,
用户没法只看自己关心的那类。

后端 NotificationService 增加 scene→大类映射(未知 scene 归「系统通知」兜底),
列表接口新增 category 参数,落到 SQL 是 scene IN (...);「系统通知」作兜底类
还要纳入未登记的 scene,故表达为「= system 或 不在其它三类里」。列表项返回
category,前端不必各自维护一份 scene 映射。前后端两个通知接口同步加参数。

前端铃铛筛选区新增一行类型 chip(全部 + 四类),与关键字、日期一起参与
hasFilter 与重置;空分类不下发,避免后端查询落空。
2026-09-16 10:32:11 +08:00
huangzd1997 f566573fce fix(菜单权限): 后台侧边栏为部分授权用户补全祖先分组(二级菜单不再平铺)
只勾选分组内子页面、未勾选分组本身时,getUserColumnPermissions 只返回
直接授权+展开后代,分组容器节点缺失,AdminMenuTreeBuilder 把子页面当根节点,
前端侧边栏渲染成一列平铺;且子页面 sort_order 是全局值、跨分组穿插显示,
与超管的「分组+组内顺序」视图完全对不上(线上 31 个账号如此,含 uid=972 阿武)。

修复:getUserColumnPermissions 增加 includeAncestorGroups 重载(默认 false),
true 时沿 parentId 链把有可见后代的祖先并入集合——分组无页面路由,仅还原
展示层级,不构成授权扩展;只有 current-user/menus(后台侧边栏)启用。

刻意保持原语义的出口:/permission-users/{id}/column-permissions 与登录响应
(桌面端 tool-catalog「组键命中即整组放行」,补组键会误放行整组工具)、
dedupe/invalidasin 的精确 key 校验。

新增 3 个测试:祖先补全含多层链、树组装还原分组与组内顺序、默认出口不含祖先。
2026-09-16 09:58:33 +08:00
huangzd1997 8fcceb3226 fix(撞款扫描): 保留期 90→7 天,并单独兜住最新 SUCCESS 行
该表每行含整份聚合 payload(线上实测约 2.3MB/行,24 行占 55MB),
而读取侧只认最新一行(selectLatestFullRow/LightRow 都是 ORDER BY id DESC LIMIT 1),
历史行纯占磁盘。保留期降到 7 天,行数下限仍由 PROTECTED_ROWS=10 兜底。

同时补一条保护线:连续失败多日时,最新 SUCCESS 行会滑出「最新 N 行」保护窗口,
再按时间线被删掉,界面就空白了——现在取「第 N 新行」与「最新 SUCCESS 行」
两者更靠前的那个 id 作为保护线,确保它永不被删。
2026-09-16 09:25:30 +08:00
huangzd1997 ed0d6575c8 feat(前端): 追加客户端 4.0.22 更新日志条目
包体积 -38%、启动时清理本地垃圾、启动与连接优化三条面向用户的说明。
version 与 web_config 发布记录一致(4.0.22),否则更新面板按本机版本筛选匹配不到。
2026-09-16 09:09:05 +08:00
huangzd1997 f2ada02383 feat(保留期清理): 补齐 7 处只增不删的数据;修结果对象孤儿
审核发现一批表/元数据只增不删,且对象存储存在活跃泄漏:

- module-cleanup 漏收 task_result_item / task_result_payload 的 payload 指针,
  每晚删完行就在 json-server 桶留下孤儿对象;指针收集触顶从"截断后照样删整组"
  改为任务组二分拆分,拆到单任务仍触顶则整组不删并记 error(截断会让指针随行消失)
- 结果文件对象此前从不回收(只删 DB 行、对象留在桶里),现改为事务提交后
  按 file_result / task_file_job 的 result_file_url 逐个回收
- 新增 7 个保留期清理:device_log_file 元数据、price_track_loop_run、撞款扫描行、
  密钥用量日统计、紫鸟记忆过期行、PUBLISH 任务、BRAND 任务。后两者刻意不复用
  module-cleanup(BRAND 不写 biz_file_task;PUBLISH 单任务可达数万行),
  改为调各自既有的业务删除入口,保证结果对象与子表一起回收
- 未读通知补保留期(180 天,已读仍 90 天);ZiniaoMemoryStoreService.deleteExpired
  此前全库无调用方(实现了没接线),现已挂定时任务

全部沿用既有范式:@Scheduled + 分布式锁单实例 + 分批 + 单轮批次数上限 + 失败只记日志。
2026-09-16 01:59:28 +08:00
huangzd1997 5e5816cd74 fix(站内通知): 麦象异常扫描滞留检查改用 update_time 过滤(18960 console 新增 update_time_end)——原按 create_time 过滤 + desc(id) 分页只能看到最新批次,积压深处的老任务永远看不到(2026-09-15 滞留在队 9 小时未被告警指出的漏报根因) 2026-09-16 01:05:26 +08:00
huangzd1997 d189d94c3b fix(更新日志): 面板随指定版本下拉联动;本机已是最新时展示本机条目
- 「指定版本更新」下拉选中版本时日志切到该版本自身的条目(升级/回退统一语义),
  未收录显示"暂无更新说明"(区块不消失),选回空项回默认区间;
  列表加载后自动默认选中第一条不触发切换
- 本机版本==线上最新时回退显示本机版本条目(原来整块消失),灰度/回滚仍隐藏
- 精简 4.0.20/4.0.21 超长文案,修复既有红测试(更新说明限 40 字)
2026-09-16 01:02:13 +08:00
huangzd1997 79b5d40327 feat(设备日志/后台配置/密钥检测): 补齐已上线未提交的设备日志与app_config,并合入检测模型解耦
三部分均已部署到双节点(当前线上 JAR 009efc3e),本次补齐仓库状态,避免"已上线未提交"
在后续最小构建比对里被误判。

- 设备日志管理:modules/devicelog + DeviceLogOssProperties(主机B 独立 MinIO,仅内网)
  + V127 device_log_file/device_log_config + admin-vue 日志管理页;客户端/麦象按 offset
  增量上报(X-Internal-Token),查询仅超管
- 后台通用配置:modules/appconfig + V128 app_config 键值表;工作台「开店流程」密码改服务端
  校验(POST /api/kd-flow/verify),改密码只需 UPDATE 该行、客户端无需重新发版
- 密钥检测模型解耦:新增 aiimage.user-secret.check-model(env AIIMAGE_USER_SECRET_CHECK_MODEL),
  默认 doubao-seed-2-0-lite-260215 —— 同系列 mini 在中继分组下无可用渠道(503 model_not_found),
  实测 lite 可路由;UserSecretModule 去掉 LlmTarget 改 resolveLlmHost,探测日志带 model=
- 前端:密钥面板「检测配置密钥」按钮不再折行;client-changelog 补 4.0.19/4.0.20/4.0.21 条目

注:application.yml 与 PropertiesConfig 同时承载上述多个部分,故未按功能拆分提交
2026-09-15 23:53:05 +08:00
huangzd1997 228d481211 perf(全链路): 连接池/事务边界/轮询与 IO 效率收口
Java:
- LLM 180s 读超时不再被全局 call-timeout 静默截断成 90s(长思考请求被掐断→重试→付费网关二次计费)
- 代理 HttpClient 缓存改有界 LRU(jikip 每次提取新 IP,无界缓存持续泄漏 selector 线程与连接池)
- 12 个 service 的 Redis 任务锁移出 @Transactional(自旋最坏 10s 白占 DB 连接,池仅 30),远端对象删/传改 afterCommit
- 结果文件 Job 闸门拒绝时不再回退内联执行(改重新入队,避免把背压转嫁给 MQ 消费线程)
- imagevideo 每秒扫描加列投影、过期清理加 LIMIT;权限页整表查询改列投影(不再拉回密码哈希)
- 哈希改 HexFormat;补 5 处"不能改"的技术依据注释(批量插入会丢回填主键、流式丢模板与图片等)

前端:4 个工具页轮询改轻量端点(带 fallback);PriceTrack 快照节流写盘;候选店铺表分页;页面隐藏时停表

客户端:HTTP 连接池按出口复用(Session 仍每请求新建,保持无跨请求状态);品牌检测 WIPO 逐请求握手;
代理配置按 mtime 缓存;串行任务改专属池;异常降级为标签页重连;紫鸟启动改端口轮询;模板编译缓存;
日志上报连接与落盘收口;Flask 版本 API 改按请求复用连接
2026-09-15 23:01:59 +08:00
huangzd1997 d6f8368493 feat(站内通知): 麦象异常扫描上线——任务停滞/失败/队列积压/批量空结果推管理员
用户要求「maixiang异常也要发通知」,确认范围(卡住/失败/队列堆积/服务不可用)与
受众(只发管理员)后实现:
- MaixiangConsoleClient:18960 后台只读接口客户端(batch/tasks、tasks、queue/status、
  batch/detail),token 鉴权、失败只记日志。URL 必须用 URI 对象提交——字符串形态会被
  RestClient 二次编码(%20→%2520),end_time 参数实测报 Incorrect DATETIME value,
  检查会静默失效;已加回归单测 buildUrlEncodesTimeParameterExactlyOnce
- MaixiangAnomalyScanner 五类检查:①批量任务停滞(status 0/1 超时无更新,默认 60 分钟)
  ②单任务滞留(创建超 30 分钟未完成)③近 30 分钟失败达最小条数 ④批量任务空结果
  (worker 报错时批次仍被标"已完成",是唯一可抓的批量失败信号)⑤队列积压(pending≥300
  / processing≥100);去重按天/小时/任务,全部为管理员全局事件(subjectUserId=null)
- 接入 NotificationScanScheduler(分布式锁内同跑);scene=maixiang_anomaly;
  AIIMAGE_NOTIFICATION_MAIXIANG_* 环境变量可调,令牌留空=跳过
- 前端 NotificationScene 类型补 maixiang_anomaly(铃铛不按 scene 渲染,无运行时改动)
- 测试:客户端解析(真实抓包样例)+ 扫描器 14 例 + 手工联调探针 MaixiangLiveProbeTest
  (-Dmaixiang.live=true 开启,只读不写通知)

已部署双节点(.env 加 console token、JAR cd3e06c4)并线上验证:首扫推 2×15 条管理员通知
(3 个变体任务停滞 / 39 个跟价任务滞留),二次扫描落库=0 去重生效。
2026-09-15 12:20:15 +08:00
huangzd1997 b0f764b6b6 feat(前端): 追加客户端 4.0.18 更新日志条目 2026-09-15 11:33:53 +08:00
huangzd1997 1403fec5fe feat(前端): 追加客户端 4.0.17 更新日志条目
跟价购物车归属未知跳过改价、店铺名回落、失败原因如实带出三条面向用户的说明。
2026-09-15 01:27:57 +08:00
huangzd1997 07b4ebe983 feat(成本): 密钥检测防抖 + 巡检降频隔日 + 客户端 4.0.16 更新日志
- 用户密钥「检测」90 秒新鲜期:同值重复检测复用上次通过结果,
  代理提取不再因连点/手滑重复扣费(只缓存 passed,失败允许立即重试)
- 连通性巡检由每日降为隔日(cron 0 30 4 */2 * *,双实例锁不变)
- changelog 追加 4.0.16 条目
2026-09-14 19:43:22 +08:00
huangzd1997 0b2b9303d0 chore(前端): 追加客户端 4.0.15 更新日志条目 2026-09-14 18:10:48 +08:00
huangzd1997 05a2c479a5 fix(测试): CollectDataNoUploadStaleTest 的 updateById 重载歧义(补编译验证) 2026-09-14 18:00:07 +08:00
huangzd1997 52b55df7b2 feat(任务判死): 心跳正常但 180 分钟无结果上报的二次判死线(13 模块)+ 同期待发改动
判死线(治 28131 型「主线程卡死、心跳线程照发」):
- 判据改看 biz_task_scope_state.last_chunk_at(HTTP 心跳不刷新它);从未上报跳过不判
- 中央线覆盖 DELETE_BRAND/PRODUCT_RISK_RESOLVE/PRICE_TRACK/SHOP_MATCH/PATROL_DELETE/QUERY_ASIN/WITHDRAW
- 自带线接入 COLLECT_DATA/SIMILAR_ASIN/APPEARANCE_PATENT/SHOP_DATA_CRAWL/PUBLISH/BRAND
- 客户端心跳带处理位置 progressText,判死文案含最后位置;no-result-upload-timeout-minutes 默认 180(0 关闭)

同期带上另一工作流的待发改动:跟价换 IP 重试、品牌检测重试上限与 LLM 并发下调、
教程包后台管理页与 V126 迁移、admin-vue 教程记录页。
2026-09-14 17:56:20 +08:00
huangzd1997 b54f72d3f6 fix(品牌检测): 熔断中止落真实原因+部分结果可下载,熔断加持续时长门槛
- 熔断改为「连续失败持续 6 分钟未恢复」才中止:原 8 次即中止,5 线程一轮
  就能凑满,重跑机制没机会生效导致任务失败率过高(任务 2309 复盘)
- 期间每 30s 冷却重试,重跑轮次 10→30;限流窗口恢复后任务自动跑完
- 失败终态上报内部接口 /api/internal/brand/tasks/{id}/abort:落真实原因 +
  用已收分片部分组装结果(未检测品牌单独成 sheet),不再悬挂到心跳超时被
  判「前端长时间无响应」且已跑数据无法下载
- 组装前从分片重建聚合(缓存快照可能缺后加字段如 keptBrands)
- 前端:失败任务有结果文件即显示「下载结果」
2026-09-14 17:04:19 +08:00
huangzd1997 24c5a09c7f feat(任务派发): 客户端兜底拉取页面未推送成功的任务 + 修店铺匹配定时任务误杀
问题:任务派发链路的"推送"只存在于页面里(Java 解析落库 PENDING → activate →
pywebview 桥 enqueue_json 推本机队列)。只解析没点启动、推送前关页面、在纯浏览器
打开,任务都会停在 PENDING,2 小时后被 StaleTaskRepairService 标失败
(「任务长期未被领取,已自动失败」,09-11 生产清理过 263 条同画像)。

- 服务端新增 GET /api/tasks/pull-pending(TaskClientPullController,身份从 JWT 取,
  不接受 user_id 参数):只挑创建超 5 分钟仍 PENDING 的本用户任务,逐条条件更新认领
  (PENDING→RUNNING + 接管 owner_instance_id)——与页面 activate 同一谓词,天然互斥,
  不会重复执行;认领后组装不出载荷则标 FAILED,不留 RUNNING 孤儿
- payload 由各业务模块实现 ClientTaskPullSpi 组装(task 侧不 import 业务模块,同 G5):
  首批 SIMILAR_ASIN / COLLECT_DATA / APPEARANCE_PATENT——这三个 Python 消费端会自行
  回拉明细,故载荷极简、客户端零模块知识;开关 aiimage.client-task-pull.enabled 默认 false
- 防双执行:三处 activate 由「非终态即可」收紧为只认 PENDING,未命中抛
  「任务已在执行中(可能已由客户端自动接管),无需重复启动」(顺带堵住整行 updateById
  把认领写入的 owner 覆盖回去的竞态);两个前端页 activate 失败即提示并停止入队
- 客户端:amazon/main.py 新增 pending_task_pull_worker,启动点挂在 app_client/main.py
  的 start_task_monitor(独立入口的 worker 线上并不生效);开关 pending_pull_enabled 用
  getattr 读取,避免 test/ 下的旧 config 缺键导致整包导入失败
- 同批修:StaleTaskRepairService 的 SCHEDULED 分支改按 scheduled_at + 120min 判死
  (原按 updated_at 会必杀排期 >2h 的店铺匹配定时任务,而 activate 又被 scheduledAt-90s 挡住)
- 测试:TaskClientPullServiceTest / StaleTaskRepairServiceTest / CollectDataTaskPullSpiImplTest /
  CollectDataActivateGuardTest 共 20 例;客户端 pending_task_pull_worker 7 例并更新启动顺序契约测试
2026-09-14 16:23:34 +08:00
huangzd1997 8cab9d4bad fix(密钥配置): 保存沿用刚检测过的输入值结果,消除「三项检测通过却提示未检测」死循环
- 服务端:对「输入值(未保存)」的检测结果按 uid+模块+值指纹暂存 Redis(TTL 30min,
  Redis 异常降级为需重新检测,不阻断保存);保存同一个值时落库该结果
  (passed/failed/error 一并沿用),改过值或从未检测则维持未检测
- 前端:保存后清理本地「输入值(未保存)」绿字,展示统一走服务端快照,避免展示与门禁矛盾;
  门禁提示改列「模块名(掩码):未检测 / 检测失败:原因 / 未配置」,同名掩码也能分辨模块
- 测试:UserApiSecretServiceTest 补沿用 / 值不一致 / Redis 降级用例;
  新增前后端一致性守卫测试(保存后必须清检测结果、提示必须带模块名)
2026-09-14 13:49:25 +08:00
huangzd1997 5ea52e5291 perf(F5+): 行数据按需拉取(结果行版本信号)+ 修复 progress/light 恒判 missing
行数据按需拉取(审查 F5 后续):
- V125 给 biz_file_result 补 updated_at(DEFAULT/ON UPDATE 由数据库维护,
  实体标注 insertStrategy/updateStrategy=NEVER —— 否则 selectById→updateById 的
  写回会把旧值写回去、ON UPDATE 不触发,版本信号静默冻结)
- 装配器回传 rowsVersion=「最后变更时间毫秒#行数」,5 个品牌工具页版本未变即跳过
  带行明细的重型 batch;前端变更信号为 rowsVersion + status/fileStatus/fileReady 复合
  (任务收尾常见「行早写完、之后才置成功」,只看行版本会把界面卡在旧状态)

修复线上缺陷(同一功能验证时暴露):
- TaskProgressLightAssembler 列裁剪漏选 module_type 却用它做模块过滤 →
  getModuleType() 恒为 null → light 恒把任务判成 missing;第七批把 light 接进
  跟价/定时匹配/商品风险的轮询后,消费方会把运行中任务判为 FAILED
- 补选中列 + 守卫用例 taskQueryMustSelectModuleType(已反向验证:去掉修复即红)
- 前端 lightClaimsAllTasksMissing:整体性 missing 结论用重型端点复核后再采信

契约与文档:light 白名单补 rowsVersion(Java 契约测试 / spec 06 §2 / 12 个端点描述)
测试:mvn test 2901 全绿;前端 npm test 765 全绿
2026-09-14 12:08:25 +08:00
huangzd1997 9166656673 perf(C6): 去重总数据列表顺序翻页改 keyset(前后端契约一起改)
背景:列表页 `ORDER BY id DESC LIMIT offset,size`,带筛选且选择性低时每页都要
对命中集做一次 filesort;深翻页 offset 也白扫索引。

改法(保持跳页/回退/改每页的原有行为):
- 后端:page 接口新增可选 `last_id` 游标 —— 传了就 `id < lastId` + `LIMIT size`(无 offset),
  响应新增 `nextLastId`(本页最后一行 id);不传仍是原 OFFSET 分页
- 管理前端:只有"下一页"用游标(上一页响应带回),跳页/改每页/筛选清空游标走 OFFSET
- 测试:新增 2 个后端契约测试(keyset 无 offset + nextLastId;无游标保持 LIMIT 30,15)

验证:mvn test 2897 全绿;admin-frontend-vue vue-tsc 通过 + 1619 测试全绿;
已部署 JAR 2b9ab774c60c3f5d802c9a2cee549008(双节点 health=200)与 admin-vue-20260914-112055。
2026-09-14 11:21:40 +08:00
huangzd1997 1360a44e01 fix(web): 货源查询/外观专利入队不再把 api_key 当必填
密钥已服务端化,本机无明文属正常状态,Java 侧 readApiKey 会按 uid 从用户密钥表兜底;
列成必填会把未在本机手输密钥的用户全部挡在启动之前。
2026-09-14 11:21:09 +08:00
huangzd1997 86c05e71a2 fix(web): 补齐命令式调用的 EP 按需样式,修拦截弹窗贴到文档左上角
Element Plus 走按需引入,只有模板里用到的组件才会注入样式;ElMessage /
ElMessageBox 都是 import 后函数式调用,构建产物里一条 .el-message-box 规则都没有,
弹窗因此没有定位与遮罩,渲染成普通块元素压在页头文字上(ElMessage 提示条同样受影响)。
入口显式引入两个样式模块,并加测试钉住「显式 import 的 EP 组件必须有样式引入」。
2026-09-14 11:21:08 +08:00
huangzd1997 fae26aa460 style(version): 指定版本下拉只显示版本号,去掉发布日期 2026-09-14 11:08:07 +08:00
huangzd1997 8803e22f39 feat(version): 桌面端更新面板支持指定版本安装
- 新增公开接口 GET /api/version/list(最近 50 条,字段口径同管理端列表),
  桌面端下拉不再拼 OSS 地址(版本包被删后拼地址会 404)
- 更新面板(登录页 + 首页)加「指定版本更新」选择器:默认选中最新版,
  可选全部已发布版本(含回退),回退/同版给出明确文案与二次确认
- 客户端无需发版:do_update_app(file_url) 本就接受任意版本包直链
2026-09-14 11:05:30 +08:00
huangzd1997 375b89154b perf(C5): 外观专利服务解析改走流式解析器(最后一条活路径)
AppearancePatentTaskService.parseWorkbook 原为 POI 全量 DOM(用户源文件整表入堆);
改为复用已有的流式孪生实现 AppearancePatentExcelParser(EasyExcel SAX,语义一致且自带单测),
服务侧只保留原有业务处理(分组键/状态过滤/字段补齐/hydrate)。外观专利模块测试全绿。

至此:用户源文件的解析已无 DOM 路径(剩余 DOM 仅用于报表模板写入,样式/图片/公式必须 DOM)。
2026-09-14 10:58:59 +08:00
huangzd1997 6a90adc765 feat(A1/A3): 三个客户端零调用前缀立即无条件收紧
实测桌面端 Python 侧调用面:/api/image-video、/api/task-file-jobs、/api/ziniao 为 0 次调用
(只有带 JWT 的网页端在用),因此从 user-tool-guard-enabled 开关名单移入无条件守卫名单,
不必等客户端铺开即完成收紧;其余 14 个前缀的客户端调用面与令牌携带情况已逐一实测,
仍在开关后面(老客户端不带身份,提前打开会 401)。

新增 2 个守卫测试:开关关闭时这三组前缀对匿名同样 401、带用户令牌仍放行。
2026-09-14 10:46:05 +08:00
huangzd1997 bcf66dc1d7 fix(startup): 两个 GroupDeletionGuard 显式命名 + Bean 名唯一性守卫测试
事故:2026-09-14 边界收敛新增 invalidasin/dedupe 两个同名 GroupDeletionGuard,
Spring 默认用简单类名做 Bean 名 → 启动抛 ConflictingBeanDefinitionException,
主机 A 的 java-server 连续重启失败(health 不通),**单测全绿也发现不了**
(不起完整 Spring 上下文),部署后才知道。

- 两个守卫分别显式命名 @Service("invalidAsinGroupDeletionGuard") / ("dedupeGroupDeletionGuard")
- 新增 SpringComponentBeanNameUniquenessTest:扫描 main 源码,同简单名的组件必须显式命名,
  否则红测试(把这次事故固化成可回归的守卫)
- 已重新打包部署:JAR 1f1e82bda227f2b0768574f8fd7c32c9,双节点 health=200
2026-09-14 10:37:44 +08:00
huangzd1997 c55c4a140b feat(A1/A3+C8): 客户端令牌链路打通(按人鉴权就绪)+ 快照 JSON 写入节流
A1/A3 客户端令牌链路(服务端守卫已能按 JWT 鉴权,缺口在客户端不带身份)
- 前端:新增 user-token-bridge(纯逻辑,值变化才推送)+ user-token-sync(启动安装,
  pywebviewready/storage/60s 轮询补推),main.ts 接入;桥接口补 set_user_token
- 客户端:新增 crawler_core/user_token.py(持有 + 对自家 Java 端点注入
  Authorization: Bearer <jwt>,Session.request 包装,第三方域名不注入、已有头不覆盖、
  登出清空、SHUFUAI_DISABLE_USER_TOKEN_HOOK 可关);main.py 暴露桥方法并在启动安装钩子
- 服务端:守卫契约测试补 5 个用户态前缀用例(开关关闭放行 / 打开后匿名 401 /
  用户令牌通过 / 内部令牌仍放行 / /api/ziniao 第二层)
- 翻开关的前置条件(客户端铺开后置 aiimage.security.user-tool-guard-enabled=true)写入报告

C8 快照 JSON 写入节流
- 读端改以 biz_task_result_item 行为准、JSON 仅兜底(历史任务 JSON 仍是唯一副本时可用)
- 整档 JSON 改 30s 节流写,终态路径 force 立即写;新增 2 个契约测试钉住语义

验证:mvn test 2888 全绿;npm test 741 全绿;user_token 钩子自测(注入/归一/第三方跳过/不覆盖/登出)通过
2026-09-14 10:20:15 +08:00
huangzd1997 7643094f1d refactor(boundary): 跨模块循环依赖清零(8 对 → 0),task→业务 依赖归零
共享内核下沉(跨模块共享的"身份/组织/安全"类型进 common)
- AdminUserEntity/AdminUserMapper、ShopManageGroupEntity/ShopManageGroupMapper → common
- AdminAuthSupport(33 文件 17 模块引用)、JwtService、AuthProperties、DeviceSessionPolicy
  → common/security(原先放在 admin/auth 里,任何模块用一次就多一条跨模块边)

端口化(消费方声明接口、数据方实现)
- task/spi:ResultFileJobHandler 新增 resolveResultFileUrl 钩子(BRAND 特例从 Worker 收回);
  Worker 改为调用 handler.onSuccess(该钩子历史上从未被调用,withdraw 的收尾靠 Worker 里的
  WITHDRAW 特判硬编码——现两者都归位,task 侧不再 import withdraw/brand)
- admin/spi/UserSecretCleanupPort(删除用户级联清理密钥)、notification/spi/ProxyBalancePort
  (代理余额探测)、shopdatacrawl/spi/ManagedShopNamesPort(可管店铺名)
- shopkey/spi/GroupDeletionGuardPort:分组删除守卫改由各业务模块实现(invalidasin/dedupe 两个实现),
  shopkey 不再直连它们的 Mapper

棘轮与量化(2026-09-14 实测)
- ArchitectureBoundaryTest:task→业务 119 → **0**(基线钉死为 0,新增跨模块动作必须走 task/spi)
- 跨模块 import 行数 650 → 605;双向依赖对 8 → **0**(usersecret/permission/shopkey 三向环、
  task↔withdraw、task↔brand、admin↔permission、admin↔usersecret、dedup↔shopkey、
  invalidasin↔shopkey、notification↔usersecret、shopdatacrawl↔shopduplicatecheck 全部拆解)

测试同步:接口契约 8→9、品牌 Handler 钩子契约、worker 的 10 个测试构造实参、通知/管理测试端口化。
mvn test 2881 全绿。
2026-09-14 10:05:04 +08:00
huangzd1997 1b480f915f test(G4): 定时匹配/格式转换/数据拆分 补 27 个契约测试(三个模块此前均零测试文件)
- ShopMatchResolveService 15:候选越权与幂等、匹配去重保序、国家偏好默认顺序与坏 JSON 兜底
- ConvertTemplateService 8:内置模板禁删(软禁用)、导入命名/后缀补全、设为默认时清掉其它默认位
- SplitRunService 4:下载/删除历史必须属于本人且模块匹配(含无结果文件不可下载)

至此审查点名的 6 个零测试模块全部有测试文件。
2026-09-14 09:40:56 +08:00
huangzd1997 ac36c08460 test(G4): 取款/查询ASIN ResolveService 各补 13 个契约测试
取款:候选增删幂等与越权保护、按店铺名批量删的归一化去重、匹配去重保序。
查询ASIN:额外覆盖「国家 ASIN 清单」契约(国家顺序固定、空国家不出现、同国家去重),
该清单会整体推给 Python,格式错会直接导致采集错列。
2026-09-14 07:11:59 +08:00
huangzd1997 ef3a2c9bd6 docs(更新日志): 追加客户端 4.0.13 条目 2026-09-14 07:08:39 +08:00
huangzd1997 da0f10f1cc test(G4): 巡店删除 ResolveService 补 13 个契约测试(该模块此前零测试文件)
覆盖:用户校验、索引未命中/同名冲突拒绝、重复添加幂等、他人记录不可删(同文案防探测)、
匹配结果去重保序、count 空值兜底。
2026-09-14 07:05:04 +08:00
huangzd1997 9ba231dc4c feat(D2): 导入进度跨节点可见(NodeSharedStore:本地快路径 + Redis 真源)
- 新增 common/service/NodeSharedStore:本地 Map 快路径 + Redis 跨节点真源 + 写节流
  (默认 500ms,逐行刷新进度不会打爆 Redis)+ 本节点条目快照(维护用)+ TTL 兜底过期
- 接入 DedupeTotalDataService(8 个进度/归属/分组/完成时间映射)、QueryAsinService、
  SkipPriceAsinService(各 3 个):轮询落到另一节点不再报"任务不存在"
- 保留期清理改为遍历本节点快照(跨节点过期由 Redis TTL 兜底),不再依赖全量遍历
- 测试同步:去重服务测试的反射注入改用 NodeSharedStore(未注入 Redis 时等价纯本地)

mvn test 2815 全绿
2026-09-14 06:54:48 +08:00
huangzd1997 9a6b57db58 refactor+perf+fix: G5 模块边界 SPI 化、C5 流式解析、C6/C7 查询优化、D13 队列持久化、A3/A4/A7 鉴权
模块边界(G5 / G7)
- 新增 task/spi/TaskModuleHeartbeatSpi + 13 个模块实现:TaskHeartbeatService 不再 import 任何
  业务模块(原先注入 12 个 CacheService 并用 switch 分发);启动校验重复注册
- 新增 task/spi/BrandTaskHeartbeatSpi(品牌任务心跳/中断)、BrandTaskStaleRepairSpi(陈旧修复)、
  CollectDataItemCleanupSpi(历史清理):跨模块 Mapper 操作收回业务模块
- G7:10 个被跨模块借用的 productrisk VO 迁至 common/model/vo
- 架构棘轮收紧:TASK_TO_BUSINESS_BASELINE 119 → 6(实测)
- 新增 TaskModuleHeartbeatSpiCoverageTest(moduleType 覆盖与拼写)

性能与容量(C5/C6/C7/D13)
- C5 流式解析:SkipPriceAsinService(含两行表头语义)、AppearancePatentExcelParser、
  BrandTaskService、DeleteBrandRunService、LocalFileStorageService.getExcelInfo 改 ExcelStreamReader;
  行数上限改为迭代中生效
- C6 去重总数据列表:关键字改前缀匹配(命中 uk_data_value);V124 删除永不生效的 idx_country
- C7 撞款扫描:按店逐批取数(索引前缀),不再全表 GROUP BY + JOIN + 全量拉内存
- D13 待删对象本地日志 PendingDeleteJournal(启动回放 + 收敛重写),RustfsDeleteRetryService 与
  TransientPayloadDeleteOrchestrator 接入;异步删除失败对象写回日志
- V123 删除 biz_file_result 两个被复合索引覆盖的单列索引

安全(A3/A4/A7 + 守卫名单)
- A4 数字人版本写操作要求管理员;A7 视频密钥按登录身份(超管例外)
- A3 上传接口加危险扩展名黑名单(可配置)
- AdminApiGuardFilter 用户态名单补 /api/ziniao(controller 已 requireAdmin,此处为开关打开后的第二层)

容量(明细表保留期)
- 新增 ShopDataCrawlItemRetentionService:biz_shop_data_crawl_item 按保留期(默认 30 天)分批清理
  (该表此前无任何清理策略,是增长最快的表),job 锁 + 单轮批次上限
2026-09-14 06:46:27 +08:00
huangzd1997 67223f8950 perf(frontend): 工具页分页/历史截断/轻量轮询/共享纯逻辑(审查 F5/F6/F9/F12/G3)
- F9 新增 useTablePaging composable 并接入 7 个工具页"匹配结果"表(只切渲染窗口,
  不加选择语义;每页 100 条)+ 深色分页样式
- F6 历史任务抽屉渲染截断(默认 50 条 + "显示全部/收起"),全选口径改为当前可见条目
- F5 新增 task-progress-polling 适配器:等待任务终态的紧循环走 /tasks/progress/light,
  异常或空响应回退重型 batch;已接入跟价/定时匹配的 waitForTaskTerminal
- F12 背景图 bg.jpg 251KB → 166KB(1920 宽 + quality 80,image-set 仍优先 webp)
- G3 抽出共享纯逻辑 task-queue-state(开始时间表序列化校验 + 记录缺失错误判定),
  5 个工具页删除逐字重复实现
- 新增 4 个单测文件(分页切片/历史截断/轻量轮询归一/任务队列纯逻辑)
2026-09-14 06:23:46 +08:00
huangzd1997 e76714c32e fix(log): 陈旧巡检 summary 日志占位符与实参对齐
每段 5 个占位符(x={}/{})只传 4 个参数,导致 withdraw 之后取值整体错位、
末尾 elapsedMs/thread 被打成字面量;改为每段 4 个占位符。
2026-09-14 05:51:06 +08:00
huangzd1997 95dfb69a18 refactor(ziniao/shopkey)+fix(stale): 打破模块循环依赖 + 采集陈旧判死全局化
模块边界(消除 ziniao ↔ shopkey 真实循环依赖):
- 新增 ziniao/service/port/{ShopKeyCatalogPort,ManagedShopNamePort}:消费方声明契约
- shopkey 侧新增 ShopKeyCatalogAdapter(读 shop_key + 白名单状态回写)、
  ManagedShopNameAdapter(店铺名校验),实现上述端口
- ZiniaoApiKeyProvider 改经端口取数,不再 import shopkey 的 Mapper/Entity;
  ZiniaoShopSwitchService 改依赖 ManagedShopNamePort
- 结果:ziniao → shopkey 的 import 归零,依赖单向(shopkey → ziniao)

陈旧判死(G1 全局判死 + D9 条件更新,替代此前的 owner 过滤/旧实体覆盖):
- ShopDataCrawlTaskService.finalizeOwnedStaleTasks → finalizeStaleTasks:去掉 owner 过滤,
  并入 DeleteBrandStaleTaskService 的 stale-check 巡检线(job 锁保证单实例扫描)
- 判死前必须持有任务锁(非阻塞获取,锁被占本轮跳过),FAILED 写入改 status CAS,
  仅在确实由 RUNNING 翻转为 FAILED 时才删缓存与分片(原实现会用扫描期旧实体覆盖在途任务)
- tryFinalizeTask 增加 allowOwnerTakeover 重载:判死场景允许跨实例接管(P1-8 盲区)
- 测试同步:owner 契约用例改为全局判死口径;mock 的 CAS 需先渲染 SQL 片段
  (MyBatis-Plus 的 where 参数延迟填充)才读参数表;新增锁被占跳过的用例

mvn test 2796 全绿
2026-09-14 05:44:37 +08:00
huangzd1997 24ada70997 refactor: 结果下载直链解析去重(11 处 → 1 处)
新增 common/service/ResultDownloadResolver,7 个模块的 resolveResultDownloadUrl
改为委托调用(appearancepatent/queryasin/withdraw/pricetrack/productrisk/
patroldelete/shopmatch)。

顺带修掉两处隐患:
- 原实现用 userId.equals(entity.getUserId()),userId 为 null 时 NPE
  (新实现用 Objects.equals)
- shopmatch 原实现在 url 为空时抛"任务不存在",与语义不符,统一为"暂无可下载文件"

其余 4 处**刻意保留**,因为它们本就不是重复:
- shopdatacrawl:有 validateUserId + requireResultEntity + ensureResultOwner 前置校验
- deletebrand:返回 null 而非抛异常(调用方依赖该语义)
- similarasin:已分叉为返回 record ResultDownloadInfo
- brand:走的是另一套下载路径
文件名解析 resolveResultDownloadFilename 同样没收口:各模块兜底文件名策略确有差异

配套更新 3 个显式构造 Service 的测试(DelegationTest/HistoryBatchTest/
RollbackSemanticsContractTest)注入新依赖的 mock。mvn test 2795 个全绿。
2026-09-14 05:17:10 +08:00
huangzd1997 ccce03b4d4 fix: 第四批修复(结果读取并发化/分页钳制/JWT 密钥强校验/凭据按 id)
性能
- listResultSnapshots 对指针化载荷改并发读取(有界池 + 保序汇总):此前逐行同步对象存储读,
  500 行结果文件生成要多花数十秒。仅在确有指针行时才走池——内联 JSON 直接串行读,
  避免为本地读取引入调度抖动(性能基准测试容差会被影响)

安全/正确性
- 管理端 GET /{id}/credential 改为按路径 id 查询(此前忽略 id、改用 shop_name,
  会出现「路径声明的店铺」与「实际读取凭据的店铺」不一致);凭据 VO 构造抽公共方法
- server profile 下 JWT 密钥缺失即拒绝启动:此前静默回退到公开默认值(等同无防护,
  任何人可伪造 token),只留 warn 日志拦不住发布事故;本地/测试 profile 保持宽松

边界
- 分页/条数参数补齐上限钳制(文档早已声明"最大 N"但无校验):
  PriceTrack/ShopMatch 的 page_size → 2000;TaskFileJob 的 limit → 1000;
  AppearancePatent/Publish 的 limit → 100
2026-09-14 05:02:33 +08:00
huangzd1997 4daf235385 docs(更新日志): 追加客户端 4.0.12 条目 2026-09-14 04:36:35 +08:00
huangzd1997 6d46506726 fix: 全维度审查修复(安全/正确性/性能/稳定性/客户端/前端)
安全
- /api/ziniao/** 五个匿名接口加管理员鉴权(此前可匿名换取任意员工店铺登录令牌)
- 删除 Flask 遗留后门:默认密码建超管 + 每次启动写生产 users 表(服务端与客户端各一份)
- 进度/详情接口归属过滤:新增 TaskProgressOwnershipSupport,11 模块 progress/light 与
  /tasks/batch 接入,DTO 补 userId,前端 13 个查询封装补传(未传时后端不过滤,兼容旧端)
- 代理提取链接(含账密)不再明文入日志(新增 common/util/SecretMasking)
- 全局异常兜底不再回传原始异常信息;内部令牌比较改常量时间
- 登录加失败计数与锁定(10 次锁 15 分钟);品牌源文件下载加 SSRF 防护
- AdminApiGuardFilter 覆盖前缀从 2 扩到 15(开关默认 false,行为不变,为收紧做准备)
- 生产关闭 springdoc/knife4j(/doc.html 匿名可读全部接口定义)

正确性
- 40901/40902 拆分:锁竞争不再被伪装成 success=true(此前客户端停止重试、分片静默丢失)
- 假成功收敛:集采明细批量写失败改为抛出、去重 worker 异常标失败、4 个 worker 改判
  success 字段、publish 空 ASIN 行参与批次 flush、巡店删除全失败带 error 上报
- 客户端心跳 discard 移入 finally(7 模块,失败路径不再留僵尸 RUNNING 任务)
- 状态机条件更新:跟价停止循环、集采 activate/fail、imagevideo 归档回填、店铺匹配提交

性能
- 前端入口包 JS 1.05MB→204KB、CSS 355KB→10.7KB(Element Plus 改按需 + el-config-provider)
- 载荷引用计数按指针里的 taskId 收敛(原 JSON 列 IN 全表扫且逐行调用)
- 店铺明细多值批量 INSERT;快照 upsert 预载缓存;结果文件列改单条 UPDATE
- 新增迁移 V120(补 3 个缺失索引)/V121(删 4 个被覆盖的冗余索引)/V122(URL 前缀索引)

稳定性
- 新增 common/util/ThreadPools 有界线程池替换 5 处无界队列(防堆积 OOM)
- Redis 锁释放改 Lua 原子校验(原裸 delete 会误删他人已过期的锁)
- imagevideo 加死节点接管;锁续期失败重试;调度池 4→16;openStream 全部加超时
- 事务内远程对象删除移到提交后;启动恢复锁按实例命名

客户端
- 不再 taskkill /f /im chrome.exe(改为按调试端口精准回收,不杀用户自己的浏览器)
- 密码检测不再无条件杀紫鸟进程;品牌检测加全局互斥(代理池不再互相覆盖)
- base_dir 统一到 exe 目录(原被 os.getcwd() 覆盖,日志/缓存会分裂两个目录)
- 缓存加定时清理;图片下载加超时;mkstemp 句柄托管

测试
- 同步更新受影响的契约测试(构造器签名/条件更新/方法改名/新增接口方法等)
- 修复 FaultInjectionTest 等 3 处 mock 未 stub 流式 read 导致的读循环 OOM
- mvn test 2795 个测试全绿
2026-09-14 04:15:36 +08:00
huangzd1997 c448f49e30 fix(临时存储): RustFS 上传失败改为直接失败,不再静默回落 local 指针
多实例/容器化部署下 local 指针只有写入它的实例能读(跨节点读直接报错、容器重建即丢),
此前上传失败静默降级会把跨节点不可读的脏指针落库,故障延后到其它节点的合并/组装才爆。
现改为在写失败点抛 BusinessException(中文原因透传调用方),并新增
fallback-to-local-on-error 开关(默认 false)供单机部署回退旧行为;
跨实例读 local 指针的报错改中文并带 objectKey;超限回落策略不变。
2026-09-14 02:31:16 +08:00
huangzd1997 3f5a234c59 fix(测试): 修复 3 处既有红灯——快照行尾、缺失基准文档、失效的边界棘轮
这三处在本次重构开始前就是红的(已在裸 HEAD 上复现),一并修掉,使 backend-java
相关测试恢复全绿(550 测试 0 失败)。

1. SimilarAsinSnapshotTest:golden 文件在 core.autocrlf=true 的检出下是 CRLF,
   而渲染结果按 LF 拼接,断言逐字符比较只差换行符即失败。改为读入时统一行尾。
   (这是测试自身缺陷,不是解析行为变化——两边的可打印内容完全一致。)

2. TxDurationBenchmarkTest:依赖 docs/tx-duration-benchmark.md,而该文档从未提交过
   (git 历史中不存在),导致 2 个用例必然失败。补齐文档,按 mock 环境实测记录
   单次事务段基线、200 分片上界与总耗时上界,并注明该基线只用于相对劣化判定。

3. ArchitectureBoundaryTest:task→业务依赖棘轮冻结在 84(task-212 后的存量),
   此后 TaskHeartbeatService 跨模块心跳(13)、StaleTaskRepairService(4)、
   ModuleHistoryCleanupService(2)、TaskResultFileJobWorker(2)持续接入新模块,
   实测已达 110,棘轮长期失效(恒红=无人看)。对齐到 110 恢复告警,并写明
   「新增依赖请走 Handler SPI,不要直接上调」。
   注意:并行会话正在改 task 模块(含 StaleTaskRepairService),其改动落地后需重新实测。
2026-09-14 02:01:16 +08:00
huangzd1997 b05bba50fa refactor(similar-asin): 抽出 LlmPipelineSupport,Service 降至 1735 行(累计 -70.7%)
在上一提交(3455 行)基础上,把「Python 结果回传 → 分片落库 → LLM 检测」整条流水线
(57 个方法 / 1694 行)抽为 SimilarAsinPipelineSupport。等价搬移,未改行为。

采用依赖倒置消除循环依赖:support 包声明 SimilarAsinPipelineHost 接口
(finalizeTask / findOrCreateResultRecordForAssembly / readCategorySwitch /
finalizeExhaustedResultFileJob),由 SimilarAsinTaskService 实现;这几项保留在宿主
是因为它们属于编排与事务边界(handleResultFileJobFailure 带 @Transactional)。

同时把 5 个被两侧共用的内部 record 提为 support 包顶层类型
(SubmittedTaskMetadata / FinalizeTaskResult / SubmitContext /
 PersistSubmittedChunkResult / PreparedSubmittedChunk),3 个仅流水线内部使用的
record 内联进流水线类;LlmBatchContext 一并归位。

测试适配:4 个测试类里对 mergeLlmRowsIntoChunk / bufferLlmRowsOrMerge /
flushLlmBufferedResults 的反射改指向流水线实例;RollbackSemanticsContractTest
通过 pipelineSupport() 反射取得实例(跨包,保持封装)。

验证:干净工作区叠加本改动跑 similarasin 386 + task 引用方 166 测试,
结果与基线一致(仅既有失败),零新增失败。
2026-09-14 01:51:36 +08:00
huangzd1997 9d92fb4af2 refactor(similar-asin): SimilarAsinTaskService 拆分为 9 个 support 类(5914→3455 行)
从 5914 行的巨型 Service 中按内聚单元抽出 9 个 support 类 + 1 个顶层 record,
净减 2459 行(-41.6%)。全部为等价搬移(Javadoc 标注搬移来源),未改任何行为:

- SimilarAsinLimits            阈值/开关解析(parse 上限、chunk merge 上限、LLM batch/缓冲/flush)
- SimilarAsinPayloadSupport    解析载荷编解码与读取门面
- SimilarAsinPoisonTracker     毒行滑窗熔断状态(Service 从此无进程内可变状态)
- SimilarAsinChunkMergeSupport 行键族 + chunk 合并纯计算
- SimilarAsinResultTextSupport 结果行判定与用户可见文本渲染
- SimilarAsinChunkPayloadSupport chunk 载荷读取、读失败诊断、orphan 兜底
- SimilarAsinResultWorkbookAssembler 结果文件装配(xlsx/zip、POI、DISPIMG、并发)
- SimilarAsinTaskOwnershipSupport    实例归属判定与 per-task 分布式锁
- SimilarAsinTaskProgressSupport     文件构建进度、任务视图映射与计数
- LlmBatchContext              从内部 record 提为顶层,供归属与 LLM 流水线共用

顺带清理 7 处死代码(imageUrlCellValue、resolveResultDownloadUrl/Filename、
applyLlmToPersistedChunks、countCompletedLlmStates、hasPromptFields、
userFacingConclusion、@PreDestroy import)。

assembleExecutor 仍由 Service 持有,shutdownAssembleExecutor 语义不变(18 个测试未动)。
验证:在干净 HEAD worktree 上叠加本改动跑 similarasin 全量测试,结果与裸 HEAD 一致
(386 测试,仅 3 个 SimilarAsinSnapshotTest 既有失败),零新增失败。
2026-09-14 01:30:11 +08:00
huangzd1997 db6869b77e perf(LLM任务): 首查短超时快速失败 + 退避抖动 + 标题失败跳过外观识别
生产 24h 数据:外观专利任务 LLM 重试失败 93+82 次几乎全是超时,
终态 41 行降级为「外观识别异常」;成功调用 p99=34s、超 60s 仅 0.02%。
原策略每轮重试都挂满 90s(被全局 call-timeout 钳制),且 1.5s/3s
密集重试整批落在同一劣化窗口内。

- 第 1 次尝试改用 llm-first-attempt-read-timeout-millis(默认 60s)快速失败,
  重试走完整读超时,慢而成功的正常调用不被误杀
- 重试等待改 LlmRetryBackoff:2s/10s + ±30% 抖动,覆盖更长窗口并打散同批尖峰
- 外观专利标题识别失败时跳过外观请求(该行必走回退,外观结果本就会被丢弃)
2026-09-14 00:28:40 +08:00
huangzd1997 864c22ffc7 fix(密钥检测): 直连失败重试一次,超时/网络提示中文化
上游 ai.t8star.org 实测约 1/8 单请求完全不应答(主机 A 上 JDK 客户端
HTTP/1.1 与 HTTP/2 均复现),检测只有一次机会时用户会看到
「网络不可达:HttpTimeoutException: request timed out」。

- 传输层失败(超时/网络不可达)对直连最后一跳重试一次(800ms 间隔);
  代理模块不重试——jikip 按提取次数计费,重试会多扣一次
- 失败文案中文化(新增 CODE_TIMEOUT),英文异常串只进服务端日志
- 探测提问改「你好」(最简一次调用)
- 检测面板标明检测对象(配置密钥 sk-**** / 输入值(未保存)),
  检测接口超时单独放宽(客户端 60s / 后台 180s),避免重试期间前端先超时
2026-09-14 00:28:35 +08:00
huangzd1997 fd614004b0 fix(撞款e2e): 台账分页断言限定到台账表格紧邻分页器
台账 tab 下页面同时有「店铺上架分布」分页器(设计要求常显)与台账分页器,
全页 .old-pagination 命中 2 个元素触发 strict mode;断言改为 .table-wrap + .old-pagination,
用例意图不变(台账分页可见)。撞款 4 用例全绿,全量 e2e 44 通过(通知面板 1 例失败系并行会话在改 NotificationBell.vue,与本改动无关)。
2026-09-14 00:25:19 +08:00
huangzd1997 ff1ffbbfa3 feat(站内通知): 铃铛面板支持时间/内容搜索、按天分组与分页
- 列表接口加 keyword(标题/内容模糊)与 startDate/endDate(年月日闭区间)参数,
  两端控制器透传,服务端补筛选日志
- 两端铃铛面板:搜索框(防抖 300ms)+ 日期区间 + 按年月日分组 + 翻页,
  面板改 Teleport 到 body(挂在顶栏时会被页面 el-select 压住,提 z-index 无效)
- 固化可见范围回归测试:超管全量/管理员只看本组/普通用户只看自己,
  含读写两侧的 user_id+audience 裁剪断言与两端控制器身份来源断言
2026-09-13 23:59:06 +08:00
huangzd1997 9ffd68bae5 feat(教程管理): 后台可上传教程包,工具台按最新上传下载
- admin-vue 新增「记录与版本 → 教程管理」页(menuKey admin_tutorial):
  zip 浏览器直传 MinIO(presign → PUT 带进度 → confirm 落库)+ 列表/下载/删除,
  最新一条标「当前生效」;补操作指引与 align-tutorial 守卫测试。
- frontend-vue 工具台「立即下载教程」进页面取 /api/tutorial/latest(最新上传优先),
  接口异常或无记录回退历史固定直链,按钮不失效。
- Java 侧 tutorial 模块与 V119 迁移已在 dc6e8924 提交并上线(2026-09-13)。

vue-tsc + 两端单测(工具台 695 / 后台 1617)通过;生产已实测
presign→PUT→confirm→latest 切换→删除回落全链路。
2026-09-13 23:48:24 +08:00
huangzd1997 14a723ab1c refactor(品牌工具页): 历史批量删除抽为 runBatchDelete
16 个品牌页逐字相同的 batchDeleteHistory 循环(逐条删除 + 容忍个别失败计数)
收敛为 shared/utils/batch-delete.ts 的 runBatchDelete;各页仅保留自己的删除调用
与提示文案。补单测 4 例(空列表 / 全成功 / 部分失败继续 / 非 Error 异常)。

净减约 120 行;vue-tsc 构建与 699 个前端单测通过。
2026-09-13 23:32:27 +08:00
huangzd1997 dc6e8924a9 refactor(品牌工具页): 历史轮询抽为 useHistoryPolling
QueryAsin / Withdraw / PatrolDelete 三页逐字相同的 startHistoryPolling /
stopHistoryPolling 收敛为 shared/composables/useHistoryPolling:定时器走各页
categorized-timers(category 固定 history-poll),间隔默认主轮询的 2 倍。
categorized-timers 补 CategorizedTimers 类型导出;补注入式假定时器单测 5 例。

净减约 40 行;vue-tsc 构建与 695 个前端单测通过。
2026-09-13 23:22:49 +08:00
huangzd1997 882ccdac12 refactor(品牌工具页): 抽取 formatDateTime 与 uploadPathsToJava 公共工具
- 新增 shared/utils/datetime.ts:13 个品牌页逐字重复的 formatDateTime 收敛为一处;
  语义不同的 3 个变体(toLocaleString / 字符串切片 / 支持时间戳)保留不动
- 新增 shared/utils/upload-to-java.ts:10 个品牌页的上传循环收敛,api 依赖注入便于单测;
  保留 uploadOss(品牌Tab)与 returnEmptyWhenUnavailable(跟价)两处行为差异
- 补 datetime / upload-to-java 单测 10 例

净减约 327 行;vue-tsc 构建与 690 个前端单测全通过
2026-09-13 23:16:59 +08:00
huangzd1997 b70557a077 feat(认证/通知): 单设备登录互踢 + 站内通知铃铛系统
- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token
  在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。
  前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine
- 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源),
  前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表

均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
2026-09-13 23:08:22 +08:00
huangzd1997 a0f6582914 feat(密钥管理): 代理配置后台明文展示 + 按次统计各密钥调用损耗
- 后台密钥管理「代理设置」列改为完整明文展示(含账号密码),便于运维核对;
  新增 full 字段仅对代理模块下发,密钥两列保持脱敏
- 新增 biz_user_secret_usage_daily(V117):用户 × 模块 × 天累计真实对外请求次数
- 计次口径:LLM 每次真实 HTTP 请求(含重试)计 1 次;代理每次成功提取计 1 次
- LLM 埋点走 SecretUsageContext 上下文(批次外设置、线程池内快照恢复)
- 新增内部上报接口 /api/internal/user-secret-usage(X-Internal-Token)
- 新增后台页「密钥用量统计」:日期范围 + 用户名 + 分组筛选,含范围内汇总
2026-09-13 21:56:46 +08:00
huangzd1997 0c98c5bc15 docs(auth): 修正 JWT 默认密钥注释——桌面客户端本地 Flask 退役后服务端可独立轮换
原注释称"客户端内置同一密钥,服务端不能换"已过时(本地 Flask 与 /api/auth/sync
验签链路已随服务端化退役),会误导运维不敢轮换;2026-09-13 生产已轮换为
application-server.yml 独立密钥并验证。
2026-09-13 21:44:20 +08:00
huangzd1997 1066625078 feat(品牌服务): 内部接口按 uid 供用户代理提取链接;代理检测改真实提取+转发探测
- 新增 /api/internal/user-proxy(X-Internal-Token 自校验,18080 公网可达必须校验),
  供主机 A 品牌检测服务(15126)按 uid 取用户代理提取链接,未配置返回空串
- 代理检测修复"假通过":旧实现把提取链接直接当静态代理交给 HttpClientPool,
  链接无显式端口解析失败静默回退直连,检测到的是自家站点直连响应(40 个用户
  3-39ms 全 301 假通过);现在先真实提取一次(余额不足/解析失败如实报),
  再经提取到的代理请求自家域名,兼容旧静态代理地址
2026-09-13 21:21:48 +08:00
huangzd1997 fe052879bd fix(用户管理): 删除用户时级联清理服务端密钥,修已删账号留下孤儿密钥行
biz_user_api_secret 按 uid 绑定且无外键,删除用户后密钥/代理配置成为无主行
(生产已出现 5 条孤儿)。deleteUser 内同步调用 adminClearByUser 并纳入同一事务,
删除失败(行不存在)时不触碰密钥。
2026-09-13 21:21:45 +08:00
huangzd1997 51c5fe1129 fix(任务中断): 已终态任务也幂等刷新缓存/快照,修历史遗留的处理中卡死
第一次修复只在 DB 刚被改成 FAILED 时同步缓存;对 DB 已是终态、但 Redis 里
仍缓存 RUNNING 的历史任务不生效(真机:28094 反复调中断仍返回 progress=RUNNING)。
现在无论是否刚更新 DB,都按 DB 现值同步模块缓存与进度快照,可自愈。
2026-09-13 15:46:18 +08:00
huangzd1997 3d208ea0e5 fix(任务中断): 标失败时同步模块缓存与进度快照,修"处理中"卡死
客户端重启上报中断只改了 file_task,模块缓存/进度快照仍是 RUNNING,
导致 progress/batch 继续回报 RUNNING:前端任务面板永远"处理中"并阻塞该工具后续任务
(2026-09-13 真机:店铺数据采集 28094 history=FAILED 但 progress=RUNNING,界面卡住)。
markInterrupted 现在同时刷新模块缓存(saveFileTaskCache)与进度快照(新增 markTerminal)。
2026-09-13 15:16:14 +08:00
huangzd1997 a4f60ef21c fix(权限): 用户菜单权限“自己没掉”——分区落库时级联清理跨类型误删 + 编辑弹窗空授权提交
- 级联清理的有效集改为按目标「完整直接授权」计算(读库,含 admin/app 全类型)。
  此前 admin/app 分区落库把本次提交的 id 当成完整有效集,员工持有的另一类型授权
  会整体被判越权删除;两次分区落库互相补刀,最终清空员工全部菜单权限
  (生产表现:保存某非超管的权限后,他直建员工的菜单隔三差五自己消失)
- AdminUserService 改为两类落库完成后再统一级联一次,新增 cascadeSubordinateOverreach 入口
- 后台「编辑用户」弹窗:授权未加载完成/失败时禁用保存并明确提示,
  避免以空 columnIds 整树清空该用户授权(改动密码等操作也会连带触发)
- 回归测试:deferredCascadeKeepsSubordinateGrantsOfOtherMenuType、
  perTypeReplacementDoesNotCascadeBeforeAllTypesWritten、edit_user_dialog_blocks_save_until_auth_loaded

已知既有红测试(与本次改动无关,干净 HEAD 上同样复现,未新增):
ArchitectureBoundaryTest.taskToBusinessDependencyDoesNotGrow(110 > 基线 84)、
HttpClientTimeoutEffectiveTest.connectTimeoutFiresOnUnreachableHost(本机网络环境 60s)
2026-09-13 14:18:01 +08:00
huangzd1997 3f6ad0c6ad feat(后台分组): 分组筛选与分组列仅超管可见,非超管一律隐藏
管理员/普通用户数据已由后端裁剪到本人可访问分组,分组维度对其无意义;
账号归属分组是超管职责,故只有超管需要按分组筛选与区分。

- 隐藏分组筛选+分组列:品牌数据库、查询ASIN、最低价ASIN、去重总数据、
  店铺管理、用户密钥
- 仅隐藏分组展示:店铺数据记录(筛选框+卡片行)、图片视频任务(所属分组行)、
  撞款检测(明细抽屉分组列+卡片分组标签)
- 弹窗分组选择保留展示但锁定:非超管仅1个可访问分组时自动选中并置灰
  (查询/最低价ASIN 另联动加载该分组店铺),多分组不锁以免限制用户
- 空数据行 colspan 随列显隐动态化
- 新增 tests/align-group-visibility.test.ts 回归守卫(5 条)
2026-09-13 14:00:19 +08:00
huangzd1997 0323f0bb7b fix(密钥管理): 分组口径改为「分组创建人名下用户」
- 生产分组成员表 biz_shop_manage_group_member 基本未使用(仅 2 条老数据),导致分组列恒空
- 实际归属:分组 created_by_id/user_id 即主管,其组员为 users.created_by_id 指向该主管的用户
- 分组名批量查询与按分组圈人 SQL 均按此关系重写;按分组筛选含组长本人
- 无分组记录的用户分组列回退展示「所属主管(未建分组)」,避免空值
2026-09-13 13:42:06 +08:00
huangzd1997 1b733b23b9 feat(密钥管理): 按数据权限分组隔离与筛选
- 后台密钥列表改为按 biz_shop_manage_group 分组:主管只看自己带的分组成员,超管看全量可按 group_id 筛选
- 行数据补「所属分组」名称(批量查询);列表接口带回分组筛选项
- 修复超管筛选不生效:前端筛选参数统一 snake_case(camel 被后端静默忽略)
- 列表列/筛选项文案由「所属管理员」改为「分组」
2026-09-13 13:34:08 +08:00
huangzd1997 05ae0c62d6 feat(密钥管理): 后台分组过滤+欠费状态识别
- 后台密钥列表:主管只看本组子账户(created_by_id=自己),超管全量可按创建人筛选;行 VO 新增 createdById/createdByUsername
- 代理提取接口欠费(message=余额不足)识别为 insufficient_balance,不再静默回退直连
- 上游 LLM 网关欠费(code=insufficient_user_quota/预扣费额度失败)同样归为欠费并透传额度明细
- 前端:桌面设置面板显示"欠费",admin 后台单格药丸显示"欠费"、新增所属管理员列与超管筛选
2026-09-13 13:22:14 +08:00
huangzd1997 4bc4969e5a docs(backend-java): 恢复 flyway 迁移模板/演练/盘点文档(原来只在未合入的并行分支上,master 的 MigrationInventory/FlywayTemplate 契约测试一直红) 2026-09-13 13:03:33 +08:00
huangzd1997 8cd8390d95 fix(健壮性): Java OOM 三处 + 双TE 502 根因 + admin-vue 403 白屏
Java:
- similarasin Excel 解析改 EasyExcel 流式(原 WorkbookFactory 全量 DOM,大表 OOM)+ 魔数校验
- collectdata 结果组装改游标分批 + 导入改流式(原全量驻留内存)
- GlobalExceptionHandler 转发响应过滤逐跳头与实例标识头(双 Transfer-Encoding 导致 nginx 502 的根因)
admin-vue:
- 403(无后台权限,如工具号 token)与 401 同样跳登录页,修复后台白屏
- task-266 测试断言对齐 daily-files 端点演进
2026-09-13 12:58:49 +08:00
huangzd1997 2a766efb83 feat(客户端密钥面板): 代理设置支持连通性检测与状态展示
- 代理卡片新增检测按钮:地址有改动时检测输入值(不落库),否则检测/落库已存值
- 状态行展示检测结果(连通正常/失败原因/耗时),颜色区分通过/失败/警示
- 余量无数据时显示"余额查询暂不可用"占位
2026-09-13 12:14:41 +08:00
huangzd1997 afa8560369 style(后台密钥管理): 用户名列加宽至 260px 并允许换行,长用户名不再截断 2026-09-13 10:51:19 +08:00
huangzd1997 c81ebb7053 fix(前端测试): 修复 npm test 卡死——定时器泄漏 + 去套娃 + 强制退出/超时
- 根因:polling-backoff-recovery 测试断言失败后跳过 loop.dispose(),残留自续期定时器导致 node --test 子进程永不退出
- dead-code-cleanup:移除套娃用例 test_full_vitest_green(再跑一遍全量套件,放大问题),execSync 加 300s 超时;其余静态断言保留
- package.json:node --test --test-force-exit --test-timeout=180000
- CI workflow 前端测试步骤改为 npm test(与 package.json 单一来源)
- 修正 3 处过时期望值(轮询退避/品牌 files+taskType/巡店 delete_conditions)
- 验证:本地 666 用例全过,19-24s,无残留进程
2026-09-13 10:51:19 +08:00
huangzd1997 b7d4d325e0 feat(密钥管理): 列表按用户聚合三字段列 + 代理配置服务端上报
- Java:后台列表一行一用户(货源查询密钥/外观专利密钥/代理设置),行级状态三项全通过才算通过;检测/清空改为按用户;搜索仅按用户名(去 UID);新增代理检测(经用户代理请求自家域名)与代理掩码(隐去账密)
- 后台前端:三字段列改版 + 行级状态筛选 + 用户名筛选
- 桌面前端:登录加载密钥时补报本地代理(只填空缺不覆盖)、保存/清空代理实时上报
2026-09-13 10:47:19 +08:00
huangzd1997 82a782550e feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定)
- 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检
- 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示
- 删除专利汇令牌全链路与密钥保留时长选择器
2026-09-13 10:03:59 +08:00
huangzd1997 d1b56918fa fix(跟价): 指定ASIN文件提交改 fileKey + 服务端三形态解析兼容
- 前端 resolveAsinRequestPaths 改发上传返回的 fileKey(与去重/转换/采集一致),
  修复安全加固后服务端按拼接解析绝对路径失败导致"ASIN 文件不存在或不可读"
- Java parseAsinRowsByCountry 三种形态解析:绝对路径直读 → tempRoot 拼接 → fileKey 反查,
  均过 isInsideTempDir 穿越校验,不放开临时目录外读取
2026-09-13 03:01:56 +08:00
huangzd1997 441917bfce feat(后台/店铺数据): 结果下载增加进度弹窗
批量打包下载与单店下载接入 axios onDownloadProgress,progress 弹窗显示
百分比;后端未回 Content-Length 时退化为 indeterminate 流动条,避免用户
在大 zip 下载期间以为页面无响应。
2026-09-12 11:42:33 +08:00
huangzd1997 037a689316 chore(日志): 服务端高频轮询日志降噪
-history timing 降debug;status无变化降debug;/history /list免INFO;stale-check合并单行;心跳常规响应降debug
2026-09-11 21:22:41 +08:00
huangzd1997 db7cd22ed1 fix(鉴权/测试): 用户态工具鉴权改开关控制(默认关)+ 修复既有测试编译与断言
- collect-data/price-track 兜底鉴权改由 aiimage.security.user-tool-guard-enabled
  控制,默认 false:老客户端直连不带认证头,先开启会让线上集采/跟价全线 401,
  待携带 X-Internal-Token 的新客户端铺开后再置 true
- 补齐 DistributedJobLockService 构造参数缺失的 6 个测试类(双活分布式锁
  commit 只改了主代码,测试已无法编译);断言按"先删 DB 行再物理删"的现语义校正
2026-09-11 18:03:54 +08:00
huangzd1997 9962d9797e feat(后台): 店铺密钥页支持配置静态代理 + 手动检测白名单——列表展示代理/失败次数,操作列加「检测白名单」按钮(绕过缓存真实请求并重置自动重试计数) 2026-09-11 17:18:27 +08:00
huangzd1997 47459e2089 feat(紫鸟): 令牌级静态代理支持——白名单被拒的 key 走已授权代理 IP
- shop_key 新增 proxy_url / ip_whitelist_fail_count(V114 迁移)
- HttpClientPool 支持按代理地址复用的静态代理客户端
- 白名单连续失败达阈值后停止自动重试,控制台可按 key 配置代理(改动自
  上一工作阶段遗留,功能已编译验证)
2026-09-11 17:11:50 +08:00
huangzd1997 e6021593ea fix(安全/健壮性): 全工作区审查修复——鉴权兜底扩展+路径穿越+忙等+泄漏
- AdminApiGuardFilter 兜底扩展到 /api/collect-data、/api/price-track:无需鉴权的
  工具接口纳入 JWT/内部令牌校验(原匿名可达即越权读写他人数据)
- pricetrack asinFiles 改为仅允许上传临时目录内文件(canonical 前缀校验),
  修复请求路径直接 new File 可读服务器任意 csv/xlsx 的穿越
- dedupe 删除导入逐行 REQUIRES_NEW 事务改 500 条一批 IN 删除,50 万行导入
  由 50 万个事务收敛为千级
- 前端记住密码 XOR 硬编码密钥改 WebCrypto AES-GCM(密钥随机生成独立存储),
  登录流程接口改 async 并保证自动登录恢复时序
- 任务进度轮询失败按指数退避(原固定 5s 无限撞);下载进度终态条目 2 分钟
  自动清理(原永久堆积);AmazonConsolePage statusTimer 卸载清理
2026-09-11 17:11:43 +08:00
huangzd1997 540d6588e6 fix(紫鸟): 店铺索引刷新加轮级时间预算熔断——单轮超 8 分钟即中断,剩余 key 顺延下一轮(游标续跑),避免紫鸟接口半死时整轮占锁几十分钟阻塞后续定时轮 2026-09-11 17:02:13 +08:00
huangzd1997 2a9110ec32 perf(店铺): 后台添加/改名店铺后异步触发一次紫鸟索引刷新——防抖合并2分钟窗口+延迟10秒等事务提交,拿不到锁或失败不影响添加流程,不等5分钟定时轮 2026-09-11 16:54:16 +08:00
huangzd1997 51fb77ec25 perf(紫鸟): 店铺索引刷新提速——间隔 10→5 分钟、每轮批量 100→200 个 apiKey,新店铺未命中等待时间约缩短 3/4 2026-09-11 16:38:56 +08:00
huangzd1997 30d31fb318 feat(代理): 代理配置按登录用户隔离——桌面端 current_uid 登录同步 + brand 页 proxy_users[uid] 读写,各用户各自代理池各自计费复用(未登录回退全局 proxy_url 兼容旧配置) 2026-09-11 16:09:56 +08:00
huangzd1997 bb52574767 fix(更新检测): 版本比较改真实比大小——线上版本不高于本机一律不提示更新;本机高于线上时明确提示无需更新(灰度/回滚/漏发版场景不再被引导降级) 2026-09-11 16:09:53 +08:00
huangzd1997 cd055f8ccd fix(内存): 两处无界内存风险加固——brand-check 线程池改有界队列+CallerRuns(大批量检查不再无界堆积 OOM);OSS readObjectBytes 加 20MB 读取上限防大对象误用 OOM 2026-09-11 16:09:50 +08:00
huangzd1997 c2915036d6 fix(调度): 双节点下 collect-data/publish/image-video 定时调度加 Redis 分布式锁互斥——双活原来各自扫描执行同一批任务,重复派发/重复终态判定 2026-09-11 16:09:46 +08:00
huangzd1997 8dc03df95b feat(task): 客户端崩溃恢复接口 POST /heartbeat/{taskId}/interrupted——客户端重启发现上次进程崩溃时立即把残留 RUNNING 任务标终态,替代最长等 30 分钟的心跳超时兜底 2026-09-11 16:09:42 +08:00
712 changed files with 49545 additions and 9198 deletions
+2 -2
View File
@@ -43,6 +43,6 @@ jobs:
- name: Type check (vue-tsc)
working-directory: frontend-vue
run: npm run build
- name: Frontend contract tests (node --test)
- name: Frontend contract tests (npm test → node --test)
working-directory: frontend-vue
run: node --test tests/*.test.ts
run: npm test
+1
View File
@@ -100,6 +100,7 @@ ipython_config.py
*.sqlite3
# ===== Misc =====
.playwright-cli/
desktop/
ERP-Demo/
xlsx/
+3 -1
View File
@@ -32,7 +32,9 @@ test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page })
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
await expect(page.locator('.old-pagination')).toBeVisible()
// 台账 tab 下页面同时有「店铺上架分布」分页器(常显,见 tests/align-dup-console.test.ts)与台账分页器,
// 全页 .old-pagination 会命中 2 个元素触发 strict mode;限定到台账表格(.table-wrap)紧邻的分页器。
await expect(page.locator('.table-wrap + .old-pagination')).toBeVisible()
expect(errors).toEqual([])
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
})
@@ -0,0 +1,44 @@
import { expect, test, type Page } from '@playwright/test'
// 后台管理列表「创建时间 + 更新时间」两列验收(2026-09-19 需求):
// 8 个实体管理列表统一展示创建/更新时间,数据取自各列表 VO。
// mock 夹具统一给 createdAt=2026-03-01 09:15:00、updatedAt=2026-09-18 16:42:30
// 页面统一经 formatDateTime 归一为「YYYY-MM-DD HH:mm:ss」。
const CREATED = '2026-03-01 09:15:00'
const UPDATED = '2026-09-18 16:42:30'
const PAGES: Array<{ title: string; path: string; heading: string }> = [
{ title: '用户管理', path: '/admin-vue/account/users', heading: '用户列表' },
{ title: '菜单管理', path: '/admin-vue/account/menus', heading: '菜单' },
{ title: '密钥管理', path: '/admin-vue/account/user-secrets', heading: '用户密钥列表' },
{ title: '不符合ASIN数据', path: '/admin-vue/asin-center/invalid', heading: '品牌数据列表' },
{ title: '数据去重总数据', path: '/admin-vue/asin-center/registry', heading: '数据去重总数据' },
{ title: '查询ASIN', path: '/admin-vue/asin-center/query', heading: '查询' },
{ title: '最低价ASIN', path: '/admin-vue/asin-center/skip-price', heading: '最低价' },
{ title: '商品类目', path: '/admin-vue/asin-center/categories', heading: '商品类目' },
]
async function openPage(page: Page, path: string) {
await page.goto(path)
await expect(page.locator('.panel-box table tbody tr').first()).toBeVisible({ timeout: 15000 })
}
for (const spec of PAGES) {
test(`${spec.title}:表格展示创建时间与更新时间`, async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openPage(page, spec.path)
const table = page.locator('.panel-box table').first()
// 表头出现两列
await expect(table.locator('thead')).toContainText('创建时间')
await expect(table.locator('thead')).toContainText('更新时间')
// 数据行渲染出真实时间值(非空、非占位符)
await expect(table.locator('tbody')).toContainText(CREATED)
await expect(table.locator('tbody')).toContainText(UPDATED)
expect(errors).toEqual([])
})
}
@@ -0,0 +1,122 @@
import { expect, test, type Page } from '@playwright/test'
// 站内通知铃铛面板验收:关键字/年月日区间搜索、按天分组、分页。
// 依赖 scripts/mock-admin-server.mjs 的通知夹具(14 条:09-13 六条、09-12 四条、09-10 四条)。
async function openBell(page: Page) {
await page.goto('/admin-vue/')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await page.locator('.admin-notification-bell .bell-trigger').click()
await expect(page.locator('.bell-panel')).toBeVisible()
await expect(page.locator('.bell-item').first()).toBeVisible()
}
test('test_notification_panel_day_group_and_pagination', async ({ page }) => {
await openBell(page)
const panel = page.locator('.bell-panel')
const nextPage = panel.getByRole('button', { name: '下一页' })
// 每页 10 条,跨两个日期分组,页脚显示页码与总数
await expect(page.locator('.bell-item')).toHaveCount(10)
await expect(page.locator('.bell-day-head')).toHaveCount(2)
await expect(page.locator('.bell-page-info')).toHaveText('1 / 2')
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
await expect(page.locator('.bell-day-head').first()).toHaveText(/^(今天|昨天|\d{4}年\d{1,2}月\d{1,2}日)$/)
const page2Request = page.waitForRequest(
(request) => request.url().includes('/api/admin/notifications?') && request.url().includes('page=2'),
)
await nextPage.click()
await page2Request
await expect(page.locator('.bell-item')).toHaveCount(4)
await expect(page.locator('.bell-page-info')).toHaveText('2 / 2')
await expect(page.locator('.bell-day-head')).toHaveCount(1)
await expect(nextPage).toBeDisabled()
})
test('test_notification_panel_search_by_keyword', async ({ page }) => {
await openBell(page)
const keywordRequest = page.waitForRequest((request) =>
decodeURIComponent(request.url()).includes('keyword=余额不足'),
)
await page.locator('.bell-search-input').fill('余额不足')
await keywordRequest
// 关键字命中标题/内容:夹具里 5 条含「余额不足」
await expect(page.locator('.bell-item')).toHaveCount(5)
await expect(page.locator('.bell-page-total')).toHaveText('共 5 条')
await expect(page.locator('.bell-search-reset')).toBeVisible()
await page.locator('.bell-search-reset').click()
await expect(page.locator('.bell-item')).toHaveCount(10)
await expect(page.locator('.bell-search-reset')).toBeHidden()
})
test('test_notification_panel_search_by_day_range', async ({ page }) => {
await openBell(page)
const rangeRequest = page.waitForRequest(
(request) =>
request.url().includes('startDate=2026-09-12') && request.url().includes('endDate=2026-09-12'),
)
await page.locator('.bell-date-input').first().fill('2026-09-12')
await page.locator('.bell-date-input').nth(1).fill('2026-09-12')
await rangeRequest
await expect(page.locator('.bell-item')).toHaveCount(4)
await expect(page.locator('.bell-day-head')).toHaveCount(1)
await expect(page.locator('.bell-page-total')).toHaveText('共 4 条')
await expect(page.locator('.bell-empty')).toHaveCount(0)
// 起止倒挂自动交换:09-13 起 / 09-10 止 → 实际按 09-10 ~ 09-13 查询(全量 14 条)
await page.locator('.bell-date-input').first().fill('2026-09-13')
await page.locator('.bell-date-input').nth(1).fill('2026-09-10')
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
await expect(page.locator('.bell-item')).toHaveCount(10)
})
test('test_notification_panel_not_covered_by_page_content', async ({ page }) => {
await openBell(page)
// 回归:面板挂在顶栏内时曾被页面 el-select 压住(提 z-index 无效),现改为 Teleport 到 body。
// 这里在面板内取多点做命中测试,最上层元素必须属于面板自身。
const covered = await page.evaluate(() => {
const panel = document.querySelector('.bell-panel') as HTMLElement
const rect = panel.getBoundingClientRect()
const points: Array<[number, number]> = [
[rect.left + 30, rect.top + 130],
[rect.left + rect.width / 2, rect.top + rect.height / 2],
[rect.right - 30, rect.bottom - 60],
]
return points
.map(([x, y]) => document.elementsFromPoint(x, y)[0])
.filter((el) => !!el && el !== panel && !panel.contains(el))
.map((el) => `${el!.tagName}.${String((el as HTMLElement).className).slice(0, 40)}`)
})
expect(covered, '面板被页面元素遮挡').toEqual([])
})
test('test_notification_panel_reference_screenshot', async ({ page }) => {
await openBell(page)
// 同日多条不得重叠:逐条校验纵向排布严格递增(截图伪影与真实布局问题的分界)
const tops = await page
.locator('.bell-item')
.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top))
for (let i = 1; i < tops.length; i += 1) {
expect(tops[i], `${i + 1} 条应排在第 ${i} 条下方`).toBeGreaterThan(tops[i - 1])
}
await page.screenshot({ path: 'test-results/notification-panel-admin-page.jpg', animations: 'disabled' })
await page.locator('.bell-panel').screenshot({ path: 'test-results/notification-panel-admin.jpg', animations: 'disabled' })
await page.locator('.bell-search-input').fill('余额不足')
await expect(page.locator('.bell-item')).toHaveCount(5)
await page.locator('.bell-panel').screenshot({
path: 'test-results/notification-panel-admin-search.jpg',
animations: 'disabled',
})
})
@@ -0,0 +1,79 @@
import { expect, test, type Page } from '@playwright/test'
// 教程管理页验收(module 记录与版本):列表新增「版本号」列 + 上传时间列,
// 默认按上传时间降序;版本号/上传时间表头可点击切换升降序;空版本历史行沉底。
// 依赖 scripts/mock-admin-server.mjs 的教程包夹具(4 条,其中 1 条无版本号、1 条 09-01 历史行)。
async function openTutorial(page: Page) {
await page.goto('/admin-vue/records/tutorial')
await expect(page.locator('.admin-topbar h1')).toHaveText('教程管理')
await expect(page.locator('.panel-box tbody tr').first()).toBeVisible()
}
const versionTexts = (page: Page) =>
page.locator('.panel-box tbody tr td:nth-child(3)').allTextContents()
const fileTexts = (page: Page) =>
page.locator('.panel-box tbody tr td:nth-child(2) .file-name').allTextContents()
test('test_tutorial_list_default_desc_by_upload_time', async ({ page }) => {
await openTutorial(page)
// 表头:版本号、上传时间(均带排序标记)
await expect(page.locator('.sort-version')).toHaveText(/版本号/)
await expect(page.locator('.sort-time')).toHaveText(/上传时间/)
// 默认排序状态:上传时间降序(▼),版本号未激活(▲▼)
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
// 默认按上传时间降序:09-14 → 09-12 → 09-10 → 09-01;空版本行沉底
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
expect(await fileTexts(page)).toEqual([
'数富AI-教学客户端-v4.zip',
'数富AI-教学客户端-v3.zip',
'数富AI-教学客户端-v2.zip',
'数富AI-教学客户端.zip',
])
// 当前生效 = 最新上传(夹具里 09-14 那条),不随列表排序变化
await expect(page.locator('.tag-active')).toHaveCount(1)
await expect(page.locator('.tag-active').locator('xpath=..')).toHaveText(/数富AI-教学客户端-v4\.zip/)
})
test('test_tutorial_sort_toggle_by_version_and_time', async ({ page }) => {
await openTutorial(page)
// 点「版本号」:首次为降序(v3.10.0 按自然序大于 v3.2.0),空版本行仍在末尾
await page.locator('.sort-version').click()
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▼')
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
// 再点一次切升序
await page.locator('.sort-version').click()
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲')
expect(await versionTexts(page)).toEqual(['v3.1.0', 'v3.2.0', 'v3.10.0', '—'])
// 点回「上传时间」:切换字段时回到降序默认
await page.locator('.sort-time').click()
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
expect(await fileTexts(page)).toEqual([
'数富AI-教学客户端-v4.zip',
'数富AI-教学客户端-v3.zip',
'数富AI-教学客户端-v2.zip',
'数富AI-教学客户端.zip',
])
})
test('test_tutorial_upload_dialog_has_version_field', async ({ page }) => {
await openTutorial(page)
await page.getByRole('button', { name: '上传教程包' }).click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.el-form-item').first()).toContainText('版本号')
await expect(dialog.locator('input').first()).toHaveAttribute('maxlength', '64')
// 不选文件直接提交:提示仍以 zip 为必填(版本号可空)
await dialog.getByRole('button', { name: '上传教程包' }).click()
await expect(dialog.locator('.el-alert')).toContainText('请选择 zip 压缩包')
})
@@ -142,6 +142,66 @@ function json(res, payload, status = 200) {
res.end(body)
}
/* ===================== 教程包夹具(教程管理页:版本号列 + 上传时间排序验收用) ===================== */
// 故意乱序给出,且含一条无版本号的历史行:页面默认应按上传时间降序、空版本行沉底。
const TUTORIAL_PACKAGES = [
{ id: 3, file_name: '数富AI-教学客户端-v3.zip', version: 'v3.2.0', object_key: 'tutorial/20260912090000-数富AI-教学客户端-v3.zip', file_size: 1048576, file_url: 'https://oss.aishufu.top/client/tutorial/t3.zip', created_at: '2026-09-12 09:00' },
{ id: 1, file_name: '数富AI-教学客户端.zip', version: '', object_key: 'tutorial/数富AI-教学客户端.zip', file_size: 0, file_url: 'https://oss.aishufu.top/client/tutorial/legacy.zip', created_at: '2026-09-01 08:00' },
{ id: 4, file_name: '数富AI-教学客户端-v4.zip', version: 'v3.10.0', object_key: 'tutorial/20260914103000-数富AI-教学客户端-v4.zip', file_size: 2097152, file_url: 'https://oss.aishufu.top/client/tutorial/t4.zip', created_at: '2026-09-14 10:30' },
{ id: 2, file_name: '数富AI-教学客户端-v2.zip', version: 'v3.1.0', object_key: 'tutorial/20260910120000-数富AI-教学客户端-v2.zip', file_size: 524288, file_url: 'https://oss.aishufu.top/client/tutorial/t2.zip', created_at: '2026-09-10 12:00' },
]
/* ===================== 站内通知夹具(铃铛面板:搜索/按天分组/分页验收用) ===================== */
const NOTIFICATIONS = [
...[6, 5, 4, 3, 2, 1].map((seq) => notif(seq, '2026-09-13', 22, seq, seq <= 3)),
...[4, 3, 2, 1].map((seq) => notif(20 + seq, '2026-09-12', 20, seq, true)),
...[4, 3, 2, 1].map((seq) => notif(30 + seq, '2026-09-10', 18, seq, true)),
]
function notif(id, day, hour, seq, read) {
const minutes = String(seq * 7).padStart(2, '0')
return {
id,
scene: id % 3 === 0 ? 'secret_balance' : 'task_failed',
level: id % 3 === 0 ? 'error' : 'warning',
title: id % 3 === 0 ? `用户密钥异常:测试用户${seq}` : `跟价任务失败`,
content: id % 3 === 0 ? `用户 测试用户${seq}uid=${1100 + seq})的代理设置对应服务商余额不足` : `用户 测试用户${seq}${seq} 个跟价任务失败`,
read,
readAt: read ? `${day} ${hour}:${minutes}:00` : null,
createdAt: `${day} ${hour}:${minutes}:00`,
}
}
/** 通知列表:关键字匹配标题/内容,日期按年月日区间(与 Java 侧同语义)。 */
function notificationPage(searchParams) {
const keyword = (searchParams.get('keyword') || '').trim()
const startDate = searchParams.get('startDate') || ''
const endDate = searchParams.get('endDate') || ''
const page = Math.max(1, Number(searchParams.get('page') || 1))
const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('pageSize') || 20)))
let rows = NOTIFICATIONS
if (keyword) {
rows = rows.filter((item) => `${item.title}${item.content}`.includes(keyword))
}
if (startDate) {
rows = rows.filter((item) => item.createdAt.slice(0, 10) >= startDate)
}
if (endDate) {
rows = rows.filter((item) => item.createdAt.slice(0, 10) <= endDate)
}
const start = (page - 1) * pageSize
return {
success: true,
data: {
items: rows.slice(start, start + pageSize),
total: rows.length,
page,
pageSize,
unreadCount: rows.filter((item) => !item.read).length,
},
}
}
const server = createServer((req, res) => {
const url = (req.url || '').split('?')[0]
const method = req.method || 'GET'
@@ -160,13 +220,183 @@ const server = createServer((req, res) => {
return json(res, {
success: true,
data: {
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', createdAt: '2026-01-01 00:00:00' }],
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', created_at: '2026-03-01 09:15:00', updated_at: '2026-09-18 16:42:30' }],
total: 1,
},
})
}
if (url === '/api/admin/permission-menus') {
return json(res, { success: true, data: { items: [] } })
// 菜单管理列表:一条根节点,带创建/更新时间(验收时间列)。
return json(res, {
success: true,
data: [
{
id: 1,
name: '用户管理',
column_key: 'admin_users',
parent_id: null,
menu_type: 'admin',
route_path: 'account/users',
sort_order: 10,
created_at: '2026-03-01T09:15:00',
updated_at: '2026-09-18T16:42:30',
},
],
})
}
if (url === '/api/admin/invalid-asin-data') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
dataValue: 'B0INVALID1',
brand: '测试品牌',
groupId: 1,
groupName: '测试分组',
recordSource: 'MANUAL',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/dedupe-total-data') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
dataValue: 'B0DEDUPE01',
country: 'DE',
groupId: 1,
groupName: '测试分组',
uploaderUserId: 1,
username: 'admin',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/query-asins') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
groupId: 1,
groupName: '测试分组',
shopName: '测试店铺',
asinDe: 'B0QUERY001',
asinUk: '',
asinFr: '',
asinIt: '',
asinEs: '',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/skip-price-asins') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
groupId: 1,
groupName: '测试分组',
shopName: '测试店铺',
asinDe: 'B0SKIP0001',
minimumPriceDe: 9.99,
asinUk: '',
minimumPriceUk: null,
asinFr: '',
minimumPriceFr: null,
asinIt: '',
minimumPriceIt: null,
asinEs: '',
minimumPriceEs: null,
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/product-categories/children') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
parentId: null,
name: '护肤品',
categoryKey: 'skincare',
sortOrder: 10,
description: '测试备注',
isBuiltin: true,
childCount: 0,
level: 0,
path: '护肤品',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
hasMore: false,
},
})
}
if (url === '/api/admin/user-secrets') {
const emptyModule = { moduleKey: '', moduleLabel: '', masked: '', full: '', exists: false, checkStatus: 'unknown', checkCode: '', checkMessage: '', checkLatencyMs: null, checkedAt: null, updatedAt: null }
return json(res, {
success: true,
data: {
items: [
{
userId: 1,
username: 'admin',
groups: [],
leaderUsername: '',
similarAsin: { ...emptyModule, moduleKey: 'similar-asin', moduleLabel: '货源查询密钥' },
appearancePatent: { ...emptyModule, moduleKey: 'appearance-patent', moduleLabel: '外观专利密钥' },
proxy: { ...emptyModule, moduleKey: 'proxy', moduleLabel: '代理设置' },
status: 'unknown',
statusMessage: '',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 15,
groupOptions: [],
},
})
}
if (url === '/api/admin/shop-manage-groups') {
return json(res, { success: true, data: { items: [] } })
@@ -180,6 +410,23 @@ const server = createServer((req, res) => {
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
})
}
if (url === '/api/admin/notifications/summary') {
return json(res, {
success: true,
data: {
unreadCount: NOTIFICATIONS.filter((item) => !item.read).length,
latestId: NOTIFICATIONS.reduce((max, item) => Math.max(max, item.id), 0),
},
})
}
if (url === '/api/admin/notifications') {
return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams))
}
if (url === '/api/admin/tutorials') {
// 与 Java 侧同契约:created_at 降序返回(页面"当前生效"取第一条)。
const items = [...TUTORIAL_PACKAGES].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
return json(res, { success: true, data: { items } })
}
if (url.startsWith('/api/')) {
return json(res, { success: true, data: { items: [], total: 0 } })
}
+135
View File
@@ -0,0 +1,135 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 日志文件行(桌面客户端 / 麦象采集机上报)。 */
export interface DeviceLogFileRow {
id: number
source: string
deviceId: string
deviceName: string | null
username: string | null
uid: number | null
fileName: string
logDate: string
uploadedBytes: number
partCount: number
lastUploadAt: string | null
createdAt: string | null
}
export interface DeviceLogPage {
items: DeviceLogFileRow[]
total: number
page: number
pageSize: number
/** 云端日志保留天数(页面提示用)。 */
retentionDays: number
}
export interface DeviceLogContent {
fileId: number
fileName: string
content: string
totalBytes: number
shownBytes: number
truncated: boolean
}
export interface DeviceLogDevice {
source: string
deviceId: string
deviceName: string | null
lastUploadAt: string | null
}
export interface DeviceLogOverride {
id: number
source: string
deviceId: string
deviceName: string | null
mode: string
updatedAt: string | null
}
export interface DeviceLogConfigData {
globalMode: string
overrides: DeviceLogOverride[]
}
export interface DeviceLogQuery {
source?: string
keyword?: string
startDate?: string
endDate?: string
page: number
pageSize: number
}
/** 分页查询日志文件列表:GET /api/admin/device-logs/files */
export async function fetchDeviceLogFiles(params: DeviceLogQuery): Promise<DeviceLogPage> {
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
if (params.source) query.source = params.source
if (params.keyword) query.keyword = params.keyword
if (params.startDate) query.startDate = params.startDate
if (params.endDate) query.endDate = params.endDate
const { data } = await http.get('/api/admin/device-logs/files', { params: query })
return unwrap<DeviceLogPage>(data)
}
/** 查看日志尾部内容:GET /api/admin/device-logs/content */
export async function fetchDeviceLogContent(fileId: number, maxBytes?: number): Promise<DeviceLogContent> {
const query: Record<string, number> = { fileId }
if (maxBytes) query.maxBytes = maxBytes
const { data } = await http.get('/api/admin/device-logs/content', { params: query })
return unwrap<DeviceLogContent>(data)
}
/** 完整日志下载地址(同域 cookie 鉴权,直接给 a[href] 或 window.open 用)。 */
export function deviceLogDownloadUrl(fileId: number): string {
return `/api/admin/device-logs/download?fileId=${fileId}`
}
/** 删除日志文件(片段与元数据,不可恢复):DELETE /api/admin/device-logs/{id} */
export async function deleteDeviceLogFile(id: number): Promise<void> {
const { data } = await http.delete(`/api/admin/device-logs/${id}`)
unwrap<unknown>(data)
}
/** 采集配置(全局默认 + 终端覆盖):GET /api/admin/device-logs/config */
export async function fetchDeviceLogConfig(keyword?: string): Promise<DeviceLogConfigData> {
const { data } = await http.get('/api/admin/device-logs/config', {
params: keyword ? { keyword } : undefined,
})
return unwrap<DeviceLogConfigData>(data)
}
/** 最近上报过的终端(覆盖选择用):GET /api/admin/device-logs/devices */
export async function fetchDeviceLogDevices(): Promise<DeviceLogDevice[]> {
const { data } = await http.get('/api/admin/device-logs/devices')
return unwrap<DeviceLogDevice[]>(data)
}
/** 设置全局采集模式:PUT /api/admin/device-logs/config/global */
export async function updateDeviceLogGlobalMode(mode: string): Promise<void> {
const { data } = await http.put('/api/admin/device-logs/config/global', undefined, { params: { mode } })
unwrap<unknown>(data)
}
/** 设置/更新终端覆盖:PUT /api/admin/device-logs/config/device */
export async function updateDeviceLogOverride(
source: string,
deviceId: string,
deviceName: string | null,
mode: string,
): Promise<void> {
const { data } = await http.put('/api/admin/device-logs/config/device', undefined, {
params: { source, deviceId, deviceName: deviceName || undefined, mode },
})
unwrap<unknown>(data)
}
/** 删除终端覆盖(回落到全局默认):DELETE /api/admin/device-logs/config/device/{id} */
export async function deleteDeviceLogOverride(id: number): Promise<void> {
const { data } = await http.delete(`/api/admin/device-logs/config/device/${id}`)
unwrap<unknown>(data)
}
+14
View File
@@ -46,6 +46,20 @@ export function isUnauthorized(payload: unknown): boolean {
return [record.status, record.statusCode, record.code].some((v) => v === 401)
}
/** 负载/状态码是否 403(已登录但无后台权限)。 */
export function isForbidden(payload: unknown): boolean {
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
if (!record || typeof record !== 'object') return false
return [record.status, record.statusCode, record.code].some((v) => v === 403)
}
/** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */
export function isKicked(payload: unknown): boolean {
const record = payload as { code?: unknown } | null
if (!record || typeof record !== 'object') return false
return record.code === 4011
}
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
export function requestErrorMessage(error: unknown): string {
const response = (error as { response?: { data?: unknown; status?: number } })?.response
+31 -4
View File
@@ -1,5 +1,12 @@
import axios from 'axios'
import { isUnauthorized, loginRedirectTarget, shouldRedirectUnauthorized } from './envelope'
import {
isForbidden,
isKicked,
isLoginLocation,
isUnauthorized,
loginRedirectTarget,
shouldRedirectUnauthorized,
} from './envelope'
export { unwrap } from './envelope'
@@ -17,14 +24,34 @@ function redirectToLogin(requestUrl?: string): void {
window.location.assign('/admin-vue/login?redirect=' + target)
}
/** 单设备登录:被新设备顶下线,整页回登录页(整页 reload 顺带重置会话 store,避免守卫弹回)。 */
function redirectToLoginKicked(): void {
if (typeof window === 'undefined') return
if (isLoginLocation(window.location.pathname)) return
window.location.assign('/admin-vue/login?kicked=1')
}
http.interceptors.response.use(
(response) => {
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理
if (isUnauthorized(response.data)) redirectToLogin(response.config?.url)
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理
// 403(已登录但无后台权限,如用工具前端账号 token 访问后台)与 401 同样跳登录页。
// 4011(账号已在其他设备登录)单独提示,不按普通 401 处理。
if (isKicked(response.data)) {
redirectToLoginKicked()
} else if (isUnauthorized(response.data) || isForbidden(response.data)) {
redirectToLogin(response.config?.url)
}
return response
},
(error) => {
if (error?.response?.status === 401 || isUnauthorized(error?.response?.data)) {
if (isKicked(error?.response?.data)) {
redirectToLoginKicked()
} else if (
error?.response?.status === 401 ||
error?.response?.status === 403 ||
isUnauthorized(error?.response?.data) ||
isForbidden(error?.response?.data)
) {
redirectToLogin(error?.config?.url)
}
return Promise.reject(error)
@@ -0,0 +1,70 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 单条通知(后台铃铛)。 */
export interface AdminNotificationItem {
id: number
scene: string
level: string
title: string
content: string
read: boolean
readAt: string | null
createdAt: string | null
}
export interface AdminNotificationPage {
items: AdminNotificationItem[]
total: number
page: number
pageSize: number
unreadCount: number
}
export interface AdminNotificationSummary {
unreadCount: number
latestId: number
}
/** 铃铛轮询:未读数 + 最新通知 id。 */
export async function fetchNotificationSummary(): Promise<AdminNotificationSummary> {
const { data } = await http.get('/api/admin/notifications/summary')
return unwrap<AdminNotificationSummary>(data)
}
/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */
export interface AdminNotificationListParams {
page?: number
pageSize?: number
onlyUnread?: boolean
keyword?: string
startDate?: string
endDate?: string
}
export async function fetchNotificationList(
params: AdminNotificationListParams = {},
): Promise<AdminNotificationPage> {
const keyword = (params.keyword ?? '').trim()
const { data } = await http.get('/api/admin/notifications', {
params: {
page: params.page ?? 1,
pageSize: params.pageSize ?? 20,
onlyUnread: params.onlyUnread ? 'true' : 'false',
keyword: keyword || undefined,
startDate: params.startDate || undefined,
endDate: params.endDate || undefined,
},
})
return unwrap<AdminNotificationPage>(data)
}
export async function markNotificationRead(id: number): Promise<boolean> {
const { data } = await http.post(`/api/admin/notifications/${id}/read`)
return unwrap<boolean>(data)
}
export async function markAllNotificationsRead(): Promise<number> {
const { data } = await http.post('/api/admin/notifications/read-all')
return unwrap<number>(data)
}
@@ -0,0 +1,47 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 单用户一行:三类密钥调用次数 + 合计(口径=真实对外请求次数)。 */
export interface AdminUserSecretUsageRow {
userId: number
username: string
groups: string[]
similarAsinCount: number
appearancePatentCount: number
proxyCount: number
totalCount: number
}
export interface AdminUserSecretUsagePage {
items: AdminUserSecretUsageRow[]
total: number
page: number
pageSize: number
/** 所选范围内(不分页)三类总次数。 */
totalCalls: number
similarAsinCalls: number
appearancePatentCalls: number
proxyCalls: number
groupOptions?: Array<{ id: number; groupName: string }>
}
export interface UserSecretUsageQuery {
startDate?: string
endDate?: string
keyword?: string
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
groupId?: number
page: number
pageSize: number
}
/** 分页查询密钥用量:GET /api/admin/user-secret-usagegroup_id 必须 snake_case)。 */
export async function fetchUserSecretUsage(params: UserSecretUsageQuery): Promise<AdminUserSecretUsagePage> {
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
if (params.startDate) query.startDate = params.startDate
if (params.endDate) query.endDate = params.endDate
if (params.keyword) query.keyword = params.keyword
if (params.groupId) query.group_id = params.groupId
const { data } = await http.get('/api/admin/user-secret-usage', { params: query })
return unwrap<AdminUserSecretUsagePage>(data)
}
@@ -0,0 +1,92 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 单模块状态(脱敏值 + 连通性)。 */
export interface AdminUserSecretModule {
moduleKey: string
moduleLabel: string
masked: string
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
full: string
exists: boolean
checkStatus: string
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: string | null
updatedAt: string | null
}
/** 一行一用户:三个字段列 + 行级状态(三类都检测通过才算 passed)。 */
export interface AdminUserSecretRow {
userId: number
username: string
/** 所属数据权限分组名(可能多个)。 */
groups: string[]
/** 所属主管(创建人)用户名;无分组时的兜底展示。 */
leaderUsername: string
similarAsin: AdminUserSecretModule
appearancePatent: AdminUserSecretModule
proxy: AdminUserSecretModule
status: string
statusMessage: string
/** 首次配置时间(三模块中最早);从未配置为 null。 */
createdAt: string | null
updatedAt: string | null
}
export interface AdminUserSecretPage {
items: AdminUserSecretRow[]
total: number
page: number
pageSize: number
/** 分组筛选项(超管=全部;主管=自己带的分组)。 */
groupOptions?: GroupOption[]
}
export interface GroupOption {
id: number
groupName: string
}
export interface UserSecretQuery {
keyword?: string
checkStatus?: string
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
groupId?: number
page: number
pageSize: number
}
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets(筛选参数必须 snake_casecamel 会被后端静默忽略)。 */
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
if (params.keyword) query.keyword = params.keyword
if (params.checkStatus) query.checkStatus = params.checkStatus
if (params.groupId) query.group_id = params.groupId
const { data } = await http.get('/api/admin/user-secrets', { params: query })
return unwrap<AdminUserSecretPage>(data)
}
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check
* 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */
export async function checkUserSecret(userId: number) {
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`, undefined, { timeout: 180_000 })
return unwrap<
Array<{
moduleKey: string
checkStatus: string
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: string | null
viaProxy: boolean
}>
>(data)
}
/** 清空该用户全部密钥与代理配置:DELETE /api/admin/user-secrets/{userId} */
export async function deleteUserSecret(userId: number): Promise<void> {
const { data } = await http.delete(`/api/admin/user-secrets/${userId}`)
unwrap<unknown>(data)
}
@@ -23,6 +23,8 @@ export function toAdminUserItem(raw: unknown): AdminUser | null {
if (createdById !== null) item.createdById = createdById
const createdAt = text(r.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(r.updated_at)
if (updatedAt) item.updatedAt = updatedAt
const creator = text(r.creator_username)
if (creator) item.creatorUsername = creator
const abbr = text(r.pinyin_abbr)
@@ -0,0 +1,683 @@
<template>
<div ref="rootRef" class="admin-notification-bell">
<button
type="button"
class="bell-trigger"
:class="{ 'bell-trigger--active': panelOpen }"
:title="unreadCount > 0 ? `通知(${unreadCount} 条未读)` : '通知'"
aria-label="通知"
@click="togglePanel"
>
<svg
class="bell-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
</button>
<!-- 面板挂到 body fixed 定位顶栏内绝对定位时会被列表页 el-select 等页面内容压住 z-index 无效 -->
<Teleport to="body">
<div
v-if="panelOpen"
ref="panelRef"
class="bell-panel"
:style="panelStyle"
role="dialog"
aria-label="通知列表"
>
<div class="bell-panel-head">
<span class="bell-panel-title">通知</span>
<button
v-if="unreadCount > 0"
type="button"
class="bell-read-all"
:disabled="markingAll"
@click="readAll"
>
{{ markingAll ? '处理中...' : '全部已读' }}
</button>
</div>
<div class="bell-search">
<input
v-model="keyword"
type="text"
class="bell-search-input"
placeholder="搜索标题或内容"
aria-label="搜索通知"
@input="onKeywordInput"
/>
<div class="bell-search-days">
<el-date-picker
:model-value="dateRangeValue"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
range-separator=""
size="small"
class="bell-date-picker"
popper-class="bell-date-popper"
:clearable="true"
@update:model-value="onDateRangeChange"
@change="reload"
/>
<button v-if="hasFilter" type="button" class="bell-search-reset" @click="resetFilters">
重置
</button>
</div>
</div>
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
<div v-else-if="!items.length" class="bell-empty">{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}</div>
<ul v-else class="bell-list">
<template v-for="group in groupedItems" :key="group.day || 'unknown'">
<li class="bell-day-head">{{ group.label }}</li>
<li
v-for="item in group.items"
:key="item.id"
class="bell-item"
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
@click="onItemClick(item)"
>
<div class="bell-item-head">
<span class="bell-item-title">{{ item.title }}</span>
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
</div>
<div class="bell-item-content">{{ item.content }}</div>
</li>
</template>
</ul>
<div class="bell-panel-foot">
<button
type="button"
class="bell-page-btn"
:disabled="page <= 1 || loading"
@click="goPage(page - 1)"
>
上一页
</button>
<span class="bell-page-info">{{ page }} / {{ totalPages }}</span>
<button
type="button"
class="bell-page-btn"
:disabled="page >= totalPages || loading"
@click="goPage(page + 1)"
>
下一页
</button>
<span class="bell-page-total"> {{ total }} </span>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchNotificationList,
fetchNotificationSummary,
markAllNotificationsRead,
markNotificationRead,
type AdminNotificationItem,
} from '@/api/notifications'
import { useAdminSessionStore } from '@/stores/admin-session'
import {
NOTIFICATION_POLL_INTERVAL_MS,
formatNotificationTime,
formatUnreadBadge,
groupNotificationsByDay,
hasNewNotification,
readLastNotifiedId,
writeLastNotifiedId,
} from '@/layout/notification-bell-model'
const PAGE_SIZE = 10
const session = useAdminSessionStore()
const rootRef = ref<HTMLElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const unreadCount = ref(0)
const items = ref<AdminNotificationItem[]>([])
const total = ref(0)
const page = ref(1)
const panelOpen = ref(false)
const loading = ref(false)
const markingAll = ref(false)
const loadError = ref('')
const keyword = ref('')
const startDate = ref('')
const endDate = ref('')
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
const groupedItems = computed(() => groupNotificationsByDay(items.value))
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const hasFilter = computed(() => Boolean(keyword.value.trim() || startDate.value || endDate.value))
/**
* el-date-picker 的绑定桥:日期区间在内部仍用 startDate/endDate 两个 ref 表示
* hasFilter/normalizedDayRange/resetFilters 都基于它们)。
* 此前模板写的是 v-model="dateRange",而 dateRange 从未声明 → vue-tsc 直接报 TS2339 构建失败,
* 且日期筛选完全不下发参数。
*/
const dateRangeValue = computed<[string, string] | null>(() =>
startDate.value && endDate.value ? [startDate.value, endDate.value] : null
)
function onDateRangeChange(value: [string, string] | null) {
const start = value?.[0]
const end = value?.[1]
startDate.value = start ? String(start) : ''
endDate.value = end ? String(end) : ''
}
let pollTimer: number | null = null
let searchTimer: number | null = null
function currentUid(): number | string {
return session.user?.id ?? 0
}
/** 起止日期倒挂时自动交换,避免用户只看到「没有符合条件的通知」不知原因。 */
function normalizedDayRange(): { startDate?: string; endDate?: string } {
let start = startDate.value
let end = endDate.value
if (start && end && start > end) {
const swapped = start
start = end
end = swapped
}
return { startDate: start || undefined, endDate: end || undefined }
}
/** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */
async function refreshSummary() {
try {
const summary = await fetchNotificationSummary()
unreadCount.value = Number(summary?.unreadCount ?? 0)
const latestId = Number(summary?.latestId ?? 0)
if (hasNewNotification(latestId, readLastNotifiedId(currentUid()))) {
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
writeLastNotifiedId(currentUid(), latestId)
console.log('[admin-notification] 检测到新通知 latestId=', latestId)
}
} catch (error) {
// 通知接口失败静默降级:不显示红点、不打扰使用者
console.warn('[admin-notification] 未读数刷新失败(静默降级):', error)
}
}
async function loadPage(targetPage: number) {
if (loading.value) return
loading.value = true
try {
const result = await fetchNotificationList({
page: targetPage,
pageSize: PAGE_SIZE,
keyword: keyword.value,
...normalizedDayRange(),
})
const list = Array.isArray(result?.items) ? result.items : []
items.value = list
total.value = Number(result?.total ?? list.length)
unreadCount.value = Number(result?.unreadCount ?? unreadCount.value)
page.value = targetPage
loadError.value = ''
// 无筛选时首页就是最新数据,顺带记录「已提醒过的最大 id」;有筛选时最新 id 不代表真实最新
if (targetPage <= 1 && !hasFilter.value && list.length) {
const latest = Number(list[0].id)
if (latest > 0) {
writeLastNotifiedId(currentUid(), Math.max(readLastNotifiedId(currentUid()), latest))
}
}
} catch (error) {
// 列表加载失败在面板内提示,不弹全局消息打扰用户
console.warn('[admin-notification] 通知列表加载失败:', error)
loadError.value = error instanceof Error ? error.message : '通知加载失败'
} finally {
loading.value = false
}
}
function goPage(targetPage: number) {
const safe = Math.min(Math.max(1, targetPage), totalPages.value)
if (safe === page.value || loading.value) return
void loadPage(safe)
}
/** 搜索/日期变化后回到第一页重新查询。 */
function reload() {
void loadPage(1)
}
/** 关键字输入防抖 300ms,避免每敲一个字都打一次接口。 */
function onKeywordInput() {
if (searchTimer != null) {
window.clearTimeout(searchTimer)
}
searchTimer = window.setTimeout(() => {
searchTimer = null
void loadPage(1)
}, 300)
}
function resetFilters() {
keyword.value = ''
startDate.value = ''
endDate.value = ''
void loadPage(1)
}
/** 面板挂到 body 后用 fixed 定位,位置按触发器实时算(右对齐在铃铛下方)。 */
function syncPanelPosition() {
const trigger = rootRef.value
if (!trigger) return
const rect = trigger.getBoundingClientRect()
panelStyle.value = {
top: `${Math.round(rect.bottom + 10)}px`,
right: `${Math.max(8, Math.round(window.innerWidth - rect.right))}px`,
}
}
function togglePanel() {
panelOpen.value = !panelOpen.value
if (panelOpen.value) {
syncPanelPosition()
void loadPage(1)
}
}
async function onItemClick(item: AdminNotificationItem) {
if (item.read) return
try {
await markNotificationRead(item.id)
item.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (error) {
console.warn('[admin-notification] 标记已读失败:', error)
}
}
async function readAll() {
if (markingAll.value || unreadCount.value === 0) return
markingAll.value = true
try {
await markAllNotificationsRead()
for (const item of items.value) {
item.read = true
}
unreadCount.value = 0
} catch (error) {
console.warn('[admin-notification] 全部已读失败:', error)
ElMessage.error(error instanceof Error ? error.message : '操作失败')
} finally {
markingAll.value = false
}
}
function formatTime(value: string | null) {
return formatNotificationTime(value)
}
/** 点击组件外部关闭面板(面板已 Teleport 到 body,需连同面板自身一起判断)。 */
function onDocumentMouseDown(event: MouseEvent) {
if (!panelOpen.value) return
const target = event.target
if (!(target instanceof Node)) return
if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) return
panelOpen.value = false
}
/** 窗口尺寸变化时重新对齐面板。 */
function onWindowResize() {
if (panelOpen.value) {
syncPanelPosition()
}
}
/** 页面回到前台时立即刷新一次未读数。 */
function onVisibilityChange() {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
void refreshSummary()
}
}
onMounted(() => {
document.addEventListener('mousedown', onDocumentMouseDown)
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('resize', onWindowResize)
void refreshSummary()
pollTimer = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return
}
void refreshSummary()
}, NOTIFICATION_POLL_INTERVAL_MS)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocumentMouseDown)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('resize', onWindowResize)
if (pollTimer != null) {
window.clearInterval(pollTimer)
pollTimer = null
}
if (searchTimer != null) {
window.clearTimeout(searchTimer)
searchTimer = null
}
})
</script>
<style scoped>
.admin-notification-bell {
position: relative;
display: inline-flex;
align-items: center;
}
.bell-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--admin-muted, #5b6f83);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.bell-trigger:hover,
.bell-trigger--active {
background: #edf5fb;
border-color: #cbd9e6;
color: var(--admin-primary-strong, #2f5d8b);
}
.bell-icon {
width: 20px;
height: 20px;
}
.bell-badge {
position: absolute;
top: 1px;
right: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
box-sizing: border-box;
border-radius: 999px;
background: #d64545;
color: #ffffff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
pointer-events: none;
}
.bell-panel {
position: fixed;
z-index: 3200;
width: 380px;
max-width: calc(100vw - 32px);
max-height: 520px;
display: flex;
flex-direction: column;
border: 1px solid var(--admin-border, #d8e3ee);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(39, 67, 94, 0.22);
overflow: hidden;
}
.bell-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #edf1f5;
}
.bell-panel-title {
color: var(--admin-text, #24384d);
font-size: 14px;
font-weight: 700;
}
.bell-read-all {
border: none;
background: transparent;
color: var(--admin-primary-strong, #2f5d8b);
font-size: 12px;
cursor: pointer;
padding: 0;
}
.bell-read-all:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.bell-empty {
padding: 36px 0;
color: #8a99a8;
font-size: 13px;
text-align: center;
}
.bell-empty--error {
color: #b23c3c;
}
.bell-list {
flex: 1;
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
}
.bell-item {
padding: 11px 14px;
border-bottom: 1px solid #f0f3f7;
cursor: pointer;
transition: background 0.12s ease;
}
.bell-item:hover {
background: #f7f9fc;
}
.bell-item--unread {
background: #f2f6ff;
}
.bell-item--unread .bell-item-title::before {
content: '';
display: inline-block;
width: 7px;
height: 7px;
margin-right: 7px;
border-radius: 50%;
background: #d64545;
vertical-align: 1px;
}
.bell-item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.bell-item-title {
color: var(--admin-text, #24384d);
font-size: 13px;
font-weight: 700;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bell-item--error .bell-item-title {
color: #b23c3c;
}
.bell-item--warning .bell-item-title {
color: #a8793e;
}
.bell-item--info .bell-item-title {
color: var(--admin-primary-strong, #2f5d8b);
}
.bell-item-time {
flex-shrink: 0;
color: #9aa6b3;
font-size: 11px;
}
.bell-item-content {
margin-top: 4px;
color: #667085;
font-size: 12px;
line-height: 1.55;
word-break: break-all;
}
.bell-search {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid #edf1f5;
background: #fafcfe;
}
.bell-search-input,
.bell-date-input {
height: 28px;
padding: 0 8px;
box-sizing: border-box;
border: 1px solid #d8e3ee;
border-radius: 6px;
background: #ffffff;
color: var(--admin-text, #24384d);
font-size: 12px;
outline: none;
}
.bell-search-input {
width: 100%;
}
.bell-search-input:focus,
.bell-date-input:focus {
border-color: var(--admin-primary-strong, #2f5d8b);
}
.bell-search-days {
display: flex;
align-items: center;
gap: 6px;
}
.bell-date-input {
flex: 1;
min-width: 0;
}
.bell-date-sep {
color: #8a99a8;
font-size: 12px;
}
.bell-search-reset {
flex-shrink: 0;
padding: 0 8px;
height: 28px;
border: 1px solid #d8e3ee;
border-radius: 6px;
background: #ffffff;
color: var(--admin-primary-strong, #2f5d8b);
font-size: 12px;
cursor: pointer;
}
.bell-search-reset:hover {
background: #edf5fb;
}
.bell-day-head {
padding: 6px 14px;
background: #f4f7fa;
color: #7c8b9a;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bell-panel-foot {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 7px 12px;
border-top: 1px solid #edf1f5;
}
.bell-page-btn {
padding: 3px 10px;
border: 1px solid #d8e3ee;
border-radius: 6px;
background: #ffffff;
color: var(--admin-primary-strong, #2f5d8b);
font-size: 12px;
cursor: pointer;
}
.bell-page-btn:hover:not(:disabled) {
background: #edf5fb;
}
.bell-page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bell-page-info {
color: var(--admin-text, #24384d);
font-size: 12px;
font-weight: 600;
}
.bell-page-total {
color: #9aa6b3;
font-size: 11px;
}
</style>
@@ -12,6 +12,7 @@ import {
shouldShowEmptyMenu,
} from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import OperationGuide from '@/layout/OperationGuide.vue'
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
import {
@@ -100,6 +101,7 @@ async function signOut() {
<h1>{{ pageTitle }}</h1>
</div>
<div class="admin-user">
<NotificationBell />
<div class="admin-user-meta">
<strong>{{ userVm.username }}</strong>
</div>
@@ -0,0 +1,147 @@
/**
* 后台铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
* 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。
*/
/** 未读轮询间隔(60 秒)。 */
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000
const NOTIFIED_KEY_PREFIX = 'admin-notification:last-notified-id'
/** 未读徽标文案:0 或非法值显示空串,>99 显示 99+。 */
export function formatUnreadBadge(count: number | null | undefined): string {
const value = Number(count ?? 0)
if (!Number.isFinite(value) || value <= 0) {
return ''
}
return value > 99 ? '99+' : String(Math.floor(value))
}
/** 是否出现新通知:latestId 大于上次已提醒过的 id。 */
export function hasNewNotification(
latestId: number | null | undefined,
lastNotifiedId: number | null | undefined,
): boolean {
const latest = Number(latestId ?? 0)
const notified = Number(lastNotifiedId ?? 0)
if (!Number.isFinite(latest) || latest <= 0) {
return false
}
return latest > (Number.isFinite(notified) && notified > 0 ? notified : 0)
}
function storageKey(uid: string | number | null | undefined): string {
const normalized = uid === null || uid === undefined || String(uid).trim() === '' ? '0' : String(uid)
return `${NOTIFIED_KEY_PREFIX}:${normalized}`
}
/** 读取当前管理员「已提醒过的最大通知 id」。 */
export function readLastNotifiedId(uid: string | number | null | undefined): number {
try {
const raw = window.localStorage.getItem(storageKey(uid))
const value = Number(raw ?? 0)
return Number.isFinite(value) && value > 0 ? value : 0
} catch {
return 0
}
}
export function writeLastNotifiedId(uid: string | number | null | undefined, id: number): void {
try {
if (Number.isFinite(id) && id > 0) {
window.localStorage.setItem(storageKey(uid), String(Math.floor(id)))
}
} catch {
/* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */
}
}
/** 通知所属日期键(本地时区 yyyy-MM-dd);时间缺失/非法返回空串。 */
export function notificationDayKey(value: string | null | undefined): string {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const pad = (input: number) => String(input).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
/** 日期组头文案:今天 / 昨天 / 2026年9月11日;空键兜底「未知时间」。 */
export function formatDayLabel(day: string, now: Date = new Date()): string {
if (!day) {
return '未知时间'
}
if (day === notificationDayKey(now.toISOString())) {
return '今天'
}
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
if (day === notificationDayKey(yesterday.toISOString())) {
return '昨天'
}
const [year, month, date] = day.split('-').map((part) => Number(part))
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(date)) {
return day
}
return `${year}${month}${date}`
}
export interface NotificationDayGroup<T> {
day: string
label: string
items: T[]
}
/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */
export function groupNotificationsByDay<T extends { createdAt: string | null }>(
items: T[],
now: Date = new Date(),
): NotificationDayGroup<T>[] {
const groups: NotificationDayGroup<T>[] = []
const indexByDay = new Map<string, NotificationDayGroup<T>>()
for (const item of items ?? []) {
const day = notificationDayKey(item.createdAt)
let group = indexByDay.get(day)
if (!group) {
group = { day, label: formatDayLabel(day, now), items: [] }
indexByDay.set(day, group)
groups.push(group)
}
group.items.push(item)
}
return groups
}
/** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */
export function formatNotificationTime(
value: string | null | undefined,
now: Date = new Date(),
): string {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const pad = (input: number) => String(input).padStart(2, '0')
const hourMinute = `${pad(date.getHours())}:${pad(date.getMinutes())}`
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (sameDay) {
return hourMinute
}
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
const isYesterday =
date.getFullYear() === yesterday.getFullYear() &&
date.getMonth() === yesterday.getMonth() &&
date.getDate() === yesterday.getDate()
if (isYesterday) {
return `昨天 ${hourMinute}`
}
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hourMinute}`
}
@@ -25,6 +25,10 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
},
admin_group_manage: OPERATION_GUIDE_FALLBACK,
admin_user_secrets: {
text: '用户密钥按账号绑定,列表只展示脱敏值。可对单条立即检测连通性;清空后该用户需要重新配置密钥。',
steps: ['筛选用户或状态', '立即检测连通性', '必要时清空'],
},
admin_dedupe_total_data: {
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
steps: ['选择分组', '筛选或导入', '核对并导出'],
@@ -70,10 +74,18 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
},
admin_tutorial: {
text: '上传新的教程压缩包后即刻生效:工具台首页「立即下载教程」以下载最新上传的包为准。确认旧包不再需要后再删除。',
steps: ['上传教程压缩包', '到工具台核对下载', '清理历史包'],
},
digital_human_version: {
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
},
admin_user_secret_usage: {
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
},
}
/** 全部有独立/兜底提示的后台业务菜单 key。 */
@@ -15,6 +15,8 @@ const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role:
const errors = reactive<EditUserFormErrors>({})
const busy = ref(false)
const loadingAuth = ref(false)
/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */
const authReady = ref(false)
watch(
() => props.modelValue,
@@ -28,6 +30,7 @@ watch(
form.columnIds = []
form.originalRole = base.originalRole
errors.password = undefined
authReady.value = false
void refreshAuth()
},
)
@@ -38,7 +41,10 @@ async function refreshAuth(): Promise<void> {
try {
const { checkedIds } = await loadUserMenuAuth(props.user.id)
form.columnIds = checkedIds
authReady.value = true
} catch (error) {
// 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。
authReady.value = false
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
loadingAuth.value = false
@@ -50,6 +56,10 @@ function close(): void {
}
async function save(): Promise<void> {
if (!authReady.value) {
showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error')
return
}
const { valid, errors: errs } = validateEditUserForm(form)
Object.assign(errors, errs)
if (!valid) return
@@ -103,12 +113,15 @@ async function save(): Promise<void> {
<div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" />
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
<div v-else-if="!authReady" class="auth-loading auth-loading-error">
菜单权限加载失败,保存已禁用;请关闭后重试
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
<el-button type="primary" :loading="busy" :disabled="!authReady" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
@@ -126,4 +139,7 @@ async function save(): Promise<void> {
font-size: 12px;
color: var(--admin-muted);
}
.auth-loading-error {
color: var(--el-color-danger);
}
</style>
@@ -241,6 +241,7 @@ onMounted(loadMenus)
<th style="width: 100px">菜单类型</th>
<th style="width: 120px">上级菜单</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
@@ -271,6 +272,7 @@ onMounted(loadMenus)
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
<td>{{ parentNameOf(row) }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
@@ -286,10 +288,10 @@ onMounted(loadMenus)
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
<td colspan="8" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
</tr>
</tbody>
</table>
@@ -77,7 +77,7 @@ watch(
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children' }"
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck"
/>
</div>
@@ -89,7 +89,7 @@ watch(
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children' }"
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck"
/>
</div>
@@ -0,0 +1,395 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import OldPagination from '@/components/OldPagination.vue'
import {
fetchUserSecretUsage,
type AdminUserSecretUsageRow,
} from '@/api/user-secret-usage'
import type { GroupOption } from '@/api/user-secrets'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<AdminUserSecretUsageRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const keyword = ref('')
const startDate = ref('')
const endDate = ref('')
const groupFilter = ref<number | null>(null)
const groupOptions = ref<GroupOption[]>([])
/** 所选范围内三类总次数(后端按筛选条件统计,不分页)。 */
const summary = ref({ totalCalls: 0, similarAsinCalls: 0, appearancePatentCalls: 0, proxyCalls: 0 })
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
function formatCount(value: number | undefined) {
const num = Number(value || 0)
return num.toLocaleString('zh-CN')
}
async function load() {
loading.value = true
try {
const result = await fetchUserSecretUsage({
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
keyword: keyword.value.trim() || undefined,
groupId: groupFilter.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
groupOptions.value = result?.groupOptions || []
summary.value = {
totalCalls: Number(result?.totalCalls || 0),
similarAsinCalls: Number(result?.similarAsinCalls || 0),
appearancePatentCalls: Number(result?.appearancePatentCalls || 0),
proxyCalls: Number(result?.proxyCalls || 0),
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
keyword.value = ''
startDate.value = ''
endDate.value = ''
groupFilter.value = null
page.value = 1
load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
onMounted(load)
</script>
<template>
<div class="secret-usage-view">
<section class="panel-box">
<div class="usage-head">
<h3>密钥用量统计</h3>
<span class="usage-note">口径真实对外请求次数LLM 每次请求代理每次成功提取</span>
</div>
<div class="summary-row">
<div class="summary-card">
<div class="summary-label">总调用次数</div>
<div class="summary-value">{{ formatCount(summary.totalCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">货源查询密钥</div>
<div class="summary-value">{{ formatCount(summary.similarAsinCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">外观专利密钥</div>
<div class="summary-value">{{ formatCount(summary.appearancePatentCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">代理提取</div>
<div class="summary-value">{{ formatCount(summary.proxyCalls) }}</div>
</div>
</div>
<div class="form-row usage-filter-row">
<div class="form-group" style="min-width: 160px">
<label>开始日期</label>
<input v-model="startDate" type="date" />
</div>
<div class="form-group" style="min-width: 160px">
<label>结束日期</label>
<input v-model="endDate" type="date" />
</div>
<div class="form-group" style="min-width: 200px">
<label>用户名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
</div>
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
<label>分组</label>
<select v-model="groupFilter">
<option :value="null">全部分组</option>
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="usage-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 220px">用户</th>
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
<th style="width: 170px">货源查询密钥</th>
<th style="width: 170px">外观专利密钥</th>
<th style="width: 150px">代理提取</th>
<th style="width: 130px">合计</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.userId">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td>
<span class="user-name">{{ row.username || `UID ${row.userId}` }}</span>
</td>
<td v-if="isSuperAdmin">
<span class="group-name">{{ row.groups?.length ? row.groups.join('、') : '—' }}</span>
</td>
<td><span class="count-value">{{ formatCount(row.similarAsinCount) }}</span></td>
<td><span class="count-value">{{ formatCount(row.appearancePatentCount) }}</span></td>
<td><span class="count-value">{{ formatCount(row.proxyCount) }}</span></td>
<td><span class="count-value count-total">{{ formatCount(row.totalCount) }}</span></td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">
{{ keyword || startDate || endDate || groupFilter ? '暂无匹配记录' : '暂无用量记录' }}
</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
</div>
</template>
<style scoped>
/* 与密钥管理页保持同一视觉语言(像素复刻后台面板风格)。 */
.secret-usage-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.usage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.usage-note {
color: #7d8fa2;
font-size: 12px;
}
.summary-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
flex: 1 1 160px;
min-width: 150px;
padding: 12px 16px;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #f7fafd;
}
.summary-label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.summary-value {
margin-top: 6px;
color: #2f5d8b;
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.usage-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.usage-table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.usage-table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1020px;
}
.usage-table-scroll th,
.usage-table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.usage-table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.usage-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.user-name {
color: #24384d;
font-weight: 600;
white-space: normal;
overflow-wrap: anywhere;
}
.group-name {
color: #5b6f83;
font-size: 12.5px;
}
.count-value {
color: #2f5d8b;
font-variant-numeric: tabular-nums;
font-size: 13.5px;
}
.count-total {
font-weight: 700;
}
.empty-tip {
color: #8293a5;
text-align: center;
}
</style>
@@ -0,0 +1,564 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { formatDateTime } from '@/utils/datetime'
import OldPagination from '@/components/OldPagination.vue'
import {
checkUserSecret,
deleteUserSecret,
fetchUserSecretList,
type AdminUserSecretModule,
type AdminUserSecretRow,
type GroupOption,
} from '@/api/user-secrets'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<AdminUserSecretRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const keyword = ref('')
const statusFilter = ref('')
const groupFilter = ref<number | null>(null)
/** 分组下拉(超管=全部;主管=自己带的分组,由列表接口带回)。 */
const groupOptions = ref<GroupOption[]>([])
/** 正在检测的行 userId,用于按钮 loading 态。 */
const checkingId = ref<number | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'passed', label: '检测通过' },
{ value: 'failed', label: '检测失败' },
{ value: 'incomplete', label: '未配齐' },
{ value: 'error', label: '无法判定' },
{ value: 'unknown', label: '未检测' },
]
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
function rowStatusMeta(status: string) {
switch (status) {
case 'passed':
return { label: '检测通过', tone: 'is-allowed' }
case 'failed':
return { label: '检测失败', tone: 'is-blocked' }
case 'incomplete':
return { label: '未配齐', tone: 'is-warn' }
case 'error':
return { label: '无法判定', tone: 'is-warn' }
default:
return { label: '未检测', tone: 'is-unknown' }
}
}
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
if (!module || !module.exists) {
return { label: '未配置', tone: 'is-unknown' }
}
switch (module.checkStatus) {
case 'passed':
return { label: '通过', tone: 'is-allowed' }
case 'failed':
if (module.checkCode === 'insufficient_balance') {
return { label: '欠费', tone: 'is-warn' }
}
return { label: '失败', tone: 'is-blocked' }
case 'error':
return { label: '无法判定', tone: 'is-warn' }
default:
return { label: '未检测', tone: 'is-unknown' }
}
}
/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */
function moduleTooltip(module: AdminUserSecretModule | undefined) {
if (!module || !module.exists) return '未配置'
const parts: string[] = []
if (module.full) parts.push(module.full)
if (module.checkMessage) parts.push(module.checkMessage)
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
return parts.join('') || '暂无检测记录'
}
/** 分组列文案:优先分组名;未建分组的用户回退展示所属主管,便于判断归属。 */
function groupTextOf(row: AdminUserSecretRow) {
if (row.groups?.length) return row.groups.join('、')
if (row.leaderUsername) return `${row.leaderUsername}(未建分组)`
return '—'
}
function rowStatusTooltip(row: AdminUserSecretRow) {
const parts: string[] = []
if (row.statusMessage) parts.push(row.statusMessage)
if (row.updatedAt) parts.push(`最近更新:${formatDateTime(row.updatedAt)}`)
return parts.join('') || '暂无检测记录'
}
async function load() {
loading.value = true
try {
const result = await fetchUserSecretList({
keyword: keyword.value.trim() || undefined,
checkStatus: statusFilter.value || undefined,
groupId: groupFilter.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
groupOptions.value = result?.groupOptions || []
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
keyword.value = ''
statusFilter.value = ''
groupFilter.value = null
page.value = 1
load()
}
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
async function check(row: AdminUserSecretRow) {
checkingId.value = row.userId
try {
const results = await checkUserSecret(row.userId)
if (!results || !results.length) {
ElMessage.warning('该用户尚未配置任何密钥或代理')
} else {
const failed = results.filter((item) => item.checkStatus === 'failed')
const errors = results.filter((item) => item.checkStatus === 'error')
if (failed.length) {
ElMessage.warning(failed[0].checkMessage || '存在检测失败项')
} else if (errors.length) {
ElMessage.warning('部分配置本次无法判定')
} else {
ElMessage.success('检测通过')
}
}
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '检测失败')
} finally {
checkingId.value = null
}
}
async function remove(row: AdminUserSecretRow) {
const who = row.username || `UID ${row.userId}`
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
try {
await deleteUserSecret(row.userId)
ElMessage.success('已清空')
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清空失败')
}
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
onMounted(load)
</script>
<template>
<div class="user-secrets-view">
<section class="panel-box">
<div class="secrets-head">
<h3>用户密钥列表</h3>
</div>
<div class="form-row secrets-filter-row">
<div class="form-group" style="min-width: 220px">
<label>用户名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
</div>
<div class="form-group" style="min-width: 170px">
<label>状态三项都通过才算通过</label>
<select v-model="statusFilter">
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</div>
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
<label>分组</label>
<select v-model="groupFilter">
<option :value="null">全部分组</option>
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="secrets-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 260px">用户</th>
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
<th style="width: 230px">货源查询密钥</th>
<th style="width: 230px">外观专利密钥</th>
<th style="width: 380px">代理设置</th>
<th style="width: 130px">状态</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.userId">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td>
<span class="user-name">{{ row.username || '—' }}</span>
</td>
<td v-if="isSuperAdmin">
<span class="creator-name">{{ groupTextOf(row) }}</span>
</td>
<td>
<div class="module-cell">
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.similarAsin).tone" :title="moduleTooltip(row.similarAsin)">
{{ moduleStatusMeta(row.similarAsin).label }}
</span>
</div>
</td>
<td>
<div class="module-cell">
<span v-if="row.appearancePatent?.exists" class="mono-mask" :title="moduleTooltip(row.appearancePatent)">{{ row.appearancePatent.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.appearancePatent).tone" :title="moduleTooltip(row.appearancePatent)">
{{ moduleStatusMeta(row.appearancePatent).label }}
</span>
</div>
</td>
<td>
<div class="module-cell module-cell--stack">
<span v-if="row.proxy?.exists" class="mono-mask mono-full" :title="moduleTooltip(row.proxy)">{{ row.proxy.full || row.proxy.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
{{ moduleStatusMeta(row.proxy).label }}
</span>
</div>
</td>
<td>
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
{{ rowStatusMeta(row.status).label }}
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button
class="btn btn-sm"
type="button"
:disabled="checkingId === row.userId"
@click="check(row)"
>
{{ checkingId === row.userId ? '检测中…' : '立即检测' }}
</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html 面板风格(与店铺密钥页同一视觉语言)。 */
.user-secrets-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.secrets-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.secrets-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.secrets-table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.secrets-table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1440px;
}
.secrets-table-scroll th,
.secrets-table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secrets-table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.secrets-table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.secrets-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.user-name {
color: #24384d;
font-weight: 600;
/* 用户名(含中文)可完整换行展示,不截断 */
white-space: normal;
overflow-wrap: anywhere;
}
.creator-name {
color: #5b6f83;
font-size: 12.5px;
}
.module-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
.module-cell--stack {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.module-cell .mono-mask {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* 代理列展示完整明文:允许折行显示,不截断、不省略 */
.module-cell .mono-full {
flex: 1 1 auto;
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.45;
cursor: text;
user-select: all;
}
.mono-mask {
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12.5px;
letter-spacing: 0.5px;
}
.empty-value {
color: #a7b4c1;
}
.ops-cell {
display: flex;
gap: 8px;
}
.empty-tip {
color: #8293a5;
text-align: center;
}
.wh-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid;
font-size: 12px;
line-height: 1.7;
cursor: help;
flex-shrink: 0;
}
.wh-pill.is-allowed {
background: #e8f6ee;
border-color: #b9e0c9;
color: #2f7d52;
}
.wh-pill.is-blocked {
background: #fdecef;
border-color: #f2c4cd;
color: #b04a5a;
}
.wh-pill.is-warn {
background: #fdf6e3;
border-color: #f0dfae;
color: #8f6d1e;
}
.wh-pill.is-unknown {
background: #f0f3f6;
border-color: #d6dee6;
color: #6b7d8f;
}
</style>
@@ -169,6 +169,7 @@ onMounted(loadUsers)
<th style="width: 140px">角色</th>
<th style="width: 160px">所属管理员</th>
<th style="width: 180px">创建时间</th>
<th style="width: 180px">更新时间</th>
<th style="width: 160px">操作</th>
</tr>
</thead>
@@ -180,6 +181,7 @@ onMounted(loadUsers)
<td>{{ roleLabel(row.role) }}</td>
<td>{{ row.creatorUsername || '-' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button
class="btn btn-sm"
@@ -203,10 +205,10 @@ onMounted(loadUsers)
</tr>
</template>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
<td colspan="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">{{ userListEmptyHint() }}</td>
<td colspan="7" class="empty-tip">{{ userListEmptyHint() }}</td>
</tr>
</tbody>
</table>
@@ -15,6 +15,7 @@ export interface MenuManageNode {
routePath: string
sortOrder: number
createdAt?: string
updatedAt?: string
children?: MenuManageNode[]
}
@@ -90,6 +91,8 @@ export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
if (rootColumnKey) node.rootColumnKey = rootColumnKey
const createdAt = text(record.created_at)
if (createdAt) node.createdAt = createdAt
const updatedAt = text(record.updated_at)
if (updatedAt) node.updatedAt = updatedAt
return node
}
@@ -16,6 +16,11 @@ export interface MenuOptionNode {
parentId: number | null
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
type: string
/**
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
*/
disabled?: boolean
children?: MenuOptionNode[]
}
@@ -39,6 +44,8 @@ export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode
sort: sortRaw === null ? 0 : sortRaw,
parentId: parentId === null ? null : parentId,
type,
// 缺省(菜单管理页等未标记的接口)按可授予处理,保持旧行为
disabled: record.grantable === false,
}
}
@@ -183,9 +183,9 @@ onMounted(() => {
<label>品牌</label>
<input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
</div>
<div class="form-group" style="min-width: 180px">
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组" :disabled="!isSuperAdmin">
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
@@ -199,9 +199,10 @@ onMounted(() => {
<th style="width: 70px">ID</th>
<th style="width: 170px">ASIN</th>
<th style="width: 150px">品牌</th>
<th style="width: 140px">分组</th>
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
<th style="width: 110px">来源</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
@@ -211,13 +212,14 @@ onMounted(() => {
<td>{{ row.id }}</td>
<td class="mono-cell">{{ row.dataValue }}</td>
<td>{{ row.brand || '—' }}</td>
<td>{{ row.groupName || '未分组' }}</td>
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
<td>
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
{{ recordSourceLabel(row.recordSource) }}
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
@@ -225,10 +227,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">暂无数据</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
@@ -24,6 +24,11 @@ import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './de
import type { DedupeImportProgress } from './dedupe-import-model.ts'
import { toExportUrl } from './dedupe-total-export.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<DedupeTotalItem[]>([])
@@ -32,7 +37,16 @@ const page = ref(1)
const pageSize = ref(10)
const filter = reactive(createDedupeTotalFilterState())
const groups = ref<DedupeGroupOption[]>([])
/** 非超管仅有一个可访问分组时,编辑/导入弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
const jumpPage = ref('')
/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */
const pageCursor = ref<number | null>(null)
/** 本次请求实际使用的游标(load 时决定) */
let pendingCursor: number | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
@@ -75,10 +89,15 @@ function stopExportWait(): void {
async function load(): Promise<void> {
loading.value = true
try {
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize.value))
const result = await fetchDedupeTotalList(
toDedupeListParams(filter, page.value, pageSize.value, pendingCursor),
)
rows.value = result.items
total.value = result.total
if (result.page >= 1) page.value = result.page
// 本页响应回传的游标留作"下一页"用;空页则清空(没有更多)
pageCursor.value = result.nextLastId ?? null
pendingCursor = null
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
} finally {
@@ -88,11 +107,15 @@ async function load(): Promise<void> {
function apply(): void {
page.value = 1
pageCursor.value = null
pendingCursor = null
void load()
}
function changePage(next: number): void {
if (next < 1 || next > totalPages.value) return
// 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变
pendingCursor = next === page.value + 1 ? pageCursor.value : null
page.value = next
void load()
}
@@ -109,13 +132,15 @@ function goJump(): void {
function changeSize(size: number) {
pageSize.value = size
page.value = 1
pageCursor.value = null
pendingCursor = null
void load()
}
function openEdit(row: DedupeTotalItem): void {
editTarget.value = row
editValue.value = row.dataValue
editGroupId.value = row.groupId
editGroupId.value = row.groupId ?? lockedGroupId.value
editVisible.value = true
}
@@ -157,7 +182,7 @@ async function removeRow(row: DedupeTotalItem): Promise<void> {
}
function resetImport(): void {
importGroupId.value = null
importGroupId.value = lockedGroupId.value
importFile.value = null
importProgress.value = ''
importRunning.value = false
@@ -306,7 +331,7 @@ onMounted(() => {
<label>用户名模糊搜索</label>
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
</div>
<div class="form-group">
<div v-if="isSuperAdmin" class="form-group">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
@@ -348,8 +373,9 @@ onMounted(() => {
<th>ASIN</th>
<th>国家</th>
<th>用户名</th>
<th>分组</th>
<th v-if="isSuperAdmin">分组</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
@@ -360,8 +386,9 @@ onMounted(() => {
<td class="mono-cell">{{ row.dataValue }}</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<td>{{ row.username || '-' }}</td>
<td>{{ row.groupName || '未分组' }}</td>
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
@@ -369,10 +396,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">暂无总数据</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无总数据</td>
</tr>
</tbody>
</table>
@@ -390,7 +417,7 @@ onMounted(() => {
</div>
<div class="form-group">
<label>分组</label>
<select v-model="editGroupId">
<select v-model="editGroupId" :disabled="lockedGroupId != null">
<option disabled :value="null">请选择分组</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
@@ -411,7 +438,7 @@ onMounted(() => {
</div>
<div class="form-group">
<label>分组</label>
<select v-model="importGroupId" :disabled="importRunning">
<select v-model="importGroupId" :disabled="importRunning || lockedGroupId != null">
<option disabled :value="null">请选择分组</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
@@ -15,6 +15,7 @@ import {
} from './product-category-model.ts'
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from './product-category-export.ts'
import { formatDateTime } from '@/utils/datetime'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false)
@@ -330,6 +331,8 @@ onMounted(loadTree)
<th style="width: 90px">排序</th>
<th style="width: 120px">来源</th>
<th>备注</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
@@ -337,7 +340,7 @@ onMounted(loadTree)
<template v-if="treeRows.length">
<template v-for="row in treeRows" :key="String(row.node.id)">
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
<td colspan="6">
<td colspan="8">
<div class="load-more-cell">
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
<button class="btn btn-sm btn-secondary" type="button" :disabled="moreLoading(row.node)" @click="loadMoreChildren(row.node)">
@@ -376,6 +379,8 @@ onMounted(loadTree)
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.node.createdAt) }}</td>
<td>{{ formatDateTime(row.node.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
<button
@@ -392,10 +397,10 @@ onMounted(loadTree)
</template>
</template>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无商品类目</td>
<td colspan="8" class="empty-tip">暂无商品类目</td>
</tr>
</tbody>
<tbody v-else>
@@ -411,6 +416,8 @@ onMounted(loadTree)
<span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
@@ -426,10 +433,10 @@ onMounted(loadTree)
</tr>
</template>
<tr v-else-if="searchLoading">
<td colspan="6" class="empty-tip">搜索中...</td>
<td colspan="8" class="empty-tip">搜索中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无搜索结果</td>
<td colspan="8" class="empty-tip">暂无搜索结果</td>
</tr>
</tbody>
</table>
@@ -4,6 +4,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
import { formatDateTime } from '@/utils/datetime'
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
import { asinCountryLabel } from './asin-country.ts'
@@ -16,6 +17,11 @@ import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<QueryAsinItem[]>([])
@@ -23,6 +29,11 @@ const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
@@ -115,7 +126,7 @@ const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void {
createGroupId.value = null
createGroupId.value = lockedGroupId.value
createShopName.value = ''
createCountry.value = ''
createAsin.value = ''
@@ -123,6 +134,8 @@ function openCreate(): void {
createMsgOk.value = false
shopNames.value = []
createVisible.value = true
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
if (createGroupId.value != null) void onCreateGroupChange()
}
async function onCreateGroupChange(): Promise<void> {
@@ -189,7 +202,7 @@ const importRunning = ref(false)
const importProgress = ref('')
function resetImport(): void {
importGroupId.value = null
importGroupId.value = lockedGroupId.value
importFile.value = null
importProgress.value = ''
importRunning.value = false
@@ -348,7 +361,7 @@ onMounted(() => {
</div>
<div class="form-row query-filter-row">
<div class="form-group">
<div v-if="isSuperAdmin" class="form-group">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
@@ -378,10 +391,12 @@ onMounted(() => {
<thead>
<tr>
<th style="width: 6%">序号</th>
<th style="width: 13%">分组</th>
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
@@ -390,7 +405,7 @@ onMounted(() => {
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '' }}</td>
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
</template>
<td>
@@ -401,6 +416,8 @@ onMounted(() => {
</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
@@ -409,10 +426,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无数据</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
@@ -423,7 +440,7 @@ onMounted(() => {
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
@@ -475,7 +492,7 @@ onMounted(() => {
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%" :disabled="lockedGroupId != null">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
@@ -4,6 +4,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
import { formatDateTime } from '@/utils/datetime'
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
import { asinCountryLabel } from './asin-country.ts'
@@ -15,6 +16,11 @@ import { fetchShopNamesByGroup } from './query-asin-api.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<SkipPriceItem[]>([])
@@ -22,6 +28,11 @@ const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
@@ -57,7 +68,7 @@ const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void {
createGroupId.value = null
createGroupId.value = lockedGroupId.value
createShopName.value = ''
createCountry.value = ''
createAsin.value = ''
@@ -66,6 +77,8 @@ function openCreate(): void {
createMsgOk.value = false
shopNames.value = []
createVisible.value = true
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
if (createGroupId.value != null) void onCreateGroupChange()
}
async function onCreateGroupChange(): Promise<void> {
@@ -238,7 +251,7 @@ const importRunning = ref(false)
const importProgress = ref('')
function resetImport(): void {
importGroupId.value = null
importGroupId.value = lockedGroupId.value
importFile.value = null
importProgress.value = ''
importRunning.value = false
@@ -392,7 +405,7 @@ onMounted(() => {
</div>
<div class="form-row skip-filter-row">
<div class="form-group">
<div v-if="isSuperAdmin" class="form-group">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
@@ -430,11 +443,13 @@ onMounted(() => {
<thead>
<tr>
<th style="width: 6%">序号</th>
<th style="width: 13%">分组</th>
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 10%">最低价</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
@@ -443,7 +458,7 @@ onMounted(() => {
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '' }}</td>
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
</template>
<td>
@@ -455,6 +470,8 @@ onMounted(() => {
<td>{{ asinCountryLabel(row.country || '') }}</td>
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
@@ -463,10 +480,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">暂无数据</td>
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
@@ -477,7 +494,7 @@ onMounted(() => {
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
@@ -535,7 +552,7 @@ onMounted(() => {
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组(必选)">
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%" :disabled="lockedGroupId != null">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
@@ -24,6 +24,8 @@ export interface AsinListParams {
groupId?: number | null
/** 国家代码(如 DE、UK)。 */
country?: string
/** 顺序翻页游标(上一页返回的 nextLastId):传了就忽略 page 偏移,走 keyset。 */
lastId?: number
}
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */
@@ -36,6 +38,8 @@ export interface AsinPageQuery {
end_date?: string
group_id?: number
country?: string
/** 顺序翻页游标(keyset):传了就忽略 page 偏移。 */
last_id?: number
}
function finiteInt(value: unknown): number | null {
@@ -77,5 +81,6 @@ export function toAsinPageQuery(params: AsinListParams): AsinPageQuery {
if (params.endDate) query.end_date = params.endDate
if (params.groupId != null) query.group_id = params.groupId
if (params.country) query.country = params.country
if (typeof params.lastId === 'number' && params.lastId > 0) query.last_id = params.lastId
return query
}
@@ -30,8 +30,10 @@ export function toDedupeListParams(
state: DedupeTotalFilterState,
page: number,
pageSize: number,
lastId?: number | null,
): AsinListParams {
const params: AsinListParams = { page, pageSize }
if (typeof lastId === 'number' && lastId > 0) params.lastId = lastId
const keyword = (state.keyword || '').trim()
const username = (state.username || '').trim()
const country = (state.country || '').trim()
@@ -11,6 +11,7 @@ export interface DedupeTotalItem {
uploaderUserId: number | null
username: string
createdAt?: string
updatedAt?: string
}
export interface DedupeTotalPageResult {
@@ -18,6 +19,8 @@ export interface DedupeTotalPageResult {
total: number
page: number
pageSize: number
/** 顺序翻页游标:本页最后一行 id;下一页回传它即可走 keyset。 */
nextLastId?: number
}
export function emptyDedupeTotalPage(): DedupeTotalPageResult {
@@ -49,6 +52,8 @@ export function toDedupeTotalItem(raw: unknown): DedupeTotalItem | null {
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item
}
@@ -65,6 +70,8 @@ export function parseDedupeTotalPage(payload: unknown): DedupeTotalPageResult {
}
if (typeof record.total === 'number') out.total = Math.floor(record.total)
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
const rawNextLastId = record.nextLastId ?? record.next_last_id
if (typeof rawNextLastId === 'number' && rawNextLastId > 0) out.nextLastId = Math.floor(rawNextLastId)
const rawSize = record.pageSize ?? record.page_size
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
return out
@@ -14,6 +14,8 @@ export interface ProductCategoryNode {
childCount: number
level: number | null
path: string
createdAt?: string
updatedAt?: string
children: ProductCategoryNode[]
}
@@ -72,6 +74,10 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null
path: text(record.path),
children: [],
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) node.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) node.updatedAt = updatedAt
if (Array.isArray(record.children)) {
node.children = record.children
.map((child) => toProductCategoryNode(child))
@@ -12,6 +12,7 @@ export interface InvalidAsinItem {
groupName: string
recordSource: string
createdAt?: string
updatedAt?: string
}
export interface InvalidAsinPageResult {
@@ -74,6 +75,8 @@ export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | null {
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item
}
@@ -18,6 +18,8 @@ const errorMessage = ref('')
const logoUrl = joinAdminPath('assets', 'logo.jpg')
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
/** 单设备登录:被新设备顶下线后跳回登录页(整页跳转携带 ?kicked=1) */
const kickedNotice = computed(() => route.query.kicked === '1')
function deviceId(): string {
try {
@@ -120,6 +122,7 @@ onMounted(() => {
<p class="login-subtitle">使用管理员账号进入数富AI运营后台</p>
</header>
<form novalidate @submit.prevent="submit">
<p v-if="kickedNotice" class="kicked-msg" role="alert">该账号已在其他设备登录本设备已下线如非本人操作请及时修改密码</p>
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
<div class="form-group">
<label for="loginUsername">用户名</label>
@@ -296,6 +299,20 @@ button, input { font: inherit; }
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
}
/* 单设备登录:被新设备顶下线的提示 */
.kicked-msg {
display: flex; align-items: flex-start; gap: 9px;
margin: -4px 0 18px; padding: 11px 12px;
border: 1px solid #e0cf9f; border-radius: 11px; background: #fbf4e2; color: #8a6a1f;
font-size: 13px; line-height: 1.55;
}
.kicked-msg::before {
content: "!";
display: inline-flex; align-items: center; justify-content: center;
flex: 0 0 18px; width: 18px; height: 18px;
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
}
.btn-login {
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
width: 100%; min-height: 48px; padding: 12px 18px;
@@ -0,0 +1,719 @@
<script setup lang="ts">
/** 日志管理页:桌面客户端 / 麦象采集机上报的日志浏览(仅超管)。
* 支持来源/日期/关键字筛选,尾部内容查看(自动滚到底、可向前加载更早内容)、
* 完整下载、删除;「采集配置」可调全量/精选模式(全局默认 + 终端覆盖)。 */
import { computed, nextTick, onMounted, ref } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
import { formatDateTime } from '@/utils/datetime'
import {
deleteDeviceLogFile,
deleteDeviceLogOverride,
deviceLogDownloadUrl,
fetchDeviceLogConfig,
fetchDeviceLogContent,
fetchDeviceLogDevices,
fetchDeviceLogFiles,
updateDeviceLogGlobalMode,
updateDeviceLogOverride,
type DeviceLogContent,
type DeviceLogDevice,
type DeviceLogFileRow,
type DeviceLogOverride,
} from '@/api/device-logs'
const SOURCE_OPTIONS = [
{ value: '', label: '全部来源' },
{ value: 'client', label: '桌面客户端' },
{ value: 'maixiang', label: '麦象采集机' },
]
function sourceLabel(source: string): string {
if (source === 'client') return '桌面客户端'
if (source === 'maixiang') return '麦象采集机'
return source || '—'
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let value = bytes / 1024
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
}
const loading = ref(false)
const rows = ref<DeviceLogFileRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const sourceFilter = ref('')
const keyword = ref('')
const startDate = ref('')
const endDate = ref('')
const retentionDays = ref(7)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
async function load() {
loading.value = true
try {
const result = await fetchDeviceLogFiles({
source: sourceFilter.value || undefined,
keyword: keyword.value.trim() || undefined,
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
if (result?.retentionDays) retentionDays.value = result.retentionDays
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '日志列表加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
sourceFilter.value = ''
keyword.value = ''
startDate.value = ''
endDate.value = ''
page.value = 1
load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
// ---------------------------------------------------------------- 内容查看
const TAIL_STEP = 256 * 1024
const TAIL_MAX = 8 * 1024 * 1024
const viewerVisible = ref(false)
const viewerLoading = ref(false)
const viewerFile = ref<DeviceLogFileRow | null>(null)
const viewerContent = ref<DeviceLogContent | null>(null)
const viewerMaxBytes = ref(TAIL_STEP)
const viewerPre = ref<HTMLElement | null>(null)
async function openViewer(row: DeviceLogFileRow) {
viewerFile.value = row
viewerMaxBytes.value = TAIL_STEP
viewerContent.value = null
viewerVisible.value = true
await loadContent(true)
}
async function loadContent(scrollToBottom: boolean) {
if (!viewerFile.value) return
viewerLoading.value = true
try {
viewerContent.value = await fetchDeviceLogContent(viewerFile.value.id, viewerMaxBytes.value)
if (scrollToBottom) {
await nextTick()
if (viewerPre.value) viewerPre.value.scrollTop = viewerPre.value.scrollHeight
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '日志内容加载失败')
} finally {
viewerLoading.value = false
}
}
function loadMore() {
viewerMaxBytes.value = Math.min(viewerMaxBytes.value * 2, TAIL_MAX)
loadContent(false)
}
async function remove(row: DeviceLogFileRow) {
if (!window.confirm(`确认删除「${row.fileName}」(${row.deviceName || row.deviceId})的云端日志?删除后不可恢复。`)) return
try {
await deleteDeviceLogFile(row.id)
ElMessage.success('已删除')
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
// ---------------------------------------------------------------- 采集配置
const configVisible = ref(false)
const configLoading = ref(false)
const globalMode = ref('full')
const overrides = ref<DeviceLogOverride[]>([])
const devices = ref<DeviceLogDevice[]>([])
const newOverrideKey = ref('')
const newOverrideMode = ref('selected')
const MODE_OPTIONS = [
{ value: 'full', label: '全量采集' },
{ value: 'selected', label: '精选采集' },
]
function modeLabel(mode: string): string {
return mode === 'selected' ? '精选' : '全量'
}
async function openConfig() {
configVisible.value = true
await loadConfig()
}
async function loadConfig() {
configLoading.value = true
try {
const data = await fetchDeviceLogConfig()
globalMode.value = data.globalMode || 'full'
overrides.value = data.overrides || []
devices.value = (await fetchDeviceLogDevices()) || []
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '采集配置加载失败')
} finally {
configLoading.value = false
}
}
async function saveGlobalMode(mode: string) {
if (mode === globalMode.value) return
try {
await updateDeviceLogGlobalMode(mode)
globalMode.value = mode
ElMessage.success(`全局采集模式已切换为「${modeLabel(mode)}`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
}
}
async function addOverride() {
if (!newOverrideKey.value) {
ElMessage.warning('请先选择终端')
return
}
const [source, deviceId] = newOverrideKey.value.split('|')
const device = devices.value.find((item) => item.source === source && item.deviceId === deviceId)
try {
await updateDeviceLogOverride(source, deviceId, device?.deviceName || null, newOverrideMode.value)
ElMessage.success('终端覆盖已保存')
newOverrideKey.value = ''
await loadConfig()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
}
}
async function removeOverride(row: DeviceLogOverride) {
const who = row.deviceName || row.deviceId
if (!window.confirm(`确认删除终端「${who}」的采集覆盖(回落到全局默认)?`)) return
try {
await deleteDeviceLogOverride(row.id)
ElMessage.success('已删除')
await loadConfig()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
onMounted(load)
</script>
<template>
<div class="device-logs-view">
<section class="panel-box">
<div class="logs-head">
<h3>日志文件列表</h3>
<div class="logs-head-tools">
<span class="retention-tip">云端仅保留 {{ retentionDays }} </span>
<button class="btn btn-ghost" type="button" @click="openConfig">采集配置</button>
<button class="btn" type="button" @click="load">刷新</button>
</div>
</div>
<div class="form-row logs-filter-row">
<div class="form-group" style="min-width: 150px">
<label>来源</label>
<select v-model="sourceFilter">
<option v-for="option in SOURCE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</div>
<div class="form-group" style="min-width: 220px">
<label>设备 / 文件名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索设备名、设备ID、文件名" @keyup.enter="search" />
</div>
<div class="form-group" style="min-width: 150px">
<label>日志日期</label>
<input v-model="startDate" type="date" />
</div>
<div class="form-group" style="min-width: 150px">
<label>日志日期</label>
<input v-model="endDate" type="date" />
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="logs-table-scroll">
<table>
<thead>
<tr>
<th style="width: 110px">来源</th>
<th style="width: 230px">设备</th>
<th style="width: 130px">用户</th>
<th style="width: 230px">文件名</th>
<th style="width: 110px">日志日期</th>
<th style="width: 100px">已收大小</th>
<th style="width: 80px">片段数</th>
<th style="width: 170px">最后更新</th>
<th style="width: 190px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="row in rows" :key="row.id">
<td>
<span class="source-pill" :class="row.source === 'maixiang' ? 'is-maixiang' : 'is-client'">
{{ sourceLabel(row.source) }}
</span>
</td>
<td>
<span class="device-name" :title="row.deviceId">{{ row.deviceName || '—' }}</span>
<span class="device-id" :title="row.deviceId">{{ row.deviceId }}</span>
</td>
<td>
<span v-if="row.username" class="user-name">{{ row.username }}</span>
<span v-else-if="row.uid" class="user-name">UID {{ row.uid }}</span>
<span v-else class="dim"></span>
</td>
<td>
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
</td>
<td>{{ row.logDate }}</td>
<td>{{ formatBytes(row.uploadedBytes) }}</td>
<td>{{ row.partCount ?? 0 }}</td>
<td>{{ row.lastUploadAt ? formatDateTime(row.lastUploadAt) : '—' }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openViewer(row)">查看</button>
<a class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(row.id)" download>下载</a>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="9" class="empty-tip">
{{ keyword || sourceFilter || startDate || endDate ? '暂无匹配日志' : '暂无日志上报(客户端/采集机上报后自动出现在这里)' }}
</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="viewerVisible" :title="viewerFile ? `${viewerFile.fileName}${viewerFile.deviceName || viewerFile.deviceId}` : '日志内容'" width="900px" top="5vh">
<div class="viewer-toolbar">
<span class="viewer-meta">
云端已收 {{ formatBytes(viewerContent?.totalBytes ?? viewerFile?.uploadedBytes) }}
<template v-if="viewerContent"> · 当前展示 {{ formatBytes(viewerContent.shownBytes) }}</template>
<template v-if="viewerContent?.truncated"> · 更早内容未加载</template>
</span>
<span class="viewer-actions">
<button class="btn btn-sm btn-ghost" type="button" :disabled="viewerLoading" @click="loadContent(true)">刷新</button>
<button
class="btn btn-sm btn-ghost"
type="button"
:disabled="viewerLoading || !viewerContent?.truncated || viewerMaxBytes >= TAIL_MAX"
@click="loadMore"
>加载更早</button>
<a v-if="viewerFile" class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(viewerFile.id)" download>下载完整日志</a>
</span>
</div>
<pre ref="viewerPre" class="log-pre">{{ viewerLoading && !viewerContent ? '加载中...' : (viewerContent?.content || '(暂无内容)') }}</pre>
</el-dialog>
<el-dialog v-model="configVisible" title="采集配置" width="720px">
<div class="config-block">
<h4>全局默认模式</h4>
<p class="config-desc">对未单独配置的终端生效全量=上传日志目录内全部文件精选=排除低价值大日志客户端排除 pywebview麦象排除 kk-browser / 控制台 / 测试日志终端在下一次上报周期 1 分钟内跟随新配置</p>
<div class="mode-switch">
<button
v-for="option in MODE_OPTIONS"
:key="option.value"
class="mode-btn"
:class="{ 'is-active': globalMode === option.value }"
type="button"
@click="saveGlobalMode(option.value)"
>{{ option.label }}</button>
</div>
</div>
<div class="config-block">
<h4>终端覆盖</h4>
<div class="override-add">
<select v-model="newOverrideKey" class="override-select">
<option value="">选择终端最近上报的设备</option>
<option v-for="device in devices" :key="`${device.source}|${device.deviceId}`" :value="`${device.source}|${device.deviceId}`">
{{ sourceLabel(device.source) }} · {{ device.deviceName || device.deviceId }}{{ device.deviceId }}
</option>
</select>
<select v-model="newOverrideMode">
<option v-for="option in MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<button class="btn btn-sm" type="button" @click="addOverride">添加/更新覆盖</button>
</div>
<table class="override-table">
<thead>
<tr>
<th style="width: 100px">来源</th>
<th>设备</th>
<th style="width: 80px">模式</th>
<th style="width: 160px">更新时间</th>
<th style="width: 90px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in overrides" :key="row.id">
<td>{{ sourceLabel(row.source) }}</td>
<td>
<span class="device-name">{{ row.deviceName || row.deviceId }}</span>
<span class="device-id">{{ row.deviceId }}</span>
</td>
<td>{{ modeLabel(row.mode) }}</td>
<td>{{ row.updatedAt ? formatDateTime(row.updatedAt) : '—' }}</td>
<td class="ops-cell">
<button class="btn btn-sm btn-danger" type="button" @click="removeOverride(row)">删除</button>
</td>
</tr>
<tr v-if="!overrides.length">
<td colspan="5" class="empty-tip">{{ configLoading ? '加载中...' : '暂无终端覆盖(全部跟随全局默认)' }}</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<el-button @click="configVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 沿用「记录与版本」系列的旧后台面板视觉语言。 */
.device-logs-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.logs-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.logs-head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.retention-tip {
color: #8598ab;
font-size: 12.5px;
}
.logs-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
text-decoration: none;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 32px;
padding: 4px 12px;
font-size: 12.5px;
border-radius: 7px;
}
.logs-table-scroll {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
padding: 10px 12px;
text-align: left;
color: #5b6f83;
font-weight: 650;
font-size: 12.5px;
border-bottom: 1px solid #dbe6f0;
white-space: nowrap;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #eaf1f7;
vertical-align: top;
}
.source-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.source-pill.is-client {
background: #e8f1fb;
color: #37618f;
}
.source-pill.is-maixiang {
background: #eef7ec;
color: #3f7a42;
}
.device-name {
display: block;
font-weight: 600;
word-break: break-all;
}
.device-id {
display: block;
color: #8598ab;
font-size: 11.5px;
word-break: break-all;
}
.user-name {
font-weight: 600;
}
.file-name {
display: block;
word-break: break-all;
}
.dim {
color: #9db0c2;
}
.ops-cell {
display: flex;
gap: 8px;
flex-wrap: wrap;
border-bottom: none;
}
.empty-tip {
padding: 26px 0;
text-align: center;
color: #8598ab;
}
.dl-link {
text-decoration: none;
}
.viewer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.viewer-meta {
color: #5b6f83;
font-size: 12.5px;
}
.viewer-actions {
display: flex;
gap: 8px;
}
.log-pre {
max-height: 62vh;
margin: 0;
padding: 12px 14px;
overflow: auto;
border: 1px solid #d8e3ee;
border-radius: 10px;
background: #0f1c29;
color: #d7e4f1;
font-family: Consolas, 'Courier New', monospace;
font-size: 12.5px;
line-height: 1.55;
white-space: pre;
}
.config-block {
margin-bottom: 18px;
}
.config-block h4 {
margin: 0 0 6px;
font-size: 13.5px;
color: #24384d;
}
.config-desc {
margin: 0 0 10px;
color: #66798d;
font-size: 12.5px;
line-height: 1.6;
}
.mode-switch {
display: flex;
gap: 10px;
}
.mode-btn {
min-height: 38px;
padding: 8px 20px;
border: 1px solid #c7d7e5;
border-radius: 9px;
background: #ffffff;
color: #4f78a5;
font-size: 13.5px;
font-family: inherit;
cursor: pointer;
}
.mode-btn.is-active {
border-color: #4f78a5;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-weight: 600;
}
.override-add {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.override-add select {
min-height: 38px;
padding: 6px 10px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
color-scheme: light;
}
.override-select {
flex: 1;
min-width: 0;
}
.override-table td {
vertical-align: middle;
}
</style>
@@ -0,0 +1,554 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 教程管理页:工具台首页「立即下载教程」的包体管理(上传新包 / 列表 / 下载 / 删除)。
* 上传走浏览器直传 MinIOpresign → PUT 带进度 → confirm 落库),与软件版本管理页同一套链路;
* 上传时可填版本号(仅展示与排序用);工具台始终下载"最新上传"的包(不随列表排序变化)。
* 列表默认按上传时间降序,「版本号」「上传时间」表头可点击切换升降序。 */
import { computed, onMounted, ref, watch } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
import { fetchTutorialPackages, uploadTutorialPackage, deleteTutorialPackages } from './tutorial-api.ts'
import { formatFileSize } from './tutorial-model.ts'
import type { TutorialPackageItem } from './tutorial-dto.ts'
const loading = ref(false)
const items = ref<TutorialPackageItem[]>([])
/** 文件名搜索(客户端过滤)。 */
const keyword = ref('')
const filteredItems = computed(() => {
const kw = (keyword.value || '').trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((item) => item.fileName.toLowerCase().includes(kw))
})
/** 全站分页统一:客户端分页(10/20/50/100)。 */
const page = ref(1)
const pageSize = ref(10)
/** 排序:默认按上传时间降序(最新上传在最前,与后端返回顺序一致);点表头切换升降序。 */
type SortKey = 'version' | 'createdAt'
const sortKey = ref<SortKey>('createdAt')
const sortAsc = ref(false)
function toggleSort(key: SortKey) {
if (sortKey.value === key) {
sortAsc.value = !sortAsc.value
} else {
sortKey.value = key
sortAsc.value = false
}
page.value = 1
}
/** 排序标记:未激活 ▲▼,激活时只留方向箭头(比 ⇅/↕ 字形支持好,避免 Windows 缺字形显示方框)。 */
function sortMark(key: SortKey): string {
if (sortKey.value !== key) return '▲▼'
return sortAsc.value ? '▲' : '▼'
}
function compareText(a: string, b: string): number {
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' })
}
const sortedItems = computed(() => {
const rows = [...filteredItems.value]
rows.sort((a, b) => {
// 版本号为空的历史行始终排在末尾,避免切换排序时"无版本"占满首页。
if (sortKey.value === 'version') {
const av = a.version
const bv = b.version
if (!av || !bv) {
if (!av && !bv) return 0
return av ? -1 : 1
}
const diff = compareText(av, bv)
return sortAsc.value ? diff : -diff
}
const at = a.createdAt
const bt = b.createdAt
if (!at || !bt) {
if (!at && !bt) return 0
return at ? -1 : 1
}
const diff = compareText(at, bt)
return sortAsc.value ? diff : -diff
})
return rows
})
const pagedItems = computed(() => sortedItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changePage(p: number) { page.value = p }
function changeSize(size: number) { pageSize.value = size; page.value = 1 }
// 搜索/排序导致数据收缩时回钳页码,避免停在空页。
watch(sortedItems, () => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(sortedItems.value.length / pageSize.value)))
})
/** 当前生效包 = 列表第一条(工具台下发的就是它)。 */
const activeId = computed(() => items.value[0]?.id ?? null)
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
const selectedIds = ref<Set<number>>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
function toggleSelect(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function toggleSelectAllPage() {
const pageRows = pagedItems.value
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
const next = new Set(selectedIds.value)
if (allOn) pageRows.forEach((row) => next.delete(row.id))
else pageRows.forEach((row) => next.add(row.id))
selectedIds.value = next
}
async function removeSelected() {
if (!selectedCount.value) return
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
const sample = rows.slice(0, 3).map((row) => row.fileName).join('、')
const summary = rows.length > 3 ? `${sample}${rows.length}` : sample
if (!window.confirm(`确认删除选中的 ${rows.length} 个教程包(${summary})?此操作不可恢复。`)) return
try {
await deleteTutorialPackages(Array.from(selectedIds.value))
ElMessage.success(`已删除 ${rows.length} 个教程包`)
selectedIds.value = new Set()
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function removeOne(row: TutorialPackageItem) {
if (!window.confirm(`确认删除教程包 ${row.fileName}?此操作不可恢复。`)) return
try {
await deleteTutorialPackages([row.id])
ElMessage.success('删除成功')
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
const newVersion = ref('')
const pickedFile = ref<File | null>(null)
/** 弹窗内成功/失败文案留驻(对齐软件版本页交互)。 */
const uploadMsg = ref('')
const uploadMsgOk = ref(false)
async function load() {
loading.value = true
try {
items.value = (await fetchTutorialPackages()).items
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '教程包列表加载失败')
} finally {
loading.value = false
}
}
function onFileChange(file: File) {
pickedFile.value = file
}
function openUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
newVersion.value = ''
pickedFile.value = null
uploadVisible.value = true
}
async function submitUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
uploadPercent.value = 0
if (!pickedFile.value) {
uploadMsg.value = '请选择 zip 压缩包'
return
}
if (!/\.zip$/i.test(pickedFile.value.name)) {
uploadMsg.value = '仅支持 .zip 格式'
return
}
if (pickedFile.value.size > 512 * 1024 * 1024) {
uploadMsg.value = '文件超过允许的大小限制'
return
}
uploading.value = true
try {
// 浏览器直传 MinIOpresign → PUT(进度条)→ confirm 落库。
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, newVersion.value.trim(), (p) => {
uploadPercent.value = p
uploadMsg.value = p >= 100 ? '上传完成,正在登记教程包...' : `正在上传:${p}%`
})
uploadMsg.value = '上传成功,工具台「立即下载教程」已切换为该包'
uploadMsgOk.value = true
newVersion.value = ''
pickedFile.value = null
uploadPercent.value = 0
load()
} catch (error) {
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
} finally {
uploading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="tutorial-view">
<section class="panel-box">
<div class="tutorial-head">
<h3>教程包列表</h3>
<div class="tutorial-head-tools">
<input v-model="keyword" type="text" placeholder="搜索文件名" class="tutorial-keyword" />
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
<button class="btn" type="button" @click="openUpload">上传教程包</button>
</div>
</div>
<div class="table-scroll tutorial-table-scroll">
<table>
<thead>
<tr>
<th style="width: 40px" class="th-select">
<input type="checkbox" :checked="pagedItems.length > 0 && pagedItems.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedItems.length" />
</th>
<th style="width: 260px">文件名</th>
<th style="width: 110px">
<button class="sort-th sort-version" type="button" @click="toggleSort('version')">版本号<span class="sort-mark">{{ sortMark('version') }}</span></button>
</th>
<th style="width: 110px">大小</th>
<th style="width: 150px">
<button class="sort-th sort-time" type="button" @click="toggleSort('createdAt')">上传时间<span class="sort-mark">{{ sortMark('createdAt') }}</span></button>
</th>
<th>下载链接</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredItems.length">
<tr v-for="row in pagedItems" :key="row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
<td class="td-select">
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
</td>
<td>
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
<span v-if="row.id === activeId" class="tag-active">当前生效</span>
</td>
<td>
<span v-if="row.version" class="version-cell" :title="row.version">{{ row.version }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatFileSize(row.fileSize) }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
<span v-else class="dim"></span>
</td>
<td class="ops-cell">
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
<span v-else class="dim"></span>
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="uploadVisible" title="上传教程包" width="520px">
<p class="upload-desc">上传教程 ZIP 包后工具台首页立即下载教程将以下载该包为准以最新上传的为主</p>
<el-form label-width="110px">
<el-form-item label="版本号">
<el-input v-model="newVersion" placeholder="例如:v2026.09 或留空" maxlength="64" />
<p class="zip-hint">仅作展示与排序用可留空不影响工具台按最新上传下载</p>
</el-form-item>
<el-form-item label="ZIP 压缩包" required>
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (pickedFile = null)">
<el-button>选择文件</el-button>
</el-upload>
<p class="zip-hint">仅支持 .zip 格式,最大 512MB</p>
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}{{ formatFileSize(pickedFile.size) }}</div>
</el-form-item>
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">上传教程包</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 沿用软件版本管理页(旧版 admin.html panel-version)的样式语言。 */
.tutorial-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.tutorial-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.tutorial-head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.tutorial-keyword {
width: 200px;
min-height: 42px;
padding: 6px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.tutorial-keyword:hover {
border-color: #9fb7cd;
}
.tutorial-keyword:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.tutorial-table-scroll > table {
min-width: 940px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.file-name {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
/* 表头排序:按钮铺满单元格,仅 hover 时加深字色,保持旧版表头观感。 */
.sort-th {
display: inline-flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 0;
border: 0;
background: none;
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
letter-spacing: inherit;
text-align: left;
cursor: pointer;
}
.sort-th:hover {
color: #2f5d8b;
}
.sort-mark {
color: #8293a5;
font-size: 12px;
}
.sort-th:hover .sort-mark {
color: #5f85ad;
}
.sort-version {
width: 110px;
}
.sort-time {
width: 150px;
}
.version-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.tag-active {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border: 1px solid #4f9a72;
border-radius: 999px;
background: #eaf6ef;
color: #2f7a55;
font-size: 11.5px;
vertical-align: middle;
}
.link-cell {
display: block;
color: #2f5d8b;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.link-cell:hover {
text-decoration: underline;
}
.dl-btn {
text-decoration: none;
}
.dim {
color: #8293a5;
font-size: 12px;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.upload-desc {
margin: 0 0 12px;
color: #5b6f83;
font-size: 12.5px;
line-height: 1.6;
}
.zip-hint {
margin: 4px 0 0;
color: #5b6f83;
font-size: 12px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.row-selected td {
background: #fdf0ee;
}
.th-select,
.td-select {
text-align: center;
padding-left: 4px !important;
padding-right: 4px !important;
}
.th-select input,
.td-select input {
cursor: pointer;
accent-color: #b33a2e;
}
.upload-progress {
margin: 2px 0 10px;
}
.upload-msg {
margin-bottom: 8px;
}
.upload-msg :deep(.el-alert__title) {
word-break: break-all;
}
</style>
@@ -0,0 +1,81 @@
/** 教程包接口适配(教程管理页):列表 / 直传预签名 / 确认 / 删除。 */
import axios from 'axios'
import { http, unwrap } from '@/api/http'
import { parseTutorialPackageList, parseTutorialPackageUpload } from './tutorial-model.ts'
import type { TutorialPackageItem, TutorialPackageList } from './tutorial-dto.ts'
export const TUTORIALS_ENDPOINT = '/api/admin/tutorials'
export const TUTORIAL_UPLOAD_ENDPOINT = '/api/admin/tutorial'
export const TUTORIAL_PRESIGN_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/presign`
export const TUTORIAL_CONFIRM_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/confirm`
export const TUTORIAL_DELETE_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/delete`
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
export async function fetchTutorialPackages(): Promise<TutorialPackageList> {
const { data } = await http.get<unknown>(TUTORIALS_ENDPOINT)
return parseTutorialPackageList(data)
}
export interface TutorialUploadTarget {
objectKey: string
uploadUrl: string
fileUrl: string
version: string
}
/** 第一步:向后端申请直传 PUT 预签名地址(登录会话签发,3 分钟有效)。 */
export async function requestTutorialPresign(fileName: string, version = ''): Promise<TutorialUploadTarget> {
const { data } = await http.post<unknown>(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName, version } })
const core = (unwrap(data) ?? {}) as Record<string, unknown>
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
const objectKey = typeof core.object_key === 'string' ? core.object_key : ''
if (!uploadUrl || !objectKey) {
throw new Error('后端未返回直传地址,请重试')
}
return {
objectKey,
uploadUrl,
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
version: typeof core.version === 'string' ? core.version : version.trim(),
}
}
/** 第三步:直传完成后通知后端校验对象并写入记录(data.item 为新记录行)。 */
export async function confirmTutorialPackage(objectKey: string, fileName: string, version = ''): Promise<TutorialPackageItem | null> {
const { data } = await http.post<unknown>(TUTORIAL_CONFIRM_ENDPOINT, null, {
params: { object_key: objectKey, file_name: fileName, version },
})
return parseTutorialPackageUpload(data)
}
/** 按 id 批量删除教程包(后端会顺带清理无引用对象的 MinIO 包体);返回实际删除行数。 */
export async function deleteTutorialPackages(ids: number[]): Promise<number> {
if (!ids.length) return 0
const { data } = await http.post<unknown>(TUTORIAL_DELETE_ENDPOINT, ids)
const core = (unwrap(data) ?? {}) as Record<string, unknown>
return typeof core.deleted === 'number' ? core.deleted : ids.length
}
/**
* 上传教程包(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
*/
export async function uploadTutorialPackage(
file: Blob,
fileName: string,
version = '',
onProgress?: (percent: number) => void,
): Promise<TutorialPackageItem | null> {
const target = await requestTutorialPresign(fileName, version)
await directPut.put(target.uploadUrl, file, {
headers: { 'Content-Type': 'application/octet-stream' },
onUploadProgress: (event) => {
if (!onProgress) return
const total = event.total || 0
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
},
})
return confirmTutorialPackage(target.objectKey, fileName, target.version)
}
@@ -0,0 +1,21 @@
/** 教程包 DTO(教程管理页):工具台下载入口对应的教程压缩包行类型;纯逻辑。 */
/** 教程包行(GET /api/admin/tutorials data.itemsJava TutorialPackageService snake)。 */
export interface TutorialPackageItem {
id: number
fileName: string
/** 版本号(上传时填写,V126 之前的历史行为空串) */
version: string
objectKey: string
fileSize: number
fileUrl: string
createdAt: string
}
export interface TutorialPackageList {
items: TutorialPackageItem[]
}
export function emptyTutorialPackageList(): TutorialPackageList {
return { items: [] }
}
@@ -0,0 +1,60 @@
/** 教程包列表加载模型:解析 /api/admin/tutorials data.items(snake);纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
import { type TutorialPackageItem, type TutorialPackageList } from './tutorial-dto.ts'
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 解析单条教程包行;缺 id 视为无效。 */
export function toTutorialPackageItem(raw: unknown): TutorialPackageItem | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const id = numberOrNull(r.id)
if (id === null) return null
return {
id,
fileName: text(r.file_name ?? r.fileName),
version: text(r.version),
objectKey: text(r.object_key ?? r.objectKey),
fileSize: numberOrNull(r.file_size ?? r.fileSize) ?? 0,
fileUrl: text(r.file_url ?? r.fileUrl),
createdAt: text(r.created_at ?? r.createdAt),
}
}
/** 归一化教程包列表负载为前端结果;缺省回空列表。 */
export function parseTutorialPackageList(payload: unknown): TutorialPackageList {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : {}
const items = Array.isArray(record.items)
? record.items.map((raw) => toTutorialPackageItem(raw)).filter((item): item is TutorialPackageItem => item !== null)
: []
return { items }
}
/** 上传成功后返回的新记录行(data.item);无 item 回 null。 */
export function parseTutorialPackageUpload(payload: unknown): TutorialPackageItem | null {
const data = unwrap<unknown>(payload)
if (!data || typeof data !== 'object') return null
const item = (data as Record<string, unknown>).item
return item ? toTutorialPackageItem(item) : null
}
/** 字节数展示:0=未知展示为 -,其余按 KB/MB/GB 人性化。 */
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let value = bytes / 1024
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
}
@@ -4,8 +4,8 @@ import { formatDateTime } from '@/utils/datetime'
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } from './shop-key-api.ts'
import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts'
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey, checkShopKeyWhitelist } from './shop-key-api.ts'
import { emptyShopKeyForm, normalizeZiniaoToken, validateProxyUrl } from './shop-key-form-model.ts'
import type { ShopKeyItem } from './shop-dto.ts'
import { whitelistStatusMeta } from './shop-key-model.ts'
import OldPagination from '@/components/OldPagination.vue'
@@ -30,6 +30,8 @@ const filteredRows = computed(() => {
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
/** 正在检测白名单的行 id,用于按钮 loading 态。 */
const checkingId = ref<number | null>(null)
const form = reactive(emptyShopKeyForm())
function maskToken(token: string): string {
@@ -46,6 +48,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
const parts: string[] = []
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage)
if (row.ipWhitelistFailCount > 0) parts.push(`连续失败 ${row.ipWhitelistFailCount}`)
if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`)
return parts.join('\n')
}
@@ -76,9 +79,30 @@ function openEdit(row: ShopKeyItem) {
Object.assign(form, emptyShopKeyForm())
form.remarkName = row.remarkName
form.ziniaoAccountName = row.ziniaoAccountName
form.proxyUrl = row.proxyUrl || ''
dialogVisible.value = true
}
/** 手动检测白名单:绕过缓存真实请求一次紫鸟,并重置自动重试次数。 */
async function checkWhitelist(row: ShopKeyItem) {
checkingId.value = row.id
try {
const updated = await checkShopKeyWhitelist(row.id)
if (updated.ipWhitelistStatus === 'ALLOWED') {
ElMessage.success('白名单检测通过')
} else if (updated.ipWhitelistStatus === 'BLOCKED') {
ElMessage.error(`白名单未放行:${updated.ipWhitelistMessage || '请求被拒绝'}`)
} else {
ElMessage.warning(updated.ipWhitelistMessage || '检测未通过')
}
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '检测失败')
} finally {
checkingId.value = null
}
}
async function submit() {
const token = normalizeZiniaoToken(form.ziniaoToken)
const creating = editingId.value == null
@@ -90,6 +114,11 @@ async function submit() {
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
return
}
const proxyError = validateProxyUrl(form.proxyUrl)
if (proxyError) {
ElMessage.warning(proxyError)
return
}
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
form.ziniaoToken = creating ? token : token || originalToken.value
saving.value = true
@@ -169,6 +198,7 @@ onMounted(load)
<th style="width: 150px">备注名</th>
<th style="width: 160px">紫鸟账号名称</th>
<th style="width: 220px">紫鸟令牌</th>
<th style="width: 190px">代理</th>
<th style="width: 150px">白名单状态 <span class="table-help" aria-hidden="true">?</span></th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">修改时间</th>
@@ -184,6 +214,9 @@ onMounted(load)
<td>
<span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
</td>
<td>
<span class="mono-mask" :title="row.proxyUrl || '直连'">{{ row.proxyUrl || '直连' }}</span>
</td>
<td>
<span
class="wh-pill"
@@ -201,15 +234,23 @@ onMounted(load)
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
class="btn btn-sm btn-ghost"
type="button"
:disabled="checkingId === row.id"
@click="checkWhitelist(row)"
>
{{ checkingId === row.id ? '检测中…' : '检测白名单' }}
</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="8" class="empty-tip">加载中...</td>
<td colspan="9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="8" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
<td colspan="9" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
</tr>
</tbody>
</table>
@@ -233,6 +274,15 @@ onMounted(load)
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
/>
</el-form-item>
<el-form-item label="代理地址">
<el-input
v-model="form.proxyUrl"
placeholder="留空直连;形如 http://host:port 或 http://user:pass@host:port"
/>
<div class="form-hint">
仅当该账号的服务器出口 IP 不在紫鸟白名单时填写代理 IP 需已由紫鸟公司加白留空则直连
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -348,6 +398,23 @@ h3 {
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.form-hint {
margin-top: 4px;
color: #8293a5;
font-size: 12px;
line-height: 1.5;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
@@ -364,7 +431,7 @@ h3 {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1000px;
min-width: 1200px;
}
.shop-key-table-scroll th,
.shop-key-table-scroll td {
@@ -18,6 +18,8 @@ import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<ShopSummary[]>([])
const total = ref(0)
@@ -42,6 +44,11 @@ const revealedPasswordCache = ref<Record<number, string>>({})
const revealedRowId = ref<number | null>(null)
const groupOptions = computed(() => groups.value)
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
function shownPassword(row: ShopSummary): string {
if (revealedRowId.value !== row.id) return row.passwordMasked || '******'
@@ -123,13 +130,14 @@ function goJump() {
function openCreate() {
editingId.value = null
Object.assign(form, emptyShopManageForm())
if (lockedGroupId.value != null) form.groupId = lockedGroupId.value
dialogVisible.value = true
}
function openEdit(row: ShopSummary) {
editingId.value = row.id
Object.assign(form, {
groupId: row.groupId,
groupId: row.groupId ?? lockedGroupId.value,
shopName: row.shopName,
mallName: row.mallName,
znUsername: row.znUsername,
@@ -214,7 +222,7 @@ onMounted(() => {
</div>
<div class="form-row shop-filter-row">
<div class="form-group" style="min-width: 180px">
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
@@ -232,7 +240,7 @@ onMounted(() => {
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 120px">分组</th>
<th v-if="isSuperAdmin" style="width: 120px">分组</th>
<th style="width: 150px">店铺名</th>
<th style="width: 130px">店铺商城名</th>
<th style="width: 130px">自动化账号</th>
@@ -247,7 +255,7 @@ onMounted(() => {
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.id">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td data-text :title="row.groupName || '—'">{{ row.groupName || '—' }}</td>
<td v-if="isSuperAdmin" data-text :title="row.groupName || '—'">{{ row.groupName || '' }}</td>
<td :title="row.shopName">{{ row.shopName }}</td>
<td :title="row.mallName || '—'">{{ row.mallName || '—' }}</td>
<td :title="row.znUsername || '—'">{{ row.znUsername || '—' }}</td>
@@ -273,10 +281,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td colspan="10" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="10" class="empty-tip">暂无店铺</td>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">暂无店铺</td>
</tr>
</tbody>
</table>
@@ -287,7 +295,7 @@ onMounted(() => {
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
<el-form label-width="120px">
<el-form-item label="所属分组" required>
<el-select filterable v-model="form.groupId" placeholder="选择分组">
<el-select filterable v-model="form.groupId" placeholder="选择分组" :disabled="lockedGroupId != null">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
@@ -316,7 +324,7 @@ onMounted(() => {
<el-dialog v-model="credentialVisible" title="店铺明文凭据" width="560px">
<el-descriptions v-if="credential" :column="1" border>
<el-descriptions-item label="店铺名">{{ credential.shopName }}</el-descriptions-item>
<el-descriptions-item label="分组">{{ credential.groupName || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="isSuperAdmin" label="分组">{{ credential.groupName || '' }}</el-descriptions-item>
<el-descriptions-item label="店铺商城名">{{ credential.mallName || '—' }}</el-descriptions-item>
<el-descriptions-item label="账号">{{ credential.account }}</el-descriptions-item>
<el-descriptions-item label="密码">
@@ -22,9 +22,13 @@ export interface ShopKeyItem {
ziniaoAccountName: string
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
ziniaoToken: SensitiveString
/** 静态代理地址;为空表示直连。用于出口 IP 不在紫鸟白名单的 key。 */
proxyUrl: string
ipWhitelistStatus: IpWhitelistStatus
ipWhitelistCheckedAt?: string
ipWhitelistMessage?: string
/** 连续白名单失败次数;达上限后不再自动重试,需人工检测。 */
ipWhitelistFailCount: number
createdAt?: string
updatedAt?: string
}
@@ -55,3 +55,13 @@ export async function updateShopKey(id: number, form: ShopKeyFormValues): Promis
export async function deleteShopKey(id: number): Promise<void> {
await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`)
}
/** 手动检测 IP 白名单:POST /api/admin/shop-keys/{id}/check-whitelist;返回更新后的记录。 */
export async function checkShopKeyWhitelist(id: number): Promise<ShopKeyItem> {
const { data } = await http.post<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}/check-whitelist`)
const item = toShopKeyItem(unwrap<unknown>(data))
if (!item) {
throw new Error('白名单检测响应异常:未返回有效记录')
}
return item
}
@@ -16,5 +16,6 @@ export function buildShopKeyEditForm(item: ShopKeyItem | null | undefined): Shop
remarkName: typeof item.remarkName === 'string' ? item.remarkName : '',
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '',
proxyUrl: typeof item.proxyUrl === 'string' ? item.proxyUrl : '',
}
}
@@ -4,18 +4,22 @@
export const SHOP_KEY_REMARK_MAX_LEN = 128
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
export const SHOP_KEY_TOKEN_MAX_LEN = 512
export const SHOP_KEY_PROXY_MAX_LEN = 255
export interface ShopKeyFormValues {
remarkName: string
ziniaoAccountName: string
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
ziniaoToken: string
/** 静态代理地址(http://host:port 或 http://user:pass@host:port);留空直连。 */
proxyUrl: string
}
export interface ShopKeyFormErrors {
remarkName?: string
ziniaoAccountName?: string
ziniaoToken?: string
proxyUrl?: string
}
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
@@ -23,10 +27,33 @@ export interface ShopKeyPayload {
remarkName?: string
ziniaoAccountName: string
ziniaoToken: string
proxyUrl?: string
}
export function emptyShopKeyForm(): ShopKeyFormValues {
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '' }
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '', proxyUrl: '' }
}
/** 校验代理地址(镜像后端 normalizeProxyUrl):留空合法,非空须为 http(s)://host:port。 */
export function validateProxyUrl(value: string): string | null {
const proxy = (value || '').trim()
if (!proxy) return null
if (proxy.length > SHOP_KEY_PROXY_MAX_LEN) {
return `代理地址长度不能超过${SHOP_KEY_PROXY_MAX_LEN}个字符`
}
let parsed: URL
try {
parsed = new URL(proxy)
} catch {
return '代理地址格式不合法,应形如 http://host:port'
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return '代理地址仅支持 http/https 协议'
}
if (!parsed.hostname || !parsed.port) {
return '代理地址缺少主机或端口,应形如 http://host:port'
}
return null
}
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
@@ -61,6 +88,10 @@ export function validateShopKeyForm(form: ShopKeyFormValues): { valid: boolean;
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符`
}
const proxyError = validateProxyUrl(form.proxyUrl)
if (proxyError) {
errors.proxyUrl = proxyError
}
return { valid: Object.keys(errors).length === 0, errors }
}
@@ -72,5 +103,7 @@ export function toShopKeyCreateRequest(form: ShopKeyFormValues): ShopKeyPayload
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
}
if (remark) payload.remarkName = remark
const proxy = (form.proxyUrl || '').trim()
if (proxy) payload.proxyUrl = proxy
return payload
}
@@ -55,8 +55,10 @@ export function toShopKeyItem(raw: unknown): ShopKeyItem | null {
remarkName: text(record.remarkName ?? record.remark_name),
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
proxyUrl: text(record.proxyUrl ?? record.proxy_url),
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message),
ipWhitelistFailCount: numberOrNull(record.ipWhitelistFailCount ?? record.ip_whitelist_fail_count) ?? 0,
}
const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at)
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt
@@ -588,7 +588,7 @@ onMounted(() => {
<div class="dup-body">
<div v-for="block in shopBlocks(item)" :key="block.shopName" class="store-col">
<div class="st-name">{{ block.shopName }}<span class="st-count">{{ block.count }} 条</span></div>
<div v-if="block.groupName" class="st-grp">分组:{{ block.groupName }}</div>
<div v-if="isSuper && block.groupName" class="st-grp">分组:{{ block.groupName }}</div>
<template v-for="site in block.sites" :key="site.country">
<div class="c-site"><span class="m-dot" :style="{ background: siteColor(site.country) }"></span>{{ asinCountryLabel(site.country) }} · {{ site.times.length }} 次</div>
<div class="c-times"><span v-for="(time, index) in site.times" :key="index" class="c-time">{{ time }}</span></div>
@@ -666,12 +666,12 @@ onMounted(() => {
</div>
<table v-if="drawerAggRows.length" class="d-table">
<thead>
<tr><th>店铺</th><th>分组</th><th>站点</th><th>上架时间(按时间升序)</th><th>次数</th></tr>
<tr><th>店铺</th><th v-if="isSuper">分组</th><th>站点</th><th>上架时间(按时间升序)</th><th>次数</th></tr>
</thead>
<tbody>
<tr v-for="row in drawerRows" :key="row.shopName + '|' + row.country">
<td class="cell-strong">{{ row.shopName }}</td>
<td><span class="d-stgrp">{{ row.groupName || '—' }}</span></td>
<td v-if="isSuper"><span class="d-stgrp">{{ row.groupName || '—' }}</span></td>
<td><span class="site-chip" :style="{ background: siteColor(row.country) + '1F', color: siteColor(row.country) }">{{ asinCountryLabel(row.country || '') }}</span></td>
<td><template v-for="(time, index) in row.times" :key="index"><span class="d-time">{{ time }}</span></template></td>
<td class="num-cell">{{ row.count }}</td>
@@ -337,7 +337,7 @@ onMounted(load)
<div class="iv-card-info">
<div class="iv-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
<div class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
<div v-if="session.isSuperAdmin" class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
<div class="iv-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
<div class="iv-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
<div class="iv-info-row">
@@ -56,6 +56,25 @@ const pagedPermissionItems = computed(() =>
const busyKey = ref('')
const deletingKey = ref('')
/** 下载进度弹窗:percent=0~100indeterminate=true 表示后端未回 Content-Length,只显示流动条。 */
const downloadProgress = reactive({ visible: false, percent: 0, indeterminate: false, label: '' })
function startDownloadProgress(label: string) {
downloadProgress.label = label
downloadProgress.percent = 0
downloadProgress.indeterminate = false
downloadProgress.visible = true
}
function updateDownloadProgress(percent: number) {
if (percent < 0) {
downloadProgress.indeterminate = true
return
}
downloadProgress.indeterminate = false
downloadProgress.percent = percent
}
/** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */
function statusLabel(status: string): string {
const map: Record<string, string> = {
@@ -185,9 +204,11 @@ async function downloadRow(row: ShopDataResultRow) {
return
}
busyKey.value = row.resultId
startDownloadProgress(`正在下载「${row.shopName || '-'}」的数据文件`)
try {
const { blob, filename } = await fetchShopDataResultDownload(row.resultId)
const { blob, filename } = await fetchShopDataResultDownload(row.resultId, updateDownloadProgress)
saveBlob(blob, filename)
downloadProgress.visible = false
ElMessage.success('下载已开始')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败')
@@ -203,9 +224,11 @@ async function downloadBatch() {
return
}
busyKey.value = 'batch'
startDownloadProgress(`正在打包下载 ${resultIds.length} 个结果文件`)
try {
const zip = await requestShopDataZipDownload(resultIds)
const zip = await requestShopDataZipDownload(resultIds, updateDownloadProgress)
saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`)
downloadProgress.visible = false
const note = zip.errorCount ? `${zip.errorCount} 个失败(详见包内错误清单)` : ''
ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`)
} catch (error) {
@@ -282,7 +305,7 @@ onMounted(load)
<label>店铺</label>
<input v-model="filter.shopName" type="text" placeholder="模糊搜索店铺名" @keyup.enter="apply" />
</div>
<div class="form-group" style="min-width: 150px">
<div v-if="session.isSuperAdmin" class="form-group" style="min-width: 150px">
<label>分组</label>
<input v-model="filter.groupName" type="text" placeholder="模糊搜索分组名" @keyup.enter="apply" />
</div>
@@ -338,7 +361,7 @@ onMounted(load)
<span class="shop-card-task-no" :title="`任务 ${row.taskId || row.taskNo || row.resultId}`">任务 {{ row.taskId || row.taskNo || row.resultId }}</span>
<span class="st-pill" :class="`is-${statusType(row.status)}`">{{ statusLabel(row.status) }}</span>
</div>
<div class="sd-card-row" v-if="row.groupName">
<div v-if="session.isSuperAdmin && row.groupName" class="sd-card-row">
<span class="sd-card-label">分组</span>
<span class="sd-card-value">{{ row.groupName }}</span>
</div>
@@ -401,6 +424,16 @@ onMounted(load)
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="downloadProgress.visible" :title="downloadProgress.label" width="440px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<el-progress
:percentage="downloadProgress.indeterminate ? 50 : downloadProgress.percent"
:indeterminate="downloadProgress.indeterminate"
:duration="downloadProgress.indeterminate ? 3 : undefined"
:stroke-width="14"
/>
<p class="sd-progress-hint">{{ downloadProgress.indeterminate ? '正在接收数据,大小未知,请稍候…' : `已完成 ${downloadProgress.percent}%` }}</p>
</el-dialog>
</div>
</template>
@@ -712,6 +745,12 @@ h3 {
color: #8293a5;
font-size: 12px;
}
.sd-progress-hint {
margin: 10px 0 0;
text-align: center;
color: #5b6f83;
font-size: 13px;
}
.empty-tip {
grid-column: 1 / -1;
padding: 44px 24px;
@@ -27,10 +27,15 @@ export interface ShopDataZipDownloadResult {
errorCount?: number
}
/** 批量下载选中结果文件为 zipPOST /download-zip(blob);从响应头读成功/失败计数。 */
export async function requestShopDataZipDownload(resultIds: readonly (number | string)[]): Promise<ShopDataZipDownloadResult> {
/** 批量下载选中结果文件为 zipPOST /download-zip(blob);从响应头读成功/失败计数。
* onProgress: 可选下载进度回调(0-100,后端未回 Content-Length 时退化为不定进度)。 */
export async function requestShopDataZipDownload(
resultIds: readonly (number | string)[],
onProgress?: (percent: number) => void,
): Promise<ShopDataZipDownloadResult> {
const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), {
responseType: 'blob',
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
})
const counts = downloadZipHeaderCounts(headers as Record<string, string>)
return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount }
@@ -41,15 +46,31 @@ export interface ShopDataResultDownload {
filename: string
}
/** 下载单店最新采集文件(按每日累计档 id):GET /api/admin/shop-data-crawl-tasks/daily-files/{id}/download。 */
export async function fetchShopDataResultDownload(resultId: number | string): Promise<ShopDataResultDownload> {
/** 下载单店最新采集文件(按每日累计档 id):GET /api/admin/shop-data-crawl-tasks/daily-files/{id}/download。
* onProgress: 可选下载进度回调(0-100)。 */
export async function fetchShopDataResultDownload(
resultId: number | string,
onProgress?: (percent: number) => void,
): Promise<ShopDataResultDownload> {
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}/download`, {
responseType: 'blob',
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
})
const disposition = (headers as Record<string, string> | undefined)?.['content-disposition']
return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) }
}
/** 将 axios 进度事件转为 0-100 百分比;响应未回 Content-Length 时报 -1,前端显示不定进度。 */
function toAxiosProgress(onProgress: (percent: number) => void) {
return (event: { loaded: number; total?: number }) => {
if (event.total) {
onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
} else {
onProgress(-1)
}
}
}
/** 删除该店这条店铺数据记录(按每日累计档 id,管理端显式操作):DELETE .../daily-files/{id}。 */
export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> {
await http.delete<unknown>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}`)
+4
View File
@@ -17,11 +17,15 @@ export const adminPages: AdminPageDef[] = [
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
{ path: 'account/user-secrets', menuKey: 'admin_user_secrets', title: '密钥管理', load: () => import('@/pages/account/UserSecretsPage.vue') },
{ path: 'account/user-secret-usage', menuKey: 'admin_user_secret_usage', title: '密钥用量统计', load: () => import('@/pages/account/UserSecretUsagePage.vue') },
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
{ path: 'records/tutorial', menuKey: 'admin_tutorial', title: '教程管理', load: () => import('@/pages/records/RecordsTutorialPage.vue') },
{ path: 'records/device-logs', menuKey: 'admin_device_logs', title: '日志管理', load: () => import('@/pages/records/DeviceLogsPage.vue') },
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
+1
View File
@@ -12,6 +12,7 @@ export interface AdminUser {
createdById?: number | null
creatorUsername?: string
createdAt?: string
updatedAt?: string
pinyinAbbr?: string
}
@@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// 回归(生产:用户菜单权限“自己没掉”):编辑用户弹窗打开时先把 columnIds 清空,再异步回填;
// 若授权尚未加载完成(或加载失败)就点保存,会把空数组提交成“清空授权”,整树权限被抹掉。
// 修复:授权未就绪时禁用保存并明确提示。
test('edit_user_dialog_blocks_save_until_auth_loaded', () => {
const dialog = readSource('src/pages/account/EditUserDialog.vue')
assert.match(dialog, /authReady/, '存在授权就绪标记')
assert.match(dialog, /authReady\.value = false/, '打开/失败时标记为未就绪')
// 加载失败分支必须保持未就绪:不能回退成“可以保存空授权”
const refreshBlock = dialog.slice(
dialog.indexOf('async function refreshAuth'),
dialog.indexOf('function close'),
)
assert.match(
refreshBlock,
/catch \(error\) \{[\s\S]*?authReady\.value = false/,
'授权加载失败保持未就绪,不用空数组覆盖服务端授权',
)
assert.match(refreshBlock, /authReady\.value = true/, '仅在加载成功后置为就绪')
// save() 自身兜底拦截,不依赖按钮禁用
const saveBlock = dialog.slice(dialog.indexOf('async function save'), dialog.indexOf('async function save') + 400)
assert.match(saveBlock, /if \(!authReady\.value\)/, '未就绪时直接拦截保存')
// 保存按钮在未就绪时禁用
assert.match(dialog, /:disabled="!authReady"/, '未就绪时保存按钮禁用')
assert.match(dialog, /加载中/, '加载中有提示')
assert.match(dialog, /保存已禁用/, '加载失败有明确提示')
})
@@ -0,0 +1,92 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
/**
* 分组维度仅超管可见:非超管(管理员/普通用户)数据已由后端裁剪到本人分组,
* 后台各页的分组筛选与分组列一律不展示,只有超管需要按分组筛选/区分。
* 参考实现见撞款检测页(DuplicateCheckPage.vue 分组筛选用 isSuper 门控)。
*/
/** 页面 → 分组筛选门控片段(正则不做宽松匹配,确保门控真实存在)。 */
const FILTER_GATES: Array<[string, RegExp]> = [
['src/pages/asin/AsinInvalidPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/asin/QueryAsinPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/asin/SkipPricePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/asin/DedupeRegistryPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/shop/ShopManagePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/tasks/ShopDataTasksPage.vue', /v-if="session\.isSuperAdmin"[\s\S]{0,160}模糊搜索分组名/],
['src/pages/account/UserSecretsPage.vue', /v-if="isSuperAdmin[\s\S]{0,160}<label>分组<\/label>/],
]
/** 页面 → 分组列表头门控片段。 */
const COLUMN_GATES: Array<[string, RegExp]> = [
['src/pages/asin/AsinInvalidPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/asin/QueryAsinPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/asin/SkipPricePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/asin/DedupeRegistryPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/shop/ShopManagePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/tasks/DuplicateCheckPage.vue', /<th v-if="isSuper"[^>]*>分组<\/th>/],
['src/pages/account/UserSecretsPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
]
test('align_group_visibility_filter_gated_by_super_admin', () => {
for (const [file, pattern] of FILTER_GATES) {
assert.match(readSource(file), pattern, `${file} 分组筛选未按超管门控`)
}
})
test('align_group_visibility_column_gated_by_super_admin', () => {
for (const [file, pattern] of COLUMN_GATES) {
assert.match(readSource(file), pattern, `${file} 分组列未按超管门控`)
}
})
test('align_group_visibility_no_ungated_group_column_header', () => {
// 不变量:以上页面的“分组”数据列表头必须带 v-if 门控,防止后续改动误放开。
// DuplicateCheckPage 的分组说明抽屉内另有一张分组统计表,该抽屉由已门控的按钮打开,
// 非超管无法触达,故不纳入本不变量(单独在 detail_cards 用例中校验按钮门控)。
const ungated = /<th(?![^>]*v-if)[^>]*>分组<\/th>/
for (const [file] of COLUMN_GATES) {
if (file.endsWith('DuplicateCheckPage.vue')) continue
assert.doesNotMatch(readSource(file), ungated, `${file} 存在未门控的分组列表头`)
}
})
test('align_group_visibility_detail_cards_gated', () => {
// 明细卡片/信息行里的分组展示同样只给超管。
assert.match(
readSource('src/pages/tasks/ImageVideoTasksPage.vue'),
/v-if="session\.isSuperAdmin"[\s\S]{0,120}所属分组/,
'图片视频任务页「所属分组」未门控',
)
assert.match(
readSource('src/pages/tasks/ShopDataTasksPage.vue'),
/v-if="session\.isSuperAdmin && row\.groupName"/,
'店铺数据记录页卡片分组行未门控',
)
assert.match(
readSource('src/pages/tasks/DuplicateCheckPage.vue'),
/v-if="isSuper && block\.groupName"/,
'撞款检测页卡片分组标签未门控',
)
// 分组说明抽屉整体为分组维度内容:入口按钮与分组范围切换条均已按超管门控。
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(dup, /<button v-if="isSuper"[^>]*@click="groupInfoVisible = true"/, '分组说明入口未门控')
assert.match(dup, /<div v-if="isSuper" class="scope-bar"/, '分组撞款范围条未门控')
})
test('align_group_visibility_dialogs_keep_locked_selector', () => {
// 弹窗分组选择保留展示但非超管锁定(单分组时),不隐藏。
const lockedPages = [
'src/pages/asin/QueryAsinPage.vue',
'src/pages/asin/SkipPricePage.vue',
'src/pages/asin/DedupeRegistryPage.vue',
'src/pages/shop/ShopManagePage.vue',
]
for (const file of lockedPages) {
const src = readSource(file)
assert.match(src, /lockedGroupId/, `${file} 缺少弹窗分组锁定计算`)
assert.match(src, /:disabled="lockedGroupId != null"/, `${file} 弹窗分组未锁定`)
}
})
@@ -82,7 +82,9 @@ test('align_query_asin_page_layout_wiring', () => {
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
// 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。
assert.match(page, /创建时间/, '表格含创建时间列')
assert.match(page, /更新时间/, '表格含更新时间列')
assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型')
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐')
@@ -82,7 +82,9 @@ test('align_skip_price_page_layout_wiring', () => {
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
// 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。
assert.match(page, /创建时间/, '表格含创建时间列')
assert.match(page, /更新时间/, '表格含更新时间列')
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
assert.match(page, /最低价格式不正确/, '最低价格式校验对齐')
@@ -0,0 +1,58 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { adminPages } from '../src/router/routes.ts'
/** 教程管理页(工具台「立即下载教程」的后台入口):上传走 presign 直传、列表取最新、下载/删除留驻反馈。 */
test('align_tutorial_page_registered', () => {
const page = adminPages.find((p) => p.path === 'records/tutorial')
assert.ok(page, '路由注册表应包含 records/tutorial')
assert.equal(page.menuKey, 'admin_tutorial')
assert.equal(page.title, '教程管理')
})
test('align_tutorial_page_wiring', () => {
const page = readSource('src/pages/records/RecordsTutorialPage.vue')
// 上传入口:版本号(可空)→ 选择 zip → 直传(进度条)→ 成功提示留驻弹窗。
assert.match(page, /上传教程包/, '存在上传入口按钮')
assert.match(page, /uploadTutorialPackage/, '上传走教程包直传链路')
assert.match(page, /label="版本号"/, '上传弹窗提供版本号输入')
assert.match(page, /newVersion\.value\.trim\(\)/, '版本号去空白后随上传提交')
assert.match(page, /请选择 zip 压缩包/, '未选文件时提示')
assert.match(page, /仅支持 \.zip 格式/, '格式校验提示')
assert.match(page, /上传成功,工具台「立即下载教程」已切换为该包/, '成功提示说明生效位置')
assert.match(page, /上传完成,正在登记教程包/, '进度文案')
// 列表:版本号列 + 上传时间列 + 当前生效标记 + 下载 + 删除 + 空态。
assert.match(page, /当前生效/, '最新上传的包标记当前生效')
assert.match(page, /formatFileSize/, '展示包体大小')
assert.match(page, /row\.version/, '展示版本号列')
assert.match(page, /toggleSort\('version'\)/, '版本号表头可点击排序')
assert.match(page, /toggleSort\('createdAt'\)/, '上传时间表头可点击排序')
assert.match(page, /const sortKey = ref<SortKey>\('createdAt'\)/, '默认排序字段为上传时间')
assert.match(page, /const sortAsc = ref\(false\)/, '默认降序')
assert.match(page, /下载/, '操作列提供下载')
assert.match(page, /<a v-if="row\.fileUrl" class="btn btn-sm dl-btn"[^>]*download/, '下载链接带 download 强制下载')
assert.match(page, /确认删除选中的/, '批量删除二次确认')
assert.match(page, /暂无教程包记录/, '空态文案')
})
test('align_tutorial_api_contract', () => {
const api = readSource('src/pages/records/tutorial-api.ts')
assert.match(api, /\/api\/admin\/tutorials/, '列表端点')
assert.match(api, /TUTORIAL_UPLOAD_ENDPOINT = '\/api\/admin\/tutorial'/, '上传端点前缀')
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/presign/, '直传预签名端点')
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/confirm/, '确认端点')
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/delete/, '删除端点')
assert.match(api, /params: \{ file_name: fileName, version \}/, '预签名回传文件名与版本号')
assert.match(api, /params: \{ object_key: objectKey, file_name: fileName, version \}/, '确认回传对象 key、文件名与版本号')
assert.match(api, /withCredentials: false/, '直传实例不挂会话拦截器')
})
test('align_tutorial_model_parsers', () => {
const model = readSource('src/pages/records/tutorial-model.ts')
assert.match(model, /r\.file_name \?\? r\.fileName/, '兼容 snake/camel 文件名')
assert.match(model, /r\.file_url \?\? r\.fileUrl/, '兼容 snake/camel 下载链接')
assert.match(model, /r\.file_size \?\? r\.fileSize/, '兼容 snake/camel 文件大小')
assert.match(model, /version: text\(r\.version\)/, '解析版本号(缺失为空串,兼容历史行)')
})
@@ -0,0 +1,86 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
buildMenuOptionTree,
compactDirectGrantIds,
parseMenuOptionList,
parsePermissionMenuItem,
} from '../src/pages/account/user-menu-auth.ts'
// 2026-09-16 线上事故:非超管(普通管理员)在授权树里勾到自己无权授予的菜单后,
// 后端 ensureGrantable 抛 403 并回滚整笔事务——创建用户与保存权限双双失败,
// 前端只显示「普通管理员只能分配自己已有的菜单权限」。
// 修复:菜单列表按操作者标记 grantable,前端把不可授予的节点置灰不可勾。
//
// 注意:这里是「置灰」而非「隐藏」。授权保存是整树替换,隐藏会让超管早先授予、
// 而操作者自己没有的菜单在提交时被当作取消勾选删掉(与 09-13「权限自己没掉」同类)。
test('align_user_menu_grantable_false_maps_to_disabled_node', () => {
const locked = parsePermissionMenuItem(
{ id: 5, name: '查询ASIN', parent_id: null, sort_order: 1, grantable: false },
'admin',
)
assert.equal(locked?.disabled, true, 'grantable=false → 节点置灰')
const allowed = parsePermissionMenuItem(
{ id: 6, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
'admin',
)
assert.equal(allowed?.disabled, false, 'grantable=true → 可勾选')
// 菜单管理页等未标记 grantable 的接口必须保持旧行为(全部可勾选)
const unmarked = parsePermissionMenuItem({ id: 7, name: '菜单权限配置', parent_id: null, sort_order: 3 }, 'admin')
assert.equal(unmarked?.disabled, false, '缺省 grantable 视为可授予')
})
test('align_user_menu_grantable_survives_tree_build', () => {
const nodes = parseMenuOptionList(
[
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
{ id: 100, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
],
'admin',
)
const tree = buildMenuOptionTree(nodes)
const account = tree.find((node) => node.id === 98)
assert.equal(account?.disabled, true, '分组节点置灰')
assert.equal(account?.children?.[0]?.disabled, true, '子节点置灰随树保留')
assert.equal(tree.find((node) => node.id === 100)?.disabled, false, '可授予节点不受影响')
})
test('align_user_menu_grantable_disabled_node_still_compactable', () => {
// 已持有但无权授予的节点会保持勾选并原样提交,压缩逻辑不能因 disabled 漏掉它
const tree = buildMenuOptionTree(
parseMenuOptionList(
[
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
],
'admin',
),
)
assert.deepEqual(compactDirectGrantIds([98, 7], tree), [98], '父级已勾选时仍压缩掉后代')
})
test('align_user_menu_grantable_wired_end_to_end', () => {
const tree = readSource('src/pages/account/UserMenuAuthTree.vue')
assert.match(tree, /disabled: 'disabled'/, 'el-tree 按 disabled 键置灰节点')
const vo = readSource(
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java',
)
assert.match(vo, /private Boolean grantable;/, 'VO 暴露 grantable')
const controller = readSource(
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java',
)
assert.match(controller, /permissionMenuService\.list\(requireAdmin\(request\), menuType\)/, '列表接口传入操作者')
const service = readSource(
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java',
)
assert.match(service, /resolveGrantableMenuIds/, '按操作者计算可授予集')
assert.match(service, /ensureGrantable\(operator, grantIds, userId\)/, '保存校验传入目标用户以放行既有授权')
})
@@ -0,0 +1,129 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
formatNotificationTime,
formatUnreadBadge,
groupNotificationsByDay,
hasNewNotification,
readLastNotifiedId,
writeLastNotifiedId,
} from '../src/layout/notification-bell-model.ts'
function createStorage() {
const store = new Map<string, string>()
return {
getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null),
setItem: (key: string, value: string) => {
store.set(key, String(value))
},
removeItem: (key: string) => {
store.delete(key)
},
}
}
function setupWindow() {
const localStorage = createStorage()
const globalScope = globalThis as Record<string, unknown>
const previous = globalScope.window
globalScope.window = { localStorage }
return {
localStorage,
restore: () => {
globalScope.window = previous
},
}
}
test('test_未读徽标:0 与非法值不显示、超过 99 显示 99+', () => {
assert.equal(formatUnreadBadge(0), '')
assert.equal(formatUnreadBadge(-3), '')
assert.equal(formatUnreadBadge(null), '')
assert.equal(formatUnreadBadge(undefined), '')
assert.equal(formatUnreadBadge(Number.NaN), '')
assert.equal(formatUnreadBadge(1), '1')
assert.equal(formatUnreadBadge(99), '99')
assert.equal(formatUnreadBadge(100), '99+')
})
test('test_新通知判定:latestId 需大于已提醒 id', () => {
assert.equal(hasNewNotification(0, 0), false)
assert.equal(hasNewNotification(null, 5), false)
assert.equal(hasNewNotification(5, 5), false)
assert.equal(hasNewNotification(4, 5), false)
assert.equal(hasNewNotification(6, 5), true)
assert.equal(hasNewNotification(6, 0), true)
assert.equal(hasNewNotification(6, null), true)
})
test('test_已提醒 id 按管理员读写并容错', () => {
setupWindow()
assert.equal(readLastNotifiedId(7), 0, '未写入时按 0 处理')
writeLastNotifiedId(7, 88)
assert.equal(readLastNotifiedId(7), 88)
assert.equal(readLastNotifiedId(8), 0, '不同管理员互不影响')
writeLastNotifiedId(7, 0)
assert.equal(readLastNotifiedId(7), 88, '非法 id 不覆盖已有值')
window.localStorage.setItem('admin-notification:last-notified-id:7', 'broken')
assert.equal(readLastNotifiedId(7), 0, '损坏值按 0 处理')
assert.equal(readLastNotifiedId(null), readLastNotifiedId('0'), '空 uid 归一为 0 号键')
})
test('test_时间展示:今天/昨天/更早', () => {
const now = new Date(2026, 8, 13, 15, 30)
assert.equal(formatNotificationTime(new Date(2026, 8, 13, 9, 5).toISOString(), now), '09:05')
assert.equal(formatNotificationTime(new Date(2026, 8, 12, 23, 59).toISOString(), now), '昨天 23:59')
assert.equal(formatNotificationTime(new Date(2026, 8, 1, 8, 0).toISOString(), now), '09-01 08:00')
assert.equal(formatNotificationTime(null, now), '')
assert.equal(formatNotificationTime('not-a-date', now), '')
})
test('test_按年月日分组:今天/昨天/更早,组内保持原顺序', () => {
const now = new Date(2026, 8, 13, 15, 30)
const items = [
{ id: 4, createdAt: new Date(2026, 8, 13, 15, 0).toISOString() },
{ id: 3, createdAt: new Date(2026, 8, 13, 9, 0).toISOString() },
{ id: 2, createdAt: new Date(2026, 8, 12, 23, 0).toISOString() },
{ id: 1, createdAt: new Date(2026, 8, 1, 8, 0).toISOString() },
]
const groups = groupNotificationsByDay(items, now)
assert.equal(groups.length, 3)
assert.deepEqual(
groups.map((group) => [group.day, group.label]),
[
['2026-09-13', '今天'],
['2026-09-12', '昨天'],
['2026-09-01', '2026年9月1日'],
],
)
assert.deepEqual(
groups[0].items.map((item) => item.id),
[4, 3],
'同一天的多条合为一组且保持倒序',
)
})
test('test_按年月日分组:时间缺失/非法归入未知时间组', () => {
const now = new Date(2026, 8, 13, 15, 30)
const groups = groupNotificationsByDay(
[
{ id: 2, createdAt: null },
{ id: 1, createdAt: 'not-a-date' },
],
now,
)
assert.equal(groups.length, 1)
assert.equal(groups[0].day, '')
assert.equal(groups[0].label, '未知时间')
assert.deepEqual(
groups[0].items.map((item) => item.id),
[2, 1],
)
})
@@ -0,0 +1,28 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { isForbidden, isKicked, isUnauthorized } from '../src/api/envelope.ts'
test('单设备登录:4011 只认业务码,不被 401/403 判定吞掉', () => {
assert.equal(isKicked({ code: 4011 }), true)
assert.equal(isKicked({ code: 401 }), false)
assert.equal(isKicked({ status: 4011 }), false)
assert.equal(isKicked(null), false)
assert.equal(isKicked('4011'), false)
// 4011 与普通 401/403 是两种处理(提示"已在其他设备登录" vs 跳登录页),不能误判
assert.equal(isUnauthorized({ code: 4011 }), false)
assert.equal(isForbidden({ code: 4011 }), false)
})
test('单设备登录:http 拦截器接线(两分支先判 4011,跳带 kicked 标记的登录页)', () => {
const http = readSource('src/api/http.ts')
assert.match(http, /isKicked/)
assert.match(http, /\/admin-vue\/login\?kicked=1/)
})
test('单设备登录:登录页显示被顶下线提示', () => {
const page = readSource('src/pages/login/LoginPage.vue')
assert.match(page, /kickedNotice/)
assert.match(page, /该账号已在其他设备登录/)
assert.match(page, /kicked-msg/)
})
+1 -1
View File
@@ -13,7 +13,7 @@ import { adminPages } from '../src/router/routes.ts'
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
assert.equal(adminPages.length, 16)
assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)')
for (const page of adminPages) {
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
}
+4 -1
View File
@@ -34,7 +34,10 @@ test('test_task_012_route_error_page_normal_repeated_operation_is_idempotent', (
test('test_task_012_route_error_page_boundary_empty_input', () => {
// 边界空值:错误页文件存在,且不进入业务路由注册表。
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
assert.equal(adminPages.length, 16, '错误页不应计入业务路由')
// 业务路由条目与页面定义一一对应;错误页不占用其中任何一条。
const router = readSource(ROUTER)
assert.equal(occurrences(router, '...adminRouteRecords'), 1, '错误页不应计入业务路由')
assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)')
})
test('test_task_012_route_error_page_boundary_single_item', () => {
+5 -4
View File
@@ -18,9 +18,10 @@ test('test_task_266_view_normal_primary_path', () => {
test('test_task_266_view_normal_variant_input', () => {
const api = readSource('src/pages/tasks/shop-data-api.ts')
assert.match(api, /fetchShopDataResultDownload/, '单文件下载适配')
assert.match(api, /\/results\/\$\{resultId\}\/download/, '单文件下载走管理端真实端点')
// 端点演进:单文件下载/删除改走每日累计档 daily-files(与 AdminShopDataCrawlTasksController 对齐)。
assert.match(api, /daily-files\/\$\{resultId\}\/download/, '单文件下载走管理端真实端点')
assert.match(api, /deleteShopDataResultHistory/, '删除适配')
assert.match(api, /\/history\/\$\{resultId\}/, '删除走管理端真实端点')
assert.match(api, /daily-files\/\$\{resultId\}/, '删除走管理端真实端点')
})
test('test_task_266_view_normal_repeated_operation_is_idempotent', () => {
@@ -44,13 +45,13 @@ test('test_task_266_view_boundary_single_item', () => {
test('test_task_266_delete_normal_primary_path', () => {
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
assert.match(page, /确认删除店铺/, '删除确认含店铺')
assert.match(page, /及结果文件/, '删除确认含结果文件')
assert.match(page, /及其数据文件/, '删除确认含数据文件')
assert.match(page, /删除成功/, '删除成功提示')
})
test('test_task_266_delete_boundary_limit_or_missing_field', () => {
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
assert.match(page, /正在删除任务/, '删除中有进行文案')
assert.match(page, /deletingKey/, '删除中状态防重复点击')
})
test('test_task_266_dependency_failure_returns_actionable_message', () => {
+1 -1
View File
@@ -10,7 +10,7 @@ import {
test('test_task_008_domain_route_registry_normal_primary_path', () => {
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
assert.equal(adminPages.length, 16)
assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)')
assert.equal(adminRouteRecords.length, adminPages.length)
const first = adminRouteRecords[0]
assert.equal(first.path, 'account/users')
@@ -0,0 +1,36 @@
# Flyway 迁移演练 runbooktask-201
> 目的:在**副本库**上验证新增迁移可干净执行、验证 SQL 通过、可回滚、可幂等重跑,且全程不动历史迁移。
> 本文档为演练步骤与检查清单;on-DB 执行需运维在有副本库的机器按步骤进行(本地/CI 无库时不执行 migrate)。
## 前置
- 副本库:与生产同版本(MySQL 8.4),已执行到当前最大版本 V108(与生产一致)。
- 拿到待演练的新迁移:`src/main/resources/db/V{N+1}__*.sql`,头注释引用 `docs/flyway-migration-template.md`task-192)六项必填齐全。
## 演练步骤
1. **基线核对**`flyway -url=<副本> info` 确认版本、描述、checksum 与生产一致;`git log` 确认历史 V1..V108 未被改动。
2. **validate**`flyway validate` —— 校验历史迁移 checksum,任何历史文件被改动会立刻失败(违规红线)。
3. **干净迁移**:把待演练迁移放入后 `flyway migrate`;记录成功版本、耗时。
4. **验证 SQL**:执行迁移头注释第 4 项的验证 SQL(行数/索引/SHOW INDEX),确认结果符合预期。
5. **回滚验证**:按头注释第 5 项回滚脚本回滚新迁移(若无回滚脚本,验证迁移可幂等重跑替代)。
6. **幂等重跑**:回滚后再 `flyway migrate` 一次,确认可重复、无残留副作用。
7. **锁窗口评估**:索引类迁移记录执行耗时与是否 ONLINE,结合表数据量估算生产锁窗口。
8. **收尾**:记录结论到本清单;生产窗口按 template 第 6 项执行。
## 离线静态检查(本仓库 JUnit 已覆盖)
- 迁移文件整数版本 1..N 连续、无重复(`MigrationInventoryTest`task-193)。
- 迁移校验和可复算稳定(同一文件两次读 SHA-256 一致,`MigrationInventoryTest`)。
- 新迁移命名合规、模板六字段可引用(`FlywayMigrationTemplateDocTest`task-192)。
## 完成检查
- [ ] 副本库 `flyway migrate` 干净执行(版本升至目标)
- [ ] 验证 SQL 通过
- [ ] 回滚验证通过 / 幂等重跑通过
- [ ] 锁窗口已按表量估算并记录
- [ ] 历史迁移未被改动(`flyway validate` 通过)
> 注:CI/本地无数据库环境时,本 runbook 的第 3-7 步需在带副本库的机器执行;仓库内以静态检查 + 本清单兜底。
@@ -0,0 +1,53 @@
# Flyway 迁移规范模板(task-192
> 新增数据库迁移一律在本仓库 `src/main/resources/db/` 追加 `V{N+1}__*.sql`(版本号在现有最大版本之上加 1),
> 每个迁移文件头必须引用本模板并补齐六项必填。**只追加,绝不修改已部署的历史迁移**(会破坏 Flyway 校验和)。
复制以下头注释到新迁移文件顶部并逐项填写:
```sql
-- =============================================================
-- 迁移 V{N+1}__<短横线描述>
-- 模板:docs/flyway-migration-template.mdtask-192
--
-- 1. 变更目的:<一句话说明要解决什么问题 / 为何变更>
-- 2. 影响表与数据量:<表名:预计行数 / 全表或增量;例如 biz_file_result ~50w 全表>
-- 3. 锁表风险:<是否 ONLINE / 是否加锁 / 大表索引类需 ALGORITHM=INPLACE 评估;风险高则拆批或窗口执行>
-- 4. 验证 SQL:<迁移后用于核对的行数 / 抽样语句,见下方示例>
-- 5. 回滚步骤:<V{N}__<desc>.sql 或补丁脚本路径;无回滚写明原因>
-- 6. 上线窗口:<建议窗口,例如 业务低峰 02:00-06:00;双节点滚动>
-- =============================================================
-- 迁移语句(DDL/DML)…
-- 可选验证(与头注释第 4 项对应)
-- SELECT COUNT(*) FROM <table>;
```
## 六项必填说明
| # | 字段 | 要求 | 反例 |
|---|------|------|------|
| 1 | 变更目的 | 一句话,写清"为什么" | 留空 / 只写表名 |
| 2 | 影响表与数据量 | 每张被改表名 + 预计行数量级 | "涉及多表" 不含表名 |
| 3 | 锁表风险 | 指出 DDL 是否 INPLACE/排他、大表评估 | "无风险" 不说明依据 |
| 4 | 验证 SQL | 迁移后可跑的核对语句 | 缺失 |
| 5 | 回滚步骤 | 回滚脚本路径或明确不可回滚原因 | 缺失 |
| 6 | 上线窗口 | 建议时段 + 是否滚动 | 缺失 |
## 示例验证 SQL(供第 4 项复制)
```sql
-- 迁移后行数与迁移前基线对比
SELECT COUNT(*) FROM biz_file_result;
-- 新索引是否生效(用于索引类迁移)
SHOW INDEX FROM biz_file_result WHERE Key_name = 'idx_task_module';
-- 抽样数据
SELECT id, task_id, status, updated_at FROM biz_file_task ORDER BY id DESC LIMIT 5;
```
## 使用约束
- 版本号在 `src/main/resources/db/` 最大现有版本上加 1(当前 ≥ V109),不抢号、不重复。
- 不修改、不删除任何已执行过的历史迁移文件。
- 上线走双节点滚动 + 生产库先 `flyway validate`,失败即停。
+22
View File
@@ -0,0 +1,22 @@
# Flyway 迁移盘点(task-193
> 生成方式:`src/test/java/com/nanri/aiimage/config/MigrationInventoryTest.java`(只读审计,可重复)。
> 快照日期:2026-09-05。
## 概览
- 版本化迁移文件数:**110**`src/main/resources/db/V*.sql`
- 整数版本范围:**V1..V109 连续**
- 历史遗留小版本:**V25_1__shop_manage_group_bind_user.sql**Flyway 语义 25.1,紧跟在 V25 之后、V26 之前执行,属历史命名,保留)
- 最新版本:**V109__admin_menu_frontend_routes.sql**
- 重复版本:无
- 迁移命名:全部符合 `V<整数>(_<子版本>)?__<描述>.sql`,无空格
## 生产已执行核对
生产 Flyway 已执行到 ≥ V108;本次新增迁移使用 V109(迁移只追加,不回滚历史);新增迁移一律在 V108 之上取 `V109__*`,头注释引用 `docs/flyway-migration-template.md`task-192)。
## 约束
- 审计测试断言:整数版本从 1 到当前最大值连续、无重复文件名、命名正则合规、历史文件校验和可复算稳定。
- 修改任何已部署历史迁移会破坏 Flyway checksum,属违规;由本盘点测试的 `no_legacy_modified` 类约束 + code review 把关(git 层是否改动由 CI/review 校验)。
@@ -0,0 +1,28 @@
# 单次提交事务段耗时基线(task-138)
> 本文档由 `TxDurationBenchmarkTest` 断言存在与内容,用于冻结「Python 结果提交」单次调用的
> 耗时上界,防止提交路径劣化。**不要删除**,劣化时更新数值并说明原因。
## 测量对象
`SimilarAsinTaskService.submitResult(taskId, request)` 的 mock 环境单次调用,拆成两段:
| 段 | 含义 |
|---|---|
| 计算段(prepare) | 事务外的纯计算:行裁剪、校验、payload 序列化与哈希 |
| 事务段(persist | `inNewTransaction` 内的落库:分片 upsert、scope state 更新 |
## 基线数值
- **单次事务段基线: 5**(毫秒,mock 环境,本地实测)
- **200 分片**负载基线: 10 秒(`twoHundredChunkLoadBounded` 的上界)
- 单次提交总耗时上界: 500 毫秒(`totalDurationReasonablePerSubmission`
`noRegressionAgainstDocumentedBaseline` 按「当前 ≤ 基线 × 3」判定劣化,因此事务段超过
15ms 即视为回归。
## 说明
- 该基线取的是 mock 环境(Mapper 全部打桩)而非真实 MySQL 的耗时,用于**相对劣化**而非绝对性能。
- 真实环境的事务段耗时会显著高于此值,本基线不适用于容量规划。
- 测量机器与 JDK 变更后如需调整,请同步更新本文件数值。
@@ -0,0 +1,36 @@
package com.nanri.aiimage.common.exception;
/**
* 业务错误码常量。
*
* <p>历史上 40901 被两种语义复用:「任务已结束」(幂等忽略)与「任务正在处理中」(分布式锁竞争,
* 应重试);而 GlobalExceptionHandler 把 40901 一律转成 HTTP 200 + success=true
* 导致锁竞争时 Python worker 误判回传成功并停止重试,分片数据静默丢失。
* 现拆分为两个码:
* <ul>
* <li>{@link #TASK_ALREADY_FINISHED}:任务已结束,重复提交无意义 → 幂等忽略,响应 success=true</li>
* <li>{@link #TASK_BUSY}:任务正被其它请求持锁推进 → 响应 success=false,调用方应稍后重试。</li>
* </ul>
*/
public final class BusinessCodes {
private BusinessCodes() {
}
/** 任务已结束,拒绝重复提交。语义:幂等忽略,响应 success=true,调用方不应重试。 */
public static final int TASK_ALREADY_FINISHED = 40901;
/** 任务正在处理中(分布式锁竞争)。语义:资源忙,响应 success=false,调用方应稍后重试。 */
public static final int TASK_BUSY = 40902;
/** 任务归属其它实例,需转发。 */
public static final int TASK_OWNER_FORWARD = 40903;
/**
* 提交结果的目标任务已不存在(通常是被删除)。
* 语义:本次提交无意义,响应 success=false 且带该码,调用方应放弃而不是反复重试。
* 与 {@link #TASK_ALREADY_FINISHED} 的区别:那个还留了任务记录(可幂等忽略),
* 这个任务已经没了——如实报错,否则任务被误删时结果会被静默吞掉。
*/
public static final int TASK_NOT_FOUND = 40401;
}
@@ -1,18 +1,21 @@
package com.nanri.aiimage.common.exception;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.security.DeviceSessionPolicy;
import com.nanri.aiimage.common.service.TaskOwnerForwardService;
import com.nanri.aiimage.config.TaskOperationLockConfig;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.io.IOException;
@@ -28,34 +31,88 @@ public class GlobalExceptionHandler {
TaskOperationLockConfig.releaseRequestLock(request);
try {
ResponseEntity<byte[]> response = taskOwnerForwardService.forwardCurrentRequest(ex, request);
// 转发响应头不能原样照搬:upstream 响应自带的逐跳头(Transfer-Encoding/Connection 等)
// 原样复制会出现「双 Transfer-Encoding」,nginx 视为协议错误直接 502
// parsed-payload/activate 偶发 502 根因);实例标识头由本层 RequestTraceFilter
// 再写一份,原样又会出现双份 X-AIIMAGE-Instance。这里过滤这两类头后再回写。
org.springframework.http.HttpHeaders safeHeaders = new org.springframework.http.HttpHeaders();
java.util.Set<String> hopByHop = java.util.Set.of(
"transfer-encoding", "connection", "keep-alive", "te", "trailer", "upgrade",
"proxy-authenticate", "proxy-authorization", "content-length", "date", "server");
response.getHeaders().forEach((name, values) -> {
String lower = name == null ? "" : name.toLowerCase();
if (lower.isBlank() || hopByHop.contains(lower) || lower.startsWith("x-aiimage-instance")
|| lower.equals("x-aiimage-host")) {
return;
}
safeHeaders.put(name, values);
});
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.headers(safeHeaders)
.body(response.getBody());
} catch (BusinessException forwardEx) {
return forwardEx.getCode() == null
? ApiResponse.fail(forwardEx.getMessage())
: ApiResponse.fail(forwardEx.getCode(), forwardEx.getMessage());
} catch (Exception forwardEx) {
// 转发失败是**瞬时基础设施故障**(归属实例正在滚动重启),不是业务结论,
// 更不能表达成「任务不存活」。原先返回 ApiResponse.fail(40903) —— HTTP 200
// 加 data:null,而客户端那句 bool((resp.json().get("data") or {}).get("alive"))
// 会把「拿不到数据」折叠成 alive=false,于是客户端把**健康的长任务主动停掉**:
// 2026-09-18 任务 28616 就是这么死的(归属节点 server-110 重启窗口内,心跳经
// nginx 落到 server-121,转发 3 次 Connection refused 后返回空 data)。
// 改为 503 + 空 body:新客户端按状态码判为「未知」继续跑;老客户端因 body 不是
// JSON、resp.json() 抛异常,同样落到「未知」。顺带让这类故障在 HTTP 指标里可见
// (原先记成 200,监控完全看不到滚动重启期间丢了多少心跳)。
log.warn("[instance-routing] forward failed taskId={} operation={} owner={} current={} msg={}",
ex.getTaskId(), ex.getOperation(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId(),
forwardEx.getMessage(), forwardEx);
return ApiResponse.fail(40903, "任务归属实例转发失败: " + forwardEx.getMessage());
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}
}
@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
if (Integer.valueOf(40901).equals(ex.getCode())) {
public ApiResponse<Void> handleBusinessException(BusinessException ex, HttpServletRequest request) {
// 业务异常此前完全不记日志:2026-09-16 线上「保存权限/创建用户」双双失败时,
// 服务端只留 RequestTraceFilter 的 200 一行,根因只能靠反推响应体字节数才定位到。
// 401/4011(未登录、被顶下线)属于轮询类接口的常态噪声,降为 debug 以免淹没真实业务错。
if (isRoutineAuthNoise(ex.getCode())) {
log.debug("[business] {} {} code={} message={}", request.getMethod(), request.getRequestURI(),
ex.getCode(), ex.getMessage());
} else {
log.warn("[business] {} {} code={} message={}", request.getMethod(), request.getRequestURI(),
ex.getCode(), ex.getMessage());
}
if (Integer.valueOf(BusinessCodes.TASK_ALREADY_FINISHED).equals(ex.getCode())) {
// 幂等忽略:任务已结束时的重复提交无副作用,按成功返回,避免客户端反复重试
return ApiResponse.success("任务已结束,忽略重复提交", null);
}
if (Integer.valueOf(BusinessCodes.TASK_BUSY).equals(ex.getCode())) {
// 锁竞争:必须如实返回失败 + 可重试码,否则 worker 会把「未落库」当成功而停止重试
return ApiResponse.fail(BusinessCodes.TASK_BUSY, ex.getMessage());
}
return ex.getCode() == null
? ApiResponse.fail(ex.getMessage())
: ApiResponse.fail(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResourceFoundException(NoResourceFoundException ex) {
// 静态资源 404。绝大部分是外部扫描器在探测 /.env、/credentials、aliyun.json、oss.json
// 这类云凭据文件(线上单节点一天 580 条)。此前落到 handleException 里,既刷 ERROR 堆栈,
// 又把探测响应伪装成 HTTP 200;这里降为 debug 并如实返回 404。
log.debug("static resource not found: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.fail("资源不存在"));
}
/** 未登录 / 登录态失效 / 被其他设备顶下线:按 401 语义的常态噪声,不占 WARN。 */
private boolean isRoutineAuthNoise(Integer code) {
return Integer.valueOf(401).equals(code)
|| Integer.valueOf(DeviceSessionPolicy.CODE_KICKED).equals(code);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldError() != null
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldError() != null
? ex.getBindingResult().getFieldError().getDefaultMessage()
: "参数校验失败";
return ApiResponse.fail(message);
@@ -84,7 +141,9 @@ public class GlobalExceptionHandler {
return ApiResponse.fail("客户端已断开连接");
}
log.error("Unhandled exception", ex);
return ApiResponse.fail("服务异常: " + ex.getMessage());
// 不再回传原始异常信息:SQL 报错、类名与内部路径会直接暴露给调用方,便于攻击者
// 摸清技术栈与表结构。详情只进日志(上方 log.error 已带完整堆栈),对外统一文案。
return ApiResponse.fail("服务器内部错误,请稍后重试");
}
private boolean isClientAbort(Throwable ex) {
@@ -1,7 +1,7 @@
package com.nanri.aiimage.modules.permission.mapper;
package com.nanri.aiimage.common.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
@@ -1,7 +1,7 @@
package com.nanri.aiimage.modules.shopkey.mapper;
package com.nanri.aiimage.common.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.common.model.entity.ShopManageGroupEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -102,4 +102,53 @@ public interface ShopManageGroupMapper extends BaseMapper<ShopManageGroupEntity>
</script>
""")
List<Long> selectUserIdsByGroupIds(@Param("groupIds") List<Long> groupIds);
/**
* 批量查用户所属分组名分组归属按分组的创建人(created_by_id/user_id) = 用户的创建人判定
* 即主管名下的子账户归入该主管创建的分组成员表 biz_shop_manage_group_member 生产上基本未使用
*/
@Select("""
<script>
SELECT u.id AS userId, g.group_name AS groupName
FROM users u
INNER JOIN biz_shop_manage_group g
ON g.created_by_id = u.created_by_id OR g.user_id = u.created_by_id
WHERE u.created_by_id IS NOT NULL
AND u.id IN
<foreach collection='userIds' item='userId' open='(' separator=',' close=')'>
#{userId}
</foreach>
ORDER BY g.id ASC
</script>
""")
List<com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef> selectGroupNamesByUserIds(
@Param("userIds") List<Long> userIds);
/** 某分组下的用户:该分组创建人(组长)本人 + 其名下子账户。 */
@Select("""
SELECT u.id AS userId
FROM users u
INNER JOIN biz_shop_manage_group g ON g.id = #{groupId}
WHERE u.id = COALESCE(g.created_by_id, g.user_id)
OR u.created_by_id = COALESCE(g.created_by_id, g.user_id)
ORDER BY u.id ASC
""")
List<Long> selectUserIdsByGroupId(@Param("groupId") Long groupId);
/** 某人作为组长(创建人)的分组(密钥管理等按组隔离场景用)。 */
@Select("""
SELECT g.id AS id, g.group_name AS groupName
FROM biz_shop_manage_group g
WHERE g.created_by_id = #{operatorId} OR g.user_id = #{operatorId}
ORDER BY g.id ASC
""")
List<ShopManageGroupEntity> selectLedGroups(@Param("operatorId") Long operatorId);
/** 全部分组(超管筛选项用)。 */
@Select("""
SELECT g.id AS id, g.group_name AS groupName
FROM biz_shop_manage_group g
ORDER BY g.id ASC
""")
List<ShopManageGroupEntity> selectAllGroups();
}
@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.permission.model.entity;
package com.nanri.aiimage.common.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -24,5 +25,13 @@ public class AdminUserEntity {
private Long createdById;
@TableField("created_at")
private LocalDateTime createdAt;
/**
* 更新时间V131 新增由数据库维护DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP
*
* <p>禁止应用显式写MySQL UPDATE 语句显式给该列赋值时不会触发自动更新
* 而本表写回多为 selectById 改字段 updateById实体带着旧值一旦写回就会冻结更新时间
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
private String machine;
}
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
package com.nanri.aiimage.common.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
package com.nanri.aiimage.common.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchTaskStageVo;
@@ -0,0 +1,111 @@
package com.nanri.aiimage.common.module;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 任务模块清单的**单一来源**(2026-09 全维度审查 G6)。
*
* 背景:新增一个工具模块此前要同时改 5 处枚举(结果文件 Job 白名单、按天清理清单、
* 站内通知模块名、任务心跳分支、陈旧判死巡检线),其中 4 处没有任何自检,
* 漏改表现为"功能静默不生效"。现在把**数据驱动的三处**(结果文件 Job、按天清理、通知中文名)
* 统一从这里派生,并由 {@code TaskModuleCoverageTest} 守住覆盖面。
*
* 心跳与陈旧判死两处是代码分支驱动(switch / 逐模块委派),无法纯数据派生,
* 新增模块时仍需实现对应 Handler,测试会提示缺失。
*/
public final class TaskModuleRegistry {
/**
* 单个任务模块的声明。
*
* @param type moduleType(与 biz_file_task.module_type 一致)
* @param label 站内通知/界面用中文名
* @param resultFileJob 是否产出结果文件(需要注册 ResultFileJobHandler
* @param ageCleanup 是否参与按天清理(ModuleCleanupProperties
* @param delegatedStaleCheck 是否以"委派"方式并入 stale-check 巡检线
* DeleteBrandStaleTaskService.delegatedStaleChecks,必须逐模块登记动作)
* @param selfScheduledStaleCheck 该模块的陈旧判死自带更快的调度(publish 60s / collect-data 30s),
* 刻意不并入 2 分钟一轮的巡检线——并入会显著拉长判死时延。
* 这类模块的"不在委托名单"是设计差异,不是漏接;由覆盖面测试固定。
*/
public record Module(String type, String label, boolean resultFileJob, boolean ageCleanup,
boolean delegatedStaleCheck, boolean selfScheduledStaleCheck) {
}
private static final List<Module> MODULES = List.of(
new Module("PUBLISH", "上架", true, false, false, true),
new Module("DEDUPE", "数据去重", false, true, false, false),
new Module("SPLIT", "数据拆分", false, true, false, false),
new Module("CONVERT", "格式转换", false, true, false, false),
new Module("DELETE_BRAND", "删除ASIN", true, true, false, false),
new Module("PRODUCT_RISK_RESOLVE", "商品风险解决", true, true, false, false),
new Module("PRICE_TRACK", "跟价", true, true, false, false),
new Module("SHOP_MATCH", "定时匹配", true, true, false, false),
new Module("PATROL_DELETE", "巡店删除", true, true, false, false),
new Module("QUERY_ASIN", "查询ASIN", true, true, false, false),
new Module("WITHDRAW", "取款", true, true, false, false),
new Module("APPEARANCE_PATENT", "外观专利检测", true, true, true, false),
new Module("SIMILAR_ASIN", "货源查询", true, true, true, false),
new Module("COLLECT_DATA", "采集数据", true, true, false, true),
new Module("SHOP_DATA_CRAWL", "店铺数据抓取", true, false, true, false),
new Module("BRAND", "品牌检测", true, false, true, false)
);
private TaskModuleRegistry() {
}
public static List<Module> modules() {
return MODULES;
}
/** 全部 moduleType。 */
public static Set<String> moduleTypes() {
return MODULES.stream().map(Module::type).collect(Collectors.toUnmodifiableSet());
}
/** moduleType → 中文名(站内通知等展示用)。 */
public static Map<String, String> labels() {
Map<String, String> labels = new LinkedHashMap<>();
for (Module module : MODULES) {
labels.put(module.type(), module.label());
}
return Map.copyOf(labels);
}
/** 产出结果文件的模块(需要注册 Handler 的模块)。 */
public static Set<String> resultFileJobModuleTypes() {
return MODULES.stream().filter(Module::resultFileJob).map(Module::type)
.collect(Collectors.toUnmodifiableSet());
}
/** 参与按天清理的模块。 */
public static Set<String> ageCleanupModuleTypes() {
return MODULES.stream().filter(Module::ageCleanup).map(Module::type)
.collect(Collectors.toUnmodifiableSet());
}
/** 自带更快巡检节奏、刻意不并入集中巡检线的模块。 */
public static Set<String> selfScheduledStaleCheckModuleTypes() {
return MODULES.stream().filter(Module::selfScheduledStaleCheck).map(Module::type)
.collect(Collectors.toUnmodifiableSet());
}
/** 以"委派"方式并入 stale-check 巡检线的模块(DeleteBrandStaleTaskService 必须逐模块登记动作)。 */
public static Set<String> delegatedStaleCheckModuleTypes() {
return MODULES.stream().filter(Module::delegatedStaleCheck).map(Module::type)
.collect(Collectors.toUnmodifiableSet());
}
public static String labelOf(String moduleType) {
for (Module module : MODULES) {
if (module.type().equals(moduleType)) {
return module.label();
}
}
return moduleType;
}
}
@@ -0,0 +1,35 @@
package com.nanri.aiimage.common.retry;
import java.util.concurrent.ThreadLocalRandom;
/**
* LLM 重试等待策略:指数退避 + 抖动。
*
* <p>背景(2026-09-13 生产实测):上游 ai.t8star.org 存在持续数十秒的劣化窗口,
* 窗口内所有请求都不应答;原来的 1.5s/3s 密集重试会整批落在窗口内,三连失败后
* 行降级(外观识别异常)。退避拉到 2s/10s 并带 ±30% 抖动,可覆盖更长的窗口,
* 同时避免同批多行在同一时刻齐发重试形成尖峰。
*/
public final class LlmRetryBackoff {
private static final long[] BASE_DELAYS_MILLIS = {2_000L, 10_000L};
private static final double JITTER_RATIO = 0.3d;
private static final long MIN_DELAY_MILLIS = 200L;
private LlmRetryBackoff() {
}
/** 第 attemptIndex 次失败后的等待毫秒数(attemptIndex 从 1 开始);超出档位取最后一档。 */
public static long delayMillis(int attemptIndex) {
return delayMillis(attemptIndex, ThreadLocalRandom.current().nextDouble());
}
/** 固定抖动入口(测试用):random 取值 [0,1)0.5 表示无抖动。 */
static long delayMillis(int attemptIndex, double random) {
int index = Math.min(Math.max(1, attemptIndex), BASE_DELAYS_MILLIS.length) - 1;
long base = BASE_DELAYS_MILLIS[index];
double bounded = Math.min(Math.max(random, 0d), 1d);
double factor = 1d + (bounded * 2d - 1d) * JITTER_RATIO;
return Math.max(MIN_DELAY_MILLIS, Math.round(base * factor));
}
}
@@ -1,23 +1,26 @@
package com.nanri.aiimage.modules.admin.support;
package com.nanri.aiimage.common.security;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.auth.service.JwtService;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.common.security.JwtService;
import com.nanri.aiimage.common.security.DeviceSessionPolicy;
import com.nanri.aiimage.common.mapper.AdminUserMapper;
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import com.nanri.aiimage.modules.auth.config.AuthProperties;
import com.nanri.aiimage.common.security.AuthProperties;
import org.springframework.beans.factory.annotation.Value;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@Slf4j
@Component
@RequiredArgsConstructor
public class AdminAuthSupport {
@@ -52,11 +55,39 @@ public class AdminAuthSupport {
if (user == null) {
throw new BusinessException(401, "用户不存在");
}
// 单设备登录被新设备顶下线的旧 token 在此统一拦截全站 requireUser 调用点自动生效
if (authProperties.isSingleDeviceEnabled()) {
DeviceSessionPolicy.assertSameDevice(user.getMachine(), DeviceSessionPolicy.claimDeviceId(claims),
DeviceSessionPolicy.isSuperAdmin(user.getRole(), user.getIsAdmin(), user.getCreatedById()),
user.getId(), user.getUsername());
}
return user;
}
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */
public AdminUserEntity requireAdmin(HttpServletRequest request) {
/**
* 解析当前请求 JWT **签名的**设备标识deviceId claim识别不出时返回空串
*
* <p> tokentoken 过期/非法内部令牌通道调用一律返回空串调用方必须把空串
* 当作"来源不明"做保守判定绝不据此放宽任何限制只认签名 claim不接受
* X-Device-Id 请求头头由客户端可控 {@link DeviceSessionPolicy} 类注释</p>
*
* <p>本方法只做识别不做鉴权因此解析失败不抛异常仅记日志后返回空串
* 避免把匿名/内部调用直接升级成 401</p>
*/
public String currentDeviceId(HttpServletRequest request) {
String token = resolveToken(request);
if (token == null || token.isBlank()) {
return "";
}
try {
return DeviceSessionPolicy.claimDeviceId(jwtService.parse(token));
} catch (Exception ex) {
log.warn("[auth] 解析 token 取设备标识失败,按来源不明处理: {}", ex.getMessage());
return "";
}
}
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */ public AdminUserEntity requireAdmin(HttpServletRequest request) {
AdminUserEntity user = requireUser(request);
String role = currentRole(user);
if (role == null) {
@@ -70,18 +101,7 @@ public class AdminAuthSupport {
if (user == null) {
return null;
}
String storedRole = user.getRole() == null ? "" : user.getRole().trim().toLowerCase();
if ("super_admin".equals(storedRole)) {
return "super_admin";
}
if ("admin".equals(storedRole)) {
return "admin";
}
boolean isAdminFlag = user.getIsAdmin() != null && user.getIsAdmin() == 1;
if (storedRole.isEmpty() && isAdminFlag) {
return user.getCreatedById() == null ? "super_admin" : "admin";
}
return null;
return DeviceSessionPolicy.resolveRole(user.getRole(), user.getIsAdmin(), user.getCreatedById());
}
/** JWT 优先;无 JWT 时以可信内部代理身份(X-Internal-Token + operatorId)回退,仍要求管理员角色。 */
@@ -147,7 +167,10 @@ public class AdminAuthSupport {
if (expectedToken.isBlank() || suppliedToken == null || suppliedToken.isBlank()) {
return false;
}
return expectedToken.equals(suppliedToken.trim());
// 常量时间比较逐字节 equals 可被计时侧信道逐位试探出内部令牌
return java.security.MessageDigest.isEqual(
expectedToken.getBytes(java.nio.charset.StandardCharsets.UTF_8),
suppliedToken.trim().getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/**
@@ -1,4 +1,4 @@
package com.nanri.aiimage.modules.auth.config;
package com.nanri.aiimage.common.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -14,4 +14,6 @@ public class AuthProperties {
private String cookieName = "aiimage_token";
private boolean cookieSecure = false;
private String cookieSameSite = "Lax";
/** 单设备登录(互踢)总开关:关闭后恢复为多设备同时在线(回滚用)。 */
private boolean singleDeviceEnabled = true;
}
@@ -0,0 +1,90 @@
package com.nanri.aiimage.common.security;
import com.nanri.aiimage.common.exception.BusinessException;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
/**
* 单设备登录(互踢)策略。
*
* <p>账号当前绑定的设备存放在 users.machine(登录成功即覆盖,last-login-wins);
* 非超管账号仅允许「token 内签名的 deviceId」与绑定设备一致的请求通过,
* 被新设备顶下线的旧 token 在下一次受保护请求时抛 4011。</p>
*
* <p>校验只认 token 内签名的 deviceId,绝不使用 X-Device-Id 请求头(头是客户端可控的)。</p>
*/
@Slf4j
public final class DeviceSessionPolicy {
/** 账号已在其他设备登录(前端据此提示并下线本设备)。 */
public static final int CODE_KICKED = 4011;
private DeviceSessionPolicy() {
}
/** 计算用户管理角色:super_admin / admin / null(含老数据 role 为空时按 created_by_id 推断)。 */
public static String resolveRole(String role, Integer isAdmin, Long createdById) {
String storedRole = role == null ? "" : role.trim().toLowerCase();
if ("super_admin".equals(storedRole)) {
return "super_admin";
}
if ("admin".equals(storedRole)) {
return "admin";
}
boolean isAdminFlag = isAdmin != null && isAdmin == 1;
if (storedRole.isEmpty() && isAdminFlag) {
return createdById == null ? "super_admin" : "admin";
}
return null;
}
/** 是否超级管理员(互踢的唯一豁免角色)。 */
public static boolean isSuperAdmin(String role, Integer isAdmin, Long createdById) {
return "super_admin".equals(resolveRole(role, isAdmin, createdById));
}
/** 从 JWT claims 中提取签名的设备号;缺失返回空串。 */
public static String claimDeviceId(Claims claims) {
Object raw = claims == null ? null : claims.get("deviceId");
return raw == null ? "" : raw.toString().trim();
}
/**
* 校验请求携带的 token 是否仍属于账号当前绑定的设备。
* 超管豁免;machine 为空(尚未绑定)放行;不匹配抛 4011。
*/
public static void assertSameDevice(String storedMachine, String claimDeviceId, boolean exempt,
Long userId, String username) {
if (exempt) {
return;
}
String bound = storedMachine == null ? "" : storedMachine.trim();
if (bound.isEmpty()) {
// 尚未绑定(首次登录前 / V118 清空后首个登录前),不做限制
return;
}
String claimed = claimDeviceId == null ? "" : claimDeviceId.trim();
if (claimed.isEmpty()) {
log.warn("[auth] 单设备登录校验:token 缺少设备标识,拒绝 userId={} username={}", userId, username);
throw new BusinessException(401, "登录态无效");
}
if (!bound.equals(claimed)) {
log.warn("[auth] 单设备登录拦截:用户 {} 已被其他设备顶下线,token设备={} 当前绑定设备={}",
userId, claimed, bound);
throw new BusinessException(CODE_KICKED, "该账号已在其他设备登录,本设备已下线");
}
}
/** 登录绑定时的中文日志:首次绑定 / 换设备(顶下线)/ 同设备重登,便于线上排查互踢来源。 */
public static void logBindOnLogin(String previousMachine, String deviceId, Long userId, String username) {
String previous = previousMachine == null ? "" : previousMachine.trim();
if (previous.isEmpty()) {
log.info("[auth] 单设备登录:用户 {}({})首次绑定设备 {}", userId, username, deviceId);
} else if (!previous.equals(deviceId)) {
log.warn("[auth] 单设备登录:用户 {}({})在设备 {} 登录,原设备 {} 已被顶下线",
userId, username, deviceId, previous);
} else {
log.info("[auth] 单设备登录:用户 {}{})同设备重新登录 device={}", userId, username, deviceId);
}
}
}
@@ -1,6 +1,6 @@
package com.nanri.aiimage.modules.auth.service;
package com.nanri.aiimage.common.security;
import com.nanri.aiimage.modules.auth.config.AuthProperties;
import com.nanri.aiimage.common.security.AuthProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
@@ -26,6 +26,26 @@ public class JwtService {
private volatile SecretKey cachedKey;
@org.springframework.beans.factory.annotation.Value("${spring.profiles.active:}")
private String activeProfiles;
/**
* 生产 profile 下密钥缺失即拒绝启动2026-09 全维度审查
*
* <p>此前未配置时静默回退到内置默认密钥那是公开值任何人可据此伪造 token
* 一次 env 丢失就会让全站鉴权形同虚设只留一行 warn 日志不足以拦住发布
* 本地/测试 profile 保持宽松否则开发无法启动
*/
@jakarta.annotation.PostConstruct
void requireSecretInServerProfile() {
boolean serverProfile = activeProfiles != null && activeProfiles.contains("server");
if (serverProfile && (props.getJwtSecret() == null || props.getJwtSecret().isBlank())) {
throw new IllegalStateException(
"server profile 下必须配置 aiimage.auth.jwt-secret(环境变量 AIIMAGE_JWT_SECRET)——"
+ "缺失时会回退到公开的默认密钥,token 可被任意伪造");
}
}
private SecretKey signingKey() {
SecretKey key = cachedKey;
if (key == null) {
@@ -35,11 +55,12 @@ public class JwtService {
String configured = props.getJwtSecret();
byte[] keyBytes;
if (configured == null || configured.isBlank()) {
// 已分发到用户机器的桌面客户端内置同一个默认密钥服务端必须保持一致
// 不能随机生成否则所有旧客户端 /api/auth/sync 验签失败导致无法登录
// 兜底未配置时使用内置默认密钥公开值等同无防护任何人可伪造 token
// 桌面客户端本地 Flask 已退役服务端可独立轮换密钥若恢复客户端侧验签
// 需与客户端同步下发密钥后才能轮换
keyBytes = INSECURE_DEFAULT_SECRET.getBytes(StandardCharsets.UTF_8);
log.warn("[auth] AIIMAGE_JWT_SECRET 未配置,使用内置默认密钥;"
+ "生产环境请通过环境变量 AIIMAGE_JWT_SECRET 配置固定密钥(需与桌面客户端一致)");
+ "生产环境请在 application-server.yml 配置 aiimage.auth.jwt-secret");
} else {
keyBytes = configured.getBytes(StandardCharsets.UTF_8);
}
@@ -0,0 +1,179 @@
package com.nanri.aiimage.common.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 跨节点的小容量状态存储(2026-09 全维度审查 D2)。
*
* <p>用途:导入/导出任务的进度、归属用户、分组等状态。原先只存节点本地内存,
* 客户端轮询落到另一节点就报"任务不存在"nginx 的 user_id 亲和只覆盖常态)。
* 现在本地 Map 作快路径、Redis 作跨节点真源,任何节点都能读到。
*
* <p>写节流:调用方可能**逐行**刷新进度(数十万行),逐次写 Redis 不可接受;
* 默认 500ms 内只写一次(终态用 {@link #putNow} 立即落库)。
*
* <p>语义取舍:读-改-写不保证原子(进度类状态可接受);{@code redis}/{@code objectMapper}
* 为空时退化为纯本地(单测与未注入场景)。
*/
@Slf4j
public final class NodeSharedStore<K, V> {
private static final long DEFAULT_WRITE_THROTTLE_MILLIS = 500L;
private final String keyPrefix;
private final Duration ttl;
private final Class<V> valueType;
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
private final long writeThrottleMillis;
private final ConcurrentHashMap<K, V> local = new ConcurrentHashMap<>();
private final ConcurrentHashMap<K, AtomicLong> lastWriteAt = new ConcurrentHashMap<>();
public NodeSharedStore(String keyPrefix,
Duration ttl,
Class<V> valueType,
StringRedisTemplate redis,
ObjectMapper objectMapper) {
this(keyPrefix, ttl, valueType, redis, objectMapper, DEFAULT_WRITE_THROTTLE_MILLIS);
}
public NodeSharedStore(String keyPrefix,
Duration ttl,
Class<V> valueType,
StringRedisTemplate redis,
ObjectMapper objectMapper,
long writeThrottleMillis) {
this.keyPrefix = keyPrefix;
this.ttl = ttl;
this.valueType = valueType;
this.redis = redis;
this.objectMapper = objectMapper;
this.writeThrottleMillis = Math.max(0L, writeThrottleMillis);
}
/** 本地优先;本地没有则读 Redis 并回填本地(跨节点可见)。 */
public V get(K key) {
if (key == null) {
return null;
}
V cached = local.get(key);
if (cached != null) {
return cached;
}
V remote = readRemote(key);
if (remote != null) {
local.put(key, remote);
}
return remote;
}
public boolean containsKey(K key) {
return get(key) != null;
}
/** 写入并(按节流)同步到 Redis。 */
public void put(K key, V value) {
if (key == null || value == null) {
return;
}
local.put(key, value);
if (!throttleAllowsWrite(key)) {
return;
}
writeRemote(key, value);
}
/** 立即写入 Redis(终态、归属等一次性状态用)。 */
public void putNow(K key, V value) {
if (key == null || value == null) {
return;
}
local.put(key, value);
writeRemote(key, value);
}
/** 删除(本地 + Redis),返回删除前的值(可能为 null)。 */
public V remove(K key) {
if (key == null) {
return null;
}
V previous = local.remove(key);
lastWriteAt.remove(key);
if (redis != null) {
try {
redis.delete(fullKey(key));
} catch (Exception ex) {
log.warn("[node-shared-store] 删除远端状态失败 key={} msg={}", fullKey(key), ex.getMessage());
}
}
return previous;
}
/** 本地快照(仅用于日志/统计;不含其它节点的写入)。 */
public int localSize() {
return local.size();
}
/**
* 本节点已知条目的快照(副本,可安全遍历)。
*
* <p>用途:保留期清理等维护动作需要遍历键;跨节点的过期回收由 Redis TTL 兜底,
* 因此这里只返回本节点写入过的条目即可。
*/
public java.util.Map<K, V> localEntriesSnapshot() {
return new java.util.LinkedHashMap<>(local);
}
private boolean throttleAllowsWrite(K key) {
if (writeThrottleMillis <= 0L) {
return true;
}
long now = System.currentTimeMillis();
AtomicLong last = lastWriteAt.computeIfAbsent(key, ignored -> new AtomicLong(0L));
long previous = last.get();
if (now - previous < writeThrottleMillis) {
return false;
}
return last.compareAndSet(previous, now);
}
private V readRemote(K key) {
if (redis == null || objectMapper == null) {
return null;
}
try {
String json = redis.opsForValue().get(fullKey(key));
if (json == null || json.isBlank()) {
return null;
}
return objectMapper.readValue(json, valueType);
} catch (Exception ex) {
log.warn("[node-shared-store] 读取远端状态失败 key={} msg={}", fullKey(key), ex.getMessage());
return null;
}
}
private void writeRemote(K key, V value) {
if (redis == null || objectMapper == null) {
return;
}
try {
String json = objectMapper.writeValueAsString(value);
redis.opsForValue().set(fullKey(key), json, ttl);
} catch (Exception ex) {
// 写失败不影响本地进度(下次 put 会重试),只留线索
log.warn("[node-shared-store] 写入远端状态失败 key={} msg={}", fullKey(key), ex.getMessage());
}
}
private String fullKey(K key) {
return keyPrefix + ":" + key;
}
}
@@ -0,0 +1,46 @@
package com.nanri.aiimage.common.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Objects;
/**
* 结果文件下载直链解析(2026-09 全维度审查去重)。
*
* <p>此前 11 个业务模块各写一份逐字相同的 {@code resolveResultDownloadUrl},下载鉴权口径
* 或 OSS 直链规则一调整就要改 11 处;similarasin 已自行分叉成返回 record 的第 12 种写法,
* 说明"改的时候漏一个"已经开始发生。
*
* <p>文件名解析({@code resolveResultDownloadFilename}**未**收口:各模块兜底文件名策略
* 确实不同(模块名+id / 源文件名派生 stem),属业务差异而非重复。
*/
@Component
@RequiredArgsConstructor
public class ResultDownloadResolver {
private final FileResultMapper fileResultMapper;
private final OssStorageService ossStorageService;
/**
* 解析结果文件下载直链。
*
* @param resultId 结果行 idbiz_file_result
* @param userId 当前用户 id,必须与结果行归属一致
* @param moduleType 期望的模块类型
*/
public String resolveUrl(Long resultId, Long userId, String moduleType) {
FileResultEntity row = fileResultMapper.selectById(resultId);
if (row == null || !moduleType.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) {
throw new BusinessException("记录不存在");
}
if (row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) {
throw new BusinessException("暂无可下载文件");
}
return ossStorageService.generateFreshDownloadUrl(row.getResultFileUrl());
}
}
@@ -13,10 +13,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.ContentCachingRequestWrapper;
import java.io.IOException;
import java.net.ConnectException;
import java.util.Enumeration;
import java.util.List;
import java.util.Set;
@@ -28,6 +30,11 @@ public class TaskOwnerForwardService {
public static final String FORWARDED_HEADER = "X-AIIMAGE-Owner-Forwarded";
/** 连接类失败的重试次数(含首次)。对端滚动重启时通常几秒内即可恢复。 */
private static final int CONNECT_RETRY_TIMES = 3;
/** 第 n 次重试前的退避:1s、2s(总等待不超过 3s,不长时间占用请求线程)。 */
private static final long CONNECT_RETRY_BACKOFF_MILLIS = 1000L;
private static final Set<String> HOP_BY_HOP_HEADERS = Set.of(
"connection",
"keep-alive",
@@ -58,12 +65,60 @@ public class TaskOwnerForwardService {
HttpHeaders headers = copyForwardHeaders(request, ex.getCurrentInstanceId());
log.info("[instance-routing] forward {} taskId={} operation={} owner={} current={} url={}",
method, ex.getTaskId(), ex.getOperation(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId(), url);
return forwardWithConnectRetry(method, url, headers, body, ex);
}
/**
* 转发带连接级重试。
*
* <p>对端实例在部署窗口(原地换 JAR + 两节点滚动重启)内会有几秒的 Connection refused。
* 连接都没建立起来说明请求没到达对端,此时重放是安全的;而读超时不重试——对端可能
* 已经在处理,盲目重放会造成重复提交。线上由此丢过用户提交的结果。
*/
private ResponseEntity<byte[]> forwardWithConnectRetry(HttpMethod method, String url,
HttpHeaders headers, byte[] body,
TaskOwnerMismatchException ex) {
RuntimeException lastError = null;
for (int attempt = 1; attempt <= CONNECT_RETRY_TIMES; attempt++) {
try {
return restClient().method(method)
.uri(url)
.headers(target -> target.addAll(headers))
.body(body)
.retrieve()
.toEntity(byte[].class);
} catch (ResourceAccessException accessError) {
if (!isConnectFailure(accessError)) {
throw accessError;
}
lastError = accessError;
log.warn("[instance-routing] 转发连接失败,第 {}/{} 次 url={} taskId={} 原因={}",
attempt, CONNECT_RETRY_TIMES, url, ex.getTaskId(), accessError.getMessage());
if (attempt < CONNECT_RETRY_TIMES) {
sleepQuietly(CONNECT_RETRY_BACKOFF_MILLIS * attempt);
}
}
}
throw lastError;
}
private static boolean isConnectFailure(Throwable error) {
Throwable cursor = error;
while (cursor != null) {
if (cursor instanceof ConnectException) {
return true;
}
cursor = cursor.getCause();
}
return false;
}
private static void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
private String resolveUrl(TaskOwnerMismatchException ex, String path) {
@@ -0,0 +1,70 @@
package com.nanri.aiimage.common.util;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
/**
* 有界 LRU 缓存:容量超限时自动淘汰最久未使用的条目。
*
* <p>用于按「外部端点」缓存长生命周期资源(HttpClient / RestClient)。这类资源各自持有
* 连接池与 selector 线程,无界累积会持续泄漏线程与内存:代理端点每次提取往往是新的
* IP:port(jikip 提取),无上限的缓存只增不减。
*
* <p>淘汰时只从缓存移除引用,不做显式关闭:JDK 的 HttpClientImpl 注册了 Cleaner
* 对象不可达后由 GC 回收并关闭其 selector 线程;显式关闭反而可能打断仍在途的请求。
*/
public final class BoundedLruCache<K, V> {
/** 默认容量:代理端点数量级远小于此,足够覆盖热点端点又不至于累积。 */
public static final int DEFAULT_MAX_SIZE = 64;
private final int maxSize;
private final Map<K, V> store;
public BoundedLruCache() {
this(DEFAULT_MAX_SIZE);
}
public BoundedLruCache(int maxSize) {
this.maxSize = Math.max(1, maxSize);
// accessOrder=true 使 get 也刷新顺序(真正的 LRU);synchronizedMap 保证其线程安全
this.store = Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > BoundedLruCache.this.maxSize;
}
});
}
/**
* 取缓存值,缺失时用 loader 计算并放入。
*
* <p>与 {@code ConcurrentHashMap.computeIfAbsent} 不同,此处不保证 loader 的原子性:
* 并发首次访问同一 key 时可能各自构造一次,随后其中一个覆盖另一个。对
* HttpClient/RestClient 这类构造廉价且幂等的资源可接受,换来的是锁粒度更小。
*/
public V computeIfAbsent(K key, Function<K, V> loader) {
V existing = store.get(key);
if (existing != null) {
return existing;
}
V created = loader.apply(key);
store.put(key, created);
return created;
}
public int size() {
return store.size();
}
/** 当前容量上限,供日志与测试断言使用。 */
public int maxSize() {
return maxSize;
}
public void clear() {
store.clear();
}
}
@@ -56,6 +56,13 @@ public final class ExcelStreamReader {
default void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) throws Exception {
}
/**
* 表头之后回调本次 sheet 的近似总行数(EasyExcel 基于 sheet 尺寸,可能为 null)。
* 流式解析无法在读完前得到精确行数,需要展示进度/做前置上限校验的调用方可用它近似。
*/
default void onSheetTotal(String sheetName, Integer sheetNo, Integer approximateTotalRows) throws Exception {
}
void onRow(String sheetName, Integer sheetNo, int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) throws Exception;
}
@@ -77,6 +84,7 @@ public final class ExcelStreamReader {
currentHeaderMap = normalizedHeadMap;
try {
handler.onHeader(sheetName(context), sheetNo(context), currentHeaderMap);
handler.onSheetTotal(sheetName(context), sheetNo(context), approximateTotalRows(context));
} catch (RuntimeException ex) {
throw ex;
} catch (Exception ex) {
@@ -109,6 +117,15 @@ public final class ExcelStreamReader {
public void doAfterAllAnalysed(AnalysisContext context) {
}
private Integer approximateTotalRows(AnalysisContext context) {
try {
return context.readSheetHolder() == null ? null
: context.readSheetHolder().getApproximateTotalRowNumber();
} catch (Exception ex) {
return null;
}
}
private String sheetName(AnalysisContext context) {
return context.readSheetHolder() == null ? "" : context.readSheetHolder().getSheetName();
}
@@ -0,0 +1,138 @@
package com.nanri.aiimage.common.util;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 待删对象队列的本地落盘日志:进程重启后仍能恢复「待删除对象」清单。
*
* <p>使用场景:对象存储删除失败后的补偿队列原本只在节点内存里,重启即丢,
* 对应对象会一直残留在桶里直到生命周期规则过期。这里用一行一个 objectKey 的
* 追加日志做最小持久化——入队追加、队列收敛后整体重写、启动时回放。
*
* <p>并发:所有方法内部同步,调用方无需额外加锁;文件损坏/读写异常只记日志不抛出,
* 保证补偿链路本身不会因为落盘失败而中断业务。
*
* <p>上限:{@code maxEntries} 用于防止日志在异常堆积时无界增长(超出后丢弃最早的记录,
* 与内存队列的容量准入语义一致——丢的是「待删对象」,最坏结果是对象残留)。
*/
@Slf4j
public final class PendingDeleteJournal {
private final Path path;
private final int maxEntries;
private final Object lock = new Object();
public PendingDeleteJournal(Path path, int maxEntries) {
this.path = path;
this.maxEntries = Math.max(1, maxEntries);
}
public Path getPath() {
return path;
}
/** 追加一条待删对象(重复追加由回放时的 Set 语义去重)。 */
public void record(String objectKey) {
if (objectKey == null || objectKey.isBlank()) {
return;
}
synchronized (lock) {
try {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(path, sanitize(objectKey) + System.lineSeparator(),
StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ex) {
log.warn("[delete-journal] 追加待删对象失败 path={} msg={}", path, ex.getMessage());
}
}
}
/** 启动回放:返回日志中的待删对象(按首次出现顺序去重)。 */
public List<String> readAll() {
synchronized (lock) {
if (!Files.isRegularFile(path)) {
return List.of();
}
List<String> lines;
try {
lines = Files.readAllLines(path, StandardCharsets.UTF_8);
} catch (Exception ex) {
log.warn("[delete-journal] 读取待删对象日志失败 path={} msg={}", path, ex.getMessage());
return List.of();
}
Set<String> unique = new LinkedHashSet<>();
for (String line : lines) {
if (line == null) {
continue;
}
String key = line.trim();
if (!key.isEmpty()) {
unique.add(key);
}
}
return new ArrayList<>(unique);
}
}
/** 队列收敛(成功删除/引用仍在)后整体重写为剩余的待删对象;剩余为空则删除日志。 */
public void rewrite(Collection<String> remaining) {
synchronized (lock) {
Set<String> keep = new LinkedHashSet<>();
if (remaining != null) {
for (String key : remaining) {
if (key != null && !key.isBlank()) {
keep.add(sanitize(key));
if (keep.size() >= maxEntries) {
break;
}
}
}
}
try {
if (keep.isEmpty()) {
Files.deleteIfExists(path);
return;
}
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Path tmp = path.resolveSibling(path.getFileName() + ".tmp");
StringBuilder content = new StringBuilder();
for (String key : keep) {
content.append(key).append(System.lineSeparator());
}
Files.writeString(tmp, content.toString(), StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
try {
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException atomicUnsupported) {
// 少数文件系统不支持 ATOMIC_MOVE,退化为普通替换
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING);
}
} catch (Exception ex) {
log.warn("[delete-journal] 重写待删对象日志失败 path={} msg={}", path, ex.getMessage());
}
}
}
/** 单行一条记录:去掉换行避免破坏行结构。 */
private static String sanitize(String objectKey) {
return objectKey.replace('\n', ' ').replace('\r', ' ').trim();
}
}
@@ -0,0 +1,52 @@
package com.nanri.aiimage.common.util;
import java.net.URI;
/**
* 敏感串脱敏工具(2026-09 全维度审查后收口)。
*
* <p>背景:代理提取链接形态为 {@code http://user:pass@host:port},代码中曾有多处
* 直接把整串打进日志,导致用户代理账号密码长期留在应用日志与 docker logs 里。
*/
public final class SecretMasking {
private SecretMasking() {
}
/** 通用掩码:保留前 4 与后 4 字符;过短则整体掩掉。 */
public static String mask(String value) {
if (value == null || value.isBlank()) {
return "";
}
String text = value.trim();
if (text.length() <= 8) {
return "****";
}
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
}
/** 代理掩码:隐去账号密码,保留 scheme://host:port 便于运维核对。 */
public static String maskProxy(String value) {
if (value == null || value.isBlank()) {
return "";
}
try {
URI uri = URI.create(value.trim());
if (uri.getHost() == null || uri.getHost().isBlank()) {
return mask(value);
}
StringBuilder masked = new StringBuilder();
masked.append(uri.getScheme() == null ? "http" : uri.getScheme()).append("://");
if (uri.getUserInfo() != null && !uri.getUserInfo().isBlank()) {
masked.append("***@");
}
masked.append(uri.getHost());
if (uri.getPort() > 0) {
masked.append(':').append(uri.getPort());
}
return masked.toString();
} catch (Exception ex) {
return mask(value);
}
}
}

Some files were not shown because too many files have changed in this diff Show More