yeg
2026-09-12 511bd91f8b4357271817d87bb8bcdd644281b811
chore(dev): 固化测试环境工具,消除新会话的重复摸索

- scripts/reset-test-db.ps1 + ResetTestDatabase.java:用项目自带 JDBC 驱动重建测试库,3 秒完成
- scripts/run-server-tests.ps1:内置 TEST_DATASOURCE 配置、单进程锁、日志与基线失败提示
- 测试 profile 关闭 10 万行网点目录导入,重建 schema 由 10 分钟降到秒级
- docs/agents/testing.md + AGENTS.md:迁移只追加、不要 drop database、一次只跑一个构建、已知基线失败清单
4个文件已添加
4个文件已修改
407 ■■■■■ 已修改文件
AGENTS.md 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/agents/testing.md 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/huifu-payment-integration.md 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/ResetTestDatabase.java 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/reset-test-db.ps1 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/run-server-tests.ps1 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchDirectoryInitializer.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/resources/application-test.yml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
AGENTS.md
@@ -11,3 +11,10 @@
### Domain docs
采用单上下文布局:根目录 `CONTEXT.md`,ADR 位于 `docs/adr/`。详见 `docs/agents/domain.md`。
### 服务器测试与数据库
后端测试跑在共享的 MySQL 测试库上。跑测试用 `talent-card-server/scripts/run-server-tests.ps1`,
重置库用 `talent-card-server/scripts/reset-test-db.ps1`,并遵守:已执行的迁移只追加不修改、同一
checkout 一次只跑一个 Maven 构建、不要 `drop database`。详见 `docs/agents/testing.md`(含 3 项已知
基线失败,不要为证明它们"是既有的"而重复造 worktree)。
docs/agents/testing.md
New file
@@ -0,0 +1,64 @@
# 服务器测试与本地数据库
本文件是 `talent-card-server` 的测试约定。目标是让**新会话**不需要重新摸索环境:一条命令跑测试,
一条命令重置库,并且不重蹈已知的坑。
## 一条命令跑测试
```powershell
cd talent-card-server
.\scripts\run-server-tests.ps1                              # 全量
.\scripts\run-server-tests.ps1 -Test PaymentSettlementTests # 单个类
.\scripts\run-server-tests.ps1 -Test A,B                    # 多个类
.\scripts\run-server-tests.ps1 -Clean -Test A               # 构建产物可疑时先 clean
.\scripts\run-server-tests.ps1 -Reset                       # 重置库后再跑
```
脚本会设置 `TEST_DATASOURCE_*`(MySQL `127.0.0.1:8134`、`talent_card_test`、`root/123456`,
也可用环境变量覆盖),把日志写到 `target/server-tests.log`,并在失败时列出已知基线失败。
**必须走这个脚本(或等价地只跑一个 Maven 进程)。** 同一个 checkout 上并发跑两个构建会写坏
`target/`,症状是看起来不可能的报错——例如 `No qualifying bean of type 'HuifuPaymentProperties'`。
遇到这类"不可能"的 bean/classpath 错误,先 `-Clean`,再怀疑代码。
## 重置测试库
```powershell
.\scripts\reset-test-db.ps1                    # 默认 talent_card_test
.\scripts\reset-test-db.ps1 -Database talent_card
```
脚本用项目已有的 MySQL JDBC 驱动(本仓库没有 mysql 客户端、没有 Docker),删掉该库里的全部表;
下一次测试由 Flyway 从 `db/migration` 重建。
**不要 `drop database`,也不要只删 `flyway_schema_history` 里的某一行。**
- `drop database` 之后重建会触发 `BankBranchDirectoryInitializer` 导入 10 万行网点数据(几分钟,
  且曾观察到卡死)。测试 profile 已关闭该导入,所以上面的重置脚本不会再有这个代价。
