feat(M07-B): 接入充值优惠规则
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"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",
|
||||
"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": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -41,7 +41,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062218_m05d_profit_sharing.up.sql',
|
||||
'database/migrations/2026062219_m06b_device_topology.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: [
|
||||
'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/2026062219_m06b_device_topology.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: [
|
||||
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
|
||||
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
|
||||
'database/migrations/2026062220_m06c_iot_messages.down.sql',
|
||||
'database/migrations/2026062219_m06b_device_topology.down.sql',
|
||||
@@ -224,7 +227,8 @@ export async function executeMigrationPlan(
|
||||
3, 7, 5, 1,
|
||||
5, 8, 5, 2, 1,
|
||||
3, 3, 9, 1,
|
||||
1, 1, 1, 4, 7, 1
|
||||
1, 1, 1, 4, 7, 1,
|
||||
1, 1, 7, 7, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 walletDownSql = read('database/migrations/2026062421_m07a_wallet_ledger.down.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 = [
|
||||
'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, /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.');
|
||||
|
||||
@@ -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, /2026062219_m06b_device_topology\.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.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user