feat(M07-B): 接入充值优惠规则

This commit is contained in:
Codex
2026-06-24 15:37:01 +08:00
parent db1d08ecf8
commit d9743d36d8
9 changed files with 578 additions and 6 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs" "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+7 -3
View File
@@ -41,7 +41,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062218_m05d_profit_sharing.up.sql', 'database/migrations/2026062218_m05d_profit_sharing.up.sql',
'database/migrations/2026062219_m06b_device_topology.up.sql', 'database/migrations/2026062219_m06b_device_topology.up.sql',
'database/migrations/2026062220_m06c_iot_messages.up.sql', 'database/migrations/2026062220_m06c_iot_messages.up.sql',
'database/migrations/2026062421_m07a_wallet_ledger.up.sql' 'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
'database/migrations/2026062422_m07b_recharge_plans.up.sql'
], ],
verify: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -64,9 +65,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062218_m05d_profit_sharing.verify.sql', 'database/migrations/2026062218_m05d_profit_sharing.verify.sql',
'database/migrations/2026062219_m06b_device_topology.verify.sql', 'database/migrations/2026062219_m06b_device_topology.verify.sql',
'database/migrations/2026062220_m06c_iot_messages.verify.sql', 'database/migrations/2026062220_m06c_iot_messages.verify.sql',
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql' 'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
'database/migrations/2026062422_m07b_recharge_plans.verify.sql'
], ],
down: [ down: [
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
'database/migrations/2026062421_m07a_wallet_ledger.down.sql', 'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
'database/migrations/2026062220_m06c_iot_messages.down.sql', 'database/migrations/2026062220_m06c_iot_messages.down.sql',
'database/migrations/2026062219_m06b_device_topology.down.sql', 'database/migrations/2026062219_m06b_device_topology.down.sql',
@@ -224,7 +227,8 @@ export async function executeMigrationPlan(
3, 7, 5, 1, 3, 7, 5, 1,
5, 8, 5, 2, 1, 5, 8, 5, 2, 1,
3, 3, 9, 1, 3, 3, 9, 1,
1, 1, 1, 4, 7, 1 1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
+241
View File
@@ -0,0 +1,241 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { WalletLedgerService } from './wallet-ledger-service.js';
interface PlanRow extends RowDataPacket {
id: string;
tenantId: string;
storeId: string | null;
name: string;
payAmountCents: number;
giftAmountCents: number;
scopeType: 'TENANT' | 'STORE';
startsAt: Date | null;
endsAt: Date | null;
purchaseLimitPerUser: number | null;
status: string;
}
interface RechargeOrderRow extends RowDataPacket {
id: string;
userId: string;
storeId: string | null;
planId: string;
rechargeNo: string;
payAmountCents: number;
giftAmountCents: number;
status: string;
}
interface CountRow extends RowDataPacket { total: number }
export class RechargeError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class RechargeService {
constructor(
private readonly pool: MySqlPool,
private readonly wallet: Pick<WalletLedgerService, 'credit'>
) {}
async createRechargeOrder(input: {
tenantId: string;
userId: string;
planId: string;
storeId?: string | null;
clientRequestId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
const duplicate = await this.findByRequest(connection, input);
if (duplicate) return rechargeResponse(duplicate, true);
const plan = await this.loadPlan(connection, input.tenantId, input.planId);
assertPlanAvailable(plan, input.storeId ?? null);
await this.assertPurchaseLimit(connection, input.tenantId, input.userId, plan);
const rechargeNo = `RCH${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_recharge_orders
(tenant_id, user_id, store_id, plan_id, recharge_no, client_request_id,
pay_amount_cents, gift_amount_cents, trace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.userId, plan.storeId ?? input.storeId ?? null, plan.id,
rechargeNo, input.clientRequestId, plan.payAmountCents, plan.giftAmountCents,
input.traceId]
);
return rechargeResponse({
id: String(result.insertId),
userId: input.userId,
storeId: plan.storeId ?? input.storeId ?? null,
planId: plan.id,
rechargeNo,
payAmountCents: plan.payAmountCents,
giftAmountCents: plan.giftAmountCents,
status: 'PENDING_PAYMENT'
} as RechargeOrderRow, false);
});
}
async markPaidAndCredit(input: {
tenantId: string;
rechargeOrderId: string;
paymentId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
const order = await this.loadRechargeOrder(connection, input, true);
if (order.status === 'CREDITED') {
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: true };
}
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
throw new RechargeError('RECHARGE_STATUS_INVALID');
}
await connection.execute(
`UPDATE qipai_recharge_orders
SET status = 'PAID', payment_id = ?, paid_at = COALESCE(paid_at, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id = ?`,
[input.paymentId, input.tenantId, order.id]
);
const plan = await this.loadPlan(connection, input.tenantId, order.planId);
await this.wallet.credit({
tenantId: input.tenantId,
userId: String(order.userId),
scopeType: plan.scopeType,
storeId: plan.scopeType === 'STORE' ? order.storeId : null,
businessType: 'RECHARGE',
businessId: order.id,
entryType: 'RECHARGE',
cashDeltaCents: Number(order.payAmountCents),
giftDeltaCents: Number(order.giftAmountCents),
traceId: input.traceId,
metadata: { paymentId: input.paymentId, rechargeNo: order.rechargeNo }
});
await connection.execute(
`UPDATE qipai_recharge_orders
SET status = 'CREDITED', credited_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, order.id]
);
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: false };
});
}
private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) {
const [rows] = await connection.execute<PlanRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, name,
pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents,
scope_type AS scopeType, starts_at AS startsAt, ends_at AS endsAt,
purchase_limit_per_user AS purchaseLimitPerUser, status
FROM qipai_recharge_plans
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[tenantId, planId]
);
if (!rows[0]) throw new RechargeError('RECHARGE_PLAN_NOT_FOUND');
return {
...rows[0],
id: String(rows[0].id),
tenantId: String(rows[0].tenantId),
storeId: rows[0].storeId === null ? null : String(rows[0].storeId)
};
}
private async findByRequest(
connection: PoolConnection,
input: { tenantId: string; userId: string; clientRequestId: string }
) {
const [rows] = await connection.execute<RechargeOrderRow[]>(
`SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId,
recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents, status
FROM qipai_recharge_orders
WHERE tenant_id = ? AND user_id = ? AND client_request_id = ? LIMIT 1`,
[input.tenantId, input.userId, input.clientRequestId]
);
return rows[0] ?? null;
}
private async loadRechargeOrder(
connection: PoolConnection,
input: { tenantId: string; rechargeOrderId: string },
lock: boolean
) {
const [rows] = await connection.execute<RechargeOrderRow[]>(
`SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId,
recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents, status
FROM qipai_recharge_orders
WHERE tenant_id = ? AND id = ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.rechargeOrderId]
);
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
return {
...rows[0],
id: String(rows[0].id),
userId: String(rows[0].userId),
storeId: rows[0].storeId === null ? null : String(rows[0].storeId),
planId: String(rows[0].planId)
};
}
private async assertPurchaseLimit(
connection: PoolConnection,
tenantId: string,
userId: string,
plan: PlanRow
) {
if (!plan.purchaseLimitPerUser) return;
const [rows] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_recharge_orders
WHERE tenant_id = ? AND user_id = ? AND plan_id = ?
AND status IN ('PENDING_PAYMENT', 'PAID', 'CREDITED')`,
[tenantId, userId, plan.id]
);
if (Number(rows[0]?.total ?? 0) >= Number(plan.purchaseLimitPerUser)) {
throw new RechargeError('RECHARGE_LIMIT_REACHED');
}
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
function assertPlanAvailable(plan: PlanRow, requestedStoreId: string | null) {
if (plan.status !== 'ACTIVE') throw new RechargeError('RECHARGE_PLAN_DISABLED');
const now = Date.now();
if (plan.startsAt && plan.startsAt.getTime() > now) {
throw new RechargeError('RECHARGE_PLAN_NOT_STARTED');
}
if (plan.endsAt && plan.endsAt.getTime() <= now) {
throw new RechargeError('RECHARGE_PLAN_EXPIRED');
}
if (plan.storeId && requestedStoreId && plan.storeId !== requestedStoreId) {
throw new RechargeError('RECHARGE_PLAN_STORE_MISMATCH');
}
}
function rechargeResponse(row: RechargeOrderRow, idempotent: boolean) {
return {
rechargeOrderId: String(row.id),
planId: String(row.planId),
rechargeNo: row.rechargeNo,
status: row.status,
payAmountCents: Number(row.payAmountCents),
giftAmountCents: Number(row.giftAmountCents),
idempotent
};
}
+16 -1
View File
@@ -72,6 +72,9 @@ const iotMessagesVerifySql = read('database/migrations/2026062220_m06c_iot_messa
const walletUpSql = read('database/migrations/2026062421_m07a_wallet_ledger.up.sql'); const walletUpSql = read('database/migrations/2026062421_m07a_wallet_ledger.up.sql');
const walletDownSql = read('database/migrations/2026062421_m07a_wallet_ledger.down.sql'); const walletDownSql = read('database/migrations/2026062421_m07a_wallet_ledger.down.sql');
const walletVerifySql = read('database/migrations/2026062421_m07a_wallet_ledger.verify.sql'); const walletVerifySql = read('database/migrations/2026062421_m07a_wallet_ledger.verify.sql');
const rechargeUpSql = read('database/migrations/2026062422_m07b_recharge_plans.up.sql');
const rechargeDownSql = read('database/migrations/2026062422_m07b_recharge_plans.down.sql');
const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_plans.verify.sql');
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -331,4 +334,16 @@ assert.match(walletUpSql, /uq_qipai_wallet_ledger_business/);
assert.match(walletUpSql, /cash_delta_cents INT NOT NULL DEFAULT 0/); assert.match(walletUpSql, /cash_delta_cents INT NOT NULL DEFAULT 0/);
assert.match(walletUpSql, /gift_delta_cents INT NOT NULL DEFAULT 0/); assert.match(walletUpSql, /gift_delta_cents INT NOT NULL DEFAULT 0/);
console.log('PASS: M01-B through M07-A migration contracts are present.'); for (const table of ['qipai_recharge_plans', 'qipai_recharge_orders']) {
assert.match(rechargeUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(rechargeDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(rechargeVerifySql, new RegExp(`'${table}'`));
}
assert.match(rechargeUpSql, /pay_amount_cents INT UNSIGNED NOT NULL/);
assert.match(rechargeUpSql, /gift_amount_cents INT UNSIGNED NOT NULL DEFAULT 0/);
assert.match(rechargeUpSql, /purchase_limit_per_user INT UNSIGNED/);
assert.match(rechargeUpSql, /UNIQUE KEY uq_qipai_recharge_order_request/);
assert.match(rechargeUpSql, /payment_id BIGINT UNSIGNED NULL/);
assert.match(rechargeUpSql, /credited_at DATETIME\(3\) NULL/);
console.log('PASS: M01-B through M07-B migration contracts are present.');
+2 -1
View File
@@ -32,7 +32,8 @@ assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql/);
assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql/); assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql/);
assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/); assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/); assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql$/); assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
+220
View File
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import { RechargeError, RechargeService } from '../dist/wallets/recharge-service.js';
const future = new Date(Date.now() + 86_400_000);
const past = new Date(Date.now() - 86_400_000);
function createHarness(overrides = {}) {
const state = {
plan: {
id: '31',
tenantId: '7',
storeId: '11',
name: '充 100 送 20',
payAmountCents: 10000,
giftAmountCents: 2000,
scopeType: 'STORE',
startsAt: past,
endsAt: future,
purchaseLimitPerUser: 2,
status: 'ACTIVE',
...overrides.plan
},
orders: [],
nextOrderId: 900,
purchaseCount: overrides.purchaseCount ?? 0,
walletCredits: []
};
const connection = {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async execute(sql, params) {
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('client_request_id')) {
const duplicate = state.orders.find((order) =>
order.tenantId === String(params[0])
&& order.userId === String(params[1])
&& order.clientRequestId === params[2]
);
return [duplicate ? [toOrderRow(duplicate)] : [], []];
}
if (sql.includes('FROM qipai_recharge_plans')) {
if (
state.plan
&& state.plan.tenantId === String(params[0])
&& state.plan.id === String(params[1])
) {
return [[state.plan], []];
}
return [[], []];
}
if (sql.includes('COUNT(*) AS total')) {
return [[{ total: state.purchaseCount }], []];
}
if (sql.includes('INSERT INTO qipai_recharge_orders')) {
const order = {
id: String(state.nextOrderId++),
tenantId: String(params[0]),
userId: String(params[1]),
storeId: params[2] === null ? null : String(params[2]),
planId: String(params[3]),
rechargeNo: params[4],
clientRequestId: params[5],
payAmountCents: Number(params[6]),
giftAmountCents: Number(params[7]),
traceId: params[8],
status: 'PENDING_PAYMENT',
paymentId: null
};
state.orders.push(order);
return [{ insertId: order.id, affectedRows: 1 }, []];
}
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) {
const order = state.orders.find((item) =>
item.tenantId === String(params[0]) && item.id === String(params[1])
);
return [order ? [toOrderRow(order)] : [], []];
}
if (sql.includes("SET status = 'PAID'")) {
const order = state.orders.find((item) =>
item.tenantId === String(params[1]) && item.id === String(params[2])
);
order.status = 'PAID';
order.paymentId = String(params[0]);
return [{ affectedRows: 1 }, []];
}
if (sql.includes("SET status = 'CREDITED'")) {
const order = state.orders.find((item) =>
item.tenantId === String(params[0]) && item.id === String(params[1])
);
order.status = 'CREDITED';
return [{ affectedRows: 1 }, []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
};
const pool = {
async getConnection() {
return connection;
}
};
const wallet = {
async credit(input) {
state.walletCredits.push(input);
return {
ledgerEntryId: String(state.walletCredits.length),
cashBalanceCents: input.cashDeltaCents,
giftBalanceCents: input.giftDeltaCents,
idempotent: false
};
}
};
return { service: new RechargeService(pool, wallet), state };
}
function toOrderRow(order) {
return {
id: order.id,
userId: order.userId,
storeId: order.storeId,
planId: order.planId,
rechargeNo: order.rechargeNo,
payAmountCents: order.payAmountCents,
giftAmountCents: order.giftAmountCents,
status: order.status
};
}
const baseOrder = {
tenantId: '7',
userId: '21',
planId: '31',
storeId: '11',
clientRequestId: 'client-001',
traceId: 'recharge-test'
};
{
const { service, state } = createHarness();
const created = await service.createRechargeOrder(baseOrder);
assert.equal(created.status, 'PENDING_PAYMENT');
assert.equal(created.payAmountCents, 10000);
assert.equal(created.giftAmountCents, 2000);
assert.equal(created.idempotent, false);
assert.equal(state.orders.length, 1);
const duplicate = await service.createRechargeOrder(baseOrder);
assert.equal(duplicate.rechargeOrderId, created.rechargeOrderId);
assert.equal(duplicate.idempotent, true);
assert.equal(state.orders.length, 1);
}
{
const { service } = createHarness({ plan: { status: 'DISABLED' } });
await assert.rejects(
() => service.createRechargeOrder(baseOrder),
(error) => error instanceof RechargeError && error.code === 'RECHARGE_PLAN_DISABLED'
);
}
{
const { service } = createHarness({ plan: { endsAt: past } });
await assert.rejects(
() => service.createRechargeOrder(baseOrder),
(error) => error instanceof RechargeError && error.code === 'RECHARGE_PLAN_EXPIRED'
);
}
{
const { service } = createHarness({ purchaseCount: 2 });
await assert.rejects(
() => service.createRechargeOrder(baseOrder),
(error) => error instanceof RechargeError && error.code === 'RECHARGE_LIMIT_REACHED'
);
}
{
const { service, state } = createHarness();
const created = await service.createRechargeOrder(baseOrder);
const credited = await service.markPaidAndCredit({
tenantId: '7',
rechargeOrderId: created.rechargeOrderId,
paymentId: '501',
traceId: 'payment-callback'
});
assert.equal(credited.status, 'CREDITED');
assert.equal(credited.idempotent, false);
assert.equal(state.orders[0].status, 'CREDITED');
assert.equal(state.walletCredits.length, 1);
assert.deepEqual(
{
businessType: state.walletCredits[0].businessType,
businessId: state.walletCredits[0].businessId,
cashDeltaCents: state.walletCredits[0].cashDeltaCents,
giftDeltaCents: state.walletCredits[0].giftDeltaCents,
storeId: state.walletCredits[0].storeId
},
{
businessType: 'RECHARGE',
businessId: created.rechargeOrderId,
cashDeltaCents: 10000,
giftDeltaCents: 2000,
storeId: '11'
}
);
const duplicateCallback = await service.markPaidAndCredit({
tenantId: '7',
rechargeOrderId: created.rechargeOrderId,
paymentId: '501',
traceId: 'payment-callback'
});
assert.equal(duplicateCallback.idempotent, true);
assert.equal(state.walletCredits.length, 1);
}
console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.');
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS qipai_recharge_orders;
DROP TABLE IF EXISTS qipai_recharge_plans;
DELETE FROM qipai_schema_migrations WHERE version = '2026062422';
@@ -0,0 +1,58 @@
CREATE TABLE IF NOT EXISTS qipai_recharge_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
pay_amount_cents INT UNSIGNED NOT NULL,
gift_amount_cents INT UNSIGNED NOT NULL DEFAULT 0,
scope_type VARCHAR(16) NOT NULL DEFAULT 'TENANT',
starts_at DATETIME(3) NULL,
ends_at DATETIME(3) NULL,
purchase_limit_per_user INT UNSIGNED NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_recharge_plan_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_recharge_plan_store
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
KEY idx_qipai_recharge_plan_scope (tenant_id, store_id, status, starts_at, ends_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_recharge_orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NULL,
plan_id BIGINT UNSIGNED NOT NULL,
payment_id BIGINT UNSIGNED NULL,
recharge_no VARCHAR(64) NOT NULL,
client_request_id VARCHAR(128) NOT NULL,
pay_amount_cents INT UNSIGNED NOT NULL,
gift_amount_cents INT UNSIGNED NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PAYMENT',
paid_at DATETIME(3) NULL,
credited_at DATETIME(3) NULL,
trace_id VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_recharge_order_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_recharge_order_user
FOREIGN KEY (user_id) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_recharge_order_store
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
CONSTRAINT fk_qipai_recharge_order_plan
FOREIGN KEY (plan_id) REFERENCES qipai_recharge_plans(id),
CONSTRAINT fk_qipai_recharge_order_payment
FOREIGN KEY (payment_id) REFERENCES qipai_payments(id),
UNIQUE KEY uq_qipai_recharge_order_no (tenant_id, recharge_no),
UNIQUE KEY uq_qipai_recharge_order_request (tenant_id, user_id, client_request_id),
KEY idx_qipai_recharge_order_user (tenant_id, user_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062422', 'm07b_recharge_plans');
@@ -0,0 +1,29 @@
SELECT 'qipai_recharge_plans' AS table_name
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'qipai_recharge_plans';
SELECT 'qipai_recharge_orders' AS table_name
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'qipai_recharge_orders';
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_recharge_plans'
AND column_name IN (
'pay_amount_cents', 'gift_amount_cents', 'purchase_limit_per_user',
'scope_type', 'starts_at', 'ends_at', 'status'
);
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_recharge_orders'
AND column_name IN (
'client_request_id', 'pay_amount_cents', 'gift_amount_cents',
'payment_id', 'paid_at', 'credited_at', 'status'
);
SELECT '2026062422' AS version
FROM qipai_schema_migrations
WHERE version = '2026062422';