diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 62261d1..e529d58 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -16,6 +16,10 @@ on: description: '构建前清空 maven m2 缓存' required: false default: 'false' + tests_only: + description: '只验证测试和 JAR,不构建推送镜像或通知建档' + required: false + default: 'true' jobs: build-push: @@ -36,6 +40,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src + git -C /tmp/src fetch --depth 1 origin "$GITHUB_SHA" + git -C /tmp/src checkout --detach FETCH_HEAD cp -a /tmp/src/. . rm -rf /tmp/src # 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符 @@ -64,6 +70,7 @@ jobs: # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。 BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)" echo "BUILD_CONTAINER=${BUILD_CONTAINER}" + trap 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true' EXIT docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true docker run -d --name "$BUILD_CONTAINER" \ --network infra-infra-net \ @@ -71,13 +78,33 @@ jobs: -w /build maven:3.6.3-jdk-8 sleep 3600 docker cp "${PWD}/." "$BUILD_CONTAINER":/build/ docker exec "$BUILD_CONTAINER" sh -c "printf '%s' 'nexus-reponexus-internalhttp://nexus:8081/repository/maven-public/truetruealwaysnexus-repo' > /root/.m2/settings.xml" - docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package' + # 为真实 MySQL 测试创建本次 job 独占的临时数据库;不映射宿主端口、不连接业务库。 + DB_CONTAINER="${BUILD_CONTAINER}-db" + trap 'docker rm -f "$BUILD_CONTAINER" "$DB_CONTAINER" >/dev/null 2>&1 || true' EXIT + export MYSQL_ROOT_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') + export MYSQL_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') + docker create --name "$DB_CONTAINER" --network infra-infra-net \ + -e MYSQL_DATABASE=flowcenter_ci -e MYSQL_USER=flow_ci \ + -e MYSQL_PASSWORD -e MYSQL_ROOT_PASSWORD mysql:8.0 --skip-log-bin >/dev/null + docker cp "${PWD}/ci/flow-test-schema.sql" "$DB_CONTAINER":/docker-entrypoint-initdb.d/001-schema.sql + docker start "$DB_CONTAINER" >/dev/null + ready=0 + for n in $(seq 1 60); do + if docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT 1" >/dev/null 2>&1'; then ready=1; break; fi + sleep 2 + done + [ "$ready" -eq 1 ] || { echo 'Isolated CI MySQL did not become ready' >&2; exit 1; } + export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true" + export CI_TEST_DB_USER=flow_ci CI_TEST_DB_PASSWORD="$MYSQL_PASSWORD" + docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \ + "$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package' docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar' mkdir -p "${PWD}/target" docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar" docker rm -f "$BUILD_CONTAINER" - name: 构建并推送镜像 + if: ${{ inputs.tests_only != 'true' }} run: | set -e TAG=$(echo "$GITHUB_SHA" | cut -c1-8) @@ -88,6 +115,7 @@ jobs: # 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual) - name: 通知 projman 建档(部署由运维手动触发) + if: ${{ inputs.tests_only != 'true' }} run: | curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \ -H "Content-Type: application/json" \ diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..a98425c --- /dev/null +++ b/ci/README.md @@ -0,0 +1,29 @@ +# FL 指定门禁与独立 MySQL + +`test-gate.sh` 是 Gitea 与本地等价 CI 共用的入口。无参运行 `test`;工作流使用 +`sh ci/test-gate.sh clean package`,真实测试未通过不会生成待发布 JAR,后续镜像步骤不会运行。 +所有指定类必须存在;Surefire 不允许零测试;Hibernate 固定 `ddl-auto=validate`。 + +工作流在 `infra-infra-net` 内新建 `${BUILD_CONTAINER}-db`,镜像 `mysql:8.0`,不映射宿主端口。 +在启动之前把 `flow-test-schema.sql` 复制进初始化目录;该文件只含 14 张表的结构,不含业务数据。 +镜像初始化阶段建立测试 schema,测试阶段只 validate。每个 job 的数据库容器名和随机凭据独立, +EXIT trap 回收 Maven/MySQL 容器;不访问 160 业务库。 + +随机凭据仅经容器环境变量传递,不写仓库或日志。Maven 子容器必须获得: + +- `CI_TEST_DB_URL`:MySQL JDBC URL;schema 必须为 `flowcenter_ci` 或 `flowcenter_ci_*`。 +- `CI_TEST_DB_USER`、`CI_TEST_DB_PASSWORD`:本次数据库的专用账号与密码。 + +脚本只允许 localhost、127.0.0.1 或 `mavenbuild-*-db` 主机,随后映射为 Spring datasource 环境变量。 +`OapiFlowTasksByBizTest` 在任何 cleanDb 前检查 JDBC 实际连接的 host/schema,防止 profile 覆盖错误。 +CI 场景必须实际连接 `flowcenter_ci*`,不能悄悄改用默认开发库;11 项继续为真 MySQL 测试。 + +本地等价运行:在独立测试库按此 schema 初始化后,通过进程环境设置上述三个变量, +再执行 `sh ci/test-gate.sh clean package`。凭据不要写入命令历史或提交到仓库。 + +本次已在本机 MySQL 8.0.45 做正反等价验证:完整 schema 通过,空 schema 验证失败且不生成 JAR。 +三个 workflow 的手动入口新增 `tests_only=true`(默认),可以在 runner 上只验测试/JAR,不推镜像或通知建档。 +本机没有 Docker,Gitea runner 的容器启动、镜像拉取和网络连通未实跑;这些仍需在 runner 上验证, +不能将等价 Maven 证据描述为实际 Gitea workflow 已通过。 + +实体结构变更时应显式刷新 SQL 快照并重新验证;不能把 validate 改为 update 来绕过缺表或缺列。 diff --git a/ci/flow-test-schema.sql b/ci/flow-test-schema.sql new file mode 100644 index 0000000..b09bab3 --- /dev/null +++ b/ci/flow-test-schema.sql @@ -0,0 +1,330 @@ + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_app` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `app_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `app_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, + `enabled` int NOT NULL, + `sch_id` bigint NOT NULL, + `sort_no` int DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_app_sch_key` (`sch_id`,`app_key`), + KEY `idx_flw_app_sch_enabled` (`sch_id`,`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_assignee_map` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `dept_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `dept_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + `sch_id` bigint NOT NULL, + `status` int NOT NULL, + `user_id` int NOT NULL, + `user_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_map_sch_dept_user` (`sch_id`,`dept_key`,`user_id`), + KEY `idx_flw_map_sch_dept` (`sch_id`,`dept_key`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_attachment` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `field_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `file_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `file_path` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, + `file_size` bigint DEFAULT NULL, + `instance_id` bigint NOT NULL, + `mime_type` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + `sch_id` bigint NOT NULL, + `web_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_flw_att_inst` (`instance_id`,`sch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_branch` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `branch_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `instance_id` bigint NOT NULL, + `status` int NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_branch_inst_key` (`instance_id`,`branch_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_callback_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `biz_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `event` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `event_id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `instance_id` bigint NOT NULL, + `last_error` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL, + `last_http_status` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `next_retry_time` datetime DEFAULT NULL, + `payload_json` longtext COLLATE utf8mb4_general_ci NOT NULL, + `push_time` datetime DEFAULT NULL, + `reg_id` bigint NOT NULL, + `retry_count` int NOT NULL, + `sch_id` bigint NOT NULL, + `status` int NOT NULL, + `resp_body` text COLLATE utf8mb4_general_ci, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_log_event` (`event_id`), + KEY `idx_flw_log_scan` (`status`,`next_retry_time`), + KEY `idx_flw_log_inst` (`instance_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_callback_reg` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `app_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `callback_url` varchar(500) COLLATE utf8mb4_general_ci NOT NULL, + `event_types` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, + `flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `sch_id` bigint NOT NULL, + `secret` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `status` int NOT NULL, + `base_path` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_reg_sch_app_flow` (`sch_id`,`app_key`,`flow_guid`), + KEY `idx_flw_reg_flow` (`sch_id`,`flow_guid`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_cc` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `instance_id` bigint NOT NULL COMMENT '归属办件', + `sch_id` bigint NOT NULL COMMENT '租户键(冗余,列表免 join)', + `node_id` varchar(64) NOT NULL COMMENT '定义节点 id', + `node_name` varchar(128) DEFAULT NULL COMMENT '节点名快照', + `user_id` int NOT NULL COMMENT '接收人 id', + `user_name` varchar(128) DEFAULT NULL COMMENT '接收人姓名快照', + `sent_time` datetime(3) NOT NULL COMMENT '抄送时间(节点激活时刻,毫秒)', + `create_by` varchar(64) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(64) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_flw_cc_user` (`user_id`,`sent_time`), + KEY `idx_flw_cc_inst` (`instance_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='抄送留痕(NOTIFY 节点逐接收人一行)'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_def` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `content_json` longtext COLLATE utf8mb4_general_ci NOT NULL, + `flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `form_id` bigint DEFAULT NULL, + `item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, + `publish_time` datetime DEFAULT NULL, + `sch_id` bigint NOT NULL, + `status` int NOT NULL, + `version` int NOT NULL, + `app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `change_log` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, + `form_uuid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `form_version` int DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_def_sch_code_ver` (`sch_id`,`code`,`version`), + UNIQUE KEY `uk_flw_def_guid_ver` (`sch_id`,`flow_guid`,`version`), + KEY `idx_flw_def_guid` (`sch_id`,`flow_guid`,`status`,`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_dept` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `dept_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, + `sch_id` bigint NOT NULL, + `source_group_id` int DEFAULT NULL, + `status` int NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_dept_sch_srcgroup` (`sch_id`,`source_group_id`), + KEY `idx_flw_dept_sch_status` (`sch_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_dept_member` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `dept_id` bigint NOT NULL, + `status` int NOT NULL, + `user_id` int NOT NULL, + `user_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_dept_member` (`dept_id`,`user_id`), + KEY `idx_flw_dept_member_dept` (`dept_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_instance` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `biz_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `biz_snapshot` longtext COLLATE utf8mb4_general_ci, + `biz_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, + `biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `biz_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, + `current_nodes` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, + `def_code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `def_id` bigint NOT NULL, + `def_version` int NOT NULL, + `finish_time` datetime DEFAULT NULL, + `form_data_json` longtext COLLATE utf8mb4_general_ci, + `item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `item_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, + `sch_id` bigint NOT NULL, + `status` int NOT NULL, + `submit_time` datetime DEFAULT NULL, + `submitter_id` int NOT NULL, + `submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, + `version` int NOT NULL, + `apply_app_code` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `apply_app_key` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `assign_json` longtext COLLATE utf8mb4_general_ci, + `apply_request_token` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `apply_round` int DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_instance_biz` (`sch_id`,`biz_type`,`biz_id`), + KEY `idx_flw_inst_submitter` (`sch_id`,`submitter_id`,`status`), + KEY `idx_flw_inst_status_time` (`sch_id`,`status`,`submit_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_route_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `attempt` int NOT NULL, + `decide_time` datetime(3) NOT NULL, + `instance_id` bigint NOT NULL, + `node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + `outlet` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `raw_response` text COLLATE utf8mb4_general_ci, + `router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `sch_id` bigint NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_flw_route_log_inst` (`instance_id`,`node_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_router` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `base_path` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, + `path` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, + `router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `service_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `status` int NOT NULL, + `direct_host` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `reg_app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_router_key` (`router_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_task` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `arrive_time` datetime(3) NOT NULL COMMENT '到达时间(毫秒)', + `assignee_id` int NOT NULL, + `assignee_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + `deadline` datetime DEFAULT NULL, + `handle_time` datetime(3) DEFAULT NULL COMMENT '办理时间(毫秒)', + `instance_id` bigint NOT NULL, + `node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + `opinion` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, + `sch_id` bigint NOT NULL, + `status` int NOT NULL, + `urge_count` int NOT NULL, + `edge_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_flw_task_inst_status` (`instance_id`,`status`), + KEY `idx_flw_task_assignee` (`assignee_id`,`status`,`arrive_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/ci/test-gate.sh b/ci/test-gate.sh new file mode 100644 index 0000000..ec2c488 --- /dev/null +++ b/ci/test-gate.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu +cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +# 真实 MySQL 测试必须使用本次任务的独立 schema,禁止默认连开发/生产库。 +: "${CI_TEST_DB_URL:?CI_TEST_DB_URL is required}" +: "${CI_TEST_DB_USER:?CI_TEST_DB_USER is required}" +: "${CI_TEST_DB_PASSWORD:?CI_TEST_DB_PASSWORD is required}" +case "$CI_TEST_DB_URL" in jdbc:mysql://*) ;; *) echo 'Expected a MySQL test URL' >&2; exit 2 ;; esac +address=${CI_TEST_DB_URL#jdbc:mysql://} +host=${address%%/*}; host=${host%%:*} +case "$host" in localhost|127.0.0.1|mavenbuild-*-db) ;; *) echo 'Database host is outside isolated CI allowlist' >&2; exit 2 ;; esac +schema=${address#*/}; schema=${schema%%\?*} +case "$schema" in flowcenter_ci|flowcenter_ci_*) ;; *) echo 'Database schema is outside isolated CI prefix' >&2; exit 2 ;; esac +export SPRING_DATASOURCE_URL="$CI_TEST_DB_URL" +export SPRING_DATASOURCE_USERNAME="$CI_TEST_DB_USER" +export SPRING_DATASOURCE_PASSWORD="$CI_TEST_DB_PASSWORD" +TESTS=FlApplyRecoveryTest,FlFormBindingTest,OapiFlowApplyDefVersionTest,CallbackPropertiesTest,FlowDefAdminServicePublishContractTest,FormCenterSchemaClientTest,OapiFlowTasksByBizTest,OapiFlowP35DoneCcContractTest +# Surefire 的选择器部分匹配也可能成功;每个指定类必须随源码存在。 +oldIFS=$IFS +IFS=, +for test in $TESTS; do + [ -n "$(find src/test/java -type f -name "$test.java" -print -quit)" ] || { echo "Missing required test class: $test" >&2; exit 2; } +done +IFS=$oldIFS +if [ "$#" -eq 0 ]; then set -- test; fi +exec mvn -B "$@" -Dtest="$TESTS" -DfailIfNoTests=true -Dsurefire.failIfNoSpecifiedTests=true -DforkCount=0 -Dspring.jpa.hibernate.ddl-auto=validate < /dev/null diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java index bec6659..61afcc8 100644 --- a/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java @@ -120,6 +120,10 @@ public class CallbackDispatcher { if (event.getData() != null) { data.putAll(event.getData()); } + // 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。 + if (event.getRoundIdentity() != null) { + data.putAll(event.getRoundIdentity()); + } payload.put("data", data); String payloadJson = objectMapper.writeValueAsString(payload); diff --git a/src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java b/src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java new file mode 100644 index 0000000..f7f4401 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java @@ -0,0 +1,23 @@ +package com.yunhai.flowcenter.callback; + +import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** 同步发布阶段冻结轮次身份;不修改引擎或 apply/v2 七支分支。 */ +@Component +@RequiredArgsConstructor +public class FlowEventIdentityListener { + private final FlwInstanceRepository instances; + + @EventListener + @Order(Ordered.HIGHEST_PRECEDENCE) + public void capture(FlowEvent event) { + instances.findById(event.getInstanceId()).ifPresent(instance -> + event.captureRoundIdentity(instance.getApplyRound(), instance.getApplyRequestToken())); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java index f500a59..30eb842 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java @@ -69,6 +69,15 @@ public class FlowEvent { private final String operatorName; /** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */ private final Map data; + private Map roundIdentity; + + public void captureRoundIdentity(Integer round, String requestToken) { + if (roundIdentity != null) return; + Map identity = new HashMap<>(); + identity.put("applyRound", round); + identity.put("requestToken", requestToken); + roundIdentity = java.util.Collections.unmodifiableMap(identity); + } public static FlowEvent of(Type type, Long instanceId) { return new FlowEvent(type, instanceId, null, null, null, new HashMap<>()); diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java index 946caf3..c8252cd 100644 --- a/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java @@ -60,6 +60,9 @@ public final class FlowApplyRetryModels { private String branch; /** 行上的当前轮次(消费方据此校正自己的命令表认知) */ private Integer applyRound; + /** 同一响应的真实状态/实例版本;分支判据保持不变。 */ + private Integer status; + private Integer version; } /** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */ diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index 4760a6c..a48a4b9 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -107,6 +107,8 @@ public final class OapiModels { @Data public static class InstanceVo { private Long instanceId; + private Integer applyRound; + private String requestToken; private Integer status; private Integer version; private String nodeSummary; diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index d399120..0a6769b 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -223,6 +223,15 @@ public class OapiFlowService { vo.setDuplicated(outcome.duplicated); vo.setBranch(outcome.branch); vo.setApplyRound(outcome.applyRound); + instanceRepository.findById(outcome.instanceId).ifPresent(current -> { + int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound(); + int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound; + if (currentRound == outcomeRound) { + vo.setStatus(current.getStatus()); + vo.setVersion(current.getVersion()); + } // 已被另一请求推进到新轮时,让消费方按实例查询恢复,不混装两轮证据。 + + }); return vo; } @@ -252,6 +261,8 @@ public class OapiFlowService { vo.setInstanceId(existing.getId()); vo.setDuplicated(true); vo.setApplyRound(existing.getApplyRound()); + vo.setStatus(existing.getStatus()); + vo.setVersion(existing.getVersion()); vo.setBranch(token != null && token.equals(existing.getApplyRequestToken()) ? FlowApplyRetryModels.Branch.SAME_REQUEST : FlowApplyRetryModels.Branch.CONCURRENT_CREATED); log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}", @@ -1112,6 +1123,8 @@ public class OapiFlowService { public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) { OapiModels.InstanceVo vo = new OapiModels.InstanceVo(); vo.setInstanceId(instance.getId()); + vo.setApplyRound(instance.getApplyRound()); + vo.setRequestToken(instance.getApplyRequestToken()); vo.setStatus(instance.getStatus()); vo.setVersion(instance.getVersion()); vo.setBizTitle(instance.getBizTitle()); diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java index 346c21a..f112b8b 100644 --- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java @@ -48,6 +48,9 @@ class OapiFlowTasksByBizTest extends FlowTestSupport { private OapiSignedClient client; + @Autowired + private javax.sql.DataSource dataSource; + @TestConfiguration static class StubConfig { @Bean @@ -58,7 +61,21 @@ class OapiFlowTasksByBizTest extends FlowTestSupport { } @BeforeEach - void setUp() { + void setUp() throws Exception { + // 在 cleanDb 之前验证真实连接,不能只相信环境变量已覆盖 profile。 + try (java.sql.Connection connection = dataSource.getConnection()) { + String schema = connection.getCatalog(); + String host = java.net.URI.create(connection.getMetaData().getURL().substring(5)).getHost(); + System.out.println("CI_DB_TARGET=" + host + "/" + schema); + assertTrue("127.0.0.1".equals(host) || "localhost".equals(host) + || (host != null && host.startsWith("mavenbuild-") && host.endsWith("-db")), + "真库门禁只允许本机或本次 CI 容器"); + assertTrue("bsch_flowcenter_test".equals(schema) || "flowcenter_ci".equals(schema) + || (schema != null && schema.startsWith("flowcenter_ci_")), "拒绝清理非测试 schema"); + if (System.getenv("CI_TEST_DB_URL") != null) { + assertTrue(schema.startsWith("flowcenter_ci"), "CI 必须实际连接独立 schema"); + } + } cleanDb(); client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java index 2dc0793..0b2558e 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java @@ -287,6 +287,35 @@ class FlApplyRecoveryTest { assertThat(existing.getApplyRound()).isNull(); } + @Test + void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception { + FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018"); + row.setVersion(3); + when(instanceRepository.findById(900L)).thenReturn(Optional.of(row)); + com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of( + com.yunhai.flowcenter.engine.FlowEvent.Type.REJECTED, 900L); + new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event); + row.setApplyRound(2); row.setApplyRequestToken(TOKEN_B); row.setVersion(4); + new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event); + assertThat(event.getRoundIdentity().get("applyRound")).isEqualTo(1); + + com.yunhai.flowcenter.repository.FlwCallbackRegRepository regs = mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class); + com.yunhai.flowcenter.callback.CallbackLogStore store = mock(com.yunhai.flowcenter.callback.CallbackLogStore.class); + com.yunhai.flowcenter.entity.FlwCallbackReg reg = new com.yunhai.flowcenter.entity.FlwCallbackReg(); + reg.setId(7L); + when(regs.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)).thenReturn(java.util.Collections.singletonList(reg)); + com.yunhai.flowcenter.callback.CallbackDispatcher dispatcher = new com.yunhai.flowcenter.callback.CallbackDispatcher( + instanceRepository, defRepository, regs, mock(com.yunhai.flowcenter.repository.FlwCallbackLogRepository.class), + store, mock(com.yunhai.flowcenter.callback.CallbackPushService.class), new com.yunhai.flowcenter.callback.CallbackProperties(), mapper); + org.springframework.test.util.ReflectionTestUtils.setField(dispatcher, "pushExecutor", mock(java.util.concurrent.ExecutorService.class)); + dispatcher.onFlowEvent(event); + ArgumentCaptor log = ArgumentCaptor.forClass(com.yunhai.flowcenter.entity.FlwCallbackLog.class); + verify(store).persist(log.capture()); + com.fasterxml.jackson.databind.JsonNode data = mapper.readTree(log.getValue().getPayloadJson()).get("data"); + assertThat(data.get("applyRound").asInt()).isEqualTo(1); + assertThat(data.get("requestToken").asText()).isEqualTo(TOKEN_A); + } + // ==================== 夹具 ==================== /** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */ diff --git a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java index 24d705e..6e804ea 100644 --- a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java @@ -47,6 +47,7 @@ class OapiFlowApplyDefVersionTest { private FlowInstanceService instanceService; private AssigneeResolver assigneeResolver; private OapiFlowService service; + private FlwInstanceRepository instances; @BeforeEach @SuppressWarnings("unchecked") @@ -54,7 +55,8 @@ class OapiFlowApplyDefVersionTest { defRepository = mock(FlwDefRepository.class); instanceService = mock(FlowInstanceService.class); assigneeResolver = mock(AssigneeResolver.class); - service = new OapiFlowService(defRepository, mock(FlwInstanceRepository.class), + instances = mock(FlwInstanceRepository.class); + service = new OapiFlowService(defRepository, instances, mock(FlwTaskRepository.class), mock(FlwCcRepository.class), mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class), mock(FlowEngine.class), assigneeResolver, new ObjectMapper(), @@ -150,4 +152,20 @@ class OapiFlowApplyDefVersionTest { assertEquals(Integer.valueOf(1), vo.getDefVersion()); assertTrue(vo.getNodes() == null || vo.getNodes().isEmpty()); } + @Test + void v2ResponseCarriesRealRejectedStatusAndStableBranch() { + stubDef(3); + com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2 body = new com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2(); + body.setFlowGuid(FLOW_GUID); body.setBizType("svc-hall"); body.setBizId("42"); body.setSubmitterId(21); + body.setRequestToken("token-1"); body.setAttempt(1); + when(instanceService.applyGuarded(any(), any())).thenReturn( + new FlowInstanceService.GuardedOutcome(500L, true, "SAME_REQUEST", 1)); + com.yunhai.flowcenter.entity.FlwInstance row = new com.yunhai.flowcenter.entity.FlwInstance(); + row.setId(500L); row.setStatus(30); row.setVersion(4); row.setApplyRound(1); + when(instances.findById(500L)).thenReturn(Optional.of(row)); + com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyResultV2 result = service.applyV2(SCH_ID, "100009", body); + assertEquals("SAME_REQUEST", result.getBranch()); assertEquals(Integer.valueOf(1), result.getApplyRound()); + assertEquals(Integer.valueOf(30), result.getStatus()); assertEquals(Integer.valueOf(4), result.getVersion()); + } + }