feat(M07-C): 建立优惠券套餐权益核销

This commit is contained in:
Codex
2026-06-24 15:45:22 +08:00
parent ba99beb221
commit 4ae9296f83
9 changed files with 936 additions and 6 deletions
+7 -3
View File
@@ -42,7 +42,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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/2026062422_m07b_recharge_plans.up.sql'
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
'database/migrations/2026062423_m07c_benefits.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -66,9 +67,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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/2026062422_m07b_recharge_plans.verify.sql'
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
'database/migrations/2026062423_m07c_benefits.verify.sql'
],
down: [
'database/migrations/2026062423_m07c_benefits.down.sql',
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
'database/migrations/2026062220_m06c_iot_messages.down.sql',
@@ -228,7 +231,8 @@ export async function executeMigrationPlan(
5, 8, 5, 2, 1,
3, 3, 9, 1,
1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1
1, 1, 7, 7, 1,
5, 10, 7, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -0,0 +1,461 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
interface CouponRow extends RowDataPacket {
id: string;
status: string;
validFrom: Date;
validTo: Date;
remainingUses: number;
couponType: 'TIME' | 'FULL_REDUCTION';
discountAmountCents: number;
timeMinutes: number;
minOrderAmountCents: number;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
weekdaysJson: string | number[] | null;
holidayOnly: number;
}
interface PackageRow extends RowDataPacket {
id: string;
status: string;
validFrom: Date;
validTo: Date;
remainingMinutes: number;
remainingAmountCents: number;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
weekdaysJson: string | number[] | null;
holidayOnly: number;
}
interface UsageRow extends RowDataPacket {
id: string;
benefitType: 'COUPON' | 'PACKAGE';
benefitId: string;
status: string;
discountCents: number;
packageCreditCents: number;
minutes: number;
}
export class MarketingBenefitError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class MarketingBenefitService {
constructor(private readonly pool: MySqlPool) {}
async reserveForOrder(input: {
tenantId: string;
userId: string;
orderId: string;
storeId: string;
roomId?: string | null;
roomCategoryId?: string | null;
orderAmountCents: number;
orderStartAt: Date;
isHoliday?: boolean;
couponGrantId?: string | null;
packageHoldingId?: string | null;
packageMinutes?: number;
packageCreditCents?: number;
clientRequestId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
const duplicate = await this.findUsageByRequest(connection, input);
if (duplicate.length > 0) return reserveResponse(duplicate, true);
const usages: UsageRow[] = [];
if (input.couponGrantId) {
const coupon = await this.loadCoupon(connection, input, input.couponGrantId);
assertUsableBenefit(coupon, input, 'COUPON');
const discountCents = coupon.couponType === 'FULL_REDUCTION'
? Math.min(Number(coupon.discountAmountCents), input.orderAmountCents)
: 0;
const minutes = coupon.couponType === 'TIME' ? Number(coupon.timeMinutes) : 0;
const usage = await this.insertUsage(connection, {
...input,
benefitType: 'COUPON',
benefitId: coupon.id,
discountCents,
packageCreditCents: 0,
minutes
});
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = 'FROZEN', frozen_order_id = ?
WHERE tenant_id = ? AND id = ?`,
[input.orderId, input.tenantId, coupon.id]
);
usages.push(usage);
}
if (input.packageHoldingId) {
const holding = await this.loadPackageHolding(connection, input, input.packageHoldingId);
assertUsableBenefit(holding, input, 'PACKAGE');
const packageCreditCents = input.packageCreditCents ?? 0;
const minutes = input.packageMinutes ?? 0;
if (minutes <= 0 && packageCreditCents <= 0) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_USAGE_EMPTY');
}
if (minutes > Number(holding.remainingMinutes)) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_MINUTES_INSUFFICIENT');
}
if (packageCreditCents > Number(holding.remainingAmountCents)) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_AMOUNT_INSUFFICIENT');
}
const usage = await this.insertUsage(connection, {
...input,
benefitType: 'PACKAGE',
benefitId: holding.id,
discountCents: 0,
packageCreditCents,
minutes
});
await connection.execute(
`UPDATE qipai_package_holdings
SET status = 'FROZEN', frozen_order_id = ?
WHERE tenant_id = ? AND id = ?`,
[input.orderId, input.tenantId, holding.id]
);
usages.push(usage);
}
if (usages.length === 0) {
throw new MarketingBenefitError('BENEFIT_EMPTY');
}
return reserveResponse(usages, false);
});
}
async confirmReserved(input: {
tenantId: string;
userId: string;
orderId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
const usages = await this.loadOrderUsages(connection, input, true);
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
if (usages.every((usage) => usage.status === 'CONFIRMED')) {
return reserveResponse(usages, true);
}
for (const usage of usages) {
if (usage.status !== 'FROZEN') {
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
}
if (usage.benefitType === 'COUPON') {
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = CASE WHEN remaining_uses <= 1 THEN 'USED' ELSE 'AVAILABLE' END,
remaining_uses = GREATEST(remaining_uses - 1, 0),
used_count = used_count + 1,
used_at = UTC_TIMESTAMP(3),
frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
} else {
await connection.execute(
`UPDATE qipai_package_holdings
SET remaining_minutes = GREATEST(remaining_minutes - ?, 0),
remaining_amount_cents = GREATEST(remaining_amount_cents - ?, 0),
status = CASE
WHEN remaining_minutes <= ? AND remaining_amount_cents <= ? THEN 'EXHAUSTED'
ELSE 'ACTIVE'
END,
frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[
usage.minutes, usage.packageCreditCents,
usage.minutes, usage.packageCreditCents,
input.tenantId, usage.benefitId
]
);
}
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'CONFIRMED');
}
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'CONFIRMED' })), false);
});
}
async releaseReserved(input: {
tenantId: string;
userId: string;
orderId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
const usages = await this.loadOrderUsages(connection, input, true);
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
if (usages.every((usage) => usage.status === 'RELEASED')) {
return reserveResponse(usages, true);
}
for (const usage of usages) {
if (usage.status !== 'FROZEN') {
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
}
if (usage.benefitType === 'COUPON') {
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = 'AVAILABLE', frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
} else {
await connection.execute(
`UPDATE qipai_package_holdings
SET status = 'ACTIVE', frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
}
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'RELEASED');
}
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'RELEASED' })), false);
});
}
private async loadCoupon(
connection: PoolConnection,
input: { tenantId: string; userId: string },
couponGrantId: string
) {
const [rows] = await connection.execute<CouponRow[]>(
`SELECT g.id, g.status, g.valid_from AS validFrom, g.valid_to AS validTo,
g.remaining_uses AS remainingUses,
t.coupon_type AS couponType, t.discount_amount_cents AS discountAmountCents,
t.time_minutes AS timeMinutes, t.min_order_amount_cents AS minOrderAmountCents,
t.store_id AS storeId, t.room_category_id AS roomCategoryId,
t.room_id AS roomId, t.weekdays_json AS weekdaysJson, t.holiday_only AS holidayOnly
FROM qipai_coupon_grants g
INNER JOIN qipai_coupon_templates t
ON t.tenant_id = g.tenant_id AND t.id = g.template_id
WHERE g.tenant_id = ? AND g.user_id = ? AND g.id = ?
AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.userId, couponGrantId]
);
if (!rows[0]) throw new MarketingBenefitError('BENEFIT_COUPON_NOT_FOUND');
return normalizeScopeRow(rows[0]);
}
private async loadPackageHolding(
connection: PoolConnection,
input: { tenantId: string; userId: string },
packageHoldingId: string
) {
const [rows] = await connection.execute<PackageRow[]>(
`SELECT h.id, h.status, h.valid_from AS validFrom, h.valid_to AS validTo,
h.remaining_minutes AS remainingMinutes,
h.remaining_amount_cents AS remainingAmountCents,
p.store_id AS storeId, p.room_category_id AS roomCategoryId,
p.room_id AS roomId, p.weekdays_json AS weekdaysJson, p.holiday_only AS holidayOnly
FROM qipai_package_holdings h
INNER JOIN qipai_package_plans p
ON p.tenant_id = h.tenant_id AND p.id = h.plan_id
WHERE h.tenant_id = ? AND h.user_id = ? AND h.id = ?
AND p.status = 'ACTIVE' AND p.deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.userId, packageHoldingId]
);
if (!rows[0]) throw new MarketingBenefitError('BENEFIT_PACKAGE_NOT_FOUND');
return normalizeScopeRow(rows[0]);
}
private async findUsageByRequest(
connection: PoolConnection,
input: { tenantId: string; userId: string; clientRequestId: string }
) {
const [rows] = await connection.execute<UsageRow[]>(
`SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status,
discount_cents AS discountCents,
package_credit_cents AS packageCreditCents, minutes
FROM qipai_benefit_usages
WHERE tenant_id = ? AND user_id = ? AND client_request_id = ?`,
[input.tenantId, input.userId, input.clientRequestId]
);
return rows.map(normalizeUsageRow);
}
private async loadOrderUsages(
connection: PoolConnection,
input: { tenantId: string; userId: string; orderId: string },
lock: boolean
) {
const [rows] = await connection.execute<UsageRow[]>(
`SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status,
discount_cents AS discountCents,
package_credit_cents AS packageCreditCents, minutes
FROM qipai_benefit_usages
WHERE tenant_id = ? AND user_id = ? AND order_id = ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.userId, input.orderId]
);
return rows.map(normalizeUsageRow);
}
private async insertUsage(
connection: PoolConnection,
input: {
tenantId: string;
userId: string;
orderId: string;
clientRequestId: string;
traceId: string;
benefitType: 'COUPON' | 'PACKAGE';
benefitId: string;
discountCents: number;
packageCreditCents: number;
minutes: number;
}
) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_benefit_usages
(tenant_id, user_id, order_id, benefit_type, benefit_id,
client_request_id, discount_cents, package_credit_cents, minutes, trace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.tenantId, input.userId, input.orderId, input.benefitType, input.benefitId,
input.clientRequestId, input.discountCents, input.packageCreditCents,
input.minutes, input.traceId
]
);
return {
id: String(result.insertId),
benefitType: input.benefitType,
benefitId: input.benefitId,
status: 'FROZEN',
discountCents: input.discountCents,
packageCreditCents: input.packageCreditCents,
minutes: input.minutes
} as UsageRow;
}
private async updateUsageStatus(
connection: PoolConnection,
tenantId: string,
usageId: string,
status: 'CONFIRMED' | 'RELEASED'
) {
await connection.execute(
`UPDATE qipai_benefit_usages
SET status = ?
WHERE tenant_id = ? AND id = ?`,
[status, tenantId, usageId]
);
}
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 assertUsableBenefit(
row: CouponRow | PackageRow,
input: {
storeId: string;
roomId?: string | null;
roomCategoryId?: string | null;
orderAmountCents: number;
orderStartAt: Date;
isHoliday?: boolean;
},
type: 'COUPON' | 'PACKAGE'
) {
if (row.status !== 'AVAILABLE' && row.status !== 'ACTIVE') {
throw new MarketingBenefitError(`BENEFIT_${type}_STATUS_INVALID`);
}
const orderTime = input.orderStartAt.getTime();
if (row.validFrom.getTime() > orderTime || row.validTo.getTime() <= orderTime) {
throw new MarketingBenefitError(`BENEFIT_${type}_EXPIRED`);
}
if ('remainingUses' in row && Number(row.remainingUses) <= 0) {
throw new MarketingBenefitError('BENEFIT_COUPON_USED_UP');
}
if ('minOrderAmountCents' in row && input.orderAmountCents < Number(row.minOrderAmountCents)) {
throw new MarketingBenefitError('BENEFIT_COUPON_MIN_AMOUNT_NOT_MET');
}
if (row.storeId && row.storeId !== input.storeId) {
throw new MarketingBenefitError(`BENEFIT_${type}_STORE_MISMATCH`);
}
if (row.roomCategoryId && row.roomCategoryId !== (input.roomCategoryId ?? null)) {
throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_CATEGORY_MISMATCH`);
}
if (row.roomId && row.roomId !== (input.roomId ?? null)) {
throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_MISMATCH`);
}
if (Number(row.holidayOnly) === 1 && input.isHoliday !== true) {
throw new MarketingBenefitError(`BENEFIT_${type}_HOLIDAY_ONLY`);
}
const weekdays = parseWeekdays(row.weekdaysJson);
if (weekdays.length > 0 && !weekdays.includes(input.orderStartAt.getUTCDay())) {
throw new MarketingBenefitError(`BENEFIT_${type}_WEEKDAY_MISMATCH`);
}
}
function normalizeScopeRow<T extends CouponRow | PackageRow>(row: T): T {
return {
...row,
id: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId)
};
}
function normalizeUsageRow(row: UsageRow): UsageRow {
return {
...row,
id: String(row.id),
benefitId: String(row.benefitId),
discountCents: Number(row.discountCents),
packageCreditCents: Number(row.packageCreditCents),
minutes: Number(row.minutes)
};
}
function parseWeekdays(value: string | number[] | null): number[] {
if (!value) return [];
if (Array.isArray(value)) return value.map(Number);
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(Number) : [];
} catch {
return [];
}
}
function reserveResponse(usages: UsageRow[], idempotent: boolean) {
return {
idempotent,
status: usages.every((usage) => usage.status === usages[0]?.status)
? usages[0]?.status ?? 'EMPTY'
: 'MIXED',
discountCents: usages.reduce((sum, usage) => sum + Number(usage.discountCents), 0),
packageCreditCents: usages.reduce((sum, usage) => sum + Number(usage.packageCreditCents), 0),
minutes: usages.reduce((sum, usage) => sum + Number(usage.minutes), 0),
usages: usages.map((usage) => ({
usageId: String(usage.id),
benefitType: usage.benefitType,
benefitId: String(usage.benefitId),
status: usage.status
}))
};
}