diff --git a/CLAUDE.md b/CLAUDE.md index 49f2e6f..b959bde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ bash tests/verify-model-change.sh # 验证"改模型→改行为"(reload ## 约定 -- 存储默认 H2 内存库(MySQL 兼容模式);生产 profile 见 `deploy/`。 +- 存储默认 **MySQL8**(本机 Docker 容器,端口 3307;env 覆盖 `DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS`)。无 MySQL 环境可 `--spring.profiles.active=h2` 切回 H2 内存库(`application-h2.yml`,零外部依赖);生产 profile 见 `deploy/`。建表 DDL 已做 H2/MySQL 双兼容(`SchemaInitializer` 用元数据判断列是否存在,勿再用 `ADD COLUMN IF NOT EXISTS` 方言)。 - Java 编译目标 21(`maven.compiler.release=21`),可在更高 JDK 上运行。 - 新增原子组件类型:在 `FieldControl.tsx` 注册表登记即可,业务页面零改动。 - 演示引导数据在 `engine/src/main/resources/seed-data.yaml`(非本体的一部分)。 diff --git a/engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java b/engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java index 0d91cc2..1599ee0 100644 --- a/engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java +++ b/engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java @@ -92,8 +92,30 @@ public class SchemaInitializer { } 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); + // 不能用 "ADD COLUMN IF NOT EXISTS":那是 H2/MariaDB 方言,MySQL 8 不支持。 + // 改为查 JDBC 元数据判断列是否已存在,两种库通用;已是主键列时 CREATE 已建,跳过。 + if (columnExists(table, column)) return; + jdbc.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + sqlType); + } + + /** 通过 JDBC 元数据判断表中是否已有该列(大小写不敏感,跨 H2/MySQL 通用)。 */ + private boolean columnExists(String table, String column) { + return Boolean.TRUE.equals(jdbc.execute((java.sql.Connection conn) -> { + java.sql.DatabaseMetaData md = conn.getMetaData(); + String catalog = conn.getCatalog(); + // 不同库对标识符大小写存储不一致:依次尝试原样/小写/大写,命中即读列名集合。 + for (String t : new String[]{table, table.toLowerCase(), table.toUpperCase()}) { + try (java.sql.ResultSet rs = md.getColumns(catalog, null, t, null)) { + boolean matched = false; + while (rs.next()) { + matched = true; + if (column.equalsIgnoreCase(rs.getString("COLUMN_NAME"))) return true; + } + if (matched) return false; // 该表名匹配到了列,但没有目标列 + } + } + return false; // 表还没有任何列信息(理论上 CREATE 后至少有主键列) + })); } private String sqlType(String m1Type, boolean isPk) { diff --git a/engine/src/main/resources/application-h2.yml b/engine/src/main/resources/application-h2.yml new file mode 100644 index 0000000..9ee1ac6 --- /dev/null +++ b/engine/src/main/resources/application-h2.yml @@ -0,0 +1,13 @@ +# H2 profile:零外部依赖开箱即跑的 H2 内存库(MySQL 兼容模式)。 +# 启用方式:--spring.profiles.active=h2 +# 用途:无 MySQL 环境时快速体验/跑测试;主用存储已切为 MySQL(见 application.yml)。 +spring: + 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 diff --git a/engine/src/main/resources/application.yml b/engine/src/main/resources/application.yml index 997f579..20bfef9 100644 --- a/engine/src/main/resources/application.yml +++ b/engine/src/main/resources/application.yml @@ -4,21 +4,18 @@ server: spring: application: name: onto-engine - # 默认 H2 内存库,MySQL8 兼容模式,零外部依赖开箱即跑 + # 默认存储 = MySQL8(本机 Docker 容器,映射端口 3307;env 可覆盖 DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS) + # 零外部依赖开箱即跑:--spring.profiles.active=h2 切回 H2 内存库(见 application-h2.yml) 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 + url: jdbc:mysql://${DB_HOST:127.0.0.1}:${DB_PORT:3307}/${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} # 本体模型引擎配置 onto: # 模型目录:引擎启动时从此目录加载全部 YAML 本体模型;支持热重载(POST /api/meta/reload) - models-dir: ../models + models-dir: ${ONTO_MODELS_DIR:../models} # 演示态默认主体(M5 权限主体) default-principal: normal_user diff --git a/start.sh b/start.sh index ac32285..efc3c72 100755 --- a/start.sh +++ b/start.sh @@ -11,6 +11,7 @@ command -v npm >/dev/null || { echo "缺少 npm"; exit 1; } ENGINE_LOG="$ROOT/.engine.log" echo "==> 启动后端引擎 (日志: $ENGINE_LOG;首次会下载 Maven 与依赖)" +echo " 默认存储为 MySQL(127.0.0.1:3307);如无 MySQL,请改用 './mvnw -Dspring-boot.run.profiles=h2 spring-boot:run' 起 H2。" ( cd "$ROOT/engine" && ./mvnw -q -DskipTests spring-boot:run > "$ENGINE_LOG" 2>&1 ) & ENGINE_PID=$! @@ -37,5 +38,5 @@ echo "==> 安装前端依赖 (首次)" ( cd "$ROOT/web" && [ -d node_modules ] || npm install ) echo "==> 启动前端 (Vite)。浏览器打开提示的 Local 地址,进入「新增订单」页。" -echo " 后端: http://localhost:8080 H2 控制台: http://localhost:8080/h2-console" +echo " 后端: http://localhost:8080(默认连 MySQL:3307;无 MySQL 时可用 --spring.profiles.active=h2 起 H2 内存库)" cd "$ROOT/web" && npm run dev diff --git a/tests/verify-mysql-rollback.sh b/tests/verify-mysql-rollback.sh new file mode 100755 index 0000000..decd5ad --- /dev/null +++ b/tests/verify-mysql-rollback.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# 验证「事务回滚」在真实 MySQL(InnoDB) 上有效。 +# +# 引擎唯一事务边界是 CommandEngine.execute 上的 @Transactional。CreateOrder(create 命令)先 +# COMMIT_TRANSACTION 落库【订单主单+明细+付款条件+送货地址】并写 Outbox 事件,再由 ME 规则 +# OrderCreated→StockDeduct 逐明细扣库存;带 guardField 的扣减 UPDATE 在库存不足时影响 0 行, +# 引擎调 setRollbackOnly()。若回滚有效:此前已插入的订单根+子实体+Outbox 应【全部消失】、库存不变。 +# +# 前置:MySQL 容器 mysql-onto-rollback 在 3307;引擎以 --spring.profiles.active=mysql 运行(默认 8081)。 +set -euo pipefail +BASE="${BASE:-http://localhost:8081}" +DB=(mysql -h127.0.0.1 -P3307 -utrade -ptrade123 -N -B trade_db) + +q() { "${DB[@]}" -e "$1" 2>/dev/null; } # 标量/多行查询 +post() { curl -s -X POST "$BASE/api/scene/SCENE_CREATE_ORDER/execute" -H 'Content-Type: application/json' -d "$1"; } +jget() { python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('$1',''))"; } +bold() { printf "\n\033[1m%s\033[0m\n" "$1"; } +pass=0; fail=0 +ok() { printf " \033[32m✔ PASS\033[0m %s\n" "$1"; pass=$((pass+1)); } +bad() { printf " \033[31m✘ FAIL\033[0m %s\n" "$1"; fail=$((fail+1)); } +eq() { if [ "$2" = "$3" ]; then ok "$1 (=$2)"; else bad "$1 期望[$3] 实得[$2]"; fi; } + +curl -s -o /dev/null "$BASE/api/meta/models" || { echo "引擎未就绪:$BASE"; exit 1; } + +# ---------- 基线快照 ---------- +P2_0=$(q "SELECT stock_num FROM t_product_main WHERE product_id='P002'") +ORD_0=$(q "SELECT COUNT(*) FROM t_order_main") +ITM_0=$(q "SELECT COUNT(*) FROM t_order_item") +OBX_0=$(q "SELECT COUNT(*) FROM t_domain_outbox") +bold "基线:P002库存=$P2_0 订单数=$ORD_0 明细数=$ITM_0 Outbox=$OBX_0" + +# ---------- 用例一:正向提交(对照,证明同一路径能落库)---------- +bold "① 正向订单:C001 买 P002 ×3(3 ≤ 8 库存)应成功并落库、扣库存" +OK_ORDER='{"master":{"customerId":"C001","totalCurrency":"CNY","createTime":"2026-07-24T10:00:00"}, + "details":{"OrderItem":[{"productId":"P002","quantity":3,"itemPrice":199,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"现金","dueDays":0}], + "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"x","receiverName":"张三","receiverPhone":"13800001111"}]}}' +R=$(post "$OK_ORDER"); echo " 响应: success=$(echo "$R" | jget success) rootId=$(echo "$R" | jget rootId)" +eq "订单表 +1" "$(q "SELECT COUNT(*) FROM t_order_main")" "$((ORD_0+1))" +eq "明细表 +1" "$(q "SELECT COUNT(*) FROM t_order_item")" "$((ITM_0+1))" +eq "P002 库存扣至 5" "$(q "SELECT stock_num FROM t_product_main WHERE product_id='P002'")" "$((P2_0-3))" + +# 更新基线到用例一之后 +P2_1=$(q "SELECT stock_num FROM t_product_main WHERE product_id='P002'") +ORD_1=$(q "SELECT COUNT(*) FROM t_order_main") +ITM_1=$(q "SELECT COUNT(*) FROM t_order_item") +OBX_1=$(q "SELECT COUNT(*) FROM t_domain_outbox") + +# ---------- 用例二:超卖 → 事务回滚(核心)---------- +bold "② 超卖订单:C001 买 P002 ×99(99 > $P2_1 库存)应失败并【整单回滚】" +BAD_ORDER='{"master":{"customerId":"C001","totalCurrency":"CNY","createTime":"2026-07-24T10:05:00"}, + "details":{"OrderItem":[{"productId":"P002","quantity":99,"itemPrice":199,"itemCurrency":"CNY"}], + "PaymentTerm":[{"paymentType":"现金","dueDays":0}], + "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"x","receiverName":"张三","receiverPhone":"13800001111"}]}}' +R=$(post "$BAD_ORDER") +echo " 响应: success=$(echo "$R" | jget success) stage=$(echo "$R" | jget stage) message=$(echo "$R" | jget message)" +eq "命令应失败" "$(echo "$R" | jget success)" "False" +eq "订单表不新增(已回滚)" "$(q "SELECT COUNT(*) FROM t_order_main")" "$ORD_1" +eq "明细表不新增(已回滚)" "$(q "SELECT COUNT(*) FROM t_order_item")" "$ITM_1" +eq "Outbox不新增(已回滚)" "$(q "SELECT COUNT(*) FROM t_domain_outbox")" "$OBX_1" +eq "P002 库存不变" "$(q "SELECT stock_num FROM t_product_main WHERE product_id='P002'")" "$P2_1" + +# ---------- 结论 ---------- +bold "结论:PASS=$pass FAIL=$fail" +if [ "$fail" -eq 0 ]; then + printf "\033[32m事务回滚在真实 MySQL 上有效:超卖时订单根/明细/Outbox 与扣库存作为一个 InnoDB 事务整体回滚。\033[0m\n" +else + printf "\033[31m存在未通过断言,回滚可能失效,请查看上方明细。\033[0m\n"; exit 1 +fi