Commit Graph

522 Commits

Author SHA1 Message Date
lzh
5756f23ed7 Merge branch 'master' into feat/multi-tenant 2026-04-24 11:45:57 +08:00
lzh
8c5c5ef44a chore(ci): 部署加磁盘预检 + 部署后自动清理 Prod 本地镜像与 Registry
Some checks failed
Java CI with Maven / build (11) (push) Has been cancelled
Java CI with Maven / build (17) (push) Has been cancelled
Java CI with Maven / build (8) (push) Has been cancelled
- 新增 Pre-deploy Check:SSH 到 Prod/Registry 读根分区空闲,<5% 直接 fail(规避磁盘满时 sshd 连带崩溃导致的 scp 失败),5~10% 仅告警
- 新增 Cleanup Old Images stage:部署成功后每服务保留最近 3 个镜像
  * Prod 侧调用 scripts/cleanup.sh
  * Registry 侧调用 scripts/registry-cleanup.py + 触发容器内 garbage-collect
- scripts/cleanup.sh:去掉 volume prune 的交互 read(CI 下会卡住),支持 --keep/--prune-volumes/--registry 参数
- scripts/registry-cleanup.py:按 tag 内数字降序保留最新 N 个;覆盖 Docker v2/OCI 多种 manifest Accept;多 tag 指向同一 digest 去重;失败不影响发布

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:20:37 +08:00
lzh
acd7a35e1d fix(iot): 轨迹检测防抖 + eventTime 用 reportTime 避免回放挤压
- 事件 eventTime 透传设备 reportTime,修复 TDengine/消息总线恢复后堆积回放导致 ENTER/LEAVE 全部塞进同一秒的问题
- 区域切换加 5dB 滞回 + 进入后 5s 最小停留,压制 RSSI 抖动造成的瞬态 AREA_SWITCH 与 SIGNAL_LOSS
- 滞回兜底改用窗口内最近一次非 -999 样本,避免当前信标短暂漏扫时滞回被缺失哨兵破坏
- reportTime 为空时记录 warn,便于追踪上游漏传的调用链

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:59:27 +08:00
lzh
9784d7dd8e perf(system): 项目授权校验改单行查询 + AuthController 切 FromCache
背景:feat/multi-tenant-project 联调发现 /admin-api/** 全线变慢,
尤其 get-permission-info 经常超时。根因是 ProjectSecurityWebFilter
每请求需校验"用户对项目是否授权",原实现走"拿全量 authorizedIds 再
contains"路径,超管分支还得 selectList 全表项目。Framework 层虽有
60s 本地缓存,cache miss 时仍要走 Feign HTTP 自调用 + 两次 DB。

优化:
1. 新增 ProjectService.isProjectAuthorized(userId, projectId) 单项校验:
   - 超管直通返回 true(不查任何表)
   - 普通用户走 (user_id, project_id) 唯一索引的 selectCount 单行查询
2. ProjectCommonApi / ProjectApiImpl / ProjectFrameworkService(Impl)
   全链路新增 isProjectAuthorized Feign 接口
3. ProjectFrameworkServiceImpl 为 isProjectAuthorized 加 60s 本地
   Guava 缓存(key=(userId,projectId));invalidateAuthorizedProjectCache
   同步清理本用户所有条目
4. ProjectSecurityWebFilter 改调 isProjectAuthorized,消除每请求
   拉全量列表的开销
5. ProjectServiceImpl.getDefaultProjectId 的 N 次 selectById
   改成一次 selectByIds 批量
6. AuthController.getPermissionInfo 第 107 行
   getUserRoleIdListByUserId → FromCache(yudao 原生小瑕疵顺手修)

预期收益:
- Filter 热路径在 cache 命中时 0 次 DB,cache miss 时 1 次单行查询
- get-permission-info 消除一次无缓存 user_role DB 查询

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:20:29 +08:00
lzh
317f1cd02f perf(system): isSuperAdmin 切到 getUserRoleIdListByUserIdFromCache
修复 /admin-api/system/auth/get-permission-info 等接口大面积超时:

原因:ProjectSecurityWebFilter 每次 admin-api 请求都调一次
ProjectService.getAuthorizedProjectIds(userId),我之前在里面塞的
isSuperAdmin 用了无缓存的 getUserRoleIdListByUserId,
每请求一次 SELECT system_user_role,并发下直接打爆 DB。

切到 getUserRoleIdListByUserIdFromCache(@Cacheable 走 Redis
USER_ROLE_ID_LIST),首次查 DB、后续命中缓存,该缓存在
assignUserRole / processUserDeleted / updateUserRole 等写入点
都已正确 CacheEvict。

同时修正 UserProjectServiceImpl.isSuperAdmin 同样问题。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:10:41 +08:00
lzh
5dbc6c5e79 feat(system): 超管绕过 user_project + 项目成员分页/增量 API
后端为配合前端"项目管理成员"从 Modal 改 Drawer 重构接口:
- ProjectServiceImpl.getAuthorizedProjectIds 新增超管分支:
  若 hasAnySuperAdmin(userRoleIds) 成立,直接返回本租户全部项目 ID
  连带影响 getAuthorizedEnabledProjects / getDefaultProjectId /
  ProjectSecurityWebFilter.authorizedProjectIds.contains 全部自动生效
- 新增 UserProjectService 三个方法:
  * getProjectUserPage(reqVO) 分页返回成员 AdminUserDO,过滤超管
  * addProjectUsers(projectId, userIds) 增量添加,已在的用户跳过
  * removeProjectUser(projectId, userId) 单删,带超管/自踢守卫
- 新增 Controller 三个端点:
  * GET  /system/user-project/project-user-page
  * POST /system/user-project/add-project-users
  * DELETE /system/user-project/remove-project-user
- 新增 VO:UserProjectPageReqVO / UserProjectAddProjectUsersReqVO
- 权限点沿用 system:project:assign-user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:48:18 +08:00
lzh
db31462774 refactor(video): JT1078 模块未迁移时的软依赖降级收敛
- JT1078 相关两个 stub 接口 Ijt1078Service / Ijt1078PlayService
  目前没有 @Service 实现(等待从 WVP 后续迁移),直接 @Autowired
  会导致 video server 启动失败。
- GbChannelPlayServiceImpl:原本只声明了 jt1078PlayService 字段,
  从未在任何方法体里引用,属于死代码,直接删掉 import + field。
- MediaServiceImpl:保留引用(checkStreamFromJt 有真实调用),
  改成 @Autowired(required=false) 做软依赖;在 "1078" app 分支
  入口加 null guard,接口缺失时直接返回 false 拒绝请求;
  补注释说明两处处理不对称的原因,并留 TODO 指向 JT1078
  迁移完成后的收尾动作(去掉 required=false / 引入
  JtModuleProperties.enabled 开关统一管理)。
2026-04-23 15:16:50 +08:00
lzh
10ea5e5eee chore(video): ZLM 凭证挪出默认配置 + 连接池 keep-alive + 日志/队列开关
- application.yaml 里的 media.id/ip/http-port/secret/auto-config 改用
  ${ZLM_*:占位} 形式,secret 默认值 please-override-in-env,防止
  真实凭证写进默认 profile 被全环境继承;开发机实际值搬到
  application-local.yaml,继续支持 env var 覆盖。已泄露到 git 历史
  的 secret 需运维侧单独旋转。
- application-local.yaml druid 池:min-evictable-idle 从 10min 降到
  5min + 打开 keep-alive,配合 eviction 扫描主动 ping,解决远程
  MySQL 经 NAT/云防火墙 60~80s 静默断开导致
  Communications link failure / last packet received X ms ago。
- application.yaml 补两个 video 模块运维开关:
    · viewsh.access-log.exclude-paths 屏蔽 /index/hook/on_server_keepalive
      心跳刷屏,出错仍会 WARN;
    · video.sip-queue.enabled 管控上一笔 commit 引入的三个
      SIP 消息 QueueScheduler,默认开启,关闭后队列会在内存堆积
      需要运维兜底(注释已提醒慎用)。
2026-04-23 15:14:00 +08:00
lzh
42d53bb02d fix(video): 收紧 Redis Jackson default typing 白名单,修补反序列化攻击面
- 本模块通过 ZLM hook / GB28181 信令把大量外部输入写入 Redis,
  必须保证 default typing 的白名单足够紧。
- 新增 buildRedisObjectMapper:
    · 注册 JavaTimeModule 支持 LocalDateTime / LocalDate 等 Java 8
      时间类型,避免含 createTime 的对象序列化报错;
    · PropertyAccessor 从 ALL/ANY 收窄到 FIELD/ANY,配合 Lombok getter
      够用,同时降低经 setter 触发 gadget chain 的面;
    · BasicPolymorphicTypeValidator 的 allowIfSubType 原先是 Object.class
      (等价 LaissezFaireSubTypeValidator,Jackson 官方警告有 CVE 级
      反序列化风险,如 jackson-databind #2367 / #2996),改成按包名
      白名单:com.viewsh.* / com.alibaba.fastjson2.JSONObject|JSONArray
      / java.util|time|lang|math / 数组,严格收敛到业务真实存取范围。
- 两个 RedisTemplate Bean 都改用自构建的 ObjectMapper。
- 后续若遇到"新 DTO 反序列化不了",优先放进 com.viewsh 包下,
  不要回退到 allowIfSubType(Object.class)。
2026-04-23 15:09:04 +08:00
lzh
a58ab1928e refactor(video): SIP 高频消息队列消费拆独立 Scheduler + 修 Catalog 事务自调用
背景:
  - 原先 Alarm / Keepalive / Catalog 三个业务 Handler 自带 @Scheduled,
    @Scheduled 会让 Spring 对业务类生成 CGLIB 代理,
    与 @EventListener / @Async 等场景叠加时容易出现"找不到方法"/
    重复包装等怪异行为。
  - CatalogResponseMessageHandler.executeTaskQueue 上标 @Transactional
    会导致空队列 50ms 触发一次空事务,浪费连接池;与此同时
    this.saveData(...) 是自调用,saveData 上的 @Transactional
    又根本不生效,事务语义双重翻车。

本次改动:
  - 新增 CatalogResponseMessageQueueScheduler 无接口 @Component,
    @Scheduled(fixedDelay=50) 驱动业务 Handler 的 executeTaskQueue。
  - Alarm / Keepalive 两个 QueueScheduler 补 @ConditionalOnProperty
    (video.sip-queue.enabled, matchIfMissing=true),三个调度器统一
    开关;注释对齐,标明关闭后消息会在内存堆积、需运维兜底。
  - CatalogResponseMessageHandler:
      · 去掉 executeTaskQueue 上的 @Scheduled + @Transactional,
        入口处 taskQueue.isEmpty() 直接 return,不再开空事务;
      · @Autowired @Lazy 注入自身代理 self,把 this.saveData(...)
        改成 self.saveData(...),让 saveData 上的 @Transactional
        真正生效(MyBatis 落库 + 区域/分组批写回到同一事务)。
2026-04-23 15:07:44 +08:00
lzh
38681c39c1 refactor(video): ISIPProcessorObserver 接口补齐注册方法,processor 改依赖接口
- 原先 addRequestProcessor / addResponseProcessor 只在实现类
  SIPProcessorObserver 上,13 个处理器在 afterPropertiesSet 里
  @Autowired 实现类才能自注册,耦合到具体实现。
- 把两个 register 方法提到 ISIPProcessorObserver 接口上,
  13 个 processor 的注入类型改为 ISIPProcessorObserver,
  处理器侧不再感知具体实现,方便后续测试 mock / 多实现。
- 本次只改注入类型与接口签名,消息分发行为不变。
2026-04-23 15:05:00 +08:00
lzh
6b50254a96 feat(video): 适配多租户上下文透传 + 定时任务 TTL 装饰 + 生命周期收口
问题背景:
  - WVP 原生代码的 SIP / @Scheduled / Hook EventListener 都跑在没有
    TenantContextHolder / ProjectContextHolder 的线程上,接入多租户
    框架后会漏 tenant_id / project_id 过滤或抛 NPE。
  - 框架默认 @EnableAsync 走 JDK 动态代理,但 WVP 代码大量"按具体类
    注入 / 按具体类查方法",JDK 代理下会 cast/查方法失败。

本次改动:
  - VideoServerApplication 排除框架 ViewshAsyncAutoConfiguration,
    由 ThreadPoolTaskConfig 本地 @EnableAsync(proxyTargetClass=true)
    + TtlRunnable 装饰器接管(从框架抄一份 BeanPostProcessor)。
  - DynamicTask 的 scheduleAtFixedRate / schedule 入口都用 TtlRunnable
    包一层,把调用线程的 TTL 快照透传到调度线程;新增 @PreDestroy
    在容器关停时取消 future、shutdown scheduler、记录耗时,
    避免关停阶段调度线程继续借 DataSource 触发噪音日志。
  - 新增 VideoContextUtils.executeIgnoreTenantAndProject,
    统一封装 TenantUtils.executeIgnore(ProjectUtils.executeIgnore(…))
    的嵌套模板,覆盖启动初始化 / 定时任务 / Hook 事件 / Redis 消息
    四种场景共 8 个调用点。
  - MobilePositionServiceImpl 拆出 @Transactional persistPositions,
    通过 @Lazy self 代理调用,避免外层方法 this 自调用让事务失效;
    外层只做 Redis 拉队列 + 空队列短路,不再开空事务。
  - StreamProxyServiceImpl / StreamPushServiceImpl 的 @EventListener
    在业务体内包裹 executeIgnoreTenantAndProject,保证 ZLM Hook
    到来时仍能跨租户落库。
2026-04-23 15:03:26 +08:00
lzh
0526322fa8 refactor(video): Controller 下沉到 controller/admin 包对齐框架 /admin-api 前缀
- 原先散落在 gb28181/controller/、streamProxy/controller/、aiot/controller/、
  vmanager/**/、streamPush/controller/ 下的 Controller 统一移到各自
  controller/admin/ 子包,对齐框架 WebProperties 的自动前缀规则
  (根 CLAUDE.md「Controller 路径规范」:admin 包自动拼 /admin-api)。
