Commit a85cc757cd6a9f140a40300cf769a3e4f86d5958

Authored by zichun
1 parent 1946deb7

feat: 默认存储切换为 MySQL8,H2 降级为 h2 profile

- application.yml 默认数据源改为 MySQL8(env 可覆盖 DB_HOST/PORT/NAME/USER/PASS)
- 新增 application-h2.yml:零依赖 H2 内存库 fallback(--spring.profiles.active=h2)
- SchemaInitializer 建表 DDL 改为元数据判断列存在,兼容 MySQL(弃用 ADD COLUMN IF NOT EXISTS 方言)
- 新增 tests/verify-mysql-rollback.sh:真实 MySQL(InnoDB) 上验证超卖整单事务回滚
- CLAUDE.md/start.sh 同步默认存储说明
CLAUDE.md
... ... @@ -61,7 +61,7 @@ bash tests/verify-model-change.sh # 验è¯"改模型→改行为"(reload é
61 61  
62 62 ## 约定
63 63  
64   -- 存储默认 H2 内存库(MySQL 兼容模å¼ï¼‰ï¼›ç”Ÿäº§ profile è§ `deploy/`。
  64 +- 存储默认 **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` 方言)。
65 65 - Java 编译目标 21(`maven.compiler.release=21`),å¯åœ¨æ›´é«˜ JDK 上è¿è¡Œã€‚
66 66 - 新增原å­ç»„件类型:在 `FieldControl.tsx` 注册表登记å³å¯ï¼Œä¸šåС页é¢é›¶æ”¹åŠ¨ã€‚
67 67 - 演示引导数æ®åœ¨ `engine/src/main/resources/seed-data.yaml`ï¼ˆéžæœ¬ä½“的一部分)。
... ...
engine/src/main/java/com/onto/engine/persist/SchemaInitializer.java
... ... @@ -92,8 +92,30 @@ public class SchemaInitializer {
92 92 }
93 93  
94 94 private void addColumnIfAbsent(String table, String column, String sqlType) {
95   - // H2 支持 IF NOT EXISTS;已是主键列时 CREATE 已建,跳过
96   - jdbc.execute("ALTER TABLE " + table + " ADD COLUMN IF NOT EXISTS " + column + " " + sqlType);
  95 + // 不能用 "ADD COLUMN IF NOT EXISTS":那是 H2/MariaDB 方言,MySQL 8 不支持。
  96 + // 改为查 JDBC 元数据判断列是否已存在,两种库通用;已是主键列时 CREATE 已建,跳过。
  97 + if (columnExists(table, column)) return;
  98 + jdbc.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + sqlType);
  99 + }
  100 +
  101 + /** 通过 JDBC 元数据判断表中是否已有该列(大小写不敏感,跨 H2/MySQL 通用)。 */
  102 + private boolean columnExists(String table, String column) {
  103 + return Boolean.TRUE.equals(jdbc.execute((java.sql.Connection conn) -> {
  104 + java.sql.DatabaseMetaData md = conn.getMetaData();
  105 + String catalog = conn.getCatalog();
  106 + // 不同库对标识符大小写存储不一致:依次尝试原样/小写/大写,命中即读列名集合。
  107 + for (String t : new String[]{table, table.toLowerCase(), table.toUpperCase()}) {
  108 + try (java.sql.ResultSet rs = md.getColumns(catalog, null, t, null)) {
  109 + boolean matched = false;
  110 + while (rs.next()) {
  111 + matched = true;
  112 + if (column.equalsIgnoreCase(rs.getString("COLUMN_NAME"))) return true;
  113 + }
  114 + if (matched) return false; // 该表名匹配到了列,但没有目标列
  115 + }
  116 + }
  117 + return false; // 表还没有任何列信息(理论上 CREATE 后至少有主键列)
  118 + }));
97 119 }
98 120  
99 121 private String sqlType(String m1Type, boolean isPk) {
... ...
engine/src/main/resources/application-h2.yml 0 → 100644
  1 +# H2 profile:零外部依赖开箱即跑的 H2 内存库(MySQL 兼容模式)。
  2 +# 启用方式:--spring.profiles.active=h2
  3 +# 用途:无 MySQL 环境时快速体验/跑测试;主用存储已切为 MySQL(见 application.yml)。
  4 +spring:
  5 + datasource:
  6 + url: jdbc:h2:mem:trade_db;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1
  7 + driver-class-name: org.h2.Driver
  8 + username: sa
  9 + password: ""
  10 + h2:
  11 + console:
  12 + enabled: true
  13 + path: /h2-console
... ...
engine/src/main/resources/application.yml
... ... @@ -4,21 +4,18 @@ server:
4 4 spring:
5 5 application:
6 6 name: onto-engine
7   - # 默认 H2 内存库,MySQL8 兼容模式,零外部依赖开箱即跑
  7 + # 默认存储 = MySQL8(本机 Docker 容器,映射端口 3307;env 可覆盖 DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS)
  8 + # 零外部依赖开箱即跑:--spring.profiles.active=h2 切回 H2 内存库(见 application-h2.yml)
8 9 datasource:
9   - url: jdbc:h2:mem:trade_db;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1
10   - driver-class-name: org.h2.Driver
11   - username: sa
12   - password: ""
13   - h2:
14   - console:
15   - enabled: true
16   - path: /h2-console
  10 + 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
  11 + driver-class-name: com.mysql.cj.jdbc.Driver
  12 + username: ${DB_USER:trade}
  13 + password: ${DB_PASS:trade123}
17 14  
18 15 # 本体模型引擎配置
19 16 onto:
20 17 # 模型目录:引擎启动时从此目录加载全部 YAML 本体模型;支持热重载(POST /api/meta/reload)
21   - models-dir: ../models
  18 + models-dir: ${ONTO_MODELS_DIR:../models}
22 19 # 演示态默认主体(M5 权限主体)
23 20 default-principal: normal_user
24 21  
... ...
start.sh
... ... @@ -11,6 +11,7 @@ command -v npm >/dev/null || { echo "缺少 npm"; exit 1; }
11 11  
12 12 ENGINE_LOG="$ROOT/.engine.log"
13 13 echo "==> 启动后端引擎 (日志: $ENGINE_LOG;首次会下载 Maven 与依赖)"
  14 +echo " 默认存储为 MySQL(127.0.0.1:3307);如无 MySQL,请改用 './mvnw -Dspring-boot.run.profiles=h2 spring-boot:run' 起 H2。"
14 15 ( cd "$ROOT/engine" && ./mvnw -q -DskipTests spring-boot:run > "$ENGINE_LOG" 2>&1 ) &
15 16 ENGINE_PID=$!
16 17  
... ... @@ -37,5 +38,5 @@ echo "==> 安装前端依赖 (首次)"
37 38 ( cd "$ROOT/web" && [ -d node_modules ] || npm install )
38 39  
39 40 echo "==> 启动前端 (Vite)。浏览器打开提示的 Local 地址,进入「新增订单」页。"
40   -echo " 后端: http://localhost:8080 H2 控制台: http://localhost:8080/h2-console"
  41 +echo " 后端: http://localhost:8080(默认连 MySQL:3307;无 MySQL 时可用 --spring.profiles.active=h2 起 H2 内存库)"
41 42 cd "$ROOT/web" && npm run dev
... ...
tests/verify-mysql-rollback.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 验证「事务回滚」在真实 MySQL(InnoDB) 上有效。
  3 +#
  4 +# 引擎唯一事务边界是 CommandEngine.execute 上的 @Transactional。CreateOrder(create 命令)先
  5 +# COMMIT_TRANSACTION 落库【订单主单+明细+付款条件+送货地址】并写 Outbox 事件,再由 ME 规则
  6 +# OrderCreated→StockDeduct 逐明细扣库存;带 guardField 的扣减 UPDATE 在库存不足时影响 0 行,
  7 +# 引擎调 setRollbackOnly()。若回滚有效:此前已插入的订单根+子实体+Outbox 应【全部消失】、库存不变。
  8 +#
  9 +# 前置:MySQL 容器 mysql-onto-rollback 在 3307;引擎以 --spring.profiles.active=mysql 运行(默认 8081)。
  10 +set -euo pipefail
  11 +BASE="${BASE:-http://localhost:8081}"
  12 +DB=(mysql -h127.0.0.1 -P3307 -utrade -ptrade123 -N -B trade_db)
  13 +
  14 +q() { "${DB[@]}" -e "$1" 2>/dev/null; } # 标量/多行查询
  15 +post() { curl -s -X POST "$BASE/api/scene/SCENE_CREATE_ORDER/execute" -H 'Content-Type: application/json' -d "$1"; }
  16 +jget() { python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('$1',''))"; }
  17 +bold() { printf "\n\033[1m%s\033[0m\n" "$1"; }
  18 +pass=0; fail=0
  19 +ok() { printf " \033[32m✔ PASS\033[0m %s\n" "$1"; pass=$((pass+1)); }
  20 +bad() { printf " \033[31m✘ FAIL\033[0m %s\n" "$1"; fail=$((fail+1)); }
  21 +eq() { if [ "$2" = "$3" ]; then ok "$1 (=$2)"; else bad "$1 期望[$3] 实得[$2]"; fi; }
  22 +
  23 +curl -s -o /dev/null "$BASE/api/meta/models" || { echo "引擎未就绪:$BASE"; exit 1; }
  24 +
  25 +# ---------- 基线快照 ----------
  26 +P2_0=$(q "SELECT stock_num FROM t_product_main WHERE product_id='P002'")
  27 +ORD_0=$(q "SELECT COUNT(*) FROM t_order_main")
  28 +ITM_0=$(q "SELECT COUNT(*) FROM t_order_item")
  29 +OBX_0=$(q "SELECT COUNT(*) FROM t_domain_outbox")
  30 +bold "基线:P002库存=$P2_0 订单数=$ORD_0 明细数=$ITM_0 Outbox=$OBX_0"
  31 +
  32 +# ---------- 用例一:正向提交(对照,证明同一路径能落库)----------
  33 +bold "① 正向订单:C001 买 P002 ×3(3 ≤ 8 库存)应成功并落库、扣库存"
  34 +OK_ORDER='{"master":{"customerId":"C001","totalCurrency":"CNY","createTime":"2026-07-24T10:00:00"},
  35 + "details":{"OrderItem":[{"productId":"P002","quantity":3,"itemPrice":199,"itemCurrency":"CNY"}],
  36 + "PaymentTerm":[{"paymentType":"现金","dueDays":0}],
  37 + "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"x","receiverName":"张三","receiverPhone":"13800001111"}]}}'
  38 +R=$(post "$OK_ORDER"); echo " 响应: success=$(echo "$R" | jget success) rootId=$(echo "$R" | jget rootId)"
  39 +eq "订单表 +1" "$(q "SELECT COUNT(*) FROM t_order_main")" "$((ORD_0+1))"
  40 +eq "明细表 +1" "$(q "SELECT COUNT(*) FROM t_order_item")" "$((ITM_0+1))"
  41 +eq "P002 库存扣至 5" "$(q "SELECT stock_num FROM t_product_main WHERE product_id='P002'")" "$((P2_0-3))"
  42 +
  43 +# 更新基线到用例一之后
  44 +P2_1=$(q "SELECT stock_num FROM t_product_main WHERE product_id='P002'")
  45 +ORD_1=$(q "SELECT COUNT(*) FROM t_order_main")
  46 +ITM_1=$(q "SELECT COUNT(*) FROM t_order_item")
  47 +OBX_1=$(q "SELECT COUNT(*) FROM t_domain_outbox")
  48 +
  49 +# ---------- 用例二:超卖 → 事务回滚(核心)----------
  50 +bold "② 超卖订单:C001 买 P002 ×99(99 > $P2_1 库存)应失败并【整单回滚】"
  51 +BAD_ORDER='{"master":{"customerId":"C001","totalCurrency":"CNY","createTime":"2026-07-24T10:05:00"},
  52 + "details":{"OrderItem":[{"productId":"P002","quantity":99,"itemPrice":199,"itemCurrency":"CNY"}],
  53 + "PaymentTerm":[{"paymentType":"现金","dueDays":0}],
  54 + "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"x","receiverName":"张三","receiverPhone":"13800001111"}]}}'
  55 +R=$(post "$BAD_ORDER")
  56 +echo " 响应: success=$(echo "$R" | jget success) stage=$(echo "$R" | jget stage) message=$(echo "$R" | jget message)"
  57 +eq "命令应失败" "$(echo "$R" | jget success)" "False"
  58 +eq "订单表不新增(已回滚)" "$(q "SELECT COUNT(*) FROM t_order_main")" "$ORD_1"
  59 +eq "明细表不新增(已回滚)" "$(q "SELECT COUNT(*) FROM t_order_item")" "$ITM_1"
  60 +eq "Outbox不新增(已回滚)" "$(q "SELECT COUNT(*) FROM t_domain_outbox")" "$OBX_1"
  61 +eq "P002 库存不变" "$(q "SELECT stock_num FROM t_product_main WHERE product_id='P002'")" "$P2_1"
  62 +
  63 +# ---------- 结论 ----------
  64 +bold "结论:PASS=$pass FAIL=$fail"
  65 +if [ "$fail" -eq 0 ]; then
  66 + printf "\033[32m事务回滚在真实 MySQL 上有效:超卖时订单根/明细/Outbox 与扣库存作为一个 InnoDB 事务整体回滚。\033[0m\n"
  67 +else
  68 + printf "\033[31m存在未通过断言,回滚可能失效,请查看上方明细。\033[0m\n"; exit 1
  69 +fi
... ...