- 手工删 history 行会让已执行的 DDL 与 Flyway 记录不一致,后续迁移报错更难查。
## 迁移是只追加的
已经执行过的 `V*__*.sql` **不要改**(改一个字符就会 checksum 不匹配,测试库直接起不来)。需要调整
就新增一个迁移文件,或用 `reset-test-db.ps1` 重建后再跑。历史上 `scripts/repair-local-mysql-v*.sql`
就是为此付出的代价。
## 已知基线失败(不是回归)
整套测试有 3 项在 `HEAD` 基线上就失败,与本轮改动无关;用 HEAD worktree + 同一测试库复现过:
| 测试 | 现象 |
| --- | --- |
| `MerchantWorkServiceTests.filtersOrdersByCategoryStatusAndDateWithoutExposingFullMobile` | `stays.total()` 期望 1 实得 0 |
| `TalentAuthServiceTests.movesWechatBindingWhenSameWechatLogsInWithAnotherMobile` | `insert into talent_session` BadSqlGrammar |
| `MerchantSettlementStatisticsMapperTests.includesHotelFreeOfflineSubsidyWithoutIncreasingTransactionAmount` | 持久化断言失败 |
新会话不要为了证明它们"是既有的"而重建 worktree:直接看这张表。修好其中任何一项时,请删掉对应行。
## 测试库的状态是共享的
Spring 测试共用一个 schema,测试之间不做清理(没有 `@Transactional` 回滚)。因此:
- 断言要按自己创建的 ID / 单号过滤,不要断言"全表只有一行"。
- 断言"后台任务只处理了我这条"时,先把自己之外的相关行挪出时间窗口(例如把历史
  `payment_transaction.created_at` 置为 `now()`),否则前面的用例会漏进来。
- 需要真实 MySQL 语义(当前所有 Spring 测试都是)时,H2 只作为没有 MySQL 时的兜底。
talent-card-server/docs/huifu-payment-integration.md
@@ -220,7 +220,17 @@
### Running the server tests
Several migrations use MySQL-only syntax that the in-memory H2 default of `application-test.yml`
cannot parse, so the Spring tests only load when pointed at MySQL:
cannot parse, so the Spring tests only load when pointed at MySQL. Run them through the script, which
sets the variables, keeps one Maven build at a time and writes `target/server-tests.log`:
```powershell
cd talent-card-server
.\scripts\run-server-tests.ps1                              # whole suite
.\scripts\run-server-tests.ps1 -Test PaymentSettlementTests # one class
.\scripts\run-server-tests.ps1 -Reset                       # rebuild the schema first
```
The equivalent manual invocation, when a raw Maven run is needed:
```powershell
$env:TEST_DATASOURCE_URL='jdbc:mysql://127.0.0.1:8134/talent_card_test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false&createDatabaseIfNotExist=true'
@@ -230,6 +240,9 @@
.\mvnw.cmd -o test
```
Schema state, the migration append-only rule and the known baseline failures are documented in
`docs/agents/testing.md`.
`MerchantPaymentReadinessServiceTests`, `MerchantSettlementCallbackIdempotencyTests` and
`AppOrderPortalServiceTests` cover the readiness snapshot, the payment gate for paid orders and
callback idempotency. They stub `HuifuMerchantCapabilityGateway`, so they never call Huifu.
talent-card-server/scripts/ResetTestDatabase.java
New file
@@ -0,0 +1,119 @@
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/**
 * Drops every table and view of one database so Flyway can rebuild the schema from the migrations.
 *
 * <p>Run through {@code scripts/reset-test-db.ps1}; this program exists because the checkout has no
 * MySQL client and no Docker, while the JDBC driver is already required by the application.
 */
