| AGENTS.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| docs/agents/testing.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| talent-card-server/docs/huifu-payment-integration.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| talent-card-server/scripts/ResetTestDatabase.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| talent-card-server/scripts/reset-test-db.ps1 | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| talent-card-server/scripts/run-server-tests.ps1 | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchDirectoryInitializer.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| talent-card-server/src/test/resources/application-test.yml | ●●●●● 补丁 | 查看 | 原始文档 | 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