diff --git b/.gitignore a/.gitignore new file mode 100644 index 0000000..1bf8f64 --- /dev/null +++ a/.gitignore @@ -0,0 +1,18 @@ +# Java / Maven +engine/target/ +*.class + +# Node / Vite +web/node_modules/ +web/dist/ +web/.vite/ + +# 运行日志 +.engine.log +*.log + +# IDE / OS +.idea/ +.vscode/ +*.iml +.DS_Store diff --git b/CLAUDE.md a/CLAUDE.md new file mode 100644 index 0000000..49f2e6f --- /dev/null +++ a/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md — 本体驱动 DDD+EDA 元数据引擎 + +给未来在本仓库工作的 Claude Code 的说明。 + +## 这是什么 + +一个**元数据驱动**系统:`models/` 下 11 份 YAML 本体(M0–M8、MetaRule)是**唯一事实源**。 +- 后端 `engine/`(Java 21 / Spring Boot 3.5)是**通用解释引擎**——不含订单专用业务代码,加载 YAML 后通用执行命令。 +- 前端 `web/`(React 18 / antd 5 / Vite)是**通用渲染引擎**——读 M8 模板动态生成页面。 + +**铁律:改需求改 `models/*.yaml`,不要在引擎/前端里硬编码订单/客户/商品字段。** 若某能力必须加代码,应加"解释某类模型结构"的通用能力,而非某个具体聚合的分支。 + +## 运行 / 验证 + +```bash +./start.sh # 一键起后端(8080)+前端(5173/顺延) +cd engine && ./mvnw spring-boot:run # 仅后端(./mvnw 包装器,无需预装 Maven) +cd web && npm run dev # 仅前端 +bash tests/verify-model-change.sh # 验证"改模型→改行为"(reload 需 X-Principal: backend_admin) +``` + +改完模型:点页面右上「热重载模型」或 `curl -X POST localhost:8080/api/meta/reload`,无需重启。 + +## 关键文件 + +- `engine/.../model/ModelRepository.java` —— 元模型加载与按 M0-M8 语义的类型化查询(所有下游只读它)。 +- `engine/.../command/CommandEngine.java` —— ★ 通用命令执行器(M4 flowSteps + M2 + MetaRule + M3 + ME)。 +- `engine/.../meta/SceneSchemaService.java` —— 合成前端渲染 Schema(M8+M1+M2+M3+M5+MetaRule)。`select` 绑定到「聚合根自身标识」或「带 M1 `refAggregate` 的引用字段」时,通用地据被引用聚合给出下拉选项(如订单审核的审核人=员工下拉,值回填 `auditorId`)。 +- `engine/.../persist/SchemaInitializer.java` —— 据 M3+M1 自动建表 + 写演示数据。 +- `web/src/engine/SceneForm.tsx` / `FieldControl.tsx` —— 通用渲染引擎与组件注册表。 + +## 结构化元数据约定(2026-07-20 审计整改后新增,引擎据此通用执行) + +- **M1 属性 `constraints`**:`{min, max, exclusiveMin, exclusiveMax, pattern, patternMessage, unique}` —— 通用校验,勿在引擎里写具体字段/中文子串。`unique:true` 会在 create/update 前按 M3 列查重(update 排除自身;加密字段用确定性密文比对)。 +- **M2 命令 `commandType`**:`create|update|cancel` —— 决定合成默认程序 + create 超卖回滚策略;缺省 create。 +- **M2 命令 `bizSteps`(命令级"闭指令集"程序,`CommandEngine.runProgram`/`runInstructions` 逐条通用解释,替代旧的硬编码 create/update/cancel)**: + 每步 `{stepNo, desc, instruction:{type, params}}`,`type` **只能取以下 10 个 opcode 之一**(闭集合,M0 `M2_BizStep.enum` 锁定,AI 转换产物可静态校验): + `LOAD_AGGREGATE`(按 `idSource` 加载既有根→update/cancel 语义) / `SET_FIELD`(`targetField` ← `source`(input.*/root.*/字面量) 或 `value`) / + `AUTO_FILL_FIELD`(`func`: `now_datetime|now_date|gen_id`) / `REMOTE_QUERY_SET_FIELD`(`remoteAggregate/queryKey/sourceField/targetField`) / + `CONDITION_SET_FIELD`(`conditions:[{when, value}]`,when 走 Aviator) / `CALL_OTHER_COMMAND`(`targetAggregate/targetCommand/paramMap/forEach/onFailure`,复用目标命令 `effect`) / + `COMMIT_TRANSACTION`(未 LOAD→insert 根+组合子实体;已 LOAD→update) / + `IF`(`{when, then:[子指令], else:[子指令]}`,when 走 Aviator,**命令级判断分支**,类比 M4 `condition`,`then/else` 内可嵌套任意 opcode) / + `REMOTE_SET_FIELD`(`remoteAggregate/keySource/targetField/source|value`,覆盖写回被引用聚合根字段) / + `REMOTE_INCREMENT_FIELD`(`remoteAggregate/keySource/targetField/amount`,原子 +N 被引用聚合根数值字段;被增字段须非空,见 seed 初始化)。 + 缺省或仍是字符串描述的命令,按 `commandType` 合成规范程序,仍走同一台解释器。 + 落库后统一:发 `emitEvents` → 跨聚合一致(ME 规则 + 指令内 CALL/REMOTE) → create 超卖回滚。表达式里 `input.`(命令入参)/`root.`(聚合现值) 命名空间由引擎改写后交 Aviator。 + 演示命令 `AuditOrder`(`SCENE_AUDIT_ORDER`)即范例:按 `root.totalAmount>100` 用 `IF`+`REMOTE_SET_FIELD` 覆盖审核人备注,按 `input.auditResult` 用 `REMOTE_INCREMENT_FIELD` 累加审核人通过/驳回数;`REMOTE_QUERY` 查 `EmployeeAggregate`。 +- **M2 命令 `effect`**:`{op: increment|decrement, targetField, keyParam, amountParam, guardField}` —— 跨聚合副作用由此通用执行。 +- **M2 命令 `derivations`**:`[{targetField, sourceEntity, aggregate: sum, expression}]` —— 服务端重算字段(防篡改)。 +- **ME `crossAggConsistencyRules`**:需带 `sourceItemEntity` + `paramMap{目标命令参数: 子实体字段}`。 +- 后端 execute 返回稳定键 `rootId` / `rootIdField`;前端只读这两个,勿读 `orderId`。 +- 校验/风控变量解析走 **M1 `refAggregate`**(`ModelRepository.refInfo`);不要在引擎写 CustomerAggregate/customerLevel/stockNum 之类字面量。 +- **M4 场景 `stepType` 全集(`CommandEngine.runStep` 递归通用解释,枚举见 M0 `M4_SceneStep`)**: + 起止 `start|return|end|errorEnd`;数据 `readOnlyCheck|validate|derive|assign|transform|script`; + 命令/事件 `bindCommand|callScene|callService|emitEvent|waitEvent`;控制流 `condition|switch|parallel|while|loopItems`; + Saga/可靠性 `retry|compensate|timer|userTask`。子步骤放 `then/else/body/branches[].steps/cases[].steps/default`; + 表达式(`when`/`switchOn`/`assignments[].expression`/`script.expression`)走 Aviator(`RuleEngine.evalValue/evalBool`),变量取 master+M1 引用+循环行。 + **switch 的分派键叫 `switchOn` 不叫 `on`**(YAML 1.1 把 `on/off/yes/no` 当布尔,`on:` 会变成 `true` 键)。 + `bindCommand` 可带 `compensateAggregate/compensateCommand` 登记逆向补偿,失败时逆序触发(`SCENE_DEMO_STEPTYPES` 为可删演示场景)。 + 演示简化:`parallel` 顺序执行后汇合、`timer/waitEvent/callService` 不真阻塞/外呼、`retry` 无瞬时故障注入、`userTask` 用 `approveParam` 入参模拟审批。 + +## 约定 + +- 存储默认 H2 内存库(MySQL 兼容模式);生产 profile 见 `deploy/`。 +- Java 编译目标 21(`maven.compiler.release=21`),可在更高 JDK 上运行。 +- 新增原子组件类型:在 `FieldControl.tsx` 注册表登记即可,业务页面零改动。 +- 演示引导数据在 `engine/src/main/resources/seed-data.yaml`(非本体的一部分)。 +- 订单审核场景 `SCENE_AUDIT_ORDER` 由 M8 `template_order_audit` 渲染:选订单 + 选审核人(员工引用下拉)+ 审核结论/备注,端到端演示 `AuditOrder` 的 7 opcode 程序。`auditResult` 为 M1 上的**瞬态字段**(无 M3 列,不落库,仅派生 `orderStatus`)。 +- 未绑定 M8 模板的场景(如 `SCENE_DEMO_STEPTYPES`,字段为合成变量非 M1 字段):前端提供「无表单直接运行」入口,以空 payload 调 execute,引擎逐条通用解释 flowSteps 产出 trace。 +- M5 `encryptRules.storageEncrypt` 字段由 `EncryptService` 落库加密/读时解密;热重载仅管理员(M8 dragRolePermission)。 +- 审计整改逐条见 `docs/审计整改说明.md`。仍为演示简化:真实认证/幂等/M0 强校验。 diff --git b/README.md a/README.md new file mode 100644 index 0000000..c160b35 --- /dev/null +++ a/README.md @@ -0,0 +1,134 @@ +# 本体驱动 DDD+EDA · 元数据解释引擎(可运行 Demo) + +> 一份**完整的前后端**,一个**能运行的网页**:网页上可操作「新增订单」,且该操作**完全由 `models/` 下的 YAML 本体模型驱动**——**改了模型文件,前端表单与后端执行的操作定义同时改变,无需改一行代码**。 + +本项目按 `request.txt` 的 11 层本体模型(M0–M8、MetaRule)落地为一个**元数据驱动**系统: + +- **后端 = 架构解释引擎**(Java 21 + Spring Boot 3.5):启动时加载 YAML,构建内存元模型,用**通用命令执行器**解释执行 `CreateOrder`(无任何订单专用业务代码)。 +- **前端 = 通用渲染引擎**(React 18 + Ant Design 5 + Vite):读取 M8 模板 + 后端合成的元数据,**动态渲染**「新增订单」页并提交到通用命令 API。 + +--- + +## 一、快速运行 + +前置:**Java 21+、Node 18+、npm**(Maven 无需预装——项目内含 `./mvnw` 包装器,首次运行自动下载 Maven 3.9.9 与全部依赖;本机实测 Java 25 + Node 26 亦可)。 + +```bash +./start.sh +``` + +脚本会:启动后端(8080) → 等待就绪 → 安装前端依赖 → 启动前端(Vite)。 +浏览器打开 Vite 提示的 `Local` 地址(默认 `http://localhost:5173`,被占用则顺延如 5174),进入「用户创建订单完整流程」页即可操作**新增订单**。 + +分别启动(调试用): + +```bash +# 终端 A —— 后端(用包装器,无需预装 Maven) +cd engine && ./mvnw spring-boot:run +# 终端 B —— 前端 +cd web && npm install && npm run dev +``` + +默认存储为 **H2 内存库(MySQL 兼容模式)**,零外部依赖开箱即跑;H2 控制台 `http://localhost:8080/h2-console`(JDBC URL: `jdbc:h2:mem:trade_db`,用户 `sa`,空密码)。 + +--- + +## 二、验证「改了模型文件 → 操作定义随之改变」(核心验收点) + +系统的关键在于**行为源于模型、而非代码**。三种典型验证(改完点页面右上「热重载模型」或 +`curl -X POST localhost:8080/api/meta/reload -H 'X-Principal: backend_admin'`,无需重启;热重载仅管理员可触发): + +1. **改前端表单(M8)**:编辑 `models/M8-front-schema.yaml`,把某组件 `compType: input` 改成 `select`、改 `formLabel`、增删组件或改 `span` → 热重载 → 「新增订单」页对应字段的**控件类型/标签/布局立即改变**。 +2. **改风控规则(MetaRule)**:编辑 `models/MetaRule-business.yaml`,把 `single_order_max_amount` 由 `50000` 改成 `100` → 热重载 → 普通用户下单金额超 100 即被 **REJECT**(同一笔请求从通过变为拦截)。 +3. **改领域字段类型(M1)**:编辑 `models/M1-domain.yaml`,把某字段 `type: string` 改成 `int` → 热重载 → 该字段的输入控件类型随之改变(配合 M3 增列后即可落库)。 + +> `curl` 复现规则 2:见 `tests/verify-model-change.sh`。 + +--- + +## 三、目录结构 + +``` +onto-implementation/ +├── models/ # ★ 唯一事实源:11 份本体 YAML(M0–M8、MetaRule) +├── engine/ # 后端解释引擎(Spring Boot) +│ └── src/main/java/com/onto/engine/ +│ ├── model/ # 元模型加载与类型化查询 (ModelRepository) +│ ├── meta/ # 场景渲染 Schema 合成 (SceneSchemaService) +│ ├── command/ # ★ 通用命令执行器 (CommandEngine) +│ ├── rule/ # Aviator MetaRule 引擎 (RuleEngine) +│ ├── persist/ # 据 M3 动态建表/读写 (SchemaInitializer/DynamicRepository) +│ ├── event/ # Outbox + 跨聚合最终一致 (EventEngine) +│ ├── security/ # M5 脱敏 (MaskService) +│ └── web/ # REST 控制器 +├── web/ # 前端通用渲染引擎(React + Vite) +│ └── src/ +│ ├── engine/ # ★ SchemaRenderer / FieldControl(组件注册表) +│ ├── panels.tsx # 执行追踪 / 溯源 / 模型查看器 / 订单事件 +│ └── App.tsx +├── templates/ # 代码生成模板(M1→DDL、M2→命令、M8→React 组件) +├── deploy/ # 生产部署(docker-compose:MySQL8 + RocketMQ + Nginx) +├── docs/ # 场景推演文档(架构运行原理) +├── tests/ # 验证脚本 +└── start.sh +``` + +--- + +## 四、架构分层与引擎如何"解释执行" + +| 层 | 职责 | 引擎如何使用 | +|---|---|---| +| **M0** meta-schema | 全域校验根规范 | 描述所有层的 JSON Schema,CI 可据此校验 YAML | +| **M1** domain | DDD 聚合唯一数据源 | 字段类型、组合子实体结构 → 前端控件类型 / DDL / 值转换 | +| **M2** command | 聚合命令入参/校验/事件 | 通用执行器据此校验入参、决定发何事件 | +| **ME** event | EDA 事件与跨聚合一致性 | Outbox 事件定义 + `OrderCreated→StockDeduct` | +| **M3** deploy | 实体↔表/字段映射、命令 API | 自动建表、动态 SQL、前端命令 URL | +| **M4** scene | Saga 流程编排 | 通用执行器逐 `flowSteps` 解释:readOnlyCheck→bindCommand→return | +| **M5** security | 权限/脱敏 | 权限校验、敏感字段掩码渲染 | +| **M6** monitor | 监控告警 | 指标/告警规范(文档态) | +| **M7** sla | 流量/熔断 | SLA 规范(文档态) | +| **MetaRule** | 前后端统一动态规则 | Aviator 实时执行:REJECT/ALERT,前后端同源 | +| **M8** front-schema | React 拖拽页面模板 | ★ 前端渲染引擎读取它动态生成整页 | + +**「新增订单」端到端(SCENE_CREATE_ORDER)**: +权限(M5) → readOnlyCheck 客户存在(M4/M1) → 入参校验(M2/M8) → 风控(MetaRule) → 落库(M3) → 发事件(ME/Outbox) → 跨聚合扣库存(ME 最终一致)。 +页面右侧「执行追踪」把每一步及其来源层实时可视化。 + +详见 [`docs/场景推演.md`](docs/场景推演.md)。 + +--- + +## 五、主要 API + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/meta/scenes` | 场景列表(M4) | +| GET | `/api/meta/scene/{sceneId}` | 场景渲染 Schema(M8+M1+M2+M3+M5+MetaRule 合成) | +| GET | `/api/meta/model/{code}` | 查看某层模型解析内容(M0..M8/MetaRule) | +| POST | `/api/meta/reload` | 热重载全部 YAML(改模型即时生效) | +| GET | `/api/data/{aggregateId}` | 引用下拉数据源(客户/商品,含脱敏) | +| POST | `/api/scene/{sceneId}/precheck` | 提交前预检(校验+MetaRule 风控,不落库) | +| POST | `/api/scene/{sceneId}/execute` | 通用执行场景命令(完整解释执行) | + +--- + +## 六、通用性与安全边界(诚实说明) + +经 2026-07-20 审计后本轮"全面改造":把原先散落在 Java/TS 里的业务字面量**上移为可机器读的本体元数据** +(M1 `constraints`、M2 `commandType/effect/derivations`、ME `sourceItemEntity/paramMap`), +使校验、风控、跨聚合一致性、落库分派**全部模型驱动**。因此新增第二个聚合/场景(如已内置的"新增客户") +或重命名字段,通常**只改 YAML**。逐条整改见 [`docs/审计整改说明.md`](docs/审计整改说明.md)。 + +**仍为演示简化(未做成生产级,明确标注)**: +- **认证**:未接入 OAuth2/JWT;主体来自 `X-Principal` 头(可伪造),仅演示 M5 权限"通过/拒绝"分支。热重载/新增客户已要求管理员主体。生产必须换真实认证。 +- **幂等**:命令未加请求幂等键。 +- **M0 校验**:M0 仍为规范文档,未在加载时对各层做 JSON-Schema 强校验。 + +> M8 补全后四个场景(新增订单/新增客户/修改地址/取消订单)均可渲染并端到端可用:`update` 命令含子实体更新、`cancel` 走通用 `StockReturn` 回补库存(均有测试)。 + +## 七、生产化部署(本机为轻量可跑版) + +为保证"本机开箱即跑",Demo 采用 H2 内存库 + 进程内 Outbox/事件总线 + **真实 Aviator 规则引擎 + 真实字段加密**。 +生产映射(真 MySQL8 + RocketMQ + Nginx)见 [`deploy/`](deploy/):`docker-compose up` 拉起完整栈,后端切 `prod` profile +连真中间件、收紧 CORS、禁用 H2 控制台、要求经环境/密管注入 DB 口令与加密密钥;YAML 驱动逻辑与规则引擎**完全一致、无需改动**。 diff --git b/deploy/Dockerfile.engine a/deploy/Dockerfile.engine new file mode 100644 index 0000000..296d296 --- /dev/null +++ a/deploy/Dockerfile.engine @@ -0,0 +1,15 @@ +# 多阶段构建:Maven 编译 -> 精简运行镜像 +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /src +COPY engine/pom.xml . +RUN mvn -q -B dependency:go-offline +COPY engine/src ./src +RUN mvn -q -B -DskipTests package + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /src/target/onto-engine.jar app.jar +# 模型目录通过挂载卷提供(改 YAML 即改行为) +ENV ONTO_MODELS_DIR=/app/models +EXPOSE 8080 +ENTRYPOINT ["java","-jar","app.jar","--spring.profiles.active=prod"] diff --git b/deploy/Dockerfile.web a/deploy/Dockerfile.web new file mode 100644 index 0000000..b8b47b0 --- /dev/null +++ a/deploy/Dockerfile.web @@ -0,0 +1,12 @@ +# 构建前端静态资源 -> Nginx 托管 +FROM node:20-alpine AS build +WORKDIR /src +COPY web/package.json web/package-lock.json* ./ +RUN npm install +COPY web/ . +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /src/dist /usr/share/nginx/html +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git b/deploy/README.md a/deploy/README.md new file mode 100644 index 0000000..6acbabf --- /dev/null +++ a/deploy/README.md @@ -0,0 +1,20 @@ +# 生产部署(docker-compose) + +将 Demo 的轻量替身映射到生产中间件:**Nginx + 引擎 + MySQL8 + RocketMQ**。 + +```bash +# 仓库根目录执行 +docker compose -f deploy/docker-compose.yml up --build +``` + +- 前端: http://localhost:8088 +- 引擎: http://localhost:8080 +- MySQL: localhost:3306(trade/trade123) +- RocketMQ Namesrv: 9876 + +关键点: +- `engine` 以 `prod` profile 启动,连真实 MySQL(`application-prod.yml`)。 +- `models/` 以只读卷挂载进引擎;线上改 YAML 后 `curl -X POST http://localhost:8080/api/meta/reload` 即热更新,**无需重新构建镜像**。 +- 业务逻辑与 MetaRule 规则引擎在 H2/MySQL 两种模式下**完全一致**,仅数据源不同。 + +> 本机仅想快速看「新增订单」跑通,用仓库根目录 `./start.sh`(H2 内存库、零外部依赖)更快。 diff --git b/deploy/docker-compose.yml a/deploy/docker-compose.yml new file mode 100644 index 0000000..8b04c4a --- /dev/null +++ a/deploy/docker-compose.yml @@ -0,0 +1,54 @@ +# 生产拓扑映射:Nginx(前端) + 引擎 + MySQL8 + RocketMQ +# 从仓库根目录执行: docker compose -f deploy/docker-compose.yml up --build +# 说明:MySQL/RocketMQ 为生产映射;本机快速体验用根目录 ./start.sh(H2 内存库)即可。 +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root123 + MYSQL_DATABASE: trade_db + MYSQL_USER: trade + MYSQL_PASSWORD: trade123 + ports: ["3306:3306"] + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123"] + interval: 5s + timeout: 3s + retries: 20 + + rocketmq-namesrv: + image: apache/rocketmq:5.3.0 + command: sh mqnamesrv + ports: ["9876:9876"] + + rocketmq-broker: + image: apache/rocketmq:5.3.0 + command: sh mqbroker -n rocketmq-namesrv:9876 --enable-proxy + environment: + NAMESRV_ADDR: rocketmq-namesrv:9876 + depends_on: [rocketmq-namesrv] + ports: ["10911:10911", "8081:8080"] + + engine: + build: + context: .. + dockerfile: deploy/Dockerfile.engine + environment: + DB_HOST: mysql + DB_USER: trade + DB_PASS: trade123 + ROCKETMQ_NAMESRV: rocketmq-namesrv:9876 + volumes: + # 挂载模型目录:线上改 YAML + 调 /api/meta/reload 即热更新,无需重发版 + - ../models:/app/models:ro + depends_on: + mysql: + condition: service_healthy + ports: ["8080:8080"] + + web: + build: + context: .. + dockerfile: deploy/Dockerfile.web + depends_on: [engine] + ports: ["8088:80"] diff --git b/deploy/nginx.conf a/deploy/nginx.conf new file mode 100644 index 0000000..11324d5 --- /dev/null +++ a/deploy/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA 路由回退 + location / { + try_files $uri $uri/ /index.html; + } + + # 反代到后端引擎,前端无需硬编码后端地址 + location /api/ { + proxy_pass http://engine:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git b/docs/场景推演.md a/docs/场景推演.md new file mode 100644 index 0000000..28e5dfb --- /dev/null +++ a/docs/场景推演.md @@ -0,0 +1,120 @@ +# 场景推演:一次「新增订单」如何被本体模型驱动执行 + +本文用 `SCENE_CREATE_ORDER` 这一具体场景,逐步推演"架构引擎如何解释与执行模型",说明整体运行原理。 + +--- + +## 0. 一图看懂 + +``` + models/*.yaml (唯一事实源) + ┌───────────────────────────────────────────────────────────┐ + │ M0 校验 M1 领域 M2 命令 ME 事件 M3 部署 M4 场景 │ + │ M5 安全 M6 监控 M7 SLA MetaRule 规则 M8 前端模板 │ + └───────────────────────────────────────────────────────────┘ + │(启动/热重载加载) │(GET /api/meta/scene) + ▼ ▼ + ┌───────────────────────┐ ┌──────────────────────────┐ + │ 后端 解释引擎(Java) │◀──REST──│ 前端 渲染引擎(React) │ + │ ModelRepository │ │ SceneForm/FieldControl │ + │ CommandEngine (通用) │ │ 读 M8 动态生成「新增订单」│ + └───────────────────────┘ └──────────────────────────┘ +``` + +**关键**:没有 `OrderController`、没有 `OrderService.createOrder()` 这类专用代码。`CommandEngine` 对任何场景都走同一套解释流程;`SceneForm` 对任何模板都走同一套渲染流程。 + +--- + +## 1. 页面从哪来:M8 → 合成 Schema → React 渲染 + +前端请求 `GET /api/meta/scene/SCENE_CREATE_ORDER`。`SceneSchemaService` 做**跨层合成**: + +1. 取 M4 场景 `SCENE_CREATE_ORDER` → 得 `flowSteps`、`permissionBind:[order_create]`、绑定命令 `OrderAggregate.CreateOrder`。 +2. 取 M8 中 `bindSceneId == SCENE_CREATE_ORDER` 的模板 `template_order_master_detail`,逐组件**富化**: + - 用 **M1** 补 `fieldType`(如 `totalAmount → decimal`、`quantity → int`)→ 前端据此选控件。 + - 用 **M5** 补 `masked`(`receiverPhone/contactPhone` 打脱敏标记)。 + - 对绑定到某聚合根标识的 `select`(`customerId→CustomerAggregate`、`productId→ProductAggregate`)补 `optionsSource`(下拉数据源端点)。 +3. 附 **M2** 命令入参/校验、**M3** 命令 API 地址(`/api/v1/order/create`)、**MetaRule** 绑定本场景的规则、以及溯源信息。 + +前端 `SceneForm` 拿到后按模板结构渲染: +- `card` + `aggregate_root` → **主信息卡片**(订单主字段) +- `table` + `aggregate_entity(OrderItem)` → **可增删行的明细表格** +- `card` + `aggregate_entity(PaymentTerm/DeliveryAddressEntity)` → **单行子实体卡片** +- 每个叶子组件按 `compType` 经 `FieldControl` 注册表映射到 antd 控件。 + +> 因此:改 M8 的 `compType/formLabel/span/childComponents` → 页面即变;这就是"仅维护模板、不改前端源码"。 + +--- + +## 2. 提交做什么:payload → 通用命令执行 + +用户填完点「提交」,前端按 M8 绑定把值组装为**领域结构**: + +```json +{ + "master": { "customerId":"C002", "totalCurrency":"CNY", "totalAmount":7997, "createTime":"..." }, + "details": { + "OrderItem": [ { "productId":"P001", "quantity":1, "itemPrice":6999, "itemCurrency":"CNY" } ], + "PaymentTerm": [ { "paymentType":"月结", "dueDays":30 } ], + "DeliveryAddressEntity": [ { "province":"广东", ... , "receiverPhone":"13800002222" } ] + } +} +``` + +POST 到 `/api/scene/SCENE_CREATE_ORDER/execute`。`CommandEngine.execute` **逐 M4 flowSteps 解释**: + +| flowStep(M4) | 引擎动作 | 依据层 | +|---|---|---| +| `permission`(隐式) | `order_create` 的 `allowPrincipals` 是否含当前主体 | M5 | +| `readOnlyCheck: CustomerAggregate` | 校验 `master.customerId` 存在于客户表 | M4 + M1 + M3 | +| `bindCommand: CreateOrder` | 见下方"命令执行" | M2 | +| `return` | 汇总结果返回 | M4 | + +**命令执行(CreateOrder)内部**: + +1. **校验**:必填项来自 **M8 `required`** 标记(与前端同源);`收货手机号格式`、`dueDays>=0` 等来自 **M2 `validations`** 文本的解释。 +2. **风控(MetaRule / Aviator)**: + - 订单级:以 `totalAmount` + 客户 `customerLevel`(查客户表得)评估 `R_ORDER_AMOUNT_LIMIT`: + `totalAmount > #{single_order_max_amount} && customerLevel != "VIP"` → 命中则 **REJECT**。 + - 逐商品级:以每个商品 `stockNum` 评估 `R_STOCK_LOW_WARN`:`stockNum < #{stock_warn_min}` → **ALERT**(不阻断)。 +3. **落库(M3)**:生成 `orderId`;据 `t_order_main` 的 `fieldMap` 写主单;据组合子实体(M1 relations)逐个写 `t_order_item / t_order_payment_term / t_order_address`,并补父外键 `order_id`。**列名、表名全部来自 M3**。 +4. **发事件(ME/Outbox)**:据 M2 `emitEvents` 写 `OrderCreated / OrderItemAdded / ...` 到 `t_domain_outbox`;topic 取自 ME(无则按命名派生)。 +5. **跨聚合最终一致(ME)**:匹配 `crossAggConsistencyRules` 中 `OrderCreated → ProductAggregate.StockDeduct`,逐商品扣减库存(库存列同样取自 M3)。 + +页面右侧「执行追踪」会显示真实的逐步日志,例如: + +``` +M5 permission — 主体 normal_user 通过权限校验 [order_create] +M4 readOnlyCheck — 校验 CustomerAggregate 引用存在:通过 +M2/M8 validate — 命令 CreateOrder 入参校验通过 +MetaRule metaRule — 风控规则评估完成,命中 0 条;REJECT=0,ALERT=0 +M3 persist — 写入 Order(O3EQD8P2591) 及 3 条子实体,表结构源自 M3 +ME emitEvents — 写入 Outbox 事件 4 条 +ME crossConsistency — 触发 StockDeduct 扣减 1 个商品库存 +M4 return — 场景执行完成,返回结果 +``` + +--- + +## 3. 推演三条"改模型即改行为" + +### (a) 大额拦截:改 MetaRule +把 `single_order_max_amount: 50000 → 100`,热重载。**同一笔** 普通用户 7997 元订单: +- 改前:`7997 > 50000` 为假 → 通过; +- 改后:`7997 > 100 && level!="VIP"` 为真 → **REJECT**,返回 `普通用户单笔订单不可超过50000元...`。 +> 规则只维护一份;前端「提交前预检」调用同一后端 MetaRule,实时给出同样结论。 + +### (b) 表单改版:改 M8 +把 `input_currency` 的 `compType: input → select`、改 `formLabel`,热重载。「新增订单」页的"结算币种"从文本框变为下拉、标签同步更新——**未动任何 React 代码**。 + +### (c) 字段类型:改 M1 +把某字段 `type: string → int`,热重载。合成 Schema 的 `fieldType` 变化 → 前端控件从 `Input` 变为 `InputNumber`;如需落库再在 M3 增列,`SchemaInitializer` 于重载时 `ALTER TABLE ADD COLUMN` 自动补列。 + +--- + +## 4. 为什么这套架构成立 + +- **单一事实源**:字段口径(M1)被前端组件、后端校验、DDL、事件负载共同引用,天然对齐,杜绝前后端不一致。 +- **解释而非生成**:引擎在运行时解释模型,故"热重载即变",无需重新编译/发版。 +- **通用而非专用**:新增一个业务场景 = 新增一套 M1/M2/M3/M4/M8 描述 + 绑定 MetaRule,无需新增 Controller/Service/React 页面。 +- **关注点分层**:业务专家维护 M8 模板与 MetaRule 规则;架构师维护 M0–M7 底座;二者解耦。 diff --git b/docs/审计报告-2026-07-20.md a/docs/审计报告-2026-07-20.md new file mode 100644 index 0000000..00d8ebc --- /dev/null +++ a/docs/审计报告-2026-07-20.md @@ -0,0 +1,917 @@ +# 审计报告 · 本体驱动 DDD+EDA 元数据引擎 + +- **日期**:2026-07-20 +- **范围**:`engine/`(17 个 Java 文件 ~1.9k 行)、`web/`(7 个 TS/TSX ~0.7k 行)、`models/`(11 份 YAML ~1.3k 行)、`deploy/`、配置 +- **方法**:6 维度并行探查(铁律 / 正确性 / 安全 / EDA 一致性 / 前端 / 模型完整性)→ 每条发现由两名独立验证者对抗核验(一名"复现"、一名专职"反驳")。共 116 个 agent;**55 条候选 → 5 条被驳回、50 条通过复现验证、其中 32 条连"反驳者"也无法推翻**。全部源码另经人工逐条复核。 + +> 置信度分层(本报告术语): +> - **A 级·双重确认**(32 条):复现者与反驳者**都**判定为真实缺陷 —— 最高置信。 +> - **B 级·单边确认/严重度存疑**(18 条):复现者判定真实、反驳者提出异议(多为严重度或"是否属既定设计"之争)—— 需人工裁定。 +> - **驳回**(5 条):复现者无法构造真实触发场景,或属"文档态/演示简化"的既定设计。 + +--- + +## 一句话结论 + +**"铁律"被系统性违背:`engine/` 不是通用解释器,而是"带通用脚手架的订单专用解释器"。** +README 承诺的"改模型即改行为、不改一行代码"只在极窄切片内成立(M8 的 `compType`/`formLabel`/`span`、MetaRule 三个白名单变量的阈值、M1 类型 → DDL)。一旦**新增第二个聚合/场景、重命名字段、或修改跨聚合规则**,都必须改 Java/TS 代码。 + +唯一的**数据损坏级**问题是**超卖链路**:库存不足/不存在时订单仍提交,且 demo 中无任何补偿接线。 + +--- + +## 二、按主题的执行摘要 + +### A. 🔴 铁律违背(本项目最核心问题) + +两个"通用执行器"里塞满订单/客户/商品专用逻辑: + +| 位置 | 硬编码内容 | +|---|---| +| `CommandEngine.java:245-255` `runRules()` | 风控变量 `totalAmount`/`customerLevel`/`stockNum`,实体 `OrderItem`、字段 `productId` | +| `CommandEngine.java:225-234` `validate()` | **子串匹配中文** `"手机号"`/`"dueDays"` 触发校验,写死 `DeliveryAddressEntity.receiverPhone`、`PaymentTerm.dueDays` | +| `CommandEngine.java:276-294` | `customerLevel()` / `productStock()` 整段客户域/商品域方法(写死 `CustomerAggregate`/`ProductAggregate`/`cust_level`/`stock_num`) | +| `CommandEngine.java:185-186` | 用字面量 `"OrderCreated"` 触发跨聚合一致性,写死 `productId`/`quantity` | +| `CommandEngine.java:386` | `idPrefix()` = 聚合首字母(注释 "O for Order") | +| `EventEngine.java:73,89` | 跨聚合一致性只认 `"StockDeduct"`,写死 `Product`/`stockNum`/`ProductStockDeducted` | +| `EventEngine.java:25` | Outbox 表名 `t_domain_outbox` 写死,**无视 ME YAML 里可配的 `outbox.table`** | +| 前端 `SceneForm.tsx:96`、`panels.tsx:34/47/143`、`api.ts:27`、`App.tsx:15`、`DataController.java:31` | 写死 `orderId`、"订单创建成功"、`/data/orders/list`、`OrderAggregate`、订单/库存列名 | + +> **验证**:把 M1 里 `stockNum` 改名,`productStock()`/`deductStock()` 会**静默失效**(走 `getOrDefault` 兜底列名,取不到就当"无库存/无规则"),而非报错 —— 即"改模型不该改代码"承诺被打破的直接证据。 + +### B. 🔴 正确性 / 数据一致性(可触发的真实 Bug) + +1. **超卖 / 库存不扣,订单照样提交**(`CommandEngine.java:185-197`)——`deductStock` 返回 `FAIL_INSUFFICIENT`/`SKIP_NOT_FOUND` 时**不抛异常**,`@Transactional execute()` 仍 `success=true` 提交。用 P002(库存 8) 下单 20 件即可复现。*(严重度存疑:若视作"事件最终一致 + 补偿"则可接受,但 demo 里没有任何补偿接线,`pollAndPublish` 只翻状态,故实为超卖漏洞。)* +2. **并发超卖 / 负库存**(`EventEngine.java:100-112`)——先 `SELECT` 读库存、Java 判 `current=?`、无行锁。 +3. **无幂等**(`CommandEngine.java:67`)——无请求键;双击/重试 = 重复订单 + 重复事件 + 重复扣减。 +4. **`reload()` 与在途请求数据竞争**(`ModelRepository.java:51-64`)——`clear()` 后重填普通 `LinkedHashMap`,读取方无同步。 +5. **非数字入 `int/decimal` 崩溃**(`DynamicRepository.java:104`)——预检通过、落库 `new BigDecimal("abc")` 抛未捕获异常 → HTTP 500。 +6. `Map.of()` 遇 null `productId`/`quantity` NPE(`:183`);`deductStock` 对 NULL 库存列 `Long.parseLong("null")` 崩溃(`EventEngine.java:105`);`toBig()` 吞异常返回 0 → 非数字 `dueDays` 绕过 `>=0`(`:419`)。 +7. **`executeCommand` 一律按"新建聚合根"落库**(`:148-168`)——`SCENE_MODIFY_ORDER_ADDR`/`SCENE_CANCEL_ORDER` 会**插入新订单**而非改/删(目前仅 Create 接线,属潜伏问题)。 + +### C. 🟠 安全 + +- **身份可伪造**(`CommandController.java:34`)——`X-Principal` 头直取、无认证,任何人可自称 `backend_admin`。 +- **权限失败即放行**(`CommandEngine.java:359`)——场景无 `permissionBind` 时 `return true`。 +- **越权读全表**——`/api/data/{aggregateId}`、`/orders/list` 无鉴权吐整表;`/api/meta/model/M5` 匿名暴露完整安全模型;`/api/meta/reload` 匿名改状态。 +- **`storageEncrypt` 从未兑现**(`M5:30`)——声明手机号加密存储、前端也提示"存储加密",实际**明文入库**。 +- **`totalAmount` 客户端可篡改**(`CommandEngine.java:246`)——从不按明细重算,且直接喂给风控 → 既能低报又能绕过限额。 +- CORS `allowedOriginPatterns("*")` 未按 profile 隔离(含 prod);默认 profile 开无密码 H2 控制台;prod/compose 弱默认口令;负数 `quantity` 反向"增库存"。 +- *非问题澄清:动态 SQL 表名/列名虽字符串拼接,但来源可信 YAML、值走 `?` 参数化,用户 payload 无法注入列名 → 仅纵深防御建议。* + +### D. 🟡 EDA / 事件架构(与宣称的落差) + +- 跨聚合一致性是**事务内同步内联调用**(`CommandEngine.java:185`),非 Outbox 驱动;`pollAndPublish` 只把 `NEW→PUBLISHED`,事件从不被消费 → "事件驱动最终一致"名不副实。 +- `ME OrderCancelled→StockDeduct` 规则**永不触发**(引擎只用字面量 `"OrderCreated"` 调用),且语义**相反**(取消应回补库存)。 +- `M2 CreateOrder.emitEvents` 引用 `OrderItemAdded`/`OrderPaymentTermSet`/`OrderDeliveryAddressSet`,`ME` **未定义**这三个事件。 +- `eventPayload` 只从 `master` 取值(`:378-380`)→ payload 引用子实体/非领域字段的事件值为 null。 + +### E. 🟡 模型完整性 + +- `M8:20` 模板绑定 `SCENE_CREATE_CUSTOMER`,`M4` **从未定义** → 死模板。 +- `M0` 自称"全域校验根规范"但**从未被引擎执行**,各层实际违反 M0 自身模式;`MetaRule.vip_discount_limit` 定义但无人引用。 +- *合理设计:M6/M7 加载但不被读取 —— README 明确标注"文档态",不算缺陷。* + +--- + +## 三、修复优先级 + +1. **超卖 + 幂等**(唯一数据损坏级):库存不足在 persist 前预留并中止、或真正接补偿;`execute()` 加幂等键。 +2. **收敛安全面**:真实认证替代 `X-Principal`、权限默认拒绝、`/api/meta/*` 与 `/api/data/*` 加鉴权、CORS 按 profile 收紧、兑现 `storageEncrypt`、服务端重算 `totalAmount`。 +3. **兑现"通用"承诺**:把 `CommandEngine`/`EventEngine` 的订单/商品硬编码抽为"由 M2 结构化 `validations`、ME `crossAggConsistencyRules` 通用驱动";否则应在 README 诚实标注当前通用边界。 + +--- + +## 附录 A · A 级发现 —— 双重确认(复现者与反驳者一致判定为真实,共 32 条) + +> 本级发现两名对抗验证者独立都无法推翻,置信度最高。 + +### A1. Cross-aggregate consistency is a hardcoded stock-deduction, not model-driven + +- **严重度**:BLOCKER **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/event/EventEngine.java:73` +- **缺陷**:The supposedly-generic EDA consistency engine only knows how to execute one specific business command (StockDeduct) and hardcodes the product aggregate root name, the stock field/column, item field names, and the emitted event. +- **证据**: +``` +Line 73: `if ("StockDeduct".equals(targetCmd)) {` ; deductStock(): line 86 `repo.fieldMap(productAgg, "Product")`, line 89 `String stockCol = fmap.getOrDefault("stockNum", "stock_num");`, line 90 `Object productId = item.get("productId");`, line 91 `item.getOrDefault("quantity", item.get("deductQty"))`, line 96 `action.put("targetCommand", "StockDeduct");`, line 117 `append(productAgg, "ProductStockDeducted", Map.of("productId", productId, "stockNum", remaining));` +``` +- **影响 / 触发场景**:ME-event.yaml declares `targetCommand: StockDeduct` generically, but the engine branches on the literal string "StockDeduct" and then applies a hand-coded decrement of the literal field `stockNum`/column `stock_num`, keyed by literal `productId`, emitting literal `ProductStockDeducted`. Renaming StockDeduct/stockNum/productId/ProductStockDeducted or the `Product` root in the YAML — or declaring any *other* cross-agg consistency command (e.g. a credit-limit deduction) — silently does nothing, because the only executable path is this order/product-specific if-branch. The decrement semantics should come from the M2 StockDeduct command definition + M1 field, executed generically (e.g. via CommandEngine), not baked in. +- **建议修复**:Drive the consistency action from the model: resolve the target command from M2, its target field/operation and emitted event from M2/ME, the root entity name via models.rootName(productAgg), and the mutated field via M1/M3 — no literal command/field/event names in Java. +- **复现核验**:All quoted evidence is verbatim-confirmed in engine/src/main/java/com/onto/engine/event/EventEngine.java. Line 73 `if ("StockDeduct".equals(targetCmd)) {` gates the entire cross-aggregate consistency execution on a literal business command name. deductStock() hardcodes the aggregate-root name (line 86 `repo.fieldMap(productAgg, "Product")`), the stock field/column (line 89 `fmap.getOrDefault("stockNum", "stock_num")`), the item keys (line 90 `item.get("productId")`, line 91 `item.getOrDefault("quantity", item.get("deductQty"))`), the command label (line 96), and the emitted event with its payload keys (line 117 `append(productAgg, "ProductStockDeducted", Map.of("productId", productId, "stock … +- **反驳核验**:I attempted to kill this finding on five fronts and all failed against the actual code. + +REACHABILITY: Not dead code. CommandEngine.java:186 calls eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)) inside the live CreateOrder path, and deductStock() performs a real repo.execute("UPDATE " + table + " SET " + stockCol + " = " + stockCol + " - ? WHERE ...") at EventEngine.java:111. So it is live business logic. + +NOT A FALLBACK: EventEngine.java:73 `if ("StockDeduct".equals(targetCmd))` is a hard gate — cross-agg consistency runs ONLY when the model's targetCommand is the literal string "StockDeduct". deductStock() then hardcodes the aggregate root "Product" (lines 86-88 r … + +### A2. CommandEngine feeds the rule engine a hardcoded order/product-specific variable set + +- **严重度**:BLOCKER **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:244` +- **缺陷**:runRules() — the heart of generic risk control — hardcodes the exact business variable names, entity, and field that MetaRule expressions reference, so the generic RuleEngine is fed a non-generic, order-aware payload. +- **证据**: +``` +Lines 244-257: `Object level = customerLevel(ctx);` / `orderVars.put("totalAmount", ctx.master.get("totalAmount"));` / `orderVars.put("customerLevel", level);` ... `for (Map it : ctx.details.getOrDefault("OrderItem", List.of())) { Object pid = it.get("productId"); Long stock = productStock(pid); ... for (RuleHit h : ruleEngine.evaluate(ctx.sceneId, Map.of("stockNum", stock)))` +``` +- **影响 / 触发场景**:RuleEngine.evaluate() is genuinely generic (it can enumerate a rule's referenced variables via exp.getVariableNames() and only fires when all are present), but CommandEngine hand-builds a map containing exactly `totalAmount`, `customerLevel`, `stockNum` and iterates the literal `OrderItem` entity reading literal `productId`. These are precisely the variables in MetaRule-business.yaml's matchConditions. Rename totalAmount/stockNum/customerLevel in M1, rename the OrderItem entity, or add a new rule referencing any other field, and the rule silently never fires — defeating the 'edit-YAML-only' promise for risk rules. The variable set should be derived from the rules' referenced variable names and resolved generically from payload/master/details/referenced aggregates. +- **建议修复**:Collect required variable names from the scene's MetaRule expressions, then resolve each generically (master field, detail field, or via M1 refAggregate lookup) instead of the hardcoded totalAmount/customerLevel/stockNum/OrderItem/productId literals. +- **复现核验**:Evidence verified verbatim in engine/src/main/java/com/onto/engine/command/CommandEngine.java, runRules() lines 241-260: line 246 `orderVars.put("totalAmount", ctx.master.get("totalAmount"))`, line 247 `orderVars.put("customerLevel", level)`, line 251 `ctx.details.getOrDefault("OrderItem", List.of())`, line 252 `it.get("productId")`, line 255 `Map.of("stockNum", stock)`. Helper methods extend the violation: customerLevel() (276-284) hardcodes customerId/CustomerAggregate/Customer/customerLevel; productStock() (286-294) hardcodes ProductAggregate/Product/stockNum. These identifiers are precisely those the iron rule enumerates as forbidden in engine/.\n\nThe finding's core mechanism is confirm … +- **反驳核验**:Confirmed by reading engine/.../command/CommandEngine.java:241-260 and rule/RuleEngine.java:34-67 and models/MetaRule-business.yaml. runRules() hand-builds the rule-engine payload with Java string literals: orderVars.put("totalAmount", ctx.master.get("totalAmount")) and orderVars.put("customerLevel", level) (lines 246-247), iterates ctx.details.getOrDefault("OrderItem", List.of()) reading it.get("productId") (lines 251-252), and calls ruleEngine.evaluate(ctx.sceneId, Map.of("stockNum", stock)) (line 255). These literals — totalAmount, customerLevel, stockNum, the OrderItem entity, the productId field — are exactly the business identifiers the iron rule forbids in engine/ (the task itself nam … + +### A3. No authentication; authorization trusts spoofable X-Principal header + +- **严重度**:BLOCKER **维度**:security **位置**:`engine/src/main/java/com/onto/engine/web/CommandController.java:34` +- **缺陷**:The caller's identity is taken verbatim from a client-supplied HTTP header with no authentication layer, so any client can act as any principal (including backend_admin). +- **证据**: +``` +@RequestHeader(value = "X-Principal", required = false) String principal) { + String p = (principal == null || principal.isBlank()) ? defaultPrincipal : principal; // CommandController L34-35; permitted() only does allow.contains(principal) (CommandEngine L362) +``` +- **影响 / 触发场景**:M5 declares authMode: oauth2_jwt, but pom.xml has NO spring-boot-starter-security and there is no filter/JWT validation anywhere. The principal is whatever the caller puts in X-Principal. Attack: curl -X POST http://host:8080/api/scene/SCENE_CANCEL_ORDER/execute -H 'X-Principal: backend_admin' -d '{...}' passes every permission check. Even with no header, the default 'normal_user' is authorized for order_create/modify/cancel, so all command scenes are effectively open to anonymous callers. The entire M5 permission model is decorative. +- **建议修复**:Introduce real authentication (Spring Security + JWT per the declared oauth2_jwt mode); derive the principal from a verified token/session, never from an arbitrary request header. Reject requests without a validated identity. +- **复现核验**:Every element of the finding is verified in the actual code. + +1. Spoofable identity (CommandController.java:34-36): `execute(...)` takes the principal verbatim from a client header — `@RequestHeader(value = "X-Principal", required = false) String principal`, then `String p = (principal == null || principal.isBlank()) ? defaultPrincipal : principal; return commandEngine.execute(sceneId, payload, p);`. The identity is 100% client-controlled with no validation. + +2. Authorization only does a membership test (CommandEngine.java:357-365): `permitted()` reads the scene's `permissionBind`, then for each perm checks `allow.contains(principal)` (L362) against M5's `allowPrincipals`. Nothing verifies t … +- **反驳核验**:The finding is factually confirmed by the actual files and survives refutation. CommandController.java:34-36 reads the caller identity verbatim from the client-controlled `X-Principal` header and, when absent/blank, falls back to `defaultPrincipal` = `normal_user` (L16-17, L35). CommandEngine.permitted() (L357-364) performs only authorization: `allow.contains(principal)` against M5 allowPrincipals — there is no authentication. I verified there is no auth layer: `grep spring-boot-starter-security` on engine/pom.xml returns 0, and there is no SecurityFilterChain / OncePerRequestFilter / HandlerInterceptor / @PreAuthorize anywhere in engine/src. WebConfig.java only sets permissive CORS. models/ … + +### A4. convert() throws NumberFormatException on non-numeric int/decimal field; precheck approves it + +- **严重度**:HIGH **维度**:correctness **位置**:`engine/src/main/java/com/onto/engine/persist/DynamicRepository.java:105` +- **缺陷**:A non-numeric (or comma/locale-formatted) value in any int/decimal domain field passes validation and precheck but crashes with an uncaught NumberFormatException at persist time, rolling back the transaction and returning HTTP 500. +- **证据**: +``` +case "int" -> s.isEmpty() ? null : new BigDecimal(s).longValue(); +case "decimal" -> s.isEmpty() ? null : new BigDecimal(s); // new BigDecimal("6,999.00") / new BigDecimal("abc") throws NumberFormatException, unguarded +``` +- **影响 / 触发场景**:CommandEngine.validate() never checks that numeric domain fields are actually numeric (only required/phone/dueDays<0), and RuleEngine swallows the Aviator error, so precheck returns reject=false. Concrete: POST /api/scene/SCENE_CREATE_ORDER/execute with body {"master":{"customerId":"C001","totalCurrency":"CNY","totalAmount":"6,999.00"},"details":{"OrderItem":[{"productId":"P001","quantity":1}]}} (default principal normal_user is authorized). Precheck says OK; execute persists Order and hits new BigDecimal("6,999.00") -> NumberFormatException -> 500. Same crash for totalAmount="abc" or dueDays="abc". The 'real-time validation shares one source' guarantee is violated. +- **建议修复**:In convert(), catch NumberFormatException and either reject via a validation error or coerce safely; and/or add a generic numeric-format validation in CommandEngine.validate() (driven by M1 field type) so precheck and execute agree before reaching persist. +- **复现核验**:Verified the full chain against the actual source. DynamicRepository.java:104-105 does new BigDecimal(s).longValue() (int) and new BigDecimal(s) (decimal) with no guard; BigDecimal rejects grouping separators ("6,999.00") and non-numeric ("abc"), throwing NumberFormatException. totalAmount is type: decimal in M1 (M1-domain.yaml:57-58, an aggregate-root attribute) and is present in the M3 root fieldMap as total_amt (M3-deploy.yaml:60), so CommandEngine.executeCommand -> repo.insertDomain(rootName) -> convert("decimal", value) is on the persist path (CommandEngine.java:155). + +CommandEngine.validate() (lines 205-239) never validates numeric parseability: it checks only M8 required blank-ness, r … +- **反驳核验**:The finding survives refutation; every link in its chain is confirmed by the code. (1) DynamicRepository.java:104-105 does `new BigDecimal(s)` for int/decimal with no try/catch — this throws NumberFormatException on "6,999.00" (grouping separators are rejected by BigDecimal) and on "abc". Contrast the guarded parseDateTime (lines 118-126) and CommandEngine.toBig (lines 418-420), showing convert() is genuinely unprotected. (2) totalAmount is type `decimal` (M1-domain.yaml:58) and mapped totalAmount->total_amt (M3-deploy.yaml:60), so it is passed to convert("decimal", value) during persist (CommandEngine:155 insertDomain). (3) CommandEngine.validate() (lines 205-239) performs no numeric-parse … + +### A5. Non-atomic check-then-decrement on stock allows oversell / negative stock under concurrency + +- **严重度**:HIGH **维度**:eda **位置**:`engine/src/main/java/com/onto/engine/event/EventEngine.java:100` +- **缺陷**:deductStock reads current stock with a plain SELECT, checks current= qty' guard and no row lock, so two concurrent orders can both pass the check and drive stock negative. +- **证据**: +``` +L100: Map row = repo.findByPk(table, pkCol, productId); +L105: long current = Long.parseLong(String.valueOf(row.get(stockCol))); +L106: if (current < qty) { ...FAIL_INSUFFICIENT...; return action; } +L111: repo.execute("UPDATE " + table + " SET " + stockCol + " = " + stockCol + " - ? WHERE " + pkCol + " = ?", qty, productId); +The SELECT (findByPk) takes no lock; the UPDATE has no stock>=qty predicate. +``` +- **影响 / 触发场景**:Product P001 stock=10. Two concurrent execute() calls each with quantity=8. Under READ_COMMITTED both findByPk read 10; both evaluate current 2. T2's UPDATE then applies to the committed value: 2-8 = -6, commits. Final stock = -6: 16 units sold from 10 in stock. TOCTOU race with no compensating guard. +- **建议修复**:Make the decrement conditional and atomic: 'UPDATE t SET stock = stock - ? WHERE pk = ? AND stock >= ?' and treat rowsAffected==0 as FAIL_INSUFFICIENT, or SELECT ... FOR UPDATE before the check. +- **复现核验**:Verified all quoted evidence in engine/.../event/EventEngine.java: L100 findByPk (plain SELECT), L105-106 Java-side current= qty` guard. Cross-checked supporting facts: (1) DynamicRepository.findByPk (L80-84) uses `SELECT * ... WHERE pk = ?` with NO `FOR UPDATE`, so the read takes no lock. (2) SchemaInitializer.sqlType (L102) maps int->BIGINT with no CHECK/UNSIGNED, so the DB accepts negative stock. (3) The path is genuinely concurrent and reachable: CommandController.execute (L31-37) calls CommandEngine.execute with no synchronization; execute is @Transactional and reaches deductStock via appl … +- **反驳核验**:Refutation failed on every angle; the finding is a genuine, reachable TOCTOU that I reproduced against the exact stack. + +Reachability: deductStock (EventEngine.java:85-119) runs synchronously in the request thread: CommandEngine.execute (@Transactional, L67) -> executeCommand -> applyCrossConsistency("OrderCreated",...) (L186) -> deductStock (L77). Spring MVC/Tomcat is multi-threaded and HikariCP gives each concurrent request its own transaction, so two concurrent POST /command for one product race. + +No guard at any layer: findByPk issues a lock-free plain SELECT (DynamicRepository.java:80-84); the Java check `if (current < qty)` (EventEngine.java:106) is check-then-act; the UPDATE `SET stoc … + +### A6. reload() mutates a shared non-thread-safe map in place while request threads read it unsynchronized + +- **严重度**:HIGH **维度**:eda **位置**:`engine/src/main/java/com/onto/engine/model/ModelRepository.java:54` +- **缺陷**:load()/reload() do layers.clear() then re-populate a plain LinkedHashMap under 'synchronized', but every reader (scene(), command(), aggregates(), etc.) accesses that same map with no synchronization, so a reload racing an in-flight execute() can observe a half-empty/corrupt model. +- **证据**: +``` +L48: private final Map layers = new LinkedHashMap<>(); +L50: @PostConstruct public synchronized void load() { +L54: layers.clear(); +L54-62: loop re-puts each layer. +L67: public synchronized void reload() { load(); } +Readers are NOT synchronized, e.g. L108-110 aggregates(): return asList(asMap(layers.get("M1")).get("aggregates")); and scene()/command()/templateByScene() all read layers directly. 'synchronized' guards writers against each other but not against concurrent readers. +``` +- **影响 / 触发场景**:Thread A is inside execute() calling models.scene()/command()/templateByScene() while an admin POSTs /api/meta/reload. Thread B enters synchronized load() and runs layers.clear(). Thread A now sees layers.get("M4") == null -> scene() returns empty map -> permitted() (empty permissionBind) returns true, flowSteps is empty, and execute() returns success=true having created no order; or SceneSchemaService throws 'no scene'. Concurrent clear()+put()/get() on a HashMap can also lose entries or, during resize, spin. Model reads mid-flight are not a consistent snapshot. +- **建议修复**:Build a new immutable map locally and publish it atomically to a volatile reference (this.layers = Map.copyOf(fresh)) instead of clear()+put on a shared map; readers then always see a complete prior or new snapshot. +- **复现核验**:Evidence verified in ModelRepository.java. `layers` (L48) is a plain LinkedHashMap. load()/reload() are `synchronized` (L50-51, L67) and do `layers.clear()` (L53, finding said L54 — trivial off-by-one) then re-put 11 layers in a disk-I/O + YAML-parse loop (L54-62). Every reader is unsynchronized: scenes()/scene() L222-232, command() L184-195, templateByScene() L262-268, aggregates() L108-110 all do bare `layers.get(...)` with no lock held. `synchronized` locks `this` and only mutually excludes writers; readers never acquire that monitor, so reader-vs-writer is an unguarded data race.\n\nReachability is real: MetaController.reload() is POST /api/meta/reload (L76-85) on one Tomcat thread; Comm … +- **反驳核验**:The finding is a genuine, reachable data race that my best refutation could not kill. ModelRepository.java:48 declares `private final Map layers = new LinkedHashMap<>()` — final pins only the reference; contents are mutated. `synchronized load()` (L51) does `layers.clear()` (L53) then re-puts 11 entries (L54-62); `reload()` (L67) calls it. Every reader (scenes() L222-224, scene() L226, command() L184, aggregates() L108, templateByScene() L262) reads `layers.get(...)` with no synchronization, so there is no happens-before edge from writer to readers under the JMM. Both paths are reachable and concurrent: reload is exposed at MetaController.java:76 (POST /api/meta/reload) and is … + +### A7. OrdersEventsPanel hardcodes order/customer business fields + +- **严重度**:HIGH **维度**:frontend **位置**:`web/src/panels.tsx:146` +- **缺陷**:The 'orders' table columns are hardcoded to order/customer-specific field names instead of being derived from any model layer. +- **证据**: +``` +columns={[ + { title: '订单号', dataIndex: 'orderId' }, + { title: '客户', dataIndex: 'customerId' }, + { title: '金额', dataIndex: 'totalAmount' }, + { title: '币种', dataIndex: 'totalCurrency' }, +]} +``` +- **影响 / 触发场景**:orderId/customerId/totalAmount/totalCurrency are business identifiers baked into the generic renderer. Renaming any of these in M1/M3 YAML (or switching the demo to a non-order aggregate) leaves this list showing empty columns — the panel is not schema-driven, directly violating the iron rule that changing requirements means editing YAML only. +- **建议修复**:Drive the list columns from a schema (e.g. OrderAggregate root attributes from /api/meta or a dedicated list-schema endpoint) so keys/labels come from M1/M8, not string literals. +- **复现核验**:Evidence verified exactly at web/src/panels.tsx:146-151: OrdersEventsPanel's 'orders' table columns are hardcoded to `dataIndex: 'orderId' | 'customerId' | 'totalAmount' | 'totalCurrency'` (plus rowKey="orderId" at line 144). These are order/customer business identifiers baked into web/, which the iron rule requires to be a generic renderer with no hardcoded business fields. The task definition literally lists `customerId` and `totalAmount` as example violations. + +Reproduction chain confirmed against the actual data path: api.orders() (api.ts:27) -> GET /data/orders/list -> DataController.orders() (DataController.java:32) -> DomainReadService.listRoot("OrderAggregate"), whose toDomain() (Dom … +- **反驳核验**:Code confirmed at web/src/panels.tsx:146-151: a static antd `columns` literal hardcodes business field names as dataIndex — `{title:'订单号', dataIndex:'orderId'}`, `{title:'客户', dataIndex:'customerId'}`, `{title:'金额', dataIndex:'totalAmount'}`, `{title:'币种', dataIndex:'totalCurrency'}`. `customerId` and `totalAmount` are listed verbatim in the iron rule as prohibited identifiers, and the rule covers all of web/. Every refutation angle fails: (1) Reachable — OrdersEventsPanel is rendered as the '订单/事件' tab in App.tsx:100. (2) Not a fallback — it is a fixed column list with no schema derivation; antd Table maps dataIndex straight onto row keys with no guarding default. (3) Triggerable impact — d … + +### A8. Success message hardcodes 'orderId' key and '订单' wording + +- **严重度**:HIGH **维度**:frontend **位置**:`web/src/engine/SceneForm.tsx:96` +- **缺陷**:On submit success the generic form reads r.orderId and says '订单创建成功', but the backend returns the root id under a dynamic field name. +- **证据**: +``` +message.success(`订单创建成功:${r.orderId}`) +``` +- **影响 / 触发场景**:CommandEngine.execute returns the root id via out.put(rootIdentifierField, rootId) where rootIdentifierField = models.rootIdentifier(aggregateId) — 'orderId' only by coincidence for OrderAggregate. For any other scene the toast shows '订单创建成功:undefined'. The order-specific field name and '订单' wording are hardcoded in the generic renderer, violating the iron rule. +- **建议修复**:Read the id generically (from the aggregate's root identifier field, or have the engine return a fixed key like result.rootId) and use schema.sceneName instead of the literal '订单创建成功'. +- **复现核验**:Evidence verified verbatim. web/src/engine/SceneForm.tsx:96 reads `message.success(`订单创建成功:${r.orderId}`)`, and this file's own header (lines 29-33) declares it the "场景通用渲染引擎" (generic scene render engine) — the web/src/engine/ layer the iron rule requires to be a generic renderer. The backend does NOT return the root id under a fixed `orderId` key: CommandEngine.java:149 computes `String rootIdentifierField = models.rootIdentifier(aggregateId)` and line 192 does `out.put(rootIdentifierField, rootId)`. ModelRepository.java:125-127 shows rootIdentifier reads `aggregateRoot(aggregateId).get("identifier")` from M1. M1-domain.yaml:51 has `identifier: orderId` for OrderAggregate only; other aggre … +- **反驳核验**:I could not kill this finding; it is a genuine, confirmed iron-rule violation. web/src/engine/SceneForm.tsx:96 reads `message.success(`订单创建成功:${r.orderId}`)`, hardcoding both the order-specific field name `orderId` and the "订单" wording inside a file whose own docstring (lines 29-33) declares it the generic scene render engine ("场景通用渲染引擎"). The backend contract confirms the coupling is wrong: CommandEngine.java:149,192 returns the root id under a DYNAMIC key — `String rootIdentifierField = models.rootIdentifier(aggregateId); ... out.put(rootIdentifierField, rootId);`. ModelRepository.java:125-127 shows `rootIdentifier` returns the aggregate's `identifier` from M1; it equals "orderId" only for … + +### A9. customerLevel() is a dedicated customer-domain method inside the generic engine + +- **严重度**:HIGH **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:276` +- **缺陷**:A whole private method hardcodes the CustomerAggregate/Customer names, the customerId reference field, and the customerLevel field (with fallback column cust_level) — pure order-domain business logic in a file that must be a generic interpreter. +- **证据**: +``` +Lines 276-284: `Object cid = ctx.master.get("customerId");` / `String table = repo.tableName("CustomerAggregate", "Customer");` / `String pk = repo.pkColumn("CustomerAggregate", "Customer");` / `String levelCol = repo.fieldMap("CustomerAggregate", "Customer").getOrDefault("customerLevel", "cust_level");` +``` +- **影响 / 触发场景**:The knowledge that 'the order references a customer, which has a level attribute used by a rule' is encoded in Java, not YAML. M1 already models this relationship (Order.customerId has refAggregate: CustomerAggregate, and Customer has attribute customerLevel). Renaming customerId/customerLevel/CustomerAggregate/Customer in the YAML — or pointing the rule at a different referenced aggregate — silently breaks the large-order risk rule. Even the physical column fallback 'cust_level' is hardcoded. +- **建议修复**:Generically resolve referenced-aggregate attribute values using the M1 refAggregate/refIdentifier metadata of the field the rule needs, so no aggregate/entity/field/column literal appears in Java. +- **复现核验**:VERIFIED verbatim at CommandEngine.java:276-284. The private method customerLevel(Ctx) hardcodes five distinct business identifiers that the iron rule says must live only in YAML: +- Line 277: `Object cid = ctx.master.get("customerId");` — the order's reference-field name. +- Line 279: `repo.tableName("CustomerAggregate", "Customer")` — aggregate id + root name. +- Line 280: `repo.pkColumn("CustomerAggregate", "Customer")`. +- Line 281: `repo.fieldMap("CustomerAggregate", "Customer").getOrDefault("customerLevel", "cust_level")` — the domain attribute name AND a hardcoded physical column fallback `cust_level`. + +This is genuine order/customer-domain business knowledge ("an order references a custo … +- **反驳核验**:Confirmed genuine iron-rule violation at engine/.../command/CommandEngine.java:276-284. The method customerLevel() hardcodes business identifiers as Java string literals: line 277 `ctx.master.get("customerId")`, lines 279-280 `repo.tableName("CustomerAggregate","Customer")` / `repo.pkColumn("CustomerAggregate","Customer")`, and line 281 `repo.fieldMap("CustomerAggregate","Customer").getOrDefault("customerLevel","cust_level")`. The iron rule text explicitly lists `CustomerAggregate` and `customerId` as example violations, so this is a direct match — and CommandEngine's own header (lines 19-20) plus CLAUDE.md declare this file must contain NO customer-specific code. + +Every refutation angle fai … + +### A10. productStock() is a dedicated product-domain method inside the generic engine + +- **严重度**:HIGH **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:286` +- **缺陷**:A whole private method hardcodes ProductAggregate/Product names and the stockNum field (fallback column stock_num) to look up inventory — product-specific business logic baked into the generic executor. +- **证据**: +``` +Lines 286-294: `String table = repo.tableName("ProductAggregate", "Product");` / `String pk = repo.pkColumn("ProductAggregate", "Product");` / `String stockCol = repo.fieldMap("ProductAggregate", "Product").getOrDefault("stockNum", "stock_num");` +``` +- **影响 / 触发场景**:The generic engine 'knows' there is a Product aggregate with a stockNum attribute and reads it directly to power the low-stock rule. Renaming ProductAggregate/Product/stockNum in the YAML silently breaks the stock-warning rule; adding a second inventory-bearing aggregate is impossible without code. Even the fallback column 'stock_num' is a hardcoded M3 detail. +- **建议修复**:Resolve the referenced aggregate + attribute needed by the rule from M1 refAggregate metadata generically; drop the ProductAggregate/Product/stockNum literals. +- **复现核验**:Evidence verified verbatim at CommandEngine.java:286-294. The private method `productStock(Object productId)` hardcodes the product-domain aggregate id "ProductAggregate", root name "Product", domain field "stockNum", and a magic fallback M3 column "stock_num": + L288 `String table = repo.tableName("ProductAggregate", "Product");` + L289 `String pk = repo.pkColumn("ProductAggregate", "Product");` + L290 `String stockCol = repo.fieldMap("ProductAggregate", "Product").getOrDefault("stockNum", "stock_num");` +It is reachable production logic: runRules() L251-257 iterates OrderItem details, calls productStock(pid) L253, and feeds the result as the rule variable "stockNum" (also hardcoded, L255 `M … +- **反驳核验**:Confirmed by reading engine/.../command/CommandEngine.java:286-294. The method productStock(Object productId) contains literal hardcoded business identifiers: line 288 `repo.tableName("ProductAggregate", "Product")`, line 289 `repo.pkColumn("ProductAggregate", "Product")`, line 290 `repo.fieldMap("ProductAggregate", "Product").getOrDefault("stockNum", "stock_num")`. These are exactly the identifiers the iron rule enumerates as violations (aggregate `ProductAggregate`, field `stockNum`), plus a concrete physical column name `stock_num`. Every refutation angle fails: (1) they are literal strings, not model-resolved values, so renaming ProductAggregate/Product/stockNum in YAML silently breaks t … + +### A11. validate() infers phone validation by string-matching Chinese free text and hardcodes the entity/field + +- **严重度**:HIGH **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:224` +- **缺陷**:Phone-format validation is triggered by searching the M2 validations free-text for the word '手机号', then applied to the literal DeliveryAddressEntity entity and receiverPhone field with an 11-digit regex. +- **证据**: +``` +Line 30: `private static final Pattern PHONE = Pattern.compile("^\\d{11}$");` ; lines 225-231: `if (cmdValidations.contains("手机号")) { for (Map row : ctx.details.getOrDefault("DeliveryAddressEntity", List.of())) { Object p = row.get("receiverPhone"); if (p != null && !PHONE.matcher(String.valueOf(p)).matches()) errors.add("收货手机号格式不合法:" + p); } }` +``` +- **影响 / 触发场景**:Three separate business bindings baked into supposedly-generic validation: (1) the field name receiverPhone, (2) the entity name DeliveryAddressEntity, (3) the very existence of the check keyed on a fuzzy substring match of the Chinese phrase '手机号' in the M2 validations prose. Renaming receiverPhone or DeliveryAddressEntity in the YAML, or rewording the M2 validation text, silently disables/misfires the check. Format constraints should be structured model data (e.g. an M1 field format/regex), not scraped from prose. +- **建议修复**:Add a structured format/regex constraint to M1 fields (or M2 structured validations) and validate generically by iterating model-declared constraints; remove the receiverPhone/DeliveryAddressEntity/'手机号' literals and the hardcoded 11-digit pattern. +- **复现核验**:Evidence verified verbatim in engine/src/main/java/com/onto/engine/command/CommandEngine.java. Line 30: `private static final Pattern PHONE = Pattern.compile("^\\d{11}$");`. Lines 224-231: `String cmdValidations = String.join(" ", stringList(models.command(ctx.cmdAggregate, ctx.cmdId).get("validations")));` then `if (cmdValidations.contains("手机号")) { for (Map row : ctx.details.getOrDefault("DeliveryAddressEntity", List.of())) { Object p = row.get("receiverPhone"); if (p != null && !PHONE.matcher(String.valueOf(p)).matches()) errors.add("收货手机号格式不合法:" + p); } }`. + +All three bindings are genuine business identifiers pulled from the single-source-of-truth YAML, not generic tokens: … +- **反驳核验**:Refutation failed on every angle. The code at CommandEngine.java:30 and 224-231 hardcodes three business bindings into the generic engine: the entity literal "DeliveryAddressEntity" (line 226), the field literal "receiverPhone" (line 227), and an 11-digit PHONE regex ^\d{11}$ (line 30), triggered by a substring scrape of Chinese prose "手机号" (line 225). The task's iron rule names receiverPhone and DeliveryAddressEntity verbatim as violations, and CLAUDE.md forbids "某个具体聚合的分支".\n\nReachability is confirmed, not a dead path: validate(ctx) is called from precheck() (line 52) and executeCommand() (line 119) on every command. M2-command.yaml:124 lists "收货手机号格式合法" in the CreateOrder validations, so … + +### A12. validate() hardcodes the dueDays field and PaymentTerm entity for a non-negative check + +- **严重度**:HIGH **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:232` +- **缺陷**:A 'dueDays >= 0' check is triggered by substring-matching 'dueDays' in the M2 validations text, then applied to the literal PaymentTerm entity and dueDays field. +- **证据**: +``` +Lines 232-237: `if (cmdValidations.contains("dueDays")) { for (Map row : ctx.details.getOrDefault("PaymentTerm", List.of())) { Object d = row.get("dueDays"); if (d != null && toBig(d).signum() < 0) errors.add("账期天数 dueDays 不可为负"); } }` +``` +- **影响 / 触发场景**:The engine hardcodes the PaymentTerm entity name and dueDays field name, and gates the rule on the presence of the literal token 'dueDays' inside M2 prose. Renaming dueDays or PaymentTerm in the YAML silently drops the validation. A numeric non-negativity constraint should be declared in the model, not inferred from free text plus hardcoded field names. +- **建议修复**:Express min/range constraints structurally in M1/M2 and enforce generically over model-declared numeric constraints; remove the PaymentTerm/dueDays literals. +- **复现核验**:Verified verbatim at engine/src/main/java/com/onto/engine/command/CommandEngine.java:232-237: `if (cmdValidations.contains("dueDays")) { for (Map row : ctx.details.getOrDefault("PaymentTerm", List.of())) { Object d = row.get("dueDays"); if (d != null && toBig(d).signum() < 0) errors.add("账期天数 dueDays 不可为负"); } }`. This hardcodes the order-domain business entity name "PaymentTerm" and business field name "dueDays" inside the generic engine, and gates the check on a substring match against M2 free-text prose (models/M2-command.yaml:123: "dueDays >= 0, depositRatio ∈ [0,1]"). Both identifiers are legitimate business model names (M1-domain.yaml:88,93; M3-deploy.yaml:71,77; M8-fron … +- **反驳核验**:The finding is confirmed; my refutation fails on every angle. CommandEngine.java:232-237 hardcodes three business identifiers inside the supposedly-generic engine: the entity name "PaymentTerm" (getOrDefault("PaymentTerm", List.of())), the field name "dueDays" (row.get("dueDays")), and gates the whole check on cmdValidations.contains("dueDays") — a substring match against M2 free-text prose. The iron rule in the task brief lists BOTH "dueDays" and "PaymentTerm" as canonical examples of violating identifiers, so these are not acceptable generic fallbacks.\n\nReachability is proven, not speculative: validate() is invoked from precheck() (line 52) and executeCommand() (line 119); the gate fires … + +### A13. Cross-consistency trigger event and item field names are hardcoded + +- **严重度**:HIGH **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:181` +- **缺陷**:The engine triggers cross-aggregate consistency with the literal event name 'OrderCreated' and builds item maps with literal productId/quantity field names. +- **证据**: +``` +Lines 182-186: `for (Map it : ctx.details.getOrDefault(firstItemEntity(aggregateId), List.of())) { items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))); } List> stockActions = eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items));` +``` +- **影响 / 触发场景**:Although events are emitted generically from cmd.emitEvents just above (lines 173-177), the cross-consistency call passes the hardcoded literal 'OrderCreated', plus hardcoded item field names productId/quantity. Rename OrderCreated in ME/M2 (or the OrderItem fields) and the down-stream stock deduction silently stops, even though ME.crossAggConsistencyRules still lists the rule. The trigger event(s) should be taken from the emitted events, and item field names from M1. +- **建议修复**:Iterate the events actually emitted (from M2 emitEvents/ME) and invoke consistency per matching ME rule; source line-item field names from M1 attributes rather than the productId/quantity/'OrderCreated' literals. +- **复现核验**:Evidence verified verbatim at CommandEngine.java:180-186. Line 186 passes the literal event name "OrderCreated" to eventEngine.applyCrossConsistency, and line 183 builds items using literal business field names "productId"/"quantity" read from the domain row (it.get("productId"), it.getOrDefault("quantity",0)). These are all business identifiers declared in the YAML models, not generic engine concepts: ME-event.yaml declares OrderAggregate.OrderCreated and crossAggConsistencyRules[triggerEvent: OrderCreated]; M1-domain.yaml:76,82 declares OrderItem fields productId and quantity; M2-command.yaml:127 declares CreateOrder emitEvents: OrderCreated. The iron rule EXPLICITLY names "OrderCreated" a … +- **反驳核验**:Confirmed verbatim at engine/.../command/CommandEngine.java:186 `eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items))` and :183 `items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0)))`. This is a textbook iron-rule violation — the contract explicitly names "event name like 'OrderCreated'" and business field names as prohibited hardcodings inside engine/. + +Every refutation fails: (1) Not a fallback — "OrderCreated" is the only literal passed; in EventEngine.applyCrossConsistency (lines 66-70) it is matched exactly against rule.get("triggerEvent") from ME.crossAggConsistencyRules (ME-event.yaml:59 `triggerEvent: OrderCreated`), and … + +### A14. 跨聚合一致性触发事件名被硬编码为 OrderCreated,ME 的 OrderCancelled 规则永不触发 + +- **严重度**:HIGH **维度**:model-integrity **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:186` +- **缺陷**:CommandEngine 用字面量 "OrderCreated" 调用 applyCrossConsistency,而不是遍历本次实际发出的事件,导致 ME.crossAggConsistencyRules 中 triggerEvent: OrderCancelled → StockDeduct 这条规则被引擎完全忽略。 +- **证据**: +``` +CommandEngine.java:186 `eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items));`;ME-event.yaml:58-66 定义了两条规则,其中 63-66 `- triggerEvent: OrderCancelled / targetAggregate: ProductAggregate / targetCommand: StockDeduct`。 +``` +- **影响 / 触发场景**:SCENE_CANCEL_ORDER 执行 CancelOrder 时发出 OrderCancelled,但引擎只会用硬编码的 "OrderCreated" 去匹配跨聚合规则,OrderCancelled 那条一致性规则永远不会执行。违背『改 ME 增删跨聚合规则即改行为』的承诺;同时把业务事件名硬编码进通用引擎,违反铁律。 +- **建议修复**:改为对本命令 emitEvents 中每个事件调用 applyCrossConsistency,由 ME.crossAggConsistencyRules 的 triggerEvent 自行匹配,而非硬编码 "OrderCreated"。 +- **复现核验**:Evidence verified at CommandEngine.java:186 — the literal `eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items));` (and the matching hardcoded comment at line 180). executeCommand() is a single generic method invoked for every bindCommand step (lines 92-102), used identically for CreateOrder, ModifyOrderDeliveryAddress, and CancelOrder — the "OrderCreated" literal is applied unconditionally, never derived from the command being run.\n\nConcrete manifestation: executing SCENE_CANCEL_ORDER (M4-scene.yaml:32-40) runs executeCommand for CancelOrder (M2-command.yaml:160-169), which emits OrderCancelled. The engine even computes the real emitted events into `events` (lines 173- … +- **反驳核验**:CommandEngine.java:186 does literally hardcode the business event name: `eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items));` (comment line 180 confirms it is order-specific, not a generic fallback). By the project's iron-rule definition this is a genuine, reachable hardcoded business event name inside the generic engine, so I cannot honestly refute the finding to real=false. HOWEVER, the finding's claimed HIGH-severity functional impact — that the ME OrderCancelled→StockDeduct rule being skipped breaks cancel-order stock consistency — does not hold in any reachable scenario: (1) the `items` list (lines 181-184) is built from OrderItem details in the payload, but Cance … + +### A15. Client-controlled totalAmount is stored as-is and drives risk rules — total tampering + MetaRule bypass + +- **严重度**:HIGH **维度**:security **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:246` +- **缺陷**:The order total is taken directly from the caller's master payload, never recomputed from line items, and that same untrusted value is fed to the REJECT/ALERT risk rules — so a caller can both understate the total and evade amount-based controls. +- **证据**: +``` +orderVars.put("totalAmount", ctx.master.get("totalAmount")); // L246, untrusted payload value drives runRules; validate() (L205-239) never checks that item amounts sum to totalAmount +``` +- **影响 / 触发场景**:insertDomain writes master.totalAmount straight into total_amt (it is a fieldMap column), and runRules evaluates MetaRule thresholds (e.g. high-amount REJECT) against the same client value. Attack: submit items worth 100000 but master.totalAmount=1 — the high-amount risk rule never fires and the order persists with total_amt=1. M2 lists validation '订单明细金额累加 ≈ 订单总金额' but it is not implemented, so the integrity/pricing control is absent. +- **建议修复**:Compute totalAmount server-side from persisted line items (price*qty) and use that computed value for both storage and rule evaluation; reject payloads whose declared total diverges from the computed sum. +- **复现核验**:Verified every link of the claim against the actual files. CommandEngine.java:246 `orderVars.put("totalAmount", ctx.master.get("totalAmount"))` takes totalAmount straight from the client payload (ctx.master is built from payload.get("master") at L322-323). That same map is fed to runRules (called on the real execute path at L130, and precheck L53). MetaRule-business.yaml:27-32 rule R_ORDER_AMOUNT_LIMIT gates a REJECT on `totalAmount > #{single_order_max_amount} && customerLevel != "VIP"` (threshold 50000) — so the untrusted value is the risk gate. The value is also persisted verbatim: M3-deploy.yaml:60 maps `totalAmount: total_amt`, and DynamicRepository.insertDomain (L46-69) writes any fiel … +- **反驳核验**:Every technical claim is verified against source, so the finding cannot be killed. CommandEngine.java:246 (orderVars.put("totalAmount", ctx.master.get("totalAmount"))) takes the total straight from the client payload.master (Ctx L322-323), which arrives unfiltered from POST /api/scene/{sceneId}/execute (CommandController.java:32-37, principal merely defaults to normal_user with no auth binding). That untrusted value both (a) drives the REJECT control R_ORDER_AMOUNT_LIMIT in MetaRule-business.yaml:30 ('totalAmount > #{single_order_max_amount} && customerLevel != "VIP"'), so understating it evades the 50000 cap, and (b) is persisted as-is: M3-deploy.yaml:60 maps totalAmount->total_amt and Dyna … + +### A16. TracePanel hardcodes StockDeduct / product-stock business fields + +- **严重度**:MEDIUM **维度**:frontend **位置**:`web/src/panels.tsx:47` +- **缺陷**:The stock-deduction visualization hardcodes the event name 'StockDeduct' and product/stock field names as table columns. +- **证据**: +``` +跨聚合最终一致:StockDeduct 扣减库存 +... +columns={[ + { title: '商品', dataIndex: 'productId' }, + { title: '扣减', dataIndex: 'deductQty' }, + { title: '剩余', dataIndex: 'remainingStock' }, +``` +- **影响 / 触发场景**:'StockDeduct', 'productId', 'deductQty', 'remainingStock' are business/event identifiers embedded in the frontend. A YAML change to ME event names or product field names would not reflect here, and the panel is meaningless for any non-order domain — an iron-rule violation in the render engine. +- **建议修复**:Render cross-consistency actions generically from the engine's returned action shape (label the section from ME event metadata, render whatever keys the rows carry) rather than fixed column literals. +- **复现核验**:Evidence confirmed verbatim in web/src/panels.tsx (TracePanel). Line 47: `跨聚合最终一致:StockDeduct 扣减库存` hardcodes the ME event/command name "StockDeduct" plus Chinese product-stock business copy. Lines 51-53 hardcode three product/inventory field names as fixed table columns: `{ dataIndex: 'productId' }`, `{ dataIndex: 'deductQty' }`, `{ dataIndex: 'remainingStock' }`. These are business identifiers embedded in web/ — exactly the class the iron rule forbids (the contract explicitly lists "StockDeduct" and "stockNum"/product fields as example violations). + +Cross-checked against the backend: engine/src/main/java/com/onto/engine/event/EventEngine.java builds the stockActions map … +- **反驳核验**:Confirmed real by reading web/src/panels.tsx:45-56. The block literally hardcodes the business event name and product/inventory field names: line 47 `跨聚合最终一致:StockDeduct 扣减库存`, and columns dataIndex `productId` (51), `deductQty` (52), `remainingStock` (53). These are order/product-domain identifiers, verified as domain concepts in the YAML single source of truth: models/ME-event.yaml:38,61,65 (StockDeduct / ProductStockDeducted / productId,stockNum), models/M2-command.yaml:88-98 (cmdId StockDeduct, productId, deductQty), models/M1-domain.yaml:27,76 (identifier productId). Editing those YAML names would not reflect here, which is precisely the iron-rule failure mode the contract forbids for w … + +### A17. TracePanel result summary hardcodes orderId + +- **严重度**:MEDIUM **维度**:frontend **位置**:`web/src/panels.tsx:34` +- **缺陷**:The success Descriptions item is hardcoded to label '订单号 orderId' and read result.orderId. +- **证据**: +``` +{result.orderId} +``` +- **影响 / 触发场景**:Same dynamic-root-id problem as the submit toast: result.orderId is only populated when the root identifier field happens to be 'orderId'. For any other aggregate this shows blank, and '订单号' is order-specific hardcoding in a supposedly generic trace panel. +- **建议修复**:Surface the root id under a stable generic key from the engine (e.g. result.rootId) and take the label from the aggregate's root-identifier metadata. +- **复现核验**:Evidence verified verbatim at web/src/panels.tsx:34: `{result.orderId}`. The impact is real: the backend writes the root id under a DYNAMIC key, not a fixed "orderId". CommandEngine.java:148-149 computes `rootIdentifierField = models.rootIdentifier(aggregateId)` and line 192 does `out.put(rootIdentifierField, rootId)`. ModelRepository.rootIdentifier (lines 125-127) returns `aggregateRoot(...).get("identifier")` — read straight from the M1 YAML, so it is "orderId" only for the order aggregate. App.tsx passes the full execute result unchanged into TracePanel (line 37 setResult(r); line 98 ). Concrete manife … +- **反驳核验**:I tried to kill this and the core survives. The mechanism is verified: CommandEngine.java:192 does `out.put(rootIdentifierField, rootId)` where `rootIdentifierField = models.rootIdentifier(aggregateId)` (ModelRepository.java:125-127 returns aggregateRoot.identifier). So the backend returns the root id under a DYNAMIC key — literally "orderId" for OrderAggregate, but a different key for any other aggregate. panels.tsx:34 hardcodes `result.orderId` and the label "订单号 orderId", so for a non-order aggregate this row would be blank and mislabeled. That is genuine order-specific hardcoding sitting in web/, exactly the pattern the iron rule names as a violation, and it is reachable via the project' … + +### A18. Default sceneId hardcoded to SCENE_CREATE_ORDER + +- **严重度**:MEDIUM **维度**:frontend **位置**:`web/src/App.tsx:15` +- **缺陷**:The initial selected scene is a hardcoded order-specific scene id rather than derived from the scenes list. +- **证据**: +``` +const [sceneId, setSceneId] = useState('SCENE_CREATE_ORDER') +``` +- **影响 / 触发场景**:If the scene is renamed in M4 YAML, the first schema fetch 404s and the form fails to load until the user manually clicks another scene. The business scene id is hardcoded in the frontend, so a YAML rename is not transparently reflected — contrary to the iron rule. +- **建议修复**:Initialize sceneId to null and, after api.scenes() resolves, default to the first scene where hasTemplate is true. +- **复现核验**:Evidence verified exactly at web/src/App.tsx:14: `const [sceneId, setSceneId] = useState('SCENE_CREATE_ORDER')`. Grep over web/src confirms this is the ONLY place in the frontend source where the business scene id appears; all other occurrences are in models/*.yaml (M4:10, M8:76, M5:16, M7:17, MetaRule:25) and backend tests. The default is never derived from the fetched scenes list — the useEffect on lines 23-27 fires on mount with the hardcoded sceneId and calls api.sceneSchema('SCENE_CREATE_ORDER') → GET /api/meta/scene/SCENE_CREATE_ORDER (api.ts:22, which throws on non-2xx at api.ts:6). Concrete manifestation: renaming SCENE_CREATE_ORDER in M4-scene.yaml plus its bindings (a legit … +- **反驳核验**:Confirmed against source. web/src/App.tsx:14 declares `const [sceneId, setSceneId] = useState('SCENE_CREATE_ORDER')` — a literal, order-specific scene id. grep confirms SCENE_CREATE_ORDER is a real M4 business scene (models/M4-scene.yaml:10, also referenced in M5/M7/M8/MetaRule), so it is unambiguously a business identifier living inside web/, which the repo's iron rule explicitly names as a violation. Reachability: App.tsx:23-27 fires on first render because sceneId is truthy, calling api.sceneSchema('SCENE_CREATE_ORDER'); api.ts:5-7 throws on non-2xx, so a rename in M4 YAML makes the first schema fetch fail, App.tsx:26 shows an error toast, and the content card drops to the empty b … + +### A19. DataController hardcodes an order-specific list endpoint and aggregate name + +- **严重度**:MEDIUM **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/web/DataController.java:31` +- **缺陷**:A dedicated /orders/list endpoint hardcodes the aggregate id 'OrderAggregate', unlike the generic /data/{aggregateId} options endpoint next to it. +- **证据**: +``` +Lines 30-33: `@GetMapping("/orders/list") public List> orders() { return readService.listRoot("OrderAggregate"); }` +``` +- **影响 / 触发场景**:A business-named route ('orders') and a hardcoded aggregate literal in a controller that is otherwise generic (the sibling method takes {aggregateId}). Renaming OrderAggregate in M1 breaks this endpoint; listing any other aggregate's roots requires new Java. Should be a generic parameterized route like /data/{aggregateId}/list. +- **建议修复**:Replace with a generic `@GetMapping("/{aggregateId}/list")` that forwards the path variable to listRoot, removing the OrderAggregate literal and the 'orders' path. +- **复现核验**:Verified evidence at engine/src/main/java/com/onto/engine/web/DataController.java:30-33 exactly matches the quote: `@GetMapping("/orders/list")` and `return readService.listRoot("OrderAggregate");`. This is an unambiguous iron-rule violation: "OrderAggregate" is a hardcoded business aggregate identifier (the exact forbidden category), and grep confirms it is a real aggregate declared across 7 YAML models (M1/M2/M3/M4/M7/M8/ME) while this is the sole .java occurrence. The violation is genuine and not merely stylistic because the downstream DomainReadService.listRoot(String aggregateId) (line 27) is fully parameterized/generic, and the sibling endpoint /{aggregateId} (line 24-27) is genuinely … +- **反驳核验**:Refutation attempt fails on every angle. DataController.java:30-33 literally contains `@GetMapping("/orders/list")` and `readService.listRoot("OrderAggregate")`. (1) "OrderAggregate" is a real business aggregate id, confirmed present in models/M1,M2,M3,M4,M7,M8 as one of three business aggregates (CustomerAggregate/ProductAggregate/OrderAggregate) — the iron rule explicitly names hardcoded aggregate names as a violation. (2) The path is live, not dead: web/src/api.ts:27 (`orders: () => get('/data/orders/list')`) is called from panels.tsx:138 and shown in the "订单/事件" tab (App.tsx:100). (3) No guarding genericity — the opposite: DomainReadService.listRoot(String aggregateId) is generic and the … + +### A20. api.ts hardcodes the order-specific data URL, contradicting its own contract + +- **严重度**:MEDIUM **维度**:iron-rule **位置**:`web/src/api.ts:27` +- **缺陷**:The API client hardcodes '/data/orders/list', despite the file's header comment asserting the frontend never hardcodes business addresses. +- **证据**: +``` +Line 1: `// ...所有 URL 均由后端场景 Schema 给出(commandApi/optionsSource),前端不硬编码业务地址。` ; line 27: `orders: () => get('/data/orders/list'),` +``` +- **影响 / 触发场景**:A business-specific URL baked into the generic renderer's API layer, directly violating the documented promise. Renaming the Order aggregate/endpoint requires editing this TS file. (Pairs with the DataController hardcode.) +- **建议修复**:Expose a generic list call `list: (aggregateId) => get(`/data/${aggregateId}/list`)` and have panels pass the aggregate id resolved from the scene schema. +- **复现核验**:Evidence verified verbatim. web/src/api.ts:1 asserts `所有 URL 均由后端场景 Schema 给出...前端不硬编码业务地址` (frontend does not hardcode business addresses), yet api.ts:27 `orders: () => get('/data/orders/list')` bakes the order-specific business word `orders` into a URL. The generic sibling on line 26 (`options: (aggregateId) => get('/data/'+aggregateId)`, parameterized by aggregate) proves a generic route pattern exists, so `orders/list` is a genuine special-case, not a necessary constant. It pairs with DataController.java:30-32, which hardcodes `@GetMapping("/orders/list")` → `readService.listRoot("OrderAggregate")` (business-literal in engine too). Concrete manifestation: per the iron rule, changing requ … +- **反驳核验**:The hardcode is verifiably present and reachable, so it cannot be killed as a false positive. web/src/api.ts:27 reads `orders: () => get('/data/orders/list')` — an order-specific path baked into the frontend. It is live, not dead code: panels.tsx:138 (`api.orders().then(setOrders)`) inside OrdersEventsPanel is mounted as a real tab at App.tsx:100 (key 'orders'). Renaming the order aggregate/endpoint would force editing this TS line plus DataController.java:30-33 (`/orders/list` -> listRoot("OrderAggregate")), so the frontend-to-order coupling the iron rule forbids genuinely exists. My best refutations only partially hold: (a) the line-1 comment's parenthetical does scope "所有 URL" to commandA … + +### A21. panels.tsx hardcodes Order/Product business field names as table columns + +- **严重度**:MEDIUM **维度**:iron-rule **位置**:`web/src/panels.tsx:143` +- **缺陷**:The results/inspection panels hardcode order and stock-action field names (orderId, customerId, totalAmount, totalCurrency, productId, deductQty, remainingStock) instead of deriving columns from the model. +- **证据**: +``` +Lines 144-151: rowKey="orderId" with columns `{ title: '订单号', dataIndex: 'orderId' }, { title: '客户', dataIndex: 'customerId' }, { title: '金额', dataIndex: 'totalAmount' }, { title: '币种', dataIndex: 'totalCurrency' }`; also lines 34 `label="订单号 orderId">{result.orderId}` and 51-54 `dataIndex: 'productId' / 'deductQty' / 'remainingStock'`. +``` +- **影响 / 触发场景**:The 'generic renderer' hardwires the Order aggregate root's field names and the stock-deduct action shape into React. Rename any of these in M1/M3, or drive the panel from a different aggregate, and the columns silently render empty. Column definitions for a root list should come from the model (M1 attributes / M8), like the main SceneForm does. +- **建议修复**:Build the orders/root table columns from the scene schema's aggregate attributes (M1) rather than literal dataIndex strings; render the created-id using the schema's rootIdentifier. +- **复现核验**:Evidence verified verbatim in web/src/panels.tsx. Line 34: `label="订单号 orderId">{result.orderId}`. Lines 144-150 (OrdersEventsPanel): rowKey="orderId" plus columns dataIndex 'orderId','customerId','totalAmount','totalCurrency'. Lines 51-53 (TracePanel stockActions table): dataIndex 'productId','deductQty','remainingStock'. These are Order-aggregate M1 field names and the StockDeduct action shape hardcoded into React. + +The data genuinely flows under these keys today, so the columns are live, not dead: DomainReadService.listRoot (lines 27-37) reverse-maps physical columns to M1 domain field names, so /data/orders/list yields rows keyed orderId/customerId/totalAmount/totalCurrency; EventEngine. … +- **反驳核验**:Verified in web/src/panels.tsx: line 34 renders `result.orderId` under label "订单号 orderId"; lines 51-53 hardcode stock-action columns `dataIndex: 'productId' / 'deductQty' / 'remainingStock'`; lines 147-150 hardcode order columns `dataIndex: 'orderId' / 'customerId' / 'totalAmount' / 'totalCurrency'` with rowKey="orderId" (line 144). These are exactly the business field names the iron rule names as violations ("customerId"/"totalAmount"), sitting inside web/. My best refutations all fail: (1) the "demo panel, not generic renderer" defense is void because the iron rule covers all of web/ with no carve-out, and the adjacent ProvenancePanel (line 83) even claims "本页面无任何业务硬编码" while OrdersEvents … + +### A22. SceneForm hardcodes the root-id field name and order-specific UI copy + +- **严重度**:MEDIUM **维度**:iron-rule **位置**:`web/src/engine/SceneForm.tsx:96` +- **缺陷**:The generic form reads the created id as r.orderId and labels its actions '新增订单', assuming the scene is always order creation. +- **证据**: +``` +Line 96: `message.success(`订单创建成功:${r.orderId}`)` ; line 147: `` +``` +- **影响 / 触发场景**:The backend returns the created id under a *dynamic* key (models.rootIdentifier(aggregateId), i.e. it would be customerId for a customer scene), so hardcoding `r.orderId` shows 'undefined' for any non-order scene. The button/message copy '新增订单' likewise ignores schema.sceneName. Renaming orderId in M1, or reusing the renderer for another aggregate/scene, breaks the success feedback despite the renderer being 'generic'. +- **建议修复**:Read the created id via the schema's rootIdentifier (or have the backend return it under a stable generic key like `rootId`), and derive button/message text from schema.sceneName/template. +- **复现核验**:Evidence verified verbatim. web/src/engine/SceneForm.tsx:96 reads the created id as `r.orderId` — `message.success(`订单创建成功:${r.orderId}`)` — and line 147 hardcodes order copy — `提交「新增订单」`. This component's own docstring (lines 29-33) declares it a "场景通用渲染引擎" ("完全由 M8 模板结构驱动…改模板即改页面"), i.e. it is supposed to be the generic renderer, so hardcoding the business field name `orderId` and the order-specific label 新增订单 is exactly the class of iron-rule violation the contract forbids in web/ (it enumerates field names like customerId as violations; orderId is the same category). + +The impact is confirmed against the backend: CommandEngine.executeCommand returns the created id under a DYNAMIC key, not … +- **反驳核验**:Finding survives refutation. Verified in web/src/engine/SceneForm.tsx:96 `message.success(`订单创建成功:${r.orderId}`)` and line 147 button copy `提交「新增订单」`, both inside the file CLAUDE.md designates as the ★ generic renderer. The backend confirms the coupling: CommandEngine.java:149 `String rootIdentifierField = models.rootIdentifier(aggregateId)` and line 192 `out.put(rootIdentifierField, rootId)` return the created id under a DYNAMIC key — `orderId` only for the order aggregate, `customerId` for a customer scene. So `r.orderId` is hard-bound to the order aggregate; any other aggregate yields `undefined`. types.ts:32 shows the schema already exposes a generic `sceneName` that the button/message i … + +### A23. MetaRule 可用变量被硬编码白名单限定,引用其他字段的规则被静默跳过 + +- **严重度**:MEDIUM **维度**:model-integrity **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:246` +- **缺陷**:runRules 只向规则引擎注入 totalAmount / customerLevel / stockNum 三个硬编码变量;RuleEngine 对『变量未在 env 中出现』的规则直接 continue,因此任何在 MetaRule 中新增、引用其他领域字段的规则都会被静默忽略。 +- **证据**: +``` +CommandEngine.java:246-247 `orderVars.put("totalAmount", ...)` `orderVars.put("customerLevel", level)`,第 255 行 `ruleEngine.evaluate(ctx.sceneId, Map.of("stockNum", stock))`;RuleEngine.java:50 `if (!env.keySet().containsAll(vars)) { continue; }`。MetaRule-business.yaml:17 定义的 vip_discount_limit 参数从未被任何 matchCondition 引用。 +``` +- **影响 / 触发场景**:README/HelpButton 宣称『改 MetaRule 表达式即改前后端行为』,但若在 MetaRule 增一条如 `dueDays > 60` 的规则,dueDays 从未注入 env,规则永远命中不了、也不报错——只有恰好用到这三个变量的规则才生效。这是『改 YAML→改行为』未被通用兑现,且 vip_discount_limit 为悬空死参数。 +- **建议修复**:根据 payload(master+details) 通用地把所有领域字段注入规则 env(或至少注入规则 matchCondition 实际引用的变量),而非固定注入三个字段。 +- **复现核验**:All quoted evidence verified in the actual files. CommandEngine.runRules (lines 241-260) injects rule variables at exactly two call sites: line 248 evaluates with orderVars={totalAmount, customerLevel} and line 255 evaluates with Map.of("stockNum", stock). These are the ONLY places variables reach RuleEngine.evaluate. RuleEngine.java:50 does `if (!env.keySet().containsAll(vars)) { continue; }` with no log at that continue, so any rule whose referenced variable is absent from env is silently skipped (env also holds the three global params single_order_max_amount/stock_warn_min/vip_discount_limit, but no business field beyond the hardcoded trio). Concrete manifestation: add a rule to ORDER_RIS … +- **反驳核验**:Core mechanism confirmed by reading the files. CommandEngine.runRules (lines 241-260) injects only hardcoded business variables into the rule env: totalAmount + customerLevel (lines 246-247, order-level) and stockNum per OrderItem (line 255); the extraction is itself order-specific (ctx.details.getOrDefault("OrderItem"...), it.get("productId"), ctx.master.get("totalAmount")). RuleEngine.evaluate line 50 `if (!env.keySet().containsAll(vars)) { continue; }` skips with NO log/error. So a MetaRule referencing any other modeled field is silently no-op'd. This is reachable and in-scope for THIS demo: dueDays is a real domain field (PaymentTerm, validated at CommandEngine:232-236) yet is never inje … + +### A24. 通用 executeCommand 一律按『新建聚合根』落库,修改/取消类命令被误执行为插入 + +- **严重度**:MEDIUM **维度**:model-integrity **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:155` +- **缺陷**:executeCommand 无视 M2 命令语义(bizSteps),对任何 bindCommand 步骤都生成新 rootId 并 insertDomain 主记录+子实体,因此 SCENE_MODIFY_ORDER_ADDR / SCENE_CANCEL_ORDER 会插入一条全新订单而非修改/取消既有订单。 +- **证据**: +``` +CommandEngine.java:148 `String rootId = IdGen.next(idPrefix(aggregateId));` 154 `master.putIfAbsent("createTime", ...)` 155 `repo.insertDomain(aggregateId, rootName, null, master, null);`(无 UPDATE 分支);M2-command.yaml ModifyOrderDeliveryAddress(132) 与 CancelOrder(160) 的 bizSteps 表达的是修改/作废语义;M4 场景 SCENE_MODIFY_ORDER_ADDR(22) / SCENE_CANCEL_ORDER(32) 都走 bindCommand。 +``` +- **影响 / 触发场景**:只要在 M4 定义一个非新增语义的场景并绑定命令,引擎就会错误地新建一条聚合根记录(ModifyOrderDeliveryAddress 会造出一条只有 orderId/createTime 的空订单,CancelOrder 亦然),并照常发事件、扣库存。引擎并未通用解释 M2 命令语义——与『通用执行任意场景命令』的承诺不符。 +- **建议修复**:依据 M2 命令是否包含聚合根标识入参 / bizSteps 判定 create vs update/cancel,分别实现 insert 与 update,或在 M2/M0 引入命令类型字段供引擎分派。 +- **复现核验**:Evidence verified against source. In CommandEngine.java, execute() (lines 82-106) dispatches every flowStep of stepType "bindCommand" to executeCommand(), which unconditionally does: line 148 `String rootId = IdGen.next(idPrefix(aggregateId));`, line 153 `master.put(rootIdentifierField, rootId)`, line 154 `master.putIfAbsent("createTime", ...)`, line 155 `repo.insertDomain(aggregateId, rootName, null, master, null)`. There is no UPDATE/cancel branch anywhere; the command's semantics (desc/bizSteps) are never consulted — only its `validations` text is scanned for the literals "手机号"/"dueDays" (lines 224-237). So the engine treats ALL commands as "create new aggregate root + insert". + +Concrete, … +- **反驳核验**:The finding survives every refutation attempt. CommandEngine.executeCommand (engine/.../command/CommandEngine.java:113-201) has a single, unconditional code path: line 148 `String rootId = IdGen.next(idPrefix(aggregateId))` generates a fresh root id, line 153 `master.put(rootIdentifierField, rootId)` overwrites any caller-supplied orderId, line 154 adds createTime, and line 155 `repo.insertDomain(aggregateId, rootName, null, master, null)` inserts a NEW aggregate-root row (plus children). There is no UPDATE/soft-delete branch and no reading of M2 command semantics (bizSteps/desc). So ModifyOrderDeliveryAddress and CancelOrder are executed as inserts. + +Reachability confirmed end-to-end: (1) B … + +### A25. 引擎在校验逻辑中硬编码 M1 实体名/字段名,重命名即静默失效 + +- **严重度**:MEDIUM **维度**:model-integrity **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:226` +- **缺陷**:validate/runRules 用字面量 "DeliveryAddressEntity"."receiverPhone"."PaymentTerm"."dueDays"."OrderItem"."productId" 定位数据,一旦 M1/M3/M8 中这些实体或字段被改名,对应的手机号/账期/库存校验会无声失效。 +- **证据**: +``` +CommandEngine.java:226 `ctx.details.getOrDefault("DeliveryAddressEntity", ...)` 227 `row.get("receiverPhone")` 233 `getOrDefault("PaymentTerm", ...)` / `row.get("dueDays")` 251 `getOrDefault("OrderItem", ...)` 183 `it.get("productId")`。这些名称均来自 M1-domain.yaml(如 receiverPhone:114, DeliveryAddressEntity:101)。 +``` +- **影响 / 触发场景**:作为『通用解释引擎』,这些应从 M2 validations/M1 结构动态推导。当前若把 M1 的 receiverPhone 或 DeliveryAddressEntity 改名(正是『改模型』场景),手机号格式校验直接找不到数据、静默跳过——latent break,且违反铁律。 +- **建议修复**:校验目标应从 M1 attributes/M2 validations 结构化推导(例如为 M2 validations 引入结构化断言而非中文文本匹配),不在引擎内硬编码实体名与字段名。 +- **复现核验**:Evidence verified exactly as quoted in CommandEngine.java: + +- Line 226: `for (Map row : ctx.details.getOrDefault("DeliveryAddressEntity", List.of()))` then line 227 `Object p = row.get("receiverPhone");` +- Line 232-234: `if (cmdValidations.contains("dueDays"))` → `ctx.details.getOrDefault("PaymentTerm", List.of())` → `row.get("dueDays")` +- Line 251-252: `for (Map it : ctx.details.getOrDefault("OrderItem", List.of()))` → `it.get("productId")` +- Line 183: `items.add(Map.of("productId", it.get("productId"), ...))` + +Every one of these literals is a business identifier that originates in models/M1-domain.yaml (confirmed: OrderItem@73, PaymentTerm@88, DeliveryAddres … +- **反驳核验**:I tried to kill this finding and could not. The cited literals are present verbatim in engine/.../command/CommandEngine.java: line 226 `ctx.details.getOrDefault("DeliveryAddressEntity", List.of())`, 227 `row.get("receiverPhone")`, 232-234 `getOrDefault("PaymentTerm", ...)` / `row.get("dueDays")`, 251 `getOrDefault("OrderItem", ...)`, 252/183 `it.get("productId")`. All are business-specific M1 identifiers (M1-domain.yaml:101 DeliveryAddressEntity, :114 receiverPhone, :88 PaymentTerm, :93 dueDays, :73 OrderItem, :27/:76 productId) — exactly the class of literal the project's iron rule names as forbidden inside engine/.\n\nRefutation attempts all fail: (1) The literal is the lookup KEY into ctx … + +### A26. M5 encryptRules.storageEncrypt 从未被引擎兑现,敏感字段明文入库 + +- **严重度**:MEDIUM **维度**:model-integrity **位置**:`models/M5-security.yaml:30` +- **缺陷**:M5 声明 contactPhone/receiverPhone 需存储加密,前端控件也提示『存储加密』,但引擎落库时无任何加密逻辑,手机号以明文写入数据库。 +- **证据**: +``` +M5-security.yaml:29-30 `encryptRules:` `storageEncrypt: [contactPhone, receiverPhone]`;DynamicRepository.insertDomain(46-69) 仅按 M1 类型 convert 后原样 jdbc.update,无加密;全仓 grep `encrypt` 在 engine/web 源码零命中;FieldControl.tsx:80 Tooltip 文案 `M5 敏感字段:存储加密、展示脱敏` 属虚假宣称。 +``` +- **影响 / 触发场景**:M5 模型的 storageEncrypt 指令是未被引擎读取/执行的死配置,敏感 PII 明文落库,与模型声明和前端提示矛盾;『改 M5 即改安全行为』对加密维度不成立。 +- **建议修复**:在 DynamicRepository 写入/DomainReadService 读取时按 M5.encryptRules.storageEncrypt 通用地加解密,或移除该未实现的模型声明与前端『存储加密』提示。 +- **复现核验**:All quoted evidence verified directly in the source. models/M5-security.yaml:29-30 declares `encryptRules: storageEncrypt: [contactPhone, receiverPhone]`. DynamicRepository.insertDomain (lines 46-69) only type-converts values via `convert(type, ...)` (lines 54-56) and calls `jdbc.update(sql, args.toArray())` (line 68) with no encryption step. `grep -rniI "encrypt"` over engine/src and web/src yields zero hits (only match repo-wide is the React DOM event `onEncrypted` in the minified web/dist bundle). Crucially, ModelRepository (lines 234-250) exposes `securityModel()`, `maskRules()`, and `functionPermission()` but has NO accessor for `encryptRules`/`storageEncrypt` — so the directive is neve … +- **反驳核验**:The finding is factually airtight and survives refutation. Verified: (1) M5-security.yaml:29-30 declares encryptRules.storageEncrypt:[contactPhone,receiverPhone]; (2) M0-meta-schema.yaml:245 makes encryptRules a REQUIRED schema member, so it is a first-class declared contract; (3) ModelRepository.java:236-242 exposes securityModel()/maskRules() but has NO encryptRules/storageEncrypt accessor, and grep -rin "encrypt" over engine/src and web/src returns ZERO hits — the directive is never read by any engine or web code; (4) DynamicRepository.insertDomain (46-69) only convert()s by M1 type then plain jdbc.update — no encryption; (5) contactPhone/receiverPhone are real persisted fields (M1:13/114 … + +### A27. Negative/zero item quantity is unvalidated and inflates inventory via StockDeduct + +- **严重度**:MEDIUM **维度**:security **位置**:`engine/src/main/java/com/onto/engine/event/EventEngine.java:106` +- **缺陷**:Item quantity is never validated to be positive; a negative quantity passes the sufficiency check and the deduct SQL subtracts a negative number, increasing stock. +- **证据**: +``` +long qty = ... Long.parseLong(...); // L92, may be negative + if (current < qty) { ... FAIL_INSUFFICIENT } // L106, negative qty passes + repo.execute("UPDATE " + table + " SET " + stockCol + " = " + stockCol + " - ? WHERE " + pkCol + " = ?", qty, productId); // L111: stock - (-N) = stock + N +``` +- **影响 / 触发场景**:CommandEngine.validate() checks only phone format and dueDays; it never checks item quantity. Attack: submit an order item with quantity -1000; the sufficiency guard (current < qty) is false, and UPDATE stock_num = stock_num - (-1000) inflates product inventory by 1000. Zero/negative quantities also let an attacker create orders that deduct nothing while still passing. +- **建议修复**:Validate quantity > 0 (and enforce numeric bounds) in the command validation stage before persistence and before cross-aggregate stock actions. +- **复现核验**:Verified all quoted evidence exists as claimed. EventEngine.deductStock (engine/.../event/EventEngine.java): L92 parses qty via Long.parseLong preserving negative sign; L106 `if (current < qty)` returns FAIL_INSUFFICIENT only when stock is below qty — for a negative qty (e.g. -1000) with positive current stock, `current < qty` is false so it passes; L111 `UPDATE ... SET stock_num = stock_num - ?` with qty=-1000 computes stock_num-(-1000)=stock_num+1000, inflating inventory. + +Reachability confirmed end-to-end: CommandController.execute (POST /api/scene/{sceneId}/execute) forwards the raw request body to commandEngine.execute with principal defaulting to normal_user. CommandEngine.validate() ( … +- **反驳核验**:Refutation failed; the finding is a genuine, reachable defect. Mechanics: EventEngine.deductStock L92 parses qty via Long.parseLong without a sign check; a negative value (e.g. -1000) passes the L106 sufficiency guard `if (current < qty)` because a positive current is never < a negative qty, then L111 `UPDATE ... SET stock = stock - ?` computes stock - (-1000) = stock + 1000, inflating inventory. Reachability: CommandEngine.executeCommand step e (L181-186) builds items from raw payload detail rows via it.getOrDefault("quantity",0) and calls applyCrossConsistency("OrderCreated", items) -> deductStock; the Ctx constructor (L326-331) copies all payload keys verbatim, so a client-supplied quanti … + +### A28. Submit button label hardcodes '新增订单' + +- **严重度**:LOW **维度**:frontend **位置**:`web/src/engine/SceneForm.tsx:147` +- **缺陷**:The primary submit button text is the order-specific literal '提交「新增订单」' regardless of which scene is rendered. +- **证据**: +``` + +``` +- **影响 / 触发场景**:A generic renderer that loads any M4 scene still labels its submit action '新增订单'; changing the scene in YAML does not change the wording. Minor, but a business term hardcoded in the generic engine. +- **建议修复**:Use schema.sceneName or the command desc for the button label, e.g. `提交「${schema.sceneName}」`. +- **复现核验**:Verified: web/src/engine/SceneForm.tsx:147 contains exactly ``. This lives in SceneForm, self-documented (lines 29-33) as the "场景通用渲染引擎" (generic scene renderer), which the iron rule requires to be business-agnostic. "新增订单" (New Order) is order-specific terminology hardcoded in the generic engine. + +Concrete manifestation: SceneSchema (web/src/types.ts:33) already carries a scene-agnostic `sceneName`, and App.tsx:73/90 renders scenes generically from it. If an operator edits M4/M8 YAML to add or rename a non-order scene (e.g. a purchase-order or repair-ticket scene) and hot-reloads, the left nav and card title cor … +- **反驳核验**:Verified against web/src/engine/SceneForm.tsx:147: ``. The literal '新增订单' (new order) is unconditionally rendered as the primary submit button for EVERY scene. My best refutation attempts all fail: (1) not unreachable — it's the main submit button in the returned JSX (lines 144-148); (2) not an acceptable generic fallback — the component's own docstring (lines 29-33) calls it "场景通用渲染引擎" and CLAUDE.md lists it as "★ 通用渲染引擎"; the useEffect is keyed on schema.sceneId (line 65) and all real logic is schema-driven, so loading any non-order M4 scene still mislabels the button; a truly generic caption would be "提交"; (3) … + +### A29. firstItemEntity() falls back to the literal 'OrderItem' + +- **严重度**:LOW **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:368` +- **缺陷**:When no composition child is found, the engine defaults the order-line entity to the hardcoded name 'OrderItem'. +- **证据**: +``` +Lines 368-371: `private String firstItemEntity(String aggregateId) { List ch = models.compositionChildren(aggregateId); return ch.isEmpty() ? "OrderItem" : ch.get(0); }` +``` +- **影响 / 触发场景**:The primary path is model-driven (first composition child), but the fallback literal 'OrderItem' and the design assumption that 'the first composition child is the line-item collection carrying productId/quantity' embed order semantics. For an aggregate with a different or reordered composition layout, the wrong entity is chosen or a non-existent 'OrderItem' is queried. +- **建议修复**:Remove the literal fallback; if line items are meaningful they should be identified via an explicit model marker rather than positional 'first child' + hardcoded default. +- **复现核验**:Evidence verified verbatim at CommandEngine.java:367-371. The ternary `return ch.isEmpty() ? "OrderItem" : ch.get(0);` embeds the literal order-domain entity name "OrderItem" directly in engine code. The iron rule explicitly names "OrderItem" as a canonical example of a forbidden hardcoded entity identifier inside engine/, so this is an unambiguous textual iron-rule violation: the generic interpreter "knows" a domain entity name. Renaming/removing the order-line entity in the YAML would not update this fallback — i.e. a requirements change would require touching engine code, exactly what the rule forbids. Reachability: `firstItemEntity` is called unconditionally at line 182 for every command … +- **反驳核验**:Verified at CommandEngine.java:370 the literal `return ch.isEmpty() ? "OrderItem" : ch.get(0);`. The iron rule explicitly names "OrderItem" as a prohibited hardcoded entity name in engine/, so this is a genuine (if trivial) iron-rule violation — an order-specific entity name compiled into the generic interpreter. It is reinforced by line 251 (`ctx.details.getOrDefault("OrderItem", ...)`) and lines 183/186 (`it.get("productId")`, `applyCrossConsistency("OrderCreated", ...)`) which hardcode order semantics unconditionally in the same method. My best refutation only partially lands: (1) the fallback branch is effectively unreachable for the configured order aggregate because compositionChildren … + +### A30. App.tsx defaults the initial scene to the literal SCENE_CREATE_ORDER + +- **严重度**:LOW **维度**:iron-rule **位置**:`web/src/App.tsx:15` +- **缺陷**:The generic shell hardcodes which scene opens first instead of selecting from the fetched scene list. +- **证据**: +``` +Line 15: `const [sceneId, setSceneId] = useState('SCENE_CREATE_ORDER')` +``` +- **影响 / 触发场景**:Scenes are otherwise fetched dynamically, but the initial selection is a business-specific scene id. If SCENE_CREATE_ORDER is renamed/removed in M4, the first schema load 404s until the user manually clicks another scene. Minor, but it means changing the demo's primary scene requires editing frontend code. +- **建议修复**:Initialize sceneId to the first scene with a bound template from the fetched /meta/scenes list, rather than a hardcoded id. +- **复现核验**:Evidence confirmed at web/src/App.tsx:14 (finding said 15, but the exact quoted code `const [sceneId, setSceneId] = useState('SCENE_CREATE_ORDER')` is on line 14 — "at/near" the cited line). `SCENE_CREATE_ORDER` is a genuine business-specific scene identifier: it is defined in models/M4-scene.yaml:10 as the "用户创建订单完整流程" (create-order) scene and cross-referenced from M5/M7/M8/MetaRule. The iron rule explicitly forbids hardcoded business identifiers in web/; the generic shell should derive its initial scene from the dynamically fetched scene list, not pin a specific order scene.\n\nManifestation is reachable: the schema-loading useEffect (App.tsx:23-27) fires on mount with the hardcode … +- **反驳核验**:Confirmed at web/src/App.tsx line 14 (finding cites line 15 — off by one; line 15 is the `schema` state, but the quoted evidence matches line 14): `const [sceneId, setSceneId] = useState('SCENE_CREATE_ORDER')`. This literal is a business-specific M4 scene id hardcoded in the generic frontend shell. My refutation attempts all fail: (1) The value is NOT overridden after fetch — the scenes effect at line 21 (`api.scenes().then(setScenes)`) never calls setSceneId, so the effect at lines 23-27 fires `api.sceneSchema('SCENE_CREATE_ORDER')` on mount and the literal persists until a manual click. (2) It is not an unavoidable seed — the existing `if (!sceneId) return` guard at line 24 already … + +### A31. SchemaInitializer.seed() hardcodes Customer/Product aggregate and entity names + +- **严重度**:LOW **维度**:iron-rule **位置**:`engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java:121` +- **缺陷**:Demo seeding is written for exactly two named aggregates (CustomerAggregate/Customer, ProductAggregate/Product) instead of iterating the seed file generically. +- **证据**: +``` +Lines 122-136: `repo.tableName("CustomerAggregate", "Customer")` ... `repo.insertDomain("CustomerAggregate", "Customer", null, row, null)` and `repo.tableName("ProductAggregate", "Product")` ... `repo.insertDomain("ProductAggregate", "Product", null, row, null)` +``` +- **影响 / 触发场景**:seed-data.yaml is explicitly non-ontology demo scaffolding, so this is the mildest case, but the loader still hardcodes both aggregate ids and their root entity names ('Customer'/'Product'). Adding a seeded reference aggregate, or renaming these in M1, requires editing Java even though the seed file is keyed by aggregate id and could be driven generically (for each key → aggregateId, look up rootName, insert). +- **建议修复**:Iterate the seed map's keys as aggregate ids, resolve rootName via models.rootName(key), and insert generically — no CustomerAggregate/ProductAggregate/Customer/Product literals. +- **复现核验**:Evidence verified verbatim in engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java. seed() (lines 113-140) hardcodes the aggregate ids and root entity names in four places: line 122 `repo.tableName("CustomerAggregate", "Customer")`, line 124 `seed.getOrDefault("CustomerAggregate", ...)`, line 127 `repo.insertDomain("CustomerAggregate", "Customer", null, row, null)`, and lines 132/134/136 the mirror image for `"ProductAggregate"`/`"Product"`. Line 126 additionally hardcodes a Customer-specific business field name: `row.putIfAbsent("registerTime", ...)`. The task's own iron-rule definition explicitly names "CustomerAggregate"/"ProductAggregate" as example aggregate identifiers w … +- **反驳核验**:Confirmed real iron-rule violation. In engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java, seed() hardcodes business aggregate/entity identifiers as string literals: line 122 `repo.tableName("CustomerAggregate", "Customer")`, line 127 `repo.insertDomain("CustomerAggregate", "Customer", null, row, null)`, line 132 `repo.tableName("ProductAggregate", "Product")`, line 136 `repo.insertDomain("ProductAggregate", "Product", null, row, null)`, plus line 126 hardcodes the business field name `registerTime`. These are exactly the aggregate/entity/field identifiers the iron rule enumerates as forbidden inside engine/. + +Refutation attempts all fail: (1) The "non-ontology demo scaffold … + +### A32. ME OrderCancelled→StockDeduct 与 M2 CancelOrder『归还库存』语义相反 + +- **严重度**:LOW **维度**:model-integrity **位置**:`models/ME-event.yaml:63` +- **缺陷**:取消订单本应回补库存,但 ME 把 OrderCancelled 的 targetCommand 设为 StockDeduct(扣减),而 ProductAggregate 根本没有『增加库存』命令;模型层面自相矛盾。 +- **证据**: +``` +ME-event.yaml:63-66 `- triggerEvent: OrderCancelled / targetAggregate: ProductAggregate / targetCommand: StockDeduct`;M2-command.yaml:167 CancelOrder.bizSteps `...3.远程调用Product聚合增加对应库存...`;M2 ProductAggregate 仅有 StockDeduct(88) 一个库存命令,无 StockReturn/StockAdd。EventEngine.deductStock(85-118) 也只做 `stock = stock - qty`。 +``` +- **影响 / 触发场景**:即便引擎将来正确按 OrderCancelled 触发跨聚合规则,也会对取消订单继续扣减库存,与业务意图完全相反;这是 ME↔M2 的内在建模不一致,且缺少回补库存的命令定义。 +- **建议修复**:在 M2 ProductAggregate 增加 StockReturn/StockAdd 命令并在 ME 将 OrderCancelled 的 targetCommand 指向它;引擎 applyCrossConsistency 依 targetCommand 区分扣减/回补。 +- **复现核验**:All quoted evidence verified. ME-event.yaml:63-66 maps `OrderCancelled → targetAggregate: ProductAggregate → targetCommand: StockDeduct` (deduct). M2-command.yaml:160-169 defines CancelOrder whose desc (161) is `取消订单,归还库存` and bizSteps step 3 (167) is `远程调用Product聚合增加对应库存` (INCREASE stock). M4-scene.yaml:32-40 SCENE_CANCEL_ORDER is likewise named `取消订单、归还库存`. So the cancel lifecycle intent is stated as "return/increase stock" in three model files, while ME routes it to StockDeduct — a direct semantic contradiction. Further, M2 ProductAggregate exposes only StockDeduct (line 88) with no StockReturn/StockAdd, and EventEngine.deductStock (85-118) only performs `stock = stock - qty` (line 111) w … +- **反驳核验**:The finding is a model-integrity claim, and the evidence is incontrovertibly verified. ME-event.yaml:63-66 routes OrderCancelled to targetCommand StockDeduct, which is a stock DECREMENT: M2-command.yaml:88 (only stock command in ProductAggregate) has validation "可用库存 >= deductQty" and emits ProductStockDeducted; EventEngine.java:111 executes `stock = stock - qty`. Yet three other model files unanimously say cancel must RETURN/increase stock: M4-scene.yaml:33 sceneName "取消订单、归还库存", M2-command.yaml:161 desc "取消订单,归还库存" and :167 bizSteps "...远程调用Product聚合增加对应库存...", and MetaRule-business.yaml:44-49 R_CANCEL_STOCK_RETURN effect COMPENSATE "订单取消自动返还锁定商品库存". No StockReturn/StockAdd command exists … + + +--- + +## 附录 B · B 级发现 —— 单边确认 / 严重度存疑(复现者判真、反驳者提异议,共 18 条) + +> 复现者确认可触发,但反驳者对其严重度或"是否属既定设计"提出异议,需人工裁定。 + +### B1. Insufficient/absent stock silently commits the order (cross-aggregate consistency computed then discarded) + +- **严重度**:BLOCKER **维度**:eda **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:185` +- **缺陷**:When StockDeduct fails (insufficient stock or product not found), the failure is only recorded in a log map; the order insert + outbox event still commit, so orders exist against stock that was never deducted. +- **证据**: +``` +CommandEngine.executeCommand(): + L185: List> stockActions = eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)); + L187: if (!stockActions.isEmpty()) { trace.add(...); } + L191: out.put("success", true); +EventEngine.deductStock(): + L106: if (current < qty) { + L107: action.put("status", "FAIL_INSUFFICIENT"); + L109: return action; // returns WITHOUT throwing + } + L101: if (row.isEmpty()) { action.put("status", "SKIP_NOT_FOUND"); return action; } +The returned status is never inspected; no exception is raised, so the @Transactional execute() commits. +``` +- **影响 / 触发场景**:Order for product P001 (stock=1) with quantity=5: master + OrderItem rows are INSERTed, OrderCreated is written to the outbox, deductStock returns status=FAIL_INSUFFICIENT, executeCommand still sets success=true, and @Transactional on execute() commits everything. Result: an order committed for goods that were never deducted (oversell), with stock unchanged. Same for a bad/renamed productId -> SKIP_NOT_FOUND, order still commits. The cross-aggregate consistency the design promises is computed and thrown away. +- **建议修复**:Have deductStock throw a domain exception on FAIL_INSUFFICIENT/SKIP_NOT_FOUND (or return a failure that executeCommand checks) so the surrounding @Transactional rolls back the order, or move stock reservation before persist and abort the flow with stage=STOCK_INSUFFICIENT. +- **复现核验**:Evidence verified verbatim. CommandEngine.executeCommand L185-186 calls eventEngine.applyCrossConsistency("OrderCreated", ...); L187-189 only adds a trace when stockActions is non-empty and never inspects the per-item status; L191 unconditionally sets out.put("success", true). execute() is @Transactional (L67) and treats a non-FALSE success as normal flow, so absent any exception the transaction commits. EventEngine.deductStock returns action maps with status SKIP_NOT_FOUND (L101-103, product not found) or FAIL_INSUFFICIENT (L106-110, current ProductAggregate.StockDeduct rule with `consistencyType: eventual`. Under DDD/EDA, a transaction mutates one aggregate and cross-aggregate effects are decoupled; an order committing independently of stock deduction is the normal saga intermediate state, not corruption. Rolling the order transaction back on another aggregate's command failure (what the finding demands) would turn declared eventual consistency into synchronous strong consistency across aggregates, contradicting the model. (2) The finding's central factual claim — "the failure is only r … + +### B2. No idempotency / duplicate-submission protection on command execution + +- **严重度**:HIGH **维度**:eda **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:67` +- **缺陷**:execute() accepts no client request/idempotency key and generates a fresh root id per call, so a retried or double-clicked POST creates duplicate orders, duplicate outbox events, and double stock deductions. +- **证据**: +``` +L67: @Transactional public Map execute(String sceneId, Map payload, String principal) { +L148: String rootId = IdGen.next(idPrefix(aggregateId)); // new id every call +CommandController.execute() (L31-37) passes the raw payload through with no dedup token. Nothing in the flow checks for a prior identical request. +``` +- **影响 / 触发场景**:Client network retry or a double-click on submit sends the same order payload twice. Each call gets a new orderId, inserts a full order, appends OrderCreated to the outbox, and deducts stock again. Two orders + double stock deduction for one business intent; no way to reconcile because there is no shared idempotency key. +- **建议修复**:Require a client-supplied idempotency key (or hash of payload) and persist it in a unique-constrained dedup table inside the same transaction; on conflict return the prior result instead of re-executing. +- **复现核验**:Evidence verified in-file. CommandEngine.execute() (L67-68) takes only (sceneId, payload, principal) with no idempotency/request key. L148 `String rootId = IdGen.next(idPrefix(aggregateId));` mints a fresh id per call. CommandController.execute() (L31-37) only reads the X-Principal header and forwards the raw payload; nothing accepts or checks a dedup token. The execute()->executeCommand() path has no prior-request lookup, no unique constraint on the payload, and no idempotency store. Downstream mutations are genuinely non-idempotent: executeCommand inserts the root+children via repo.insertDomain (L155/165), appends OrderCreated to the outbox (eventEngine.append, L175), and triggers cross-ag … +- **反驳核验**:I read CommandEngine.java, CommandController.java, EventEngine.java and SchemaInitializer.java. The finding's factual claims are all accurate: execute() (L67) takes no idempotency token, rootId=IdGen.next(...) (L148) mints a fresh id per call, the controller (L31-37) only reads X-Principal, EventEngine.append (L42) always generates a new eventId, deductStock (L111) always re-subtracts, and the only PRIMARY KEY on domain tables is the freshly-generated root id (SchemaInitializer L91) — no natural-key uniqueness. So there is genuinely no dedup at any layer. + +However, this is a MISSING HARDENING FEATURE, not a defect. Reasons the finding should not stand at the claimed 'high' severity: + +1) No c … + +### B3. M8 客户模板绑定的 SCENE_CREATE_CUSTOMER 在 M4 中不存在 + +- **严重度**:HIGH **维度**:model-integrity **位置**:`models/M8-front-schema.yaml:20` +- **缺陷**:M8 template_customer_single 绑定 bindSceneId: SCENE_CREATE_CUSTOMER,但 M4 sceneModel 从未定义该场景,导致该模板永远无法被通用引擎渲染或执行。 +- **证据**: +``` +M8-front-schema.yaml:20 `bindSceneId: SCENE_CREATE_CUSTOMER`;M4-scene.yaml 仅定义 SCENE_CREATE_ORDER / SCENE_MODIFY_ORDER_ADDR / SCENE_CANCEL_ORDER(无 SCENE_CREATE_CUSTOMER)。grep 全仓 SCENE_CREATE_CUSTOMER 仅出现在 M8。 +``` +- **影响 / 触发场景**:MetaController.scenes() 只遍历 M4 scenes,SCENE_CREATE_CUSTOMER 不在其中,故前端侧边栏永远不列出客户模板;若前端直接请求 /api/meta/scene/SCENE_CREATE_CUSTOMER,SceneSchemaService.buildSceneSchema 第 26 行 `if (scene.isEmpty()) throw new NoSuchElementException` 直接抛异常。M2 的 CreateCustomer/CreateProduct 命令、M5 无对应权限,全部成为无法触达的死元数据——违背『M8 模板↔M4 场景』契约。 +- **建议修复**:在 M4 增加 sceneId: SCENE_CREATE_CUSTOMER(含 permissionBind + flowSteps bindCommand: CreateCustomer),并在 M5 functionPermissions 增补对应 permId;或修正 M8 的 bindSceneId 指向已存在场景。 +- **复现核验**:Every link in the claimed chain is verified against the actual files. (1) models/M8-front-schema.yaml:20 does contain `bindSceneId: SCENE_CREATE_CUSTOMER` on template `template_customer_single`. (2) models/M4-scene.yaml defines only SCENE_CREATE_ORDER (l.10), SCENE_MODIFY_ORDER_ADDR (l.22), SCENE_CANCEL_ORDER (l.32); repo-wide grep shows SCENE_CREATE_CUSTOMER exists nowhere except M8, so it is a dangling reference. (3) MetaController.scenes() (l.30) iterates models.scenes(), which reads strictly M4.sceneModel.sceneDefinitions (ModelRepository.java:222-224); the React sidebar is built entirely from that list (web/src/App.tsx:21,53), so the customer template can never be listed. There is no al … +- **反驳核验**:The finding's raw facts check out but its "high"/reachable-defect framing does not survive inspection. + +FACTS (confirmed): M4-scene.yaml only defines SCENE_CREATE_ORDER / SCENE_MODIFY_ORDER_ADDR / SCENE_CANCEL_ORDER (M4:10,22,32); M8-front-schema.yaml:20 `bindSceneId: SCENE_CREATE_CUSTOMER` has no matching M4 scene. True. + +WHY IT IS NOT A REACHABLE DEFECT: +1. No forward dereference exists. The ONLY consumer of M8 templates is a reverse lookup: ModelRepository.templateByScene (ModelRepository.java:262-267) iterates pageTemplates() and matches `sceneId.equals(tm.get("bindSceneId"))`, keyed on a REAL scene id passed in. pageTemplates()'s only caller is templateByScene itself (grep confirms line … + +### B4. M2 CreateOrder.emitEvents 引用了 ME 未定义的事件 + +- **严重度**:HIGH **维度**:model-integrity **位置**:`models/M2-command.yaml:128` +- **缺陷**:CreateOrder 声明发出 OrderItemAdded / OrderPaymentTermSet / OrderDeliveryAddressSet,但 ME-event.yaml 的 OrderAggregate 只定义了 OrderCreated / OrderDeliveryAddressModified / OrderCancelled,三个事件在事件模型中不存在。 +- **证据**: +``` +M2-command.yaml:128-130 `- OrderItemAdded` `- OrderPaymentTermSet` `- OrderDeliveryAddressSet`;ME-event.yaml:45/49/53 OrderAggregate.events 仅有 `OrderCreated:` `OrderDeliveryAddressModified:` `OrderCancelled:`。 +``` +- **影响 / 触发场景**:CommandEngine 第 174-177 行遍历 cmd.emitEvents 调用 eventEngine.append;EventEngine.append 第 41 行对 ME 未声明的事件走 `def.isEmpty() ? deriveTopic(eventName)`,凭空派生出 topic_order_item_added 等 topic,且 eventPayload 因 eventDef 为空只塞 rootId。这些是 ME 从未声明的『幻影事件』,topic/payload/consumers 全部脱离事件本体——M2↔ME 不一致,消费者(product-service 等)也无从订阅。 +- **建议修复**:要么在 ME-event.yaml 的 OrderAggregate 补齐 OrderItemAdded/OrderPaymentTermSet/OrderDeliveryAddressSet 的 topic+payload+consumers,要么从 M2 CreateOrder.emitEvents 删除这三个未建模事件。 +- **复现核验**:Evidence fully verified. models/M2-command.yaml:126-130 declares CreateOrder.emitEvents = [OrderCreated, OrderItemAdded, OrderPaymentTermSet, OrderDeliveryAddressSet]. models/ME-event.yaml:43-56 defines OrderAggregate.events with only OrderCreated (45), OrderDeliveryAddressModified (49), OrderCancelled (53) — so OrderItemAdded/OrderPaymentTermSet/OrderDeliveryAddressSet are undefined. Grep over models/, engine/, web/ confirms those three names appear ONLY at M2:128-130. The runtime chain is real: CommandEngine.java:174-177 loops cmd.emitEvents calling eventEngine.append; eventPayload (373-384) resolves an empty eventDef (ModelRepository.eventDef:276-285 returns emptyMap for undefined events) … +- **反驳核验**:The literal observation is true: M2-command.yaml:126-130 declares CreateOrder.emitEvents = [OrderCreated, OrderItemAdded, OrderPaymentTermSet, OrderDeliveryAddressSet], while ME-event.yaml:44-56 defines only OrderCreated/OrderDeliveryAddressModified/OrderCancelled for OrderAggregate. But the finding's characterization as a high-severity defect does not survive. (1) The engine INTENTIONALLY supports events not declared in ME: EventEngine.append:40-41 comments "ME 已声明 topic 则用之;未声明的事件按命名约定派生 topic_snake_case" and branches def.isEmpty()?deriveTopic(...); ModelRepository.eventDef:284 returns emptyMap by design; CommandEngine.eventPayload:382 falls back to {rootId}. Nowhere is emitEvents⊆ME enfor … + +### B5. permitted() fails open when a scene has no permissionBind + +- **严重度**:HIGH **维度**:security **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:359` +- **缺陷**:When a scene's permissionBind list is empty/absent the permission check returns true for everyone (default-allow / fail-open). +- **证据**: +``` +List perms = asList(scene.get("permissionBind")); + if (perms.isEmpty()) return true; // any scene lacking permissionBind is executable by any principal +``` +- **影响 / 触发场景**:Because the model is the source of truth and scenes are hot-reloadable (POST /api/meta/reload), any new/edited scene that omits permissionBind — or a typo that makes asList() return empty — becomes fully unauthenticated-executable. The safe default for an authorization gate must be deny, not allow. Attack: add a scene without permissionBind, reload, and anyone can run its bound command (persist rows, emit events, deduct stock). +- **建议修复**:Default to deny: return false (or throw) when permissionBind is empty/unresolved, and require every executable scene to bind at least one functionPermission. +- **复现核验**:Evidence confirmed at CommandEngine.java:357-364: permitted() does `if (perms.isEmpty()) return true;`, so a scene whose permissionBind is empty/absent authorizes every principal. It is reached from the execute() gate (line 76-77). There is genuinely no guardrail: ModelRepository.load()/reload() (lines 50-69) only calls `new Yaml().load(in)` with no schema validation, and the engine has no JSON-schema dependency. M0-meta-schema.yaml:226 lists permissionBind under `required`, but that is documentary and never enforced — a scene that omits the key or mistypes it (e.g. `permissonBind:`) loads fine, then scene.get("permissionBind") is null, asList()->empty, and permitted() returns true. Concrete … +- **反驳核验**:The literal code is as quoted: CommandEngine.java:359 `if (perms.isEmpty()) return true;` default-allows when permissionBind is empty. But the finding's framing as a high-severity, reachable authorization bypass does not hold up. + +(1) There is no authorization boundary to fail open against. `principal` is read verbatim from a client-controlled `X-Principal` header (CommandController.java:34-35); grep of engine/src and pom.xml shows NO Spring Security, no SecurityFilterChain/filter/@PreAuthorize, and CORS is wide open (WebConfig.java:12-15). Any anonymous caller can set X-Principal to any value, and allowPrincipals values are served publicly (MetaController GET /api/meta/model/{code} raw M5 a … + +### B6. Generic /api/data/{aggregateId} dumps entire root table of any aggregate with no authz + +- **严重度**:HIGH **维度**:security **位置**:`engine/src/main/java/com/onto/engine/web/DataController.java:24` +- **缺陷**:The options/list read endpoints perform no authentication or permission check and return every row of the requested aggregate's root table, exposing all customers/orders to anonymous callers. +- **证据**: +``` +@GetMapping("/{aggregateId}") + public List> options(@PathVariable String aggregateId) { + return readService.options(aggregateId); // DomainReadService.listRoot -> repo.listAll(table) = SELECT * FROM ; opt.put("data", d) returns ALL domain fields +``` +- **影响 / 触发场景**:No principal is even passed to DataController; there is no ownership/row-level check. GET /api/data/CustomerAggregate returns every customer (name, level, masked phone); GET /api/data/OrderAggregate returns every order root row (customerId, totalAmount, etc.); GET /api/data/orders/list returns all orders. Any aggregateId is accepted, so the endpoint is a generic anonymous table-dump primitive. Only two fields (contactPhone/receiverPhone) are masked — all other columns are returned in the clear. +- **建议修复**:Require authentication and enforce a read permission (and per-record ownership for order data) before returning rows; restrict which aggregates are exposed as option sources rather than accepting an arbitrary aggregateId. +- **复现核验**:Evidence verified verbatim. DataController.java:24-27 exposes GET /api/data/{aggregateId} -> DomainReadService.options() -> listRoot() -> DynamicRepository.listAll() which runs `SELECT * FROM
` (DynamicRepository.java:76-78) and returns all rows. options() attaches every domain field via opt.put("data", d) (DomainReadService.java:48). M5-security-model.yaml maskRules contains only contactPhone and receiverPhone, so all other columns (customer name/level, order customerId/totalAmount) are returned in clear. There is NO authorization on this path: no @RequestHeader/principal param on DataController, no permission() call, and no Spring Security dependency in pom.xml (grep found none). Th … +- **反驳核验**:The finding is factually accurate (DataController.java:25-26 `options()` and :30-33 `orders()` return every root row with no principal), but it mischaracterizes an app-wide, intentional demo posture as a high-severity access-control regression in the reviewed engine code. Refutation on four grounds: + +1) There is NO authentication anywhere in the system to bypass. pom.xml has no spring-boot-starter-security (only web, jdbc, h2, mysql, snakeyaml, aviator). The only "identity" is the self-asserted, unverified `X-Principal` HTTP header on the command path (CommandController.java:34-35), defaulting to `normal_user` (application.yml:23). Any caller can send `X-Principal: admin`, so CommandEngine.p … + +### B7. Map.of() with user-derived nullable values throws NPE when building stock-deduct items + +- **严重度**:MEDIUM **维度**:correctness **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:183` +- **缺陷**:Map.of() rejects null keys/values, so an OrderItem whose productId (or explicitly-null quantity) is null causes a NullPointerException while assembling cross-consistency items, aborting execute after the order was already persisted. +- **证据**: +``` +for (Map it : ctx.details.getOrDefault(firstItemEntity(aggregateId), List.of())) { + items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))); +} +// Map.of throws NPE if it.get("productId") is null, or if key "quantity" is present with a null value (getOrDefault only substitutes for ABSENT keys) +``` +- **影响 / 触发场景**:In the shipped M8, productId/quantity are required so validate() blocks the null case. But making a line-item field optional is exactly the supported 'edit YAML only' workflow: remove `required: true` from select_product in M8, then POST an OrderItem like {"quantity":1} (no productId). validate() passes, persist succeeds, then line 183 executes Map.of("productId", null, ...) -> NPE -> transaction rollback / HTTP 500. Also triggers if a client sends {"productId":"P001","quantity":null} once quantity is non-required. The generic engine should not assume these payload fields are non-null. +- **建议修复**:Use a null-tolerant map (e.g. new LinkedHashMap<>() with put/putIfAbsent, or HashMap) instead of Map.of, and skip/handle items with missing productId explicitly. +- **复现核验**:Evidence verified verbatim at CommandEngine.java:183: `items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0)));`. Both NPE mechanisms are correct Java: `Map.of` (ImmutableCollections.MapN) calls Objects.requireNonNull on every value, and `getOrDefault` returns the map's null value when the key is PRESENT-but-null (it only substitutes the default for ABSENT keys). So a null/absent productId, or an explicit `"quantity":null`, both crash line 183. + +Reachability confirmed end-to-end: (1) M8 currently marks both fields required (M8-front-schema.yaml:144-146 productId required:true, 154-156 quantity required:true), and validate() (lines 208-222) rejects blan … +- **反驳核验**:The Map.of NPE pattern at CommandEngine.java:183 is real in the abstract (Map.of rejects null values, and getOrDefault only substitutes for ABSENT keys, not present-but-null), but it is NOT reachable in the delivered system, and the finding itself concedes this. + +Guard verified: validate() (lines 205-222) drives off ctx.requiredFields, populated by collectRequired() (lines 337-352) from every M8 childComponent carrying required:true, keyed by domainFieldName + the container's domainEntityName ("OrderItem"). In the shipped M8 (M8-front-schema.yaml:144-146 productId, 153-156 quantity) BOTH fields are required:true bound to OrderItem. So any OrderItem row with a null/blank productId or quantity … + +### B8. Cross-consistency is a hardcoded inline call decoupled from the outbox and from actually-emitted events + +- **严重度**:MEDIUM **维度**:eda **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:186` +- **缺陷**:StockDeduct is triggered by a literal "OrderCreated" and item fields "productId"/"quantity" pulled straight from the payload in the same transaction, while the outbox poller only flips status NEW->PUBLISHED; the appended events are never consumed to drive side effects, so the 'event-driven eventual consistency' is neither event-driven nor eventual. +- **证据**: +``` +CommandEngine L182-186: + for (Map it : ctx.details.getOrDefault(firstItemEntity(aggregateId), List.of())) { + items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))); + } + eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)); +EventEngine.pollAndPublish() L131-134 only logs and does UPDATE ... SET status='PUBLISHED'; no consumer reads payload_json to invoke StockDeduct. The emitted events (cmd.emitEvents, L174) and the cross-consistency trigger are wired independently. +``` +- **影响 / 触发场景**:(a) If M2 emitEvents is renamed away from OrderCreated, the outbox no longer carries OrderCreated yet stock is still deducted (trigger ignores what was actually appended); conversely renaming keeps deduction firing off a stale literal. (b) If the item entity's field is renamed from productId/quantity in the model, items carry null productId -> deductStock returns SKIP_NOT_FOUND and stock is silently never deducted while the order commits. (c) Because deduction runs inline in execute()'s transaction, it is strong-consistent, not the 'eventual' path the outbox implies; the outbox is write-only decoration. +- **建议修复**:Drive cross-consistency from the outbox relay: have pollAndPublish (or a real consumer) read appended events and dispatch crossAggConsistencyRules by matching the persisted event_name/payload, instead of a literal "OrderCreated" and hardcoded item field names. +- **复现核验**:Evidence verified verbatim. CommandEngine.java L180-186 (inside the `@Transactional execute()` path, step "e"): +``` +List> items = new ArrayList<>(); +for (Map it : ctx.details.getOrDefault(firstItemEntity(aggregateId), List.of())) { + items.add(Map.of("productId", it.get("productId"), "quantity", it.getOrDefault("quantity", 0))); +} +List> stockActions = eventEngine.applyCrossConsistency("OrderCreated", Map.of("items", items)); +``` +The trigger event is the string literal "OrderCreated" and the item fields "productId"/"quantity" are pulled from the payload by hardcoded key. EventEngine.applyCrossConsistency (L66-82) matches on `rule.get("trig … +- **反驳核验**:The finding is an architecture/design-fidelity critique dressed as an EDA correctness defect; as an "eda" defect it produces no reachable wrong behavior in the system as designed and shipped. + +1) The mapping is in fact model-driven, contradicting "neither event-driven." At CommandEngine.java:186 the literal "OrderCreated" is only a lookup key; the actual decision OrderCreated→StockDeduct→targetAggregate is resolved from ME.yaml via EventEngine.applyCrossConsistency (L66-82: `for (Object r : models.crossConsistencyRules())` matching `rule.get("triggerEvent")`), and ModelRepository.crossConsistencyRules() (L287-289) reads ME-event.yaml's crossAggConsistencyRules (ME L58-62). So the consistency … + +### B9. Control type ignores M1 fieldType, contradicting advertised model-change behavior + +- **严重度**:MEDIUM **维度**:frontend **位置**:`web/src/engine/FieldControl.tsx:24` +- **缺陷**:FieldControl selects the widget solely from M8 compType and never consults the M1 fieldType, so changing a field's domain type does not change its control. +- **证据**: +``` +switch (comp.compType) { + case 'number_input': ... + case 'input': + default: return +``` +- **影响 / 触发场景**:App.tsx Help step 3 tells users '把某字段 type 由 string 改为 int → 热重载 → 该组件输入类型随之改变', but fieldType is only used for the small '[int]' tag in FieldLabel (SceneForm.tsx:161); the actual control stays whatever M8 compType says. This documented 'change model → change behavior' scenario silently does not work, undermining the core demo claim. +- **建议修复**:Either derive/override the control from fb.fieldType when compType is generic (int/decimal -> InputNumber), or correct the Help text to state the widget is driven by M8 compType, not M1 type. +- **复现核验**:Verified in the actual files. FieldControl.tsx:24 does `switch (comp.compType)` and never reads `fb.fieldType`; the only fieldBind props it uses are placeholder/formLabel/optionsSource/masked. Grep confirms `fieldType` is referenced in web/src only at types.ts:12 and SceneForm.tsx:161 (the small `[{fb.fieldType}]` secondary tag). SceneSchemaService.enrich() proves the two are decoupled: line 115 copies M8 compType verbatim while line 128 separately sets fbOut.fieldType from M1, so a change to M1 type cannot alter compType. App.tsx:122 (Help modal titled 验证「改模型→改行为」) explicitly promises step 3: change a field's M1 type string→int → hot reload → 该组件输入类型随之改变. Concrete repro: M8 line 101–106 bin … +- **反驳核验**:The finding files a code defect against FieldControl.tsx:24, but the code there is correct by design, not broken. FieldControl is deliberately a GENERIC widget registry keyed on M8 `compType`, and SceneSchemaService.enrich() (SceneSchemaService.java:116) copies `compType` verbatim from M8 — `out.put("compType", comp.get("compType"))`. M8-front-schema.yaml authors `compType` explicitly per component (input/select/number_input/date_picker). So widget selection is a presentation concern owned by M8, while domain type is owned by M1. That separation is defensible DDD layering, arguably MORE correct than auto-deriving the widget from the domain type (an `int` field might legitimately want a slide … + +### B10. M0 元校验根规范从未被引擎执行,且各层实际不符合 M0 的模式约束 + +- **严重度**:MEDIUM **维度**:model-integrity **位置**:`models/M0-meta-schema.yaml:10` +- **缺陷**:M0 自称『全域校验根规范』,但 ModelRepository 只把 M0 当作原始层加载展示,从不对任何层做 JSON-Schema 校验;而其它层还大面积违反 M0 自身定义的模式。 +- **证据**: +``` +M0-meta-schema.yaml:10 Identifier `pattern: "^[a-z][a-zA-Z0-9]*$"`(禁止下划线),但 M8 的 compId(如 24:`card_customer_base`)、templateId(16:`template_customer_single`) 及 M4 permissionBind(order_create) 全是 snake_case;M0:21 SceneId `pattern: "^SCENE_[A-Z0-9_]+$"`,而 M8:278 template3 `bindSceneId: ""` 为空串不匹配。ModelRepository.java:50-64 load() 仅 `new Yaml().load(in)` 存入 layers,全仓无任何按 rootAllOntologySchema 的校验调用。 +``` +- **影响 / 触发场景**:M0 承诺的『统一前后端数据/组件/页面描述标准』只是装饰性文档:既不会在加载/热重载时拦截不合规模型,其它层与 M0 的 pattern 也系统性冲突。任何『先改 M0 收紧约束』的操作对运行时行为零影响,给出错误的合规安全感。 +- **建议修复**:在 ModelRepository.load()/reload() 中用 M0 的 definitions 对各层做 JSON-Schema 校验并在不合规时报错;同时统一 Identifier/SceneId 模式与实际命名(snake_case 或改 M0 pattern)。 +- **复现核验**:All quoted evidence verified against the actual files. M0-meta-schema.yaml:10 defines `Identifier: pattern: "^[a-z][a-zA-Z0-9]*$"` (no underscores) and M0:21 defines `SceneId: pattern: "^SCENE_[A-Z0-9_]+$"`. These patterns are bound via $ref to real fields: compId→Identifier (M0:298), templateId→Identifier (M0:310), permissionBind items→Identifier (M0:237), bindSceneId→SceneId (M0:314, required at M0:308). M0 also declares `rootAllOntologySchema` (M0:372-384) and its header calls itself the "全域校验根规范". Sibling models systematically violate these: M8:16 templateId `template_customer_single`, M8:24 compId `card_customer_base`, M4:13 permissionBind `order_create` all use snake_case (illegal unde … +- **反驳核验**:The finding's facts are accurate but it does not describe a reachable functional defect nor an iron-rule violation; it is an aspirational-documentation / cosmetic-consistency observation. + +Confirmed facts: ModelRepository.load() (lines 50-64) only calls `new Yaml(new LoaderOptions()).load(in)` and stores raw objects; grep shows no JSON-Schema library and no reference to `rootAllOntologySchema` anywhere in engine/ or web/. The only 3 code hits for "M0" are two doc comments and `LAYER_FILES.put("M0","M0-meta-schema.yaml")`, which loads it purely as a display layer (rawLayer). compIds/templateIds are snake_case (M8 lines 16,24,34...), `bindSceneId: ""` at M8:278, and `permissionBind:[order_crea … + +### B11. H2 web console enabled without authentication in the default profile + +- **严重度**:MEDIUM **维度**:security **位置**:`engine/src/main/resources/application.yml:13` +- **缺陷**:The default profile (used by start.sh and mvn spring-boot:run) exposes the H2 console at /h2-console with the sa account and an empty password. +- **证据**: +``` +username: sa + password: "" + h2: + console: + enabled: true + path: /h2-console +``` +- **影响 / 触发场景**:Anyone who can reach the engine port can browse to /h2-console and connect with jdbc:h2:mem:trade_db, user sa, empty password — full read/write/arbitrary-SQL on the running database (and H2 console SQL can reach the filesystem/RCE in some configs). There is no auth in front of it. This is the day-to-day run mode per CLAUDE.md. +- **建议修复**:Disable the H2 console by default (only enable behind auth on an explicitly local bind), and never ship an empty DB password on a network-reachable service. +- **复现核验**:Evidence verified at engine/src/main/resources/application.yml:11-16: `username: sa`, `password: ""`, and `h2.console.enabled: true` / `path: /h2-console`. No Spring Security exists (grep for SecurityFilterChain/spring-boot-starter-security/@EnableWebSecurity/httpBasic returns nothing; pom.xml has only starter-web/starter-jdbc/h2), and WebConfig.java only adds permissive CORS — so nothing guards /h2-console. Concrete reproduction: `./start.sh` or `mvn spring-boot:run` uses the default profile; browsing to http://localhost:8080/h2-console and connecting with jdbc:h2:mem:trade_db, user sa, empty password yields full SQL access to the live in-memory DB (same JVM, shared named mem DB), and H2 co … +- **反驳核验**:The config line is factually present (application.yml:11-16: username sa, password "", h2.console.enabled: true, path /h2-console), but it is not a reachable security defect. (1) Production disables it and the deployment forces the prod profile: deploy/Dockerfile.engine:15 hardcodes ENTRYPOINT java -jar app.jar --spring.profiles.active=prod, and application-prod.yml:9-11 sets h2.console.enabled: false (and switches to MySQL). deploy/README.md:16 confirms the engine runs with the prod profile. So no deployed topology exposes the console. (2) The enabled-console profile is only reachable via a local ./start.sh / mvn spring-boot:run run against jdbc:h2:mem:trade_db — an in-memory, ephemeral DB … + +### B12. CORS allows any origin on all /api/** endpoints in every profile + +- **严重度**:MEDIUM **维度**:security **位置**:`engine/src/main/java/com/onto/engine/config/WebConfig.java:12` +- **缺陷**:The CORS policy allows all origins, methods and headers for /api/**, and this config is not profile-scoped, so it applies to prod as well as dev. +- **证据**: +``` +registry.addMapping("/api/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*"); +``` +- **影响 / 触发场景**:Combined with the lack of authentication and the header-based principal, any malicious website loaded in a victim's browser can issue cross-origin GET/POST to the engine (including /execute with X-Principal: backend_admin, since allowedHeaders('*') permits the custom header) and read all /api/data responses. There is no origin allow-list even for production. +- **建议修复**:Restrict allowedOrigins to the known front-end origin(s) per environment (profile-scoped config), and only allow the headers/methods actually required. +- **复现核验**:Evidence verified verbatim at engine/src/main/java/com/onto/engine/config/WebConfig.java:12-15: addMapping("/api/**").allowedOriginPatterns("*").allowedMethods("GET","POST","PUT","DELETE","OPTIONS").allowedHeaders("*"). WebConfig is an unconditional @Configuration (no @Profile), so the wide-open CORS applies to every profile including prod — application-prod.yml exists and points at a real MySQL, confirming a production deployment path. + +The impact is reachable because there is NO authentication in the app: grep shows no spring-security dependency in pom.xml, no SecurityFilterChain/@EnableWebSecurity anywhere. The only principal mechanism is the attacker-controllable X-Principal request head … +- **反驳核验**:Read WebConfig.java:12-15 and grepped all of engine/ for allowCredentials/SecurityFilterChain/cookies — none exist. The refutation stands on four points: (1) The CORS mapping uses allowedOriginPatterns("*") WITHOUT allowCredentials(true). Wildcard CORS is only a recognized vulnerability when paired with credentials; without it, the browser attaches no cookies/ambient auth and no credentialed data can be exfiltrated. (2) The engine has NO authentication at all (no Spring Security, no session, no cookies — confirmed by grep). The "principal" is a purely client-supplied X-Principal header (CommandController.java:34, default normal_user at application.yml:23). CORS exists to protect ambient auth … + +### B13. Model viewer /api/meta/model/{code} exposes the full security model unauthenticated + +- **严重度**:MEDIUM **维度**:security **位置**:`engine/src/main/java/com/onto/engine/web/MetaController.java:66` +- **缺陷**:Any caller can retrieve the raw parsed content of any model layer, including M5 with its principals, functionPermissions and mask/encrypt rules. +- **证据**: +``` +@GetMapping("/model/{code}") + public Map model(@PathVariable String code) { + ... out.put("content", models.rawLayer(code)); // returns raw M5-security.yaml incl. allowPrincipals: [normal_user, backend_admin] +``` +- **影响 / 触发场景**:GET /api/meta/model/M5 discloses the exact principal strings and which permissions map to which scenes. Because the principal is a spoofable header (see other finding), this hands an attacker the precise value (backend_admin) needed to escalate, plus the full mask/encrypt configuration. All of it is unauthenticated. +- **建议修复**:Gate the model-viewer and metadata endpoints behind authentication/authorization, and avoid returning the security layer (principals/permissions) to unprivileged callers. +- **复现核验**:Evidence verified. MetaController.java:66-72 returns models.rawLayer(code) with no auth; ModelRepository.java:94 confirms rawLayer just returns layers.get(code) (full parsed content, unfiltered). M5-security.yaml contains the exact sensitive data claimed: principals [normal_user, backend_admin], functionPermissions with allowPrincipals mapped to scenes, maskRules, and encryptRules. There is no authentication layer: engine/pom.xml has no spring-boot-starter-security, WebConfig only adds a wildcard CORS mapping, and no filter/interceptor exists. Reproducible manifestation: `curl http://localhost:8080/api/meta/model/M5` returns the full raw M5 security model to any anonymous caller. So the endp … +- **反驳核验**:The endpoint exists and does return raw M5 content unauthenticated, but the finding's substantive claims (medium-severity info disclosure that enables privilege escalation) are refuted by the actual code. + +1) It is an intentional core feature, not a leak. MetaController.java:66-73 `/api/meta/model/{code}` powers the frontend "模型查看器" (Model Viewer) tab — web/src/panels.tsx:110 comment: "直接查看任意本体层解析后的内容,证明「页面即模型」" and App.tsx:125 "「模型查看器」标签页可实时查看引擎当前加载的各层模型内容". The whole design contract (CLAUDE.md) is that the models are the openly-displayed SINGLE SOURCE OF TRUTH. Displaying every layer is the endpoint's designed purpose. + +2) No secrets are disclosed. M5-security.yaml (read in full) contains … + +### B14. Hardcoded default database credentials in prod profile and compose file + +- **严重度**:MEDIUM **维度**:security **位置**:`engine/src/main/resources/application-prod.yml:8` +- **缺陷**:The production profile falls back to a hardcoded DB password, and docker-compose commits weak MySQL root/user passwords. +- **证据**: +``` +password: ${DB_PASS:trade123} // application-prod.yml L8; deploy/docker-compose.yml L8 MYSQL_ROOT_PASSWORD: root123, L11 MYSQL_PASSWORD: trade123 +``` +- **影响 / 触发场景**:If DB_PASS is not overridden, prod connects with the well-known committed password 'trade123'; the compose stack ships root123/trade123. Anyone with the repo (or scanning the deployment) knows the DB credentials, enabling direct database access on the exposed 3306 port. +- **建议修复**:Remove hardcoded fallbacks; require DB_PASS/root password to be injected via secrets with no default, and do not commit real credentials to the compose file. +- **复现核验**:Evidence verified exactly as quoted. engine/src/main/resources/application-prod.yml:8 reads `password: ${DB_PASS:trade123}`, so the production Spring profile silently falls back to the committed literal `trade123` when DB_PASS is unset (CWE-798/CWE-521 silent weak-credential fallback rather than a fail-fast). deploy/docker-compose.yml — whose own header comment calls it the production topology mapping ("生产拓扑映射...MySQL/RocketMQ 为生产映射") — commits `MYSQL_ROOT_PASSWORD: root123` (L8), `MYSQL_USER: trade`/`MYSQL_PASSWORD: trade123` (L10-11), passes `DB_PASS: trade123` to the engine (L39), and binds MySQL to the host via `ports: ["3306:3306"]` (L12). Concrete manifestation: deploying via the docum … +- **反驳核验**:Read application-prod.yml, docker-compose.yml, and application.yml. The finding does not survive refutation as a genuine reachable defect. (1) application-prod.yml:8 `password: ${DB_PASS:trade123}` is the canonical Spring env-var-with-fallback idiom; the file header (lines 1-2) explicitly documents `环境变量可覆盖...DB_PASS` and that the prod profile is opt-in via `--spring.profiles.active=prod`. `trade123` is a documented, overridable placeholder, not an accidentally leaked secret. (2) In docker-compose.yml the MySQL container is PROVISIONED with `trade123` (L11) and the engine connects with the same `trade123` (L39) — a self-contained stack must state its DB password somewhere; this is the standa … + +### B15. toBig() swallows parse errors and returns ZERO, letting non-numeric dueDays bypass the >=0 check + +- **严重度**:LOW **维度**:correctness **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:419` +- **缺陷**:toBig() catches every exception and returns BigDecimal.ZERO, so a non-numeric dueDays like "abc" yields signum()==0 and silently passes the 'dueDays 不可为负' validation instead of being rejected. +- **证据**: +``` +private static BigDecimal toBig(Object o) { + try { return new BigDecimal(String.valueOf(o)); } catch (Exception e) { return BigDecimal.ZERO; } +} +// used at line 235: if (d != null && toBig(d).signum() < 0) errors.add("账期天数 dueDays 不可为负"); +``` +- **影响 / 触发场景**:An invalid dueDays value (e.g. "abc", "3d", "1,0") is treated as 0 and accepted by the validator, then crashes downstream at DynamicRepository.convert("int", "abc"). The swallowed exception hides malformed input and produces a wrong validation verdict rather than a clear rejection. +- **建议修复**:Treat unparseable numeric input as a validation error (return an errors entry) instead of silently mapping it to ZERO. +- **复现核验**:Evidence verified. CommandEngine.java:418-420 `toBig()` catches every exception and returns BigDecimal.ZERO. It is used at line 235 (`if (d != null && toBig(d).signum() < 0) errors.add("账期天数 dueDays 不可为负")`), inside the branch guarded by line 232 `cmdValidations.contains("dueDays")`, which is satisfied because M2-command.yaml:123 contains `- dueDays >= 0, depositRatio ∈ [0,1]`. dueDays is declared int in M1-domain.yaml:93-94 and mapped to due_days in M3-deploy.yaml:77. + +Concrete manifestation: submit SCENE_CREATE_ORDER with details.PaymentTerm[0].dueDays = "abc". In validate(), toBig("abc") swallows the NumberFormatException → returns ZERO → signum()==0 is not < 0 → no error added → validati … +- **反驳核验**:The mechanism is literally true (toBig("abc") returns BigDecimal.ZERO so signum()==0 skips the "dueDays 不可为负" error at CommandEngine.java:235), but the claimed correctness impact is neutralized on every path. + +(1) The line-232 check is a narrow NEGATIVITY gate derived from M2 text "dueDays >= 0" (models/M2-command.yaml:123); its sole job is to reject negatives. "abc" is genuinely not a negative number, so not adding a negativity error is semantically consistent. This engine has no per-field numeric-type validator for any field — type enforcement is delegated to the persist layer by design. + +(2) No data ever gets accepted. dueDays is M1 type int (models/M1-domain.yaml:93-94) persisted to due_ … + +### B16. deductStock() unguarded Long.parseLong crashes on NULL stock column (asymmetric with productStock) + +- **严重度**:LOW **维度**:correctness **位置**:`engine/src/main/java/com/onto/engine/event/EventEngine.java:105` +- **缺陷**:deductStock only checks row.isEmpty() before Long.parseLong(String.valueOf(row.get(stockCol))); if the stock column is NULL the value becomes the literal "null" and parseLong throws NumberFormatException inside the order transaction. +- **证据**: +``` +Map row = repo.findByPk(table, pkCol, productId); +if (row.isEmpty()) { action.put("status", "SKIP_NOT_FOUND"); return action; } +long current = Long.parseLong(String.valueOf(row.get(stockCol))); // row.get(stockCol)==null -> "null" -> NumberFormatException +``` +- **影响 / 触发场景**:CommandEngine.productStock() guards this exact case (returns null when row.get(stockCol)==null, so precheck's runRules silently skips the low-stock ALERT), but deductStock() does not, so precheck and execute diverge: for a product row with a NULL stock value, precheck passes cleanly while OrderCreated -> StockDeduct crashes with NumberFormatException, rolling back a valid order. Same crash if stockNum is later modeled as decimal in M1 (H2 returns BigDecimal -> String.valueOf("100.0000") -> parseLong fails). Trigger: any Product whose stock_num is NULL (e.g. created without stockNum, or column added post-hoc) referenced by an OrderItem. +- **建议修复**:Null-check row.get(stockCol) (mirror productStock's guard) and parse via BigDecimal(...).longValue() to tolerate decimal storage; treat missing/NULL stock as 0 or SKIP rather than throwing. +- **复现核验**:Evidence confirmed verbatim. EventEngine.java:101 guards only `if (row.isEmpty())`, then line 105 does `long current = Long.parseLong(String.valueOf(row.get(stockCol)));`. When the stock column is NULL, `String.valueOf((Object)null)` yields the literal "null" and `Long.parseLong("null")` throws NumberFormatException. The asymmetry is real: CommandEngine.java:292 `productStock()` guards `if (row.isEmpty() || row.get(stockCol) == null) return null;` whereas deductStock does not. There is even an internal inconsistency inside deductStock: line 92 strips decimals for qty via `.replaceAll("\\..*$","")` but line 105 does not for stock. + +Concrete reproduction (NULL path): DynamicRepository.insertDo … +- **反驳核验**:The syntactic asymmetry is real: EventEngine.deductStock() (engine/.../event/EventEngine.java:105) does `long current = Long.parseLong(String.valueOf(row.get(stockCol)));` guarding only `row.isEmpty()` (line 101), whereas CommandEngine.productStock() (engine/.../command/CommandEngine.java:292-293) additionally guards `row.get(stockCol) == null`. But the claimed runtime crash requires a Product row whose stock_num is NULL, and that state is unreachable in the current models. (1) No product-creation path exists: M4-scene.yaml defines only SCENE_CREATE_ORDER/SCENE_MODIFY_ORDER_ADDR/SCENE_CANCEL_ORDER (none create a product), and M8 has only customer/order/blank templates — no product template. … + +### B17. Required fields are visual-only; no client-side validation + +- **严重度**:LOW **维度**:frontend **位置**:`web/src/engine/SceneForm.tsx:160` +- **缺陷**:M8 'required' renders a red asterisk but the Form.Items have no name/rules, so submit and precheck never enforce required fields on the client. +- **证据**: +``` +{fb.required && *} +``` +- **影响 / 触发场景**:The
is decorative: fields are tracked in manual master/singles/tables state with no antd Form binding, so doSubmit/doPrecheck fire even with empty required fields and rely entirely on the backend to reject. The asterisk implies client validation that does not exist (poorer UX, extra round-trips). +- **建议修复**:Before doPrecheck/doSubmit, validate required fields from schema against collected state and surface inline errors, or wire fields through antd Form name/rules. +- **复现核验**:Verified in web/src/engine/SceneForm.tsx. Line 160 renders the required asterisk (`{fb.required && *}`) inside FieldLabel. Every Form.Item (lines 176, 197, and table columns 224-231) is declared as `` with no `name` and no `rules`, so antd's Form store never tracks these fields and can perform no validation. Line 135's `` has no `form` instance and no `onFinish`; submission is a plain Button onClick={doSubmit} (line 147). Field values are held in manual useState (master/singles/tables, lines 38-40) via FieldControl value/onChange, bypassing antd Form entirely. Neither doSubmit (90-107) nor doPrecheck (79-88) contains … +- **反驳核验**:The code claims are literally accurate: SceneForm.tsx:160 renders `{fb.required && *}`, and the Form.Items at lines 176/197/232 have no name/rules, so antd validation is never engaged; doSubmit (90) and doPrecheck (79) build the payload from manual master/singles/tables state (38-40) and POST without a client-side required check. However this is not a defect. (1) The project's IRON RULE makes models/*.yaml the single source of truth and the backend (MetaRule + M2 + M3) the authoritative generic validator; duplicating required-field logic in React would create rule-drift, precisely what the architecture avoids — delegation is intentional design. (2) A pre-submit va … + +### B18. eventPayload 仅从 master 取值,ME 中落在子实体/非领域字段的 payload 解析为 null + +- **严重度**:LOW **维度**:model-integrity **位置**:`engine/src/main/java/com/onto/engine/command/CommandEngine.java:380` +- **缺陷**:eventPayload 对每个 ME payload 字段只从 master 读取,凡是 payload 引用子实体字段(receiverPhone)或根本不是领域字段(productIdList)的事件,其 payload 值都会是 null。 +- **证据**: +``` +CommandEngine.java:378-380 遍历 def.payload,`payload.put(field, field.equals(rootIdField) ? rootId : master.get(field))`;ME-event.yaml:51 OrderDeliveryAddressModified `payload: [orderId, receiverPhone]`(receiverPhone 属 DeliveryAddressEntity 子实体,不在 master);55 OrderCancelled `payload: [orderId, productIdList]`(productIdList 非任何 M1 字段)。 +``` +- **影响 / 触发场景**:当前 CreateOrder 的 OrderCreated payload[orderId,customerId,totalAmount] 恰好都在 master 才侥幸正确;一旦启用 ModifyOrderDeliveryAddress/CancelOrder 等命令,其事件 payload 的子实体/聚合字段会全部为 null,事件内容失真。属 latent break。 +- **建议修复**:eventPayload 应能从 master 与 details 子实体、乃至聚合上下文中按字段名解析 payload 值,而非仅 master.get(field)。 +- **复现核验**:Evidence verified at the cited lines. CommandEngine.java:378-380 builds event payloads with `payload.put(field, field.equals(rootIdField) ? rootId : master.get(field))`, so every payload field except the root id is resolved solely from `master`. ME-event.yaml:51 declares OrderDeliveryAddressModified payload [orderId, receiverPhone] and ME-event.yaml:55 declares OrderCancelled payload [orderId, productIdList]. + +I constructed a concrete, reachable manifestation. SCENE_CANCEL_ORDER is a live scene (M4-scene.yaml:32-40) exposed via POST /api/scene/{sceneId}/execute (CommandController.java:31-37). Permission order_cancel allows normal_user/backend_admin (M5-security.yaml:21-23), and default princ … +- **反驳核验**:Mechanically the observation is accurate: CommandEngine.java:378-380 resolves each ME payload field via `field.equals(rootIdField) ? rootId : master.get(field)`, so a child-entity or non-domain field yields null. But this is not a reachable defect in the system. + +(1) Not wired: the events OrderDeliveryAddressModified / OrderCancelled are only emitted by commands ModifyOrderDeliveryAddress / CancelOrder, reachable only via scenes SCENE_MODIFY_ORDER_ADDR / SCENE_CANCEL_ORDER. M8-front-schema.yaml binds templates ONLY to SCENE_CREATE_CUSTOMER (line 20) and SCENE_CREATE_ORDER (line 76); templateByScene (ModelRepository.java:262-268) returns emptyMap otherwise. The generic renderer refuses these: … + + +--- + +## 附录 C · 被驳回(5 条) + +本组候选未通过复现验证,主要为把"文档态/演示简化"的既定设计误报为缺陷(如 M6/M7 层未接线属设计意图、动态 SQL 标识符来自可信 YAML 不可被用户注入等),或严重度被下调至可忽略。详见运行日志 journal.jsonl。 diff --git b/docs/审计整改说明.md a/docs/审计整改说明.md new file mode 100644 index 0000000..52bd6e2 --- /dev/null +++ a/docs/审计整改说明.md @@ -0,0 +1,69 @@ +# 审计整改说明(对应 `审计报告-2026-07-20.md`) + +本轮按"全面改造"口径整改。核心思路:**把散落在 Java/TS 里的业务字面量上移为可机器读的本体元数据**, +让引擎真正通用地解释执行。所有改动均加法、向后兼容。 + +## 一、模型层新增的结构化元数据(加法) + +| 模型 | 新增字段 | 用途 | +|---|---|---| +| M1 | 属性 `constraints`(min/max/pattern/patternMessage) | 通用校验(取代中文子串匹配 + 硬编码字段) | +| M2 | 命令 `commandType`(create/update/cancel) | 通用分派(取代"一律新建")| +| M2 | 命令 `effect`(op/targetField/keyParam/amountParam/guardField) | 跨聚合副作用通用执行(取代硬编码扣库存)| +| M2 | 命令 `derivations`(如 totalAmount=sum(quantity*itemPrice)) | 服务端重算(防 totalAmount 篡改)| +| M2 | 新增 `StockReturn` 命令 | 取消订单回补库存(修正 ME 与语义相反)| +| ME | 跨聚合规则 `sourceItemEntity` + `paramMap` | 通用组装目标命令入参(取代硬编码 productId/quantity)| +| ME | 补齐 `OrderItemAdded/OrderPaymentTermSet/OrderDeliveryAddressSet/ProductStockReturned` | 修复 M2 引用了 ME 未定义的事件 | +| M4 | 新增 `SCENE_CREATE_CUSTOMER` | 修复 M8 悬空绑定;顺带证明"第二个聚合/场景零代码可跑" | +| M5 | 新增 `customer_create` 权限 | 配套客户场景 | + +## 二、按发现的整改(A 级 32 条 / B 级 18 条) + +### 铁律违背 → 全部改为模型驱动 +- **A1/A13/A16/B8 跨聚合硬编码 StockDeduct/Product/stockNum/productId/OrderCreated**:`EventEngine.applyCrossConsistency` 现遍历**实际发出的事件**,按 ME `crossAggConsistencyRules` 匹配,用 M2 `effect` + `paramMap` **通用执行**任意增/减副作用;`CommandEngine` 不再传字面量 `"OrderCreated"`。 +- **A2/A9/A10/A23 风控变量白名单 + customerLevel()/productStock() 专用方法**:删除这两个方法;`runRules` 现把 master/detail 全字段注入规则 env,并按 **M1 `refAggregate`** 通用解析被引用聚合属性(customerLevel、stockNum 自动可用)。新增引用其他字段的规则即生效。 +- **A11/A12/A25 中文子串匹配 + 硬编码实体/字段校验**:删除;改由 **M1 `constraints`**(pattern/min/max)通用校验,遍历 master 与各子实体行。 +- **A29 firstItemEntity/idPrefix "O for Order"**:删除 `firstItemEntity` 字面量兜底(改由 ME `sourceItemEntity` 指定);`idPrefix` 取聚合根名首字母(通用)。 +- **A31 seed 硬编码 Customer/Product**:改为遍历 `seed-data.yaml` 的聚合 key 通用落库。 +- **A7/A8/A16/A17/A21/A22/A28 前端硬编码**:`SceneForm` 用 `schema.sceneName` + `result.rootId`(后端新增稳定键 `rootId/rootIdField`);`TracePanel`/`OrdersEventsPanel` 列改由 `/api/meta/aggregate/{id}` 的 M1 属性与引擎通用动作形状驱动。 +- **A19/A20 /orders/list 硬编码 OrderAggregate**:改为通用 `/api/data/{aggregateId}/list`;前端 `api.list(agg)`。 +- **A30 前端默认场景硬编码**:改为取 `/meta/scenes` 首个 `hasTemplate` 场景。 +- **EventEngine Outbox 表名硬编码**:改读 ME `outbox.table`。 + +### 正确性 / 数据一致性 +- **B1 超卖**:create 语义下,跨聚合动作任一非 OK(库存不足/不存在)→ `setRollbackOnly` 回滚整单,返回 `CONSISTENCY_FAILED`(已加测试:P002 库存8 下单100 → 失败且库存仍为8)。 +- **A5 并发扣减竞争**:改为原子条件更新 `... SET col=col+? WHERE pk=? AND col+?>=0`,受影响 0 行即判不足。 +- **A27 负数数量膨胀库存**:`effect` 执行拒绝 amount≤0;且 M1 `constraints.min=1` 在校验阶段拦截。 +- **A4/B15/B16 非数字入 int/decimal / dueDays 绕过 / NULL 库存崩溃**:校验阶段按 M1 类型做数值可解析性校验(precheck 与 execute 同源);`readLong` 容错 NULL。 +- **A6 reload 数据竞争**:`ModelRepository` 改为**不可变快照 + volatile 原子发布**,读取方永远看到完整旧/新快照。 +- **A24 修改/取消被误当新建插入**:按 `commandType` 分派 create/update/cancel,不再对非新建命令插入新根。 +- **A14/A32 ME OrderCancelled→StockDeduct 语义相反且从不触发**:新增 `StockReturn`(increment),ME 指向它;触发改为遍历实际事件,cancel 场景从库加载明细回补库存。 +- **B18 事件 payload 仅取 master**:`eventPayload` 现 master 优先、其次子实体行查找。 +- **B7 Map.of NPE**:改用可空 Map 组装(现由 paramMap 通用组装,容忍空值并 SKIP)。 + +### 安全 +- **A26 storageEncrypt 从未兑现**:新增 `EncryptService`(AES-GCM),落库前加密、读出后解密(M5 声明的字段)。**已加测试断言物理列为 `enc:` 密文**。 +- **A15 totalAmount 客户端篡改**:M2 `derivations` 服务端按明细重算,喂给风控与落库。 +- **B5 permitted 失败即放行**:改为 fail-closed(无 permissionBind 的场景默认拒绝)。 +- **A3 X-Principal 可伪造 / 无认证**:坦诚标注为**演示态 stub**(`CommandController` Javadoc + README);热重载改为**仅管理员**(M8 dragRolePermission)可触发。*真正的 OAuth2/JWT 属生产接入项,见"仍为演示简化"。* +- **B12 CORS 全放行**:改为按 profile 配置 `onto.cors.allowed-origins`,生产收紧为具体域名。 +- **B11/B14 H2 控制台无鉴权 / 弱默认口令**:生产 profile 已禁用 H2 控制台;加密密钥与 CORS 域名在 prod profile 要求经环境/密管注入(文档强调)。 + +### 模型完整性 +- **B3 M8 绑定未定义的 SCENE_CREATE_CUSTOMER**:M4 补齐该场景 + M5 权限,客户模板变为可跑。 +- **B4 M2 引用 ME 未定义事件**:ME 补齐三事件定义。 +- **B9 控件不随 M1 type 变**:`FieldControl` 在 compType 为通用文本时按 M1 `fieldType`(int/decimal/boolean/datetime)升级控件,兑现"改 M1 type→控件变"。 + +## 三、仍为演示简化(诚实标注,未在本轮做成生产级) + +- **真实认证(A3)**:未接入 Spring Security + OAuth2/JWT;`X-Principal` 仅演示 M5 权限分支。生产必须替换。 +- **幂等(B2)**:未加请求幂等键;生产应加唯一约束去重表。 +- **M0 JSON-Schema 强校验(B10)**:M0 仍为规范文档,未在加载时对各层做 Schema 校验(各层命名 snake_case 与 M0 Identifier 模式的历史不一致亦未强改)。 +- 生产重组件(MySQL/RocketMQ/SkyWalking/Prometheus)仍以 `deploy/` docker-compose + 文档形式交付。 + +> **更新(补全 M8 后)**:M8 已补齐 `SCENE_CANCEL_ORDER`(订单取消模板)与 `SCENE_MODIFY_ORDER_ADDR`(收货地址修改模板)两套模板,四个场景全部可渲染。引擎相应把 `update` 命令做成**含子实体更新**(`updateChildByParent`,据 M3 定位子实体表按父外键更新),`cancel` 走通用 `StockReturn` 回补库存。已加测试:改地址断言子实体行被更新、取消断言库存回补。故原"update/cancel 子实体级修改未接线"一项已闭合。 + +## 四、验证 +- 后端 9 个 JUnit 全绿:schema 合成、有效下单(通用一致性)、大额 REJECT、约束校验、**超卖回滚**、负数数量拒绝、预检、**客户权限**、**手机号密文落库**。 +- `tests/verify-model-change.sh`:改 MetaRule 阈值 + 热重载 → 同单通过↔拦截。 +- 浏览器端到端:订单与客户两个场景均由 M8 动态渲染;执行追踪显示 derive/通用跨聚合动作/正确 topic。 diff --git b/engine/.mvn/wrapper/maven-wrapper.properties a/engine/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..ffcab66 --- /dev/null +++ a/engine/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git b/engine/mvnw a/engine/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ a/engine/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git b/engine/mvnw.cmd a/engine/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ a/engine/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git b/engine/pom.xml a/engine/pom.xml new file mode 100644 index 0000000..8ecd95f --- /dev/null +++ a/engine/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.6 + + + + com.onto + onto-engine + 2.0.0 + onto-engine + 本体驱动 DDD+EDA 元数据解释引擎 —— 通用命令执行 + 动态渲染元数据服务 + + + + 21 + 21 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jdbc + + + + com.h2database + h2 + runtime + + + + com.mysql + mysql-connector-j + runtime + + + + org.yaml + snakeyaml + + + + com.googlecode.aviator + aviator + 5.4.3 + + + org.springframework.boot + spring-boot-starter-test + test + + + + + onto-engine + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git b/engine/src/main/java/com/onto/engine/EngineApplication.java a/engine/src/main/java/com/onto/engine/EngineApplication.java new file mode 100644 index 0000000..eee85e3 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/EngineApplication.java @@ -0,0 +1,26 @@ +package com.onto.engine; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 本体驱动 DDD+EDA 元数据解释引擎入口。 + * + *

本引擎不包含任何"订单/客户/商品"的专用业务代码。它在启动时加载 models/ 下的 11 份 + * 本体 YAML(M0-M8、MetaRule),构建内存元模型,并以通用方式: + *

    + *
  • 据 M3 自动建表;
  • + *
  • 据 M8+M1+M2+M5+MetaRule 合成前端渲染 Schema;
  • + *
  • 据 M4 flowSteps + M2 命令定义 + MetaRule 通用解释执行命令;
  • + *
  • 据 ME 事件定义 + 跨聚合一致性规则驱动 Outbox 事件与库存扣减。
  • + *
+ * 修改任意 YAML 后调用 POST /api/meta/reload(或重启)即可让行为随之改变,无需改一行 Java。 + */ +@SpringBootApplication +@EnableScheduling +public class EngineApplication { + public static void main(String[] args) { + SpringApplication.run(EngineApplication.class, args); + } +} diff --git b/engine/src/main/java/com/onto/engine/command/CommandEngine.java a/engine/src/main/java/com/onto/engine/command/CommandEngine.java new file mode 100644 index 0000000..62c5964 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/command/CommandEngine.java @@ -0,0 +1,1044 @@ +package com.onto.engine.command; + +import com.onto.engine.event.EventEngine; +import com.onto.engine.model.ModelRepository; +import com.onto.engine.persist.DynamicRepository; +import com.onto.engine.rule.RuleEngine; +import com.onto.engine.rule.RuleHit; +import com.onto.engine.security.EncryptService; +import com.onto.engine.util.IdGen; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.regex.Pattern; + +/** + * 通用命令执行器:不含任何具体聚合/字段的业务字面量。它读取 M4 场景 flowSteps、M2 命令定义 + * (commandType / validations / derivations / effect)、M8 模板结构、M1 字段类型与约束/引用、 + * M3 落库映射、MetaRule 规则、ME 事件与跨聚合规则,通用地"解释执行"任意场景命令。 + * + *

解释流程:权限(M5) → readOnlyCheck(引用存在) → 派生(M2 derivations 服务端重算) → + * 校验(M8 required + M1 类型/约束) → 风控(MetaRule,变量按 M1 引用通用解析) → + * 按 commandType 落库/更新 → 发事件(ME) → 跨聚合最终一致(ME 规则 + M2 effect)。 + */ +@Service +public class CommandEngine { + + private static final Logger log = LoggerFactory.getLogger(CommandEngine.class); + + private final ModelRepository models; + private final DynamicRepository repo; + private final RuleEngine ruleEngine; + private final EventEngine eventEngine; + private final EncryptService encrypt; + + public CommandEngine(ModelRepository models, DynamicRepository repo, + RuleEngine ruleEngine, EventEngine eventEngine, EncryptService encrypt) { + this.models = models; + this.repo = repo; + this.ruleEngine = ruleEngine; + this.eventEngine = eventEngine; + this.encrypt = encrypt; + } + + /** 提交前预检:派生 + 校验 + 风控(不落库)。 */ + public Map precheck(String sceneId, Map payload) { + Ctx ctx = new Ctx(sceneId, payload); + applyDerivations(ctx); + Map out = new LinkedHashMap<>(); + List errors = validate(ctx); + List hits = runRules(ctx); + List> rejects = new ArrayList<>(); + List> warns = new ArrayList<>(); + for (RuleHit h : hits) (("REJECT".equals(h.effect())) ? rejects : warns).add(ruleToMap(h)); + out.put("reject", !errors.isEmpty() || !rejects.isEmpty()); + out.put("errors", errors); + out.put("rejectRules", rejects); + out.put("warnings", warns); + out.put("derivedMaster", ctx.master); // 让前端看到服务端重算后的值 + return out; + } + + /** 完整执行场景命令。 */ + @Transactional + public Map execute(String sceneId, Map payload, String principal) { + Ctx ctx = new Ctx(sceneId, payload); + List> trace = new ArrayList<>(); + Map result = new LinkedHashMap<>(); + result.put("sceneId", sceneId); + result.put("trace", trace); + + if (!permitted(ctx.scene, principal)) { + return fail(result, "PERMISSION", "主体 " + principal + " 无权执行场景 " + sceneId); + } + trace.add(step("permission", "M5", "主体 " + principal + " 通过权限校验 " + ctx.scene.get("permissionBind"))); + + Run run = new Run(result, trace, principal, ctx); + Flow flow = runSteps(asList(ctx.scene.get("flowSteps")), run); + if (flow == Flow.FAILED) return result; // result 已带 success=false + result.put("success", true); + return result; + } + + // ===================== 通用步骤解释器(M4 flowSteps;递归,支持控制流/Saga/EDA) ===================== + // + // 所有 stepType 均按 M4 配置"通用解释",不含任何具体聚合/字段字面量。控制流步骤(condition/switch/ + // parallel/while/loopItems/retry)携带嵌套子步骤,故用递归 runSteps 取代原先的扁平 switch。 + // 演示简化:parallel 顺序执行后汇合;timer/waitEvent/callService 不做真实阻塞/外呼;retry 无瞬时故障注入。 + + private enum Flow { CONTINUE, RETURNED, FAILED } + + /** 一次场景执行的运行态:结果、轨迹、Saga 补偿栈、主体、上下文、循环游标。 */ + private final class Run { + final Map result; + final List> trace; + final String principal; + final Ctx ctx; + final Deque compensations = new ArrayDeque<>(); // 已提交步骤的 {aggregate, command},逆序补偿 + Map loopItem; // loopItems 当前行,供表达式读取 + Run(Map result, List> trace, String principal, Ctx ctx) { + this.result = result; this.trace = trace; this.principal = principal; this.ctx = ctx; + } + } + + /** 顺序解释一串步骤;遇 return/end 停止,遇失败停止(失败前已触发补偿)。 */ + private Flow runSteps(List steps, Run run) { + for (Object st : steps) { + Flow f = runStep(asMap(st), run); + if (f != Flow.CONTINUE) return f; + } + return Flow.CONTINUE; + } + + /** 单步分派:每种 stepType 的通用解释。 */ + private Flow runStep(Map stp, Run run) { + String type = String.valueOf(stp.get("stepType")); + switch (type) { + // —— 起止 —— + case "start" -> run.trace.add(step("start", "M4", + "场景开始" + (stp.get("triggerType") == null ? "" : "(触发:" + stp.get("triggerType") + ")"))); + case "return", "end" -> { + run.trace.add(step(type, "M4", "场景执行完成,返回结果")); + return Flow.RETURNED; + } + case "errorEnd" -> { + return failFlow(run, "ERROR_END", String.valueOf(stp.getOrDefault("message", "场景异常结束"))); + } + + // —— 数据/校验/派生 —— + case "readOnlyCheck" -> { + String agg = String.valueOf(stp.get("bindAggregate")); + Map err = readOnlyCheck(agg, run.ctx); + if (err != null) return failFlow(run, "READ_ONLY_CHECK", String.valueOf(err.get("message"))); + run.trace.add(step("readOnlyCheck", "M4", "校验 " + agg + " 引用存在:通过")); + } + case "validate" -> { + List errors = validate(run.ctx); + if (!errors.isEmpty()) return failFlow(run, "VALIDATION", String.join(";", errors)); + run.trace.add(step("validate", "M2/M8/M1", "独立校验步骤通过")); + } + case "derive" -> { + List changed = applyDerivations(run.ctx); + run.trace.add(step("derive", "M2", "服务端重算字段 " + changed)); + } + case "assign", "transform" -> doAssign(stp, run); + case "script" -> doScript(stp, run); + + // —— 命令/事件(EDA) —— + case "bindCommand" -> { + Map cmdResult = executeCommand( + String.valueOf(stp.get("bindAggregate")), String.valueOf(stp.get("bindCommand")), run.ctx, run.trace); + run.result.putAll(cmdResult); + if (Boolean.FALSE.equals(cmdResult.get("success"))) { + run.result.put("success", false); + return compensateAndFail(run); // 触发 Saga 补偿并失败 + } + if (stp.get("compensateCommand") != null) { // 登记该步骤的逆向补偿命令 + run.compensations.push(new String[]{ + String.valueOf(stp.getOrDefault("compensateAggregate", stp.get("bindAggregate"))), + String.valueOf(stp.get("compensateCommand"))}); + } + } + case "callScene" -> { + Flow f = callScene(String.valueOf(stp.get("callScene")), run); + if (f == Flow.FAILED) return Flow.FAILED; + } + case "callService" -> run.trace.add(step("callService", "M4", + "调用外部服务 " + stp.getOrDefault("method", "") + " " + + stp.getOrDefault("endpoint", stp.get("serviceRef")) + "(演示:记录意图,不实际外呼)")); + case "emitEvent" -> doEmitEvent(stp, run); + case "waitEvent" -> { + String ev = String.valueOf(stp.get("waitEvent")); + boolean arrived = eventEngine.recentOutbox(50).stream() + .anyMatch(o -> o.values().stream().anyMatch(v -> ev.equals(String.valueOf(v)))); + run.trace.add(step("waitEvent", "ME", + "等待事件 " + ev + ":" + (arrived ? "已到达" : "未到达(演示:不阻塞,继续)"))); + } + + // —— 控制流 —— + case "condition" -> { + boolean ok = ruleEngine.evalBool(String.valueOf(stp.get("when")), exprEnv(run)); + run.trace.add(step("condition", "M4", "条件 [" + stp.get("when") + "] = " + ok)); + Flow f = runSteps(asList(stp.get(ok ? "then" : "else")), run); + if (f != Flow.CONTINUE) return f; + } + case "switch" -> { + Object val = ruleEngine.evalValue(String.valueOf(stp.get("switchOn")), exprEnv(run)); + List chosen = null; + for (Object c : asList(stp.get("cases"))) { + Map cm = asMap(c); + if (String.valueOf(cm.get("equals")).equals(String.valueOf(val))) { chosen = asList(cm.get("steps")); break; } + } + if (chosen == null) chosen = asList(stp.get("default")); + run.trace.add(step("switch", "M4", "分派 on=" + val)); + Flow f = runSteps(chosen, run); + if (f != Flow.CONTINUE) return f; + } + case "parallel" -> { + List branches = asList(stp.get("branches")); + run.trace.add(step("parallel", "M4", "并行分支 " + branches.size() + " 条(演示:顺序执行后汇合)")); + for (Object b : branches) { + Flow f = runSteps(asList(asMap(b).get("steps")), run); + if (f == Flow.FAILED) return Flow.FAILED; // 任一分支失败即整体失败(补偿已在失败点触发) + } + } + case "while" -> { + int max = intOf(stp.get("maxIterations"), 1000), i = 0; + while (ruleEngine.evalBool(String.valueOf(stp.get("when")), exprEnv(run))) { + if (i++ >= max) { run.trace.add(step("while", "M4", "达到迭代上限 " + max + ",中止")); break; } + Flow f = runSteps(asList(stp.get("body")), run); + if (f != Flow.CONTINUE) return f; + } + run.trace.add(step("while", "M4", "循环结束,迭代 " + i + " 次")); + } + case "loopItems" -> { + String entity = String.valueOf(stp.get("itemsFrom")); + List> rows = run.ctx.details.getOrDefault(entity, List.of()); + run.trace.add(step("loopItems", "M4", "遍历子实体 " + entity + " 共 " + rows.size() + " 行")); + List body = asList(stp.get("body")); + for (Map row : rows) { + run.loopItem = row; + Flow f = runSteps(body, run); + run.loopItem = null; + if (f != Flow.CONTINUE) return f; + } + } + + // —— Saga / 可靠性 —— + case "retry" -> { + int max = intOf(stp.get("maxAttempts"), 2); + List body = asList(stp.get("body")); + Flow f = Flow.CONTINUE; + for (int a = 1; a <= max; a++) { + f = runSteps(body, run); + if (f != Flow.FAILED) break; + run.trace.add(step("retry", "M4", "第 " + a + " 次尝试失败" + (a < max ? ",重试" : ",已达上限"))); + } + if (f != Flow.CONTINUE) return f; + } + case "compensate" -> { + run.trace.add(step("compensate", "M4/Saga", "显式补偿:逆序执行 " + run.compensations.size() + " 项")); + runCompensations(run); + } + case "timer" -> run.trace.add(step("timer", "M4", + "定时/延时 " + stp.getOrDefault("delayMs", stp.getOrDefault("timeoutMs", 0)) + "ms(演示:不实际阻塞)")); + case "userTask" -> { + Object who = stp.getOrDefault("assignee", stp.getOrDefault("role", "人工")); + String approveParam = stp.get("approveParam") == null ? null : String.valueOf(stp.get("approveParam")); + if (approveParam != null && !truthy(run.ctx.master.get(approveParam))) + return failFlow(run, "PENDING_APPROVAL", "待 " + who + " 审批(缺少 " + approveParam + "=true)"); + run.trace.add(step("userTask", "M4", who + " 审批通过")); + } + + default -> run.trace.add(step(type, "M4", "跳过未识别步骤")); + } + return Flow.CONTINUE; + } + + /** 设置失败信息并触发补偿,返回 FAILED。 */ + private Flow failFlow(Run run, String stage, String message) { + fail(run.result, stage, message); + return compensateAndFail(run); + } + + private Flow compensateAndFail(Run run) { + runCompensations(run); + return Flow.FAILED; + } + + /** 逆序执行已登记的补偿命令(Saga)。演示简化:与本地事务共存,回滚时补偿亦回滚。 */ + private void runCompensations(Run run) { + while (!run.compensations.isEmpty()) { + String[] c = run.compensations.pop(); + Map r = executeCommand(c[0], c[1], run.ctx, run.trace); + run.trace.add(step("compensate", "M4/Saga", + "补偿命令 " + c[1] + "@" + c[0] + " → " + (Boolean.FALSE.equals(r.get("success")) ? "失败" : "已执行"))); + } + } + + /** 调用子场景:校验其权限,复用当前 master/details 作为入参,共享 trace/结果/补偿栈的独立上下文。 */ + private Flow callScene(String subSceneId, Run run) { + Map subScene = models.scene(subSceneId); + if (subScene.isEmpty()) return failFlow(run, "SCENE_NOT_FOUND", "子场景不存在:" + subSceneId); + if (!permitted(subScene, run.principal)) + return failFlow(run, "PERMISSION", "主体 " + run.principal + " 无权执行子场景 " + subSceneId); + run.trace.add(step("callScene", "M4", "进入子场景 " + subSceneId)); + Map subPayload = new LinkedHashMap<>(); + subPayload.put("master", run.ctx.master); + subPayload.put("details", run.ctx.details); + Run subRun = new Run(run.result, run.trace, run.principal, new Ctx(subSceneId, subPayload)); + return runSteps(asList(subScene.get("flowSteps")), subRun); + } + + /** assign/transform:按表达式给 master 字段赋值(支持 assignments 列表或 targetField/expression 简写)。 */ + private void doAssign(Map stp, Run run) { + List assigns = new ArrayList<>(asList(stp.get("assignments"))); + if (stp.get("targetField") != null) + assigns.add(Map.of("targetField", stp.get("targetField"), "expression", String.valueOf(stp.get("expression")))); + List changed = new ArrayList<>(); + for (Object a : assigns) { + Map am = asMap(a); + String tf = String.valueOf(am.get("targetField")); + run.ctx.master.put(tf, ruleEngine.evalValue(String.valueOf(am.get("expression")), exprEnv(run))); + changed.add(tf); + } + run.trace.add(step(String.valueOf(stp.get("stepType")), "M4", "赋值/转换字段 " + changed)); + } + + /** script:求值一段表达式,可选写回字段。 */ + private void doScript(Map stp, Run run) { + Object v = ruleEngine.evalValue(String.valueOf(stp.get("expression")), exprEnv(run)); + if (stp.get("assignTo") != null) run.ctx.master.put(String.valueOf(stp.get("assignTo")), v); + run.trace.add(step("script", "M4", "脚本求值 [" + stp.get("expression") + "] = " + v)); + } + + /** emitEvent:显式发布一个领域事件(payload 通用地从 master/details 组装)。 */ + private void doEmitEvent(Map stp, Run run) { + String agg = String.valueOf(stp.getOrDefault("bindAggregate", run.ctx.cmdAggregate)); + String ev = String.valueOf(stp.get("emitEvent")); + String idField = models.rootIdentifier(agg); + Object rootId = run.ctx.master.getOrDefault(idField, run.result.get("rootId")); + eventEngine.append(agg, ev, eventPayload(agg, ev, String.valueOf(rootId), run.ctx.master, run.ctx.details)); + run.trace.add(step("emitEvent", "ME", "发布事件 " + ev)); + } + + /** 表达式上下文:master 扁平 + 按 M1 引用解析的被引用聚合属性 + 循环行 + 稳定键 rootId。 */ + private Map exprEnv(Run run) { + Map env = new HashMap<>(run.ctx.master); + if (run.ctx.cmdAggregate != null) mergeReferences(env, run.ctx.cmdAggregate, null, run.ctx.master); + if (run.loopItem != null) env.putAll(run.loopItem); + if (run.result.get("rootId") != null) env.put("rootId", run.result.get("rootId")); + return env; + } + + private static boolean truthy(Object o) { + if (o == null) return false; + if (o instanceof Boolean b) return b; + String s = String.valueOf(o).trim(); + return s.equalsIgnoreCase("true") || s.equals("1") || s.equalsIgnoreCase("yes"); + } + + private static int intOf(Object o, int def) { + try { return Integer.parseInt(String.valueOf(o).trim()); } catch (Exception e) { return def; } + } + + // ===================== 命令执行(M2 bizSteps 指令程序通用解释) ===================== + + private Map executeCommand(String aggregateId, String cmdId, Ctx ctx, + List> trace) { + Map cmd = models.command(aggregateId, cmdId); + Map out = new LinkedHashMap<>(); + + // 派生(服务端重算,如 totalAmount = sum(quantity*itemPrice)) + List derived = applyDerivations(ctx); + if (!derived.isEmpty()) trace.add(step("derive", "M2", "服务端重算字段 " + derived + "(防客户端篡改)")); + + // 校验(M8 required + M1 类型/约束) + List errors = validate(ctx); + if (!errors.isEmpty()) { + out.put("success", false); + out.put("stage", "VALIDATION"); + out.put("message", String.join(";", errors)); + out.put("errors", errors); + return out; + } + trace.add(step("validate", "M2/M8/M1", "命令 " + cmdId + " 入参校验通过")); + + // 风控(MetaRule;变量按 M1 引用通用解析) + List hits = runRules(ctx); + List> appliedRules = new ArrayList<>(); + List> warnings = new ArrayList<>(); + for (RuleHit h : hits) { + appliedRules.add(ruleToMap(h)); + if ("REJECT".equals(h.effect())) { + out.put("success", false); + out.put("stage", "RULE_REJECT"); + out.put("message", h.message()); + out.put("appliedRules", appliedRules); + return out; + } + if ("ALERT".equals(h.effect())) warnings.add(ruleToMap(h)); + } + trace.add(step("metaRule", "MetaRule", "风控评估完成,命中 " + hits.size() + + " 条;REJECT=0,ALERT=" + warnings.size())); + + // 命令落库:由 M2 bizSteps 指令程序通用解释执行(替代硬编码 create/update/cancel 分派) + Map body = runProgram(aggregateId, cmdId, ctx, trace); + if (Boolean.FALSE.equals(body.get("success"))) return body; + + body.put("command", cmdId); + body.put("commandType", models.commandType(aggregateId, cmdId)); + body.put("appliedRules", appliedRules); + body.put("warnings", warnings); + return body; + } + + // ===================== 命令级 bizSteps 指令解释器(替代硬编码 create/update/cancel) ===================== + // + // M2 命令的 bizSteps 是一段“闭指令集”程序(AI 转换产物,type 只能取下列枚举之一),引擎逐条通用解释, + // 不含任何具体聚合/字段字面量: + // LOAD_AGGREGATE / SET_FIELD / AUTO_FILL_FIELD / REMOTE_QUERY_SET_FIELD / + // CONDITION_SET_FIELD / CALL_OTHER_COMMAND / COMMIT_TRANSACTION + // 未声明 bizSteps(或仍是旧的字符串描述)的命令,按 commandType 合成等价规范程序,仍走同一台解释器。 + // 落库后统一:发事件(M2 emitEvents) → 跨聚合最终一致(ME 规则 + 指令内 CALL 动作) → create 语义超卖回滚。 + + /** 一次命令的解释运行态:待落库的聚合根、是否已加载(update/cancel)、跨聚合动作、失败即中止。 */ + private final class Prog { + final String aggregateId, cmdId, rootName, rootIdField; + final Ctx ctx; + final List> trace; + final Map work; // 待落库的聚合根字段(初始=入参 master) + Map loadedRow = Collections.emptyMap(); // LOAD 读到的库中现值(供 root.* 表达式读取) + boolean loaded = false; + Object rootId; + int childCount = 0; + final List> crossActions = new ArrayList<>(); + Map failure; // 非空表示中止并返回该结果 + + Prog(String aggregateId, String cmdId, Ctx ctx, List> trace) { + this.aggregateId = aggregateId; this.cmdId = cmdId; this.ctx = ctx; this.trace = trace; + this.rootName = models.rootName(aggregateId); + this.rootIdField = models.rootIdentifier(aggregateId); + this.work = new LinkedHashMap<>(ctx.master); + this.rootId = ctx.master.get(rootIdField); + } + } + + /** 解释执行命令的 bizSteps 指令程序,落库后完成发事件与跨聚合一致。 */ + private Map runProgram(String aggregateId, String cmdId, Ctx ctx, + List> trace) { + Prog p = new Prog(aggregateId, cmdId, ctx, trace); + runInstructions(commandProgram(aggregateId, cmdId), p); + if (p.failure != null) return p.failure; + return finalizeCommand(p); + } + + /** 顺序解释一串指令(IF 携带的 then/else 子指令经此递归执行);任一步置 failure 即中止。 */ + private void runInstructions(List steps, Prog p) { + for (Object insObj : steps) { + if (p.failure != null) return; + Map ins = asMap(insObj); + // 每步可写作 {stepNo, desc, instruction:{type, params}}(AI 转换产物)或扁平 {type, params}(合成/嵌套) + Map instr = ins.containsKey("instruction") ? asMap(ins.get("instruction")) : ins; + runInstruction(String.valueOf(instr.get("type")), asMap(instr.get("params")), p); + } + } + + /** 取命令显式 bizSteps 指令程序;缺省或仍是字符串描述时,按 commandType 合成规范程序。 */ + private List commandProgram(String aggregateId, String cmdId) { + Object steps = models.command(aggregateId, cmdId).get("bizSteps"); + if (steps instanceof List list && !list.isEmpty()) return new ArrayList<>(list); + // 合成默认程序:update/cancel 先 LOAD 校验存在,再 COMMIT;create 直接 COMMIT。 + String type = models.commandType(aggregateId, cmdId); + List prog = new ArrayList<>(); + if ("update".equals(type) || "cancel".equals(type)) { + prog.add(Map.of("type", "LOAD_AGGREGATE", + "params", Map.of("idSource", "input." + models.rootIdentifier(aggregateId)))); + } + prog.add(Map.of("type", "COMMIT_TRANSACTION", "params", Map.of())); + return prog; + } + + /** 单条指令分派:每种 type 的通用解释(无任何聚合/字段字面量)。 */ + private void runInstruction(String type, Map params, Prog p) { + switch (type) { + case "LOAD_AGGREGATE" -> opLoadAggregate(params, p); + case "SET_FIELD" -> opSetField(params, p); + case "AUTO_FILL_FIELD" -> opAutoFill(params, p); + case "REMOTE_QUERY_SET_FIELD" -> opRemoteQuery(params, p); + case "CONDITION_SET_FIELD" -> opConditionSet(params, p); + case "CALL_OTHER_COMMAND" -> opCallOther(params, p); + case "COMMIT_TRANSACTION" -> opCommit(params, p); + case "IF" -> opIf(params, p); + case "REMOTE_SET_FIELD" -> opRemoteSet(params, p); + case "REMOTE_INCREMENT_FIELD" -> opRemoteIncrement(params, p); + default -> p.trace.add(step(type, "M2", "跳过未识别指令")); + } + } + + /** LOAD_AGGREGATE:按 idSource 定位并加载既有聚合根(不存在即失败),标记为 update/cancel 语义。 */ + private void opLoadAggregate(Map params, Prog p) { + Object id = resolveRef(String.valueOf(params.get("idSource")), p); + if (id == null || !repo.existsByPk(repo.tableName(p.aggregateId, p.rootName), + repo.pkColumn(p.aggregateId, p.rootName), id)) { + p.failure = fail(new LinkedHashMap<>(), "NOT_FOUND", + "待操作的 " + p.rootName + " 不存在:" + p.rootIdField + "=" + id); + return; + } + p.rootId = id; + p.loaded = true; + p.loadedRow = repo.loadDomainRow(p.aggregateId, p.rootName, id); + p.work.put(p.rootIdField, id); + p.trace.add(step("LOAD_AGGREGATE", "M2/M3", "加载 " + p.rootName + "(" + id + ")")); + } + + /** SET_FIELD:把 source(input.* / root.* / 字面量)或 value 赋给目标字段。 */ + private void opSetField(Map params, Prog p) { + String tf = String.valueOf(params.get("targetField")); + Object v = params.containsKey("source") + ? resolveRef(String.valueOf(params.get("source")), p) : params.get("value"); + p.work.put(tf, v); + p.trace.add(step("SET_FIELD", "M2", tf + " = " + v)); + } + + /** AUTO_FILL_FIELD:按 func 自动生成值(now_datetime / now_date / gen_id)。 */ + private void opAutoFill(Map params, Prog p) { + String tf = String.valueOf(params.get("targetField")); + String func = String.valueOf(params.get("func")); + Object v = switch (func) { + case "now_datetime" -> LocalDateTime.now().toString(); + case "now_date" -> java.time.LocalDate.now().toString(); + case "gen_id" -> IdGen.next(idPrefix(p.aggregateId)); + default -> null; + }; + p.work.put(tf, v); + p.trace.add(step("AUTO_FILL_FIELD", "M2", tf + " = " + v + "(" + func + ")")); + } + + /** REMOTE_QUERY_SET_FIELD:按 queryKey 远程查另一聚合根,取 sourceField 回填 targetField。 */ + private void opRemoteQuery(Map params, Prog p) { + String remoteAgg = String.valueOf(params.get("remoteAggregate")); + Object key = resolveRef(String.valueOf(params.get("queryKey")), p); + String sourceField = String.valueOf(params.get("sourceField")); + String tf = String.valueOf(params.get("targetField")); + Map row = repo.loadDomainRow(remoteAgg, models.rootName(remoteAgg), key); + Object v = row.get(sourceField); + p.work.put(tf, v); + p.trace.add(step("REMOTE_QUERY_SET_FIELD", "M2/M3", + "查 " + remoteAgg + "(" + key + ")." + sourceField + " → " + tf + " = " + v)); + } + + /** CONDITION_SET_FIELD:按 conditions 顺序取首个命中的 when(Aviator 布尔),赋对应 value。 */ + private void opConditionSet(Map params, Prog p) { + String tf = String.valueOf(params.get("targetField")); + for (Object c : asList(params.get("conditions"))) { + Map cm = asMap(c); + String when = String.valueOf(cm.get("when")); + if (ruleEngine.evalBool(rewriteRefs(when), condEnv(p))) { + p.work.put(tf, cm.get("value")); + p.trace.add(step("CONDITION_SET_FIELD", "M2", tf + " = " + cm.get("value") + "(命中 " + when + ")")); + return; + } + } + p.trace.add(step("CONDITION_SET_FIELD", "M2", tf + " 无条件命中,保持不变")); + } + + /** IF:对 when(Aviator,input./root. 命名空间)求值,真跑 then 子指令、假跑 else 子指令(可嵌套,类比 M4 condition)。 */ + private void opIf(Map params, Prog p) { + boolean ok = ruleEngine.evalBool(rewriteRefs(String.valueOf(params.get("when"))), condEnv(p)); + p.trace.add(step("IF", "M2", "条件 [" + params.get("when") + "] = " + ok)); + runInstructions(asList(params.get(ok ? "then" : "else")), p); + } + + /** REMOTE_SET_FIELD:按 keySource 定位被引用聚合根,把 source(input./root./字面量) 或 value 覆盖写入其 targetField。 */ + private void opRemoteSet(Map params, Prog p) { + String remoteAgg = String.valueOf(params.get("remoteAggregate")); + Object key = resolveRef(String.valueOf(params.get("keySource")), p); + String tf = String.valueOf(params.get("targetField")); + Object v = params.containsKey("source") ? resolveRef(String.valueOf(params.get("source")), p) : params.get("value"); + Map vals = new LinkedHashMap<>(); + vals.put(tf, v); + int n = key == null ? 0 : repo.updateDomain(remoteAgg, models.rootName(remoteAgg), null, vals, + models.rootIdentifier(remoteAgg), key); + String status = n > 0 ? "OK" : "SKIP_NOT_FOUND"; + p.crossActions.add(remoteAction(remoteAgg, "set", tf, key, v, status, null)); + p.trace.add(step("REMOTE_SET_FIELD", "M2/M3", + "覆盖 " + remoteAgg + "(" + key + ")." + tf + " = " + v + "(" + status + ")")); + } + + /** REMOTE_INCREMENT_FIELD:按 keySource 定位被引用聚合根,对数值 targetField 原子 +amount(缺省 1)。 */ + private void opRemoteIncrement(Map params, Prog p) { + String remoteAgg = String.valueOf(params.get("remoteAggregate")); + Object key = resolveRef(String.valueOf(params.get("keySource")), p); + String tf = String.valueOf(params.get("targetField")); + long amount = intOf(params.getOrDefault("amount", 1), 1); + String rootName = models.rootName(remoteAgg); + String table = repo.tableName(remoteAgg, rootName); + String col = repo.fieldMap(remoteAgg, rootName).getOrDefault(tf, tf); + String pkCol = repo.pkColumn(remoteAgg, rootName); + int n = key == null ? 0 : repo.adjustColumn(table, col, pkCol, key, amount, false); + Long remaining = key == null ? null : repo.readLong(table, col, pkCol, key); + String status = n > 0 ? "OK" : "SKIP_NOT_FOUND"; + p.crossActions.add(remoteAction(remoteAgg, "increment", tf, key, amount, status, remaining)); + p.trace.add(step("REMOTE_INCREMENT_FIELD", "M2/M3", + remoteAgg + "(" + key + ")." + tf + " += " + amount + " → " + remaining + "(" + status + ")")); + } + + /** 组装一条跨聚合写回动作(供前端「跨聚合一致」表格通用展示)。 */ + private Map remoteAction(String agg, String op, String field, Object key, + Object amountOrValue, String status, Long remaining) { + Map a = new LinkedHashMap<>(); + a.put("targetAggregate", agg); + a.put("targetCommand", "-"); + a.put("op", op); + a.put("keyField", models.rootIdentifier(agg)); + a.put("key", key); + a.put("field", field); + a.put("amount", amountOrValue); + a.put("status", status); + a.put("remaining", remaining); + return a; + } + + /** CALL_OTHER_COMMAND:调另一聚合根命令的结构化 effect(可 forEach 子实体批量);onFailure=rollback 时失败即回滚。 */ + private void opCallOther(Map params, Prog p) { + String targetAgg = String.valueOf(params.getOrDefault("targetAggregate", params.get("aggregate"))); + String targetCmd = String.valueOf(params.getOrDefault("targetCommand", params.get("command"))); + Map paramMap = asMap(params.get("paramMap")); + boolean rollback = "rollback".equals(String.valueOf(params.get("onFailure"))); + List> rows; + if (params.get("forEach") != null) { + String entity = String.valueOf(params.get("forEach")); + rows = p.ctx.details.get(entity); + if ((rows == null || rows.isEmpty()) && p.rootId != null) + rows = repo.loadChildRows(p.aggregateId, entity, p.rootId); + if (rows == null) rows = List.of(); + } else { + rows = List.of(p.work); + } + for (Map row : rows) { + Map callParams = new LinkedHashMap<>(); + for (var pm : paramMap.entrySet()) callParams.put(pm.getKey(), row.get(String.valueOf(pm.getValue()))); + Map action = eventEngine.applyCommandEffect(targetAgg, targetCmd, callParams); + p.crossActions.add(action); + if (rollback && !"OK".equals(action.get("status"))) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + p.failure = fail(new LinkedHashMap<>(), "CONSISTENCY_FAILED", + "跨聚合调用 " + targetCmd + "@" + targetAgg + " 失败,已回滚:" + action); + p.failure.put("stockActions", p.crossActions); + return; + } + } + p.trace.add(step("CALL_OTHER_COMMAND", "M2/ME", "调用 " + targetCmd + "@" + targetAgg + " ×" + rows.size())); + } + + /** COMMIT_TRANSACTION:把工作聚合根落库——已加载则 update(含子实体),否则 insert(生成主键 + 组合子实体)。 */ + private void opCommit(Map params, Prog p) { + if (p.loaded) { + int n = repo.updateDomain(p.aggregateId, p.rootName, null, p.work, p.rootIdField, p.rootId); + int childUpdated = 0; + for (var e : p.ctx.details.entrySet()) + for (Map row : e.getValue()) + childUpdated += repo.updateChildByParent(p.aggregateId, e.getKey(), p.rootId, row); + p.trace.add(step("COMMIT_TRANSACTION", "M3", + "更新 " + p.rootName + "(" + p.rootId + ") 根字段 " + n + " 列,子实体 " + childUpdated + " 行")); + } else { + if (isBlank(p.rootId)) p.rootId = IdGen.next(idPrefix(p.aggregateId)); + p.work.put(p.rootIdField, p.rootId); + p.work.putIfAbsent("createTime", LocalDateTime.now().toString()); + repo.insertDomain(p.aggregateId, p.rootName, null, p.work, null); + String rootPkColumn = repo.pkColumn(p.aggregateId, p.rootName); + for (String entity : models.compositionChildren(p.aggregateId)) { + String localId = String.valueOf(models.entity(p.aggregateId, entity).get("localIdentifier")); + for (Map row : p.ctx.details.getOrDefault(entity, List.of())) { + Map r = new LinkedHashMap<>(row); + r.put(localId, IdGen.next("D")); + repo.insertDomain(p.aggregateId, entity, entity, r, Map.of(rootPkColumn, p.rootId)); + p.childCount++; + } + } + p.trace.add(step("COMMIT_TRANSACTION", "M3", + "写入 " + p.rootName + "(" + p.rootId + ") 及 " + p.childCount + " 条子实体")); + } + } + + /** 落库后统一收尾:发事件 → 跨聚合一致(ME 规则 + 指令内 CALL)→ create 超卖回滚 → 组装稳定返回键。 */ + private Map finalizeCommand(Prog p) { + List> events = + emitEvents(p.aggregateId, p.cmdId, String.valueOf(p.rootId), p.work, p.ctx.details); + p.trace.add(step("emitEvents", "ME", "写入 Outbox 事件 " + events.size() + " 条")); + + List> actions = new ArrayList<>(p.crossActions); + actions.addAll(triggerConsistency(events, p.aggregateId, p.rootId, p.ctx.details)); + + // 超卖防护:create 语义下任一 ME 一致动作非 OK(库存不足/商品不存在)即回滚整个事务 + if ("create".equals(models.commandType(p.aggregateId, p.cmdId))) { + for (Map a : actions) { + if (!"OK".equals(a.get("status"))) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + Map out = fail(new LinkedHashMap<>(), "CONSISTENCY_FAILED", + "跨聚合一致性失败(如库存不足/目标不存在),已回滚:" + a); + out.put("stockActions", actions); + p.trace.add(step("crossConsistency", "ME", "一致性失败 → 事务回滚")); + return out; + } + } + } + if (!actions.isEmpty()) + p.trace.add(step("crossConsistency", "ME", "跨聚合一致动作 " + actions.size() + " 项")); + + Map out = new LinkedHashMap<>(); + out.put("success", true); + out.put("rootId", p.rootId); + out.put("rootIdField", p.rootIdField); + out.put(p.rootIdField, p.rootId); + out.put("aggregateId", p.aggregateId); + out.put("childCount", p.childCount); + out.put("events", events); + out.put("stockActions", actions); + return out; + } + + /** 解析引用:input. → 入参;root. → 工作聚合(优先 work,其次库中现值);否则字面量。 */ + private Object resolveRef(String ref, Prog p) { + if (ref == null) return null; + if (ref.startsWith("input.")) return p.ctx.master.get(ref.substring(6)); + if (ref.startsWith("root.")) { + String f = ref.substring(5); + return p.work.containsKey(f) ? p.work.get(f) : p.loadedRow.get(f); + } + return ref; + } + + /** 条件表达式变量环境:入参(input__ 及扁平) + 工作聚合与库中现值(root__)。 */ + private Map condEnv(Prog p) { + Map env = new HashMap<>(); + p.loadedRow.forEach((k, v) -> env.put("root__" + k, v)); + p.work.forEach((k, v) -> env.put("root__" + k, v)); + p.ctx.master.forEach((k, v) -> { env.put("input__" + k, v); env.putIfAbsent(k, v); }); + return env; + } + + /** 把表达式里的 input./root. 命名空间改写为 Aviator 合法变量名(与 condEnv 对应)。 */ + private static String rewriteRefs(String expr) { + if (expr == null) return ""; + return expr.replaceAll("\\binput\\.", "input__").replaceAll("\\broot\\.", "root__"); + } + + private List> emitEvents(String aggregateId, String cmdId, String rootId, + Map master, + Map>> details) { + List> events = new ArrayList<>(); + for (Object evName : asList(models.command(aggregateId, cmdId).get("emitEvents"))) { + events.add(eventEngine.append(aggregateId, String.valueOf(evName), + eventPayload(aggregateId, String.valueOf(evName), rootId, master, details))); + } + return events; + } + + /** 遍历实际发出的事件,逐个触发匹配的 ME 跨聚合一致性规则。 */ + private List> triggerConsistency(List> events, String aggregateId, + Object rootId, + Map>> details) { + List> actions = new ArrayList<>(); + for (Map ev : events) { + actions.addAll(eventEngine.applyCrossConsistency( + String.valueOf(ev.get("eventName")), aggregateId, rootId, details)); + } + return actions; + } + + // ===================== 派生 / 校验 / 风控(全部模型驱动) ===================== + + /** 应用 M2 derivations:如 totalAmount = sum(quantity*itemPrice)。返回被重算的字段名。 */ + private List applyDerivations(Ctx ctx) { + List changed = new ArrayList<>(); + for (Object d : models.commandDerivations(ctx.cmdAggregate, ctx.cmdId)) { + Map dm = asMap(d); + String targetField = String.valueOf(dm.get("targetField")); + String sourceEntity = String.valueOf(dm.get("sourceEntity")); + String agg = String.valueOf(dm.get("aggregate")); + String expr = String.valueOf(dm.get("expression")); + if ("sum".equals(agg)) { + BigDecimal total = BigDecimal.ZERO; + for (Map row : ctx.details.getOrDefault(sourceEntity, List.of())) { + total = total.add(ruleEngine.evalNumber(expr, row)); + } + ctx.master.put(targetField, total); + changed.add(targetField); + } + } + return changed; + } + + private List validate(Ctx ctx) { + List errors = new ArrayList<>(); + // 必填(源自 M8 required) + for (RequiredField rf : ctx.requiredFields) { + if (rf.master) { + if (isBlank(ctx.master.get(rf.field))) errors.add("缺少必填项:" + rf.label); + } else { + List> rows = ctx.details.getOrDefault(rf.entity, List.of()); + if (rows.isEmpty()) errors.add("缺少必填子实体:" + rf.entity); + else for (int i = 0; i < rows.size(); i++) + if (isBlank(rows.get(i).get(rf.field))) + errors.add(rf.entity + " 第" + (i + 1) + "行缺少必填项:" + rf.label); + } + } + // 类型 + 约束(源自 M1,通用遍历 master 与各子实体行) + validateFields(errors, ctx.cmdAggregate, null, ctx.master, ""); + for (var e : ctx.details.entrySet()) { + List> rows = e.getValue(); + for (int i = 0; i < rows.size(); i++) + validateFields(errors, ctx.cmdAggregate, e.getKey(), rows.get(i), e.getKey() + " 第" + (i + 1) + "行 "); + } + // 唯一性约束(跨行,需查库;update 时排除自身) + checkUnique(ctx, errors); + return errors; + } + + /** 校验 master 中带 constraints.unique 的字段在聚合根表内全局唯一(据 M3 定位列,加密字段用确定性密文比对)。 */ + private void checkUnique(Ctx ctx, List errors) { + String agg = ctx.cmdAggregate; + if (agg == null) return; + String rootName = models.rootName(agg); + boolean forUpdate = "update".equals(models.commandType(agg, ctx.cmdId)); + Object pkValue = forUpdate ? ctx.master.get(models.rootIdentifier(agg)) : null; + for (var e : ctx.master.entrySet()) { + if (isBlank(e.getValue())) continue; + Map c = models.attributeConstraints(agg, null, e.getKey()); + if (!Boolean.TRUE.equals(c.get("unique"))) continue; + String col = repo.fieldMap(agg, rootName).get(e.getKey()); + if (col == null) continue; + Object qv = DynamicRepository.convert(models.fieldType(agg, null, e.getKey()), e.getValue()); + if (models.encryptFields().contains(e.getKey())) qv = encrypt.encrypt(qv); + if (repo.existsOther(repo.tableName(agg, rootName), col, qv, + repo.pkColumn(agg, rootName), pkValue)) { + errors.add(e.getKey() + " 已存在,需全局唯一:" + e.getValue()); + } + } + } + + private void validateFields(List errors, String agg, String entity, + Map data, String prefix) { + for (var e : data.entrySet()) { + String field = e.getKey(); + Object value = e.getValue(); + if (isBlank(value)) continue; + String type = models.fieldType(agg, entity, field); + boolean numeric = "int".equals(type) || "decimal".equals(type); + BigDecimal num = null; + if (numeric) { + num = tryBig(value); + if (num == null) { errors.add(prefix + field + " 需为数字:" + value); continue; } + } + Map c = models.attributeConstraints(agg, entity, field); + if (c.isEmpty()) continue; + if (c.get("min") != null && num != null && num.compareTo(tryBig(c.get("min"))) < 0) + errors.add(prefix + field + " 不可小于 " + c.get("min")); + if (c.get("max") != null && num != null && num.compareTo(tryBig(c.get("max"))) > 0) + errors.add(prefix + field + " 不可大于 " + c.get("max")); + if (c.get("exclusiveMin") != null && num != null && num.compareTo(tryBig(c.get("exclusiveMin"))) <= 0) + errors.add(prefix + field + " 必须大于 " + c.get("exclusiveMin")); + if (c.get("exclusiveMax") != null && num != null && num.compareTo(tryBig(c.get("exclusiveMax"))) >= 0) + errors.add(prefix + field + " 必须小于 " + c.get("exclusiveMax")); + if (c.get("pattern") != null && !Pattern.matches(String.valueOf(c.get("pattern")), String.valueOf(value))) + errors.add(prefix + String.valueOf(c.getOrDefault("patternMessage", field + " 格式不合法")) + ":" + value); + } + } + + private List runRules(Ctx ctx) { + List hits = new ArrayList<>(); + // 命令级:master 扁平 + 按 M1 引用解析出的被引用聚合属性(如客户等级) + Map env = new HashMap<>(ctx.master); + mergeReferences(env, ctx.cmdAggregate, null, ctx.master); + hits.addAll(ruleEngine.evaluate(ctx.sceneId, env)); + // 逐子实体行:行字段 + 被引用聚合属性(如商品库存) + Set seen = new HashSet<>(); + for (var e : ctx.details.entrySet()) { + String entity = e.getKey(); + List> rows = e.getValue(); + for (int i = 0; i < rows.size(); i++) { + Map rowEnv = new HashMap<>(rows.get(i)); + mergeReferences(rowEnv, ctx.cmdAggregate, entity, rows.get(i)); + for (RuleHit h : ruleEngine.evaluate(ctx.sceneId, rowEnv)) + if (seen.add(h.ruleId() + "|" + entity + i)) hits.add(h); + } + } + return hits; + } + + /** 把 data 中"跨聚合引用字段"所指向聚合的属性并入 env(据 M1 refAggregate/refRoot,通用)。 */ + private void mergeReferences(Map env, String agg, String entity, Map data) { + for (var e : data.entrySet()) { + Map ref = models.refInfo(agg, entity, e.getKey()); + if (ref.isEmpty() || e.getValue() == null) continue; + Map refRow = repo.loadDomainRow( + String.valueOf(ref.get("refAggregate")), String.valueOf(ref.get("refRoot")), e.getValue()); + refRow.forEach(env::putIfAbsent); + } + } + + // ===================== readOnlyCheck ===================== + + private Map readOnlyCheck(String aggregateId, Ctx ctx) { + String idField = models.rootIdentifier(aggregateId); + Object idValue = ctx.master.get(idField); + if (idValue == null) return Map.of("message", "缺少引用键 " + idField); + String rootName = models.rootName(aggregateId); + if (!repo.existsByPk(repo.tableName(aggregateId, rootName), repo.pkColumn(aggregateId, rootName), idValue)) + return Map.of("message", aggregateId + " 中不存在 " + idField + "=" + idValue); + return null; + } + + // ===================== 上下文 / 辅助 ===================== + + private class Ctx { + final String sceneId; + final Map scene; + final Map template; + String cmdAggregate, cmdId; + final Map master; + final Map>> details; + final List requiredFields = new ArrayList<>(); + + @SuppressWarnings("unchecked") + Ctx(String sceneId, Map payload) { + this.sceneId = sceneId; + this.scene = models.scene(sceneId); + this.template = models.templateByScene(sceneId); + findBoundCommand(asList(scene.get("flowSteps"))); + this.master = payload.get("master") instanceof Map + ? new LinkedHashMap<>((Map) payload.get("master")) : new LinkedHashMap<>(); + this.details = new LinkedHashMap<>(); + if (payload.get("details") instanceof Map dm) { + for (var e : dm.entrySet()) { + List> rows = new ArrayList<>(); + for (Object row : asList(e.getValue())) rows.add(new LinkedHashMap<>(asMap(row))); + details.put(String.valueOf(e.getKey()), rows); + } + } + collectRequired(); + } + + void collectRequired() { + for (Object c : asList(template.get("rootComponents"))) { + Map container = asMap(c); + Map fb = asMap(container.get("fieldBind")); + boolean isMaster = "aggregate_root".equals(fb.get("bindSourceType")); + String entity = fb.get("domainEntityName") == null ? null : String.valueOf(fb.get("domainEntityName")); + for (Object ch : asList(container.get("childComponents"))) { + Map lfb = asMap(asMap(ch).get("fieldBind")); + if (!Boolean.TRUE.equals(lfb.get("required"))) continue; + requiredFields.add(new RequiredField(isMaster, entity, + String.valueOf(lfb.get("domainFieldName")), + String.valueOf(lfb.getOrDefault("formLabel", lfb.get("domainFieldName"))))); + } + } + } + + /** 递归定位场景绑定的主命令(含嵌套在 condition/switch/parallel/loop 等控制流内的 bindCommand)。 */ + void findBoundCommand(List steps) { + for (Object st : steps) { + Map stp = asMap(st); + if ("bindCommand".equals(stp.get("stepType"))) { + cmdAggregate = String.valueOf(stp.get("bindAggregate")); + cmdId = String.valueOf(stp.get("bindCommand")); + } + for (String key : List.of("then", "else", "body", "default")) findBoundCommand(asList(stp.get(key))); + for (Object b : asList(stp.get("branches"))) findBoundCommand(asList(asMap(b).get("steps"))); + for (Object c : asList(stp.get("cases"))) findBoundCommand(asList(asMap(c).get("steps"))); + } + } + } + + private record RequiredField(boolean master, String entity, String field, String label) {} + + private boolean permitted(Map scene, String principal) { + List perms = asList(scene.get("permissionBind")); + if (perms.isEmpty()) return false; // 默认拒绝(fail-closed):未绑定权限的场景不可执行 + for (Object permId : perms) { + if (asList(models.functionPermission(String.valueOf(permId)).get("allowPrincipals")).contains(principal)) + return true; + } + return false; + } + + /** 事件 payload:字段值优先取 master,其次在子实体行中查找(修复子实体/非领域字段解析为 null)。 */ + private Map eventPayload(String aggregateId, String eventName, String rootId, + Map master, + Map>> details) { + Map def = models.eventDef(aggregateId, eventName); + String rootIdField = models.rootIdentifier(aggregateId); + Map payload = new LinkedHashMap<>(); + for (Object f : asList(def.get("payload"))) { + String field = String.valueOf(f); + if (field.equals(rootIdField)) payload.put(field, rootId); + else if (master.containsKey(field)) payload.put(field, master.get(field)); + else payload.put(field, findInDetails(details, field)); + } + if (payload.isEmpty()) payload.put(rootIdField, rootId); + return payload; + } + + private Object findInDetails(Map>> details, String field) { + for (List> rows : details.values()) + for (Map row : rows) + if (row.containsKey(field)) return row.get(field); + return null; + } + + /** 通用 ID 前缀:聚合根名首字母(Order→O, Customer→C)。 */ + private String idPrefix(String aggregateId) { + String name = models.rootName(aggregateId); + return name.isEmpty() ? "X" : name.substring(0, 1).toUpperCase(); + } + + private Map fail(Map result, String stage, String message) { + result.put("success", false); + result.put("stage", stage); + result.put("message", message); + return result; + } + + private Map step(String name, String layer, String detail) { + Map m = new LinkedHashMap<>(); + m.put("step", name); + m.put("layer", layer); + m.put("detail", detail); + return m; + } + + private Map ruleToMap(RuleHit h) { + Map m = new LinkedHashMap<>(); + m.put("ruleId", h.ruleId()); + m.put("ruleName", h.ruleName()); + m.put("effect", h.effect()); + m.put("message", h.message()); + return m; + } + + private static boolean isBlank(Object o) { + return o == null || String.valueOf(o).trim().isEmpty(); + } + + private static BigDecimal tryBig(Object o) { + try { return new BigDecimal(String.valueOf(o).trim()); } catch (Exception e) { return null; } + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private static List asList(Object o) { + return o instanceof List ? (List) o : Collections.emptyList(); + } +} diff --git b/engine/src/main/java/com/onto/engine/config/WebConfig.java a/engine/src/main/java/com/onto/engine/config/WebConfig.java new file mode 100644 index 0000000..211d5de --- /dev/null +++ a/engine/src/main/java/com/onto/engine/config/WebConfig.java @@ -0,0 +1,25 @@ +package com.onto.engine.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * CORS 配置:允许来源按 profile 收紧(onto.cors.allowed-origins)。 + * 开发默认 * 方便本机联调;生产 profile 应设为具体前端域名。 + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${onto.cors.allowed-origins:*}") + private String[] allowedOrigins; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*"); + } +} diff --git b/engine/src/main/java/com/onto/engine/event/EventEngine.java a/engine/src/main/java/com/onto/engine/event/EventEngine.java new file mode 100644 index 0000000..f02f08a --- /dev/null +++ a/engine/src/main/java/com/onto/engine/event/EventEngine.java @@ -0,0 +1,220 @@ +package com.onto.engine.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onto.engine.model.ModelRepository; +import com.onto.engine.persist.DynamicRepository; +import com.onto.engine.util.IdGen; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; + +/** + * EDA 事件引擎:Outbox 事件落库 + 跨聚合最终一致性——全部由模型驱动,无任何订单/商品字面量。 + * + *

一致性执行流程(据 ME.crossAggConsistencyRules + M2 命令 effect): + * 匹配 triggerEvent → 取 sourceItemEntity 逐行 → 按 paramMap 组装目标命令入参 → + * 读目标命令 effect(op/targetField/keyParam/amountParam/guardField) → 原子条件更新目标聚合。 + * 改 ME/M2 即改跨聚合行为,无需改代码。 + */ +@Component +public class EventEngine { + + private static final Logger log = LoggerFactory.getLogger(EventEngine.class); + + private final ModelRepository models; + private final DynamicRepository repo; + private final ObjectMapper json; + + public EventEngine(ModelRepository models, DynamicRepository repo, ObjectMapper json) { + this.models = models; + this.repo = repo; + this.json = json; + } + + private String outbox() { return models.outboxTable(); } + + /** 据 ME 事件定义把一条领域事件写入 Outbox(status=NEW)。 */ + public Map append(String aggregateId, String eventName, Map payload) { + Map def = models.eventDef(aggregateId, eventName); + String topic = def.isEmpty() ? deriveTopic(eventName) : String.valueOf(def.get("topic")); + String eventId = IdGen.next("E"); + String payloadJson; + try { + payloadJson = json.writeValueAsString(payload); + } catch (Exception e) { + payloadJson = "{}"; + } + repo.execute("INSERT INTO " + outbox() + + " (event_id, aggregate_id, event_name, topic, payload_json, status, create_time)" + + " VALUES (?,?,?,?,?,?,?)", + eventId, aggregateId, eventName, topic, payloadJson, "NEW", LocalDateTime.now()); + Map rec = new LinkedHashMap<>(); + rec.put("eventId", eventId); + rec.put("eventName", eventName); + rec.put("topic", topic); + rec.put("payload", payload); + return rec; + } + + /** + * 应用跨聚合一致性规则(通用)。 + * + * @param triggerEvent 触发事件名(由 CommandEngine 遍历实际发出的事件传入,非硬编码) + * @param sourceAggregate 触发聚合(用于在 payload 缺行时从库中按父外键加载子实体行) + * @param rootId 触发聚合根标识 + * @param detailsByEntity 本次命令 payload 的子实体行(create 路径直接用;cancel 路径为空则查库) + */ + @SuppressWarnings("unchecked") + public List> applyCrossConsistency(String triggerEvent, String sourceAggregate, + Object rootId, + Map>> detailsByEntity) { + List> actions = new ArrayList<>(); + for (Object r : models.crossConsistencyRules()) { + Map rule = (Map) r; + if (!triggerEvent.equals(String.valueOf(rule.get("triggerEvent")))) continue; + + String sourceEntity = String.valueOf(rule.get("sourceItemEntity")); + String targetAgg = String.valueOf(rule.get("targetAggregate")); + String targetCmd = String.valueOf(rule.get("targetCommand")); + Map paramMap = asMap(rule.get("paramMap")); + Map effect = models.commandEffect(targetAgg, targetCmd); + if (effect.isEmpty()) { + log.warn("跨聚合规则 {} 的目标命令 {} 未声明 effect,跳过", triggerEvent, targetCmd); + continue; + } + + List> rows = detailsByEntity == null ? null : detailsByEntity.get(sourceEntity); + if ((rows == null || rows.isEmpty()) && rootId != null) { + rows = repo.loadChildRows(sourceAggregate, sourceEntity, rootId); // cancel 路径:从库加载 + } + if (rows == null) rows = List.of(); + + for (Map row : rows) { + // 按 paramMap 组装目标命令入参:{targetParam: row[sourceField]} + Map params = new LinkedHashMap<>(); + for (var pm : paramMap.entrySet()) { + params.put(pm.getKey(), row.get(String.valueOf(pm.getValue()))); + } + actions.add(executeEffect(targetAgg, targetCmd, effect, params)); + } + } + return actions; + } + + /** + * 供命令解释器的 CALL_OTHER_COMMAND 指令调用:按目标命令声明的 M2 effect 通用执行其副作用 + * (原子增/减 + 越界保护),返回动作结果(status 为 OK 或 FAIL/SKIP 系列)。目标命令未声明 effect 则跳过。 + */ + public Map applyCommandEffect(String targetAgg, String targetCmd, Map params) { + Map effect = models.commandEffect(targetAgg, targetCmd); + if (effect.isEmpty()) { + Map action = new LinkedHashMap<>(); + action.put("targetAggregate", targetAgg); + action.put("targetCommand", targetCmd); + action.put("status", "SKIP_NO_EFFECT"); + return action; + } + return executeEffect(targetAgg, targetCmd, effect, params); + } + + /** 通用执行一个命令的结构化 effect(增/减某数值列,原子且带越界保护)。 */ + private Map executeEffect(String targetAgg, String targetCmd, + Map effect, Map params) { + String op = String.valueOf(effect.get("op")); + String targetField = String.valueOf(effect.get("targetField")); + String keyParam = String.valueOf(effect.get("keyParam")); + String amountParam = String.valueOf(effect.get("amountParam")); + boolean guard = effect.get("guardField") != null; + + String rootName = models.rootName(targetAgg); + String table = repo.tableName(targetAgg, rootName); + String pkCol = repo.pkColumn(targetAgg, rootName); + String col = repo.fieldMap(targetAgg, rootName).getOrDefault(targetField, targetField); + + Object keyValue = params.get(keyParam); + long amount = parseLong(params.get(amountParam)); + + Map action = new LinkedHashMap<>(); + action.put("targetAggregate", targetAgg); + action.put("targetCommand", targetCmd); + action.put("op", op); + action.put("keyField", keyParam); + action.put("key", keyValue); + action.put("field", targetField); + action.put("amount", amount); + + if (keyValue == null) { action.put("status", "SKIP_NO_KEY"); return action; } + if (amount <= 0) { action.put("status", "SKIP_NON_POSITIVE"); return action; } // 拒绝负数/零,防库存膨胀 + if (repo.readLong(table, col, pkCol, keyValue) == null + && !repo.existsByPk(table, pkCol, keyValue)) { + action.put("status", "SKIP_NOT_FOUND"); + return action; + } + + long delta = "decrement".equals(op) ? -amount : amount; + int affected = repo.adjustColumn(table, col, pkCol, keyValue, delta, guard); + if (affected == 0) { + action.put("status", "decrement".equals(op) ? "FAIL_INSUFFICIENT" : "FAIL_NOT_FOUND"); + action.put("remaining", repo.readLong(table, col, pkCol, keyValue)); + return action; + } + Long remaining = repo.readLong(table, col, pkCol, keyValue); + action.put("status", "OK"); + action.put("remaining", remaining); + + // 通用发出目标命令声明的事件(如 ProductStockDeducted / ProductStockReturned) + for (Object ev : asList(models.command(targetAgg, targetCmd).get("emitEvents"))) { + Map payload = new LinkedHashMap<>(); + payload.put(keyParam, keyValue); + payload.put(targetField, remaining); + append(targetAgg, String.valueOf(ev), payload); + } + return action; + } + + /** 轮询 Outbox,把 NEW 事件标记为已发布(模拟发布到消息总线 topic)。 */ + @Scheduled(fixedDelay = 1000) + public void pollAndPublish() { + List> pending; + try { + pending = repo.jdbc().queryForList( + "SELECT event_id, topic, event_name FROM " + outbox() + " WHERE status = 'NEW' LIMIT 50"); + } catch (Exception e) { + return; // 表尚未就绪 + } + for (Map ev : pending) { + log.info("[Outbox->Bus] 发布事件 {} 到 topic {}", ev.get("event_name"), ev.get("topic")); + repo.execute("UPDATE " + outbox() + " SET status='PUBLISHED' WHERE event_id=?", ev.get("event_id")); + } + } + + public List> recentOutbox(int limit) { + return repo.jdbc().queryForList( + "SELECT event_id, aggregate_id, event_name, topic, status, create_time FROM " + outbox() + + " ORDER BY create_time DESC LIMIT " + limit); + } + + private static long parseLong(Object o) { + if (o == null) return 0; + try { return new BigDecimal(String.valueOf(o)).longValue(); } catch (Exception e) { return 0; } + } + + private static String deriveTopic(String eventName) { + return "topic_" + eventName.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private static List asList(Object o) { + return o instanceof List ? (List) o : Collections.emptyList(); + } +} diff --git b/engine/src/main/java/com/onto/engine/meta/SceneSchemaService.java a/engine/src/main/java/com/onto/engine/meta/SceneSchemaService.java new file mode 100644 index 0000000..02e1509 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/meta/SceneSchemaService.java @@ -0,0 +1,193 @@ +package com.onto.engine.meta; + +import com.onto.engine.model.ModelRepository; +import com.onto.engine.security.MaskService; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * 场景渲染元数据合成服务:把 M8 页面模板与 M1 字段类型、M5 脱敏、M2 命令、M3 接口、MetaRule 规则 + * 合并为一份"前端通用渲染引擎"可直接消费的 Schema。前端据此动态生成"新增订单"页,无任何业务硬编码。 + */ +@Service +public class SceneSchemaService { + + private final ModelRepository models; + private final MaskService mask; + + public SceneSchemaService(ModelRepository models, MaskService mask) { + this.models = models; + this.mask = mask; + } + + public Map buildSceneSchema(String sceneId) { + Map scene = models.scene(sceneId); + if (scene.isEmpty()) throw new NoSuchElementException("未找到场景: " + sceneId); + Map template = models.templateByScene(sceneId); + + // 从 M4 flowSteps(含嵌套控制流)递归找到本场景绑定的命令 + String[] bound = findBoundCommand(asList(scene.get("flowSteps")), new String[]{null, null}); + String cmdAggregate = bound[0], cmdId = bound[1]; + + Map out = new LinkedHashMap<>(); + out.put("sceneId", sceneId); + out.put("sceneName", scene.get("sceneName")); + out.put("aggregateScope", scene.get("aggregateScope")); + out.put("permissionBind", scene.get("permissionBind")); + + // M2 命令(前端提交时据此把表单值映射为命令入参) + if (cmdId != null) { + Map cmd = models.command(cmdAggregate, cmdId); + Map cmdOut = new LinkedHashMap<>(); + cmdOut.put("aggregateId", cmdAggregate); + cmdOut.put("cmdId", cmdId); + cmdOut.put("desc", cmd.get("desc")); + cmdOut.put("inputParams", cmd.get("inputParams")); + cmdOut.put("validations", cmd.get("validations")); + cmdOut.put("bizSteps", cmd.get("bizSteps")); + cmdOut.put("emitEvents", cmd.get("emitEvents")); + out.put("command", cmdOut); + // M3 命令 API 地址(前端无硬编码 URL,从模型取) + Object api = asMap(models.deployOf(cmdAggregate).get("commandApi")).get(cmdId); + out.put("commandApi", api); + } + + // M8 模板(逐组件富化 M1 类型 / M5 脱敏 / 引用下拉数据源) + if (!template.isEmpty()) { + Map tOut = new LinkedHashMap<>(); + tOut.put("templateId", template.get("templateId")); + tOut.put("templateName", template.get("templateName")); + tOut.put("templateType", template.get("templateType")); + tOut.put("renderMode", template.get("renderMode")); + tOut.put("masterAggregate", template.get("masterAggregate")); + tOut.put("detailAggregates", template.get("detailAggregates")); + List enriched = new ArrayList<>(); + for (Object c : asList(template.get("rootComponents"))) { + enriched.add(enrich(asMap(c))); + } + tOut.put("rootComponents", enriched); + out.put("template", tOut); + } + + // MetaRule 规则(前端实时校验用,与后端同源) + List> rules = new ArrayList<>(); + for (Map group : models.ruleGroupsByScene(sceneId)) { + for (Object r : asList(group.get("rules"))) { + Map rule = asMap(r); + Map ro = new LinkedHashMap<>(); + ro.put("groupId", group.get("groupId")); + ro.put("ruleId", rule.get("ruleId")); + ro.put("ruleName", rule.get("ruleName")); + ro.put("effect", rule.get("effect")); + ro.put("message", rule.get("message")); + ro.put("matchCondition", rule.get("matchCondition")); + rules.add(ro); + } + } + out.put("rules", rules); + out.put("ruleGlobalParams", models.ruleGlobalParams()); + + // 溯源信息(供前端"运行原理"面板展示每部分来自哪一层) + Map provenance = new LinkedHashMap<>(); + provenance.put("template", "M8-front-schema.yaml"); + provenance.put("fieldType", "M1-domain.yaml"); + provenance.put("command", "M2-command.yaml"); + provenance.put("commandApi", "M3-deploy.yaml"); + provenance.put("mask", "M5-security.yaml"); + provenance.put("rules", "MetaRule-business.yaml"); + provenance.put("permission", "M5-security.yaml (functionPermissions)"); + out.put("sourceLayers", provenance); + + return out; + } + + /** 递归富化一个 M8 组件。 */ + private Map enrich(Map comp) { + Map out = new LinkedHashMap<>(); + out.put("compId", comp.get("compId")); + out.put("compType", comp.get("compType")); + out.put("width", comp.get("width")); + out.put("span", comp.get("span")); + + Map fb = asMap(comp.get("fieldBind")); + if (!fb.isEmpty()) { + Map fbOut = new LinkedHashMap<>(fb); + String agg = String.valueOf(fb.get("bindAggregateId")); + String sourceType = String.valueOf(fb.get("bindSourceType")); + String entityName = fb.get("domainEntityName") == null ? null : String.valueOf(fb.get("domainEntityName")); + String field = String.valueOf(fb.get("domainFieldName")); + String entForType = "aggregate_root".equals(sourceType) ? null : entityName; + + fbOut.put("fieldType", models.fieldType(agg, entForType, field)); + boolean masked = Boolean.TRUE.equals(fb.get("maskField")) || mask.isMasked(field); + fbOut.put("masked", masked); + + // 下拉引用数据源(通用,全部源自模型;无任何具体聚合/字段字面量): + // 1) select 绑定到某聚合根【自身标识】字段 => 选项来自该聚合(如客户/商品/订单下拉); + // 2) select 绑定到带 M1 refAggregate 的【引用】字段 => 选项来自被引用聚合,值=对方标识、回填本字段 + // (如订单审核里 auditorId 引用 EmployeeAggregate.employeeId,渲染为员工下拉)。 + if ("select".equals(comp.get("compType"))) { + if ("aggregate_root".equals(sourceType) && field.equals(models.rootIdentifier(agg))) { + fbOut.put("optionsSource", optionsSource(agg)); + } else { + Map ref = models.refInfo(agg, entForType, field); + if (!ref.isEmpty()) fbOut.put("optionsSource", optionsSource(String.valueOf(ref.get("refAggregate")))); + } + } + out.put("fieldBind", fbOut); + } + + List children = asList(comp.get("childComponents")); + if (!children.isEmpty()) { + List co = new ArrayList<>(); + for (Object ch : children) co.add(enrich(asMap(ch))); + out.put("childComponents", co); + } + return out; + } + + /** 某聚合根的下拉选项数据源描述(value=聚合根标识,label=展示字段,endpoint=/api/data/agg,通用)。 */ + private Map optionsSource(String aggregateId) { + Map src = new LinkedHashMap<>(); + src.put("aggregateId", aggregateId); + src.put("endpoint", "/api/data/" + aggregateId); + src.put("valueField", models.rootIdentifier(aggregateId)); + src.put("labelField", displayField(aggregateId)); + return src; + } + + /** 聚合根的展示字段:首个 string 类型属性(如 customerName/productName),否则用标识字段。 */ + private String displayField(String aggregateId) { + for (Object at : models.rootAttributes(aggregateId)) { + Map am = asMap(at); + if ("string".equals(am.get("type"))) return String.valueOf(am.get("name")); + } + return models.rootIdentifier(aggregateId); + } + + /** 递归定位场景绑定的主命令(含嵌套控制流),返回 {aggregate, cmdId}(后者为最后命中)。 */ + private static String[] findBoundCommand(List steps, String[] acc) { + for (Object st : steps) { + Map stp = asMap(st); + if ("bindCommand".equals(stp.get("stepType"))) { + acc[0] = String.valueOf(stp.get("bindAggregate")); + acc[1] = String.valueOf(stp.get("bindCommand")); + } + for (String key : List.of("then", "else", "body", "default")) findBoundCommand(asList(stp.get(key)), acc); + for (Object b : asList(stp.get("branches"))) findBoundCommand(asList(asMap(b).get("steps")), acc); + for (Object c : asList(stp.get("cases"))) findBoundCommand(asList(asMap(c).get("steps")), acc); + } + return acc; + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private static List asList(Object o) { + return o instanceof List ? (List) o : Collections.emptyList(); + } +} diff --git b/engine/src/main/java/com/onto/engine/model/ModelRepository.java a/engine/src/main/java/com/onto/engine/model/ModelRepository.java new file mode 100644 index 0000000..65db532 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/model/ModelRepository.java @@ -0,0 +1,395 @@ +package com.onto.engine.model; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +/** + * 本体元模型仓库:启动时(及热重载时)从 models 目录读取全部 YAML 本体文件, + * 构建内存元模型,并向引擎其余部分提供"按 M0-M8/MetaRule 语义"的类型化查询。 + * + *

这是"架构引擎负责解释与执行"的解释根:所有下游逻辑都只读本仓库,不硬编码任何业务字段。 + */ +@Component +public class ModelRepository { + + private static final Logger log = LoggerFactory.getLogger(ModelRepository.class); + + /** 层短码 -> 文件名。顺序即模型查看器展示顺序。 */ + private static final LinkedHashMap LAYER_FILES = new LinkedHashMap<>(); + static { + LAYER_FILES.put("M0", "M0-meta-schema.yaml"); + LAYER_FILES.put("M1", "M1-domain.yaml"); + LAYER_FILES.put("M2", "M2-command.yaml"); + LAYER_FILES.put("ME", "ME-event.yaml"); + LAYER_FILES.put("M3", "M3-deploy.yaml"); + LAYER_FILES.put("M4", "M4-scene.yaml"); + LAYER_FILES.put("M5", "M5-security.yaml"); + LAYER_FILES.put("M6", "M6-monitor.yaml"); + LAYER_FILES.put("M7", "M7-sla.yaml"); + LAYER_FILES.put("MetaRule", "MetaRule-business.yaml"); + LAYER_FILES.put("M8", "M8-front-schema.yaml"); + } + + @Value("${onto.models-dir:../models}") + private String modelsDir; + + private volatile Path resolvedDir; + // 不可变快照 + volatile 原子发布:热重载与在途请求并发时,读取方要么看到旧快照、要么看到新快照, + // 绝不会看到"半空"中间态(修复 reload 与在途 execute 的数据竞争)。 + private volatile Map layers = Map.of(); + + @PostConstruct + public synchronized void load() { + Path dir = resolveModelsDir(); + Map fresh = new LinkedHashMap<>(); + for (var e : LAYER_FILES.entrySet()) { + Path f = dir.resolve(e.getValue()); + try (InputStream in = Files.newInputStream(f)) { + fresh.put(e.getKey(), new Yaml(new LoaderOptions()).load(in)); + } catch (Exception ex) { + throw new IllegalStateException("加载本体模型失败: " + f + " -> " + ex.getMessage(), ex); + } + } + this.resolvedDir = dir; + this.layers = Collections.unmodifiableMap(fresh); // 原子发布 + log.info("本体模型已加载: dir={}, layers={}", dir.toAbsolutePath(), fresh.keySet()); + } + + /** 热重载:重新读取磁盘上的 YAML,使模型改动即时生效(无需重启)。 */ + public synchronized void reload() { + load(); + } + + private Path resolveModelsDir() { + List candidates = new ArrayList<>(); + if (modelsDir != null && !modelsDir.isBlank()) candidates.add(modelsDir); + candidates.addAll(List.of("../models", "models", "../../models")); + for (String c : candidates) { + Path p = Paths.get(c).toAbsolutePath().normalize(); + if (Files.isDirectory(p) && Files.exists(p.resolve("M1-domain.yaml"))) { + return p; + } + } + throw new IllegalStateException("找不到 models 目录,已尝试: " + candidates + + " (cwd=" + Paths.get("").toAbsolutePath() + ")"); + } + + public Path modelsDir() { return resolvedDir; } + + // ===================== 通用访问器 ===================== + + public Set layerCodes() { return LAYER_FILES.keySet(); } + + public String fileNameOf(String code) { return LAYER_FILES.get(code); } + + /** 返回某层解析后的原始对象(供模型查看器/前端"运行原理"面板展示)。 */ + public Object rawLayer(String code) { return layers.get(code); } + + @SuppressWarnings("unchecked") + static Map asMap(Object o) { + return o instanceof Map ? (Map) o : Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + static List asList(Object o) { + return o instanceof List ? (List) o : Collections.emptyList(); + } + + // ---------- M1 领域模型 ---------- + + public List aggregates() { + return asList(asMap(layers.get("M1")).get("aggregates")); + } + + public Map aggregate(String aggregateId) { + for (Object a : aggregates()) { + Map m = asMap(a); + if (aggregateId.equals(m.get("aggregateId"))) return m; + } + return Collections.emptyMap(); + } + + public Map aggregateRoot(String aggregateId) { + return asMap(aggregate(aggregateId).get("aggregateRoot")); + } + + /** 聚合根唯一标识字段名(如 orderId)。 */ + public String rootIdentifier(String aggregateId) { + return String.valueOf(aggregateRoot(aggregateId).get("identifier")); + } + + public String rootName(String aggregateId) { + return String.valueOf(aggregateRoot(aggregateId).get("name")); + } + + public List rootAttributes(String aggregateId) { + return asList(aggregateRoot(aggregateId).get("attributes")); + } + + public List entities(String aggregateId) { + return asList(aggregate(aggregateId).get("entities")); + } + + public Map entity(String aggregateId, String entityName) { + for (Object e : entities(aggregateId)) { + Map m = asMap(e); + if (entityName.equals(m.get("name"))) return m; + } + return Collections.emptyMap(); + } + + /** + * 查询某(聚合, [子实体])下某领域字段的 M1 类型。用于前端组件类型推导、DDL 生成、值转换。 + * entityName 为 null 时查聚合根,否则查子实体;标识字段默认 string。 + */ + public String fieldType(String aggregateId, String entityName, String fieldName) { + List attrs; + String identifier; + if (entityName == null) { + attrs = rootAttributes(aggregateId); + identifier = rootIdentifier(aggregateId); + } else { + Map ent = entity(aggregateId, entityName); + attrs = asList(ent.get("attributes")); + identifier = String.valueOf(ent.get("localIdentifier")); + } + if (fieldName.equals(identifier)) return "string"; + for (Object at : attrs) { + Map am = asMap(at); + if (fieldName.equals(am.get("name"))) return String.valueOf(am.get("type")); + } + return "string"; + } + + /** 组合关系子实体名列表(如 OrderItem/PaymentTerm/DeliveryAddressEntity)。 */ + public List compositionChildren(String aggregateId) { + List out = new ArrayList<>(); + for (Object r : asList(aggregateRoot(aggregateId).get("relations"))) { + Map rm = asMap(r); + if ("composition".equals(rm.get("relationType"))) out.add(String.valueOf(rm.get("ref"))); + } + return out; + } + + // ---------- M2 命令 ---------- + + public Map command(String aggregateId, String cmdId) { + for (Object b : asList(asMap(layers.get("M2")).get("behaviors"))) { + Map bm = asMap(b); + if (aggregateId.equals(bm.get("aggregateId"))) { + for (Object c : asList(bm.get("commands"))) { + Map cm = asMap(c); + if (cmdId.equals(cm.get("cmdId"))) return cm; + } + } + } + return Collections.emptyMap(); + } + + // ---------- M3 部署映射 ---------- + + public Map deployOf(String aggregateId) { + Object dm = asMap(layers.get("M3")).get("deploymentMappings"); + for (Object a : asList(asMap(dm).get("aggregateMappingList"))) { + Map am = asMap(a); + if (aggregateId.equals(am.get("aggregateId"))) return am; + } + return Collections.emptyMap(); + } + + public List tableMappings(String aggregateId) { + return asList(deployOf(aggregateId).get("tableMappings")); + } + + public Map tableMapping(String aggregateId, String domainEntity) { + for (Object t : tableMappings(aggregateId)) { + Map tm = asMap(t); + if (domainEntity.equals(tm.get("domainEntity"))) return tm; + } + return Collections.emptyMap(); + } + + // ---------- M4 场景 ---------- + + public List scenes() { + return asList(asMap(asMap(layers.get("M4")).get("sceneModel")).get("sceneDefinitions")); + } + + public Map scene(String sceneId) { + for (Object s : scenes()) { + Map sm = asMap(s); + if (sceneId.equals(sm.get("sceneId"))) return sm; + } + return Collections.emptyMap(); + } + + // ---------- M5 安全 ---------- + + public Map securityModel() { + return asMap(asMap(layers.get("M5")).get("securityModel")); + } + + public Map maskRules() { + return asMap(securityModel().get("maskRules")); + } + + public Map functionPermission(String permId) { + for (Object p : asList(securityModel().get("functionPermissions"))) { + Map pm = asMap(p); + if (permId.equals(pm.get("permId"))) return pm; + } + return Collections.emptyMap(); + } + + // ---------- M8 前端模板 ---------- + + public List pageTemplates() { + return asList(asMap(asMap(layers.get("M8")).get("frontModel")).get("pageTemplates")); + } + + public Map frontGlobalConfig() { + return asMap(asMap(asMap(layers.get("M8")).get("frontModel")).get("reactGlobalConfig")); + } + + public Map templateByScene(String sceneId) { + for (Object t : pageTemplates()) { + Map tm = asMap(t); + if (sceneId.equals(tm.get("bindSceneId"))) return tm; + } + return Collections.emptyMap(); + } + + // ---------- ME 事件 ---------- + + public Map eventModel() { + return asMap(asMap(layers.get("ME")).get("eventModel")); + } + + public Map eventDef(String aggregateId, String eventName) { + for (Object a : asList(eventModel().get("aggregateEventDefinitions"))) { + Map am = asMap(a); + if (aggregateId.equals(am.get("aggregateId"))) { + Map events = asMap(am.get("events")); + return asMap(events.get(eventName)); + } + } + return Collections.emptyMap(); + } + + public List crossConsistencyRules() { + return asList(eventModel().get("crossAggConsistencyRules")); + } + + // ---------- MetaRule 规则 ---------- + + public Map metaRuleModel() { + return asMap(asMap(layers.get("MetaRule")).get("metaRuleModel")); + } + + /** 全局规则参数 paramId -> defaultValue。 */ + public Map ruleGlobalParams() { + Map out = new LinkedHashMap<>(); + for (Object p : asList(metaRuleModel().get("ruleGlobalParams"))) { + Map pm = asMap(p); + out.put(String.valueOf(pm.get("paramId")), pm.get("defaultValue")); + } + return out; + } + + /** 绑定到指定场景的规则组列表。 */ + public List> ruleGroupsByScene(String sceneId) { + List> out = new ArrayList<>(); + for (Object g : asList(metaRuleModel().get("ruleGroups"))) { + Map gm = asMap(g); + if (sceneId.equals(gm.get("bindScene"))) out.add(gm); + } + return out; + } + + // ---------- M2 命令扩展元数据(commandType / effect / derivations) ---------- + + /** 命令类型:create / update / cancel(缺省 create)。 */ + public String commandType(String aggregateId, String cmdId) { + Object t = command(aggregateId, cmdId).get("commandType"); + return t == null ? "create" : String.valueOf(t); + } + + /** 命令的结构化副作用(op/targetField/keyParam/amountParam/guardField)。 */ + public Map commandEffect(String aggregateId, String cmdId) { + return asMap(command(aggregateId, cmdId).get("effect")); + } + + /** 命令的服务端派生规则(如 totalAmount = sum(quantity*itemPrice))。 */ + public List commandDerivations(String aggregateId, String cmdId) { + return asList(command(aggregateId, cmdId).get("derivations")); + } + + // ---------- M1 字段约束 / 属性 / 引用 ---------- + + public List attributesOf(String aggregateId, String entityName) { + return entityName == null ? rootAttributes(aggregateId) + : asList(entity(aggregateId, entityName).get("attributes")); + } + + /** 字段的结构化约束(min/max/pattern/...),无则空。 */ + public Map attributeConstraints(String aggregateId, String entityName, String fieldName) { + for (Object at : attributesOf(aggregateId, entityName)) { + Map am = asMap(at); + if (fieldName.equals(am.get("name"))) return asMap(am.get("constraints")); + } + return Collections.emptyMap(); + } + + /** 若字段是跨聚合引用,返回 {refAggregate, refRoot, refIdentifier};否则空。 */ + public Map refInfo(String aggregateId, String entityName, String fieldName) { + for (Object at : attributesOf(aggregateId, entityName)) { + Map am = asMap(at); + if (fieldName.equals(am.get("name")) && am.get("refAggregate") != null) { + Map r = new LinkedHashMap<>(); + r.put("refAggregate", am.get("refAggregate")); + r.put("refRoot", am.get("refRoot")); + r.put("refIdentifier", am.get("refIdentifier")); + return r; + } + } + return Collections.emptyMap(); + } + + /** 聚合根展示字段:首个 string 属性,否则用标识字段。 */ + public String displayField(String aggregateId) { + for (Object at : rootAttributes(aggregateId)) { + Map am = asMap(at); + if ("string".equals(am.get("type"))) return String.valueOf(am.get("name")); + } + return rootIdentifier(aggregateId); + } + + // ---------- ME / M5 扩展 ---------- + + /** ME 声明的 Outbox 表名(缺省 t_domain_outbox)。 */ + public String outboxTable() { + Object ob = asMap(eventModel().get("globalConfig")).get("outbox"); + Object t = asMap(ob).get("table"); + return t == null ? "t_domain_outbox" : String.valueOf(t); + } + + /** M5 声明需存储加密的领域字段集合。 */ + public Set encryptFields() { + Set out = new LinkedHashSet<>(); + for (Object f : asList(asMap(securityModel().get("encryptRules")).get("storageEncrypt"))) { + out.add(String.valueOf(f)); + } + return out; + } +} diff --git b/engine/src/main/java/com/onto/engine/persist/DomainReadService.java a/engine/src/main/java/com/onto/engine/persist/DomainReadService.java new file mode 100644 index 0000000..b488331 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/persist/DomainReadService.java @@ -0,0 +1,86 @@ +package com.onto.engine.persist; + +import com.onto.engine.model.ModelRepository; +import com.onto.engine.security.EncryptService; +import com.onto.engine.security.MaskService; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * 领域读服务:把物理表行按 M3 fieldMap 反向映射回领域字段,先解密 M5 加密字段、再脱敏。 + * 用于下拉数据源与聚合根列表展示。 + */ +@Service +public class DomainReadService { + + private final ModelRepository models; + private final DynamicRepository repo; + private final MaskService mask; + private final EncryptService encrypt; + + public DomainReadService(ModelRepository models, DynamicRepository repo, MaskService mask, EncryptService encrypt) { + this.models = models; + this.repo = repo; + this.mask = mask; + this.encrypt = encrypt; + } + + /** 列出某聚合根的全部记录,转为领域字段并脱敏。 */ + public List> listRoot(String aggregateId) { + String rootName = models.rootName(aggregateId); + String table = repo.tableName(aggregateId, rootName); + Map fmap = repo.fieldMap(aggregateId, rootName); + Map inverse = invert(fmap); + List> out = new ArrayList<>(); + for (Map row : repo.listAll(table)) { + out.add(toDomain(row, inverse)); + } + return out; + } + + /** 下拉选项:value=聚合根标识,label=展示字段,并携带全部领域字段(供前端联动,如带出售价)。 */ + public List> options(String aggregateId) { + String idField = models.rootIdentifier(aggregateId); + String labelField = displayField(aggregateId); + List> out = new ArrayList<>(); + for (Map d : listRoot(aggregateId)) { + Object id = d.get(idField); + Object display = d.get(labelField); + Map opt = new LinkedHashMap<>(); + opt.put("value", id); + // 标签带上标识,避免展示字段不唯一时(如订单以币种为展示字段)选项难以区分 + opt.put("label", (display != null && !display.equals(id)) ? display + " · " + id : id); + opt.put("data", d); + out.add(opt); + } + return out; + } + + private Map toDomain(Map row, Map columnToField) { + Set encFields = models.encryptFields(); + Map d = new LinkedHashMap<>(); + for (var e : row.entrySet()) { + String field = columnToField.getOrDefault(e.getKey().toLowerCase(), e.getKey()); + Object value = e.getValue(); + if (value != null && encFields.contains(field)) value = encrypt.decrypt(value); // 先解密 + if (mask.isMasked(field)) value = mask.mask(field, value); // 再脱敏 + d.put(field, value); + } + return d; + } + + private String displayField(String aggregateId) { + for (Object at : models.rootAttributes(aggregateId)) { + Map am = (Map) at; + if ("string".equals(am.get("type"))) return String.valueOf(am.get("name")); + } + return models.rootIdentifier(aggregateId); + } + + private Map invert(Map fieldToColumn) { + Map inv = new LinkedHashMap<>(); + for (var e : fieldToColumn.entrySet()) inv.put(e.getValue().toLowerCase(), e.getKey()); + return inv; + } +} diff --git b/engine/src/main/java/com/onto/engine/persist/DynamicRepository.java a/engine/src/main/java/com/onto/engine/persist/DynamicRepository.java new file mode 100644 index 0000000..48994f8 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/persist/DynamicRepository.java @@ -0,0 +1,245 @@ +package com.onto.engine.persist; + +import com.onto.engine.model.ModelRepository; +import com.onto.engine.security.EncryptService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 通用领域仓储:不依赖任何实体类,全部按 M3 tableMappings 的 fieldMap 把"领域字段"翻译为"物理列", + * 并按 M1 字段类型做值转换后落库。改 M3 映射或 M1 字段类型,SQL 随之改变。 + * M5 声明 storageEncrypt 的字段在落库前加密、读出后解密。 + */ +@Repository +public class DynamicRepository { + + private final JdbcTemplate jdbc; + private final ModelRepository models; + private final EncryptService encrypt; + + public DynamicRepository(JdbcTemplate jdbc, ModelRepository models, EncryptService encrypt) { + this.jdbc = jdbc; + this.models = models; + this.encrypt = encrypt; + } + + /** 领域字段 -> 物理列(据 M3 fieldMap)。 */ + @SuppressWarnings("unchecked") + public Map fieldMap(String aggregateId, String domainEntity) { + Object fm = models.tableMapping(aggregateId, domainEntity).get("fieldMap"); + return fm instanceof Map ? (Map) fm : Collections.emptyMap(); + } + + public String tableName(String aggregateId, String domainEntity) { + return String.valueOf(models.tableMapping(aggregateId, domainEntity).get("table")); + } + + public String pkColumn(String aggregateId, String domainEntity) { + return String.valueOf(models.tableMapping(aggregateId, domainEntity).get("pk")); + } + + /** + * 按领域字段写入一行。domainValues 以领域字段名为 key;extraColumns 以物理列名为 key(如父外键)。 + * 仅写入既出现在 domainValues 又存在于 M3 fieldMap 中的字段。 + */ + public void insertDomain(String aggregateId, String domainEntity, String entityNameForType, + Map domainValues, Map extraColumns) { + Map fmap = fieldMap(aggregateId, domainEntity); + Set encFields = models.encryptFields(); + List cols = new ArrayList<>(); + List args = new ArrayList<>(); + for (var e : fmap.entrySet()) { + String domainField = e.getKey(); + if (!domainValues.containsKey(domainField)) continue; + String type = models.fieldType(aggregateId, entityNameForType, domainField); + Object value = convert(type, domainValues.get(domainField)); + if (value != null && encFields.contains(domainField)) value = encrypt.encrypt(value); // M5 存储加密 + cols.add(e.getValue()); + args.add(value); + } + if (extraColumns != null) { + for (var e : extraColumns.entrySet()) { + cols.add(e.getKey()); + args.add(e.getValue()); + } + } + if (cols.isEmpty()) return; + String table = tableName(aggregateId, domainEntity); + String placeholders = String.join(",", Collections.nCopies(cols.size(), "?")); + String sql = "INSERT INTO " + table + " (" + String.join(",", cols) + ") VALUES (" + placeholders + ")"; + jdbc.update(sql, args.toArray()); + } + + /** 按领域字段更新一行(据 M3 fieldMap;加密敏感字段)。返回受影响行数。 */ + public int updateDomain(String aggregateId, String domainEntity, String entityNameForType, + Map domainValues, String pkDomainField, Object pkValue) { + Map fmap = fieldMap(aggregateId, domainEntity); + Set encFields = models.encryptFields(); + List sets = new ArrayList<>(); + List args = new ArrayList<>(); + for (var e : fmap.entrySet()) { + String df = e.getKey(); + if (df.equals(pkDomainField) || !domainValues.containsKey(df)) continue; + Object v = convert(models.fieldType(aggregateId, entityNameForType, df), domainValues.get(df)); + if (v != null && encFields.contains(df)) v = encrypt.encrypt(v); + sets.add(e.getValue() + " = ?"); + args.add(v); + } + if (sets.isEmpty()) return 0; + String pkCol = fmap.getOrDefault(pkDomainField, pkColumn(aggregateId, domainEntity)); + args.add(pkValue); + return jdbc.update("UPDATE " + tableName(aggregateId, domainEntity) + " SET " + + String.join(",", sets) + " WHERE " + pkCol + " = ?", args.toArray()); + } + + /** 按父外键(=聚合根标识)更新某组合子实体的行(据 M3 fieldMap;加密敏感字段)。返回受影响行数。 */ + public int updateChildByParent(String aggregateId, String childEntity, Object parentIdValue, + Map domainValues) { + String parentFk = pkColumn(aggregateId, models.rootName(aggregateId)); + Set encFields = models.encryptFields(); + List sets = new ArrayList<>(); + List args = new ArrayList<>(); + for (var e : fieldMap(aggregateId, childEntity).entrySet()) { + String df = e.getKey(); + if (!domainValues.containsKey(df)) continue; // 未提供的字段(含子实体主键)不覆盖 + Object v = convert(models.fieldType(aggregateId, childEntity, df), domainValues.get(df)); + if (v != null && encFields.contains(df)) v = encrypt.encrypt(v); + sets.add(e.getValue() + " = ?"); + args.add(v); + } + if (sets.isEmpty()) return 0; + args.add(parentIdValue); + return jdbc.update("UPDATE " + tableName(aggregateId, childEntity) + " SET " + + String.join(",", sets) + " WHERE " + parentFk + " = ?", args.toArray()); + } + + public long count(String table) { + Long n = jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); + return n == null ? 0 : n; + } + + public List> listAll(String table) { + return jdbc.queryForList("SELECT * FROM " + table); + } + + public Map findByPk(String table, String pkColumn, Object pkValue) { + List> rows = + jdbc.queryForList("SELECT * FROM " + table + " WHERE " + pkColumn + " = ?", pkValue); + return rows.isEmpty() ? Collections.emptyMap() : rows.get(0); + } + + public boolean existsByPk(String table, String pkColumn, Object pkValue) { + Long n = jdbc.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE " + pkColumn + " = ?", Long.class, pkValue); + return n != null && n > 0; + } + + /** 是否存在 column=value 的其它行(excludePk 为空则不排除自身)——用于唯一性约束校验。 */ + public boolean existsOther(String table, String column, Object value, String pkColumn, Object excludePk) { + Long n = (excludePk == null) + ? jdbc.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class, value) + : jdbc.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ? AND " + + pkColumn + " <> ?", Long.class, value, excludePk); + return n != null && n > 0; + } + + public int execute(String sql, Object... args) { + return jdbc.update(sql, args); + } + + public JdbcTemplate jdbc() { return jdbc; } + + /** 按聚合根标识加载一行,反向映射为领域字段并解密敏感字段(不脱敏)——供规则/引用解析。 */ + public Map loadDomainRow(String aggregateId, String domainEntity, Object idValue) { + if (idValue == null) return Collections.emptyMap(); + Map row = findByPk(tableName(aggregateId, domainEntity), + pkColumn(aggregateId, domainEntity), idValue); + if (row.isEmpty()) return Collections.emptyMap(); + Set encFields = models.encryptFields(); + Map out = new LinkedHashMap<>(); + for (var e : fieldMap(aggregateId, domainEntity).entrySet()) { + Object v = row.containsKey(e.getValue()) ? row.get(e.getValue()) : row.get(e.getValue().toLowerCase()); + if (v != null && encFields.contains(e.getKey())) v = encrypt.decrypt(v); + out.put(e.getKey(), v); + } + return out; + } + + /** + * 原子条件调整数值列:delta 有符号(扣减为负,回补为正)。 + * guardNonNegative=true 时要求结果不为负(如库存);返回受影响行数(0=不存在/越界,未变更)。 + */ + /** 加载某聚合根下某组合子实体的全部行(按父外键=根标识),反向映射为领域字段并解密。 */ + public List> loadChildRows(String aggregateId, String childEntity, Object parentIdValue) { + String parentFk = pkColumn(aggregateId, models.rootName(aggregateId)); + String table = tableName(aggregateId, childEntity); + Set encFields = models.encryptFields(); + List> out = new ArrayList<>(); + for (Map row : jdbc.queryForList( + "SELECT * FROM " + table + " WHERE " + parentFk + " = ?", parentIdValue)) { + Map d = new LinkedHashMap<>(); + for (var e : fieldMap(aggregateId, childEntity).entrySet()) { + Object v = row.containsKey(e.getValue()) ? row.get(e.getValue()) : row.get(e.getValue().toLowerCase()); + if (v != null && encFields.contains(e.getKey())) v = encrypt.decrypt(v); + d.put(e.getKey(), v); + } + out.add(d); + } + return out; + } + + public int adjustColumn(String table, String column, String pkColumn, Object pkValue, + long delta, boolean guardNonNegative) { + if (delta < 0 && guardNonNegative) { + return jdbc.update("UPDATE " + table + " SET " + column + " = " + column + " + ? WHERE " + + pkColumn + " = ? AND " + column + " + ? >= 0", delta, pkValue, delta); + } + return jdbc.update("UPDATE " + table + " SET " + column + " = " + column + " + ? WHERE " + + pkColumn + " = ?", delta, pkValue); + } + + /** 读取某列当前数值(供展示扣减后余额)。null/缺失返回 null。 */ + public Long readLong(String table, String column, String pkColumn, Object pkValue) { + Map row = findByPk(table, pkColumn, pkValue); + Object v = row.get(column); + if (v == null) v = row.get(column.toLowerCase()); + if (v == null) return null; + try { return new BigDecimal(String.valueOf(v)).longValue(); } catch (Exception e) { return null; } + } + + // ===================== 值类型转换(据 M1 类型) ===================== + + public static Object convert(String m1Type, Object raw) { + if (raw == null) return null; + String s = String.valueOf(raw).trim(); + return switch (m1Type == null ? "string" : m1Type) { + case "int" -> s.isEmpty() ? null : new BigDecimal(s).longValue(); + case "decimal" -> s.isEmpty() ? null : new BigDecimal(s); + case "boolean" -> (raw instanceof Boolean b) ? b : Boolean.parseBoolean(s); + case "datetime" -> parseDateTime(s); + default -> s; + }; + } + + private static LocalDateTime parseDateTime(String s) { + if (s == null || s.isEmpty()) return LocalDateTime.now(); + String v = s.replace('T', ' '); + // 去除毫秒/时区尾巴,尽量宽松 + int dot = v.indexOf('.'); + if (dot > 0) v = v.substring(0, dot); + try { + if (v.length() <= 10) { // 仅日期 + return LocalDateTime.parse(v + " 00:00:00", + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + return LocalDateTime.parse(v, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } catch (Exception ex) { + return LocalDateTime.now(); + } + } +} diff --git b/engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java a/engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java new file mode 100644 index 0000000..0d91cc2 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java @@ -0,0 +1,151 @@ +package com.onto.engine.persist; + +import com.onto.engine.model.ModelRepository; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.yaml.snakeyaml.Yaml; + +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.*; + +/** + * 据 M3 tableMappings + M1 字段类型自动生成 DDL 建表,并写入演示引导数据。 + * 这体现"表结构也由模型驱动":新增 M1 字段 + M3 列映射后,reload 会自动补列。 + */ +@Component +public class SchemaInitializer { + + private static final Logger log = LoggerFactory.getLogger(SchemaInitializer.class); + + private final ModelRepository models; + private final JdbcTemplate jdbc; + private final DynamicRepository repo; + + public SchemaInitializer(ModelRepository models, JdbcTemplate jdbc, DynamicRepository repo) { + this.models = models; + this.jdbc = jdbc; + this.repo = repo; + } + + @PostConstruct + public void init() { + ensureEventOutboxTable(); + ensureDomainSchema(); + seed(); + } + + /** 供热重载后补齐新增表/列。 */ + public void refresh() { + ensureDomainSchema(); + } + + private void ensureEventOutboxTable() { + // ME.eventModel.globalConfig.outbox.table = t_domain_outbox + String table = "t_domain_outbox"; + try { + Object outbox = ((Map) ((Map) models.eventModel().get("globalConfig")).get("outbox")); + if (outbox instanceof Map m && m.get("table") != null) table = String.valueOf(m.get("table")); + } catch (Exception ignore) { /* 用默认名 */ } + jdbc.execute("CREATE TABLE IF NOT EXISTS " + table + " (" + + "event_id VARCHAR(64) PRIMARY KEY, aggregate_id VARCHAR(64), event_name VARCHAR(64), " + + "topic VARCHAR(128), payload_json TEXT, status VARCHAR(16), create_time TIMESTAMP)"); + } + + private void ensureDomainSchema() { + for (Object a : models.aggregates()) { + String aggId = String.valueOf(ModelRepositoryAccess.get(a, "aggregateId")); + String rootName = models.rootName(aggId); + String rootPkColumn = repo.pkColumn(aggId, rootName); + for (Object tmObj : models.tableMappings(aggId)) { + @SuppressWarnings("unchecked") + Map tm = (Map) tmObj; + String domainEntity = String.valueOf(tm.get("domainEntity")); + String table = String.valueOf(tm.get("table")); + String pk = String.valueOf(tm.get("pk")); + boolean isRoot = domainEntity.equals(rootName); + String entityNameForType = isRoot ? null : domainEntity; + + @SuppressWarnings("unchecked") + Map fieldMap = (Map) tm.get("fieldMap"); + + createTableIfAbsent(table, pk); + for (var e : fieldMap.entrySet()) { + String type = models.fieldType(aggId, entityNameForType, e.getKey()); + addColumnIfAbsent(table, e.getValue(), sqlType(type, e.getValue().equals(pk))); + } + // 组合子实体:补父外键列(据 M1 composition + M3 根主键列) + if (!isRoot && !rootPkColumn.equals("null")) { + addColumnIfAbsent(table, rootPkColumn, "VARCHAR(64)"); + } + } + } + log.info("领域表结构已按 M3/M1 生成/校准完毕"); + } + + private void createTableIfAbsent(String table, String pkColumn) { + jdbc.execute("CREATE TABLE IF NOT EXISTS " + table + " (" + pkColumn + " VARCHAR(64) PRIMARY KEY)"); + } + + private void addColumnIfAbsent(String table, String column, String sqlType) { + // H2 支持 IF NOT EXISTS;已是主键列时 CREATE 已建,跳过 + jdbc.execute("ALTER TABLE " + table + " ADD COLUMN IF NOT EXISTS " + column + " " + sqlType); + } + + private String sqlType(String m1Type, boolean isPk) { + if (isPk) return "VARCHAR(64)"; + return switch (m1Type == null ? "string" : m1Type) { + case "int" -> "BIGINT"; + case "decimal" -> "DECIMAL(18,4)"; + case "datetime" -> "TIMESTAMP"; + case "boolean" -> "BOOLEAN"; + default -> "VARCHAR(255)"; + }; + } + + // ===================== 演示引导数据 ===================== + + @SuppressWarnings("unchecked") + private void seed() { + Map seed; + try (InputStream in = new ClassPathResource("seed-data.yaml").getInputStream()) { + seed = new Yaml().load(in); + } catch (Exception ex) { + log.warn("未找到 seed-data.yaml,跳过引导数据: {}", ex.getMessage()); + return; + } + if (seed == null) return; + // 通用:以聚合 id 为 key 遍历,解析根实体名后落库;缺失的 datetime 字段填当前时间。 + for (var entry : seed.entrySet()) { + String aggregateId = entry.getKey(); + if (models.aggregate(aggregateId).isEmpty()) continue; + String rootName = models.rootName(aggregateId); + if (repo.count(repo.tableName(aggregateId, rootName)) > 0) continue; + + List datetimeFields = new ArrayList<>(); + for (Object at : models.rootAttributes(aggregateId)) { + Map am = (Map) at; + if ("datetime".equals(am.get("type"))) datetimeFields.add(String.valueOf(am.get("name"))); + } + int n = 0; + for (Object rowObj : (List) entry.getValue()) { + Map row = new LinkedHashMap<>((Map) rowObj); + for (String dt : datetimeFields) row.putIfAbsent(dt, LocalDateTime.now().toString()); + repo.insertDomain(aggregateId, rootName, null, row, null); + n++; + } + log.info("已写入演示数据 {}:{} 条", aggregateId, n); + } + } + + /** 小工具:从任意 Map-like 对象取 key。 */ + static final class ModelRepositoryAccess { + static Object get(Object mapLike, String key) { + return mapLike instanceof Map m ? m.get(key) : null; + } + } +} diff --git b/engine/src/main/java/com/onto/engine/rule/RuleEngine.java a/engine/src/main/java/com/onto/engine/rule/RuleEngine.java new file mode 100644 index 0000000..4d6a6fa --- /dev/null +++ a/engine/src/main/java/com/onto/engine/rule/RuleEngine.java @@ -0,0 +1,126 @@ +package com.onto.engine.rule; + +import com.googlecode.aviator.AviatorEvaluator; +import com.googlecode.aviator.Expression; +import com.onto.engine.model.ModelRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.*; +import java.util.regex.Pattern; + +/** + * MetaRule 动态规则引擎(Aviator)。前端"实时校验"与后端"持久化校验"共用同一份 MetaRule 表达式, + * 规则仅维护一份。改 MetaRule-business.yaml 的阈值/表达式/message,reload 后前后端行为同时改变。 + */ +@Component +public class RuleEngine { + + private static final Logger log = LoggerFactory.getLogger(RuleEngine.class); + private static final Pattern PARAM_REF = Pattern.compile("#\\{([a-zA-Z0-9_]+)\\}"); + + private final ModelRepository models; + + public RuleEngine(ModelRepository models) { + this.models = models; + } + + /** + * 针对某场景,按给定业务变量评估所有绑定规则组。 + * 仅评估其"所引用变量"都在 env 中出现的规则(据此把订单级规则与逐商品级规则区分开)。 + */ + public List evaluate(String sceneId, Map businessVars) { + Map env = new HashMap<>(); + // 注入全局规则参数(#{paramId} 会被改写为变量 paramId) + models.ruleGlobalParams().forEach((k, v) -> env.put(k, toNumberIfPossible(v))); + businessVars.forEach((k, v) -> env.put(k, toNumberIfPossible(v))); + + List hits = new ArrayList<>(); + for (Map group : models.ruleGroupsByScene(sceneId)) { + for (Object r : asList(group.get("rules"))) { + Map rule = asMap(r); + String cond = String.valueOf(rule.get("matchCondition")); + // 把 #{paramId} 改写为变量名 paramId(全局参数已注入 env) + String processed = PARAM_REF.matcher(cond).replaceAll("$1"); + try { + Expression exp = AviatorEvaluator.compile(processed, true); + List vars = exp.getVariableNames(); + if (!env.keySet().containsAll(vars)) { + continue; // 该规则所需变量在此上下文未提供,跳过 + } + Object result = exp.execute(env); + if (Boolean.TRUE.equals(result)) { + hits.add(new RuleHit( + String.valueOf(rule.get("ruleId")), + String.valueOf(rule.get("ruleName")), + String.valueOf(rule.get("effect")), + String.valueOf(rule.get("message")))); + } + } catch (Exception ex) { + log.warn("规则求值失败 ruleId={} cond={} err={}", rule.get("ruleId"), processed, ex.getMessage()); + } + } + } + return hits; + } + + /** 用 Aviator 求一个算术表达式(如 "quantity * itemPrice")为数值;出错或缺变量返回 0。 */ + public BigDecimal evalNumber(String expression, Map row) { + Map env = new HashMap<>(); + row.forEach((k, v) -> env.put(k, toNumberIfPossible(v))); + try { + Object r = AviatorEvaluator.compile(expression, true).execute(env); + return r == null ? BigDecimal.ZERO : new BigDecimal(String.valueOf(r)); + } catch (Exception e) { + log.warn("派生表达式求值失败 expr={} err={}", expression, e.getMessage()); + return BigDecimal.ZERO; + } + } + + /** + * 通用表达式求值(供 M4 场景步骤 condition/switch/while/assign/script 使用)。 + * 与规则同源:支持 #{paramId} 引用全局参数,缺变量/出错返回 null。 + */ + public Object evalValue(String expression, Map vars) { + if (expression == null || expression.isBlank()) return null; + Map env = new HashMap<>(); + models.ruleGlobalParams().forEach((k, v) -> env.put(k, toNumberIfPossible(v))); + vars.forEach((k, v) -> env.put(k, toNumberIfPossible(v))); + String processed = PARAM_REF.matcher(expression).replaceAll("$1"); + try { + return AviatorEvaluator.compile(processed, true).execute(env); + } catch (Exception e) { + log.warn("表达式求值失败 expr={} err={}", processed, e.getMessage()); + return null; + } + } + + /** 求布尔表达式(如 "totalAmount > single_order_max_amount");非真即 false。 */ + public boolean evalBool(String expression, Map vars) { + Object v = evalValue(expression, vars); + return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v)); + } + + private static Object toNumberIfPossible(Object v) { + if (v instanceof Number || v instanceof Boolean) return v; + if (v == null) return null; + String s = String.valueOf(v); + try { + if (s.matches("-?\\d+")) return Long.parseLong(s); + if (s.matches("-?\\d+\\.\\d+")) return new BigDecimal(s); + } catch (Exception ignore) { /* fall through */ } + return s; + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private static List asList(Object o) { + return o instanceof List ? (List) o : Collections.emptyList(); + } +} diff --git b/engine/src/main/java/com/onto/engine/rule/RuleHit.java a/engine/src/main/java/com/onto/engine/rule/RuleHit.java new file mode 100644 index 0000000..7d965e1 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/rule/RuleHit.java @@ -0,0 +1,4 @@ +package com.onto.engine.rule; + +/** 一条命中的 MetaRule 规则结果。 */ +public record RuleHit(String ruleId, String ruleName, String effect, String message) {} diff --git b/engine/src/main/java/com/onto/engine/security/EncryptService.java a/engine/src/main/java/com/onto/engine/security/EncryptService.java new file mode 100644 index 0000000..e4126d7 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/security/EncryptService.java @@ -0,0 +1,69 @@ +package com.onto.engine.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +/** + * 字段级存储加密(兑现 M5 encryptRules.storageEncrypt)。AES-256-GCM,落库前加密、读出后解密。 + * + *

演示态使用固定派生密钥(可经 onto.encrypt-key 覆盖);生产应改为 KMS/密钥轮换。 + * 密文以 "enc:" 前缀标记,便于幂等(已加密的值不会二次加密)。 + */ +@Component +public class EncryptService { + + private static final String PREFIX = "enc:"; + private static final int IV_LEN = 12; + private static final int TAG_BITS = 128; + + private final SecretKeySpec key; + // 固定 IV 用于演示可复现(同明文→同密文,便于去重/等值查询);生产必须随机 IV。 + private final byte[] iv; + + public EncryptService(@Value("${onto.encrypt-key:onto-demo-key}") String secret) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)); + this.key = new SecretKeySpec(digest, "AES"); + byte[] ivBytes = new byte[IV_LEN]; + System.arraycopy(digest, 0, ivBytes, 0, IV_LEN); + this.iv = ivBytes; + } catch (Exception e) { + throw new IllegalStateException("初始化加密服务失败", e); + } + } + + public String encrypt(Object plain) { + if (plain == null) return null; + String s = String.valueOf(plain); + if (s.startsWith(PREFIX)) return s; // 已加密,幂等 + try { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] enc = c.doFinal(s.getBytes(StandardCharsets.UTF_8)); + return PREFIX + Base64.getEncoder().encodeToString(enc); + } catch (Exception e) { + throw new IllegalStateException("加密失败", e); + } + } + + public String decrypt(Object stored) { + if (stored == null) return null; + String s = String.valueOf(stored); + if (!s.startsWith(PREFIX)) return s; // 非密文,原样返回 + try { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] dec = c.doFinal(Base64.getDecoder().decode(s.substring(PREFIX.length()))); + return new String(dec, StandardCharsets.UTF_8); + } catch (Exception e) { + return s; // 解密失败时不阻断读取 + } + } +} diff --git b/engine/src/main/java/com/onto/engine/security/MaskService.java a/engine/src/main/java/com/onto/engine/security/MaskService.java new file mode 100644 index 0000000..af81bdd --- /dev/null +++ a/engine/src/main/java/com/onto/engine/security/MaskService.java @@ -0,0 +1,64 @@ +package com.onto.engine.security; + +import com.onto.engine.model.ModelRepository; +import org.springframework.stereotype.Component; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * M5 脱敏服务:按 maskRules(如 contactPhone/receiverPhone: "{first3}****{last4}")对敏感字段做掩码。 + * 前端渲染引擎读取 maskField 标记,后端返回读数据时套用本服务,规则同源于 M5。 + */ +@Component +public class MaskService { + + private static final Pattern FIRST = Pattern.compile("\\{first(\\d+)\\}"); + private static final Pattern LAST = Pattern.compile("\\{last(\\d+)\\}"); + + private final ModelRepository models; + + public MaskService(ModelRepository models) { + this.models = models; + } + + /** 该领域字段是否配置了脱敏规则。 */ + public boolean isMasked(String domainFieldName) { + return models.maskRules().containsKey(domainFieldName); + } + + /** 按 M5 规则对某字段值脱敏;无规则则原样返回。 */ + public String mask(String domainFieldName, Object value) { + if (value == null) return null; + Object rule = models.maskRules().get(domainFieldName); + String s = String.valueOf(value); + if (rule == null) return s; + return applyPattern(String.valueOf(rule), s); + } + + private String applyPattern(String pattern, String value) { + String out = pattern; + out = replaceToken(FIRST, out, value, true); + out = replaceToken(LAST, out, value, false); + return out; + } + + private String replaceToken(Pattern p, String pattern, String value, boolean first) { + Matcher m = p.matcher(pattern); + StringBuilder sb = new StringBuilder(); + while (m.find()) { + int n = Integer.parseInt(m.group(1)); + String frag; + if (value.length() <= n) { + frag = value; + } else if (first) { + frag = value.substring(0, n); + } else { + frag = value.substring(value.length() - n); + } + m.appendReplacement(sb, Matcher.quoteReplacement(frag)); + } + m.appendTail(sb); + return sb.toString(); + } +} diff --git b/engine/src/main/java/com/onto/engine/util/IdGen.java a/engine/src/main/java/com/onto/engine/util/IdGen.java new file mode 100644 index 0000000..3afafa7 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/util/IdGen.java @@ -0,0 +1,18 @@ +package com.onto.engine.util; + +import java.util.concurrent.atomic.AtomicLong; + +/** 轻量雪花风格 ID 生成器(ME.globalConfig.idGenerator = snowflake 的演示态实现)。 */ +public final class IdGen { + + private static final AtomicLong SEQ = new AtomicLong(0); + private static final long EPOCH = 1_700_000_000_000L; + + private IdGen() {} + + public static String next(String prefix) { + long ms = System.currentTimeMillis() - EPOCH; + long seq = SEQ.incrementAndGet() & 0xFFF; + return prefix + Long.toString((ms << 12) | seq, 36).toUpperCase(); + } +} diff --git b/engine/src/main/java/com/onto/engine/web/CommandController.java a/engine/src/main/java/com/onto/engine/web/CommandController.java new file mode 100644 index 0000000..e052e6b --- /dev/null +++ a/engine/src/main/java/com/onto/engine/web/CommandController.java @@ -0,0 +1,44 @@ +package com.onto.engine.web; + +import com.onto.engine.command.CommandEngine; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 命令 API:场景命令的预检与执行(通用,非订单专用)。 + * + *

认证说明(演示态):M5 声明 authMode=oauth2_jwt,但本 Demo 未接入真实 OAuth2/JWT。 + * 主体来自 {@code X-Principal} 头(可伪造),仅用于演示 M5 权限模型的"通过/拒绝"分支。 + * 生产必须以经校验的 JWT/会话派生主体替换该头,切勿信任客户端自报身份。 + */ +@RestController +@RequestMapping("/api/scene") +public class CommandController { + + private final CommandEngine commandEngine; + + @Value("${onto.default-principal:normal_user}") + private String defaultPrincipal; + + public CommandController(CommandEngine commandEngine) { + this.commandEngine = commandEngine; + } + + /** 提交前实时预检:校验 + MetaRule 风控(不落库)。 */ + @PostMapping("/{sceneId}/precheck") + public Map precheck(@PathVariable String sceneId, + @RequestBody Map payload) { + return commandEngine.precheck(sceneId, payload); + } + + /** 执行场景命令(完整解释执行:校验→风控→落库→发事件→跨聚合扣库存)。 */ + @PostMapping("/{sceneId}/execute") + public Map execute(@PathVariable String sceneId, + @RequestBody Map payload, + @RequestHeader(value = "X-Principal", required = false) String principal) { + String p = (principal == null || principal.isBlank()) ? defaultPrincipal : principal; + return commandEngine.execute(sceneId, payload, p); + } +} diff --git b/engine/src/main/java/com/onto/engine/web/DataController.java a/engine/src/main/java/com/onto/engine/web/DataController.java new file mode 100644 index 0000000..e8e4708 --- /dev/null +++ a/engine/src/main/java/com/onto/engine/web/DataController.java @@ -0,0 +1,40 @@ +package com.onto.engine.web; + +import com.onto.engine.event.EventEngine; +import com.onto.engine.persist.DomainReadService; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** 业务数据 API:下拉引用数据源、已创建订单列表、事件 Outbox 查看。 */ +@RestController +@RequestMapping("/api/data") +public class DataController { + + private final DomainReadService readService; + private final EventEngine eventEngine; + + public DataController(DomainReadService readService, EventEngine eventEngine) { + this.readService = readService; + this.eventEngine = eventEngine; + } + + /** 某聚合根的下拉选项(如客户、商品),value/label 及全部领域字段。 */ + @GetMapping("/{aggregateId}") + public List> options(@PathVariable String aggregateId) { + return readService.options(aggregateId); + } + + /** 某聚合根记录列表(通用,脱敏后)。取代旧的订单专用端点。 */ + @GetMapping("/{aggregateId}/list") + public List> list(@PathVariable String aggregateId) { + return readService.listRoot(aggregateId); + } + + /** 最近事件(Outbox)。 */ + @GetMapping("/events/recent") + public List> events() { + return eventEngine.recentOutbox(30); + } +} diff --git b/engine/src/main/java/com/onto/engine/web/MetaController.java a/engine/src/main/java/com/onto/engine/web/MetaController.java new file mode 100644 index 0000000..1fde2ea --- /dev/null +++ a/engine/src/main/java/com/onto/engine/web/MetaController.java @@ -0,0 +1,120 @@ +package com.onto.engine.web; + +import com.onto.engine.meta.SceneSchemaService; +import com.onto.engine.model.ModelRepository; +import com.onto.engine.persist.SchemaInitializer; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.*; + +/** 元数据 API:场景渲染 Schema、模型查看、热重载。 */ +@RestController +@RequestMapping("/api/meta") +public class MetaController { + + private final ModelRepository models; + private final SceneSchemaService sceneSchema; + private final SchemaInitializer schemaInitializer; + + public MetaController(ModelRepository models, SceneSchemaService sceneSchema, + SchemaInitializer schemaInitializer) { + this.models = models; + this.sceneSchema = sceneSchema; + this.schemaInitializer = schemaInitializer; + } + + /** 场景列表(供前端切换/展示)。 */ + @GetMapping("/scenes") + public List> scenes() { + List> out = new ArrayList<>(); + for (Object s : models.scenes()) { + Map sm = (Map) s; + Map item = new LinkedHashMap<>(); + item.put("sceneId", sm.get("sceneId")); + item.put("sceneName", sm.get("sceneName")); + Map tpl = models.templateByScene(String.valueOf(sm.get("sceneId"))); + item.put("hasTemplate", !tpl.isEmpty()); + item.put("templateName", tpl.get("templateName")); + out.add(item); + } + return out; + } + + /** 某场景的完整渲染元数据(M8+M1+M2+M3+M5+MetaRule 合成)。 */ + @GetMapping("/scene/{sceneId}") + public Map scene(@PathVariable String sceneId) { + return sceneSchema.buildSceneSchema(sceneId); + } + + /** 某聚合根的标识与属性(供前端通用地构造列表列,无需硬编码字段名)。 */ + @GetMapping("/aggregate/{aggregateId}") + public Map aggregate(@PathVariable String aggregateId) { + List> attrs = new ArrayList<>(); + for (Object at : models.rootAttributes(aggregateId)) { + Map am = (Map) at; + Map a = new LinkedHashMap<>(); + a.put("name", am.get("name")); + a.put("type", am.get("type")); + attrs.add(a); + } + Map out = new LinkedHashMap<>(); + out.put("aggregateId", aggregateId); + out.put("rootIdentifier", models.rootIdentifier(aggregateId)); + out.put("displayField", models.displayField(aggregateId)); + out.put("attributes", attrs); + return out; + } + + /** 已加载的本体模型层清单。 */ + @GetMapping("/models") + public Map modelList() { + List> layers = new ArrayList<>(); + for (String code : models.layerCodes()) { + Map m = new LinkedHashMap<>(); + m.put("code", code); + m.put("fileName", models.fileNameOf(code)); + layers.add(m); + } + Map out = new LinkedHashMap<>(); + out.put("modelsDir", models.modelsDir().toString()); + out.put("layers", layers); + return out; + } + + /** 某一层解析后的模型内容(供"运行原理/模型查看器"展示)。 */ + @GetMapping("/model/{code}") + public Map model(@PathVariable String code) { + Map out = new LinkedHashMap<>(); + out.put("code", code); + out.put("fileName", models.fileNameOf(code)); + out.put("content", models.rawLayer(code)); + return out; + } + + /** + * 热重载:重新读取磁盘 YAML 并补齐表结构,使模型改动即时生效(无需重启)。 + * 仅 M8 dragRolePermission 声明的管理员可触发(演示态用 X-Principal 头表示主体;生产应换真实认证)。 + */ + @PostMapping("/reload") + public Map reload(@RequestHeader(value = "X-Principal", required = false) String principal) { + List admins = asList(models.frontGlobalConfig().get("dragRolePermission")); + if (!admins.isEmpty() && !admins.contains(principal)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "仅管理员 " + admins + " 可热重载模型(当前主体:" + principal + ")"); + } + models.reload(); + schemaInitializer.refresh(); + Map out = new LinkedHashMap<>(); + out.put("reloaded", true); + out.put("layers", models.layerCodes()); + out.put("message", "本体模型已热重载,前端刷新即可看到新的表单与行为"); + return out; + } + + @SuppressWarnings("unchecked") + private static List asList(Object o) { + return o instanceof List ? (List) o : java.util.Collections.emptyList(); + } +} diff --git b/engine/src/main/resources/application-prod.yml a/engine/src/main/resources/application-prod.yml new file mode 100644 index 0000000..2c6b3bf --- /dev/null +++ a/engine/src/main/resources/application-prod.yml @@ -0,0 +1,19 @@ +# 生产 profile:连真实 MySQL8。启用方式:--spring.profiles.active=prod +# 环境变量可覆盖:DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS +spring: + datasource: + url: jdbc:mysql://${DB_HOST:mysql}:${DB_PORT:3306}/${DB_NAME:trade_db}?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USER:trade} + password: ${DB_PASS:trade123} + h2: + console: + enabled: false + +onto: + models-dir: ${ONTO_MODELS_DIR:/app/models} + # 生产 CORS 收紧为具体前端域名(逗号分隔),勿用 * + cors: + allowed-origins: ${CORS_ORIGINS:http://localhost:8088} + # 生产字段加密密钥必须经密管注入,切勿用演示默认值 + encrypt-key: ${ONTO_ENCRYPT_KEY:请在生产用密管注入} diff --git b/engine/src/main/resources/application.yml a/engine/src/main/resources/application.yml new file mode 100644 index 0000000..997f579 --- /dev/null +++ a/engine/src/main/resources/application.yml @@ -0,0 +1,27 @@ +server: + port: 8080 + +spring: + application: + name: onto-engine + # 默认 H2 内存库,MySQL8 兼容模式,零外部依赖开箱即跑 + datasource: + url: jdbc:h2:mem:trade_db;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: "" + h2: + console: + enabled: true + path: /h2-console + +# 本体模型引擎配置 +onto: + # 模型目录:引擎启动时从此目录加载全部 YAML 本体模型;支持热重载(POST /api/meta/reload) + models-dir: ../models + # 演示态默认主体(M5 权限主体) + default-principal: normal_user + +logging: + level: + com.onto.engine: INFO diff --git b/engine/src/main/resources/seed-data.yaml a/engine/src/main/resources/seed-data.yaml new file mode 100644 index 0000000..f783984 --- /dev/null +++ a/engine/src/main/resources/seed-data.yaml @@ -0,0 +1,54 @@ +# 演示引导数据(非本体模型的一部分,仅用于让"新增订单"页有真实的客户/商品可选,并让扣库存有库存可扣) +# 字段名严格对齐 M1 领域模型;引擎按 M3 映射落库到 t_customer_main / t_product_main。 +CustomerAggregate: + - customerId: C001 + customerName: 张三(普通客户) + contactPhone: "13800000001" + customerLevel: 普通 + - customerId: C002 + customerName: 李四(VIP客户) + contactPhone: "13800000002" + customerLevel: VIP + - customerId: C003 + customerName: 王五(高级VIP) + contactPhone: "13800000003" + customerLevel: 高级VIP + +EmployeeAggregate: + # 审核统计计数需初始化为 0,REMOTE_INCREMENT_FIELD 的 col = col + N 才能在非空值上累加 + - employeeId: E001 + employeeName: 审核员小王 + department: 风控部 + auditApproveCount: 0 + auditRejectCount: 0 + - employeeId: E002 + employeeName: 审核主管李雷 + department: 风控部 + auditApproveCount: 0 + auditRejectCount: 0 + +ProductAggregate: + - productId: P001 + productName: 笔记本电脑 + skuCode: SKU-NB-001 + category: 电脑办公 + saleCurrency: CNY + salePrice: 6999 + stockNum: 100 + isOnSale: true + - productId: P002 + productName: 无线鼠标(低库存) + skuCode: SKU-MS-002 + category: 电脑配件 + saleCurrency: CNY + salePrice: 199 + stockNum: 8 + isOnSale: true + - productId: P003 + productName: 机械键盘 + skuCode: SKU-KB-003 + category: 电脑配件 + saleCurrency: CNY + salePrice: 499 + stockNum: 50 + isOnSale: true diff --git b/engine/src/test/java/com/onto/engine/OrderSceneTest.java a/engine/src/test/java/com/onto/engine/OrderSceneTest.java new file mode 100644 index 0000000..5599db9 --- /dev/null +++ a/engine/src/test/java/com/onto/engine/OrderSceneTest.java @@ -0,0 +1,378 @@ +package com.onto.engine; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 「新增订单/新增客户」通用执行端到端测试:验证模型驱动的校验、风控、超卖防护、权限、跨聚合一致性。 + */ +@SpringBootTest +@AutoConfigureMockMvc +class OrderSceneTest { + + @Autowired + MockMvc mvc; + + @Autowired + JdbcTemplate jdbc; + + @Autowired + ObjectMapper json; + + /** 建一单并返回 orderId(用于 modify/cancel 场景测试)。 */ + private String createOrder(String productId, int qty) throws Exception { + String body = (""" + {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"}, + "details":{"OrderItem":[{"productId":"%s","quantity":%d,"itemPrice":100,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"月结","dueDays":30}], + "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"旧地址", + "receiverName":"李四","receiverPhone":"13800002222"}]}} + """).formatted(productId, qty); + String resp = mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(jsonPath("$.success").value(true)).andReturn().getResponse().getContentAsString(); + return json.readTree(resp).get("rootId").asText(); + } + + @Test + void modifyOrderAddress_updatesChildEntityRow() throws Exception { + String oid = createOrder("P003", 1); + String mod = (""" + {"master":{"orderId":"%s"}, + "details":{"DeliveryAddressEntity":[{"province":"北京","city":"北京","district":"海淀", + "streetDetail":"新地址999号","receiverName":"李四","receiverPhone":"13900009999"}]}} + """).formatted(oid); + mvc.perform(post("/api/scene/SCENE_MODIFY_ORDER_ADDR/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(mod)) + .andExpect(jsonPath("$.success").value(true)); + String street = jdbc.queryForObject( + "SELECT street FROM t_order_address WHERE order_id = ?", String.class, oid); + assertThat(street).isEqualTo("新地址999号"); + } + + @Test + void cancelOrder_returnsStockViaGenericStockReturn() throws Exception { + String oid = createOrder("P001", 3); + Long afterCreate = jdbc.queryForObject( + "SELECT stock_num FROM t_product_main WHERE product_id = 'P001'", Long.class); + mvc.perform(post("/api/scene/SCENE_CANCEL_ORDER/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content("{\"master\":{\"orderId\":\"" + oid + "\"}}")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.stockActions[0].op").value("increment")); + Long afterCancel = jdbc.queryForObject( + "SELECT stock_num FROM t_product_main WHERE product_id = 'P001'", Long.class); + assertThat(afterCancel).isEqualTo(afterCreate + 3); + } + + @Test + void createProduct_asAdmin_persists_normalUserDenied() throws Exception { + String body = """ + {"master":{"productName":"测试键帽","skuCode":"SKU-KC-999","category":"配件", + "saleCurrency":"CNY","salePrice":99,"stockNum":10,"isOnSale":true}} + """; + String resp = mvc.perform(post("/api/scene/SCENE_CREATE_PRODUCT/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.rootIdField").value("productId")).andReturn().getResponse().getContentAsString(); + String pid = json.readTree(resp).get("rootId").asText(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM t_product_main WHERE product_id = ?", Long.class, pid)) + .isEqualTo(1L); + // 普通用户无 product_create 权限 → 拒绝 + mvc.perform(post("/api/scene/SCENE_CREATE_PRODUCT/execute").header("X-Principal", "normal_user") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("PERMISSION")); + } + + @Test + void createProduct_negativePrice_isRejectedByConstraint() throws Exception { + String body = """ + {"master":{"productName":"坏价商品","skuCode":"SKU-BAD-1","category":"x", + "saleCurrency":"CNY","salePrice":-5,"stockNum":10,"isOnSale":true}} + """; + mvc.perform(post("/api/scene/SCENE_CREATE_PRODUCT/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("VALIDATION")); + } + + @Test + void createProduct_duplicateSku_isRejectedByUnique() throws Exception { + // 种子商品 P001 的 skuCode = SKU-NB-001,再建同 sku 应被唯一性约束拦截 + String body = """ + {"master":{"productName":"重复SKU","skuCode":"SKU-NB-001","category":"x", + "saleCurrency":"CNY","salePrice":10,"stockNum":1,"isOnSale":true}} + """; + mvc.perform(post("/api/scene/SCENE_CREATE_PRODUCT/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("VALIDATION")); + } + + @Test + void modifyProductPrice_updatesRoot() throws Exception { + mvc.perform(post("/api/scene/SCENE_MODIFY_PRODUCT_PRICE/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"master\":{\"productId\":\"P003\",\"salePrice\":333,\"saleCurrency\":\"CNY\"}}")) + .andExpect(jsonPath("$.success").value(true)); + assertThat(jdbc.queryForObject("SELECT sale_price FROM t_product_main WHERE product_id='P003'", Double.class)) + .isEqualTo(333.0); + } + + @Test + void modifyCustomer_updatesRoot() throws Exception { + mvc.perform(post("/api/scene/SCENE_MODIFY_CUSTOMER/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"master\":{\"customerId\":\"C003\",\"customerLevel\":\"普通\"}}")) + .andExpect(jsonPath("$.success").value(true)); + assertThat(jdbc.queryForObject("SELECT cust_level FROM t_customer_main WHERE customer_id='C003'", String.class)) + .isEqualTo("普通"); + } + + @Test + void auditOrder_highAmountPass_updatesOrderAndBumpsAuditorApproveCountAndRemark() throws Exception { + // 金额 2*100=200 (>100) + PASS:订单置 PASS、REMOTE_QUERY 回填审核人名; + // IF(root.totalAmount>100) → REMOTE_SET_FIELD 覆盖员工备注;IF(PASS) → REMOTE_INCREMENT_FIELD 通过数 +1。 + String oid = createOrder("P003", 2); + String body = (""" + {"master":{"orderId":"%s","auditorId":"E001","auditResult":"PASS","auditRemark":"资料齐全,通过"}} + """).formatted(oid); + mvc.perform(post("/api/scene/SCENE_AUDIT_ORDER/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.command").value("AuditOrder")); + // 订单侧 + assertThat(jdbc.queryForObject("SELECT order_status FROM t_order_main WHERE order_id=?", String.class, oid)) + .isEqualTo("PASS"); + assertThat(jdbc.queryForObject("SELECT auditor_name FROM t_order_main WHERE order_id=?", String.class, oid)) + .isEqualTo("审核员小王"); // REMOTE_QUERY_SET_FIELD 回填 + // 审核人(员工)侧:条件回写 + assertThat(jdbc.queryForObject("SELECT approve_count FROM t_employee_main WHERE employee_id='E001'", Long.class)) + .isEqualTo(1L); + assertThat(jdbc.queryForObject("SELECT remark FROM t_employee_main WHERE employee_id='E001'", String.class)) + .isEqualTo("资料齐全,通过"); + assertThat(jdbc.queryForObject("SELECT last_audit_time FROM t_employee_main WHERE employee_id='E001'", Object.class)) + .isNotNull(); + } + + @Test + void auditOrder_lowAmountReject_skipsRemarkAndBumpsRejectCount() throws Exception { + // 金额 1*100=100 (不 >100) + REJECT:IF(root.totalAmount>100) 为假 → 不写员工备注;IF(REJECT) → 驳回数 +1。 + String oid = createOrder("P001", 1); + String body = (""" + {"master":{"orderId":"%s","auditorId":"E002","auditResult":"REJECT","auditRemark":"信息不全"}} + """).formatted(oid); + mvc.perform(post("/api/scene/SCENE_AUDIT_ORDER/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(jsonPath("$.success").value(true)); + assertThat(jdbc.queryForObject("SELECT reject_count FROM t_employee_main WHERE employee_id='E002'", Long.class)) + .isEqualTo(1L); + // 金额不足,备注分支未执行,员工备注保持为空 + assertThat(jdbc.queryForObject("SELECT remark FROM t_employee_main WHERE employee_id='E002'", String.class)) + .isNull(); + } + + @Test + void createEmployee_initsCountsToZero_thenModify_normalUserDenied() throws Exception { + // CreateEmployee 的 bizSteps 用 SET_FIELD 把两个审核计数初始化为 0 + String create = """ + {"master":{"employeeName":"新审核员小赵","department":"风控部"}} + """; + String resp = mvc.perform(post("/api/scene/SCENE_CREATE_EMPLOYEE/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(create)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.rootIdField").value("employeeId")).andReturn().getResponse().getContentAsString(); + String eid = json.readTree(resp).get("rootId").asText(); + assertThat(jdbc.queryForObject("SELECT approve_count FROM t_employee_main WHERE employee_id=?", Long.class, eid)) + .isEqualTo(0L); + assertThat(jdbc.queryForObject("SELECT reject_count FROM t_employee_main WHERE employee_id=?", Long.class, eid)) + .isEqualTo(0L); + // ModifyEmployee:改部门 + 备注 + String modify = (""" + {"master":{"employeeId":"%s","department":"合规部","remark":"转岗"}} + """).formatted(eid); + mvc.perform(post("/api/scene/SCENE_MODIFY_EMPLOYEE/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(modify)) + .andExpect(jsonPath("$.success").value(true)); + assertThat(jdbc.queryForObject("SELECT dept FROM t_employee_main WHERE employee_id=?", String.class, eid)) + .isEqualTo("合规部"); + assertThat(jdbc.queryForObject("SELECT remark FROM t_employee_main WHERE employee_id=?", String.class, eid)) + .isEqualTo("转岗"); + // 普通用户无 employee_create 权限 → 拒绝 + mvc.perform(post("/api/scene/SCENE_CREATE_EMPLOYEE/execute").header("X-Principal", "normal_user") + .contentType(MediaType.APPLICATION_JSON).content(create)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("PERMISSION")); + } + + @Test + void demoStepTypes_noTemplate_interpretsAllStepTypes() throws Exception { + // 无表单场景:空 payload 直接解释 flowSteps,验证控制流/Saga/EDA/人工各类步骤通用解释成功(不落库) + mvc.perform(post("/api/scene/SCENE_DEMO_STEPTYPES/execute").header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + void auditScene_schema_rendersAuditorAsEmployeeReferenceDropdown() throws Exception { + // 通用引用下拉:auditorId 带 M1 refAggregate=EmployeeAggregate,合成 Schema 应给出员工选项数据源 + mvc.perform(get("/api/meta/scene/SCENE_AUDIT_ORDER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.template.templateId").value("template_order_audit")) + .andExpect(jsonPath("$.template.rootComponents[0].childComponents[1].fieldBind.optionsSource.aggregateId") + .value("EmployeeAggregate")); + } + + @Test + void sensitivePhone_isEncryptedAtRest() { + // M5 storageEncrypt 生效:物理列存密文(enc: 前缀),而非明文手机号 + String raw = jdbc.queryForObject("SELECT phone FROM t_customer_main WHERE customer_id = 'C001'", String.class); + assertThat(raw).startsWith("enc:"); + assertThat(raw).doesNotContain("13800000001"); + } + + private static final String VALID_ORDER = """ + {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"}, + "details":{"OrderItem":[{"productId":"P003","quantity":1,"itemPrice":499,"itemCurrency":"CNY"}, + {"productId":"P001","quantity":1,"itemPrice":6999,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"月结","dueDays":30}], + "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"科技园1号", + "receiverName":"李四","receiverPhone":"13800002222"}]}} + """; + + // 普通用户 + 明细金额 8*6999=55992 > 50000(服务端重算),应被 MetaRule REJECT + private static final String OVER_LIMIT_ORDER = """ + {"master":{"customerId":"C001","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"}, + "details":{"OrderItem":[{"productId":"P001","quantity":8,"itemPrice":6999,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"现金","dueDays":0}], + "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"x", + "receiverName":"张三","receiverPhone":"13800001111"}]}} + """; + + private static final String BAD_PHONE_ORDER = """ + {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"}, + "details":{"OrderItem":[{"productId":"P003","quantity":1,"itemPrice":499,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"现金","dueDays":0}], + "DeliveryAddressEntity":[{"province":"x","city":"y","district":"z","streetDetail":"w", + "receiverName":"赵六","receiverPhone":"139"}]}} + """; + + // P002 库存 8,下单 100 件 → 跨聚合扣减失败 → 整单回滚(防超卖) + private static final String OVERSELL_ORDER = """ + {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"}, + "details":{"OrderItem":[{"productId":"P002","quantity":100,"itemPrice":199,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"现金","dueDays":0}], + "DeliveryAddressEntity":[{"province":"x","city":"y","district":"z","streetDetail":"w", + "receiverName":"钱七","receiverPhone":"13800007777"}]}} + """; + + // 负数数量:M1 constraints.min=1 应拒绝 + private static final String NEGATIVE_QTY_ORDER = """ + {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"}, + "details":{"OrderItem":[{"productId":"P003","quantity":-5,"itemPrice":499,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"现金","dueDays":0}], + "DeliveryAddressEntity":[{"province":"x","city":"y","district":"z","streetDetail":"w", + "receiverName":"钱七","receiverPhone":"13800007777"}]}} + """; + + private static final String NEW_CUSTOMER = """ + {"master":{"customerName":"测试客户","contactPhone":"13900000000","customerLevel":"普通"}} + """; + + @Test + void sceneSchema_isComposedFromModel() throws Exception { + mvc.perform(get("/api/meta/scene/SCENE_CREATE_ORDER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.template.templateType").value("master_detail_form")) + .andExpect(jsonPath("$.commandApi").value("/api/v1/order/create")) + .andExpect(jsonPath("$.template.rootComponents[0].compId").value("card_order_master")); + } + + @Test + void createOrder_valid_succeedsWithGenericConsistency() throws Exception { + mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute") + .contentType(MediaType.APPLICATION_JSON).content(VALID_ORDER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.rootId").isNotEmpty()) + .andExpect(jsonPath("$.rootIdField").value("orderId")) + .andExpect(jsonPath("$.childCount").value(4)) + .andExpect(jsonPath("$.stockActions[0].status").value("OK")) + .andExpect(jsonPath("$.stockActions[0].targetCommand").value("StockDeduct")); + } + + @Test + void createOrder_overLimitNormalUser_isRejectedByMetaRule() throws Exception { + mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute") + .contentType(MediaType.APPLICATION_JSON).content(OVER_LIMIT_ORDER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("RULE_REJECT")); + } + + @Test + void createOrder_badPhone_failsConstraintValidation() throws Exception { + mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute") + .contentType(MediaType.APPLICATION_JSON).content(BAD_PHONE_ORDER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("VALIDATION")); + } + + @Test + void createOrder_insufficientStock_rollsBackNoOversell() throws Exception { + mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute") + .contentType(MediaType.APPLICATION_JSON).content(OVERSELL_ORDER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("CONSISTENCY_FAILED")); + } + + @Test + void createOrder_negativeQuantity_isRejected() throws Exception { + mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute") + .contentType(MediaType.APPLICATION_JSON).content(NEGATIVE_QTY_ORDER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("VALIDATION")); + } + + @Test + void precheck_overLimit_flagsReject() throws Exception { + mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/precheck") + .contentType(MediaType.APPLICATION_JSON).content(OVER_LIMIT_ORDER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reject").value(true)) + .andExpect(jsonPath("$.rejectRules[0].ruleId").value("R_ORDER_AMOUNT_LIMIT")); + } + + @Test + void createCustomer_requiresAdminPrincipal() throws Exception { + // 管理员:通过 + mvc.perform(post("/api/scene/SCENE_CREATE_CUSTOMER/execute") + .header("X-Principal", "backend_admin") + .contentType(MediaType.APPLICATION_JSON).content(NEW_CUSTOMER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.rootIdField").value("customerId")); + // 普通用户:无权 → PERMISSION + mvc.perform(post("/api/scene/SCENE_CREATE_CUSTOMER/execute") + .header("X-Principal", "normal_user") + .contentType(MediaType.APPLICATION_JSON).content(NEW_CUSTOMER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.stage").value("PERMISSION")); + } +} diff --git b/models/M0-meta-schema.yaml a/models/M0-meta-schema.yaml new file mode 100644 index 0000000..ac01649 --- /dev/null +++ a/models/M0-meta-schema.yaml @@ -0,0 +1,509 @@ +# M0 全域元数据校验根规范 (v2.0) +# 全域本体校验根规范,新增React前端模板JSON Schema约束;统一前后端数据、组件、页面描述标准。 +meta_model: + version: "2.0" + $schema: https://json-schema.org/draft/2020-12/schema + definitions: + # 基础通用标识类型 + Identifier: + type: string + pattern: "^[a-z][a-zA-Z0-9]*$" + description: 小写驼峰字段/组件标识 + UpperIdentifier: + type: string + pattern: "^[A-Z][a-zA-Z0-9]*$" + description: 大写驼峰聚合/命令/事件ID + NonEmptyString: + type: string + minLength: 1 + SceneId: + type: string + pattern: "^SCENE_[A-Z0-9_]+$" + RuleId: + type: string + pattern: "^R_[A-Z0-9_]+$" + TopicName: + type: string + pattern: "^topic_[a-z0-9_]+$" + ConsistencyTypeEnum: + type: string + enum: [strong, eventual] + DbEngineEnum: + type: string + enum: [mysql8] + AuthModeEnum: + type: string + enum: [oauth2_jwt] + + # ========== M8前端专用枚举(新增) ========== + FrontTemplateTypeEnum: + type: string + enum: [single_table_form, master_detail_form, custom_drag_form] + description: single_table_form单实体表单;master_detail_form主从表单;custom_drag_form自定义空白拖拽模板 + FrontComponentEnum: + type: string + enum: [input, number_input, date_picker, switch, table, card, text_area, select] + description: React底层原子拖拽组件 + BindSourceTypeEnum: + type: string + enum: [aggregate_root, aggregate_entity] + description: 组件绑定数据源类型:聚合根/聚合内部子实体 + ReactRenderModeEnum: + type: string + enum: [form_modal, page_container, drawer] + description: 页面渲染载体:弹窗/整页/侧边抽屉 + + # M1 领域模型单元定义 + M1_Attribute: + type: object + required: [name, type] + properties: + name: { $ref: "#/definitions/Identifier" } + type: + type: string + enum: [string, int, decimal, datetime, boolean] + refAggregate: { type: string } + refRoot: { type: string } + refIdentifier: { type: string } + description: { type: string } + M1_Relation: + type: object + required: [ref, relationType] + properties: + ref: { $ref: "#/definitions/NonEmptyString" } + relationType: + type: string + enum: [composition] + M1_Entity: + type: object + required: [name, localIdentifier, attributes] + properties: + name: { $ref: "#/definitions/NonEmptyString" } + localIdentifier: { $ref: "#/definitions/Identifier" } + attributes: + type: array + items: { $ref: "#/definitions/M1_Attribute" } + M1_AggregateRoot: + type: object + required: [name, identifier, attributes] + properties: + name: { $ref: "#/definitions/NonEmptyString" } + identifier: { $ref: "#/definitions/Identifier" } + attributes: + type: array + items: { $ref: "#/definitions/M1_Attribute" } + relations: + type: array + items: { $ref: "#/definitions/M1_Relation" } + M1_Aggregate: + type: object + required: [aggregateId, description, aggregateRoot] + properties: + aggregateId: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + description: { $ref: "#/definitions/NonEmptyString" } + aggregateRoot: { $ref: "#/definitions/M1_AggregateRoot" } + entities: + type: array + items: { $ref: "#/definitions/M1_Entity" } + valueObjects: + type: array + maxItems: 0 + + # M2 命令行为单元定义 + M2_InputParam: + type: object + required: [name, type] + properties: + name: { $ref: "#/definitions/Identifier" } + type: { type: string } + optional: { type: boolean } + # bizSteps 单步:一段“闭指令集”程序的一条。CommandEngine 逐条通用解释执行。 + # ★ 闭集合的意义:AI 转换命令行为时 type 只能取下列枚举之一——越界值在模型校验层即被拒, + # 引擎面被封顶(仅需实现这些 opcode),AI 产物可静态校验。 + M2_BizStep: + type: object + required: [instruction] + properties: + stepNo: { type: integer } + desc: { type: string } + instruction: + type: object + required: [type] + properties: + type: + type: string + # 数据/落库 opcode + 控制流/跨聚合写回 opcode(IF 携带 then/else 嵌套指令,类比 M4 condition, + # 使命令级 bizSteps 也具备判断分支能力;REMOTE_SET_FIELD/REMOTE_INCREMENT_FIELD 通用回写被引用聚合字段) + enum: [LOAD_AGGREGATE, SET_FIELD, AUTO_FILL_FIELD, REMOTE_QUERY_SET_FIELD, + CONDITION_SET_FIELD, CALL_OTHER_COMMAND, COMMIT_TRANSACTION, + IF, REMOTE_SET_FIELD, REMOTE_INCREMENT_FIELD] + params: { type: object } + M2_Command: + type: object + required: [cmdId, desc, inputParams, validations, emitEvents] + properties: + cmdId: { $ref: "#/definitions/UpperIdentifier" } + desc: { $ref: "#/definitions/NonEmptyString" } + inputParams: + type: array + items: { $ref: "#/definitions/M2_InputParam" } + validations: + type: array + items: { type: string } + # 命令行为:闭指令集程序(首选);或旧的自然语言描述字符串(向后兼容,引擎按 commandType 合成默认程序) + bizSteps: + oneOf: + - type: string + - type: array + items: { $ref: "#/definitions/M2_BizStep" } + emitEvents: + type: array + items: { $ref: "#/definitions/UpperIdentifier" } + sideEffect: { type: string } + M2_AggregateBehavior: + type: object + required: [aggregateId, aggregateRoot, commands] + properties: + aggregateId: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + aggregateRoot: { $ref: "#/definitions/NonEmptyString" } + commands: + type: array + items: { $ref: "#/definitions/M2_Command" } + + # ME 事件模型单元定义 + ME_Event: + type: object + required: [topic, payload] + properties: + topic: { $ref: "#/definitions/TopicName" } + payload: + type: array + items: { $ref: "#/definitions/Identifier" } + consumers: + type: array + items: { type: string } + ME_OutboxConfig: + type: object + required: [enabled, table] + properties: + enabled: { type: boolean } + table: { $ref: "#/definitions/NonEmptyString" } + pollInterval: { type: string } + batchSize: { type: integer, minimum: 1 } + cleanupDays: { type: integer, minimum: 1 } + ME_CrossConsistencyRule: + type: object + required: [triggerEvent, targetAggregate, targetCommand, consistencyType] + properties: + triggerEvent: { $ref: "#/definitions/UpperIdentifier" } + targetAggregate: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + targetCommand: { $ref: "#/definitions/UpperIdentifier" } + consistencyType: { $ref: "#/definitions/ConsistencyTypeEnum" } + + # M3 存储部署映射单元定义 + M3_TableMapping: + type: object + required: [domainEntity, table, pk, fieldMap] + properties: + domainEntity: { $ref: "#/definitions/NonEmptyString" } + table: { $ref: "#/definitions/NonEmptyString" } + pk: { $ref: "#/definitions/Identifier" } + shardRule: { type: string } + fieldMap: + type: object + additionalProperties: { type: string } + M3_AggregateDeploy: + type: object + required: [aggregateId, serviceName, commandApi, tableMappings] + properties: + aggregateId: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + serviceName: + type: string + pattern: "^[a-z-]+-service$" + commandApi: + type: object + additionalProperties: + type: string + pattern: "^/api/v1/.+" + tableMappings: + type: array + items: { $ref: "#/definitions/M3_TableMapping" } + + # M4 业务场景编排单元定义(Saga 编排步骤) + # stepType 全集按职责分组;引擎(CommandEngine)对每种类型做"模型驱动的通用解释", + # 不含任何具体聚合/字段字面量。所有非 stepType 的属性均为可选,按步骤类型取用。 + M4_SceneStep: + type: object + required: [stepType] + properties: + stepType: + type: string + enum: + # —— 起止 —— + - start # 显式起点(可标注触发方式) + - return # 正常结束并返回结果 + - end # 正常结束(return 的别名) + - errorEnd # 异常结束,抛出 message + # —— 数据/校验/派生 —— + - readOnlyCheck # 校验 bindAggregate 引用存在 + - validate # 独立执行 M8 必填 + M1 类型/约束校验 + - derive # 独立执行 M2 derivations 服务端重算 + - assign # 按表达式给字段赋值 + - transform # 数据转换(assign 的语义别名) + - script # 求值一段表达式,可写回字段 + # —— 命令/事件(EDA) —— + - bindCommand # 绑定并执行某聚合的 M2 命令 + - callScene # 调用子场景(嵌套编排/复用) + - callService # 调用外部系统/API + - emitEvent # 显式发布领域事件 + - waitEvent # 等待某外部事件到达 + # —— 控制流 —— + - condition # 排他分支(if/else) + - switch # 多路分支 + - parallel # 并行网关(多分支汇合) + - while # 条件循环 + - loopItems # 遍历某子实体集合(foreach) + # —— Saga/可靠性 —— + - retry # 带重试的子步骤 + - compensate # 逆序执行已登记的补偿 + - timer # 定时/延时/超时 + - userTask # 人工任务/审批网关 + # 命令绑定(bindCommand) + bindAggregate: { type: string } + bindCommand: { $ref: "#/definitions/UpperIdentifier" } + # Saga 补偿:bindCommand 成功后登记的逆向命令,失败时逆序触发 + compensateAggregate: { type: string } + compensateCommand: { $ref: "#/definitions/UpperIdentifier" } + # 控制流:条件/循环谓词、switch 分派表达式与分支 + when: { type: string } # condition/while 的布尔表达式 + switchOn: { type: string } # switch 的分派表达式(避开 YAML 保留字 on/off) + then: # condition 为真的子步骤 + type: array + items: { $ref: "#/definitions/M4_SceneStep" } + else: # condition 为假的子步骤 + type: array + items: { $ref: "#/definitions/M4_SceneStep" } + body: # while/loopItems/retry 的子步骤 + type: array + items: { $ref: "#/definitions/M4_SceneStep" } + cases: # switch 分支列表 + type: array + items: + type: object + properties: + equals: { type: string } + steps: + type: array + items: { $ref: "#/definitions/M4_SceneStep" } + default: # switch 兜底子步骤 + type: array + items: { $ref: "#/definitions/M4_SceneStep" } + branches: # parallel 并行分支 + type: array + items: + type: object + properties: + steps: + type: array + items: { $ref: "#/definitions/M4_SceneStep" } + itemsFrom: { type: string } # loopItems 遍历的子实体名 + maxIterations: { type: integer }# while/loopItems 迭代上限(防失控) + maxAttempts: { type: integer } # retry 最大尝试次数 + # 数据步骤:assign/transform/script + assignments: # assign/transform 的赋值列表 + type: array + items: + type: object + required: [targetField, expression] + properties: + targetField: { type: string } + expression: { type: string } + targetField: { type: string } # assign 单字段简写 + expression: { type: string } # assign 简写/script 表达式 + assignTo: { type: string } # script 结果写回字段 + # 事件步骤:emitEvent/waitEvent/timer + emitEvent: { $ref: "#/definitions/UpperIdentifier" } + waitEvent: { $ref: "#/definitions/UpperIdentifier" } + timeoutMs: { type: integer } + delayMs: { type: integer } + # 人工任务:userTask + assignee: { type: string } + role: { type: string } + approveParam: { type: string } # 入参中表示"已审批"的字段名(true 放行) + # 外部调用:callService / 子场景:callScene + callScene: { $ref: "#/definitions/SceneId" } + serviceRef: { type: string } + endpoint: { type: string } + method: { type: string } + # 通用:起点触发方式 / 异常结束消息 + triggerType: { type: string } + message: { type: string } + M4_Scene: + type: object + required: [sceneId, sceneName, aggregateScope, permissionBind, flowSteps] + properties: + sceneId: { $ref: "#/definitions/SceneId" } + sceneName: { $ref: "#/definitions/NonEmptyString" } + aggregateScope: + type: array + items: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + permissionBind: + type: array + items: { $ref: "#/definitions/Identifier" } + flowSteps: + type: array + items: { $ref: "#/definitions/M4_SceneStep" } + + # M5 安全权限单元定义 + M5_SecurityRoot: + type: object + required: [globalConfig, principals, functionPermissions, maskRules, encryptRules] + # M6 监控告警单元定义 + M6_Alert: + type: object + required: [desc, level, trigger] + properties: + desc: { type: string } + level: + type: string + enum: [严重告警, 警告告警] + trigger: { type: string } + M6_MonitorRoot: + type: object + required: [globalConfig, serviceMetricBind, alertRules] + # M7 流量SLA单元定义 + M7_QualityRoot: + type: object + required: [aggregateSla, commandSla, sceneSla, flowControl] + # MetaRule 动态业务规则单元定义 + MetaRule_Param: + type: object + required: [paramId, name, dataType, defaultValue] + MetaRule_Item: + type: object + required: [ruleId, ruleVersion, ruleName, matchCondition, effect, message] + properties: + ruleId: { $ref: "#/definitions/RuleId" } + effect: + type: string + enum: [REJECT, ALERT, SKIP_CHECK, CALC_FILL, COMPENSATE] + MetaRule_Group: + type: object + required: [groupId, groupType, bindScene, rules] + + # ===================== M8 前端拖拽模板完整定义(新增核心) ===================== + M8_FrontFieldBind: + type: object + required: [bindAggregateId, bindSourceType, domainFieldName] + properties: + bindAggregateId: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + bindSourceType: { $ref: "#/definitions/BindSourceTypeEnum" } + domainEntityName: { type: string } + domainFieldName: { $ref: "#/definitions/Identifier" } + formLabel: { type: string } + placeholder: { type: string } + required: { type: boolean } + maskField: { type: boolean } + M8_FrontDragComponent: + type: object + required: [compId, compType, fieldBind] + properties: + compId: { $ref: "#/definitions/Identifier" } + compType: { $ref: "#/definitions/FrontComponentEnum" } + width: { type: string } + span: { type: integer } + fieldBind: { $ref: "#/definitions/M8_FrontFieldBind" } + childComponents: + type: array + items: { $ref: "#/definitions/M8_FrontDragComponent" } + M8_FrontPageTemplate: + type: object + required: [templateId, templateName, templateType, renderMode, bindSceneId, rootComponents] + properties: + templateId: { $ref: "#/definitions/Identifier" } + templateName: { $ref: "#/definitions/NonEmptyString" } + templateType: { $ref: "#/definitions/FrontTemplateTypeEnum" } + renderMode: { $ref: "#/definitions/ReactRenderModeEnum" } + bindSceneId: { $ref: "#/definitions/SceneId" } + masterAggregate: { type: string } + detailAggregates: + type: array + items: { type: string } + rootComponents: + type: array + items: { $ref: "#/definitions/M8_FrontDragComponent" } + M8_FrontSchema: + type: object + required: [reactGlobalConfig, pageTemplates] + properties: + reactGlobalConfig: + type: object + required: [uiLib, dragEngineStorage] + properties: + uiLib: + type: string + const: antd + dragEngineStorage: + type: string + const: mysql8 + pageTemplates: + type: array + items: { $ref: "#/definitions/M8_FrontPageTemplate" } + + # 各分层根Schema定义(新增M8根校验) + M1_Schema: + type: object + required: [aggregates, globalConstraints] + M2_Schema: + type: object + required: [behaviors, globalBehaviorConstraints] + ME_Schema: + type: object + required: [eventModel] + M3_Schema: + type: object + required: [deploymentMappings] + M4_Schema: + type: object + required: [sceneModel] + M5_Schema: + type: object + required: [securityModel] + M6_Schema: + type: object + required: [monitorModel] + M7_Schema: + type: object + required: [qualityModel] + MetaRule_Schema: + type: object + required: [metaRuleModel] + M8_Schema: + type: object + required: [frontModel] + + rootAllOntologySchema: + type: object + oneOf: + - { $ref: "#/definitions/M1_Schema" } + - { $ref: "#/definitions/M2_Schema" } + - { $ref: "#/definitions/ME_Schema" } + - { $ref: "#/definitions/M3_Schema" } + - { $ref: "#/definitions/M4_Schema" } + - { $ref: "#/definitions/M5_Schema" } + - { $ref: "#/definitions/M6_Schema" } + - { $ref: "#/definitions/M7_Schema" } + - { $ref: "#/definitions/MetaRule_Schema" } + - { $ref: "#/definitions/M8_Schema" } diff --git b/models/M1-domain.yaml a/models/M1-domain.yaml new file mode 100644 index 0000000..1169d78 --- /dev/null +++ a/models/M1-domain.yaml @@ -0,0 +1,191 @@ +# M1 DDD聚合领域静态模型 (v2.2:EmployeeAggregate 增审核统计字段 auditApproveCount/auditRejectCount/remark/lastAuditTime;订单审核字段见 Order) +# 领域聚合唯一数据源:前端所有拖拽组件强制绑定本层字段,禁止自定义字段。 +aggregates: + # 聚合1:客户聚合 CustomerAggregate + - aggregateId: CustomerAggregate + description: 客户主数据聚合,独立事务边界 + aggregateRoot: + name: Customer + identifier: customerId + attributes: + - name: customerName + type: string + - name: contactPhone + type: string + constraints: + pattern: "^\\d{11}$" + patternMessage: 手机号需为 11 位数字 + - name: registerTime + type: datetime + - name: customerLevel + type: string + entities: [] + valueObjects: [] + + # 聚合2:产品聚合 ProductAggregate + - aggregateId: ProductAggregate + description: 商品主数据聚合,独立事务边界 + aggregateRoot: + name: Product + identifier: productId + attributes: + - name: productName + type: string + - name: skuCode + type: string + constraints: + unique: true + - name: category + type: string + - name: saleCurrency + type: string + - name: salePrice + type: decimal + constraints: + exclusiveMin: 0 + - name: stockNum + type: int + constraints: + min: 0 + - name: isOnSale + type: boolean + entities: [] + valueObjects: [] + + # 聚合3:订单聚合 OrderAggregate + - aggregateId: OrderAggregate + description: 订单业务聚合,独立事务边界 + aggregateRoot: + name: Order + identifier: orderId + attributes: + - name: createTime + type: datetime + - name: totalCurrency + type: string + - name: totalAmount + type: decimal + - name: customerId + type: string + refAggregate: CustomerAggregate + refRoot: Customer + refIdentifier: customerId + description: 客户ID,引用客户聚合根主键 + # 审核相关字段:由 AuditOrder 命令的 bizSteps 指令程序通用填充(非订单专用引擎代码) + - name: orderStatus + type: string + description: 订单状态(如 CREATED/PASS/REJECT),AuditOrder 按审核结果置位 + - name: auditorId + type: string + refAggregate: EmployeeAggregate + refRoot: Employee + refIdentifier: employeeId + description: 审核人工号,引用员工聚合根主键 + - name: auditorName + type: string + - name: auditTime + type: datetime + - name: auditRemark + type: string + - name: auditResult + type: string + description: 审核结论(PASS/REJECT)——AuditOrder 命令入参,仅用于 CONDITION_SET_FIELD 派生 orderStatus;未在 M3 建列,故为不落库的瞬态字段 + relations: + - ref: OrderItem + relationType: composition + - ref: PaymentTerm + relationType: composition + - ref: DeliveryAddressEntity + relationType: composition + entities: + - name: OrderItem + localIdentifier: itemId + attributes: + - name: productId + type: string + refAggregate: ProductAggregate + refRoot: Product + refIdentifier: productId + description: 商品ID,引用产品聚合根主键 + - name: quantity + type: int + constraints: + min: 1 + - name: itemCurrency + type: string + - name: itemPrice + type: decimal + - name: PaymentTerm + localIdentifier: termId + attributes: + - name: paymentType + type: string + - name: dueDays + type: int + constraints: + min: 0 + - name: depositRatio + type: decimal + constraints: + min: 0 + max: 1 + - name: settleCurrency + type: string + - name: settleAmount + type: decimal + - name: DeliveryAddressEntity + localIdentifier: addrId + attributes: + - name: province + type: string + - name: city + type: string + - name: district + type: string + - name: streetDetail + type: string + - name: receiverName + type: string + - name: receiverPhone + type: string + constraints: + pattern: "^\\d{11}$" + patternMessage: 收货手机号需为 11 位数字 + valueObjects: [] + + # 聚合4:员工聚合 EmployeeAggregate(供 AuditOrder 的 REMOTE_QUERY_SET_FIELD 查审核人姓名; + # 并承载审核统计——AuditOrder 的 bizSteps 条件程序按审核结果通用地回写这些字段) + - aggregateId: EmployeeAggregate + description: 员工主数据聚合,独立事务边界 + aggregateRoot: + name: Employee + identifier: employeeId + attributes: + - name: employeeName + type: string + - name: department + type: string + - name: auditApproveCount + type: int + constraints: + min: 0 + description: 累计审核通过数——AuditOrder 命中 PASS 时 REMOTE_INCREMENT_FIELD +1 + - name: auditRejectCount + type: int + constraints: + min: 0 + description: 累计审核驳回数——AuditOrder 命中 REJECT 时 REMOTE_INCREMENT_FIELD +1 + - name: remark + type: string + description: 备注——AuditOrder 在订单金额>100 时以 REMOTE_SET_FIELD 覆盖写入审核备注 + - name: lastAuditTime + type: datetime + description: 上次审核时间——AuditOrder 每次审核以 REMOTE_SET_FIELD 覆盖为本次审核时间 + entities: [] + valueObjects: [] + +globalConstraints: + - crossAggRefRule: 跨聚合仅允许引用对方【聚合根唯一标识】,禁止引用对方内部实体、值对象 + - transactionBoundary: 一次事务只能修改同一个聚合内对象,跨聚合拆分为多个事务 + - cascadeDelete: 聚合根删除时,级联删除自身内部所有组合子实体 + - foreignKeyStrategy: "业务层做引用校验,默认不创建数据库物理外键;如需物理外键增加 dbForeignKey: true" diff --git b/models/M2-command.yaml a/models/M2-command.yaml new file mode 100644 index 0000000..2c86d76 --- /dev/null +++ a/models/M2-command.yaml @@ -0,0 +1,451 @@ +# M2 聚合行为命令模型 (v2.2:新增 Employee 的 CreateEmployee/ModifyEmployee;AuditOrder 的 bizSteps 用 IF 判断分支 + +# REMOTE_SET_FIELD/REMOTE_INCREMENT_FIELD 条件回写审核人统计;修正 totalAmount 派生为 quantity*itemPrice) +# 聚合操作命令入参定义;React页面提交时自动映射表单组件值至命令参数,无硬编码接口参数。 +behaviors: + # 1.客户聚合行为 + - aggregateId: CustomerAggregate + aggregateRoot: Customer + commands: + - cmdId: CreateCustomer + desc: 新增客户主数据 + commandType: create + inputParams: + - name: customerName + type: string + - name: contactPhone + type: string + - name: customerLevel + type: string + validations: + - contactPhone非空、手机号格式校验 + - customerLevel取值范围[普通,VIP,高级VIP] + emitEvents: + - CustomerCreated + sideEffect: 仅写入本聚合,不可修改其他聚合 + + - cmdId: ModifyCustomerInfo + desc: 修改客户名称、联系方式、等级 + commandType: update + inputParams: + - name: customerId + type: string + - name: customerName + type: string + optional: true + - name: contactPhone + type: string + optional: true + - name: customerLevel + type: string + optional: true + validations: + - customerId必须存在 + emitEvents: + - CustomerInfoModified + + # 2.产品聚合行为 + - aggregateId: ProductAggregate + aggregateRoot: Product + commands: + - cmdId: CreateProduct + desc: 新增商品档案 + commandType: create + inputParams: + - name: productName + type: string + - name: skuCode + type: string + - name: category + type: string + - name: saleCurrency + type: string + - name: salePrice + type: decimal + - name: stockNum + type: int + - name: isOnSale + type: boolean + validations: + - skuCode全局唯一 + - salePrice > 0 + - stockNum >= 0 + emitEvents: + - ProductCreated + + - cmdId: ModifyProductPrice + desc: 修改售价与币种 + commandType: update + inputParams: + - name: productId + type: string + - name: saleCurrency + type: string + optional: true + - name: salePrice + type: decimal + optional: true + validations: + - productId存在 + - 修改后售价>0 + emitEvents: + - ProductPriceChanged + + - cmdId: StockDeduct + desc: 下单扣减库存(订单聚合远程调用) + commandType: update + inputParams: + - name: productId + type: string + - name: deductQty + type: int + validations: + - 可用库存 >= deductQty + # 结构化副作用:引擎据此通用执行"按 keyParam 定位、对 targetField 做 op 运算、amountParam 为量、guardField 保证不越界" + effect: + op: decrement + targetField: stockNum + keyParam: productId + amountParam: deductQty + guardField: stockNum + emitEvents: + - ProductStockDeducted + + - cmdId: StockReturn + desc: 取消订单归还库存 + commandType: update + inputParams: + - name: productId + type: string + - name: returnQty + type: int + validations: [] + effect: + op: increment + targetField: stockNum + keyParam: productId + amountParam: returnQty + emitEvents: + - ProductStockReturned + + # 3.订单聚合行为 + - aggregateId: OrderAggregate + aggregateRoot: Order + commands: + - cmdId: CreateOrder + desc: 创建订单主单+明细+付款条件+送货地址 + commandType: create + # 结构化派生:落库/风控前,引擎按明细 sum(quantity*itemPrice) 服务端重算 totalAmount,杜绝客户端篡改 + derivations: + - targetField: totalAmount + sourceEntity: OrderItem + aggregate: sum + expression: "quantity * itemPrice" + inputParams: + - name: customerId + type: string + - name: totalCurrency + type: string + - name: totalAmount + type: decimal + - name: items + type: array + - name: paymentTerm + type: object + - name: deliveryAddress + type: object + validations: + - customerId 存在于CustomerAggregate + - 所有productId存在于ProductAggregate + - 订单明细金额累加 ≈ 订单总金额 + - dueDays >= 0, depositRatio ∈ [0,1] + - 收货手机号格式合法 + # bizSteps 现为“闭指令集”程序(CommandEngine 逐条通用解释,替代硬编码 create 分派)。 + # create 语义:COMMIT 落库聚合根 + 组合子实体(totalAmount 已由 derivations 服务端重算); + # 收尾发 emitEvents,ME 规则 OrderCreated→StockDeduct 逐明细扣库存,任一失败超卖回滚。 + bizSteps: + - stepNo: 1 + desc: 落库 Order 主记录及 OrderItem/PaymentTerm/DeliveryAddress 组合子实体 + instruction: + type: COMMIT_TRANSACTION + params: {} + emitEvents: + - OrderCreated + - OrderItemAdded + - OrderPaymentTermSet + - OrderDeliveryAddressSet + + - cmdId: ModifyOrderDeliveryAddress + desc: 修改订单送货地址子实体 + commandType: update + inputParams: + - name: orderId + type: string + - name: province + type: string + optional: true + - name: city + type: string + optional: true + - name: district + type: string + optional: true + - name: streetDetail + type: string + optional: true + - name: receiverName + type: string + optional: true + - name: receiverPhone + type: string + optional: true + validations: + - orderId存在 + bizSteps: + - stepNo: 1 + desc: 按 orderId 加载既有订单聚合根(不存在即失败) + instruction: + type: LOAD_AGGREGATE + params: + idSource: input.orderId + - stepNo: 2 + desc: 更新命令携带的根字段与组合子实体(送货地址)后提交事务 + instruction: + type: COMMIT_TRANSACTION + params: {} + emitEvents: + - OrderDeliveryAddressModified + + - cmdId: CancelOrder + desc: 取消订单,归还库存 + commandType: cancel + inputParams: + - name: orderId + type: string + validations: + - 订单状态允许取消 + # cancel 语义:LOAD 校验存在 + COMMIT(无字段变更);收尾发 OrderCancelled, + # ME 规则 OrderCancelled→StockReturn 从库加载明细逐项回补库存。 + bizSteps: + - stepNo: 1 + desc: 按 orderId 加载既有订单聚合根(不存在即失败) + instruction: + type: LOAD_AGGREGATE + params: + idSource: input.orderId + - stepNo: 2 + desc: 提交事务(标记作废;库存回补由 ME 一致性规则完成) + instruction: + type: COMMIT_TRANSACTION + params: {} + emitEvents: + - OrderCancelled + + - cmdId: AuditOrder + desc: 审核订单:填审核人/时间/备注,按审核结果置订单状态(bizSteps 闭指令集程序) + commandType: update + inputParams: + - name: orderId + type: string + - name: auditorId + type: string + - name: auditRemark + type: string + optional: true + - name: auditResult + type: string + validations: + - orderId 存在 + - auditResult ∈ [PASS, REJECT] + # ★ 展示“行为即数据”:这段程序由 CommandEngine 逐条通用解释,改步骤=改 YAML,无需改引擎。 + # Step1:根据orderId加载完整订单聚合根 + # Step2:系统当前时间自动赋值给auditTime + # Step3:前端传的auditorId赋值给订单的auditorId字段 + # Step4:用auditorId去员工服务查审核人姓名,存到auditorName + # Step5:把审核备注赋值给auditRemark + # Step6:审核通过就把订单状态改成PASS,驳回就改成REJECT + # Step7:统一提交数据库事务 + bizSteps: + - stepNo: 1 + desc: 根据 orderId 加载完整订单聚合根 + instruction: + type: LOAD_AGGREGATE + params: + idSource: input.orderId + - stepNo: 2 + desc: 系统当前时间自动赋值给 auditTime + instruction: + type: AUTO_FILL_FIELD + params: + targetField: auditTime + func: now_datetime + - stepNo: 3 + desc: 前端传的 auditorId 赋值给订单的 auditorId 字段 + instruction: + type: SET_FIELD + params: + targetField: auditorId + source: input.auditorId + - stepNo: 4 + desc: 用 auditorId 去员工聚合查审核人姓名,回填 auditorName + instruction: + type: REMOTE_QUERY_SET_FIELD + params: + remoteAggregate: EmployeeAggregate + queryKey: input.auditorId + sourceField: employeeName + targetField: auditorName + - stepNo: 5 + desc: 把审核备注赋值给 auditRemark + instruction: + type: SET_FIELD + params: + targetField: auditRemark + source: input.auditRemark + - stepNo: 6 + desc: 审核通过置 PASS,驳回置 REJECT + instruction: + type: CONDITION_SET_FIELD + params: + targetField: orderStatus + conditions: + - when: 'input.auditResult == "PASS"' + value: "PASS" + - when: 'input.auditResult == "REJECT"' + value: "REJECT" + # —— 命令级 bizSteps 现支持 IF 判断分支 + 跨聚合写回(类比 M4 flowSteps 的 condition)—— + # 审核人(员工)统计随审核结果通用回写;when 走 Aviator,root.=订单现值 / input.=命令入参。 + - stepNo: 7 + desc: 订单金额 > 100 时,把本次审核备注覆盖写入审核人的备注字段(金额不足则不写) + instruction: + type: IF + params: + when: 'root.totalAmount > 100' + then: + - instruction: + type: REMOTE_SET_FIELD + params: + remoteAggregate: EmployeeAggregate + keySource: input.auditorId + targetField: remark + source: input.auditRemark + else: [] + - stepNo: 8 + desc: 审核通过(PASS)时,审核人累计通过数 +1 + instruction: + type: IF + params: + when: 'input.auditResult == "PASS"' + then: + - instruction: + type: REMOTE_INCREMENT_FIELD + params: + remoteAggregate: EmployeeAggregate + keySource: input.auditorId + targetField: auditApproveCount + amount: 1 + - stepNo: 9 + desc: 审核驳回(REJECT)时,审核人累计驳回数 +1 + instruction: + type: IF + params: + when: 'input.auditResult == "REJECT"' + then: + - instruction: + type: REMOTE_INCREMENT_FIELD + params: + remoteAggregate: EmployeeAggregate + keySource: input.auditorId + targetField: auditRejectCount + amount: 1 + - stepNo: 10 + desc: 记录审核人“上次审核时间”为本次审核时间 + instruction: + type: REMOTE_SET_FIELD + params: + remoteAggregate: EmployeeAggregate + keySource: input.auditorId + targetField: lastAuditTime + source: root.auditTime + - stepNo: 11 + desc: 统一提交数据库事务 + instruction: + type: COMMIT_TRANSACTION + params: {} + emitEvents: + - OrderAudited + + # 4.员工聚合行为(“新增/修改员工”场景;亦为 AuditOrder 跨聚合回写的目标聚合根) + - aggregateId: EmployeeAggregate + aggregateRoot: Employee + commands: + - cmdId: CreateEmployee + desc: 新增员工主数据(审核统计计数初始化为 0) + commandType: create + inputParams: + - name: employeeName + type: string + - name: department + type: string + validations: + - employeeName 非空 + # bizSteps:把两个审核计数初始化为 0(后续 REMOTE_INCREMENT_FIELD 才能在非空值上累加),再落库 + bizSteps: + - stepNo: 1 + desc: 审核通过数初始化 0 + instruction: + type: SET_FIELD + params: + targetField: auditApproveCount + value: 0 + - stepNo: 2 + desc: 审核驳回数初始化 0 + instruction: + type: SET_FIELD + params: + targetField: auditRejectCount + value: 0 + - stepNo: 3 + desc: 落库新员工 + instruction: + type: COMMIT_TRANSACTION + params: {} + emitEvents: + - EmployeeCreated + + - cmdId: ModifyEmployee + desc: 修改员工姓名、部门、备注 + commandType: update + inputParams: + - name: employeeId + type: string + - name: employeeName + type: string + optional: true + - name: department + type: string + optional: true + - name: remark + type: string + optional: true + validations: + - employeeId 必须存在 + bizSteps: + - stepNo: 1 + desc: 按 employeeId 加载既有员工聚合根(不存在即失败) + instruction: + type: LOAD_AGGREGATE + params: + idSource: input.employeeId + - stepNo: 2 + desc: 更新命令携带的字段后提交事务 + instruction: + type: COMMIT_TRANSACTION + params: {} + emitEvents: + - EmployeeModified + +globalBehaviorConstraints: + - 命令只能操作所属聚合内部对象,跨聚合仅允许调用对方聚合根暴露Command,禁止直接操作对方内部实体 + - 单个命令事务边界仅限当前聚合;跨聚合操作拆为分布式事件最终一致性 + - 子实体只能被所属聚合根命令修改,外部无法直接操作 + - 所有跨聚合依赖仅基于对方聚合根ID做校验,不依赖内部字段 diff --git b/models/M3-deploy.yaml a/models/M3-deploy.yaml new file mode 100644 index 0000000..d377374 --- /dev/null +++ a/models/M3-deploy.yaml @@ -0,0 +1,115 @@ +# M3 服务与数据库部署映射 (v2.1:新增 EmployeeAggregate→t_employee_main、AuditOrder API 与订单审核列映射) +# 领域实体 -> 物理表/字段映射;引擎据此自动建表并生成动态SQL,前端无硬编码URL。 +deploymentMappings: + globalConfig: + dbEngine: mysql8 + defaultSchema: trade_db + idGen: snowflake + + aggregateMappingList: + - aggregateId: CustomerAggregate + serviceName: customer-service + commandApi: + CreateCustomer: /api/v1/customer/create + ModifyCustomerInfo: /api/v1/customer/modify + tableMappings: + - domainEntity: Customer + table: t_customer_main + pk: customer_id + fieldMap: + customerId: customer_id + customerName: cust_name + contactPhone: phone + registerTime: register_time + customerLevel: cust_level + + - aggregateId: ProductAggregate + serviceName: product-service + commandApi: + CreateProduct: /api/v1/product/create + ModifyProductPrice: /api/v1/product/price + StockDeduct: /api/v1/product/stock/deduct + tableMappings: + - domainEntity: Product + table: t_product_main + pk: product_id + fieldMap: + productId: product_id + productName: prod_name + skuCode: sku_code + category: category + saleCurrency: currency + salePrice: sale_price + stockNum: stock_num + isOnSale: is_on_sale + + - aggregateId: OrderAggregate + serviceName: order-service + commandApi: + CreateOrder: /api/v1/order/create + ModifyOrderDeliveryAddress: /api/v1/order/addr + CancelOrder: /api/v1/order/cancel + AuditOrder: /api/v1/order/audit + tableMappings: + - domainEntity: Order + table: t_order_main + pk: order_id + fieldMap: + orderId: order_id + createTime: create_time + totalCurrency: total_currency + totalAmount: total_amt + customerId: cust_id + orderStatus: order_status + auditorId: auditor_id + auditorName: auditor_name + auditTime: audit_time + auditRemark: audit_remark + - domainEntity: OrderItem + table: t_order_item + pk: item_id + fieldMap: + itemId: item_id + productId: product_id + quantity: buy_qty + itemCurrency: item_currency + itemPrice: item_price + - domainEntity: PaymentTerm + table: t_order_payment_term + pk: term_id + fieldMap: + termId: term_id + paymentType: pay_type + dueDays: due_days + depositRatio: deposit_ratio + settleCurrency: settle_currency + settleAmount: settle_amt + - domainEntity: DeliveryAddressEntity + table: t_order_address + pk: addr_id + fieldMap: + addrId: addr_id + province: province + city: city + district: district + streetDetail: street + receiverName: receiver_name + receiverPhone: receiver_phone + + - aggregateId: EmployeeAggregate + serviceName: employee-service + commandApi: + CreateEmployee: /api/v1/employee/create + ModifyEmployee: /api/v1/employee/modify + tableMappings: + - domainEntity: Employee + table: t_employee_main + pk: employee_id + fieldMap: + employeeId: employee_id + employeeName: emp_name + department: dept + auditApproveCount: approve_count + auditRejectCount: reject_count + remark: remark + lastAuditTime: last_audit_time diff --git b/models/M4-scene.yaml a/models/M4-scene.yaml new file mode 100644 index 0000000..d5534fe --- /dev/null +++ a/models/M4-scene.yaml @@ -0,0 +1,169 @@ +# M4 业务Saga场景编排 (v2.2:新增 SCENE_CREATE_EMPLOYEE/SCENE_MODIFY_EMPLOYEE;含 SCENE_AUDIT_ORDER 审核 + SCENE_DEMO_STEPTYPES 全 stepType 演示) +# M8页面模板绑定SceneId,一套模板对应完整端到端业务流程。 +sceneModel: + globalConfig: + engine: saga + consistency: eventual + maxRetry: 2 + + sceneDefinitions: + - sceneId: SCENE_CREATE_ORDER + sceneName: 用户创建订单完整流程 + aggregateScope: [CustomerAggregate, ProductAggregate, OrderAggregate] + permissionBind: [order_create] + flowSteps: + - stepType: readOnlyCheck + bindAggregate: CustomerAggregate + - stepType: bindCommand + bindAggregate: OrderAggregate + bindCommand: CreateOrder + - stepType: return + + - sceneId: SCENE_MODIFY_ORDER_ADDR + sceneName: 修改订单收货地址 + aggregateScope: [OrderAggregate] + permissionBind: [order_modify_addr] + flowSteps: + - stepType: bindCommand + bindAggregate: OrderAggregate + bindCommand: ModifyOrderDeliveryAddress + - stepType: return + + - sceneId: SCENE_CANCEL_ORDER + sceneName: 取消订单、归还库存 + aggregateScope: [OrderAggregate, ProductAggregate] + permissionBind: [order_cancel] + flowSteps: + - stepType: bindCommand + bindAggregate: OrderAggregate + bindCommand: CancelOrder + - stepType: return + + - sceneId: SCENE_AUDIT_ORDER + sceneName: 审核订单(bizSteps 闭指令集程序演示) + aggregateScope: [OrderAggregate, EmployeeAggregate] + permissionBind: [order_audit] + flowSteps: + - stepType: bindCommand + bindAggregate: OrderAggregate + bindCommand: AuditOrder + - stepType: return + + - sceneId: SCENE_CREATE_CUSTOMER + sceneName: 新增客户主数据 + aggregateScope: [CustomerAggregate] + permissionBind: [customer_create] + flowSteps: + - stepType: bindCommand + bindAggregate: CustomerAggregate + bindCommand: CreateCustomer + - stepType: return + + - sceneId: SCENE_MODIFY_CUSTOMER + sceneName: 修改客户信息 + aggregateScope: [CustomerAggregate] + permissionBind: [customer_modify] + flowSteps: + - stepType: bindCommand + bindAggregate: CustomerAggregate + bindCommand: ModifyCustomerInfo + - stepType: return + + - sceneId: SCENE_CREATE_PRODUCT + sceneName: 新增商品档案 + aggregateScope: [ProductAggregate] + permissionBind: [product_create] + flowSteps: + - stepType: bindCommand + bindAggregate: ProductAggregate + bindCommand: CreateProduct + - stepType: return + + - sceneId: SCENE_MODIFY_PRODUCT_PRICE + sceneName: 修改商品售价 + aggregateScope: [ProductAggregate] + permissionBind: [product_modify_price] + flowSteps: + - stepType: bindCommand + bindAggregate: ProductAggregate + bindCommand: ModifyProductPrice + - stepType: return + + - sceneId: SCENE_CREATE_EMPLOYEE + sceneName: 新增员工 + aggregateScope: [EmployeeAggregate] + permissionBind: [employee_create] + flowSteps: + - stepType: bindCommand + bindAggregate: EmployeeAggregate + bindCommand: CreateEmployee + - stepType: return + + - sceneId: SCENE_MODIFY_EMPLOYEE + sceneName: 修改员工信息 + aggregateScope: [EmployeeAggregate] + permissionBind: [employee_modify] + flowSteps: + - stepType: bindCommand + bindAggregate: EmployeeAggregate + bindCommand: ModifyEmployee + - stepType: return + + # 演示场景:集中展示新增的全部通用 stepType(控制流/数据/Saga/EDA/人工)。 + # 不落库(无 bindCommand),字段为合成变量(非 M1 领域字段),故不绑定 M8 表单模板; + # 前端对无模板场景提供"无表单直接运行"入口(空 payload),引擎逐条通用解释 flowSteps 产出可读 trace。可安全删除。 + - sceneId: SCENE_DEMO_STEPTYPES + sceneName: 演示:全部 stepType 通用解释 + aggregateScope: [OrderAggregate] + permissionBind: [order_create] + flowSteps: + - stepType: start + triggerType: manual + - stepType: assign + assignments: + - { targetField: qty, expression: "3" } + - { targetField: unitPrice, expression: "100" } + - stepType: script + expression: "qty * unitPrice" + assignTo: amount + - stepType: condition + when: "amount > 200" + then: + - stepType: assign + targetField: tier + expression: "'BIG'" + else: + - stepType: assign + targetField: tier + expression: "'SMALL'" + - stepType: switch + switchOn: "tier" + cases: + - equals: "BIG" + steps: + - { stepType: assign, targetField: discount, expression: "0.9" } + - equals: "SMALL" + steps: + - { stepType: assign, targetField: discount, expression: "1" } + default: + - { stepType: assign, targetField: discount, expression: "1" } + - stepType: while + when: "qty > 0" + maxIterations: 10 + body: + - { stepType: assign, targetField: qty, expression: "qty - 1" } + - stepType: parallel + branches: + - steps: + - { stepType: callService, method: GET, endpoint: /ext/credit-check } + - steps: + - { stepType: timer, delayMs: 500 } + - { stepType: waitEvent, waitEvent: OrderCreatedEvent } + - stepType: userTask + role: 主管 + # 无表单演示场景不设 approveParam,审批网关自动放行;真实场景可设 approveParam 卡在待审批(缺 true 即 PENDING) + - stepType: retry + maxAttempts: 2 + body: + - { stepType: script, expression: "amount * discount", assignTo: payable } + - stepType: return diff --git b/models/M5-security.yaml a/models/M5-security.yaml new file mode 100644 index 0000000..a8fb1a8 --- /dev/null +++ a/models/M5-security.yaml @@ -0,0 +1,51 @@ +# M5 权限脱敏安全模型 (v2.1:新增 order_audit 权限,绑定 SCENE_AUDIT_ORDER) +# 区分模板编辑管理员权限、页面访问权限、敏感字段掩码渲染规则;前端渲染引擎自动读取执行。 +securityModel: + globalConfig: + authMode: oauth2_jwt + auditEnable: true + + principals: + - principalId: normal_user + desc: C端普通消费者 + - principalId: backend_admin + desc: 后台运营管理员 + + functionPermissions: + - permId: customer_create + bindScene: SCENE_CREATE_CUSTOMER + allowPrincipals: [backend_admin] + - permId: order_create + bindScene: SCENE_CREATE_ORDER + allowPrincipals: [normal_user, backend_admin] + - permId: order_modify_addr + bindScene: SCENE_MODIFY_ORDER_ADDR + allowPrincipals: [normal_user, backend_admin] + - permId: order_cancel + bindScene: SCENE_CANCEL_ORDER + allowPrincipals: [normal_user, backend_admin] + - permId: order_audit + bindScene: SCENE_AUDIT_ORDER + allowPrincipals: [normal_user, backend_admin] + - permId: customer_modify + bindScene: SCENE_MODIFY_CUSTOMER + allowPrincipals: [backend_admin] + - permId: product_create + bindScene: SCENE_CREATE_PRODUCT + allowPrincipals: [backend_admin] + - permId: product_modify_price + bindScene: SCENE_MODIFY_PRODUCT_PRICE + allowPrincipals: [backend_admin] + - permId: employee_create + bindScene: SCENE_CREATE_EMPLOYEE + allowPrincipals: [backend_admin] + - permId: employee_modify + bindScene: SCENE_MODIFY_EMPLOYEE + allowPrincipals: [backend_admin] + + maskRules: + contactPhone: "{first3}****{last4}" + receiverPhone: "{first3}****{last4}" + + encryptRules: + storageEncrypt: [contactPhone, receiverPhone] diff --git b/models/M6-monitor.yaml a/models/M6-monitor.yaml new file mode 100644 index 0000000..1fa6c55 --- /dev/null +++ a/models/M6-monitor.yaml @@ -0,0 +1,22 @@ +# M6 全链路监控告警 (v2.0, 无修改) +# 前端页面加载、表单提交、接口异常自动埋点,匹配后端告警规则。 +monitorModel: + globalConfig: + traceEngine: skywalking + metricExport: prometheus + + serviceMetricBind: + - serviceName: customer-service + metricItems: [qps, rt, error_count] + - serviceName: product-service + metricItems: [qps, rt, stock_deduct_fail] + - serviceName: order-service + metricItems: [qps, rt, create_fail, cancel_fail] + + alertRules: + - desc: 订单创建失败率大于5% + level: 严重告警 + trigger: order-service.create_fail / order-service.qps > 0.05 + - desc: 库存扣减失败突增 + level: 警告告警 + trigger: product-service.stock_deduct_fail > 10 diff --git b/models/M7-sla.yaml a/models/M7-sla.yaml new file mode 100644 index 0000000..8fbaa75 --- /dev/null +++ a/models/M7-sla.yaml @@ -0,0 +1,25 @@ +# M7 流量管控SLA规范 (v2.0, 无修改) +# 接口流量、熔断、超时规范,前端请求层统一复用SLA限流配置。 +qualityModel: + aggregateSla: + CustomerAggregate: + availability: 99.95 + ProductAggregate: + availability: 99.95 + OrderAggregate: + availability: 99.99 + commandSla: + CreateOrder: + RT: 300ms + StockDeduct: + RT: 100ms + sceneSla: + SCENE_CREATE_ORDER: + endToEndRT: 600ms + successRate: 99.9 + flowControl: + globalLimitQps: 20000 + circuitBreaker: + enable: true + failureThreshold: 20 + waitDuration: 3000ms diff --git b/models/M8-front-schema.yaml a/models/M8-front-schema.yaml new file mode 100644 index 0000000..3785109 --- /dev/null +++ a/models/M8-front-schema.yaml @@ -0,0 +1,735 @@ +# M8 React前端可视化拖拽页面模板建模层 v2.2 (新增 template_order_audit 订单审核 + template_employee_create/modify 员工录入/修改) +# 约束:所有组件字段强制绑定M1领域模型,不允许自定义字段;自动关联M4场景、M2命令、M5脱敏、MetaRule规则。 +# 业务迭代仅修改本模板,无需修改React底层业务源码 —— 通用渲染引擎读取本文件动态生成页面。 +frontModel: + reactGlobalConfig: + uiLib: antd + dragEngineStorage: mysql8 + renderFramework: react18 + autoGenFormSubmit: true + autoBindApiFromScene: true + dragRolePermission: [backend_admin] + desc: 拖拽编辑器仅管理员可见;普通用户仅渲染页面,不可编辑模板 + + pageTemplates: + # 模板1:单实体表单模板 - 客户录入 + - templateId: template_customer_single + templateName: 客户单表单录入模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_CREATE_CUSTOMER + masterAggregate: CustomerAggregate + detailAggregates: [] + rootComponents: + - compId: card_customer_base + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerId + formLabel: 客户基础信息卡片12 + childComponents: + - compId: input_cust_name + compType: input + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerName + formLabel: 客户名称12123123 + required: true + - compId: input_cust_phone + compType: input + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: contactPhone + formLabel: 联系手机号 + required: true + maskField: true + - compId: select_cust_level + compType: select + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerLevel + formLabel: 客户等级 + required: true + - compId: date_register + compType: date_picker + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: registerTime + formLabel: 注册时间 + + # 模板2:主从表单模板 - 订单录入(聚合根+多子实体) + - templateId: template_order_master_detail + templateName: 订单主从录入模板 + templateType: master_detail_form + renderMode: page_container + bindSceneId: SCENE_CREATE_ORDER + masterAggregate: OrderAggregate + detailAggregates: [OrderItem, PaymentTerm, DeliveryAddressEntity] + rootComponents: + # 订单主信息卡片 + - compId: card_order_master + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: orderId + formLabel: 订单主信息 + childComponents: + - compId: select_customer + compType: select + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerId + formLabel: 客户 + required: true + - compId: input_currency + compType: input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: totalCurrency + formLabel: 结算币种 + required: true + - compId: number_total_amt + compType: number_input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: totalAmount + formLabel: 订单总金额 + required: true + - compId: date_create + compType: date_picker + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: createTime + formLabel: 下单时间 + # 订单明细表格子实体 + - compId: table_order_item + compType: table + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: OrderItem + domainFieldName: itemId + formLabel: 订单商品明细 + childComponents: + - compId: select_product + compType: select + span: 6 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: productId + formLabel: 商品 + required: true + - compId: number_qty + compType: number_input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: OrderItem + domainFieldName: quantity + formLabel: 购买数量 + required: true + - compId: number_price + compType: number_input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: OrderItem + domainFieldName: itemPrice + formLabel: 单品单价 + - compId: input_item_currency + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: OrderItem + domainFieldName: itemCurrency + formLabel: 单品币种 + # 付款条件子实体卡片 + - compId: card_payment_term + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: PaymentTerm + domainFieldName: termId + formLabel: 付款条款 + childComponents: + - compId: select_pay_type + compType: select + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: PaymentTerm + domainFieldName: paymentType + formLabel: 付款方式 + - compId: number_duedays + compType: number_input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: PaymentTerm + domainFieldName: dueDays + formLabel: 账期天数 + # 收货地址子实体卡片 + - compId: card_delivery_addr + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: addrId + formLabel: 收货地址 + childComponents: + - compId: input_province + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: province + formLabel: 省份 + - compId: input_city + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: city + formLabel: 城市 + - compId: input_district + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: district + formLabel: 区县 + - compId: input_street + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: streetDetail + formLabel: 详细街道 + - compId: input_receiver + compType: input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: receiverName + formLabel: 收货人 + - compId: input_receiver_phone + compType: input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: receiverPhone + formLabel: 收货电话 + maskField: true + + # 模板3:单键操作模板 - 取消订单(cancel 命令,仅选订单即可,后端据 ME 补偿归还库存) + - templateId: template_order_cancel + templateName: 订单取消模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_CANCEL_ORDER + masterAggregate: OrderAggregate + detailAggregates: [] + rootComponents: + - compId: card_cancel_order + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: orderId + formLabel: 取消订单 + childComponents: + - compId: select_cancel_order + compType: select + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: orderId + formLabel: 待取消订单 + required: true + + # 模板4:主从更新模板 - 修改订单收货地址(update 命令,主卡选订单+子实体卡录新地址) + - templateId: template_order_modify_addr + templateName: 订单收货地址修改模板 + templateType: master_detail_form + renderMode: page_container + bindSceneId: SCENE_MODIFY_ORDER_ADDR + masterAggregate: OrderAggregate + detailAggregates: [DeliveryAddressEntity] + rootComponents: + # 目标订单(聚合根,提供 orderId 定位待更新订单) + - compId: card_modaddr_order + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: orderId + formLabel: 目标订单 + childComponents: + - compId: select_modaddr_order + compType: select + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: orderId + formLabel: 订单 + required: true + # 新收货地址(DeliveryAddressEntity 子实体,字段/校验/脱敏与订单录入同源) + - compId: card_modaddr_addr + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: addrId + formLabel: 新收货地址 + childComponents: + - compId: input_modaddr_province + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: province + formLabel: 省份 + - compId: input_modaddr_city + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: city + formLabel: 城市 + - compId: input_modaddr_district + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: district + formLabel: 区县 + - compId: input_modaddr_street + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: streetDetail + formLabel: 详细街道 + - compId: input_modaddr_receiver + compType: input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: receiverName + formLabel: 收货人 + - compId: input_modaddr_receiver_phone + compType: input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: receiverPhone + formLabel: 收货电话 + maskField: true + + # 模板5:客户信息修改模板(update;主卡选客户 + 可改字段,contactPhone 存储加密/展示脱敏) + - templateId: template_customer_modify + templateName: 客户信息修改模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_MODIFY_CUSTOMER + masterAggregate: CustomerAggregate + detailAggregates: [] + rootComponents: + - compId: card_modcust_base + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerId + formLabel: 修改客户信息 + childComponents: + - compId: select_modcust_id + compType: select + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerId + formLabel: 目标客户 + required: true + - compId: input_modcust_name + compType: input + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerName + formLabel: 客户名称 + - compId: input_modcust_phone + compType: input + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: contactPhone + formLabel: 联系手机号 + maskField: true + - compId: select_modcust_level + compType: select + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerLevel + formLabel: 客户等级 + + # 模板6:商品档案录入模板(create;productId 由引擎生成,故主卡不含 id 输入) + - templateId: template_product_single + templateName: 商品档案录入模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_CREATE_PRODUCT + masterAggregate: ProductAggregate + detailAggregates: [] + rootComponents: + - compId: card_product_base + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: productId + formLabel: 商品基础信息 + childComponents: + - compId: input_prod_name + compType: input + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: productName + formLabel: 商品名称 + required: true + - compId: input_prod_sku + compType: input + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: skuCode + formLabel: SKU编码 + required: true + - compId: input_prod_category + compType: input + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: category + formLabel: 商品类目 + - compId: input_prod_currency + compType: input + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: saleCurrency + formLabel: 销售币种 + - compId: number_prod_price + compType: number_input + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: salePrice + formLabel: 销售单价 + required: true + - compId: number_prod_stock + compType: number_input + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: stockNum + formLabel: 库存数量 + required: true + - compId: switch_prod_onsale + compType: switch + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: isOnSale + formLabel: 是否上架 + + # 模板7:商品售价修改模板(update;主卡选商品 + 改币种/售价,售价字段在聚合根表可真正落库) + - templateId: template_product_modify_price + templateName: 商品售价修改模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_MODIFY_PRODUCT_PRICE + masterAggregate: ProductAggregate + detailAggregates: [] + rootComponents: + - compId: card_modprice_base + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: productId + formLabel: 修改商品售价 + childComponents: + - compId: select_modprice_id + compType: select + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: productId + formLabel: 目标商品 + required: true + - compId: input_modprice_currency + compType: input + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: saleCurrency + formLabel: 销售币种 + - compId: number_modprice_price + compType: number_input + span: 12 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: salePrice + formLabel: 新售价 + + # 模板9:订单审核模板(update;主卡选订单 + 选审核人 + 审核结论/备注) + # 演示 AuditOrder 的 bizSteps 闭指令集程序:LOAD_AGGREGATE → AUTO_FILL(auditTime) → SET_FIELD(auditorId) + # → REMOTE_QUERY_SET_FIELD(按 auditorId 查 EmployeeAggregate.employeeName 回填 auditorName) + # → SET_FIELD(auditRemark) → CONDITION_SET_FIELD(auditResult → orderStatus) → COMMIT_TRANSACTION。 + # 审核人下拉:auditorId 带 M1 refAggregate=EmployeeAggregate,渲染引擎通用地据引用取员工选项(非硬编码)。 + - templateId: template_order_audit + templateName: 订单审核模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_AUDIT_ORDER + masterAggregate: OrderAggregate + detailAggregates: [] + rootComponents: + - compId: card_audit_order + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: orderId + formLabel: 订单审核 + childComponents: + - compId: select_audit_order + compType: select + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: orderId + formLabel: 待审核订单 + required: true + - compId: select_audit_auditor + compType: select + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: auditorId + formLabel: 审核人(引用员工聚合) + required: true + - compId: select_audit_result + compType: select + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: auditResult + formLabel: 审核结论(PASS/REJECT) + required: true + - compId: input_audit_remark + compType: text_area + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: auditRemark + formLabel: 审核备注 + + # 模板10:员工档案录入模板(create;employeeId 由引擎生成,审核计数由 CreateEmployee 的 bizSteps 置 0) + - templateId: template_employee_create + templateName: 员工档案录入模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_CREATE_EMPLOYEE + masterAggregate: EmployeeAggregate + detailAggregates: [] + rootComponents: + - compId: card_employee_base + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: EmployeeAggregate + bindSourceType: aggregate_root + domainFieldName: employeeId + formLabel: 员工基础信息 + childComponents: + - compId: input_emp_name + compType: input + span: 12 + fieldBind: + bindAggregateId: EmployeeAggregate + bindSourceType: aggregate_root + domainFieldName: employeeName + formLabel: 员工姓名 + required: true + - compId: input_emp_dept + compType: input + span: 12 + fieldBind: + bindAggregateId: EmployeeAggregate + bindSourceType: aggregate_root + domainFieldName: department + formLabel: 部门 + + # 模板11:员工信息修改模板(update;主卡选员工 + 改姓名/部门/备注) + - templateId: template_employee_modify + templateName: 员工信息修改模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_MODIFY_EMPLOYEE + masterAggregate: EmployeeAggregate + detailAggregates: [] + rootComponents: + - compId: card_modemp_base + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: EmployeeAggregate + bindSourceType: aggregate_root + domainFieldName: employeeId + formLabel: 修改员工信息 + childComponents: + - compId: select_modemp_id + compType: select + span: 12 + fieldBind: + bindAggregateId: EmployeeAggregate + bindSourceType: aggregate_root + domainFieldName: employeeId + formLabel: 目标员工 + required: true + - compId: input_modemp_name + compType: input + span: 12 + fieldBind: + bindAggregateId: EmployeeAggregate + bindSourceType: aggregate_root + domainFieldName: employeeName + formLabel: 员工姓名 + - compId: input_modemp_dept + compType: input + span: 12 + fieldBind: + bindAggregateId: EmployeeAggregate + bindSourceType: aggregate_root + domainFieldName: department + formLabel: 部门 + - compId: input_modemp_remark + compType: text_area + span: 12 + fieldBind: + bindAggregateId: EmployeeAggregate + bindSourceType: aggregate_root + domainFieldName: remark + formLabel: 备注 + + # 模板8:空白自定义拖拽模板(全新业务场景使用) + - templateId: template_custom_blank + templateName: 空白自定义拖拽模板 + templateType: custom_drag_form + renderMode: drawer + bindSceneId: "" + masterAggregate: null + detailAggregates: [] + rootComponents: [] diff --git b/models/ME-event.yaml a/models/ME-event.yaml new file mode 100644 index 0000000..f0d28e9 --- /dev/null +++ a/models/ME-event.yaml @@ -0,0 +1,108 @@ +# ME EDA事件驱动模型 (v2.1:新增 OrderAudited 事件) +# 页面提交完成后监听领域事件,自动刷新页面、弹窗反馈、触发异步补偿逻辑。 +eventModel: + globalConfig: + idGenerator: snowflake + bus: rocketmq + retry: 3 + dlq: topic_msg_dlq + outbox: + enabled: true + table: t_domain_outbox + pollInterval: 1000ms + batchSize: 50 + cleanupDays: 7 + + aggregateEventDefinitions: + - aggregateId: CustomerAggregate + events: + CustomerCreated: + topic: topic_customer_create + payload: [customerId, customerName, customerLevel] + consumers: [customer-service] + CustomerInfoModified: + topic: topic_customer_modify + payload: [customerId, contactPhone, customerLevel] + consumers: [customer-service] + + - aggregateId: ProductAggregate + events: + ProductCreated: + topic: topic_product_create + payload: [productId, skuCode, salePrice, stockNum] + consumers: [product-service] + ProductPriceChanged: + topic: topic_product_price + payload: [productId, salePrice, saleCurrency] + consumers: [order-service] + ProductStockDeducted: + topic: topic_stock_deduct + payload: [productId, stockNum] + consumers: [order-service] + ProductStockReturned: + topic: topic_stock_return + payload: [productId, stockNum] + consumers: [order-service] + + - aggregateId: OrderAggregate + events: + OrderCreated: + topic: topic_order_create + payload: [orderId, customerId, totalAmount] + consumers: [product-service, customer-service] + OrderItemAdded: + topic: topic_order_item_added + payload: [orderId] + consumers: [order-service] + OrderPaymentTermSet: + topic: topic_order_payment_term_set + payload: [orderId] + consumers: [order-service] + OrderDeliveryAddressSet: + topic: topic_order_delivery_address_set + payload: [orderId] + consumers: [order-service] + OrderDeliveryAddressModified: + topic: topic_order_addr_modify + payload: [orderId, receiverPhone] + consumers: [order-service] + OrderCancelled: + topic: topic_order_cancel + payload: [orderId, productIdList] + consumers: [product-service] + OrderAudited: + topic: topic_order_audited + payload: [orderId, orderStatus, auditorId, auditorName] + consumers: [order-service] + + - aggregateId: EmployeeAggregate + events: + EmployeeCreated: + topic: topic_employee_create + payload: [employeeId, employeeName, department] + consumers: [employee-service] + EmployeeModified: + topic: topic_employee_modify + payload: [employeeId, employeeName, department, remark] + consumers: [employee-service] + + # 跨聚合最终一致规则(结构化): + # sourceItemEntity 指明从触发聚合的哪个子实体取"逐项上下文"; + # paramMap 把子实体字段映射为目标命令入参;引擎据此 + M2 命令的 effect 通用执行,无硬编码。 + crossAggConsistencyRules: + - triggerEvent: OrderCreated + sourceItemEntity: OrderItem + targetAggregate: ProductAggregate + targetCommand: StockDeduct + paramMap: + productId: productId + deductQty: quantity + consistencyType: eventual + - triggerEvent: OrderCancelled + sourceItemEntity: OrderItem + targetAggregate: ProductAggregate + targetCommand: StockReturn + paramMap: + productId: productId + returnQty: quantity + consistencyType: eventual diff --git b/models/MetaRule-business.yaml a/models/MetaRule-business.yaml new file mode 100644 index 0000000..d23c4d8 --- /dev/null +++ a/models/MetaRule-business.yaml @@ -0,0 +1,49 @@ +# MetaRule 前端后端统一动态规则引擎 (v2.0, 无修改) +# Aviator统一动态规则:前端渲染引擎实时执行(显隐/禁用/输入拦截/预警),后端执行持久化校验,规则仅维护一份。 +metaRuleModel: + globalConfig: + ruleEngine: aviator + hotUpdate: true + + ruleGlobalParams: + - paramId: single_order_max_amount + name: 单笔订单最大限额 + dataType: decimal + defaultValue: 50000 + - paramId: stock_warn_min + name: 库存预警最低值 + dataType: int + defaultValue: 20 + - paramId: vip_discount_limit + name: VIP免风控金额阈值 + dataType: decimal + defaultValue: 10000 + + ruleGroups: + - groupId: ORDER_RISK_RULE + groupType: 下单风控校验 + bindScene: SCENE_CREATE_ORDER + rules: + - ruleId: R_ORDER_AMOUNT_LIMIT + ruleVersion: "1.0" + ruleName: 大额订单拦截 + matchCondition: 'totalAmount > #{single_order_max_amount} && customerLevel != "VIP"' + effect: REJECT + message: 普通用户单笔订单不可超过50000元,请拆分下单 + - ruleId: R_STOCK_LOW_WARN + ruleVersion: "1.0" + ruleName: 低库存预警 + matchCondition: 'stockNum < #{stock_warn_min}' + effect: ALERT + message: 商品库存低于预警值,请及时补货 + + - groupId: ORDER_COMPENSATE_RULE + groupType: 订单取消补偿逻辑 + bindScene: SCENE_CANCEL_ORDER + rules: + - ruleId: R_CANCEL_STOCK_RETURN + ruleVersion: "1.0" + ruleName: 取消订单归还库存 + matchCondition: "true" + effect: COMPENSATE + message: 订单取消自动返还锁定商品库存 diff --git b/request.txt a/request.txt new file mode 100644 index 0000000..2454a88 --- /dev/null +++ a/request.txt @@ -0,0 +1,1326 @@ + +1、根据下面模型文件,完整代码实现案例(含前端React端到端测试页Demo),特别是M4的flowSteps,M2的bizSteps,MetaRule,架构引擎负责解释与执行,各层釆用什么具体技术,M 8前端模板定义及如何与具体业务绑定,并用案例方式推演解释整体架构的运行原理 + +2、以上方案实现使用 Claude Code 全流程开发,包含工程目录结构、Claude专用配置、全套建模YAML、后端Java引擎源码、React前端Demo、场景推演文档、代码生成模板、部署脚本、测试用例等等。 + +模型文件如下: +本体驱动 DDD+EDA 全量11份YAML架构文档 v2.0 +架构总述 +版本:v2.0 | 状态:生产级完备,支持代码生成 +架构分层全集:M0-meta-schema、M1-domain、M2-command、ME-event、M3-deploy、M4-scene、M5-security、M6-monitor、M7-sla、MetaRule-business、M8-front-schema +核心扩展:新增M8-front-schema React可视化拖拽建模层,M0补充前端模板完整JSON Schema校验;全量兼容原有DDD+EDA本体模型,无破坏性变更、仅增量扩展;实现业务专家无代码拖拽生成React页面,页面数据源强制绑定M1领域模型,页面自动关联M4场景、M2命令API、M5脱敏权限、MetaRule动态规则,实现后期仅维护模板、不修改前端业务源码。 + +一、M0-meta-schema.yaml(全域元数据校验根规范,新增M8前端Schema定义) +meta_model: + version: "2.0" + $schema: https://json-schema.org/draft/2020-12/schema + definitions: + # 基础通用标识类型 + Identifier: + type: string + pattern: "^[a-z][a-zA-Z0-9]*$" + description: 小写驼峰字段/组件标识 + UpperIdentifier: + type: string + pattern: "^[A-Z][a-zA-Z0-9]*$" + description: 大写驼峰聚合/命令/事件ID + NonEmptyString: + type: string + minLength: 1 + SceneId: + type: string + pattern: "^SCENE_[A-Z0-9_]+$" + RuleId: + type: string + pattern: "^R_[A-Z0-9_]+$" + TopicName: + type: string + pattern: "^topic_[a-z0-9_]+$" + ConsistencyTypeEnum: + type: string + enum: [strong, eventual] + DbEngineEnum: + type: string + enum: [mysql8] + AuthModeEnum: + type: string + enum: [oauth2_jwt] + + # ========== M8前端专用枚举(新增) ========== + FrontTemplateTypeEnum: + type: string + enum: [single_table_form, master_detail_form, custom_drag_form] + description: single_table_form单实体表单;master_detail_form主从表单;custom_drag_form自定义空白拖拽模板 + FrontComponentEnum: + type: string + enum: [input, number_input, date_picker, switch, table, card, text_area, select] + description: React底层原子拖拽组件 + BindSourceTypeEnum: + type: string + enum: [aggregate_root, aggregate_entity] + description: 组件绑定数据源类型:聚合根/聚合内部子实体 + ReactRenderModeEnum: + type: string + enum: [form_modal, page_container, drawer] + description: 页面渲染载体:弹窗/整页/侧边抽屉 + + # M1 领域模型单元定义 + M1_Attribute: + type: object + required: [name, type] + properties: + name: { $ref: "#/definitions/Identifier" } + type: + type: string + enum: [string, int, decimal, datetime, boolean] + refAggregate: { type: string } + refRoot: { type: string } + refIdentifier: { type: string } + description: { type: string } + M1_Relation: + type: object + required: [ref, relationType] + properties: + ref: { $ref: "#/definitions/NonEmptyString" } + relationType: + type: string + enum: [composition] + M1_Entity: + type: object + required: [name, localIdentifier, attributes] + properties: + name: { $ref: "#/definitions/NonEmptyString" } + localIdentifier: { $ref: "#/definitions/Identifier" } + attributes: + type: array + items: { $ref: "#/definitions/M1_Attribute" } + M1_AggregateRoot: + type: object + required: [name, identifier, attributes] + properties: + name: { $ref: "#/definitions/NonEmptyString" } + identifier: { $ref: "#/definitions/Identifier" } + attributes: + type: array + items: { $ref: "#/definitions/M1_Attribute" } + relations: + type: array + items: { $ref: "#/definitions/M1_Relation" } + M1_Aggregate: + type: object + required: [aggregateId, description, aggregateRoot] + properties: + aggregateId: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + description: { $ref: "#/definitions/NonEmptyString" } + aggregateRoot: { $ref: "#/definitions/M1_AggregateRoot" } + entities: + type: array + items: { $ref: "#/definitions/M1_Entity" } + valueObjects: + type: array + maxItems: 0 + + # M2 命令行为单元定义 + M2_InputParam: + type: object + required: [name, type] + properties: + name: { $ref: "#/definitions/Identifier" } + type: { type: string } + optional: { type: boolean } + M2_Command: + type: object + required: [cmdId, desc, inputParams, validations, emitEvents] + properties: + cmdId: { $ref: "#/definitions/UpperIdentifier" } + desc: { $ref: "#/definitions/NonEmptyString" } + inputParams: + type: array + items: { $ref: "#/definitions/M2_InputParam" } + validations: + type: array + items: { type: string } + bizSteps: { type: string } + emitEvents: + type: array + items: { $ref: "#/definitions/UpperIdentifier" } + sideEffect: { type: string } + M2_AggregateBehavior: + type: object + required: [aggregateId, aggregateRoot, commands] + properties: + aggregateId: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + aggregateRoot: { $ref: "#/definitions/NonEmptyString" } + commands: + type: array + items: { $ref: "#/definitions/M2_Command" } + + # ME 事件模型单元定义 + ME_Event: + type: object + required: [topic, payload] + properties: + topic: { $ref: "#/definitions/TopicName" } + payload: + type: array + items: { $ref: "#/definitions/Identifier" } + consumers: + type: array + items: { type: string } + ME_OutboxConfig: + type: object + required: [enabled, table] + properties: + enabled: { type: boolean } + table: { $ref: "#/definitions/NonEmptyString" } + pollInterval: { type: string } + batchSize: { type: integer, minimum:1 } + cleanupDays: { type: integer, minimum:1 } + ME_CrossConsistencyRule: + type: object + required: [triggerEvent, targetAggregate, targetCommand, consistencyType] + properties: + triggerEvent: { $ref: "#/definitions/UpperIdentifier" } + targetAggregate: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + targetCommand: { $ref: "#/definitions/UpperIdentifier" } + consistencyType: { $ref: "#/definitions/ConsistencyTypeEnum" } + + # M3 存储部署映射单元定义 + M3_TableMapping: + type: object + required: [domainEntity, table, pk, fieldMap] + properties: + domainEntity: { $ref: "#/definitions/NonEmptyString" } + table: { $ref: "#/definitions/NonEmptyString" } + pk: { $ref: "#/definitions/Identifier" } + shardRule: { type: string } + fieldMap: + type: object + additionalProperties: { type: string } + M3_AggregateDeploy: + type: object + required: [aggregateId, serviceName, commandApi, tableMappings] + properties: + aggregateId: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + serviceName: + type: string + pattern: "^[a-z-]+-service$" + commandApi: + type: object + additionalProperties: + type: string + pattern: "^/api/v1/.+" + tableMappings: + type: array + items: { $ref: "#/definitions/M3_TableMapping" } + + # M4 业务场景编排单元定义 + M4_SceneStep: + type: object + required: [stepType] + properties: + stepType: + type: string + enum: [bindCommand, readOnlyCheck, loopItems, return] + bindAggregate: { type: string } + bindCommand: { $ref: "#/definitions/UpperIdentifier" } + M4_Scene: + type: object + required: [sceneId, sceneName, aggregateScope, permissionBind, flowSteps] + properties: + sceneId: { $ref: "#/definitions/SceneId" } + sceneName: { $ref: "#/definitions/NonEmptyString" } + aggregateScope: + type: array + items: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + permissionBind: + type: array + items: { $ref: "#/definitions/Identifier" } + flowSteps: + type: array + items: { $ref: "#/definitions/M4_SceneStep" } + + # M5 安全权限单元定义 + M5_SecurityRoot: + type: object + required: [globalConfig, principals, functionPermissions, maskRules, encryptRules] + # M6 监控告警单元定义 + M6_Alert: + type: object + required: [desc, level, trigger] + properties: + desc: { type: string } + level: + type: string + enum: [严重告警, 警告告警] + trigger: { type: string } + M6_MonitorRoot: + type: object + required: [globalConfig, serviceMetricBind, alertRules] + # M7 流量SLA单元定义 + M7_QualityRoot: + type: object + required: [aggregateSla, commandSla, sceneSla, flowControl] + # MetaRule 动态业务规则单元定义 + MetaRule_Param: + type: object + required: [paramId, name, dataType, defaultValue] + MetaRule_Item: + type: object + required: [ruleId, ruleVersion, ruleName, matchCondition, effect, message] + properties: + ruleId: { $ref: "#/definitions/RuleId" } + effect: + type: string + enum: [REJECT, ALERT, SKIP_CHECK, CALC_FILL, COMPENSATE] + MetaRule_Group: + type: object + required: [groupId, groupType, bindScene, rules] + + # ===================== M8 前端拖拽模板完整定义(新增核心) ===================== + M8_FrontFieldBind: + type: object + required: [bindAggregateId, bindSourceType, domainFieldName] + properties: + bindAggregateId: + type: string + pattern: "^[A-Z][a-zA-Z]+Aggregate$" + bindSourceType: { $ref: "#/definitions/BindSourceTypeEnum" } + domainEntityName: { type: string } + domainFieldName: { $ref: "#/definitions/Identifier" } + formLabel: { type: string } + placeholder: { type: string } + required: { type: boolean } + maskField: { type: boolean } + M8_FrontDragComponent: + type: object + required: [compId, compType, fieldBind] + properties: + compId: { $ref: "#/definitions/Identifier" } + compType: { $ref: "#/definitions/FrontComponentEnum" } + width: { type: string } + span: { type: integer } + fieldBind: { $ref: "#/definitions/M8_FrontFieldBind" } + childComponents: + type: array + items: { $ref: "#/definitions/M8_FrontDragComponent" } + M8_FrontPageTemplate: + type: object + required: [templateId, templateName, templateType, renderMode, bindSceneId, rootComponents] + properties: + templateId: { $ref: "#/definitions/Identifier" } + templateName: { $ref: "#/definitions/NonEmptyString" } + templateType: { $ref: "#/definitions/FrontTemplateTypeEnum" } + renderMode: { $ref: "#/definitions/ReactRenderModeEnum" } + bindSceneId: { $ref: "#/definitions/SceneId" } + masterAggregate: { type: string } + detailAggregates: + type: array + items: { type: string } + rootComponents: + type: array + items: { $ref: "#/definitions/M8_FrontDragComponent" } + M8_FrontSchema: + type: object + required: [reactGlobalConfig, pageTemplates] + properties: + reactGlobalConfig: + type: object + required: [uiLib, dragEngineStorage] + properties: + uiLib: + type: string + const: antd + dragEngineStorage: + type: string + const: mysql8 + pageTemplates: + type: array + items: { $ref: "#/definitions/M8_FrontPageTemplate" } + + # 各分层根Schema定义(新增M8根校验) + M1_Schema: + type: object + required: [aggregates, globalConstraints] + M2_Schema: + type: object + required: [behaviors, globalBehaviorConstraints] + ME_Schema: + type: object + required: [eventModel] + M3_Schema: + type: object + required: [deploymentMappings] + M4_Schema: + type: object + required: [sceneModel] + M5_Schema: + type: object + required: [securityModel] + M6_Schema: + type: object + required: [monitorModel] + M7_Schema: + type: object + required: [qualityModel] + MetaRule_Schema: + type: object + required: [metaRuleModel] + M8_Schema: + type: object + required: [frontModel] + + rootAllOntologySchema: + type: object + oneOf: + - {$ref: "#/definitions/M1_Schema"} + - {$ref: "#/definitions/M2_Schema"} + - {$ref: "#/definitions/ME_Schema"} + - {$ref: "#/definitions/M3_Schema"} + - {$ref: "#/definitions/M4_Schema"} + - {$ref: "#/definitions/M5_Schema"} + - {$ref: "#/definitions/M6_Schema"} + - {$ref: "#/definitions/M7_Schema"} + - {$ref: "#/definitions/MetaRule_Schema"} + - {$ref: "#/definitions/M8_Schema"} +二、M1-domain.yaml(DDD领域聚合静态模型,无修改) +# M1 DDD聚合领域静态模型 +aggregates: + # 聚合1:客户聚合 CustomerAggregate + - aggregateId: CustomerAggregate + description: 客户主数据聚合,独立事务边界 + aggregateRoot: + name: Customer + identifier: customerId + attributes: + - name: customerName + type: string + - name: contactPhone + type: string + - name: registerTime + type: datetime + - name: customerLevel + type: string + entities: [] + valueObjects: [] + + # 聚合2:产品聚合 ProductAggregate + - aggregateId: ProductAggregate + description: 商品主数据聚合,独立事务边界 + aggregateRoot: + name: Product + identifier: productId + attributes: + - name: productName + type: string + - name: skuCode + type: string + - name: category + type: string + - name: saleCurrency + type: string + - name: salePrice + type: decimal + - name: stockNum + type: int + - name: isOnSale + type: boolean + entities: [] + valueObjects: [] + + # 聚合3:订单聚合 OrderAggregate + - aggregateId: OrderAggregate + description: 订单业务聚合,独立事务边界 + aggregateRoot: + name: Order + identifier: orderId + attributes: + - name: createTime + type: datetime + - name: totalCurrency + type: string + - name: totalAmount + type: decimal + - name: customerId + type: string + refAggregate: CustomerAggregate + refRoot: Customer + refIdentifier: customerId + description: 客户ID,引用客户聚合根主键 + relations: + - ref: OrderItem + relationType: composition + - ref: PaymentTerm + relationType: composition + - ref: DeliveryAddressEntity + relationType: composition + entities: + - name: OrderItem + localIdentifier: itemId + attributes: + - name: productId + type: string + refAggregate: ProductAggregate + refRoot: Product + refIdentifier: productId + description: 商品ID,引用产品聚合根主键 + - name: quantity + type: int + - name: itemCurrency + type: string + - name: itemPrice + type: decimal + - name: PaymentTerm + localIdentifier: termId + attributes: + - name: paymentType + type: string + - name: dueDays + type: int + - name: depositRatio + type: decimal + - name: settleCurrency + type: string + - name: settleAmount + type: decimal + - name: DeliveryAddressEntity + localIdentifier: addrId + attributes: + - name: province + type: string + - name: city + type: string + - name: district + type: string + - name: streetDetail + type: string + - name: receiverName + type: string + - name: receiverPhone + type: string + valueObjects: [] + +globalConstraints: + - crossAggRefRule: 跨聚合仅允许引用对方【聚合根唯一标识】,禁止引用对方内部实体、值对象 + - transactionBoundary: 一次事务只能修改同一个聚合内对象,跨聚合拆分为多个事务 + - cascadeDelete: 聚合根删除时,级联删除自身内部所有组合子实体 + - foreignKeyStrategy: 业务层做引用校验,默认不创建数据库物理外键;如需物理外键增加 dbForeignKey: true +三、M2-command.yaml(聚合命令行为模型,无修改) +# M2 聚合行为命令模型 +behaviors: + # 1.客户聚合行为 + - aggregateId: CustomerAggregate + aggregateRoot: Customer + commands: + - cmdId: CreateCustomer + desc: 新增客户主数据 + inputParams: + - name: customerName + type: string + - name: contactPhone + type: string + - name: customerLevel + type: string + validations: + - contactPhone非空、手机号格式校验 + - customerLevel取值范围[普通,VIP,高级VIP] + emitEvents: + - CustomerCreated + sideEffect: 仅写入本聚合,不可修改其他聚合 + + - cmdId: ModifyCustomerInfo + desc: 修改客户名称、联系方式、等级 + inputParams: + - name: customerId + type: string + - name: customerName + type: string + optional: true + - name: contactPhone + type: string + optional: true + - name: customerLevel + type: string + optional: true + validations: + - customerId必须存在 + emitEvents: + - CustomerInfoModified + + # 2.产品聚合行为 + - aggregateId: ProductAggregate + aggregateRoot: Product + commands: + - cmdId: CreateProduct + desc: 新增商品档案 + inputParams: + - name: productName + type: string + - name: skuCode + type: string + - name: category + type: string + - name: saleCurrency + type: string + - name: salePrice + type: decimal + - name: stockNum + type: int + - name: isOnSale + type: boolean + validations: + - skuCode全局唯一 + - salePrice > 0 + - stockNum >= 0 + emitEvents: + - ProductCreated + + - cmdId: ModifyProductPrice + desc: 修改售价与币种 + inputParams: + - name: productId + type: string + - name: saleCurrency + type: string + optional: true + - name: salePrice + type: decimal + optional: true + validations: + - productId存在 + - 修改后售价>0 + emitEvents: + - ProductPriceChanged + + - cmdId: StockDeduct + desc: 下单扣减库存(订单聚合远程调用) + inputParams: + - name: productId + type: string + - name: deductQty + type: int + validations: + - 可用库存 >= deductQty + emitEvents: + - ProductStockDeducted + + # 3.订单聚合行为 + - aggregateId: OrderAggregate + aggregateRoot: Order + commands: + - cmdId: CreateOrder + desc: 创建订单主单+明细+付款条件+送货地址 + inputParams: + - name: customerId + type: string + - name: totalCurrency + type: string + - name: totalAmount + type: decimal + - name: items + type: array + - name: paymentTerm + type: object + - name: deliveryAddress + type: object + validations: + - customerId 存在于CustomerAggregate + - 所有productId存在于ProductAggregate + - 订单明细金额累加 ≈ 订单总金额 + - dueDays >= 0, depositRatio ∈ [0,1] + - 收货手机号格式合法 + bizSteps: "1.构建Order聚合根主记录 2.批量构建OrderItem子实体 3.构建PaymentTerm、DeliveryAddressEntity子实体 4.远程调用Product聚合StockDeduct扣减各商品库存" + emitEvents: + - OrderCreated + - OrderItemAdded + - OrderPaymentTermSet + - OrderDeliveryAddressSet + + - cmdId: ModifyOrderDeliveryAddress + desc: 修改订单送货地址子实体 + inputParams: + - name: orderId + type: string + - name: province + type: string + optional: true + - name: city + type: string + optional: true + - name: district + type: string + optional: true + - name: streetDetail + type: string + optional: true + - name: receiverName + type: string + optional: true + - name: receiverPhone + type: string + optional: true + validations: + - orderId存在 + emitEvents: + - OrderDeliveryAddressModified + + - cmdId: CancelOrder + desc: 取消订单,归还库存 + inputParams: + - name: orderId + type: string + validations: + - 订单状态允许取消 + bizSteps: "1.校验订单可取消状态 2.查询订单全部明细商品与数量 3.远程调用Product聚合增加对应库存 4.标记订单作废" + emitEvents: + - OrderCancelled + +globalBehaviorConstraints: + - 命令只能操作所属聚合内部对象,跨聚合仅允许调用对方聚合根暴露Command,禁止直接操作对方内部实体 + - 单个命令事务边界仅限当前聚合;跨聚合操作拆为分布式事件最终一致性 + - 子实体只能被所属聚合根命令修改,外部无法直接操作 + - 所有跨聚合依赖仅基于对方聚合根ID做校验,不依赖内部字段 +四、ME-event.yaml(EDA事件驱动模型,无修改) +eventModel: + globalConfig: + idGenerator: snowflake + bus: rocketmq + retry: 3 + dlq: topic_msg_dlq + outbox: + enabled: true + table: t_domain_outbox + pollInterval: 1000ms + batchSize: 50 + cleanupDays: 7 + + aggregateEventDefinitions: + - aggregateId: CustomerAggregate + events: + CustomerCreated: + topic: topic_customer_create + payload: [customerId,customerName,customerLevel] + consumers: [customer-service] + CustomerInfoModified: + topic: topic_customer_modify + payload: [customerId,contactPhone,customerLevel] + consumers: [customer-service] + + - aggregateId: ProductAggregate + events: + ProductCreated: + topic: topic_product_create + payload: [productId,skuCode,salePrice,stockNum] + consumers: [product-service] + ProductPriceChanged: + topic: topic_product_price + payload: [productId,salePrice,saleCurrency] + consumers: [order-service] + ProductStockDeducted: + topic: topic_stock_deduct + payload: [productId,stockNum] + consumers: [order-service] + + - aggregateId: OrderAggregate + events: + OrderCreated: + topic: topic_order_create + payload: [orderId,customerId,totalAmount] + consumers: [product-service,customer-service] + OrderDeliveryAddressModified: + topic: topic_order_addr_modify + payload: [orderId,receiverPhone] + consumers: [order-service] + OrderCancelled: + topic: topic_order_cancel + payload: [orderId,productIdList] + consumers: [product-service] + + crossAggConsistencyRules: + - triggerEvent: OrderCreated + targetAggregate: ProductAggregate + targetCommand: StockDeduct + consistencyType: eventual + - triggerEvent: OrderCancelled + targetAggregate: ProductAggregate + targetCommand: StockDeduct + consistencyType: eventual +五、M3-deploy.yaml(服务存储部署映射,无修改) +deploymentMappings: + globalConfig: + dbEngine: mysql8 + defaultSchema: trade_db + idGen: snowflake + + aggregateMappingList: + - aggregateId: CustomerAggregate + serviceName: customer-service + commandApi: + CreateCustomer: /api/v1/customer/create + ModifyCustomerInfo: /api/v1/customer/modify + tableMappings: + - domainEntity: Customer + table: t_customer_main + pk: customer_id + fieldMap: + customerId: customer_id + customerName: cust_name + contactPhone: phone + registerTime: register_time + customerLevel: cust_level + + - aggregateId: ProductAggregate + serviceName: product-service + commandApi: + CreateProduct: /api/v1/product/create + ModifyProductPrice: /api/v1/product/price + StockDeduct: /api/v1/product/stock/deduct + tableMappings: + - domainEntity: Product + table: t_product_main + pk: product_id + fieldMap: + productId: product_id + productName: prod_name + skuCode: sku_code + category: category + saleCurrency: currency + salePrice: sale_price + stockNum: stock_num + isOnSale: is_on_sale + + - aggregateId: OrderAggregate + serviceName: order-service + commandApi: + CreateOrder: /api/v1/order/create + ModifyOrderDeliveryAddress: /api/v1/order/addr + CancelOrder: /api/v1/order/cancel + tableMappings: + - domainEntity: Order + table: t_order_main + pk: order_id + fieldMap: + orderId: order_id + createTime: create_time + totalCurrency: total_currency + totalAmount: total_amt + customerId: cust_id + - domainEntity: OrderItem + table: t_order_item + pk: item_id + fieldMap: + itemId: item_id + productId: product_id + quantity: buy_qty + itemCurrency: item_currency + itemPrice: item_price + - domainEntity: PaymentTerm + table: t_order_payment_term + pk: term_id + fieldMap: + termId: term_id + paymentType: pay_type + dueDays: due_days + depositRatio: deposit_ratio + settleCurrency: settle_currency + settleAmount: settle_amt + - domainEntity: DeliveryAddressEntity + table: t_order_address + pk: addr_id + fieldMap: + addrId: addr_id + province: province + city: city + district: district + streetDetail: street + receiverName: receiver_name + receiverPhone: receiver_phone +六、M4-scene.yaml(业务Saga场景编排,无修改) +sceneModel: + globalConfig: + engine: saga + consistency: eventual + maxRetry: 2 + + sceneDefinitions: + - sceneId: SCENE_CREATE_ORDER + sceneName: 用户创建订单完整流程 + aggregateScope: [CustomerAggregate,ProductAggregate,OrderAggregate] + permissionBind: [order_create] + flowSteps: + - stepType: readOnlyCheck + bindAggregate: CustomerAggregate + - stepType: bindCommand + bindAggregate: OrderAggregate + bindCommand: CreateOrder + - stepType: return + + - sceneId: SCENE_MODIFY_ORDER_ADDR + sceneName: 修改订单收货地址 + aggregateScope: [OrderAggregate] + permissionBind: [order_modify_addr] + flowSteps: + - stepType: bindCommand + bindAggregate: OrderAggregate + bindCommand: ModifyOrderDeliveryAddress + - stepType: return + + - sceneId: SCENE_CANCEL_ORDER + sceneName: 取消订单、归还库存 + aggregateScope: [OrderAggregate,ProductAggregate] + permissionBind: [order_cancel] + flowSteps: + - stepType: bindCommand + bindAggregate: OrderAggregate + bindCommand: CancelOrder + - stepType: return +七、M5-security.yaml(权限脱敏安全模型,无修改) +securityModel: + globalConfig: + authMode: oauth2_jwt + auditEnable: true + + principals: + - principalId: normal_user + desc: C端普通消费者 + - principalId: backend_admin + desc: 后台运营管理员 + + functionPermissions: + - permId: order_create + bindScene: SCENE_CREATE_ORDER + allowPrincipals: [normal_user,backend_admin] + - permId: order_modify_addr + bindScene: SCENE_MODIFY_ORDER_ADDR + allowPrincipals: [normal_user,backend_admin] + - permId: order_cancel + bindScene: SCENE_CANCEL_ORDER + allowPrincipals: [normal_user,backend_admin] + + maskRules: + contactPhone: "{first3}****{last4}" + receiverPhone: "{first3}****{last4}" + + encryptRules: + storageEncrypt: [contactPhone,receiverPhone] +八、M6-monitor.yaml(全链路监控告警,无修改) +monitorModel: + globalConfig: + traceEngine: skywalking + metricExport: prometheus + + serviceMetricBind: + - serviceName: customer-service + metricItems: [qps,rt,error_count] + - serviceName: product-service + metricItems: [qps,rt,stock_deduct_fail] + - serviceName: order-service + metricItems: [qps,rt,create_fail,cancel_fail] + + alertRules: + - desc: 订单创建失败率大于5% + level: 严重告警 + trigger: order-service.create_fail / order-service.qps > 0.05 + - desc: 库存扣减失败突增 + level: 警告告警 + trigger: product-service.stock_deduct_fail > 10 +九、M7-sla.yaml(流量管控SLA规范,无修改) +qualityModel: + aggregateSla: + CustomerAggregate: + availability: 99.95 + ProductAggregate: + availability: 99.95 + OrderAggregate: + availability: 99.99 + commandSla: + CreateOrder: + RT: 300ms + StockDeduct: + RT: 100ms + sceneSla: + SCENE_CREATE_ORDER: + endToEndRT: 600ms + successRate: 99.9 + flowControl: + globalLimitQps: 20000 + circuitBreaker: + enable: true + failureThreshold: 20 + waitDuration: 3000ms +十、MetaRule-business.yaml(前端后端统一动态规则引擎,无修改) +metaRuleModel: + globalConfig: + ruleEngine: aviator + hotUpdate: true + + ruleGlobalParams: + - paramId: single_order_max_amount + name: 单笔订单最大限额 + dataType: decimal + defaultValue: 50000 + - paramId: stock_warn_min + name: 库存预警最低值 + dataType: int + defaultValue: 20 + - paramId: vip_discount_limit + name: VIP免风控金额阈值 + dataType: decimal + defaultValue: 10000 + + ruleGroups: + - groupId: ORDER_RISK_RULE + groupType: 下单风控校验 + bindScene: SCENE_CREATE_ORDER + rules: + - ruleId: R_ORDER_AMOUNT_LIMIT + ruleVersion: 1.0 + ruleName: 大额订单拦截 + matchCondition: totalAmount > #{single_order_max_amount} && customerLevel != "VIP" + effect: REJECT + message: 普通用户单笔订单不可超过50000元,请拆分下单 + - ruleId: R_STOCK_LOW_WARN + ruleVersion: 1.0 + ruleName: 低库存预警 + matchCondition: stockNum < #{stock_warn_min} + effect: ALERT + message: 商品库存低于预警值,请及时补货 + + - groupId: ORDER_COMPENSATE_RULE + groupType: 订单取消补偿逻辑 + bindScene: SCENE_CANCEL_ORDER + rules: + - ruleId: R_CANCEL_STOCK_RETURN + ruleVersion: 1.0 + ruleName: 取消订单归还库存 + matchCondition: true + effect: COMPENSATE + message: 订单取消自动返还锁定商品库存 +十一、M8-front-schema.yaml(新增核心:React可视化拖拽前端模板建模层) +# M8 React前端可视化拖拽页面模板建模层 v2.0 +# 约束:所有组件字段强制绑定M1领域模型,不允许自定义字段;自动关联M4场景、M2命令、M5脱敏、MetaRule规则 +frontModel: + reactGlobalConfig: + uiLib: antd + dragEngineStorage: mysql8 + renderFramework: react18 + autoGenFormSubmit: true + autoBindApiFromScene: true + dragRolePermission: [backend_admin] + desc: 拖拽编辑器仅管理员可见;普通用户仅渲染页面,不可编辑模板 + + pageTemplates: + # 模板1:单实体表单模板 - 客户录入 + - templateId: template_customer_single + templateName: 客户单表单录入模板 + templateType: single_table_form + renderMode: page_container + bindSceneId: SCENE_CREATE_CUSTOMER + masterAggregate: CustomerAggregate + detailAggregates: [] + rootComponents: + - compId: card_customer_base + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerId + formLabel: 客户基础信息卡片 + childComponents: + - compId: input_cust_name + compType: input + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerName + formLabel: 客户名称 + required: true + - compId: input_cust_phone + compType: input + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: contactPhone + formLabel: 联系手机号 + required: true + maskField: true + - compId: select_cust_level + compType: select + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerLevel + formLabel: 客户等级 + required: true + - compId: date_register + compType: date_picker + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: registerTime + formLabel: 注册时间 + + # 模板2:主从表单模板 - 订单录入(聚合根+多子实体) + - templateId: template_order_master_detail + templateName: 订单主从录入模板 + templateType: master_detail_form + renderMode: page_container + bindSceneId: SCENE_CREATE_ORDER + masterAggregate: OrderAggregate + detailAggregates: [OrderItem,PaymentTerm,DeliveryAddressEntity] + rootComponents: + # 订单主信息卡片 + - compId: card_order_master + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: orderId + formLabel: 订单主信息 + childComponents: + - compId: select_customer + compType: select + span: 12 + fieldBind: + bindAggregateId: CustomerAggregate + bindSourceType: aggregate_root + domainFieldName: customerId + formLabel: 客户 + required: true + - compId: input_currency + compType: input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: totalCurrency + formLabel: 结算币种 + required: true + - compId: number_total_amt + compType: number_input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: totalAmount + formLabel: 订单总金额 + required: true + - compId: date_create + compType: date_picker + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_root + domainFieldName: createTime + formLabel: 下单时间 + # 订单明细表格子实体 + - compId: table_order_item + compType: table + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: OrderItem + domainFieldName: itemId + formLabel: 订单商品明细 + childComponents: + - compId: select_product + compType: select + span: 6 + fieldBind: + bindAggregateId: ProductAggregate + bindSourceType: aggregate_root + domainFieldName: productId + formLabel: 商品 + required: true + - compId: number_qty + compType: number_input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: OrderItem + domainFieldName: quantity + formLabel: 购买数量 + required: true + - compId: number_price + compType: number_input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: OrderItem + domainFieldName: itemPrice + formLabel: 单品单价 + - compId: input_item_currency + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: OrderItem + domainFieldName: itemCurrency + formLabel: 单品币种 + # 付款条件子实体卡片 + - compId: card_payment_term + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: PaymentTerm + domainFieldName: termId + formLabel: 付款条款 + childComponents: + - compId: select_pay_type + compType: select + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: PaymentTerm + domainFieldName: paymentType + formLabel: 付款方式 + - compId: number_duedays + compType: number_input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: PaymentTerm + domainFieldName: dueDays + formLabel: 账期天数 + # 收货地址子实体卡片 + - compId: card_delivery_addr + compType: card + width: 100% + span: 24 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: addrId + formLabel: 收货地址 + childComponents: + - compId: input_province + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: province + formLabel: 省份 + - compId: input_city + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: city + formLabel: 城市 + - compId: input_district + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: district + formLabel: 区县 + - compId: input_street + compType: input + span: 6 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: streetDetail + formLabel: 详细街道 + - compId: input_receiver + compType: input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: receiverName + formLabel: 收货人 + - compId: input_receiver_phone + compType: input + span: 12 + fieldBind: + bindAggregateId: OrderAggregate + bindSourceType: aggregate_entity + domainEntityName: DeliveryAddressEntity + domainFieldName: receiverPhone + formLabel: 收货电话 + maskField: true + + # 模板3:空白自定义拖拽模板(全新业务场景使用) + - templateId: template_custom_blank + templateName: 空白自定义拖拽模板 + templateType: custom_drag_form + renderMode: drawer + bindSceneId: "" + masterAggregate: null + detailAggregates: [] + rootComponents: [] +完整架构分层定位总总结 + +全套11层分层职责 + +1. M0-meta-schema:全域本体校验根规范,新增React前端模板JSON Schema约束,CI流水线校验所有YAML语法合法;统一前后端数据、组件、页面描述标准。 + +2. M1-domain:DDD领域聚合唯一数据源,前端所有拖拽组件强制绑定本层字段,禁止自定义字段,保证前后端数据口径完全统一。 + +3. M2-command:聚合操作命令入参定义,React页面提交时自动映射表单组件值至命令参数,无硬编码接口参数。 + +4. ME-event:EDA事件总线定义,页面提交完成后监听领域事件,自动刷新页面、弹窗反馈、触发异步补偿逻辑。 + +5. M3-deploy:服务与数据库映射,自动关联页面场景对应的后端HTTP接口地址,前端无硬编码URL。 + +6. M4-scene:Saga业务流程编排,M8页面模板绑定SceneId,一套模板对应完整端到端业务流程。 + +7. M5-security:权限、脱敏、数据加密规范;区分模板编辑管理员权限、页面访问权限、敏感字段掩码渲染规则,前端渲染引擎自动读取执行。 + +8. M6-monitor:全链路监控指标,前端页面加载、表单提交、接口异常自动埋点,匹配后端告警规则。 + +9. M7-sla:接口流量、熔断、超时规范,前端请求层统一复用SLA限流配置。 + +10. MetaRule-business:Aviator统一动态规则,前端渲染引擎实时执行,控制组件显隐、禁用、输入拦截、预警弹窗;后端执行持久化校验,规则仅维护一份。 + +11. M8-front-schema(新增):React18+Ant Design可视化拖拽建模层,业务专家无代码操作;内置单表单/主从表单/空白模板三类模板;模板持久化MySQL;自动联动上游全部M层元数据;业务迭代仅修改模板yaml,无需修改React底层业务代码,满足后期专业人员独立维护模板的核心需求。 + +业务落地闭环流程 + +1. 架构工程师维护M0-M7、MetaRule底层元模型(一次性开发,极少变更); + +2. 业务建模/专业维护人员仅操作M8-front-schema模板:可视化拖拽绑定M1字段生成页面; + +3. 模板绑定M4场景后,系统自动关联M2命令API、M5脱敏权限、MetaRule动态规则; + +4. 前端React通用渲染引擎读取M8模板动态生成页面,无业务硬编码; + +5. 业务需求变更:仅调整M8模板组件配置,无需发布前端JS/TS源码,支持配置中心热更新。 diff --git b/start.sh a/start.sh new file mode 100755 index 0000000..ac32285 --- /dev/null +++ a/start.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# 一键启动:后端元数据引擎(8080) + 前端渲染引擎(5173/自动顺延) +# 用法: ./start.sh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" + +echo "==> 环境检查(Maven 由 ./mvnw 包装器自动下载,无需预装)" +command -v java >/dev/null || { echo "缺少 Java (建议 21+)"; exit 1; } +command -v node >/dev/null || { echo "缺少 Node.js (建议 18+)"; exit 1; } +command -v npm >/dev/null || { echo "缺少 npm"; exit 1; } + +ENGINE_LOG="$ROOT/.engine.log" +echo "==> 启动后端引擎 (日志: $ENGINE_LOG;首次会下载 Maven 与依赖)" +( cd "$ROOT/engine" && ./mvnw -q -DskipTests spring-boot:run > "$ENGINE_LOG" 2>&1 ) & +ENGINE_PID=$! + +cleanup() { + echo; echo "==> 停止服务" + kill "$ENGINE_PID" 2>/dev/null || true + # 兜底:杀掉占用 8080 的子进程 + if command -v lsof >/dev/null; then + PIDS=$(lsof -ti tcp:8080 2>/dev/null || true); [ -n "$PIDS" ] && kill $PIDS 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +echo "==> 等待引擎就绪 (http://localhost:8080)" +for i in $(seq 1 60); do + if curl -s -o /dev/null http://localhost:8080/api/meta/models 2>/dev/null; then + echo " 引擎已就绪 ✔"; break + fi + sleep 2 + [ "$i" = "60" ] && { echo "引擎启动超时,请查看 $ENGINE_LOG"; exit 1; } +done + +echo "==> 安装前端依赖 (首次)" +( cd "$ROOT/web" && [ -d node_modules ] || npm install ) + +echo "==> 启动前端 (Vite)。浏览器打开提示的 Local 地址,进入「新增订单」页。" +echo " 后端: http://localhost:8080 H2 控制台: http://localhost:8080/h2-console" +cd "$ROOT/web" && npm run dev diff --git b/templates/README.md a/templates/README.md new file mode 100644 index 0000000..7821b64 --- /dev/null +++ a/templates/README.md @@ -0,0 +1,11 @@ +# 代码生成模板 + +本引擎默认采用**运行时解释**模型(改 YAML 即改行为,无需生成代码)。当团队更偏好**静态生成**时,可用下列模板由模型一次性生成源码(Mustache 风格占位符,配合任意模板引擎/脚本渲染)。 + +| 模板 | 输入模型 | 产物 | +|---|---|---| +| `ddl.sql.mustache` | M3 tableMappings + M1 类型 | 建表 DDL | +| `react-page.tsx.mustache` | M8 pageTemplate + M1 | 静态 React 录入页 | +| `command-handler.java.mustache` | M2 command + M4 scene | 命令处理器骨架 | + +渲染入参对象可由 `GET /api/meta/model/{code}` 拿到的解析结果拼装。两条路线(解释/生成)共享同一套 `models/`,可按团队工程习惯二选一或混用。 diff --git b/templates/command-handler.java.mustache a/templates/command-handler.java.mustache new file mode 100644 index 0000000..1f0be1c --- /dev/null +++ a/templates/command-handler.java.mustache @@ -0,0 +1,28 @@ +// 由 M2-command.yaml (command) + M4-scene.yaml + ME-event.yaml 生成的命令处理器骨架 +// 渲染上下文:{ aggregateId, cmdId, inputParams:[{name,type,optional}], validations:[...], emitEvents:[...] } +package com.onto.generated.{{aggregateLower}}; + +import org.springframework.stereotype.Service; + +@Service +public class {{cmdId}}Handler { + + /** {{desc}} */ + public {{cmdId}}Result handle({{cmdId}}Command cmd) { + // 1. 校验(源自 M2 validations) + {{#validations}} + // - {{.}} + {{/validations}} + + // 2. 业务步骤(源自 M2 bizSteps):{{bizSteps}} + + // 3. 发出领域事件(源自 M2 emitEvents / ME 定义) + {{#emitEvents}} + // emit {{.}} + {{/emitEvents}} + return new {{cmdId}}Result(); + } + + public record {{cmdId}}Command({{#inputParams}}{{javaType}} {{name}}{{^last}}, {{/last}}{{/inputParams}}) {} + public record {{cmdId}}Result() {} +} diff --git b/templates/ddl.sql.mustache a/templates/ddl.sql.mustache new file mode 100644 index 0000000..31e5313 --- /dev/null +++ a/templates/ddl.sql.mustache @@ -0,0 +1,15 @@ +-- 由 M3-deploy.yaml (tableMappings) + M1-domain.yaml (字段类型) 生成 +-- 渲染上下文:{ aggregates: [ { tables: [ { table, pk, columns:[{col,type,isPk}], parentFk } ] } ] } +{{#aggregates}} +{{#tables}} +CREATE TABLE IF NOT EXISTS {{table}} ( +{{#columns}} + {{col}} {{type}}{{#isPk}} PRIMARY KEY{{/isPk}}, +{{/columns}} +{{#parentFk}} + {{parentFk}} VARCHAR(64), -- 组合子实体父外键(据 M1 composition) +{{/parentFk}} + _row_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +{{/tables}} +{{/aggregates}} diff --git b/templates/react-page.tsx.mustache a/templates/react-page.tsx.mustache new file mode 100644 index 0000000..f3f07db --- /dev/null +++ a/templates/react-page.tsx.mustache @@ -0,0 +1,30 @@ +// 由 M8-front-schema.yaml (pageTemplate) + M1 字段类型生成的静态录入页骨架 +// 渲染上下文:{ templateName, sceneId, commandApi, masterFields:[...], detailEntities:[{name, fields:[...]}] } +import { Form, Input, InputNumber, DatePicker, Select, Card, Button } from 'antd' + +export default function {{pascalTemplateId}}Page() { + const [form] = Form.useForm() + const submit = async () => { + const values = await form.validateFields() + await fetch('{{commandApi}}', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(values) }) + } + return ( + + + {{#masterFields}} + + {{control}} + + {{/masterFields}} + + {{#detailEntities}} + + {{#fields}} + {{control}} + {{/fields}} + + {{/detailEntities}} + + + ) +} diff --git b/tests/verify-model-change.sh a/tests/verify-model-change.sh new file mode 100755 index 0000000..af379de --- /dev/null +++ a/tests/verify-model-change.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# 验证"改了模型文件 → 执行的操作定义随之改变"(针对 MetaRule 阈值)。 +# 前置:后端已在 8080 运行(./start.sh 或 cd engine && mvn spring-boot:run)。 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE="http://localhost:8080" +MODEL="$ROOT/models/MetaRule-business.yaml" + +# 明细 1 件 × 7997 → 引擎服务端重算 totalAmount=7997(无视 master 传入值,防篡改) +ORDER='{"master":{"customerId":"C001","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"}, + "details":{"OrderItem":[{"productId":"P003","quantity":1,"itemPrice":7997,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"现金","dueDays":0}], + "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"x","receiverName":"张三","receiverPhone":"13800001111"}]}}' + +say() { printf "\n\033[1m%s\033[0m\n" "$1"; } +exec_order() { curl -s -X POST "$BASE/api/scene/SCENE_CREATE_ORDER/execute" -H 'Content-Type: application/json' -d "$ORDER"; } +field() { python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('$1'))"; } + +curl -s -o /dev/null "$BASE/api/meta/models" || { echo "后端未就绪,请先启动 8080"; exit 1; } + +say "A) 改动前:普通用户 C001,金额 7997 (< 50000)" +echo "$ORDER" | exec_order /dev/null 2>&1 || true +R=$(exec_order); echo " success=$(echo "$R" | field success)" + +say "B) 仅改模型:single_order_max_amount 50000 -> 100(无代码改动)" +perl -0pi -e 's/defaultValue: 50000/defaultValue: 100/' "$MODEL" +curl -s -X POST "$BASE/api/meta/reload" -H 'X-Principal: backend_admin' >/dev/null; echo " 已热重载" + +say "C) 改动后:同一笔订单 (7997 > 100) 应被 REJECT" +R=$(exec_order) +echo " success=$(echo "$R" | field success) stage=$(echo "$R" | field stage) message=$(echo "$R" | field message)" + +say "D) 恢复模型 100 -> 50000 并重载" +perl -0pi -e 's/defaultValue: 100\b/defaultValue: 50000/' "$MODEL" +curl -s -X POST "$BASE/api/meta/reload" -H 'X-Principal: backend_admin' >/dev/null; echo " 已恢复" + +say "结论:同一请求、仅改 YAML,结果从『通过』变为『拦截』——行为源于模型而非代码。" diff --git b/web/index.html a/web/index.html new file mode 100644 index 0000000..5264a0c --- /dev/null +++ a/web/index.html @@ -0,0 +1,12 @@ + + + + + + 本体驱动 · 元数据引擎 Demo + + +
+ + + diff --git b/web/package-lock.json a/web/package-lock.json new file mode 100644 index 0000000..4dc5f3d --- /dev/null +++ a/web/package-lock.json @@ -0,0 +1,2793 @@ +{ + "name": "onto-web", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "onto-web", + "version": "2.0.0", + "dependencies": { + "@ant-design/icons": "^5.5.1", + "antd": "^5.21.6", + "dayjs": "^1.11.13", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz", + "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.3.tgz", + "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git b/web/package.json a/web/package.json new file mode 100644 index 0000000..b7e4ed6 --- /dev/null +++ a/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "onto-web", + "private": true, + "version": "2.0.0", + "type": "module", + "description": "本体驱动前端通用渲染引擎:读取 M8 模板动态生成页面,无业务硬编码", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --port 5173" + }, + "dependencies": { + "@ant-design/icons": "^5.5.1", + "antd": "^5.21.6", + "dayjs": "^1.11.13", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git b/web/src/App.tsx a/web/src/App.tsx new file mode 100644 index 0000000..99f54f0 --- /dev/null +++ a/web/src/App.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react' +import { Layout, Card, Tabs, Button, Space, Typography, Tag, message, Spin, Modal, Select } from 'antd' +import { ReloadOutlined, ThunderboltOutlined, BranchesOutlined, UserOutlined } from '@ant-design/icons' +import { api } from './api' +import type { SceneSchema } from './types' +import { SceneForm } from './engine/SceneForm' +import { TracePanel, ProvenancePanel, ModelViewer, OrdersEventsPanel } from './panels' + +const { Header, Sider, Content } = Layout +const { Title, Text, Paragraph } = Typography + +export default function App() { + const [scenes, setScenes] = useState([]) + const [sceneId, setSceneId] = useState('') // 初始不硬编码,待场景列表返回后取首个可渲染场景 + const [schema, setSchema] = useState(null) + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + const [refreshKey, setRefreshKey] = useState(0) + const [formKey, setFormKey] = useState(0) + // 演示态主体(M5 principal):随手切换以观察权限"通过/拒绝"分支;管理员方可热重载/新增客户 + const [principal, setPrincipal] = useState('backend_admin') + + // 拉取场景列表(首个可渲染场景兜底选中;已有选择则保留,以便热重载后停留在当前场景) + const loadScenes = () => + api.scenes().then((list) => { + setScenes(list) + setSceneId((cur) => cur || list.find((s: any) => s.hasTemplate)?.sceneId || '') + }).catch(() => message.error('无法连接引擎(8080),请先启动后端')) + + useEffect(() => { loadScenes() }, []) + + useEffect(() => { + if (!sceneId) return + setLoading(true); setResult(null) + api.sceneSchema(sceneId).then(setSchema).catch((e) => message.error('加载场景失败:' + e.message)).finally(() => setLoading(false)) + }, [sceneId, formKey]) + + async function reloadModels() { + try { + const r = await api.reload(principal) + message.success(r.message) + await loadScenes() // 重新拉取场景列表:模板增删 / hasTemplate 变化即时反映到侧栏 + setFormKey((k) => k + 1) // 强制重新拉取当前场景 schema + 重挂载表单 + } catch (e: any) { message.error('热重载失败:' + e.message) } + } + + const onExecuted = (r: any) => { setResult(r); if (r.success) setRefreshKey((k) => k + 1) } + + return ( + +
+ + 本体驱动 DDD+EDA · 元数据引擎 Demo + 改 YAML → 页面与行为随之变 +
+ + + ({ value: o.value, label: o.label }))} + /> + ) + } + // 绑定到普通字符串字段的 select 在 M1 中无枚举来源,通用引擎降级为可输入选择 + return ( + onChange(e.target.value)} + placeholder={placeholder} + suffix={fb.masked ? ( + + + + ) : } + /> + ) + } +} diff --git b/web/src/engine/SceneForm.tsx a/web/src/engine/SceneForm.tsx new file mode 100644 index 0000000..5207386 --- /dev/null +++ a/web/src/engine/SceneForm.tsx @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Card, Row, Col, Form, Button, Table, Space, Tag, Typography, Alert, message, Divider, Empty, +} from 'antd' +import { PlusOutlined, DeleteOutlined } from '@ant-design/icons' +import { api } from '../api' +import type { DragComponent, OptionItem, SceneSchema } from '../types' +import { FieldControl } from './FieldControl' + +const { Text } = Typography + +type Role = 'master' | 'single' | 'table' + +function roleOf(container: DragComponent): Role { + const fb = container.fieldBind || ({} as any) + if (fb.bindSourceType === 'aggregate_root') return 'master' + if (container.compType === 'table') return 'table' + return 'single' +} + +function collectOptionSources(comps: DragComponent[], acc: Set) { + for (const c of comps) { + const src = c.fieldBind?.optionsSource + if (src) acc.add(src.aggregateId) + if (c.childComponents) collectOptionSources(c.childComponents, acc) + } +} + +/** + * 场景通用渲染引擎:读取合成后的场景 Schema,动态生成表单。 + * 完全由 M8 模板结构驱动——主表(aggregate_root)渲染为卡片字段, + * 子实体(aggregate_entity)按 table/card 渲染为多行表格或单行卡片。改模板即改页面。 + */ +export function SceneForm({ schema, onExecuted, principal }: + { schema: SceneSchema; onExecuted: (r: any) => void; principal: string }) { + const template = schema.template! + const containers = template.rootComponents || [] + + const [master, setMaster] = useState>({}) + const [singles, setSingles] = useState>>({}) + const [tables, setTables] = useState>>>({}) + const [optionsMap, setOptionsMap] = useState>({}) + const [precheck, setPrecheck] = useState(null) + const [submitting, setSubmitting] = useState(false) + + // 初始化表单状态(据模板结构) + useEffect(() => { + const s: Record> = {} + const t: Record>> = {} + for (const c of containers) { + const role = roleOf(c) + const entity = c.fieldBind?.domainEntityName + if (role === 'single' && entity) s[entity] = {} + if (role === 'table' && entity) t[entity] = [{}] + } + setMaster({}) + setSingles(s) + setTables(t) + setPrecheck(null) + // 拉取所有引用下拉数据源 + const aggs = new Set() + collectOptionSources(containers, aggs) + Promise.all([...aggs].map((a) => api.options(a).then((opts) => [a, opts] as const))) + .then((pairs) => setOptionsMap(Object.fromEntries(pairs))) + .catch(() => {}) + }, [schema.sceneId]) + + function buildPayload() { + const details: Record = {} + for (const c of containers) { + const role = roleOf(c) + const entity = c.fieldBind?.domainEntityName + if (!entity) continue + if (role === 'table') details[entity] = tables[entity] || [] + if (role === 'single') details[entity] = [singles[entity] || {}] + } + return { master, details } + } + + async function doPrecheck() { + try { + const r = await api.precheck(schema.sceneId, buildPayload(), principal) + setPrecheck(r) + if (r.reject) message.warning('预检未通过,请查看提示') + else message.success('预检通过,可提交') + } catch (e: any) { + message.error('预检失败:' + e.message) + } + } + + async function doSubmit() { + setSubmitting(true) + try { + const r = await api.execute(schema.sceneId, buildPayload(), principal) + onExecuted(r) + if (r.success) { + message.success(`${schema.sceneName}成功:${r.rootId ?? ''}`) + setPrecheck(null) + } else { + message.error(`${r.stage} · ${r.message}`) + setPrecheck({ reject: true, errors: r.errors || [], rejectRules: r.appliedRules?.filter((x: any) => x.effect === 'REJECT') || [], warnings: r.warnings || [] }) + } + } catch (e: any) { + message.error('提交失败:' + e.message) + } finally { + setSubmitting(false) + } + } + + return ( +
+ + 模板 {template.templateId} + {template.templateType} + 主聚合 {template.masterAggregate} + 命令 API {schema.commandApi} + 权限 {schema.permissionBind?.join(',')} + + + {precheck && (precheck.reject || precheck.warnings?.length > 0) && ( + + {(precheck.errors || []).map((e: string, i: number) =>
❌ {e}
)} + {(precheck.rejectRules || []).map((r: any, i: number) =>
⛔ [{r.ruleId}] {r.message}
)} + {(precheck.warnings || []).map((w: any, i: number) =>
⚠️ [{w.ruleId}] {w.message}
)} +
+ } + /> + )} + +
+ {containers.map((c) => { + const role = roleOf(c) + if (role === 'master') return + if (role === 'single') return + return + })} + + + + + + + +
+ ) +} + +// ---------- 字段标签(展示 M1 绑定溯源,强化"字段强绑定领域模型") ---------- +function FieldLabel({ comp }: { comp: DragComponent }) { + const fb = comp.fieldBind! + const path = `${fb.bindAggregateId}${fb.domainEntityName ? '.' + fb.domainEntityName : ''}.${fb.domainFieldName}` + return ( + + {fb.formLabel} + {fb.required && *} + [{fb.fieldType}] + {fb.masked && 脱敏} +
⇠ {path}
+
+ ) +} + +function MasterCard({ container, values, setValues, optionsMap }: any) { + const children: DragComponent[] = container.childComponents || [] + return ( + {container.fieldBind?.formLabel}} style={{ marginBottom: 12 }}> + + {children.map((ch) => ( +

+ }> + setValues((prev: any) => ({ ...prev, [ch.fieldBind!.domainFieldName]: v }))} + optionsMap={optionsMap} /> + + + ))} + + + ) +} + +function SingleCard({ container, state, setState, optionsMap }: any) { + const entity = container.fieldBind?.domainEntityName + const children: DragComponent[] = container.childComponents || [] + const values = state[entity] || {} + return ( + {container.fieldBind?.formLabel}} extra={子实体 {entity}} style={{ marginBottom: 12 }}> + + {children.map((ch) => ( + + }> + setState((prev: any) => ({ ...prev, [entity]: { ...prev[entity], [ch.fieldBind!.domainFieldName]: v } }))} + optionsMap={optionsMap} /> + + + ))} + + + ) +} + +function TableCard({ container, state, setState, optionsMap }: any) { + const entity = container.fieldBind?.domainEntityName + const children: DragComponent[] = container.childComponents || [] + const rows: Array> = state[entity] || [] + + const setCell = (idx: number, field: string, v: any) => + setState((prev: any) => { + const next = [...(prev[entity] || [])] + next[idx] = { ...next[idx], [field]: v } + return { ...prev, [entity]: next } + }) + const addRow = () => setState((prev: any) => ({ ...prev, [entity]: [...(prev[entity] || []), {}] })) + const delRow = (idx: number) => setState((prev: any) => ({ ...prev, [entity]: (prev[entity] || []).filter((_: any, i: number) => i !== idx) })) + + const columns = [ + ...children.map((ch) => ({ + title: , + key: ch.compId, + render: (_: any, __: any, idx: number) => ( + setCell(idx, ch.fieldBind!.domainFieldName, v)} optionsMap={optionsMap} /> + ), + })), + { + title: '操作', key: '_op', width: 70, + render: (_: any, __: any, idx: number) => ( +
String(i)} + dataSource={rows.map((r, i) => ({ ...r, _k: i }))} columns={columns as any} + locale={{ emptyText: }} /> + + + ) +} diff --git b/web/src/main.tsx a/web/src/main.tsx new file mode 100644 index 0000000..25f3552 --- /dev/null +++ a/web/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { ConfigProvider } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import 'antd/dist/reset.css' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git b/web/src/panels.tsx a/web/src/panels.tsx new file mode 100644 index 0000000..0263587 --- /dev/null +++ a/web/src/panels.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react' +import { Timeline, Tag, Table, Descriptions, Alert, Empty, Select, Button, Space, Typography, List } from 'antd' +import { api } from './api' +import type { SceneSchema } from './types' + +const { Text, Paragraph } = Typography + +const LAYER_COLOR: Record = { + M8: 'blue', M1: 'green', M2: 'volcano', 'M2/M8': 'volcano', M3: 'geekblue', + M4: 'purple', M5: 'orange', ME: 'magenta', MetaRule: 'red', +} + +/** 执行追踪:把引擎返回的 trace/事件/扣库存/风控可视化,直观展示"元数据解释执行"全过程。 */ +export function TracePanel({ result }: { result: any }) { + if (!result) return + + if (result.success === false) { + return ( +
+ + {result.appliedRules?.length > 0 && ( + 命中的 MetaRule 规则} + dataSource={result.appliedRules} + renderItem={(r: any) => {r.effect}[{r.ruleId}] {r.message}} /> + )} + +
+ ) + } + + return ( +
+ + {result.rootId} + {result.childCount != null && {result.childCount}} + + + + + 领域事件(ME → Outbox) +
+ {(result.events || []).map((e: any) => {e.eventName} → {e.topic})} +
+ + {result.stockActions?.length > 0 && ( + <> + 跨聚合最终一致(ME 规则 + M2 effect 通用执行) +
String(i)} + dataSource={result.stockActions} + columns={[ + { title: '目标聚合', dataIndex: 'targetAggregate' }, + { title: '命令', dataIndex: 'targetCommand' }, + { title: '键', render: (_: any, a: any) => `${a.keyField}=${a.key}` }, + { title: '变更', render: (_: any, a: any) => `${a.op} ${a.amount}` }, + { title: '剩余', dataIndex: 'remaining' }, + { title: '状态', dataIndex: 'status', render: (s: string) => {s} }, + ]} /> + + )} + + {result.warnings?.length > 0 && ( +
⚠️ [{w.ruleId}] {w.message}
)} /> + )} + + ) +} + +function TraceTimeline({ trace }: { trace: any[] }) { + if (!trace?.length) return null + return ( + ({ + color: LAYER_COLOR[s.layer] || 'gray', + children: {s.layer}{s.step} — {s.detail}, + }))} /> + ) +} + +/** 溯源面板:展示页面各部分分别来自哪一层模型,以及自动关联的 MetaRule 规则。 */ +export function ProvenancePanel({ schema }: { schema: SceneSchema }) { + const sl = schema.sourceLayers || {} + return ( +
+ 本页面无任何业务硬编码,各部分均由下列本体模型层实时合成: +
({ k, v }))} + columns={[ + { title: '页面要素', dataIndex: 'k' }, + { title: '来源模型层', dataIndex: 'v', render: (v: string) => {v} }, + ]} /> + 自动关联的 MetaRule 动态规则 + ( + +
+ {r.effect} + {r.ruleName} {r.ruleId} +
{r.matchCondition}
+
→ {r.message}
+
+
+ )} /> + 规则全局参数 +
+ {Object.entries(schema.ruleGlobalParams || {}).map(([k, v]) => {k} = {String(v)})} +
+ + ) +} + +/** 模型查看器:直接查看任意本体层解析后的内容,证明"页面即模型"。 */ +export function ModelViewer() { + const [layers, setLayers] = useState([]) + const [code, setCode] = useState('M8') + const [content, setContent] = useState(null) + const [dir, setDir] = useState('') + + useEffect(() => { api.models().then((d) => { setLayers(d.layers); setDir(d.modelsDir) }) }, []) + useEffect(() => { if (code) api.model(code).then((d) => setContent(d.content)) }, [code]) + + return ( +
+ +
String(r[rowKeyField] ?? i)} dataSource={rows} columns={cols} /> + 事件 Outbox(最近) +
{s} }, + ]} /> + + ) +} diff --git b/web/src/types.ts a/web/src/types.ts new file mode 100644 index 0000000..b128487 --- /dev/null +++ a/web/src/types.ts @@ -0,0 +1,52 @@ +// 与后端 SceneSchemaService 输出对应的类型(宽松定义,随模型演进自动兼容) + +export interface FieldBind { + bindAggregateId: string + bindSourceType: 'aggregate_root' | 'aggregate_entity' + domainEntityName?: string + domainFieldName: string + formLabel?: string + placeholder?: string + required?: boolean + maskField?: boolean + fieldType?: string + masked?: boolean + optionsSource?: { + aggregateId: string + endpoint: string + valueField: string + labelField: string + } +} + +export interface DragComponent { + compId: string + compType: 'input' | 'number_input' | 'date_picker' | 'switch' | 'table' | 'card' | 'text_area' | 'select' + width?: string + span?: number + fieldBind?: FieldBind + childComponents?: DragComponent[] +} + +export interface SceneSchema { + sceneId: string + sceneName: string + aggregateScope: string[] + permissionBind: string[] + command?: any + commandApi?: string + template?: { + templateId: string + templateName: string + templateType: string + renderMode: string + masterAggregate: string + detailAggregates: string[] + rootComponents: DragComponent[] + } + rules?: Array<{ ruleId: string; ruleName: string; effect: string; message: string; matchCondition: string }> + ruleGlobalParams?: Record + sourceLayers?: Record +} + +export type OptionItem = { value: any; label: any; data: Record } diff --git b/web/tsconfig.json a/web/tsconfig.json new file mode 100644 index 0000000..445f15b --- /dev/null +++ a/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "allowJs": true + }, + "include": ["src"] +} diff --git b/web/vite.config.ts a/web/vite.config.ts new file mode 100644 index 0000000..0206bb1 --- /dev/null +++ a/web/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// 开发服务器把 /api 代理到引擎(8080),避免跨域并让前端无需硬编码后端地址 +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:8091', + changeOrigin: true, + }, + }, + }, +})