public final class ResetTestDatabase {
    public static void main(String[] args) throws Exception {
        if (args.length < 3) {
            System.err.println("usage: ResetTestDatabase <jdbcUrl> <user> <password>");
            System.exit(2);
        }
        String url = args[0];
        String user = args[1];
        String password = args[2];
        String database = databaseName(url);
        if (database.isEmpty()) {
            System.err.println("The JDBC URL must name the database to reset: " + url);
            System.exit(2);
        }
        try (Connection connection = DriverManager.getConnection(url, user, password)) {
            List<String> views = objects(connection, database, "VIEW");
            List<String> tables = objects(connection, database, "BASE TABLE");
            dropAll(connection, views, tables);
            System.out.println("reset " + database + ": dropped " + views.size() + " views and " + tables.size() + " tables");
            if (!tables.isEmpty()) {
                System.out.println("  dropped: " + summary(tables));
            }
            System.out.println("The next test run recreates the schema from db/migration.");
            return;
        } catch (SQLException exception) {
            if (!unknownDatabase(exception)) throw exception;
        }
        try (Connection connection = DriverManager.getConnection(serverUrl(url), user, password);
             Statement statement = connection.createStatement()) {
            statement.executeUpdate("create database if not exists `" + database + "` default charset utf8mb4");
        }
        System.out.println("created empty database " + database + " (it did not exist yet)");
    }
    private static List<String> objects(Connection connection, String database, String type) throws SQLException {
        List<String> names = new ArrayList<>();
        try (var statement = connection.prepareStatement("""
                select table_name from information_schema.tables
                where table_schema = ? and table_type = ?
                order by table_name
                """)) {
            statement.setString(1, database);
            statement.setString(2, type);
            try (ResultSet result = statement.executeQuery()) {
                while (result.next()) names.add(result.getString(1));
            }
        }
        return names;
    }
    private static void dropAll(Connection connection, List<String> views, List<String> tables) throws SQLException {
        try (Statement statement = connection.createStatement()) {
            statement.execute("set foreign_key_checks = 0");
            try {
                for (String view : views) statement.executeUpdate("drop view if exists `" + view + "`");
                for (String table : tables) statement.executeUpdate("drop table if exists `" + table + "`");
            } finally {
                statement.execute("set foreign_key_checks = 1");
            }
        }
    }
    /** The database name of a JDBC URL such as jdbc:mysql://host:port/db?params. */
    static String databaseName(String url) {
        int pathStart = pathStart(url);
        if (pathStart < 0) return "";
        String tail = url.substring(pathStart + 1);
        int query = tail.indexOf('?');
        return (query < 0 ? tail : tail.substring(0, query)).trim();
    }
    /** The same URL without its database, used to create a database that does not exist yet. */
    static String serverUrl(String url) {
        int pathStart = pathStart(url);
        if (pathStart < 0) return url;
        int query = url.indexOf('?', pathStart);
        return url.substring(0, pathStart + 1) + (query < 0 ? "" : url.substring(query));
    }
    /**
     * Index of the slash that separates the host from the database path. Splitting on the last slash
     * would break on URLs that carry a path-like parameter, for example serverTimezone=Asia/Shanghai.
     */
    private static int pathStart(String url) {
        int schemeEnd = url.indexOf("//");
        return schemeEnd < 0 ? -1 : url.indexOf('/', schemeEnd + 2);
    }
    private static boolean unknownDatabase(SQLException exception) {
        for (SQLException current = exception; current != null; current = current.getNextException()) {
            if (current.getErrorCode() == 1049) return true;
            String message = current.getMessage();
            if (message != null && message.toLowerCase().contains("unknown database")) return true;
        }
        return false;
    }
    private static String summary(List<String> names) {
        if (names.size() <= 12) return String.join(", ", names);
        return String.join(", ", names.subList(0, 12)) + ", ... (" + (names.size() - 12) + " more)";
    }
}
talent-card-server/scripts/reset-test-db.ps1
New file
@@ -0,0 +1,98 @@
# Drops every table in the MySQL test schema so the next test run rebuilds it from db/migration.
#
# Why this exists instead of "drop database":
#   * the checkout has no mysql client and no Docker, so the script uses the JDBC driver that the
#     application already depends on;
#   * dropping and recreating the schema through Flyway is the only supported way to get back in
#     sync after a migration was edited, and it is fast because the test profile switches off the
#     100k-row bank-branch import (see docs/agents/testing.md).
#
# Usage:
#   .\scripts\reset-test-db.ps1
#   .\scripts\reset-test-db.ps1 -Database talent_card_test -Port 8134 -User root -Password 123456
#   .\scripts\reset-test-db.ps1 -Database talent_card            # reset another schema on the same server
param(
    [string] $Database = '',
    [string] $DatabaseHost = '',
    [int]    $Port = 0,
    [string] $User = '',
    [string] $Password = ''
)
$ErrorActionPreference = 'Stop'
function Get-DefaultDatabase {
    if ($env:TEST_DATASOURCE_URL -and $env:TEST_DATASOURCE_URL -match '/[^/?]+\?') {
        return [regex]::Match($env:TEST_DATASOURCE_URL, '/([^/?]+)\?').Groups[1].Value
    }
    return 'talent_card_test'
}
function Get-DefaultHost {
    if ($env:TEST_DATASOURCE_URL -and $env:TEST_DATASOURCE_URL -match '//([^:/]+)') {
        return [regex]::Match($env:TEST_DATASOURCE_URL, '//([^:/]+)').Groups[1].Value
    }
    return '127.0.0.1'
}
function Get-DefaultPort {
    if ($env:TEST_DATASOURCE_URL -and $env:TEST_DATASOURCE_URL -match '//[^:/]+:(\d+)') {
        return [int][regex]::Match($env:TEST_DATASOURCE_URL, '//[^:/]+:(\d+)').Groups[1].Value
    }
    return 8134
}
function Find-MySqlDriver {
    $roots = @()
    if ($env:MAVEN_REPO_LOCAL) { $roots += $env:MAVEN_REPO_LOCAL }
    $settings = Join-Path $env:USERPROFILE '.m2\settings.xml'
    if (Test-Path $settings) {
        try {
            [xml] $document = Get-Content $settings
            if ($document.settings.localRepository) { $roots += $document.settings.localRepository }
        } catch {
            Write-Host "Could not read $settings; falling back to the default repository path." -ForegroundColor Yellow
        }
    }
    $roots += (Join-Path $env:USERPROFILE '.m2\repository')
    foreach ($root in $roots) {
        if (-not $root -or -not (Test-Path $root)) { continue }
        $jar = Get-ChildItem -Path $root -Recurse -Filter 'mysql-connector-j-*.jar' -ErrorAction SilentlyContinue |
            Where-Object { $_.Name -notmatch 'sources|javadoc' } |
            Sort-Object FullName -Descending |
            Select-Object -First 1
        if ($jar) { return $jar.FullName }
    }
    return $null
}
function Get-JavaExecutable {
    $candidates = @()
    if ($env:JAVA_HOME) { $candidates += (Join-Path $env:JAVA_HOME 'bin\java.exe') }
    $candidates += 'java'
    foreach ($candidate in $candidates) {
        $command = Get-Command $candidate -ErrorAction SilentlyContinue
        if ($command) { return $command.Source }
    }
    throw 'No java executable found. Set JAVA_HOME or add java to PATH.'
}
if (-not $Database)   { $Database = Get-DefaultDatabase }
if (-not $DatabaseHost) { $DatabaseHost = Get-DefaultHost }
if ($Port -le 0)      { $Port = Get-DefaultPort }
if (-not $User)       { $User = if ($env:TEST_DATASOURCE_USERNAME) { $env:TEST_DATASOURCE_USERNAME } else { 'root' } }
if (-not $Password)   { $Password = if ($env:TEST_DATASOURCE_PASSWORD) { $env:TEST_DATASOURCE_PASSWORD } else { '123456' } }
$driver = Find-MySqlDriver
if (-not $driver) {
    throw 'mysql-connector-j was not found in the Maven local repository. Run ".\mvnw.cmd -o test-compile" once so Maven downloads the project dependencies.'
}
$java = Get-JavaExecutable
$program = Join-Path $PSScriptRoot 'ResetTestDatabase.java'
$url = "jdbc:mysql://${DatabaseHost}:${Port}/${Database}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false"
Write-Host "Resetting $Database on ${DatabaseHost}:${Port} ..." -ForegroundColor Cyan
& $java --class-path $driver $program $url $User $Password
if ($LASTEXITCODE -ne 0) { throw "Reset failed (exit $LASTEXITCODE). Driver: $driver" }
talent-card-server/scripts/run-server-tests.ps1
New file
@@ -0,0 +1,86 @@
# Runs the server test suite against the MySQL test schema with the TEST_DATASOURCE_* variables
# already set, so a new session does not have to rediscover them.
#
# It refuses to start while another run against this checkout is still active: two Maven builds
# sharing one target/ directory leave inconsistent class files, which then surface as impossible
# errors such as a @ConfigurationProperties bean that "does not exist".
#
# Usage:
#   .\scripts\run-server-tests.ps1                                  # whole suite
#   .\scripts\run-server-tests.ps1 -Test PaymentSettlementTests     # one class
#   .\scripts\run-server-tests.ps1 -Test PaymentSettlementTests,AppOrderPortalServiceTests
#   .\scripts\run-server-tests.ps1 -Reset                           # reset the schema first, then run
#   .\scripts\run-server-tests.ps1 -Clean                           # clean build (use after an interrupted run)
param(
    [string[]] $Test = @(),
    [switch]   $Reset,
    [switch]   $Clean
)
$ErrorActionPreference = 'Stop'
$serverRoot = Split-Path -Parent $PSScriptRoot
$lockFile = Join-Path $env:TEMP ("talent-card-tests-{0}.lock" -f ($serverRoot -replace '[\\/:]', '_'))
function Test-RunActive {
    if (-not (Test-Path $lockFile)) { return $false }
    $owner = (Get-Content $lockFile -ErrorAction SilentlyContinue | Select-Object -First 1)
    if (-not $owner) { return $false }
    $processId = 0
    if (-not [int]::TryParse($owner.Trim(), [ref] $processId)) { return $false }
    return [bool] (Get-Process -Id $processId -ErrorAction SilentlyContinue)
}
if (Test-RunActive) {
    $owner = (Get-Content $lockFile | Select-Object -First 1).Trim()
    throw "Another test run (pid $owner) is still using $serverRoot. Wait for it to finish: concurrent Maven builds corrupt target/ and produce misleading failures."
}
Set-Content -Path $lockFile -Value $PID
try {
    if (-not $env:TEST_DATASOURCE_URL) {
        $env:TEST_DATASOURCE_URL = 'jdbc:mysql://127.0.0.1:8134/talent_card_test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false&createDatabaseIfNotExist=true'
    }
    if (-not $env:TEST_DATASOURCE_USERNAME) { $env:TEST_DATASOURCE_USERNAME = 'root' }
    if (-not $env:TEST_DATASOURCE_PASSWORD) { $env:TEST_DATASOURCE_PASSWORD = '123456' }
    if (-not $env:TEST_DATASOURCE_DRIVER)   { $env:TEST_DATASOURCE_DRIVER = 'com.mysql.cj.jdbc.Driver' }
    if ($Reset) {
        & (Join-Path $PSScriptRoot 'reset-test-db.ps1')
        if ($LASTEXITCODE -ne 0) { throw "Database reset failed (exit $LASTEXITCODE)." }
        $Clean = $true
    }
    $goals = @('test')
    if ($Clean) { $goals = @('clean') + $goals }
    $arguments = @('-o') + $goals
    if ($Test.Count -gt 0) { $arguments += ('-Dtest=' + ($Test -join ',')) }
    $arguments += '-DfailIfNoTests=false'
    $log = Join-Path $serverRoot 'target\server-tests.log'
    Write-Host ("Running: .\mvnw.cmd {0}" -f ($arguments -join ' ')) -ForegroundColor Cyan
    Push-Location $serverRoot
    try {
        & (Join-Path $serverRoot 'mvnw.cmd') @arguments *> $log
        $exitCode = $LASTEXITCODE
    } finally {
        Pop-Location
    }
    Select-String -Path $log -Pattern 'Tests run:.*Skipped: \d+$|^\[ERROR\]   ' |
        ForEach-Object { $_.Line } |
        Select-Object -Last 12
    if ($exitCode -eq 0) {
        Write-Host "Tests passed. Full log: $log" -ForegroundColor Green
    } else {
        Write-Host "Tests failed (exit $exitCode). Full log: $log" -ForegroundColor Red
        Write-Host 'Known baseline failures (not regressions, see docs/agents/testing.md):' -ForegroundColor Yellow
        Write-Host '  MerchantWorkServiceTests.filtersOrdersByCategoryStatusAndDateWithoutExposingFullMobile'
        Write-Host '  TalentAuthServiceTests.movesWechatBindingWhenSameWechatLogsInWithAnotherMobile'
        Write-Host '  MerchantSettlementStatisticsMapperTests.includesHotelFreeOfflineSubsidyWithoutIncreasingTransactionAmount'
    }
    exit $exitCode
} finally {
    Remove-Item $lockFile -Force -ErrorAction SilentlyContinue
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchDirectoryInitializer.java
@@ -12,6 +12,7 @@
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.ClassPathResource;
@@ -25,9 +26,14 @@
    private static final int BATCH_SIZE = 1000;
    private final JdbcTemplate jdbc;
    private final boolean importEnabled;
    public BankBranchDirectoryInitializer(JdbcTemplate jdbc) {
    public BankBranchDirectoryInitializer(
            JdbcTemplate jdbc,
            @Value("${app.merchant.bank-branch-directory.import-enabled:true}") boolean importEnabled
    ) {
        this.jdbc = jdbc;
        this.importEnabled = importEnabled;
    }
    @Override
@@ -36,7 +42,9 @@
            ensureBankCardBranchCodeColumn();
            ensureBankCardHistoryIndex();
            ensureDirectoryTable();
            importDirectoryIfEmpty();
            // Importing the spreadsheet inserts roughly 100k rows in batches and takes minutes, so
            // deployments that do not need it (tests) switch it off.
            if (importEnabled) importDirectoryIfEmpty();
        } catch (Exception exception) {
            log.warn("Bank branch directory initialization skipped", exception);
        }
talent-card-server/src/test/resources/application-test.yml
@@ -19,3 +19,9 @@
      # enforces the local onboarding and bank-card preconditions.
      enabled: false
      merchant-capability-required: false
  merchant:
    bank-branch-directory:
      # No test reads the branch directory, and importing bank-branches.xlsx inserts ~100k rows in
      # batches, which adds minutes to the first application context of every fresh schema (and has
      # been observed to stall). Production keeps the default, so a real deployment still imports it.
      import-enabled: false