Compare commits

..

2 Commits

Author SHA1 Message Date
Codex 3d885852cd docs(M08-A): 更新充值微信支付进度 2026-06-25 12:41:40 +08:00
Codex 53f5d6776f feat(M08-A): 接入充值微信支付调起 2026-06-25 12:40:21 +08:00
21 changed files with 712 additions and 22 deletions
+2 -2
View File
@@ -121,8 +121,8 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。 项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。
- 当前执行游标:`M08-A`PARTIAL - 当前执行游标:`M08-A`PARTIAL
- 最近工程提交:`e24920c`,完成顾客端优惠券/套餐明细和订单微信支付调起。 - 最近工程提交:`53f5d67`,完成顾客端充值微信支付调起、通知验签解密和充值入账链路
- 下一工程目标:继续 M08-A,补充值微信支付调起和权益下单抵扣集成。 - 下一工程目标:继续 M08-A,补余额/优惠券/套餐下单抵扣集成。
## 版本递进 ## 版本递进
+5 -2
View File
@@ -43,7 +43,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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', 'database/migrations/2026062422_m07b_recharge_plans.up.sql',
'database/migrations/2026062423_m07c_benefits.up.sql' 'database/migrations/2026062423_m07c_benefits.up.sql',
'database/migrations/2026062524_m08a_recharge_wechat.up.sql'
], ],
verify: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -68,9 +69,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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', 'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
'database/migrations/2026062423_m07c_benefits.verify.sql' 'database/migrations/2026062423_m07c_benefits.verify.sql',
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql'
], ],
down: [ down: [
'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
'database/migrations/2026062423_m07c_benefits.down.sql', 'database/migrations/2026062423_m07c_benefits.down.sql',
'database/migrations/2026062422_m07b_recharge_plans.down.sql', 'database/migrations/2026062422_m07b_recharge_plans.down.sql',
'database/migrations/2026062421_m07a_wallet_ledger.down.sql', 'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
+77 -4
View File
@@ -1,9 +1,11 @@
import type { FastifyInstance, FastifyReply } from 'fastify'; import type { FastifyInstance, FastifyReply, preParsingHookHandler } from 'fastify';
import { Transform } from 'node:stream';
import { z } from 'zod'; import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js'; import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js'; import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js'; import type { AccessProfile } from '../auth/rbac-repository.js';
import { RechargeError, type RechargeService } from '../wallets/recharge-service.js'; import { RechargeError, type RechargeService } from '../wallets/recharge-service.js';
import { WechatPayError } from '../payments/wechat-pay-client.js';
const listSchema = z.object({ const listSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional() storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
@@ -13,9 +15,15 @@ const createSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(), storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
clientRequestId: z.string().min(8).max(128) clientRequestId: z.string().min(8).max(128)
}).strict(); }).strict();
const orderParams = z.object({
rechargeOrderId: z.string().regex(/^[1-9]\d{0,19}$/)
});
export interface RechargeRouteOptions { export interface RechargeRouteOptions {
service: Pick<RechargeService, 'listAvailablePlans' | 'createRechargeOrder'>; service: Pick<
RechargeService,
'listAvailablePlans' | 'createRechargeOrder' | 'createWechatPrepay' | 'processWechatNotification'
>;
authRepository: Pick<AuthRepository, 'validateSession'>; authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> }; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string; jwtSecret: string;
@@ -58,6 +66,36 @@ export async function registerRechargeRoutes(
traceId: request.traceId traceId: request.traceId
})); }));
}); });
app.post('/app-api/recharge/orders/:rechargeOrderId/wechat-prepay', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = orderParams.safeParse(request.params);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.createWechatPrepay({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
userId: auth.userId,
rechargeOrderId: params.data.rechargeOrderId
}),
traceId: request.traceId
}));
});
app.post('/app-api/recharge/wechat/notify', {
preParsing: captureRawBody
}, async (request, reply) => {
return handle(reply, request.traceId, async () => {
const data = await options.service.processWechatNotification(
notificationHeaders(request.headers),
request.rawBody,
request.traceId
);
return reply.send({ code: 'SUCCESS', message: '成功', data });
});
});
} }
async function authenticate( async function authenticate(
@@ -77,6 +115,7 @@ async function authenticate(
if (!access.capabilities.includes('profile.read')) return null; if (!access.capabilities.includes('profile.read')) return null;
return { return {
tenantId: result.session.tenantId, tenantId: result.session.tenantId,
platformAppId: result.session.platformAppId,
userId: result.session.user.id userId: result.session.user.id
}; };
} }
@@ -85,11 +124,12 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
try { try {
return await work(); return await work();
} catch (error) { } catch (error) {
if (!(error instanceof RechargeError)) throw error; if (!(error instanceof RechargeError) && !(error instanceof WechatPayError)) throw error;
const status = error.code === 'RECHARGE_PLAN_NOT_FOUND' const status = error.code === 'RECHARGE_PLAN_NOT_FOUND'
|| error.code === 'RECHARGE_ORDER_NOT_FOUND' || error.code === 'RECHARGE_ORDER_NOT_FOUND'
? 404 ? 404
: error.code === 'RECHARGE_LIMIT_REACHED' ? 409 : 400; : error.code === 'RECHARGE_LIMIT_REACHED' ? 409
: error.code.includes('FORBIDDEN') ? 403 : 400;
return reply.status(status).send({ return reply.status(status).send({
code: error.code, code: error.code,
message: 'The recharge request is not available.', message: 'The recharge request is not available.',
@@ -98,6 +138,39 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
} }
} }
const captureRawBody: preParsingHookHandler = (request, _reply, payload, done) => {
const chunks: Buffer[] = [];
const capture = new Transform({
transform(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback(null, chunk);
},
flush(callback) {
request.rawBody = Buffer.concat(chunks).toString('utf8');
callback();
}
});
const transformed = payload.pipe(capture) as typeof payload;
transformed.receivedEncodedLength = payload.receivedEncodedLength;
done(null, transformed);
};
function notificationHeaders(headers: Record<string, unknown>) {
const read = (name: string) => {
const value = headers[name];
if (typeof value !== 'string' || value.length === 0) {
throw new WechatPayError('WECHAT_NOTIFICATION_HEADER_INVALID');
}
return value;
};
return {
timestamp: read('wechatpay-timestamp'),
nonce: read('wechatpay-nonce'),
serial: read('wechatpay-serial'),
signature: read('wechatpay-signature')
};
}
function unauthorized(reply: FastifyReply, traceId: string) { function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({ return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', code: 'AUTH_SESSION_INVALID',
+5 -1
View File
@@ -174,7 +174,11 @@ const app = await buildApp({
jwtSecret: config.auth.jwtSecret jwtSecret: config.auth.jwtSecret
}, },
recharge: { recharge: {
service: new RechargeService(pool, walletLedgerService), service: new RechargeService(pool, walletLedgerService, {
paymentRepository,
client: wechatPayClient,
credentials: wechatCredentials
}),
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret jwtSecret: config.auth.jwtSecret
+292 -1
View File
@@ -1,6 +1,13 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js'; import type { MySqlPool } from '../db/mysql.js';
import type { PaymentRepository } from '../payments/payment-repository.js';
import {
WechatPayClient,
WechatPayError,
type WechatNotificationHeaders,
type WechatPayCredential
} from '../payments/wechat-pay-client.js';
import type { WalletLedgerService } from './wallet-ledger-service.js'; import type { WalletLedgerService } from './wallet-ledger-service.js';
interface PlanRow extends RowDataPacket { interface PlanRow extends RowDataPacket {
@@ -26,6 +33,7 @@ interface RechargeOrderRow extends RowDataPacket {
payAmountCents: number; payAmountCents: number;
giftAmountCents: number; giftAmountCents: number;
status: string; status: string;
providerPaymentId?: string | null;
} }
interface CountRow extends RowDataPacket { total: number } interface CountRow extends RowDataPacket { total: number }
@@ -37,7 +45,12 @@ export class RechargeError extends Error {
export class RechargeService { export class RechargeService {
constructor( constructor(
private readonly pool: MySqlPool, private readonly pool: MySqlPool,
private readonly wallet: Pick<WalletLedgerService, 'credit'> private readonly wallet: Pick<WalletLedgerService, 'credit'>,
private readonly wechat?: {
paymentRepository: Pick<PaymentRepository, 'resolveConfig'>;
client: WechatPayClient;
credentials: ReadonlyMap<string, WechatPayCredential>;
}
) {} ) {}
async listAvailablePlans(input: { async listAvailablePlans(input: {
@@ -158,6 +171,113 @@ export class RechargeService {
}); });
} }
async createWechatPrepay(input: {
tenantId: string;
platformAppId: string;
userId: string;
rechargeOrderId: string;
}) {
if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED');
const order = await this.loadOwnedRechargeOrder(input, false);
if (order.status === 'CREDITED') throw new RechargeError('RECHARGE_ALREADY_CREDITED');
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
throw new RechargeError('RECHARGE_STATUS_INVALID');
}
const config = await this.wechat.paymentRepository.resolveConfig(
this.pool,
input.tenantId,
input.platformAppId,
order.storeId ?? '0',
'WECHAT'
);
const credential = this.resolveWechatCredential(config.credentialRef);
const settings = config.settings as Record<string, unknown>;
const [identityRows] = await this.pool.execute<RowDataPacket[]>(
`SELECT openid FROM qipai_user_identities
WHERE tenant_id = ? AND platform_app_id = ? AND user_id = ?
AND provider = 'WECHAT' LIMIT 1`,
[input.tenantId, input.platformAppId, input.userId]
);
if (!identityRows[0]?.openid) throw new WechatPayError('WECHAT_OPENID_NOT_FOUND');
const result = await this.wechat.client.createJsapiPrepay(credential, {
description: typeof settings.rechargeDescription === 'string'
? settings.rechargeDescription : `棋牌室余额充值 ${order.rechargeNo}`,
outTradeNo: order.rechargeNo,
notifyUrl: settingUrl(
settings, 'rechargeNotifyUrl', 'https://api.txyundm.cn/app-api/recharge/wechat/notify'
),
amountCents: Number(order.payAmountCents),
payerOpenId: String(identityRows[0].openid)
});
await this.pool.execute(
`UPDATE qipai_recharge_orders
SET payment_provider = 'WECHAT', provider_prepay_id = ?
WHERE tenant_id = ? AND id = ? AND user_id = ?`,
[result.prepayId, input.tenantId, input.rechargeOrderId, input.userId]
);
return {
rechargeOrderId: input.rechargeOrderId,
rechargeNo: order.rechargeNo,
amountCents: Number(order.payAmountCents),
...result.paymentParams
};
}
async processWechatNotification(
headers: WechatNotificationHeaders,
rawBody: string,
traceId: string
) {
if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED');
const data = this.verifyWithConfiguredCredential(headers, rawBody);
const rechargeNo = requiredString(data, 'out_trade_no');
const transactionId = requiredString(data, 'transaction_id');
const tradeState = requiredString(data, 'trade_state');
const amount = objectValue(data.amount);
const total = requiredNumber(amount, 'total');
return this.transaction(async (connection) => {
const order = await this.loadRechargeOrderByNo(connection, rechargeNo, true);
const callbackId = `${headers.serial}:${transactionId}:${tradeState}`;
const [callback] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_payment_callbacks
(tenant_id, payment_id, provider, callback_id, callback_type,
verified, payload)
VALUES (?, NULL, 'WECHAT_RECHARGE', ?, 'PAYMENT_NOTIFICATION', 1, CAST(? AS JSON))`,
[order.tenantId, callbackId, JSON.stringify(redactNotification(data))]
);
if (callback.affectedRows === 0) {
return { rechargeOrderId: order.id, status: order.status, idempotent: true };
}
if (tradeState !== 'SUCCESS' || total !== Number(order.payAmountCents)) {
const code = tradeState !== 'SUCCESS'
? `WECHAT_TRADE_${tradeState}` : 'PAYMENT_AMOUNT_MISMATCH';
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'REJECTED', error_code = ?,
processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`,
[code, order.tenantId, callbackId]
);
return { rechargeOrderId: order.id, status: 'REJECTED', code, idempotent: false };
}
const credited = await this.creditRechargeOrder(connection, {
tenantId: String(order.tenantId),
rechargeOrderId: String(order.id),
providerTransactionId: transactionId,
providerCallbackId: callbackId,
rawNotify: redactNotification(data),
traceId
});
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`,
[order.tenantId, callbackId]
);
return credited;
});
}
private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) { private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) {
const [rows] = await connection.execute<PlanRow[]>( const [rows] = await connection.execute<PlanRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, name, `SELECT id, tenant_id AS tenantId, store_id AS storeId, name,
@@ -217,6 +337,128 @@ export class RechargeService {
}; };
} }
private async loadOwnedRechargeOrder(
input: { tenantId: string; userId: string; rechargeOrderId: string },
lock: boolean
) {
const [rows] = await this.pool.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,
provider_payment_id AS providerPaymentId
FROM qipai_recharge_orders
WHERE tenant_id = ? AND id = ? AND user_id = ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.rechargeOrderId, input.userId]
);
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
return normalizeRechargeOrder(rows[0]);
}
private async loadRechargeOrderByNo(
connection: PoolConnection,
rechargeNo: string,
lock: boolean
) {
const [rows] = await connection.execute<Array<RechargeOrderRow & { tenantId: string }>>(
`SELECT id, tenant_id AS tenantId, 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,
provider_payment_id AS providerPaymentId
FROM qipai_recharge_orders
WHERE recharge_no = ?
${lock ? 'FOR UPDATE' : ''}`,
[rechargeNo]
);
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
return { ...normalizeRechargeOrder(rows[0]), tenantId: String(rows[0].tenantId) };
}
private async creditRechargeOrder(
connection: PoolConnection,
input: {
tenantId: string;
rechargeOrderId: string;
providerTransactionId: string;
providerCallbackId: string;
rawNotify: Record<string, unknown>;
traceId: string;
}
) {
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',
provider_payment_id = ?,
provider_callback_id = ?,
raw_notify = CAST(? AS JSON),
paid_at = COALESCE(paid_at, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id = ?`,
[
input.providerTransactionId,
input.providerCallbackId,
JSON.stringify(input.rawNotify),
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: {
provider: 'WECHAT',
providerTransactionId: input.providerTransactionId,
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 resolveWechatCredential(reference: string) {
const credential = this.wechat?.credentials.get(reference)
?? this.wechat?.credentials.get(reference.replace(/^env:/, ''));
if (!credential) throw new WechatPayError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
return credential;
}
private verifyWithConfiguredCredential(
headers: WechatNotificationHeaders,
rawBody: string
) {
let lastError: unknown;
for (const credential of this.wechat?.credentials.values() ?? []) {
if (!credential.platformCertificates[headers.serial]) continue;
try {
return this.wechat!.client.verifyAndDecrypt(credential, headers, rawBody);
} catch (error) {
lastError = error;
}
}
throw lastError ?? new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
}
private async assertPurchaseLimit( private async assertPurchaseLimit(
connection: PoolConnection, connection: PoolConnection,
tenantId: string, tenantId: string,
@@ -276,3 +518,52 @@ function rechargeResponse(row: RechargeOrderRow, idempotent: boolean) {
idempotent idempotent
}; };
} }
function normalizeRechargeOrder(row: RechargeOrderRow): RechargeOrderRow {
return {
...row,
id: String(row.id),
userId: String(row.userId),
storeId: row.storeId === null ? null : String(row.storeId),
planId: String(row.planId),
payAmountCents: Number(row.payAmountCents),
giftAmountCents: Number(row.giftAmountCents)
};
}
function settingUrl(
settings: Record<string, unknown>, key: string, fallback: string
) {
const value = settings[key];
return typeof value === 'string' && value.startsWith('https://') ? value : fallback;
}
function requiredString(value: Record<string, unknown>, key: string) {
const field = value[key];
if (typeof field !== 'string' || field.length === 0) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field;
}
function requiredNumber(value: Record<string, unknown>, key: string) {
const field = value[key];
if (typeof field !== 'number' || !Number.isInteger(field)) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field;
}
function objectValue(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return value as Record<string, unknown>;
}
function redactNotification(value: Record<string, unknown>) {
const copy = { ...value };
delete copy.payer;
delete copy.user_received_account;
return copy;
}
+15 -1
View File
@@ -78,6 +78,9 @@ const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_pla
const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql'); const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql');
const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql'); const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql');
const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql'); const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql');
const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql');
const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql');
const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql');
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -364,4 +367,15 @@ assert.match(benefitUpSql, /remaining_amount_cents INT UNSIGNED/);
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/); assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/);
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/); assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/);
console.log('PASS: M01-B through M07-C migration contracts are present.'); assert.match(rechargeWechatUpSql, /ALTER TABLE qipai_recharge_orders/);
assert.match(rechargeWechatUpSql, /provider_prepay_id VARCHAR\(128\) NULL/);
assert.match(rechargeWechatUpSql, /provider_payment_id VARCHAR\(128\) NULL/);
assert.match(rechargeWechatUpSql, /provider_callback_id VARCHAR\(128\) NULL/);
assert.match(rechargeWechatUpSql, /raw_notify JSON NULL/);
assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_payment/);
assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_callback/);
assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
console.log('PASS: M01-B through M08-A migration contracts are present.');
+1 -1
View File
@@ -34,7 +34,7 @@ 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.file, /2026062422_m07b_recharge_plans\.up\.sql/);
assert.match(plan.file, /2026062423_m07c_benefits\.up\.sql$/); assert.match(plan.file, /2026062524_m08a_recharge_wechat\.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);
+46
View File
@@ -10,6 +10,8 @@ const token = signAccessToken({
let createdInput; let createdInput;
let listInput; let listInput;
let prepayInput;
let notifyInput;
const app = await buildApp({ const app = await buildApp({
recharge: { recharge: {
jwtSecret: secret, jwtSecret: secret,
@@ -61,6 +63,23 @@ const app = await buildApp({
giftAmountCents: 2000, giftAmountCents: 2000,
idempotent: false idempotent: false
}; };
},
async createWechatPrepay(input) {
prepayInput = input;
return {
rechargeOrderId: input.rechargeOrderId,
rechargeNo: 'RCHTEST',
amountCents: 10000,
timeStamp: '1782360000',
nonceStr: 'nonce',
package: 'prepay_id=wx-recharge-prepay',
signType: 'RSA',
paySign: 'signed'
};
},
async processWechatNotification(headers, rawBody, traceId) {
notifyInput = { headers, rawBody, traceId };
return { rechargeOrderId: '901', status: 'CREDITED', idempotent: false };
} }
} }
} }
@@ -91,6 +110,33 @@ assert.equal(createdInput.userId, '21');
assert.equal(createdInput.planId, '31'); assert.equal(createdInput.planId, '31');
assert.equal(createdInput.traceId, 'm08a-recharge-order'); assert.equal(createdInput.traceId, 'm08a-recharge-order');
const prepay = await app.inject({
method: 'POST',
url: '/app-api/recharge/orders/901/wechat-prepay',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(prepay.statusCode, 200);
assert.equal(prepayInput.tenantId, '7');
assert.equal(prepayInput.platformAppId, '9');
assert.equal(prepayInput.userId, '21');
assert.equal(prepay.json().data.package, 'prepay_id=wx-recharge-prepay');
const notify = await app.inject({
method: 'POST',
url: '/app-api/recharge/wechat/notify',
headers: {
'content-type': 'application/json',
'wechatpay-timestamp': '1782360001',
'wechatpay-nonce': 'notify-nonce',
'wechatpay-serial': 'PLATFORM-SERIAL',
'wechatpay-signature': 'signature'
},
payload: '{"resource":"encrypted"}'
});
assert.equal(notify.statusCode, 200);
assert.equal(notifyInput.headers.serial, 'PLATFORM-SERIAL');
assert.equal(notifyInput.rawBody, '{"resource":"encrypted"}');
const invalid = await app.inject({ const invalid = await app.inject({
method: 'POST', method: 'POST',
url: '/app-api/recharge/orders', url: '/app-api/recharge/orders',
+150 -1
View File
@@ -21,6 +21,7 @@ function createHarness(overrides = {}) {
...overrides.plan ...overrides.plan
}, },
orders: [], orders: [],
callbacks: [],
nextOrderId: 900, nextOrderId: 900,
purchaseCount: overrides.purchaseCount ?? 0, purchaseCount: overrides.purchaseCount ?? 0,
walletCredits: [] walletCredits: []
@@ -71,12 +72,60 @@ function createHarness(overrides = {}) {
state.orders.push(order); state.orders.push(order);
return [{ insertId: order.id, affectedRows: 1 }, []]; return [{ insertId: order.id, affectedRows: 1 }, []];
} }
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('user_id = ?')) {
const order = state.orders.find((item) =>
item.tenantId === String(params[0])
&& item.id === String(params[1])
&& item.userId === String(params[2])
);
return [order ? [toOrderRow(order)] : [], []];
}
if (sql.includes('SELECT openid FROM qipai_user_identities')) {
return [[{ openid: 'openid-recharge-test' }], []];
}
if (sql.includes('provider_prepay_id')) {
const order = state.orders.find((item) =>
item.tenantId === String(params[1])
&& item.id === String(params[2])
&& item.userId === String(params[3])
);
order.providerPrepayId = params[0];
return [{ affectedRows: 1 }, []];
}
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('recharge_no = ?')) {
const order = state.orders.find((item) => item.rechargeNo === String(params[0]));
return [order ? [{ ...toOrderRow(order), tenantId: order.tenantId }] : [], []];
}
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) { if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) {
const order = state.orders.find((item) => const order = state.orders.find((item) =>
item.tenantId === String(params[0]) && item.id === String(params[1]) item.tenantId === String(params[0]) && item.id === String(params[1])
); );
return [order ? [toOrderRow(order)] : [], []]; return [order ? [toOrderRow(order)] : [], []];
} }
if (sql.includes('INSERT IGNORE INTO qipai_payment_callbacks')) {
const duplicate = state.callbacks.some((item) =>
item.tenantId === String(params[0]) && item.callbackId === String(params[1])
);
if (!duplicate) {
state.callbacks.push({
tenantId: String(params[0]),
callbackId: String(params[1]),
status: 'RECEIVED',
payload: params[2]
});
}
return [{ affectedRows: duplicate ? 0 : 1 }, []];
}
if (sql.includes('provider_payment_id')) {
const order = state.orders.find((item) =>
item.tenantId === String(params[3]) && item.id === String(params[4])
);
order.status = 'PAID';
order.providerPaymentId = String(params[0]);
order.providerCallbackId = String(params[1]);
order.rawNotify = params[2];
return [{ affectedRows: 1 }, []];
}
if (sql.includes("SET status = 'PAID'")) { if (sql.includes("SET status = 'PAID'")) {
const order = state.orders.find((item) => const order = state.orders.find((item) =>
item.tenantId === String(params[1]) && item.id === String(params[2]) item.tenantId === String(params[1]) && item.id === String(params[2])
@@ -92,11 +141,23 @@ function createHarness(overrides = {}) {
order.status = 'CREDITED'; order.status = 'CREDITED';
return [{ affectedRows: 1 }, []]; return [{ affectedRows: 1 }, []];
} }
if (sql.includes('UPDATE qipai_payment_callbacks')) {
const callback = state.callbacks.find((item) =>
item.tenantId === String(params[0]) && item.callbackId === String(params[1])
) ?? state.callbacks.find((item) =>
item.tenantId === String(params[1]) && item.callbackId === String(params[2])
);
if (callback) callback.status = sql.includes('REJECTED') ? 'REJECTED' : 'PROCESSED';
return [{ affectedRows: 1 }, []];
}
throw new Error(`Unexpected SQL: ${sql}`); throw new Error(`Unexpected SQL: ${sql}`);
} }
}; };
const pool = { const pool = {
async execute(sql, params) {
return connection.execute(sql, params);
},
async getConnection() { async getConnection() {
return connection; return connection;
} }
@@ -113,7 +174,58 @@ function createHarness(overrides = {}) {
} }
}; };
return { service: new RechargeService(pool, wallet), state }; const wechat = {
paymentRepository: {
async resolveConfig(_connection, tenantId, platformAppId, storeId, provider) {
state.resolvedConfig = { tenantId, platformAppId, storeId, provider };
return {
credentialRef: 'wechat-test',
settings: {
rechargeDescription: '测试充值',
rechargeNotifyUrl: 'https://api.txyundm.cn/app-api/recharge/wechat/notify'
}
};
}
},
client: {
async createJsapiPrepay(_credential, input) {
state.prepayInput = input;
return {
prepayId: 'wx-recharge-prepay',
paymentParams: {
timeStamp: '1782360000',
nonceStr: 'nonce',
package: 'prepay_id=wx-recharge-prepay',
signType: 'RSA',
paySign: 'signed'
}
};
},
verifyAndDecrypt(_credential, headers, rawBody) {
state.verifiedNotification = { headers, rawBody };
return {
out_trade_no: state.orders[0].rechargeNo,
transaction_id: 'wx-recharge-transaction',
trade_state: 'SUCCESS',
amount: { total: state.orders[0].payAmountCents },
payer: { openid: 'redacted' }
};
}
},
credentials: new Map([[
'wechat-test',
{
appId: 'wx-test-app',
merchantId: '1900000109',
serialNo: 'MERCHANT-SERIAL',
privateKeyPem: 'test-private-key',
apiV3Key: '0123456789abcdef0123456789abcdef',
platformCertificates: { 'PLATFORM-SERIAL': 'test-cert' }
}
]])
};
return { service: new RechargeService(pool, wallet, wechat), state };
} }
function toOrderRow(order) { function toOrderRow(order) {
@@ -217,4 +329,41 @@ const baseOrder = {
assert.equal(state.walletCredits.length, 1); assert.equal(state.walletCredits.length, 1);
} }
{
const { service, state } = createHarness();
const created = await service.createRechargeOrder(baseOrder);
const prepay = await service.createWechatPrepay({
tenantId: '7',
platformAppId: '9',
userId: '21',
rechargeOrderId: created.rechargeOrderId
});
assert.equal(prepay.package, 'prepay_id=wx-recharge-prepay');
assert.equal(state.resolvedConfig.storeId, '11');
assert.equal(state.prepayInput.outTradeNo, created.rechargeNo);
assert.equal(state.prepayInput.amountCents, 10000);
assert.equal(state.orders[0].providerPrepayId, 'wx-recharge-prepay');
const notified = await service.processWechatNotification({
timestamp: '1782360001',
nonce: 'notify-nonce',
serial: 'PLATFORM-SERIAL',
signature: 'signed'
}, '{"resource":"encrypted"}', 'recharge-notify');
assert.equal(notified.status, 'CREDITED');
assert.equal(state.orders[0].providerPaymentId, 'wx-recharge-transaction');
assert.equal(state.orders[0].providerCallbackId, 'PLATFORM-SERIAL:wx-recharge-transaction:SUCCESS');
assert.equal(state.walletCredits.length, 1);
assert.equal(JSON.parse(state.orders[0].rawNotify).payer, undefined);
const duplicate = await service.processWechatNotification({
timestamp: '1782360001',
nonce: 'notify-nonce',
serial: 'PLATFORM-SERIAL',
signature: 'signed'
}, '{"resource":"encrypted"}', 'recharge-notify');
assert.equal(duplicate.idempotent, true);
assert.equal(state.walletCredits.length, 1);
}
console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.'); console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.');
@@ -0,0 +1,11 @@
ALTER TABLE qipai_recharge_orders
DROP KEY uq_qipai_recharge_provider_callback,
DROP KEY uq_qipai_recharge_provider_payment,
DROP COLUMN raw_notify,
DROP COLUMN provider_callback_id,
DROP COLUMN provider_payment_id,
DROP COLUMN provider_prepay_id,
DROP COLUMN payment_provider;
DELETE FROM qipai_schema_migrations
WHERE version = '2026062524';
@@ -0,0 +1,13 @@
ALTER TABLE qipai_recharge_orders
ADD COLUMN payment_provider VARCHAR(32) NOT NULL DEFAULT 'WECHAT' AFTER payment_id,
ADD COLUMN provider_prepay_id VARCHAR(128) NULL AFTER payment_provider,
ADD COLUMN provider_payment_id VARCHAR(128) NULL AFTER provider_prepay_id,
ADD COLUMN provider_callback_id VARCHAR(128) NULL AFTER provider_payment_id,
ADD COLUMN raw_notify JSON NULL AFTER provider_callback_id,
ADD UNIQUE KEY uq_qipai_recharge_provider_payment
(tenant_id, payment_provider, provider_payment_id),
ADD UNIQUE KEY uq_qipai_recharge_provider_callback
(tenant_id, payment_provider, provider_callback_id);
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062524', 'm08a_recharge_wechat');
@@ -0,0 +1,20 @@
SELECT COUNT(*) AS expected_columns
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_recharge_orders'
AND column_name IN (
'payment_provider',
'provider_prepay_id',
'provider_payment_id',
'provider_callback_id',
'raw_notify'
);
SELECT COUNT(*) AS expected_indexes
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qipai_recharge_orders'
AND index_name IN (
'uq_qipai_recharge_provider_payment',
'uq_qipai_recharge_provider_callback'
);
@@ -0,0 +1,11 @@
# M08-A 充值微信支付 API
- `POST /app-api/recharge/orders/:rechargeOrderId/wechat-prepay`
- 鉴权:顾客登录态,需 `profile.read`
- 行为:校验充值单属于当前用户,读取微信支付配置和当前用户 openid,创建 JSAPI 预支付。
- 返回:`rechargeOrderId``rechargeNo``amountCents``timeStamp``nonceStr``package``signType``paySign`
- `POST /app-api/recharge/wechat/notify`
- 鉴权:微信支付平台证书签名和 AES-GCM 资源解密。
- 行为:按 `out_trade_no` 定位充值单,校验金额,写入 `qipai_payment_callbacks` 幂等记录,成功后给钱包现金/赠送余额入账。
- 隐私:通知 payload 入库前移除 `payer` 等敏感对象。
+3 -3
View File
@@ -2,7 +2,7 @@
> V5.0 首次核验日期:2026-06-16 > V5.0 首次核验日期:2026-06-16
> audited_commit: `4ae9296` > audited_commit: `4ae9296`
> next_engineering_target: 继续 M08-A 充值微信支付调起和权益下单抵扣集成 > next_engineering_target: 继续 M08-A 余额/优惠券/套餐下单抵扣集成
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
## 总体结论 ## 总体结论
@@ -13,7 +13,7 @@
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 | | Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 |
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**``database/migrations/2026061805_m02c_rbac.*.sql``backend/tests/**` | M01、M02-A/B/C DONEMySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D | | 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**``database/migrations/2026061805_m02c_rbac.*.sql``backend/tests/**` | M01、M02-A/B/C DONEMySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D |
| 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE | | 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE |
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单前半链路 | `miniapp/pages/index``miniapp/pages/store/detail``miniapp/pages/room/detail``miniapp/pages/orders/*``miniapp/utils/api.js``scripts/check-miniapp-m08-a.mjs` | M08-A PARTIAL;已支持选店、门店/房间浏览、报价、预占、测试支付、受控 Wi-Fi、订单列表/详情、状态历史、取消订单权限开门入口,仍不能标记 DONE | | 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值和权益明细主体链路 | `miniapp/pages/index``miniapp/pages/store/detail``miniapp/pages/room/detail``miniapp/pages/orders/*``miniapp/pages/recharge/*``miniapp/pages/profile/*``miniapp/utils/api.js``scripts/check-miniapp-m08-a.mjs` | M08-A PARTIAL;已支持选店、门店/房间浏览、报价、预占、测试支付、订单微信预支付调起、充值微信支付调起、受控 Wi-Fi、订单列表/详情、状态历史、取消订单权限开门、续费、换房、分享、个人中心和权益明细,仍不能标记 DONE |
| 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql``database/seeds/2026061601_m01b_minimal_seed.sql``database/fixtures/2026061801_m01b_legacy_schema.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONEWSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 | | 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql``database/seeds/2026061601_m01b_minimal_seed.sql``database/fixtures/2026061801_m01b_legacy_schema.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONEWSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 |
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 | | M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 |
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 | | 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
@@ -35,6 +35,6 @@
## 下一步 ## 下一步
1. 继续 M08-A:在已完成选店、门店/房间浏览、报价、预占、测试支付、订单微信预支付调起、受控 Wi-Fi、订单列表/详情、取消、订单权限开门、续费、换房、分享、个人中心、充值入口和权益明细基础上,补充值微信支付调起和权益下单抵扣集成。 1. 继续 M08-A:在已完成选店、门店/房间浏览、报价、预占、测试支付、订单微信预支付调起、充值微信支付调起、受控 Wi-Fi、订单列表/详情、取消、订单权限开门、续费、换房、分享、个人中心、充值入口和权益明细基础上,补余额/优惠券/套餐下单抵扣集成。
2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。 2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。
3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。 3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。
@@ -0,0 +1,7 @@
# M08-A 充值微信支付字段
- 迁移:`database/migrations/2026062524_m08a_recharge_wechat.*.sql`
- 目的:让充值单独立记录微信预支付、交易号、回调幂等键和脱敏通知,不复用订单支付的 `order_id` 语义。
- 正向变更:`qipai_recharge_orders` 新增 `payment_provider``provider_prepay_id``provider_payment_id``provider_callback_id``raw_notify`,并增加交易号与回调唯一键。
- 回滚:删除新增唯一键和字段,删除迁移版本 `2026062524`
- 验证:检查 5 个新增字段和 2 个唯一索引存在。
@@ -157,3 +157,32 @@ M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单
- 工程提交:`e24920c` - 工程提交:`e24920c`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main` - 远端校验:待本轮 push 后执行 `HEAD == origin/main`
## 2026-06-25 续接:顾客端充值微信支付调起
- 数据库新增 `2026062524_m08a_recharge_wechat` 迁移,为 `qipai_recharge_orders` 增加微信预支付 ID、微信交易号、回调幂等 ID 和脱敏通知 JSON,避免把充值单伪造成订单支付。
- `RechargeService` 新增 `createWechatPrepay``processWechatNotification`,充值预支付复用微信支付配置、顾客 openid 和 JSAPI 参数生成;微信通知验签解密后按充值单号入账,重复通知不重复加余额。
- `POST /app-api/recharge/orders/:rechargeOrderId/wechat-prepay` 暴露给小程序,校验当前登录顾客只能为本人充值单调起支付。
- `POST /app-api/recharge/wechat/notify` 新增充值微信支付通知入口,通知 payload 入库前移除 `payer`
- 小程序充值页新增“微信支付”按钮,创建充值单后可调起微信支付;到账结果以后端通知和个人中心余额为准。
- 迁移 runner 白名单纳入新迁移,并补充 API/DB changelog。
验收:
- `npm run build``backend/`):PASS。
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `node tests/recharge-service.test.mjs`PASS。
- `node tests/recharge-route.test.mjs`PASS。
- `node tests/migration-contract.test.mjs`PASS。
- `node tests/migration-runner.test.mjs`PASS。
- `node tests/wechat-pay.test.mjs`PASS。
- `npm test``backend/`):PASS。
状态:
- M08-A 仍为 `PARTIAL`;余额/套餐/优惠券下单抵扣深度集成、真机合法域名、真实微信支付和实物开门验证待继续。
提交:
- 工程提交:`53f5d67`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`
+3 -3
View File
@@ -17,7 +17,7 @@
| OPS-003 | 整仓发布清单 | M00-E/M10 | PARTIAL | `59d92c0` | `deploy-business.sh --dry-run` 可在 Windows/WSL 验证当前 HEAD 的 release manifest 结构;`check-release-manifest.ps1` 已接入 `test-all.ps1`;正式构建待项目生成。 | 后端/后台尚未生成,生产菜单未执行。 | M01/M09 后接入真实构建结果,并在生产菜单 2 生成真实 release manifest。 | | OPS-003 | 整仓发布清单 | M00-E/M10 | PARTIAL | `59d92c0` | `deploy-business.sh --dry-run` 可在 Windows/WSL 验证当前 HEAD 的 release manifest 结构;`check-release-manifest.ps1` 已接入 `test-all.ps1`;正式构建待项目生成。 | 后端/后台尚未生成,生产菜单未执行。 | M01/M09 后接入真实构建结果,并在生产菜单 2 生成真实 release manifest。 |
| OPS-004 | 菜单式更新与环境监测 | M00-E/M10 | PARTIAL | `292ab7f` | `setup.sh` 已接入初始化、更新、MQTT、HTTPS、状态、备份、恢复、回滚和诊断菜单;`--backup-status` 已在 WSL 验证,可检查备份工具、目录和模板。 | 未在生产 Ubuntu 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 | | OPS-004 | 菜单式更新与环境监测 | M00-E/M10 | PARTIAL | `292ab7f` | `setup.sh` 已接入初始化、更新、MQTT、HTTPS、状态、备份、恢复、回滚和诊断菜单;`--backup-status` 已在 WSL 验证,可检查备份工具、目录和模板。 | 未在生产 Ubuntu 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 |
| IOT-001 | MQTT Broker 生产部署 | M00/M06 | PARTIAL | `d01f771` | 菜单 3 已支持 Ubuntu 24.04 amd64 原生安装/更新与复检;正式 Topic ACL 已固定;后端实现 MQTT.js 3.1、QoS 1、持久会话、自动重连、订阅恢复、健康检查、消息上限和真实硬件烟测运行器;Windows 全量测试通过,WSL EMQX 5.8.9 服务级检查通过。 | 缺生产 EMQX 安装、正式账号、ACL/端口/备份验收;WSL 本地账号未配置,真实硬件烟测按预期 SKIP。 | 由管理员在生产菜单 3 执行并登记结果;提供生产账号、DeviceID 和实物后执行 `node dist/devices/hardware-smoke-runner.js`。 | | IOT-001 | MQTT Broker 生产部署 | M00/M06 | PARTIAL | `d01f771` | 菜单 3 已支持 Ubuntu 24.04 amd64 原生安装/更新与复检;正式 Topic ACL 已固定;后端实现 MQTT.js 3.1、QoS 1、持久会话、自动重连、订阅恢复、健康检查、消息上限和真实硬件烟测运行器;Windows 全量测试通过,WSL EMQX 5.8.9 服务级检查通过。 | 缺生产 EMQX 安装、正式账号、ACL/端口/备份验收;WSL 本地账号未配置,真实硬件烟测按预期 SKIP。 | 由管理员在生产菜单 3 执行并登记结果;提供生产账号、DeviceID 和实物后执行 `node dist/devices/hardware-smoke-runner.js`。 |
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `e24920c` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;`scripts/check-miniapp-m08-a.mjs`、相关会员/权益测试通过。 | 多角色菜单、充值微信支付调起、权益下单抵扣深度集成和真机联调尚未完成。 | 继续 M08-A,补充值微信支付调起和权益下单抵扣集成。 | | SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `53f5d67` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;`scripts/check-miniapp-m08-a.mjs`、相关会员/权益/充值测试通过。 | 多角色菜单、权益下单抵扣深度集成和真机联调尚未完成。 | 继续 M08-A,补余额/优惠券/套餐下单抵扣集成。 |
| CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 | | CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 |
| BKG-010 | 后台多小程序管理 | M02-A/M08/M10 | PARTIAL | `8b8fb28` | 已建立逻辑应用、租户绑定和租户配置模型;多租户 AppID 未指定租户会被拒绝。 | 尚无后台管理页面,多 AppID 真机配置未验证。 | M02-B/C 完成身份权限,M08 增加管理页面。 | | BKG-010 | 后台多小程序管理 | M02-A/M08/M10 | PARTIAL | `8b8fb28` | 已建立逻辑应用、租户绑定和租户配置模型;多租户 AppID 未指定租户会被拒绝。 | 尚无后台管理页面,多 AppID 真机配置未验证。 | M02-B/C 完成身份权限,M08 增加管理页面。 |
| AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 | | AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 |
@@ -32,8 +32,8 @@
| MAP-001 | 地图选店和最近门店 | M03-C/M08 | PARTIAL | `4ac2c96` | 已实现 AppID/tenant 隔离门店发现、城市/营业筛选、按门店时区判断营业中、后端 Haversine 距离排序;小程序支持定位和手工城市降级,并可从门店卡片进入公开门店详情与房间列表。 | 地图 SDK 可视化和真机定位授权尚未验收。 | M08-A 继续补真机定位、地图展示和门店装修渲染。 | | MAP-001 | 地图选店和最近门店 | M03-C/M08 | PARTIAL | `4ac2c96` | 已实现 AppID/tenant 隔离门店发现、城市/营业筛选、按门店时区判断营业中、后端 Haversine 距离排序;小程序支持定位和手工城市降级,并可从门店卡片进入公开门店详情与房间列表。 | 地图 SDK 可视化和真机定位授权尚未验收。 | M08-A 继续补真机定位、地图展示和门店装修渲染。 |
| QR-001 | 门店/房间场景码与 NFC | M03-D/M08 | PARTIAL | `d563078` | 已实现场景码生成、重建失效、撤销、扫码统计和二维码/NFC 统一页面解析;返回权限固定为空,旧码失效已在 MySQL 8.4.9 实测。 | 真实微信小程序码图片生成依赖可用 AppSecret 与真机环境。 | M08 接入微信小程序码接口并完成真机扫描验收。 | | QR-001 | 门店/房间场景码与 NFC | M03-D/M08 | PARTIAL | `d563078` | 已实现场景码生成、重建失效、撤销、扫码统计和二维码/NFC 统一页面解析;返回权限固定为空,旧码失效已在 MySQL 8.4.9 实测。 | 真实微信小程序码图片生成依赖可用 AppSecret 与真机环境。 | M08 接入微信小程序码接口并完成真机扫描验收。 |
| WIFI-001 | 受控 Wi-Fi 凭据 | M03-D/M04/M08 | PARTIAL | `4ac2c96` | 正式订单与分享令牌均执行最小权限;M08-A 门店详情新增登录后受控 Wi-Fi 请求入口,不在未鉴权页面暴露密码。 | 真实到店权限、真机 Wi-Fi 连接和设备权限仍需现场联调。 | M08-A 补有效订单下的 Wi-Fi 真机连接验证。 | | WIFI-001 | 受控 Wi-Fi 凭据 | M03-D/M04/M08 | PARTIAL | `4ac2c96` | 正式订单与分享令牌均执行最小权限;M08-A 门店详情新增登录后受控 Wi-Fi 请求入口,不在未鉴权页面暴露密码。 | 真实到店权限、真机 Wi-Fi 连接和设备权限仍需现场联调。 | M08-A 补有效订单下的 Wi-Fi 真机连接验证。 |
| ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `e24920c` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、订单生产微信预支付调起、订单列表、订单详情、状态历史、取消测算、取消订单、续费和同门店换房入口。 | 缺真实微信和团购平台授权联调;充值微信支付调起、余额/套餐支付和下单权益抵扣入口待补。 | M08-A 继续补充值微信支付调起和权益支付。 | | ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `e24920c` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、订单生产微信预支付调起、订单列表、订单详情、状态历史、取消测算、取消订单、续费和同门店换房入口。 | 缺真实微信和团购平台授权联调;余额/套餐支付和下单权益抵扣入口待补。 | M08-A 继续补余额/优惠券/套餐下单抵扣集成。 |
| WAL-001 | 充值优惠 | M07-B/M08 | PARTIAL | `dbdfe3d` | 已建立充值规则和充值订单表,支持有效期、适用门店、限购、启停、clientRequestId 幂等;M08-A 新增顾客端充值计划查询、充值单创建 API小程序充值页;支付成功后调用钱包入账重复支付回调不重复充值;Windows 全量后端回归通过。 | 真实微信充值支付调起和回调仍需 M05 生产凭据与后续联调。 | M08-A 继续接入充值微信支付调起。 | | WAL-001 | 充值优惠 | M07-B/M08 | PARTIAL | `53f5d67` | 已建立充值规则和充值订单表,支持有效期、适用门店、限购、启停、clientRequestId 幂等;M08-A 新增顾客端充值计划查询、充值单创建 API小程序充值页、微信 JSAPI 预支付调起和 WeChat Pay v3 通知验签解密入账重复支付回调不重复充值;Windows 全量回归通过。 | 真实微信商户凭据、生产合法域名和微信侧回调仍需联调。 | M08-A 继续补余额/优惠券/套餐下单抵扣集成。 |
| WAL-002 | 门店独立会员余额 | M07-A | DONE | `63711ad` | 已建立租户/门店余额范围策略、现金/赠送双余额账户和不可变流水;充值、赠送、消费、退款、人工调整均预留 entry type;消费按赠送后现金扣款,业务号幂等,余额不足拒绝;Windows 全量后端回归通过。 | - | M07-D 聚合会员余额和权益状态。 | | WAL-002 | 门店独立会员余额 | M07-A | DONE | `63711ad` | 已建立租户/门店余额范围策略、现金/赠送双余额账户和不可变流水;充值、赠送、消费、退款、人工调整均预留 entry type;消费按赠送后现金扣款,业务号幂等,余额不足拒绝;Windows 全量后端回归通过。 | - | M07-D 聚合会员余额和权益状态。 |
| WAL-003 | 余额账单 | M07-A/M08 | PARTIAL | `7446852` | 账单底表已包含业务类型、业务号、现金/赠送变动、变动后余额、操作人、trace 和 metadata;M07-B 充值成功后写入真实充值流水;M08-A 个人中心已展示最近钱包账单。 | 完整分页账单页面待继续;优惠券/套餐不直接写钱包流水。 | M08-A 增加分页账单或权益明细入口。 | | WAL-003 | 余额账单 | M07-A/M08 | PARTIAL | `7446852` | 账单底表已包含业务类型、业务号、现金/赠送变动、变动后余额、操作人、trace 和 metadata;M07-B 充值成功后写入真实充值流水;M08-A 个人中心已展示最近钱包账单。 | 完整分页账单页面待继续;优惠券/套餐不直接写钱包流水。 | M08-A 增加分页账单或权益明细入口。 |
| WAL-004 | 优惠券和套餐权益 | M07-C/M08 | PARTIAL | `e24920c` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;M08-A 个人中心已展示可用/冻结券和套餐汇总,并新增本人优惠券/套餐明细页。 | 发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M08-A 继续接入下单使用权益入口。 | | WAL-004 | 优惠券和套餐权益 | M07-C/M08 | PARTIAL | `e24920c` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;M08-A 个人中心已展示可用/冻结券和套餐汇总,并新增本人优惠券/套餐明细页。 | 发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M08-A 继续接入下单使用权益入口。 |
+3 -3
View File
@@ -7,11 +7,11 @@ execution_cursor:
stage_status: PARTIAL stage_status: PARTIAL
last_completed_stage: M07-D last_completed_stage: M07-D
next_stage: M08-A next_stage: M08-A
last_engineering_commit: `e24920c` last_engineering_commit: `53f5d67`
last_push_verified: true last_push_verified: true
base_branch: main base_branch: main
blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。" blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。"
updated_at: 2026-06-25T12:30:00+08:00 updated_at: 2026-06-25T13:10:00+08:00
``` ```
| 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 | | 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 |
@@ -24,7 +24,7 @@ execution_cursor:
| M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 | | M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 |
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `d01f771` | docs/devlogs/2026-06-24-M06-G-真实硬件联调.md | M06-A 至 F 已完成 MQTT、拓扑、协议幂等、控制箱、Sub-1G 门锁、智慧插座和订单自动联动任务;M06-G 已补真实硬件烟测运行器,缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 | | M06 设备、MQTT 与真实硬件联动 | PARTIAL | `d01f771` | docs/devlogs/2026-06-24-M06-G-真实硬件联调.md | M06-A 至 F 已完成 MQTT、拓扑、协议幂等、控制箱、Sub-1G 门锁、智慧插座和订单自动联动任务;M06-G 已补真实硬件烟测运行器,缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 | | M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
| M08 微信原生小程序完整业务 | PARTIAL | `e24920c` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;充值微信支付调起、权益下单抵扣深度集成真机联调继续留在 M08-A。 | | M08 微信原生小程序完整业务 | PARTIAL | `53f5d67` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;权益下单抵扣深度集成真机合法域名、真实微信支付和实物开门验证继续留在 M08-A。 |
| M09 后台管理端完整业务 | TODO | - | - | - | | M09 后台管理端完整业务 | TODO | - | - | - |
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
+16
View File
@@ -3,6 +3,7 @@ const {
ensureLogin, ensureLogin,
cents, cents,
clientRequestId, clientRequestId,
requestWechatPayment,
} = require('../../utils/api.js') } = require('../../utils/api.js')
Page({ Page({
@@ -76,6 +77,21 @@ Page({
}) })
}, },
async payRechargeOrder() {
if (!this.data.rechargeOrder || !this.data.rechargeOrder.rechargeOrderId) {
this.setData({ errorMessage: '请先创建充值单' })
return
}
await this.withRequest(async () => {
const response = await request(
`/recharge/orders/${encodeURIComponent(this.data.rechargeOrder.rechargeOrderId)}/wechat-prepay`,
{ method: 'POST' }
)
await requestWechatPayment(response.data)
this.setData({ successMessage: '微信支付已提交,到账结果以服务端通知为准' })
})
},
async withRequest(work) { async withRequest(work) {
this.setData({ loading: true, errorMessage: '', successMessage: '' }) this.setData({ loading: true, errorMessage: '', successMessage: '' })
try { try {
+1
View File
@@ -30,6 +30,7 @@
<view class="muted">状态:{{rechargeOrder.status}}</view> <view class="muted">状态:{{rechargeOrder.status}}</view>
<view class="muted">实付:{{rechargeOrder.payText}}</view> <view class="muted">实付:{{rechargeOrder.payText}}</view>
<view class="muted">赠送:{{rechargeOrder.giftText}}</view> <view class="muted">赠送:{{rechargeOrder.giftText}}</view>
<button loading="{{loading}}" bindtap="payRechargeOrder">微信支付</button>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
+2
View File
@@ -94,6 +94,8 @@ const recharge = read('miniapp/pages/recharge/index.js')
for (const pattern of [ for (const pattern of [
'/recharge/plans', '/recharge/plans',
'/recharge/orders', '/recharge/orders',
'/wechat-prepay',
'requestWechatPayment',
'clientRequestId', 'clientRequestId',
'selectedPlanId', 'selectedPlanId',
'rechargeOrder' 'rechargeOrder'