Commit Graph

548 Commits

Author SHA1 Message Date
lzh
7dc00b542d feat(iot): 一期 Controller 补齐 (B2/B4-6/B10/B11/B12/B13)
对照前端 feat/iot-2.0 已固化 API 契约补齐 5 组缺失端点(发现于一期 19/19
宣称完成后前端联调阶段),归属原任务卡 Controller 层返工,不占用二期
B20+ 编号。

- B2  规则链: 补 PUT /disable /deploy /debug + POST /copy?id= +
              新增 GET /rule-chain/get?id= 返 GraphVO(保留 /get/{id})
              deployRuleChain=enable+主动 Pub/Sub evict(对齐 B8)
- B10 子系统: 新增 GET /device-count 聚合(HGETALL 返空 map 遵循 A6)
              + GET /get?id= query 别名(保留 /get/{id})
- B11 设备:   新增驼峰 PUT /bindSubsystem /batchBindSubsystem
              + 2 ReqVO,保留 kebab 兼容
- B12/B13 告警: 新增 IotAlarmRecordController(整缺)11 端点:
                page/get/ack/unack/clear/archive/batch-{ack,clear,archive}/
                history/remark;Service 补 6 方法(getPage/batchAck/
                batchClear/batchArchive/updateRemark/listHistory)
                + Mapper 2 方法 + 8 VO
- B4/5/6 节点元数据: 新增 GET /iot/rule/provider/metadata 聚合端点;
                    3 SPI 加 default getMetadata(),4 Manager 加
                    listAllMetadata(),13 具体 Provider 覆写(中文 label
                    + mdi: icon),schema MVP 空骨架 {rule:[]}

测试:
- iot-rule   191/191 全绿(+5 B2 补齐 +9 B4/5/6 补齐)
- iot-server 106 active/161 Skipped v1 遗产 全绿
            (+6 B12/B13 补齐 +3 B10 补齐)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:47:41 +08:00
lzh
9912b73c56 feat(iot): IoT v2.0 菜单迁移脚本(规则链 + 子系统)
使用子查询按名称取 parent_id,不写死 ID,兼容不同环境自增序列。
新增:
- 规则链管理(父: 规则引擎)+ 5 个权限按钮 + 隐藏编辑器路由
- 子系统管理(父: 设备接入)+ 4 个权限按钮
待补:告警记录 v2(IotAlarmRecordController 补齐后再执行)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 14:39:26 +08:00
lzh
4f89b49e5a fix(iot): B7 BranchNode @Lazy 注入 NodeProviderRegistry 打破循环依赖
启动失败:Spring 检测到环形依赖
  nodeProviderRegistry → branchNode → nodeProviderRegistry

根因:BranchNode 既是 NodeProvider(被 Registry 收集),又依赖 Registry
dispatch 子节点 — 典型 "collect vs dispatch" 死结。

修复:构造参数加 @Lazy,Spring 注入代理,首次方法调用才解析 Registry,
构造阶段打破环。运行期行为等价。

rule 模块 177/177 测试全绿。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:35:30 +08:00
lzh
ce2e6cc32a fix(iot): 清理 iot-rule spring.factories 旧 EnableAutoConfiguration 条目
Boot 3.x 已弃用,实际加载改由 AutoConfiguration.imports 负责,
spring.factories 中的有效条目移除,仅保留注释说明。

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 14:22:47 +08:00
lzh
04eba61067 fix(iot): iot-rule 注册 AutoConfiguration.imports(Spring Boot 3.x)
spring.factories 中的 EnableAutoConfiguration 在 Spring Boot 3.x 已被移除,
须改用 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。
缺失此文件导致 IotRuleEngineVersionAutoConfiguration 未加载,
RuleEngineVersionProperties bean 找不到,服务启动失败。

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 14:20:25 +08:00
lzh
66e57e4d5e Merge branch 'feat/multi-tenant' into feat/iot-2.0
同步 multi-tenant 分支最新变更:
- feat(system): SSO 回调换 Token + OAuth2 客户端 platform 过滤菜单
- chore(tenant): Tenant RPC Feign 引入 ProjectCommonApi
- feat(web): API 访问日志 exclude-paths 过滤
- fix(iot): 轨迹检测防抖 + eventTime 修正
- perf(system): 项目授权校验优化 + isSuperAdmin 缓存切换
- feat(system): 超管绕过 user_project + 项目成员分页 API
- chore(ci): 部署磁盘预检 + 自动清理镜像
- chore: .gitattributes 统一行尾 LF

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 13:59:50 +08:00
lzh
4386a69a4a feat(system): 新增内部 SSO 回调换 Token 接口
面向业务/物联运维平台之间的互跳场景:已登录一端 → /system/oauth2/authorize 拿 code
→ 浏览器重定向 → 另一端调用本接口用 code 换 access_token。

