Commit a85cc757cd6a9f140a40300cf769a3e4f86d5958
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 同步默认存储说明
Showing
6 changed files
with
116 additions
and
14 deletions
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 | ... | ... |