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
+7 -3
View File
@@ -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(
+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
};
}