安全要点:
- body 传参而非 query,code/state 不落 nginx access log 和浏览器历史
- client_secret 不传:secret 仅存 DB,验证安全性来自 OAuth2 code 一次性 +
  redirect_uri 白名单 + state 一致性 + short TTL
- state 入参改为必填(@NotBlank),强制 CSRF 防护
- 日志中 code 截断(前 6 + ***+ 末 2),state 只记录长度不暴露值

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:32:40 +08:00
lzh
cbbb048a4d feat(system): 按 OAuth2 客户端 platform 过滤菜单,支持业务/物联双前端
问题:业务平台(biz)和物联运维平台(iot)共用一套用户体系和菜单表,但每个前端
只该看到自己域的菜单。原来没有按客户端过滤的机制。

方案:在 OAuth2 客户端维度打 platform 标签(biz/iot/NULL),菜单也打同样标签,
登录时下发菜单按二者匹配过滤。

链路:
- OAuth2AccessTokenCheckRespDTO / LoginUser(framework + gateway)新增 clientId 字段
- TokenAuthenticationFilter(framework + gateway)把 accessToken.clientId 带进 LoginUser
- WebFrameworkUtils.HEADER_CLIENT_ID="X-Client-Id":登录/refresh 等"无 token 入口"
  允许前端声明 client,避免硬编码 default
- AdminAuthServiceImpl.resolveClientId:未传 Header 时回退 OAuth2ClientConstants.CLIENT_ID_DEFAULT
- MenuDO / OAuth2ClientDO 各加 platform 列
- MenuService.filterMenusByPlatform:platform 为空(全平台共用)或匹配即保留

SQL 迁移按字母序编号:
- _01_oauth2_client_platform.sql:加列 + 给 default/iot-client 客户端打标 + 递归标
  IoT 菜单子树(root id=4000)为 iot
- _02_bulk_mark_biz_menus.sql:其余 platform=NULL 的菜单兜底标 biz
- 顺序依赖:_01 标完 iot 后 _02 才动剩余 NULL,避免 _02 把 IoT 菜单错标 biz

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:32:26 +08:00
lzh
4564eec893 chore(tenant): Tenant RPC Feign 客户端列表引入 ProjectCommonApi
框架层 starter-biz-tenant 的 @EnableFeignClients 补齐 ProjectCommonApi,
供租户隔离检查时跨服务拉取项目元数据使用。后续如体量增长可考虑拆独立
ViewshProjectRpcAutoConfiguration。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:31:55 +08:00
lzh
04961ee614 feat(web): API 访问日志支持 exclude-paths 过滤高频心跳刷屏
- 新增 viewsh.access-log.exclude-paths 配置(Ant Pattern),命中路径默认不打 INFO
- 异常或 HTTP 4xx/5xx 时仍打 WARN,保证"默认安静、出错必响"
- ApiAccessLogInterceptor 保留无参构造,兼容老用法
- gateway application.yaml 补注释:ZLM Hook 走 video-server 直连而非网关,理由说明

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:31:44 +08:00
lzh
ca575d6297 chore: 加 .gitattributes 统一 Java/SQL/shell 等文本文件行尾为 LF
仓库历史存在零星 CRLF 文件(Windows 编辑器保存导致),每次他人修改都会出现
整文件 diff 污染 git blame。显式规则让 git add 自动规范化,避免复现。

