283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
|
import type { MySqlPool } from '../db/mysql.js';
|
|
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
|
|
|
|
export const orderActions = [
|
|
'SUBMIT', 'CONFIRM_PAYMENT', 'RESERVE', 'START', 'FINISH', 'CANCEL',
|
|
'BEGIN_REFUND', 'COMPLETE_REFUND', 'CLOSE'
|
|
] as const;
|
|
export type OrderAction = typeof orderActions[number];
|
|
export type OrderStatus =
|
|
| 'DRAFT' | 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS'
|
|
| 'FINISHED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED' | 'CLOSED';
|
|
|
|
export interface OrderActor {
|
|
tenantId: string;
|
|
userId: string;
|
|
actorType: 'USER' | 'SYSTEM';
|
|
source: 'APP' | 'ADMIN' | 'PAYMENT' | 'WORKER' | 'SYSTEM';
|
|
traceId: string;
|
|
ip: string;
|
|
userAgent: string;
|
|
access?: AccessProfile;
|
|
}
|
|
|
|
interface OrderRow extends RowDataPacket {
|
|
id: string;
|
|
storeId: string;
|
|
status: OrderStatus;
|
|
statusVersion: number;
|
|
}
|
|
interface HistoryRow extends RowDataPacket {
|
|
id: string;
|
|
fromStatus: OrderStatus | null;
|
|
toStatus: OrderStatus;
|
|
action: OrderAction | 'CREATED' | 'EXPIRED' | 'MIGRATED';
|
|
actorType: string;
|
|
actorId: string | null;
|
|
source: string;
|
|
reason: string;
|
|
traceId: string;
|
|
createdAt: Date;
|
|
}
|
|
|
|
const targetByAction: Record<OrderAction, OrderStatus> = {
|
|
SUBMIT: 'PENDING_PAYMENT',
|
|
CONFIRM_PAYMENT: 'PAID',
|
|
RESERVE: 'RESERVED',
|
|
START: 'IN_PROGRESS',
|
|
FINISH: 'FINISHED',
|
|
CANCEL: 'CANCELLED',
|
|
BEGIN_REFUND: 'REFUNDING',
|
|
COMPLETE_REFUND: 'REFUNDED',
|
|
CLOSE: 'CLOSED'
|
|
};
|
|
|
|
const allowedActions: Record<OrderStatus, readonly OrderAction[]> = {
|
|
DRAFT: ['SUBMIT', 'CANCEL', 'CLOSE'],
|
|
PENDING_PAYMENT: ['CONFIRM_PAYMENT', 'CANCEL', 'CLOSE'],
|
|
PAID: ['RESERVE', 'START', 'CANCEL', 'BEGIN_REFUND'],
|
|
RESERVED: ['START', 'CANCEL', 'BEGIN_REFUND'],
|
|
IN_PROGRESS: ['FINISH', 'BEGIN_REFUND'],
|
|
FINISHED: ['BEGIN_REFUND', 'CLOSE'],
|
|
CANCELLED: ['BEGIN_REFUND', 'CLOSE'],
|
|
REFUNDING: ['COMPLETE_REFUND'],
|
|
REFUNDED: ['CLOSE'],
|
|
CLOSED: []
|
|
};
|
|
|
|
export class OrderStateError extends Error {
|
|
constructor(public readonly code: string) { super(code); }
|
|
}
|
|
|
|
export class OrderStateRepository {
|
|
constructor(
|
|
private readonly pool: MySqlPool,
|
|
private readonly benefits?: Pick<MarketingBenefitService, 'releaseReservedInTransaction'>,
|
|
private readonly cleaningTasks?: {
|
|
createForFinishedOrder(
|
|
connection: PoolConnection,
|
|
input: { tenantId: string; orderId: string; actorId: string; traceId: string }
|
|
): Promise<void>;
|
|
}
|
|
) {}
|
|
|
|
async transition(actor: OrderActor, orderId: string, action: OrderAction, reason = '') {
|
|
return this.transaction(async (connection) => {
|
|
const order = await this.loadOrder(connection, actor.tenantId, orderId, true);
|
|
await this.assertAuthorized(connection, actor, order, action);
|
|
const duplicate = await this.findByTrace(connection, actor.tenantId, orderId, actor.traceId);
|
|
if (duplicate) {
|
|
return {
|
|
orderId,
|
|
status: duplicate.toStatus,
|
|
statusVersion: null,
|
|
historyId: duplicate.id,
|
|
idempotent: true
|
|
};
|
|
}
|
|
if (!allowedActions[order.status].includes(action)) {
|
|
throw new OrderStateError('ORDER_TRANSITION_NOT_ALLOWED');
|
|
}
|
|
const targetStatus = targetByAction[action];
|
|
const nextVersion = Number(order.statusVersion) + 1;
|
|
await connection.execute(
|
|
`UPDATE qipai_orders
|
|
SET status = ?, status_version = ?, status_updated_at = UTC_TIMESTAMP(3)
|
|
WHERE tenant_id = ? AND id = ?`,
|
|
[targetStatus, nextVersion, actor.tenantId, orderId]
|
|
);
|
|
await this.applyReservationState(connection, actor.tenantId, orderId, targetStatus);
|
|
if (targetStatus === 'FINISHED' && this.cleaningTasks) {
|
|
await this.cleaningTasks.createForFinishedOrder(connection, {
|
|
tenantId: actor.tenantId,
|
|
orderId,
|
|
actorId: actor.userId,
|
|
traceId: actor.traceId
|
|
});
|
|
}
|
|
const [result] = await connection.execute<ResultSetHeader>(
|
|
`INSERT INTO qipai_order_status_history
|
|
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
|
actor_id, source, reason, trace_id, metadata)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('statusVersion', ?))`,
|
|
[actor.tenantId, orderId, order.status, targetStatus, action,
|
|
actor.actorType, actor.userId, actor.source, reason.slice(0, 512),
|
|
actor.traceId, nextVersion]
|
|
);
|
|
await connection.execute(
|
|
`INSERT INTO qipai_audit_logs
|
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
|
trace_id, ip, user_agent, metadata)
|
|
VALUES (?, ?, ?, 'ORDER_STATUS_CHANGED', 'ORDER', ?, ?, ?, ?,
|
|
JSON_OBJECT('fromStatus', ?, 'toStatus', ?, 'orderAction', ?))`,
|
|
[actor.tenantId, actor.actorType, actor.userId, orderId, actor.traceId,
|
|
actor.ip, actor.userAgent.slice(0, 255), order.status, targetStatus, action]
|
|
);
|
|
return {
|
|
orderId,
|
|
status: targetStatus,
|
|
statusVersion: nextVersion,
|
|
historyId: String(result.insertId),
|
|
idempotent: false
|
|
};
|
|
});
|
|
}
|
|
|
|
async history(tenantId: string, userId: string, orderId: string, access: AccessProfile) {
|
|
const order = await this.loadOrder(this.pool, tenantId, orderId, false);
|
|
const manager = canManageStore(access, order.storeId);
|
|
if (!manager) {
|
|
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
|
`SELECT 1 FROM qipai_order_user_access
|
|
WHERE tenant_id = ? AND order_id = ? AND user_id = ?`,
|
|
[tenantId, orderId, userId]
|
|
);
|
|
if (!rows[0]) throw new OrderStateError('ORDER_ACCESS_FORBIDDEN');
|
|
}
|
|
const [rows] = await this.pool.execute<HistoryRow[]>(
|
|
`SELECT id, from_status AS fromStatus, to_status AS toStatus, action,
|
|
actor_type AS actorType, actor_id AS actorId, source, reason,
|
|
trace_id AS traceId, created_at AS createdAt
|
|
FROM qipai_order_status_history
|
|
WHERE tenant_id = ? AND order_id = ? ORDER BY id`,
|
|
[tenantId, orderId]
|
|
);
|
|
return rows.map((row) => ({
|
|
...row,
|
|
id: String(row.id),
|
|
actorId: row.actorId === null ? null : String(row.actorId)
|
|
}));
|
|
}
|
|
|
|
private async assertAuthorized(
|
|
connection: PoolConnection, actor: OrderActor, order: OrderRow, action: OrderAction
|
|
) {
|
|
if (actor.source !== 'APP') {
|
|
if (!actor.access || !canManageStore(actor.access, order.storeId)) {
|
|
throw new OrderStateError('ORDER_MANAGEMENT_FORBIDDEN');
|
|
}
|
|
return;
|
|
}
|
|
if (action !== 'CANCEL') throw new OrderStateError('ORDER_ACTION_FORBIDDEN');
|
|
const [rows] = await connection.execute<RowDataPacket[]>(
|
|
`SELECT 1 FROM qipai_order_user_access
|
|
WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
|
[actor.tenantId, order.id, actor.userId]
|
|
);
|
|
if (!rows[0]) throw new OrderStateError('ORDER_ACCESS_FORBIDDEN');
|
|
}
|
|
|
|
private async loadOrder(
|
|
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
|
|
tenantId: string,
|
|
orderId: string,
|
|
lock: boolean
|
|
) {
|
|
const [rows] = await connection.execute<OrderRow[]>(
|
|
`SELECT id, store_id AS storeId, status, status_version AS statusVersion
|
|
FROM qipai_orders
|
|
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
|
|
${lock ? 'FOR UPDATE' : ''}`,
|
|
[tenantId, orderId]
|
|
);
|
|
if (!rows[0]) throw new OrderStateError('ORDER_NOT_FOUND');
|
|
return rows[0];
|
|
}
|
|
|
|
private async findByTrace(
|
|
connection: PoolConnection, tenantId: string, orderId: string, traceId: string
|
|
) {
|
|
const [rows] = await connection.execute<HistoryRow[]>(
|
|
`SELECT id, to_status AS toStatus
|
|
FROM qipai_order_status_history
|
|
WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`,
|
|
[tenantId, orderId, traceId]
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
private async applyReservationState(
|
|
connection: PoolConnection, tenantId: string, orderId: string, status: OrderStatus
|
|
) {
|
|
if (['PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED'].includes(status)) {
|
|
await connection.execute(
|
|
`UPDATE qipai_room_reservations
|
|
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
|
|
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
|
|
[tenantId, orderId]
|
|
);
|
|
} else if (['CANCELLED', 'REFUNDED', 'CLOSED'].includes(status)) {
|
|
await connection.execute(
|
|
`UPDATE qipai_room_reservations
|
|
SET status = 'RELEASED', released_at = COALESCE(released_at, UTC_TIMESTAMP(3))
|
|
WHERE tenant_id = ? AND order_id = ? AND status IN ('HELD', 'CONSUMED')`,
|
|
[tenantId, orderId]
|
|
);
|
|
await connection.execute(
|
|
`UPDATE qipai_order_user_access
|
|
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(3))
|
|
WHERE tenant_id = ? AND order_id = ?`,
|
|
[tenantId, orderId]
|
|
);
|
|
if (this.benefits) {
|
|
const [users] = await connection.execute<RowDataPacket[]>(
|
|
`SELECT user_id AS userId FROM qipai_order_user_access
|
|
WHERE tenant_id = ? AND order_id = ? ORDER BY id LIMIT 1`,
|
|
[tenantId, orderId]
|
|
);
|
|
if (users[0]?.userId) {
|
|
await this.benefits.releaseReservedInTransaction(connection, {
|
|
tenantId,
|
|
userId: String(users[0].userId),
|
|
orderId,
|
|
traceId: `order-benefit-release-${orderId}`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 canManageStore(access: AccessProfile, storeId: string) {
|
|
return access.capabilities.includes('tenant.manage')
|
|
|| access.roles.includes('PLATFORM_ADMIN')
|
|
|| (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId));
|
|
}
|