feat(M01-B): 建立核心迁移与MySQL连接池

This commit is contained in:
Codex
2026-06-16 21:15:29 +08:00
parent de63164972
commit 8801881f9c
13 changed files with 404 additions and 7 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"test": "node tests/backend-contract.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+4 -1
View File
@@ -11,6 +11,7 @@ const configSchema = z.object({
QIPAI_MYSQL_DATABASE: z.string().min(1).default('qipai'),
QIPAI_MYSQL_USER: z.string().min(1).default('qipai_app'),
QIPAI_MYSQL_PASSWORD: z.string().default(''),
QIPAI_MYSQL_CONNECTION_LIMIT: z.coerce.number().int().min(1).max(50).default(10),
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
QIPAI_MQTT_USERNAME: z.string().default(''),
QIPAI_MQTT_PASSWORD: z.string().default('')
@@ -32,7 +33,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
port: parsed.QIPAI_MYSQL_PORT,
database: parsed.QIPAI_MYSQL_DATABASE,
user: parsed.QIPAI_MYSQL_USER,
passwordConfigured: parsed.QIPAI_MYSQL_PASSWORD.length > 0
password: parsed.QIPAI_MYSQL_PASSWORD,
passwordConfigured: parsed.QIPAI_MYSQL_PASSWORD.length > 0,
connectionLimit: parsed.QIPAI_MYSQL_CONNECTION_LIMIT
},
mqtt: {
url: parsed.QIPAI_MQTT_URL,
+23
View File
@@ -0,0 +1,23 @@
import mysql, { type Pool, type PoolOptions } from 'mysql2/promise';
import type { AppConfig } from '../config.js';
export type MySqlPool = Pool;
export function createMySqlPool(config: AppConfig): MySqlPool {
return mysql.createPool(toPoolOptions(config));
}
export function toPoolOptions(config: AppConfig): PoolOptions {
return {
host: config.mysql.host,
port: config.mysql.port,
database: config.mysql.database,
user: config.mysql.user,
password: config.mysql.password,
waitForConnections: true,
connectionLimit: config.mysql.connectionLimit,
namedPlaceholders: true,
timezone: 'Z',
dateStrings: false
};
}
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const backendRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const repoRoot = dirname(backendRoot);
const read = (path) => readFileSync(join(repoRoot, path), 'utf8');
const upSql = read('database/migrations/2026061601_m01b_core_schema.up.sql');
const downSql = read('database/migrations/2026061601_m01b_core_schema.down.sql');
const verifySql = read('database/migrations/2026061601_m01b_core_schema.verify.sql');
const seedSql = read('database/seeds/2026061601_m01b_minimal_seed.sql');
const coreTables = [
'qipai_schema_migrations',
'qipai_tenants',
'qipai_stores',
'qipai_rooms',
'qipai_members',
'qipai_orders',
'qipai_payments',
'qipai_devices',
'qipai_audit_logs',
'qipai_legacy_table_mappings'
];
for (const table of coreTables) {
assert.match(upSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
assert.match(downSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
assert.match(verifySql, new RegExp(`'${table}'`));
}
for (const table of [
'qipai_stores',
'qipai_rooms',
'qipai_members',
'qipai_orders',
'qipai_payments',
'qipai_devices',
'qipai_audit_logs'
]) {
const block = upSql.slice(upSql.indexOf(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(block, /\btenant_id BIGINT UNSIGNED NOT NULL\b/);
}
for (const amountColumn of [
'base_price_cents',
'balance_cents',
'total_amount_cents',
'paid_amount_cents',
'amount_cents'
]) {
assert.match(upSql, new RegExp(`\\b${amountColumn}\\b`));
}
assert.doesNotMatch(upSql, /\bDECIMAL\b/i);
assert.match(upSql, /DATETIME\(3\)/);
assert.match(upSql, /legacy_store_id/);
assert.match(upSql, /legacy_order_id/);
assert.match(seedSql, /INSERT IGNORE INTO qipai_legacy_table_mappings/);
assert.match(seedSql, /member_order_info/);
console.log('PASS: M01-B migration contract is present.');
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { loadConfig } from '../dist/config.js';
import { toPoolOptions } from '../dist/db/mysql.js';
const config = loadConfig({
NODE_ENV: 'test',
QIPAI_MYSQL_HOST: 'db.internal',
QIPAI_MYSQL_PORT: '3307',
QIPAI_MYSQL_DATABASE: 'qipai_test',
QIPAI_MYSQL_USER: 'qipai_test_user',
QIPAI_MYSQL_PASSWORD: 'test-password',
QIPAI_MYSQL_CONNECTION_LIMIT: '7',
QIPAI_MQTT_URL: 'mqtt://127.0.0.1:1883'
});
const poolOptions = toPoolOptions(config);
assert.equal(config.mysql.passwordConfigured, true);
assert.equal(poolOptions.host, 'db.internal');
assert.equal(poolOptions.port, 3307);
assert.equal(poolOptions.database, 'qipai_test');
assert.equal(poolOptions.user, 'qipai_test_user');
assert.equal(poolOptions.password, 'test-password');
assert.equal(poolOptions.connectionLimit, 7);
assert.equal(poolOptions.waitForConnections, true);
assert.equal(poolOptions.namedPlaceholders, true);
assert.equal(poolOptions.timezone, 'Z');
console.log('PASS: MySQL pool contract is present.');