*.bat/*.cmd/*.ps1 保留 CRLF(Windows 原生脚本语义要求),二进制类型显式 binary
阻止任何转换。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:31:32 +08:00
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
bac4f216fc feat(iot): B15 告警传播(iot_alarm_propagation 关联表)
沿资产层级(SUBSYSTEM→PROJECT→TENANT)向上传播,替代原 JSON_CONTAINS 全表扫描。

关键实现:
- IotAlarmPropagationMapper:INSERT IGNORE 幂等批量插入;selectAlarmIdsByAsset
  命中 idx_asset(asset_type, asset_id, tenant_id),毫秒级响应
- IotAlarmPropagationServiceImpl:三层传播逻辑(无 project_id 时仅 2 层)
- IotAlarmRecordServiceImpl:首次触发(existing==null)时调用传播,
  @Lazy 注入避免循环依赖
- IotProjectMapper:最小化 Mapper(B10 有 DO 无 Mapper)
- 7 个单元测试(3层/2层/无subsystem/重复幂等/分页查询)

测试:iot-server 258/258 全绿(含 B14/B15 新增 23 用例)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:08:36 +08:00
lzh
171f201384 feat(iot): Wave 5 Round 2 — B9/B14/B16 统一消费入口 + 告警分布式锁 + 通知集成
B9 IotRuleEngineMessageHandler(统一消费入口)
- 新消费者 v2 统一入口,@PostConstruct 注册到 IotMessageBus
- versionResolver.shouldUseV2 三态路由(V1/V2/HYBRID),绝不双跑
- device null 时 WARN + skip;RuleEngine 异常 try-catch 吞掉防重试风暴
- v1 三消费者(DataRule/SceneRule/CleanRule)增加前置 v2 bypass 判断
- 6 个单元测试(global-v1/v2/hybrid 白名单命中/未命中/device-null/引擎异常)

B14 告警缓存 + SET NX PX 分布式锁 + 有效性判断
- IotAlarmLockService:SET NX PX + Lua 原子解锁,锁冲突抛 ALARM_LOCK_CONFLICT
- IotAlarmCacheService:Redis Hash iot:alarm:state:{id},TTL 7天,cache miss 从DB重建
- AlarmStateValidator:isEffectiveTrigger/isEffectiveClear 时序校验,防旧消息重置已清除告警
- IotAlarmRecordServiceImpl:trigger/clear/ack/archive 全部在锁内,DB写后立即同步缓存
- 新增 ALARM_LOCK_CONFLICT 错误码;AlarmTriggerRequest 增加 timestamp 字段
- 17 个单元测试(锁 4 + 缓存 5 + 校验 9 + 集成 3)

B16 NotifyAction 4 通道集成 + 模板解析
- NotifyChannel SPI 接口 + Sms/Email/InApp/Webhook 四实现(@Component 注册)
- WebhookNotifyChannel:JDK 17 HttpClient 10s 超时 + SSRF 强制 HTTPS 校验
- NotifyDispatcher:独立 ForkJoinPool(8) 并行分发,30s 整体超时,部分失败不阻塞
- 模板变量统一走 TemplateResolver(评审 C5),缺失变量降级为空串
- NotifyAction 移除 stub,委托 NotifyDispatcher
- viewsh-module-system-api 依赖引入;13 个测试(Dispatcher 7 + Webhook SSRF 6)

测试:iot-rule 177/177 全绿;iot-server 251/251 全绿(含 Skipped 161 旧 v1 测试)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:00:38 +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
8e7631987f feat(iot): Wave 5 Round 1 — B8/B13 规则链缓存 + AlarmHistory 时序 DAO
B8 规则链全量缓存 + Redis Pub/Sub + 版本拉模式兜底:
- CompiledRuleChainFactory:IotRuleChainGraphVO→CompiledRuleChain
- RuleChainCache(@PostConstruct loadAll + evict + reload + B48 钩子)
  · TenantUtils.executeIgnore 跨租户全量加载;TenantUtils.execute 逐租户切换
  · ConcurrentHashMap.compute 保证 reload 串行(避免并发 DB 查询)
  · 超 500 条规则链打 WARN 日志
- RuleChainCacheListener:Redis Pub/Sub 订阅 iot:rule:cache:evict,收到后 evict+reload
- RuleChainVersionChecker:5 分钟拉模式兜底,version drift 时 reload + metric
- RuleChainCacheConfiguration:@EnableScheduling + RedisMessageListenerContainer
- IotRuleChainMapper 新增 selectAllEnabledTenantIds()(跨租户查询)
- IotRuleChainServiceImpl.updateRuleChain 末尾发布 Pub/Sub 驱逐事件
- 5 单元测试全绿(含 version drift 检测 + 容量告警)

B13 AlarmHistory 时序表 DAO 双实现:
- AlarmHistoryDO(时序对象:ts/device/severity/ack/clear/archived/eventType 等)
- IotTsDbAlarmHistoryDao 接口(insert/queryByAlarmRecord/queryLatestByDevice)
- CtsdbAlarmHistoryDaoImpl(CTSDB/InfluxDB 协议,@ConditionalOnProperty)
- TdengineAlarmHistoryDaoImpl(TDengine JDBC,@ConditionalOnProperty)
- IotAlarmHistoryService(协调 TSDB 写;异步 @Async;写失败不影响主流程)
- TsDbAutoConfiguration 注册 IotAlarmHistoryService
- 5 单元测试全绿(含 TSDB 失败降级 + 异步写验证)

测试总计:rule 模块 164/164 ✓,server 模块 B13 5/5 ✓

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 10:37:07 +08:00
lzh
ec3981195d feat(iot): B17 SceneRule → DAG 自动转换工具 + dry-run/execute
- SceneRuleToChainMapper:v1→v2 纯转换逻辑
  · trigger type 映射(1→device_state 等 4 种 + timer)
  · action type 映射(1→device_property_set / 2→device_service_invoke / 100→alarm_trigger / 101→alarm_clear)
  · SpEL→Aviator:#root.x → ${data.x};含 T(/instanceof/new 标记 WARNING 不中断
  · 线性 DAG:Trigger → [Condition] → Action×N,临时 key -1/-2/-3...
- SceneRuleMigrator:干运行 + 分批执行(50条/批)+ 幂等(force 覆盖重迁)
- SceneRuleMigrationController:3 端点 dry-run/execute/mapping
- MigrationDryRunResultVO / MigrationExecuteReqVO
- 8 单元测试全绿(含 spel→aviator / unsupported_spel / idempotent / force)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 10:21:52 +08:00
lzh
24c486900a feat(iot): Wave 4 Round 2 — B6/B7/B18 ActionProvider + 分支执行 + DataRule迁移
B6 ActionProvider SPI + 5 核心动作(alarm/notify/device-ctrl):
- ActionProvider 接口(extends NodeProvider,默认 bridge execute)
- ActionResult record(SUCCESS/FAILURE/SKIP + output + message)
- ActionProviderManager(Spring 自动收集 + fail-fast 重复 type)
- AlarmTriggerAction(调用 IotAlarmRecordApi.triggerAlarm,模板变量解析)
- AlarmClearAction(alarmId 从 config 或 ctx.metadata 解析,幂等)
- NotifyAction(4 通道并发 + 部分失败不阻塞,第一期 stub)
- DeviceServiceInvokeAction(调用 IotDeviceControlApi.invokeService)
- DevicePropertySetAction(第一期 stub,B27 补全 Redis/MySQL)
- IotAlarmRecordApi + DTO(rule 模块→server 跨模块接口)
- IotAlarmRecordApiImpl(server 端 FeignClient 实现,委托 Service)
- 14 单元测试全绿

B7 分支执行逻辑(executeAnyway if/else-if/else):
- BranchConfiguration POJO(branches[] + executeAnyway + BranchCondition)
- BranchExecutor(核心语义:else/executeAnyway/条件异常短路/action 异常隔离)
- BranchNode NodeProvider(ACTION/"branch",内联执行命中 branch actions)
- DagExecutor 最小扩展(ctx.metadata 传递 CompiledRuleChain 供 BranchNode 使用)
- 9 单元测试全绿(含 validate else 位置校验)

B18 DataRule → DAG 自动转换工具:
- DataRuleToChainMapper(v1→v2 映射,6 种 Sink,合并/拆分多 source)
- DataRuleMigrator(dry-run + execute + 幂等映射表)
- DataRuleMigrationController(3 端点:dry-run/execute/mapping)
- 8 单元测试全绿

测试总计:rule 模块 159/159 ✓,server 模块 8/8(B18)✓

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 10:10:04 +08:00
lzh
42466363c7 feat(iot): Wave 4 Round 1 — B12/B4/B5 告警状态机 + 规则引擎 SPI
主会话 Opus:
- B12 iot_alarm_record 正交状态机(ack_state + clear_state + archived)
  * V2.0.4__iot_alarm_record.sql:主表 + iot_alarm_propagation 关联表
  * 评审 C1 正交三字段(替代线性 4 枚举,表达"已清除未确认")
  * 评审 C2 联合 UK (device_id, alarm_config_id, tenant_id, deleted)
  * 评审 C3 传播关联表(替代 propagated_to JSON 查询)
  * Service 5 方法:triggerAlarm / ackAlarm / unackAlarm / clearAlarm / archiveAlarm
  * 幂等 upsert(trigger_count++)+ 归档后禁止修改
  * 13 单元测试全绿
  * TODO B14 分布式锁 / B15 传播 / B16 通知

Sonnet subagent B4:TriggerProvider SPI + 5 内置触发器
  * spi/TriggerProvider + TriggerProviderManager(@Component + getType 索引,fail-fast 重复 type)
  * trigger/DeviceState / DeviceProperty / DeviceEvent / DeviceService / Timer(Spring TaskScheduler)
  * 评审 A3 落地:禁 ServiceLoader / @SPI
  * 44 单元测试全绿

Sonnet subagent B5:ConditionEvaluator SPI + 3 条件 + 统一模板变量
  * spi/ConditionEvaluator + condition/Manager
  * condition/Expression(Aviator + LRU(256) 编译缓存)
  * condition/TimeRange(跨午夜支持)
  * condition/DeviceState(Redis 查询,空值按 offline)
  * template/TemplateResolver:\${namespace.key},拒绝 \$[...] 旧语法(评审 B5)
  * TODO B44 完整 8 层 Aviator 沙箱
  * 50 单元测试全绿(TemplateResolver 16 + 条件 3x ≈ 34)

测试汇总:rule 136 全绿 / server 13 新增全绿

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet (subagent) <noreply@anthropic.com>
2026-04-24 00:35:14 +08:00
lzh
1f87d599c0 feat(iot): B11 iot_device.subsystem_id + 设备归属绑定 API(一期允许 NULL)
- 新增 sql/iot/V2.0.3__iot_device_add_subsystem.sql(ALTER + idx_subsystem)
- 新增 sql/iot/V2.1.0__iot_device_subsystem_not_null.sql(二期预留,带 "勿执行" 注释)
- IotDeviceDO 加 subsystemId(一期可 NULL,二期改 NOT NULL)
- IotDeviceService 加 bindDeviceToSubsystem / batchBind / unbind / selectCountBySubsystemId
- IotDeviceServiceImpl.createDevice 强校验 subsystemId + 同租户 + Redis HINCRBY +1
- 绑定变更按 TransactionSynchronizationManager afterCommit 同步 Redis(-1 / +1,避免脏状态)
- IotDeviceMapper 加 selectCountBySubsystemId / updateSubsystemId 等
- IotSubsystemServiceImpl 加 incrementDeviceCount/decrementDeviceCount;deleteSubsystem 改用 DB 计数兜底(更可靠)
- IotDeviceController 加 PUT /bindSubsystem + /batchBindSubsystem(@PreAuthorize iot:device:update)
- IotDevicePageReqVO 加 subsystemId 过滤参数(null 可走 IS NULL 查未归属)
- api ErrorCodeConstants 加 DEVICE_SUBSYSTEM_REQUIRED / DEVICE_SUBSYSTEM_CROSS_TENANT(1_050_003_009/010)
- 测试:IotDeviceServiceImplTest 8/8 + B10 IotSubsystemServiceImplTest 补 mock deviceMapper 后 8/8 全绿
- Known Pitfalls 落地:
  ⚠️ 评审 A2:一期允许 NULL,V2.1.0 预留二期 NOT NULL
  ⚠️ Redis 计数:事务提交后同步(TransactionSynchronizationManager.afterCommit)
  ⚠️ 跨租户:校验 subsystem 属于当前租户,不然抛 DEVICE_SUBSYSTEM_CROSS_TENANT
  ⚠️ 索引 idx_subsystem (tenant_id, subsystem_id, deleted) 最左匹配;IS NULL 查询走全表扫,文档已提示

Co-Authored-By: Claude Sonnet (B11 subagent) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
2026-04-24 00:03:57 +08:00
lzh
ae74b4752a feat(iot): B3 RuleEngine 执行器(DAG + 链级隔离 + 三层匹配去重)
rule 模块 engine/ 新增 14 个核心类 + 4 个测试:

- RuleEngine / DefaultRuleEngine:对外入口,链级 try-catch 隔离(决议 #3)
- DagExecutor:BFS 遍历,按 relation_type 选 outgoing links,RuntimeException 转 FAILURE
- ChainIndex:三层绑定(subsystem/product/device)4 种 key 匹配 + LinkedHashMap 去重 + priority ASC 排序
- RuleChainCompiler:DO → CompiledRuleChain,含单 Trigger + DAG 无环 + 非法枚举兜底
- NodeProvider / NodeProviderRegistry:SPI + Spring @Component 路由(禁用 ServiceLoader/@SPI)
- RuleContext / NodeResult / CompiledRuleChain/Node/Link / RuleEngineResult / RuleChainException

测试覆盖(42/42 全绿):
- DagExecutorTest: 线性链 / 分支(TRUE/FALSE)/ 并行动作 / 异常转 FAILURE / metadata 传递 / SKIP 截断 / 缺 Trigger
- ChainIndexTest: 4 种 wildcard 组合 / 去重 / priority 排序 / 租户隔离 / evict
- RuleChainCompilerTest: 正常编译 / 单 Trigger 兜底 / DAG 无环 / 非法 category & relation_type / 连线 sortOrder
- DefaultRuleEngineTest: 链级异常隔离(chain1+chain3 成功,chain2 失败,counters 各 1 次)

补齐依赖:
- rule/pom.xml 加 io.micrometer:micrometer-core(节点执行 Timer + 失败 Counter)
- RuleNodeCategory 加 of(String) 静态查找方法(配合 RuleLinkRelationType.of 一致风格)

Known Pitfalls 落地:
  ⚠️ 评审 B1:ShakeLimit 节点 hook 留在 DagExecutor(B48 补)
  ⚠️ 评审 B3:LinkedHashMap 去重保留顺序(ChainIndex.match)
  ⚠️ 评审 B4:relation_type 严格封闭 6 值(RuleLinkRelationType + isValid)
  ⚠️ 评审 A5:chains 顺序 for 循环 + try-catch;不使用 Reactor flatMap 并发
  ⚠️ 评审 B10:单 Trigger 兜底在 Compiler 层(Service 层 + Compiler 双重保障)
  ⚠️ Metrics 基数:tag 含 chainId + nodeType + outcome;规则链 ≤ 500 控制
  ⚠️ DAG 兜底 MAX_NODES_PER_EXECUTION=1000 防脏数据绕过无环校验

未实现(留给后续任务):
- 具体 Provider(B4/B5/B6 Trigger/Condition/Action 实现)
- 全量缓存加载 + Pub/Sub 驱逐(B8)
- JMH Benchmark(任务卡 §6.4 + AC9 p99 < 50ms,第二期补)
- @SpringBootTest 集成测试(B9 Handler 就位后补)

Co-Authored-By: Claude Opus 4.7 (1M context, main session) <noreply@anthropic.com>
2026-04-23 23:58:48 +08:00
lzh
66647e19dd feat(iot): B19 规则引擎 v1/v2/hybrid 版本解析器 + 管理 API
- config/RuleEngineVersion.java(三态枚举 V1/V2/HYBRID)
- config/RuleEngineVersionProperties.java(@ConfigurationProperties, prefix=viewsh.iot.rule.engine)
- config/IotRuleEngineVersionResolver.java(volatile Set 本地缓存 < 1μs + Redis 动态白名单 + 降级)
- config/RuleEngineVersionAdminController.java(5 端点:add/remove/list/version/refresh,@PreAuthorize)
- config/IotRuleEngineVersionAutoConfiguration.java(@AutoConfiguration + @EnableConfigurationProperties)
- spring.factories 注册 AutoConfiguration
- 测试:IotRuleEngineVersionResolverTest 8 用例全绿(含 Redis 降级 + 动态刷新)
- Known Pitfalls 落地:
  ⚠️ 评审 B11:三态枚举 + switch 全覆盖
  ⚠️ 性能:volatile + Set.contains,无 I/O
  ⚠️ Redis 降级:try-catch + log.warn,不抛出
  ⚠️ subsystemId=null 走 v1(存量未归属设备安全默认)

Co-Authored-By: Claude Sonnet (B19 subagent) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
2026-04-23 23:40:25 +08:00
lzh
48e605b3c9 fix(iot): 恢复 B2 Controller/VO 的 @PreAuthorize/@Schema/@Valid 注解
Wave 2 三个 subagent 并行时,B19 subagent 看不到主会话后补的
rule/pom.xml web/security 依赖,把 B2 已写的注解全注释掉作为编译兜底。
rule/pom.xml 现已含 web/security/biz-tenant starter,恢复注解。

- IotRuleChainController: 7 @PreAuthorize + @Tag + 7 @Operation + @Parameter + @Valid + @Validated
- IotRuleChainSaveReqVO: @Schema + @NotEmpty/@NotNull/@Valid(含 NodeVO/LinkVO 子 VO)
- IotRuleChainRespVO: @Schema
- IotRuleChainGraphVO: @Schema(含子类)
- IotRuleChainPageReqVO: @Schema

落地 B2 AC9(REST API 所有端点带权限校验)。
测试:mvn test -pl viewsh-module-iot/viewsh-module-iot-rule → 17/17 全绿

Co-Authored-By: Claude Sonnet (annotation-restore subagent) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
2026-04-23 23:39:26 +08:00
lzh
962e69290b feat(iot): B2 RuleChain/Node/Link 数据模型 + CRUD(单 Trigger/DAG 无环/乐观锁)
- 新增 sql/iot/V2.0.1__iot_rule_chain.sql(iot_rule_chain/node/link 三表 + idx_binding 索引)
- 新增 rule 模块 dal/(3 个 DO + 4 个封闭枚举 + 3 个 Mapper)
- 新增 rule 模块 service/(CRUD + 单 Trigger 校验 + DAG DFS 无环 + 乐观锁 + 级联软删)
- 新增 rule 模块 controller/admin/(7 REST 端点 + @PreAuthorize + VO)
- 新增 resources/mapper/rule/(3 个 MyBatis XML)
- api 模块 ErrorCodeConstants 新增规则链段(1-050-030-xxx)
- **补 B1 遗漏依赖**:rule/pom.xml 追加 viewsh-spring-boot-starter-{web,security,biz-tenant}
- 测试:8 个单元用例全绿(BaseMockitoUnitTest)
- Known Pitfalls 落地:
  ⚠️ 评审 B4:relation_type VARCHAR + 应用层 RuleLinkRelationType.isValid 校验
  ⚠️ 评审 B9:updateWithVersion 乐观锁原子 SQL + idx_update_time 索引支撑 B9 拉模式兜底扫描
  ⚠️ 评审 B10:单 Trigger 校验在 Service 层(validateSingleTrigger)
  ⚠️ 评审 A4:name UK(name, tenant_id, deleted)
  ⚠️ 评审 §十一-B:idx_binding (tenant_id, subsystem_id, product_id, device_id) 最左匹配

Co-Authored-By: Claude Sonnet (B2 subagent) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
2026-04-23 21:09:54 +08:00
lzh
6649e1abb6 feat(iot): B10 iot_subsystem 表 + CRUD + Redis 设备计数聚合
- 新增 sql/iot/V2.0.2__iot_subsystem.sql(iot_project + iot_subsystem)
- 新增 server 模块 subsystem/ 下 DO + Mapper + Service + Controller + VO(7 端点)
- 新增 IotSubsystemDeviceCountRedisDAO(HINCRBY + rebuild + ApplicationReadyEvent 触发)
- api 模块 ErrorCodeConstants 新增子系统段(1-050-020-xxx)
- server 模块 RedisKeyConstants 新增 SUBSYSTEM_DEVCOUNT
- 测试:8 个单元用例全绿(mvn test IotSubsystemServiceImplTest)
- Known Pitfalls 落地:
  ⚠️ 评审 A4:UK(name, tenant_id, project_id, deleted) + 应用层 existsByNameAndProject 兜底 NULL
  ⚠️ 评审 A6:device-stats 走 Redis Hash,避免 GROUP BY
  ⚠️ 评审 A7:simple-list 权限码 iot:device:query,返回字段仅 id/name/code
  ⚠️ 删除校验:Redis 计数 > 0 抛 SUBSYSTEM_HAS_DEVICES
  ⚠️ Redis 重建:ApplicationReadyEvent + try/catch + log.warn 不阻塞启动

说明:iot_device 当前无 subsystem_id 列(rebuild 逻辑标 TODO B11),
待 B11 加列后启用 DB 重建查询。

Co-Authored-By: Claude Sonnet (B10 subagent) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
2026-04-23 21:08:00 +08:00
lzh
4614737d51 feat(iot): B1 新建 viewsh-module-iot-rule Maven 模块骨架
- 新增 viewsh-module-iot-rule/pom.xml(仅依赖 iot-api + iot-core + Aviator 5.3.3 + mybatis/redis starter)
- 新增 package-info.java / ModuleSmokeTest.java / spring.factories 占位
- viewsh-module-iot/pom.xml <modules> 新增 rule(core 之后、server 之前)
- viewsh-module-iot-server/pom.xml 新增 rule 依赖(core 之后、业务组件之前)
- Known Pitfalls 落地:
  ⚠️ 评审 A3:依赖方向 rule → core → api,严禁反向(dependency:tree 验证)
  ⚠️ 评审 R6:server 对 rule 的依赖按项目一贯顺序插入
  ⚠️ Aviator 5.3.3 首次引入,rule 模块 pom 内显式声明 version

Acceptance Criteria 全 8 项通过(见任务卡 Notes §8.2026-04-23 执行记录)

Co-Authored-By: Claude Sonnet (B1 subagent) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
2026-04-23 20:50:31 +08:00
lzh
a72c96692e feat(iot-v2): 拆分 AGENTS.md / CLAUDE.md(方案 C)
- AGENTS.md 作为 agents.md 标准通用规范(所有 AI agent 可读)
- .gitignore 放开 AGENTS.md(删除 AGENTS.md 忽略规则)
- CLAUDE.md 保持 gitignored(Claude Code 本地配置)

用于 IoT v2.0 第一期任务的跨 agent 规范:
- 项目背景 / 任务卡系统 / 硬约束 / 技术约定 / 测试命令 → AGENTS.md
- /loop / subagent 并行 / checkpoint / PushNotification → CLAUDE.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:48:40 +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
fa8e814af4 feat(iot): 抽象时序数据库访问层,支持 TDengine + CTSDB(InfluxDB) 双引擎
将原有 TDengine 强耦合的 Mapper 层重构为统一的 TsDb 抽象接口:
- 新增 IotTsDbDeviceMessageDao / IotTsDbDevicePropertyDao 接口
- 实现 TDengine 和 CTSDB(InfluxDB) 两套适配器
- 通过 viewsh.iot.tsdb.type 配置项切换时序数据库引擎
- Service 层从直接依赖 TDengine Mapper 改为依赖抽象 Dao 接口
- 新增 influxdb-client-java 7.2.0 依赖
- 删除旧的 TDengineTableInitRunner,统一由 TsDbTableInitRunner 管理

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:57:04 +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