- SecurityConfiguration 的 permitAll 路径同步补 /admin-api 前缀,
  覆盖 /video/device/query/snap/** 与 /video/sse/** / /video/emit,
  避免路径匹配不上导致匿名请求被拦截。
- 本次仅变更 package 声明与安全路径,controller 内部逻辑保持不变。
2026-04-23 15:00:57 +08:00
lzh
54250f2f5a feat(video): video_media_server 补齐 deleted 列对齐 BaseDO
- DO 继承 BaseDO(含 @TableLogic),但建表脚本初版漏了 deleted 列,
  MyBatis Plus 自动生成的 UPDATE/SELECT 语句会报
  "Unknown column 'deleted' in 'where clause'"。
- 同步更新 video.sql 中的建表脚本与段落注释,增量脚本放到
  sql/mysql/migrations/2026-04-22_video_media_server_deleted.sql,
  已部署库手动执行即可。
2026-04-23 14:59:31 +08:00
lzh
88cab42a9c feat(system): 用户-项目绑定管理 API + 顶栏项目下拉修正
- 新增 UserProjectService/ServiceImpl/Controller:给用户分配项目、给项目分配成员
  幂等覆盖写入(diff 出增删),参考 PermissionServiceImpl.assignUserRole 模式
- 自踢守卫:禁止用户把自己从当前正在访问的项目中移除
- 超管守卫:assignProjectUsers 拒绝移除持有超管角色的用户(用 RoleService.hasAnySuperAdmin 判别,非 userId==1)
- ProjectController.simple-list 改为只返回"当前用户授权且启用"的项目(修 bug:原返回整租户启用项目,会让顶栏下拉看到无权访问的项目)
- 新增 /system/project/all-simple-list:管理员分配场景的全量项目下拉,权限复用 system:project:query
- ProjectService.deleteProject 加 @Transactional,同事务内级联软删 system_user_project
- 新增两条菜单权限种子 SQL,parent_id 子查询动态定位:
  * system:user:assign-project
  * system:project:assign-user
- 新增错误码 USER_PROJECT_CANNOT_REMOVE_SELF_CURRENT / USER_PROJECT_CANNOT_REMOVE_SUPER_ADMIN

设计文档:docs/design/2026-04-23-user-project-binding.md(在前端仓库)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:48:57 +08:00
lzh
b91a366f51 Merge branch 'master' into feat/multi-tenant 2026-04-22 18:23:50 +08:00
lzh
d6f625151c Merge pull request 'fix(ops): 修复工牌绑定/手动派单/审计日志三处缺陷' (#2) from fix/badge-online-and-manual-dispatch into master
Some checks failed
Java CI with Maven / build (11) (push) Has been cancelled
Java CI with Maven / build (17) (push) Has been cancelled
Java CI with Maven / build (8) (push) Has been cancelled
Reviewed-on: #2
2026-04-22 18:10:33 +08:00
lzh
3cfd342318 fix(ops): BADGE 绑定/解绑后即时同步工牌缓存
问题:
1. 设备先上线、后绑定为 BADGE 时,"可分配工牌"列表迟迟不出现该设备;
2. 已绑定区域的设备收不到工单(被分派策略过滤掉)。

根因:实时上线路径 BadgeDeviceStatusEventHandler.onMessage 在写
ops:badge:status:{deviceId} Redis 缓存前,先 isBadgeDevice() 校验
ops_area_device_relation 是否存在 BADGE 关系。设备如果在绑定前就上线,
事件被丢弃;建立关系后又没有任何机制回填 Redis,得等 5/30 分钟的
BadgeDeviceStatusSyncJob 对账才会被发现(线上日志可见 deviceId=58 在
绑定后 30 分钟才被对账修正:reason=定时对账修正-上线)。
解绑同样有反向缺口:关系记录删了但 Redis 缓存得等 24h TTL 自然过期,
期间 listAvailableBadges 仍可能返回该设备。

修复:在 ops-biz 引入 AreaDeviceBoundEvent / AreaDeviceUnboundEvent,
bindDevice / unbindDevice 成功后通过 ApplicationEventPublisher 发布;
environment-biz 新增 BadgeAreaBoundEventListener 仅订阅 BADGE 类型,
使用 @TransactionalEventListener(AFTER_COMMIT) + @Async 确保事务提交后
异步执行不阻塞接口:
  - 绑定:单次调 IotDeviceQueryApi.getDevice 取齐 state + nickname +
          deviceName,根据状态写 Redis(IDLE/OFFLINE);
  - 解绑:直接调 BadgeDeviceStatusService.deleteBadgeStatus 清理 Redis。

依赖方向遵循 environment-biz → ops-biz;ops-biz 不反向依赖条线模块,
通过事件解耦,与现有 OrderStateChangedEvent 模式一致。

测试:
- AreaDeviceRelationServiceTest 补 iotDeviceQueryApi mock 让原本因缺
  mock 而 RED 的 testBindDevice_TrafficCounter_Success 转绿;
- 新增对 bound / unbound 事件 publishEvent 调用的 verify。
- 全套 10/10 通过。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:04:47 +08:00
lzh
6c4153fe23 fix(ops): 手动派单提前写执行人字段,修复按键报"没有工单"
问题:管理员手动派单后,工牌按键查询持续返回"没有工单",TTS 循环播报"工单
来啦"但用户无法响应(线上日志可见 deviceId=58 一段时间内连续 8 次按键查询
全部命中 CleanOrderAuditEventHandler.handleQueryEvent 的"没有工单"分支)。

根因:ManualOrderActionFacade.dispatch() 原顺序是
  1. transition() —— 事务内同步发布 OrderStateChangedEvent,
     BadgeDeviceStatusEventListener.onOrderStateChanged 重新 selectById 拿
     assigneeId 决定是否写 Redis 工单关联;
  2. update assigneeId / assigneeName。

第 1 步事件触发时 assigneeId 仍是 null,listener 走"工单未关联设备,跳过
处理"分支,Redis ops:badge:status:{deviceId} 的 currentOpsOrderId 永远不
会写入;同时主表 assigneeDeviceId 也始终为 null,
CleanOrderAuditEventHandler.handleQueryEvent 用
"WHERE assignee_device_id=?" 查工单永远落空 → "没有工单"。

修复:把执行人字段更新前置到 transition() 之前,并补 setAssigneeDeviceId
(与 OrderLifecycleManagerImpl.dispatch() 自动派单路径一致)。
事件 listener 此时 selectById 拿得到 deviceId,正常落 Redis;audit
查询也命中,按键路径恢复。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:04:10 +08:00
lzh
8c664a479d refactor(ops): 状态转换成功不再镜像写 bus_log
问题:每次 transition() 成功 commit 后,OrderTransitionAuditListener 都会
向 bus_log 写一条"状态转换成功: X -> Y"的镜像记录。该信息已由各条线
EventListener(CleanOrderEventListener 的 ORDER_DISPATCHED 等)和
ops_order_event 表完整覆盖,bus_log 里这条镜像形成噪声且与业务日志重复,
线上一次工单流转能产出 4-5 条同义日志,运维抓异常时被淹没。

改动:onAfterCommit 在 event.isSuccess()=true 时直接 return;
失败 / 派发被拒(DISPATCH_REJECTED)/ 回滚三类异常路径继续写,
保留运维真正关心的"为什么失败"审计闭环。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:03:51 +08:00
lzh
b3d76ad00c fix(video): 修复 CommonGBChannel 子类 @TableId 冲突与主键列映射
问题:
1. CommonGBChannel.gbId 的 @TableId 按驼峰转 snake_case 映射到 gb_id 列,
   但实际所有相关表的主键列都是 id
2. StreamProxy/StreamPush 子类自己声明 @TableId(id),与父类 gbId 的
   @TableId 同时扫描到 MyBatis-Plus 会报 "@TableId can't more than one"
3. DeviceChannel/PlatformChannel 没自己的 @TableId,运行时会用父类 gbId
   作为主键查 gb_id 列 → Unknown column 运行时错误

修复:
- CommonGBChannel.gbId 的 @TableId 加 value="id" 显式映射到 id 列
  保证 CommonGBChannelMapper 操作 video_common_gb_channel 时主键正确
- 4 个子类(StreamProxy/StreamPush/DeviceChannel/PlatformChannel)均
  shadow 父类 gbId 字段:
    @TableField(exist = false)
    @Getter(AccessLevel.NONE)
    @Setter(AccessLevel.NONE)
    private Long gbId;
  让 MyBatis-Plus 反射扫描时只看到子类 gbId(标记 exist=false 跳过),
  父类 @TableId 不参与扫描;Lombok 禁用子类 getter/setter,所有业务
  代码的 setGbId/getGbId 继续走父类访问器,字段存储在父类
- DeviceChannel 和 PlatformChannel 给自己的 id 字段加 @TableId(IdType.AUTO)
  显式声明各子表主键

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:40:13 +08:00
lzh
d876d0387a refactor(video): @Scheduled 转 xxl-job,对齐 ops 模块定时任务约定
WVP 原生单体采用 @Scheduled 做周期任务,位于 ServiceImpl 上的任务
Spring 用 JDK 代理无法匹配到接口方法,导致启动失败。按 ops 模块
约定(QueueSyncJob 模板),把 6 个 ServiceImpl 的周期任务转为
独立 @XxlJob Job 类;2 个 IMessageHandler 的高频轮询拆为独立
无接口 @Component。

新建 6 个 Job 类(framework/job/):
- InviteStreamCleanupJob (10s)    清理 Redis 错误 Invite 数据
- DeviceSubscribeLostCheckJob (10s) 设备订阅丢失检查
- DeviceStatusLostCheckJob (30s)   设备状态丢失检查
- PlatformStatusLostCheckJob (20s) 平台注册状态检查
- PlatformAutoRegisterJob (2s)     级联平台自动注册监听
- AiEdgeDeviceOfflineCheckJob (90s) AI 边缘设备离线标记

接口变更(让 Job 类通过 JDK 代理正常调用):
- IInviteStreamService 新增 cleanInvalidInviteCache()
- IDeviceService 新增 lostCheckForSubscribe() / lostCheckForStatus()
- IPlatformService 新增 statusLostCheck() / cascadePlatformAutoRegister()
- PlatformServiceImpl.execute() 重命名为 cascadePlatformAutoRegister()

ServiceImpl 调整:
- InviteStream/Device/Platform/AiEdgeDevice ServiceImpl 删除
  @Scheduled 注解,方法体保留
- 清理 @Scheduled / TimeUnit 无用 import

新建 2 个高频 Scheduler @Component(保持 100-200ms 毫秒级轮询):
- AlarmNotifyMessageQueueScheduler (200ms)
- KeepaliveNotifyMessageQueueScheduler (100ms)
这两个消息处理器是 SIP 协议栈内部机制,不适合走 xxl-job 中心调度,
拆到独立无接口 Component 后 Spring 自动走 CGLIB,代理正常。
原 MessageHandler 删除 @Scheduled 注解,executeTaskQueue() 保留
为 public 供新 Scheduler 调用。

配置:本地 xxl.job.enabled=false 已配置(与 ops 对齐)

编译通过(mvn compile BUILD SUCCESS)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:29:51 +08:00
lzh
4f0c8f7162 fix(video): Mapper 移除多数据库方言分支(仅保留 MySQL)
WVP 原支持 MySQL / H2 / KingBase / PostgreSQL,使用 databaseId 属性
区分 SQL 方言,但本项目 MyBatis 未配置 databaseIdProvider,运行时
databaseId 为 null 导致 "Could not find a statement annotation that
correspond a current database" 启动失败。

改动:
- CommonGBChannelMapper.queryListInCircle / queryListInPolygon:
  仅保留 queryListInCircleForMysql / queryListInPolygonForMysql
  对应的 @SelectProvider,删除 h2/kingbase/postgresql 变体
- GroupMapper.updateParentId / updateParentIdWithBusinessGroup /
  fixParentId:仅保留 MySQL JOIN 语法,删除 3 种方言 UPDATE
- RegionMapper.updateParentId:同上

项目固定使用 MySQL,这些方言变体在本仓库永远不会命中,删除后启动
不再需要依赖 databaseIdProvider 配置。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:00:29 +08:00
lzh
3a3f7b78d4 refactor(video): 预置 AI 算法改为 SQL 种子数据,移除 @PostConstruct 初始化
原 AiAlgorithmServiceImpl.initPresetAlgorithms() 在 @PostConstruct
里插入 4 条预置算法,带来两个问题:
1. 启动时无登录上下文,MP 自动填充 creator 失效 → 启动失败
2. 算法清单硬编码在 Java 代码,迭代要重编译发布

改为 video.sql 种子数据管理:
- video.sql 的预置算法 INSERT 扩展为 4 条(leave_post / intrusion /
  illegal_parking / vehicle_congestion),参数 schema 与边缘端对齐
- 使用 ON DUPLICATE KEY UPDATE 保证幂等:新库初始化 + 存量库升级
  都走同一条语句,param_schema / description 会被自动校正
- 保留用户侧可修改的字段(is_active / global_params)不被覆盖

代码层:
- 删除 initPresetAlgorithms() 方法与 PRESET_ALGORITHMS 静态 Map
- 删除 SYSTEM_USER 常量
- 删除 @PostConstruct / HashMap 相关 import
- 保留 syncFromEdge() 作为边缘端主动同步的运行时入口

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:54:39 +08:00
lzh
1ac72b23c5 fix(video): AI 算法预置数据 @PostConstruct 插入时显式设置 creator/updater
启动时 initPresetAlgorithms() 在 @PostConstruct 执行,此时无登录上下文:
- DefaultDBFieldHandler.insertFill 在 getLoginUserId()==null 时不填充
  creator/updater
- SQL video_ai_algorithm.creator NOT NULL 约束触发
  "Column 'creator' cannot be null" 启动失败

手动设置 creator/updater = "1"(系统用户)作为系统级初始化的占位,
同时 update 分支也显式设置 updater 避免同类问题。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:48:52 +08:00
lzh
42cb3d9d57 chore(video): 数据源改用统一 aiot-platform 库,与 ops/iot 对齐
原 video 模块 application-local.yaml 默认 MYSQL_DATABASE 是 aiot-video
(设计初衷是 WVP 独立库)。运维按单库部署,26 张 video_* 业务表
已执行到 aiot-platform 库,因此默认值改为 aiot-platform,
避免启动时 "Unknown database 'aiot-video'" 报错。

同步 master / slave 两个数据源默认值。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:33:49 +08:00
lzh
8ae7b1a823 docs(video): 遗留项防御性加固 — GB2022 扩展字段 exist=false + Mapper 继承警告
基于遗留项调研结论(三项均无当前运行时风险,真正价值是防御未来
误用 BaseMapperX 高层方法),执行最小化防御性修复:

PlatformChannel 的 20 个 GB2022 扩展字段加 @TableField(exist=false):
- customSecurityLevelCode / customPhotoelectricImagingTyp /
  customCapturePositionType / customStreamNumberList /
  customSsvcRatioSupportList / customMobileDeviceType /
  customHorizontalFieldAngle / customVerticalFieldAngle /
  customMaxViewDistance / customGrassrootsCode / customPoType /
  customPoCommonName / customMac / customFunctionType /
  customEncodeType / customInstallTime / customManagementUnit /
  customContactInfo / customRecordSaveDays / customIndustrialClassification
这些是 WVP 预占位字段,SQL 尚未建列,加 exist=false 消除
BaseMapperX 反射写入时 "Unknown column" 的潜在风险,
待业务方决定是否推进 GB2022 自定义属性持久化再补 SQL 列。

StreamProxyMapper / StreamPushMapper 加接口级 Javadoc 警告:
- StreamProxy/StreamPush 继承 CommonGBChannel 带入 40+ 个 gb_* 字段,
  但 video_stream_proxy / video_stream_push 表不含这些列
  (gb 通道信息由 GbChannelService 写入 video_common_gb_channel)
- 警告开发者严禁使用 BaseMapperX 的 insert/updateById/selectByMap
  自动映射方法,写入必须走显式 SQL 方法(add/update/addAll)
- 读取 selectById/selectList 可用(SELECT 时 gb_* 字段返回 null 不报错)

未改动(零当前风险 + 改动成本高):
- SIP 协议时间字段(register_time 等 varchar):Mapper 全显式 SQL
  用 #{} 绑定 String,与 varchar 列自洽;DateUtil.getNow() 返回
  "yyyy-MM-dd HH:mm:ss" 格式字符串,时间比较查询的字符串排序
  与 datetime 排序结果一致;改造涉及 72+ 处调用链
- StreamProxy/StreamPush 继承架构:改组合式重构代价大,
  通过 Javadoc 警告防御未来误用即可

编译通过(mvn compile BUILD SUCCESS)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:43:23 +08:00
lzh
0ca1adf2a4 refactor(video): P2 应用层时间字段 varchar → datetime / String → LocalDateTime
SQL 类型优化(应用层生成的业务时间改为标准 datetime):
- video_ai_edge_device: last_heartbeat / updated_at
- video_ai_config_log: updated_at
- video_ai_config_snapshot: created_at
- video_ai_algorithm: sync_time
- video_ai_alert: received_at
- video_media_server: last_keepalive_time

DO 字段 String → LocalDateTime 对齐:AiEdgeDevice / AiConfigLog /
AiConfigSnapshot / AiAlgorithm / AiAlert / MediaServer

调用点适配:
- Service 层 new Date() 字符串格式化赋值统一改为 LocalDateTime.now()
- AiAlertController.edgeReport / MqttService.handleAlert 新增
  parseEventTime / parseTimestamp 私有方法,外部字符串防御性解析

保留 varchar(本批次暂不动,SIP 协议原生字符串,改动涉及 72+ 处
Mapper 与 SIP 处理器,单独迭代):
- video_device.register_time / keepalive_time
- video_device_channel.end_time / gps_time
- video_device_mobile_position.time
- video_stream_push.push_time

编译通过(mvn compile BUILD SUCCESS)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:32:23 +08:00
lzh
64dcdd8d4c refactor(video): P1 类型规范化 — varchar 数值转 int / Integer 布尔转 Boolean
SQL 类型修正(数值语义列不再用 varchar):
- video_platform: device_port / expires / keep_timeout varchar(50) → int
- video_platform_channel: custom_cert_num / custom_end_time varchar(50) → int

DO 布尔语义字段 Integer → Boolean(SQL 早已是 tinyint(1),DO 对齐语义):
- AiAlgorithm.isActive
- AiRoi.enabled
- AiRoiAlgoBind.enabled
同步修复 ~20 处调用点的布尔判断,setEnabled(1) → setEnabled(true),
getEnabled() == 1 → Boolean.TRUE.equals(getEnabled())

MediaServer.hookAliveInterval Float → Integer(SQL 是 int,Float 精度丢失)
同步修复 MediaConfig、ABL/ZLM 两个 StatusManager 的 10F → 10 字面量

编译通过(mvn compile BUILD SUCCESS)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:13:56 +08:00
lzh
1698c71d84 fix(video): P0 修复 @TableName 映射错误、字段命名、补缺列与 exist=false
@TableName 修正:
- CommonGBChannel: "video_device_channel" → "video_common_gb_channel"
- DeviceChannel: 显式加 @TableName("video_device_channel")
- PlatformChannel: 补充 @TableName("video_platform_channel")
  避免因继承 CommonGBChannel 而写入错误表

字段命名规范化(修复驼峰转 snake_case 错位):
- CommonGBChannel.recordPLan → recordPlanId(对应 record_plan_id)
- MediaServer.httpSSlPort / flvSSLPort / wsFlvSSLPort
  → httpSslPort / flvSslPort / wsFlvSslPort
  (原命名 L 大写/双大写导致转 snake 变 http_s_sl_port)
- 同步更新 Mapper SQL 中的 #{xxx} 引用和 ZLM/ABL 节点服务调用点

@TableField(exist=false) 标注(JOIN 显示字段/业务计算值/内存对象):
- DeviceChannel: parentDeviceId、parentName、ptzTypeText
- DeviceAlarm: deviceName、alarmPriorityDescription、alarmMethodDescription、
  alarmTypeDescription
- AiAlert: cameraName、roiName
- Device: sipTransactionInfo
- MobilePosition: channelDeviceId
- StreamPush: uniqueKey

补齐 SQL 缺失列(需持久化的业务状态字段):
- video_device: channel_count
- video_platform: channel_count / catalog_subscribe / alarm_subscribe
  / mobile_position_subscribe
- video_media_server: status / last_keepalive_time
- video_cloud_record: reserve
- video_device_mobile_position.channel_id: varchar(50) → bigint
  (DO 字段是 Long,作为外键引用 video_device_channel.id)

补齐 DO 缺失字段:
- AiAlgorithm: 新增 globalParams(对应 SQL global_params)
- StreamPush: 新增 status(对应 SQL status)

编译通过(mvn compile BUILD SUCCESS)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:07:47 +08:00
lzh
43becf998c refactor(video): projectId 两级隔离适配 + SQL 融合到 video.sql
SQL 融合:
- sql/mysql/video.sql 作为最终主脚本(26 张表),整合用户改造版表前缀 video_
  与 cherry-pick 版的框架字段(tenant_id/creator/updater/deleted/datetime)
- 新增 video_ai_camera_snapshot(AI 抓拍)、video_common_gb_channel(国标通道抽象)
- 删除旧的 aiot-video.sql(被 video.sql 替代)
- 17 张业务表 + 6 张 AI 业务表加 project_id 列(项目级隔离)
- 2 张字典表(video_ai_algorithm/video_ai_algo_template)仅租户级
- video_media_server 全局共享,无多租户字段

代码改造:
- 21 个 DO 的 @TableName 从 wvp_* 改为 video_*
- 16 个业务 DO 改继承 ProjectBaseDO(StreamProxy/StreamPush 通过
  CommonGBChannel 自动获得),字典 DO 保留 TenantBaseDO,
  MediaServer 保留 BaseDO
- 28 个 Mapper/Provider 的 SQL 表名全部更新为 video_*
- application.yaml 新增 tenant.ignore-project-tables 配置,
  列出 video_media_server/video_ai_algorithm/video_ai_algo_template
  不参与项目隔离
- 编译通过(mvn compile BUILD SUCCESS)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:10:27 +08:00
lzh
fe5bbbe8c6 refactor(video): 清理 Security/User stub,替换为框架 SecurityFrameworkUtils
- 删除 SecurityUtils/LoginUser/JwtUtils 3 个空壳 stub
- 删除 UserMapper/RoleMapper/UserApiKeyMapper 3 个废弃 Mapper 及 DTO
- 删除 IUserService/IRoleService/IUserApiKeyService 及实现类
- MediaController 改用 SecurityFrameworkUtils.getLoginUserId()
- 21 个 Controller 的 JwtUtils.HEADER 替换为 "Authorization" 字面量

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:20 +08:00
lzh
3c752aeb89 refactor(video): PageHelper → PageResult 分页迁移,删除 shim 兼容类
- 54 个文件从 PageHelper.startPage() + PageInfo 迁移到 MyBatis Plus Page + PageResult
- 复杂 @Select 查询加 IPage 参数实现自动分页
- 删除 PageHelper/PageInfo/StringUtil 3 个 shim 文件

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:20 +08:00
lzh
f48c1846fb refactor(video): Media/Stream 域 ORM 改造 + 全域硬删除转逻辑删除
- MediaServer 继承 BaseDO(共享表无 tenant_id)
- StreamProxy/StreamPush/CloudRecord/RecordPlan DO 改造
- 5 个 Mapper 继承 BaseMapperX
- 32 处 @Delete 硬删除转为逻辑删除(default 方法或 @Update SET deleted=1)
- Service/Controller/RPC 适配 int→Long 类型变化

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:19 +08:00
lzh
0b0c264dca refactor(video): GB28181 域 ORM 改造 — DO 继承 TenantBaseDO + Mapper 继承 BaseMapperX
- Device/Platform/Channel/Group/Region/Alarm/MobilePosition 等 DO 改造
- 9 个 Mapper 继承 BaseMapperX,简单 CRUD 改为 default 方法
- 复杂多表 JOIN/动态 SQL 保留 @Select 注解
- Service/Controller 适配 int→Long 类型变化

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:17 +08:00
lzh
0c061ad74c refactor(video): AI 域 ORM 改造 — DO 继承 TenantBaseDO + Mapper 继承 BaseMapperX
- 8 个 AI DO 加 @TableName、继承 TenantBaseDO、主键改 Long
- 9 个 AI Mapper 继承 BaseMapperX,简单 CRUD 改为 default 方法
- Service/Controller 适配类型变化(Integer→Long、删除手动 setCreateTime)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:15 +08:00
lzh
a604471e6f feat(video): Phase 5-9 编译修复、Controller 路径、数据库 SQL
Phase 5: 全局编译修复 — Security 引用清理、JT1078 引用移除
         PageInfo shim 兼容类(后续迁移到 PageResult)
         web/custom 和 web/gb28181 补充迁移
Phase 6: SecurityConfiguration 更新放行 Hook/SSE 路径
Phase 7: 32 个 Controller 路径 /api/ → /video/
Phase 8: aiot-video 数据库 SQL(25 张表,含多租户 tenant_id + 逻辑删除 deleted)
Phase 9: mvn compile BUILD SUCCESS

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:14 +08:00
lzh
d2c82d52c9 feat(video): Phase 0-4 WVP 代码搬迁、依赖、Redis 隔离、多租户配置
Phase 0: 前置检查完成(Java 21 语法无风险)
Phase 1: 574 个 Java 文件搬迁,包名替换 com.genersoft.iot.vmp → com.viewsh.module.video
Phase 2: pom.xml 添加 JAIN SIP/dom4j/okhttp/bouncycastle/fastjson2 等依赖
         application.yaml 添加 SIP/Media/UserSettings 配置段
Phase 3: RedisTemplate Bean 隔离(videoRedisTemplate),30+ 文件加 @Qualifier
Phase 4: 多租户配置(ignore-tables: wvp_media_server)

待 Phase 5: ORM 改造 + 编译修复

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:07 +08:00
lzh
50a826f157 docs(video): WVP-Platform 迁移提案 (proposal + tasks + design)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:02 +08:00
lzh
948d2c6a41 feat(video): 新建 viewsh-module-video 服务模块骨架
新增视频管理模块,用于后续迁移 WVP-Platform(GB28181 视频监控平台)。
- viewsh-module-video-api: 契约层(Feign RPC 接口、枚举、错误码)
- viewsh-module-video-server: 业务层(端口 48093)
- 网关路由: video-admin-api / video-app-api
- SecurityConfiguration: 放行 Swagger/Actuator/Druid/RPC

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:02 +08:00
lzh
65ad3f35e5 Merge branch 'master' into feat/multi-tenant
吸收 master 今日 9 个工单链路修复:
- autoDispatchNext/dispatch 空闲兜底 + FOR UPDATE 并发防护
- 状态转换审计闭环(AFTER_COMMIT/AFTER_ROLLBACK)
- 队列楼层权重强优先 + 三级 baseline 兜底 + N+1 优化
- 工牌 nickname 回填
- CleanOrderAutoCancelJob 超时工单自动取消
2026-04-20 16:04:46 +08:00
lzh
c78759fd52 feat(ops): 新增保洁工单超时自动取消 Job + 集成测试
Some checks failed
Java CI with Maven / build (11) (push) Has been cancelled
Java CI with Maven / build (17) (push) Has been cancelled
Java CI with Maven / build (8) (push) Has been cancelled
背景:保洁工单偶尔因设备离线/信标丢失导致卡在非终态(如 PENDING 超 12h 没派,
DISPATCHED 超 12h 没确认),靠人工清理成本高。补一个每小时跑的 XXL-Job 扫描关单。

实现:
- CleanOrderAutoCancelJob.scanAndCancel:
  * 查询 update_time 距今超 timeoutHours(默认 12h)的 CLEAN 工单
  * 状态白名单 = PENDING/QUEUED/DISPATCHED/CONFIRMED/ARRIVED,**排除 PAUSED**
    (PAUSED 是 P0 打断的产物,应由 resumeInterruptedOrder 走状态机恢复,
    此处若把它 CANCEL,会破坏 P0 完成后的 resume 链路)
  * 调用 orderLifecycleManager.cancelOrder 走完整责任链,事件监听器负责
    TTS 停播/设备关联回收/审计日志
  * cancel 前再 selectById 做乐观校验:若 update_time 已刷新或状态已变
    (COMPLETED/CANCELLED/PAUSED),跳过;避免候选装内存到实际 cancel
    之间用户刚触达的工单被误杀
  * 单单独立 try/catch 隔离,单条失败不断批
  * batchSize 限流(默认 200),事件风暴防护
- application.yaml 补默认配置:viewsh.ops.clean.auto-cancel.{timeout-hours, batch-size}
- CleanOrderAutoCancelJobTest 覆盖 6 条不变量:
  无候选零计数、全成功、部分失败不中断、乐观锁跳过 stale、终态跳过、PAUSED 跳过

XXL-Job 配置建议:
- JobHandler: cleanOrderAutoCancelJob
- Cron: 0 17 * * * ? (每小时 :17,避开整点尖峰)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:21:33 +08:00
lzh
ba6f94a279 fix(ops): review 复盘补齐 FOR UPDATE 覆盖面 + 清理注解/日志死角
今日 review 发现 Bug #2 的 FOR UPDATE 防线只装在 dispatch() 上,但同文件另有两条
路径绕过它:

1. P1 — DispatchEngineImpl.autoDispatchNext 调 transition() 派发队列下一单,
   不走 FOR UPDATE。idle 校验和 transition 之间存在竞争窗口,能再次让同 assignee
   挂两条 DISPATCHED。改调 dispatch(),天然继承串行化。
   补测 autoDispatchNext_whenDispatchingFromQueue_shouldGoThroughDispatchNotTransition
   锁定该不变量。

2. P2 — OrderLifecycleManagerImpl.resumeOrder/resumeInterruptedOrder 同样走
   transition(),P0 恢复与并发派发竞争时可能产生两条 DISPATCHED。改为先
   selectById 取 assigneeId,改调 dispatch() 让同一检查生效。

顺手清理 3 个误导:

- DispatchEngineImpl.executePushAndEnqueue 原先忽略内部 dispatch 的返回值,
  并发场景下会输出假的“已推送等待任务”日志误导运维,改为按 result.isSuccess()
  分支打印。
- OrderTransitionAuditListener.writeRollbackAudit 的 @Transactional(REQUIRES_NEW)
  是死注解(由 onAfterRollback 自调用,Spring 代理无法拦截;且 AFTER_ROLLBACK
  本就无事务),移除并更新 Javadoc 说明实际行为。
- OrderQueueServiceEnhanced.triggerQueueRebuildAfterCommit 的自调用绕过
  @Transactional 是设计意图(最终一致即可),补 Javadoc 解释事务边界,
  避免后续误判为 bug。

测试:ops-biz 56 个相关用例全部通过,含新增的 P1 锁定测试。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:51:32 +08:00
lzh
9f3ca9c6f2 test(ops): 补齐工单链路 5 个修复点的集成测试
与 4d85659…a5f916c 的 5 次修复对齐,用 Mockito 风格覆盖状态链路关键分支:
- DispatchEngineIdleCheckTest:autoDispatchNext 空闲兜底 + executeDispatch
  MySQL 活跃态降级(Bug #1/#4),ENQUEUE_ONLY 路径不触发兜底查询避免开销浪费
- DispatchEngineConflictFallbackTest:FOR UPDATE 冲突分支(Bug #2),
  PENDING → 降级入队、QUEUED → 保持排队、其他错误码 → 硬失败
- OrderTransitionAuditListenerTest:审计闭环(Bug #7),AFTER_COMMIT 成功/WARN/ERROR
  分支 + AFTER_ROLLBACK 强制视为失败 + 7 种目标状态映射
- QueueScoreCalculatorEnhancedTest:楼层权重 G+B,锁死"FLOOR×10 > AGING×240"
  不变量,验证 base/target 任一 null → score=0,移除旧 +600 罚分后语义对称

22 个新测试全部通过;模块内 115/117 测试通过,2 个 pre-existing 失败
(VspNotifyClient/AreaDeviceRelation) 依赖外部服务,与本次改动无关。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:24:07 +08:00
lzh
323ddf27fb fix(ops): 对账回填工牌 nickname,修复重启后派单人名降级为 deviceCode
根因:BadgeDeviceStatusSyncJob 硬编码 nickname=null,依赖 Redis 已有值。
重启后若 ops:badge:device:{deviceId} 的 nickname 丢失(TTL/清理/首次写入),
BadgeDeviceAreaAssignStrategy 会降级用 deviceCode,导致 assigneeName 变成 "43607737587"。

- SyncJob 注入 IotDeviceQueryApi,批量拉 IotDeviceSimpleRespDTO.nickname 做回填
- 状态一致但 Redis 缺 nickname 时也补写一次,覆盖最常见的重启路径
- AreaAssignStrategy 降级兜底改为 "工牌-尾号",避免再把裸 deviceCode 当人名暴露

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:50:00 +08:00
lzh
a5f916c62a fix(ops): 队列楼层权重修复——强楼层优先 + 闭环基准兜底 + N+1 优化
问题:楼层差在分数公式中本该主导同优先级排序,但有四个缺陷导致效果不稳:

1. 有 base 无 target 时给 +600 罚分,无 base 时则全免罚——同一工单在
   保洁员忙/闲时排序不单调(B)。
2. 基准楼层只在 user 有 PROCESSING 时生效,空闲时完全无楼层信号(A)。
3. enqueue 瞬间 score 不含楼层,要等下一轮 rebuild 才补上(H)。
4. aging 上限 720 > floorDiff 上限 600,等满 4 小时可反超同优先级 10 层差
   任务,削弱"强楼层优先"语义(G)。
5. rebuild 内 for 循环对每条 WAITING 单独 selectById(order)+selectById(area),
   N+1 问题(F)。

修复:

1. QueueScoreCalculator(B + G)
   - FLOOR_WEIGHT 60 → 100:上限 1000 > aging 上限 720,4 小时老化不再反超
     同优先级的近楼层任务。
   - 删除"有 base 无 target +600"分支:任一侧缺失即 score=0,语义对称。

2. OpsOrderMapper.selectLatestCompletedAreaIdByAssignee(A 二级兜底)
   查最近 24h 内已完成工单的 area,用来推断空闲保洁员的物理位置。
   超过 24h 视为跨班次、轨迹失效。

3. OrderQueueServiceEnhanced.resolveBaselineAreaId(A 三级兜底)
   PROCESSING.area → 最近 24h COMPLETED.area → 调用方传的 fallbackAreaId。

4. OrderQueueServiceEnhanced.enqueue(H)
   事务提交后 triggerQueueRebuildAfterCommit(userId, null),新入队工单
   立即按楼层差参与排序,不依赖下一次 autoDispatchNext 触发。

5. OrderQueueServiceEnhanced.rebuildWaitingTasksByUserId(F)
   批量 selectBatchIds(orders) + selectBatchIds(areas),100 条 WAITING
   从 200 次 SELECT 降到 2 次。

权重直观对比(P2=priority×1500=3000):
             旧分数         新分数
同层刚入队    3000          3000
差5层刚入队   3000+300=3300 3000+500=3500
差5层等2小时  3000+300-360=2940 3000+500-360=3140
同层等4小时   3000+0-720=2280   3000+0-720=2280

新权重下"差5层等2小时"仍大于"同层刚入队",楼层稳定主导排序;
极端 aging(>4h)仍能让同层任务被近楼层任务压制优先执行。

测试:QueueScoreCalculatorTest(3)、OrderQueueServiceEnhancedTest(1,
已按 selectBatchIds + selectActiveListByUserId 更新 mock)、QueueSyncServiceTest
全绿。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:32:24 +08:00
lzh
3e248fee8c fix(ops): 补齐状态转换审计闭环,回滚场景也留痕到 bus_log
问题:ops_order_event 在主事务内写,事务 rollback 则整段记录消失;
若状态机转换抛异常或并发冲突被拒,线上只有控制台日志而无数据库审计,
运维难以追溯"是谁、在什么时候、尝试做了什么转换、为什么失败"。

设计:中央事件发布 + TransactionalEventListener 双阶段落盘

1. OrderTransitionAttemptedEvent(新)
   覆盖 transition 成功、失败、FOR UPDATE 被拒三种情况,携带 orderId、
   fromStatus、targetStatus、errorCode、errorMessage、causeSummary 等。

2. OrderLifecycleManagerImpl
   - transition 成功分支:publishAttempt(success=true)
   - transition 失败分支(context.hasError):publishAttempt(success=false,
     errorCode=INVALID_TRANSITION, cause=摘要)
   - dispatch FOR UPDATE 命中分支:publishAttempt(success=false,
     errorCode=ASSIGNEE_HAS_ACTIVE_ORDER)
   publishAttempt 内部 try/catch,审计失败不影响主流程。

3. OrderTransitionAuditListener(新)
   - @TransactionalEventListener(AFTER_COMMIT, fallbackExecution=true)
     主事务已提交,按事件本身的 success 写 bus_log;INFO 级。
   - @TransactionalEventListener(AFTER_ROLLBACK) + @Transactional(REQUIRES_NEW)
     主事务已回滚,事件里声称的 success 强制视为失败;独立事务写 bus_log
     避免因主事务回滚而日志同样丢失。
   - errorCode、fromStatus、targetStatus、reason、cause 全部落 payload。
   - 冲突(ASSIGNEE_HAS_ACTIVE_ORDER)→ WARN;其他失败 → ERROR。

4. LogType 新增 TRANSITION_FAILED、DISPATCH_REJECTED。
5. EventLogRecorder 接口补 recordSync(实现类已有同名方法)。

运维查询:按 eventDomain='dispatch' + eventLevel IN ('WARN','ERROR')
即可一眼看出所有"尝试但未成功"的状态转换。errorCode 留在 payload JSON 内,
未升级为一等字段(后续如需聚合统计再迁移)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:11:28 +08:00
lzh
b534d79434 fix(ops): 派发入口加 FOR UPDATE 并发兜底,冲突时降级入队避免悬空
业务不变量:同一执行人在任一时刻最多只有 1 条活跃工单
(DISPATCHED/CONFIRMED/ARRIVED)。PAUSED 不纳入——P0 打断恢复
走 PAUSED→DISPATCHED,此处必须放行。

实现:

1. OpsOrderMapper.selectActiveByAssigneeForUpdate
   查询 assignee 活跃工单并对命中行加 FOR UPDATE 排他锁。必须在
   事务中调用。

2. OrderLifecycleManagerImpl.dispatch 入口校验
   事务开启后立即执行 FOR UPDATE 查询,命中则返回带错误码
   ASSIGNEE_HAS_ACTIVE_ORDER 的失败结果,不再执行责任链,
   事务 commit 空操作、锁释放;并发竞争的第二个线程会阻塞到
   第一个 commit 后看到活跃单,失败退出。

3. 新增 TransitionErrorCode 枚举 + OrderTransitionResult.errorCode
   调用方可区分需降级的冲突与硬失败,避免把"可降级"的结果
   直接抛给用户。

4. DispatchEngineImpl.executeDirectDispatch 降级逻辑
   - 冲突 + 原状态 PENDING → 调 executeEnqueueOnly 降级到 QUEUED,
     工单不悬空,等下一轮 autoDispatchNext 重挑。
   - 冲突 + 原状态已是 QUEUED(并发另一路抢先派发时回滚保留)
     → 返回 fail 但不重复入队,天然等下一轮。
   - 其他失败 → 照常 fail。

职责划分:
- 生命周期层负责"拒绝违反不变量的转换"
- 编排层负责"失败后给工单安置归宿"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:52:38 +08:00
lzh
c24b1eb641 fix(ops): 直接派发加空闲兜底 + 队列同步按活跃状态过滤
1. 直接派发空闲兜底(补 autoDispatchNext 之外的另一条派发入口)
   DispatchEngineImpl.executeDispatch 在 DIRECT_DISPATCH/PUSH_AND_ENQUEUE
   前增加 MySQL 兜底校验:若执行人仍挂活跃工单(Redis 判空闲但 MySQL
   不一致的场景),强制降级为 ENQUEUE_ONLY 让任务进队列等待下一轮
   autoDispatchNext 接力。避免同一设备再次出现并行多单。

2. 队列同步按活跃状态过滤
   syncUserQueueToRedis / getTasksByUserId 的 MySQL 回填路径此前调用
   selectListByUserId 不过滤状态,会把历史 REMOVED 记录一并同步到
   Redis(线上观察到设备 31 的 Redis ZSet 塞了 206 条、其中 205 条是
   REMOVED)。新增 OpsOrderQueueMapper.selectActiveListByUserId,只返
   回 WAITING/PROCESSING/PAUSED,两条同步链路改走此方法。原 selectList
   ByUserId 保留给审计/统计场景。

未清理历史 REMOVED 记录,保留审计追溯。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:22:18 +08:00
lzh
4d85659277 fix(ops): 修复同一工牌并行多单的状态错乱
线上观察:管理员手动取消一个僵尸 DISPATCHED 单会引发"越清越多"——
系统顺势派队列首条给仍在工作的保洁员,监听器再用"旧工单残留"机制
尝试取消当前正在执行的工单,该取消走 REQUIRES_NEW 独立事务且吞异常,
最终新单落地、旧单残留,同一设备挂多个非终态工单。

修复两处:

1. DispatchEngineImpl.autoDispatchNext 入口加设备空闲校验:
   若执行人名下还有 DISPATCHED/CONFIRMED/ARRIVED/PAUSED 工单(排除
   completedOrderId),直接早返回,不再派发。所有调用方(保洁/安保
   handleCancelled、asyncCompleteAndDispatchNext、xxl-job 空闲扫描)
   自动受保护。新增 OpsOrderMapper.selectActiveByAssignee。

2. BadgeDeviceStatusEventListener.handleDispatched 移除"残留取消":
   旧逻辑用 REQUIRES_NEW 事务 + 吞异常,是对数据已错乱场景的暴力兜底,
   失败时导致误杀。改为只打 ERROR 告警暴露问题,仅清理 Redis 关联。
   真正的防线在 DispatchEngine 入口。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:54:54 +08:00