feat(M08-A): 接入顾客端权益明细
This commit is contained in:
@@ -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` 为准。
|
||||
|
||||
- 当前执行游标:`M08-A`(PARTIAL)
|
||||
- 最近工程提交:`dbdfe3d`,完成顾客端充值计划查询和充值单创建入口。
|
||||
- 下一工程目标:继续 M08-A,补顾客端优惠券/套餐明细和生产支付调起。
|
||||
- 最近工程提交:`6cb3d2d`,完成顾客端优惠券/套餐明细和订单微信预支付调起。
|
||||
- 下一工程目标:继续 M08-A,补充值微信支付调起和权益下单抵扣集成。
|
||||
|
||||
## 版本递进
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const listSchema = z.object({
|
||||
});
|
||||
|
||||
export interface MemberRouteOptions {
|
||||
service: Pick<MemberProfileService, 'listMembers' | 'getMember' | 'getMyProfile'>;
|
||||
service: Pick<MemberProfileService, 'listMembers' | 'getMember' | 'getMyProfile' | 'getMyBenefits'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
@@ -29,31 +29,26 @@ export async function registerMemberRoutes(
|
||||
options: MemberRouteOptions
|
||||
): Promise<void> {
|
||||
app.get('/app-api/profile', async (request, reply) => {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization,
|
||||
options.authRepository,
|
||||
options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
return reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||
});
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId,
|
||||
auth.session.user.id
|
||||
);
|
||||
if (!access.capabilities.includes('profile.read')) {
|
||||
return reply.status(403).send({
|
||||
code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
const auth = await requireProfileReader(request, reply, options);
|
||||
if (!auth) return;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getMyProfile({
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id
|
||||
tenantId: auth.tenantId,
|
||||
userId: auth.userId
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/profile/benefits', async (request, reply) => {
|
||||
const auth = await requireProfileReader(request, reply, options);
|
||||
if (!auth) return;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getMyBenefits({
|
||||
tenantId: auth.tenantId,
|
||||
userId: auth.userId
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
@@ -84,6 +79,36 @@ export async function registerMemberRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
async function requireProfileReader(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: MemberRouteOptions
|
||||
): Promise<{ tenantId: string; userId: string } | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization,
|
||||
options.authRepository,
|
||||
options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId,
|
||||
auth.session.user.id
|
||||
);
|
||||
if (!access.capabilities.includes('profile.read')) {
|
||||
reply.status(403).send({
|
||||
code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { tenantId: auth.session.tenantId, userId: auth.session.user.id };
|
||||
}
|
||||
|
||||
async function requireReader(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
|
||||
@@ -55,6 +55,45 @@ interface LedgerRow extends RowDataPacket {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface CouponDetailRow extends RowDataPacket {
|
||||
id: string;
|
||||
templateId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
couponType: string;
|
||||
discountAmountCents: number;
|
||||
timeMinutes: number;
|
||||
minOrderAmountCents: number;
|
||||
remainingUses: number;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
storeId: string | null;
|
||||
roomCategoryId: string | null;
|
||||
roomId: string | null;
|
||||
holidayOnly: number;
|
||||
frozenOrderId: string | null;
|
||||
usedAt: Date | null;
|
||||
}
|
||||
|
||||
interface PackageDetailRow extends RowDataPacket {
|
||||
id: string;
|
||||
planId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
priceCents: number;
|
||||
minutesTotal: number;
|
||||
amountCentsTotal: number;
|
||||
remainingMinutes: number;
|
||||
remainingAmountCents: number;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
storeId: string | null;
|
||||
roomCategoryId: string | null;
|
||||
roomId: string | null;
|
||||
holidayOnly: number;
|
||||
frozenOrderId: string | null;
|
||||
}
|
||||
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
|
||||
export interface MemberActor {
|
||||
@@ -167,6 +206,93 @@ export class MemberProfileService {
|
||||
};
|
||||
}
|
||||
|
||||
async getMyBenefits(input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const limit = Math.max(1, Math.min(input.limit ?? 50, 100));
|
||||
const [coupons] = await this.pool.execute<CouponDetailRow[]>(
|
||||
`SELECT g.id, g.template_id AS templateId, t.name, g.status,
|
||||
t.coupon_type AS couponType,
|
||||
t.discount_amount_cents AS discountAmountCents,
|
||||
t.time_minutes AS timeMinutes,
|
||||
t.min_order_amount_cents AS minOrderAmountCents,
|
||||
g.remaining_uses AS remainingUses,
|
||||
g.valid_from AS validFrom, g.valid_to AS validTo,
|
||||
t.store_id AS storeId, t.room_category_id AS roomCategoryId,
|
||||
t.room_id AS roomId, t.holiday_only AS holidayOnly,
|
||||
g.frozen_order_id AS frozenOrderId, g.used_at AS usedAt
|
||||
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 t.deleted_at IS NULL
|
||||
ORDER BY FIELD(g.status, 'AVAILABLE', 'FROZEN', 'USED', 'EXPIRED'),
|
||||
g.valid_to ASC, g.id DESC
|
||||
LIMIT ?`,
|
||||
[input.tenantId, input.userId, limit]
|
||||
);
|
||||
const [packages] = await this.pool.execute<PackageDetailRow[]>(
|
||||
`SELECT h.id, h.plan_id AS planId, p.name, h.status,
|
||||
p.price_cents AS priceCents,
|
||||
p.minutes_total AS minutesTotal,
|
||||
p.amount_cents_total AS amountCentsTotal,
|
||||
h.remaining_minutes AS remainingMinutes,
|
||||
h.remaining_amount_cents AS remainingAmountCents,
|
||||
h.valid_from AS validFrom, h.valid_to AS validTo,
|
||||
p.store_id AS storeId, p.room_category_id AS roomCategoryId,
|
||||
p.room_id AS roomId, p.holiday_only AS holidayOnly,
|
||||
h.frozen_order_id AS frozenOrderId
|
||||
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 p.deleted_at IS NULL
|
||||
ORDER BY FIELD(h.status, 'ACTIVE', 'FROZEN', 'EXHAUSTED', 'EXPIRED'),
|
||||
h.valid_to ASC, h.id DESC
|
||||
LIMIT ?`,
|
||||
[input.tenantId, input.userId, limit]
|
||||
);
|
||||
return {
|
||||
coupons: coupons.map((row) => ({
|
||||
couponGrantId: String(row.id),
|
||||
templateId: String(row.templateId),
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
couponType: row.couponType,
|
||||
discountAmountCents: Number(row.discountAmountCents),
|
||||
timeMinutes: Number(row.timeMinutes),
|
||||
minOrderAmountCents: Number(row.minOrderAmountCents),
|
||||
remainingUses: Number(row.remainingUses),
|
||||
validFrom: row.validFrom,
|
||||
validTo: row.validTo,
|
||||
storeId: row.storeId === null ? null : String(row.storeId),
|
||||
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
|
||||
roomId: row.roomId === null ? null : String(row.roomId),
|
||||
holidayOnly: Number(row.holidayOnly) === 1,
|
||||
frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId),
|
||||
usedAt: row.usedAt
|
||||
})),
|
||||
packages: packages.map((row) => ({
|
||||
packageHoldingId: String(row.id),
|
||||
planId: String(row.planId),
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
priceCents: Number(row.priceCents),
|
||||
minutesTotal: Number(row.minutesTotal),
|
||||
amountCentsTotal: Number(row.amountCentsTotal),
|
||||
remainingMinutes: Number(row.remainingMinutes),
|
||||
remainingAmountCents: Number(row.remainingAmountCents),
|
||||
validFrom: row.validFrom,
|
||||
validTo: row.validTo,
|
||||
storeId: row.storeId === null ? null : String(row.storeId),
|
||||
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
|
||||
roomId: row.roomId === null ? null : String(row.roomId),
|
||||
holidayOnly: Number(row.holidayOnly) === 1,
|
||||
frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
private async memberCard(actor: MemberActor, row: MemberRow) {
|
||||
const memberId = String(row.id);
|
||||
const [walletRows] = await this.pool.execute<WalletSummaryRow[]>(
|
||||
|
||||
@@ -13,6 +13,7 @@ const forbiddenToken = signAccessToken({
|
||||
}, secret, 900);
|
||||
|
||||
let profileInput;
|
||||
let benefitsInput;
|
||||
const app = await buildApp({
|
||||
members: {
|
||||
jwtSecret: secret,
|
||||
@@ -46,6 +47,23 @@ const app = await buildApp({
|
||||
service: {
|
||||
async listMembers() { throw new Error('not called'); },
|
||||
async getMember() { throw new Error('not called'); },
|
||||
async getMyBenefits(input) {
|
||||
benefitsInput = input;
|
||||
return {
|
||||
coupons: [{
|
||||
couponGrantId: '401',
|
||||
name: '30元抵扣券',
|
||||
status: 'AVAILABLE',
|
||||
discountAmountCents: 3000
|
||||
}],
|
||||
packages: [{
|
||||
packageHoldingId: '501',
|
||||
name: '三小时畅玩包',
|
||||
status: 'ACTIVE',
|
||||
remainingMinutes: 120
|
||||
}]
|
||||
};
|
||||
},
|
||||
async getMyProfile(input) {
|
||||
profileInput = input;
|
||||
return {
|
||||
@@ -71,6 +89,17 @@ assert.equal(profileInput.tenantId, '7');
|
||||
assert.equal(profileInput.userId, '21');
|
||||
assert.equal(profile.json().data.wallet.totalBalanceCents, 1200);
|
||||
|
||||
const benefits = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/profile/benefits',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(benefits.statusCode, 200);
|
||||
assert.equal(benefitsInput.tenantId, '7');
|
||||
assert.equal(benefitsInput.userId, '21');
|
||||
assert.equal(benefits.json().data.coupons[0].couponGrantId, '401');
|
||||
assert.equal(benefits.json().data.packages[0].packageHoldingId, '501');
|
||||
|
||||
const unauthorized = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/profile'
|
||||
|
||||
@@ -47,25 +47,111 @@ const wallets = [
|
||||
];
|
||||
|
||||
const coupons = [
|
||||
{ tenantId: '7', userId: '21', status: 'AVAILABLE' },
|
||||
{ tenantId: '7', userId: '21', status: 'FROZEN' },
|
||||
{ tenantId: '7', userId: '22', status: 'USED' }
|
||||
{
|
||||
id: '401',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
templateId: '301',
|
||||
name: '30元抵扣券',
|
||||
status: 'AVAILABLE',
|
||||
couponType: 'FULL_REDUCTION',
|
||||
discountAmountCents: 3000,
|
||||
timeMinutes: 0,
|
||||
minOrderAmountCents: 10000,
|
||||
remainingUses: 1,
|
||||
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
||||
validTo: new Date('2026-07-01T00:00:00.000Z'),
|
||||
storeId: '11',
|
||||
roomCategoryId: null,
|
||||
roomId: null,
|
||||
holidayOnly: 0,
|
||||
frozenOrderId: null,
|
||||
usedAt: null
|
||||
},
|
||||
{
|
||||
id: '402',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
templateId: '302',
|
||||
name: '60分钟券',
|
||||
status: 'FROZEN',
|
||||
couponType: 'TIME',
|
||||
discountAmountCents: 0,
|
||||
timeMinutes: 60,
|
||||
minOrderAmountCents: 0,
|
||||
remainingUses: 1,
|
||||
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
||||
validTo: new Date('2026-07-02T00:00:00.000Z'),
|
||||
storeId: null,
|
||||
roomCategoryId: null,
|
||||
roomId: null,
|
||||
holidayOnly: 0,
|
||||
frozenOrderId: '301',
|
||||
usedAt: null
|
||||
},
|
||||
{
|
||||
id: '403',
|
||||
tenantId: '7',
|
||||
userId: '22',
|
||||
templateId: '303',
|
||||
name: '其他用户券',
|
||||
status: 'USED',
|
||||
couponType: 'FULL_REDUCTION',
|
||||
discountAmountCents: 500,
|
||||
timeMinutes: 0,
|
||||
minOrderAmountCents: 0,
|
||||
remainingUses: 0,
|
||||
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
||||
validTo: new Date('2026-07-01T00:00:00.000Z'),
|
||||
storeId: null,
|
||||
roomCategoryId: null,
|
||||
roomId: null,
|
||||
holidayOnly: 0,
|
||||
frozenOrderId: null,
|
||||
usedAt: new Date('2026-06-20T00:00:00.000Z')
|
||||
}
|
||||
];
|
||||
|
||||
const packages = [
|
||||
{
|
||||
id: '501',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
planId: '601',
|
||||
name: '三小时畅玩包',
|
||||
status: 'ACTIVE',
|
||||
priceCents: 9000,
|
||||
minutesTotal: 180,
|
||||
amountCentsTotal: 3000,
|
||||
remainingMinutes: 120,
|
||||
remainingAmountCents: 3_000
|
||||
remainingAmountCents: 3_000,
|
||||
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
||||
validTo: new Date('2026-08-01T00:00:00.000Z'),
|
||||
storeId: '11',
|
||||
roomCategoryId: null,
|
||||
roomId: null,
|
||||
holidayOnly: 0,
|
||||
frozenOrderId: null
|
||||
},
|
||||
{
|
||||
id: '502',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
planId: '602',
|
||||
name: '锁定中的套餐',
|
||||
status: 'FROZEN',
|
||||
priceCents: 5000,
|
||||
minutesTotal: 60,
|
||||
amountCentsTotal: 2000,
|
||||
remainingMinutes: 60,
|
||||
remainingAmountCents: 2_000
|
||||
remainingAmountCents: 2_000,
|
||||
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
||||
validTo: new Date('2026-08-02T00:00:00.000Z'),
|
||||
storeId: null,
|
||||
roomCategoryId: null,
|
||||
roomId: null,
|
||||
holidayOnly: 0,
|
||||
frozenOrderId: '301'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -185,6 +271,53 @@ function createService() {
|
||||
.reduce((sum, item) => sum + item.remainingAmountCents, 0)
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_coupon_grants g')) {
|
||||
const rows = coupons
|
||||
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
|
||||
.slice(0, Number(params[2]));
|
||||
return [rows.map((item) => ({
|
||||
id: item.id,
|
||||
templateId: item.templateId,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
couponType: item.couponType,
|
||||
discountAmountCents: item.discountAmountCents,
|
||||
timeMinutes: item.timeMinutes,
|
||||
minOrderAmountCents: item.minOrderAmountCents,
|
||||
remainingUses: item.remainingUses,
|
||||
validFrom: item.validFrom,
|
||||
validTo: item.validTo,
|
||||
storeId: item.storeId,
|
||||
roomCategoryId: item.roomCategoryId,
|
||||
roomId: item.roomId,
|
||||
holidayOnly: item.holidayOnly,
|
||||
frozenOrderId: item.frozenOrderId,
|
||||
usedAt: item.usedAt
|
||||
})), []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_package_holdings h')) {
|
||||
const rows = packages
|
||||
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
|
||||
.slice(0, Number(params[2]));
|
||||
return [rows.map((item) => ({
|
||||
id: item.id,
|
||||
planId: item.planId,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
priceCents: item.priceCents,
|
||||
minutesTotal: item.minutesTotal,
|
||||
amountCentsTotal: item.amountCentsTotal,
|
||||
remainingMinutes: item.remainingMinutes,
|
||||
remainingAmountCents: item.remainingAmountCents,
|
||||
validFrom: item.validFrom,
|
||||
validTo: item.validTo,
|
||||
storeId: item.storeId,
|
||||
roomCategoryId: item.roomCategoryId,
|
||||
roomId: item.roomId,
|
||||
holidayOnly: item.holidayOnly,
|
||||
frozenOrderId: item.frozenOrderId
|
||||
})), []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_recharge_orders')) {
|
||||
const tenantId = String(params[0]);
|
||||
const userId = String(params[1]);
|
||||
@@ -306,6 +439,18 @@ const forbiddenReader = {
|
||||
assert.equal(member.benefits.packageMinutes, 180);
|
||||
}
|
||||
|
||||
{
|
||||
const service = createService();
|
||||
const benefits = await service.getMyBenefits({ tenantId: '7', userId: '21' });
|
||||
assert.equal(benefits.coupons.length, 2);
|
||||
assert.equal(benefits.coupons[0].couponGrantId, '401');
|
||||
assert.equal(benefits.coupons[0].discountAmountCents, 3000);
|
||||
assert.equal(benefits.coupons[1].frozenOrderId, '301');
|
||||
assert.equal(benefits.packages.length, 2);
|
||||
assert.equal(benefits.packages[0].packageHoldingId, '501');
|
||||
assert.equal(benefits.packages[0].remainingAmountCents, 3000);
|
||||
}
|
||||
|
||||
{
|
||||
const service = createService();
|
||||
const scoped = await service.listMembers({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> V5.0 首次核验日期:2026-06-16
|
||||
> audited_commit: `4ae9296`
|
||||
> next_engineering_target: 继续 M08-A 顾客端续费、换房与会员权益页面
|
||||
> next_engineering_target: 继续 M08-A 充值微信支付调起和权益下单抵扣集成
|
||||
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
|
||||
|
||||
## 总体结论
|
||||
@@ -35,6 +35,6 @@
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 继续 M08-A:在已完成选店、门店/房间浏览、报价、预占、测试支付、受控 Wi-Fi、订单列表/详情、取消和订单权限开门入口基础上,补续费、换房、分享、余额、优惠券、套餐和个人中心。
|
||||
1. 继续 M08-A:在已完成选店、门店/房间浏览、报价、预占、测试支付、订单微信预支付调起、受控 Wi-Fi、订单列表/详情、取消、订单权限开门、续费、换房、分享、个人中心、充值入口和权益明细基础上,补充值微信支付调起和权益下单抵扣集成。
|
||||
2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。
|
||||
3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。
|
||||
|
||||
@@ -129,3 +129,31 @@ M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单
|
||||
|
||||
- 工程提交:`dbdfe3d`
|
||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||
|
||||
## 2026-06-25 续接:顾客端权益明细与微信支付调起
|
||||
|
||||
- 后端 `MemberProfileService` 新增 `getMyBenefits`,按当前登录顾客读取本人优惠券和套餐持有明细,返回状态、金额、分钟、剩余次数、有效期、适用范围和冻结订单,不暴露其他用户权益。
|
||||
- 新增 `GET /app-api/profile/benefits`,复用登录态和 `profile.read` 权限校验,只允许读取当前用户本人权益。
|
||||
- `backend/tests/member-profile-service.test.mjs` 与 `backend/tests/member-profile-route.test.mjs` 扩展覆盖权益明细查询和本人鉴权。
|
||||
- 小程序新增 `pages/benefits/index` 权益明细页,个人中心新增入口,可展示优惠券和套餐的可用/锁定/已用尽状态、金额、分钟、有效期和适用范围。
|
||||
- 小程序 `utils/api.js` 新增 `requestWechatPayment`,房间下单页新增“微信支付”按钮:先创建 `WECHAT` 支付单,再调用 `/pay/wechat/prepay`,仅在后端返回真实预支付参数后调用 `wx.requestPayment`;测试支付路径保留。
|
||||
- `scripts/check-miniapp-m08-a.mjs` 扩展检查权益页注册、`/profile/benefits`、`/pay/wechat/prepay` 和 `wx.requestPayment`。
|
||||
|
||||
验收:
|
||||
|
||||
- `npm run build`(`backend/`):PASS。
|
||||
- `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- `node tests/member-profile-service.test.mjs`:PASS。
|
||||
- `node tests/member-profile-route.test.mjs`:PASS。
|
||||
- `node tests/marketing-benefit-service.test.mjs`:PASS。
|
||||
- `npm test`(`backend/`):PASS。
|
||||
- `powershell -ExecutionPolicy Bypass -File scripts\dev\windows\test-all.ps1`:PASS。
|
||||
|
||||
状态:
|
||||
|
||||
- M08-A 仍为 `PARTIAL`;充值微信支付调起、余额/套餐/优惠券下单抵扣深度集成、真机合法域名、真实微信支付和实物开门验证待继续。
|
||||
|
||||
提交:
|
||||
|
||||
- 工程提交:`6cb3d2d`
|
||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||
|
||||
@@ -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-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`。 |
|
||||
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `dbdfe3d` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总和充值计划/充值单入口;`scripts/check-miniapp-m08-a.mjs` 与后端全量测试通过。 | 优惠券/套餐明细、多角色菜单、生产支付调起和真机联调尚未完成。 | 继续 M08-A,补顾客端优惠券/套餐明细和生产支付调起。 |
|
||||
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `6cb3d2d` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、受控 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 接入配置管理和小程序展示。 |
|
||||
| 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 完成小程序角色界面与真机登录。 |
|
||||
@@ -32,11 +32,11 @@
|
||||
| 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 接入微信小程序码接口并完成真机扫描验收。 |
|
||||
| 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 | `874b9b8` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、订单列表、订单详情、状态历史、取消测算、取消订单、续费和同门店换房入口,后端全量测试通过。 | 缺真实微信和团购平台授权联调;生产微信支付调起和余额/套餐支付入口待补。 | M08-A 继续补生产支付调起和权益支付。 |
|
||||
| ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `6cb3d2d` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、订单生产微信预支付调起、订单列表、订单详情、状态历史、取消测算、取消订单、续费和同门店换房入口。 | 缺真实微信和团购平台授权联调;充值微信支付调起、余额/套餐支付和下单权益抵扣入口待补。 | M08-A 继续补充值微信支付调起和权益支付。 |
|
||||
| WAL-001 | 充值优惠 | M07-B/M08 | PARTIAL | `dbdfe3d` | 已建立充值规则和充值订单表,支持有效期、适用门店、限购、启停、clientRequestId 幂等;M08-A 新增顾客端充值计划查询、充值单创建 API 和小程序充值页;支付成功后调用钱包入账,重复支付回调不重复充值;Windows 全量后端回归通过。 | 真实微信充值支付调起和回调仍需 M05 生产凭据与后续联调。 | M08-A 继续接入充值微信支付调起。 |
|
||||
| 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-004 | 优惠券和套餐权益 | M07-C/M08 | PARTIAL | `7446852` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;M08-A 个人中心已展示可用/冻结券和套餐汇总。 | 前端优惠券/套餐明细、发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M08-A 继续接入权益明细与下单使用入口。 |
|
||||
| WAL-004 | 优惠券和套餐权益 | M07-C/M08 | PARTIAL | `6cb3d2d` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;M08-A 个人中心已展示可用/冻结券和套餐汇总,并新增本人优惠券/套餐明细页。 | 发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M08-A 继续接入下单使用权益入口。 |
|
||||
| MEM-001 | 会员管理 | M07-D/M08 | PARTIAL | `7446852` | 已实现后台会员聚合查询服务和 `/admin-api/members`、`/admin-api/members/:id`;新增顾客端 `/app-api/profile` 和小程序个人中心,可查看注册/最近登录、订单数、消费额、余额、充值、优惠券、套餐和最近钱包流水,手机号脱敏,租户/门店范围及本人读取通过测试。 | 管理员赠券、余额人工调整、禁用、备注和后台前端页面需在 M09/M08 管理端接入并复用既有审计能力。 | M08-A 继续补顾客端权益明细;M09 接入后台会员管理页面。 |
|
||||
| GRP-001 | 团购券兑换 | M05-C | PARTIAL | `cda640b` | 已实现美团/点评、抖音、快手统一适配器,支持顾客粘贴/扫码值、管理员人工核销、Mock/API 核销、clientRequestId 幂等和券码 SHA-256 存储;重复券不能再次记账,WSL MySQL 8.4.9 实测通过。 | 缺各平台商家 API 授权,未执行真实厂商核销。 | 取得授权后配置 API endpoint/token 并执行真实验券。 |
|
||||
| GRP-002 | 美团直订 | M05-C | PARTIAL | `cda640b` | 已实现 HMAC 回调、事件幂等、外部门店/房间映射、未映射人工队列、顾客认领和团购支付订单生成;映射成功订单可进入 PAID。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 |
|
||||
|
||||
@@ -7,11 +7,11 @@ execution_cursor:
|
||||
stage_status: PARTIAL
|
||||
last_completed_stage: M07-D
|
||||
next_stage: M08-A
|
||||
last_engineering_commit: `dbdfe3d`
|
||||
last_engineering_commit: `6cb3d2d`
|
||||
last_push_verified: true
|
||||
base_branch: main
|
||||
blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。"
|
||||
updated_at: 2026-06-25T00:00:00+08:00
|
||||
updated_at: 2026-06-25T12:30: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。 |
|
||||
| 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 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
|
||||
| M08 微信原生小程序完整业务 | PARTIAL | `dbdfe3d` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总和充值计划/充值单入口;优惠券/套餐明细和生产支付继续留在 M08-A。 |
|
||||
| M08 微信原生小程序完整业务 | PARTIAL | `6cb3d2d` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;充值微信支付调起、权益下单抵扣深度集成和真机联调继续留在 M08-A。 |
|
||||
| M09 后台管理端完整业务 | TODO | - | - | - |
|
||||
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"pages/orders/list",
|
||||
"pages/orders/detail",
|
||||
"pages/profile/index",
|
||||
"pages/benefits/index",
|
||||
"pages/recharge/index",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const { request, ensureLogin, cents } = require('../../utils/api.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
coupons: [],
|
||||
packages: [],
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadBenefits()
|
||||
},
|
||||
|
||||
async loadBenefits() {
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
await ensureLogin()
|
||||
const response = await request('/profile/benefits')
|
||||
this.setData({
|
||||
coupons: (response.data.coupons || []).map(formatCoupon),
|
||||
packages: (response.data.packages || []).map(formatPackage),
|
||||
})
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '权益明细加载失败' })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function formatCoupon(item) {
|
||||
const isTime = item.couponType === 'TIME'
|
||||
return {
|
||||
...item,
|
||||
statusText: benefitStatus(item.status),
|
||||
valueText: isTime ? `${item.timeMinutes || 0}分钟` : cents(item.discountAmountCents),
|
||||
thresholdText: Number(item.minOrderAmountCents || 0) > 0
|
||||
? `满${cents(item.minOrderAmountCents)}可用`
|
||||
: '无门槛',
|
||||
validText: `${formatDate(item.validFrom)} 至 ${formatDate(item.validTo)}`,
|
||||
scopeText: scopeText(item),
|
||||
}
|
||||
}
|
||||
|
||||
function formatPackage(item) {
|
||||
return {
|
||||
...item,
|
||||
statusText: benefitStatus(item.status),
|
||||
priceText: cents(item.priceCents),
|
||||
remainingAmountText: cents(item.remainingAmountCents),
|
||||
totalAmountText: cents(item.amountCentsTotal),
|
||||
validText: `${formatDate(item.validFrom)} 至 ${formatDate(item.validTo)}`,
|
||||
scopeText: scopeText(item),
|
||||
}
|
||||
}
|
||||
|
||||
function benefitStatus(status) {
|
||||
return ({
|
||||
AVAILABLE: '可用',
|
||||
ACTIVE: '可用',
|
||||
FROZEN: '锁定中',
|
||||
USED: '已使用',
|
||||
EXHAUSTED: '已用尽',
|
||||
EXPIRED: '已过期',
|
||||
})[status] || status
|
||||
}
|
||||
|
||||
function scopeText(item) {
|
||||
if (item.roomId) return `指定房间 ${item.roomId}`
|
||||
if (item.roomCategoryId) return `指定房型 ${item.roomCategoryId}`
|
||||
if (item.storeId) return `指定门店 ${item.storeId}`
|
||||
return item.holidayOnly ? '节假日可用' : '全部门店可用'
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value)
|
||||
const pad = (input) => String(input).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "权益明细"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="page">
|
||||
<view class="title">权益明细</view>
|
||||
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||
|
||||
<view class="section-title">优惠券</view>
|
||||
<view wx:if="{{!loading && coupons.length === 0}}" class="empty">暂无优惠券</view>
|
||||
<view wx:for="{{coupons}}" wx:key="couponGrantId" class="benefit-card">
|
||||
<view class="card-head">
|
||||
<view class="benefit-name">{{item.name}}</view>
|
||||
<view class="status">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view class="benefit-value">{{item.valueText}}</view>
|
||||
<view class="muted">{{item.thresholdText}}</view>
|
||||
<view class="muted">{{item.scopeText}}</view>
|
||||
<view class="muted">{{item.validText}}</view>
|
||||
<view class="muted">剩余次数:{{item.remainingUses}}</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">套餐</view>
|
||||
<view wx:if="{{!loading && packages.length === 0}}" class="empty">暂无套餐</view>
|
||||
<view wx:for="{{packages}}" wx:key="packageHoldingId" class="benefit-card">
|
||||
<view class="card-head">
|
||||
<view class="benefit-name">{{item.name}}</view>
|
||||
<view class="status">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text>剩余分钟</text>
|
||||
<text>{{item.remainingMinutes}} / {{item.minutesTotal}}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text>剩余金额</text>
|
||||
<text>{{item.remainingAmountText}} / {{item.totalAmountText}}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text>购买价</text>
|
||||
<text>{{item.priceText}}</text>
|
||||
</view>
|
||||
<view class="muted">{{item.scopeText}}</view>
|
||||
<view class="muted">{{item.validText}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,72 @@
|
||||
.scrollarea {
|
||||
height: 100vh;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 24rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 28rpx 0 16rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.benefit-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 28rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.card-head,
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.benefit-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #1f6feb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.benefit-value {
|
||||
margin-top: 16rpx;
|
||||
color: #b42318;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-top: 16rpx;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin-top: 8rpx;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 60rpx 0;
|
||||
color: #888888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 16rpx 0;
|
||||
color: #c73535;
|
||||
}
|
||||
@@ -36,6 +36,10 @@ Page({
|
||||
openRecharge() {
|
||||
wx.navigateTo({ url: '/pages/recharge/index' })
|
||||
},
|
||||
|
||||
openBenefits() {
|
||||
wx.navigateTo({ url: '/pages/benefits/index' })
|
||||
},
|
||||
})
|
||||
|
||||
function formatProfile(profile) {
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<text>套餐余额</text>
|
||||
<text>{{profile.benefits.packageAmountText}}</text>
|
||||
</view>
|
||||
<button bindtap="openBenefits">查看权益明细</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{profile}}" class="card">
|
||||
|
||||
@@ -3,6 +3,7 @@ const {
|
||||
ensureLogin,
|
||||
cents,
|
||||
clientRequestId,
|
||||
requestWechatPayment,
|
||||
} = require('../../utils/api.js')
|
||||
|
||||
function defaultStartAt() {
|
||||
@@ -96,6 +97,35 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
async createWechatPayment() {
|
||||
if (!this.data.order || !this.data.order.orderId) {
|
||||
this.setData({ errorMessage: '请先预占订单' })
|
||||
return
|
||||
}
|
||||
await this.withRequest(async () => {
|
||||
const paymentResponse = await request('/payments', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
orderId: this.data.order.orderId,
|
||||
provider: 'WECHAT',
|
||||
clientRequestId: clientRequestId('miniapp-wxpay'),
|
||||
},
|
||||
})
|
||||
const prepayResponse = await request('/pay/wechat/prepay', {
|
||||
method: 'POST',
|
||||
data: { paymentId: paymentResponse.data.paymentId },
|
||||
})
|
||||
await requestWechatPayment(prepayResponse.data)
|
||||
this.setData({
|
||||
payment: {
|
||||
...paymentResponse.data,
|
||||
amountText: cents(paymentResponse.data.amountCents),
|
||||
},
|
||||
successMessage: '微信支付已提交,请稍后查看订单状态',
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async completeTestPayment() {
|
||||
if (!this.data.payment || !this.data.payment.paymentId) {
|
||||
this.setData({ errorMessage: '请先创建测试支付单' })
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<view class="action-grid">
|
||||
<button loading="{{loading}}" bindtap="quote">报价</button>
|
||||
<button loading="{{loading}}" bindtap="reserve">预占</button>
|
||||
<button loading="{{loading}}" bindtap="createWechatPayment">微信支付</button>
|
||||
<button loading="{{loading}}" bindtap="createTestPayment">测试支付</button>
|
||||
<button loading="{{loading}}" bindtap="completeTestPayment">完成测试支付</button>
|
||||
</view>
|
||||
|
||||
@@ -64,6 +64,24 @@ function clientRequestId(prefix) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function requestWechatPayment(params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!params || !params.timeStamp || !params.nonceStr || !params.package || !params.paySign) {
|
||||
reject(new Error('微信支付参数不完整'))
|
||||
return
|
||||
}
|
||||
wx.requestPayment({
|
||||
timeStamp: String(params.timeStamp),
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType || 'RSA',
|
||||
paySign: params.paySign,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
login,
|
||||
@@ -71,4 +89,5 @@ module.exports = {
|
||||
clearSession,
|
||||
cents,
|
||||
clientRequestId,
|
||||
requestWechatPayment,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ for (const page of [
|
||||
'pages/orders/list',
|
||||
'pages/orders/detail',
|
||||
'pages/profile/index',
|
||||
'pages/benefits/index',
|
||||
'pages/recharge/index'
|
||||
]) {
|
||||
assert.ok(appJson.pages.includes(page), `${page} must be registered`);
|
||||
@@ -25,6 +26,7 @@ assert.doesNotMatch(env, /localhost|127\.0\.0\.1|http:\/\//);
|
||||
const api = read('miniapp/utils/api.js');
|
||||
assert.match(api, /\/auth\/wechat-login/);
|
||||
assert.match(api, /authorization: `Bearer \$\{token\}`/);
|
||||
assert.match(api, /wx\.requestPayment/);
|
||||
|
||||
const index = read('miniapp/pages/index/index.js');
|
||||
assert.match(index, /\/stores\?\$\{query\}/);
|
||||
@@ -40,10 +42,12 @@ for (const route of [
|
||||
'/pricing/quote',
|
||||
'/orders/reserve',
|
||||
'/payments',
|
||||
'/pay/wechat/prepay',
|
||||
'/test-complete'
|
||||
]) {
|
||||
assert.match(room, new RegExp(route.replace('/', '\\/')));
|
||||
}
|
||||
assert.match(room, /requestWechatPayment/);
|
||||
|
||||
const orders = read('miniapp/pages/orders/list.js')
|
||||
+ read('miniapp/pages/orders/detail.js');
|
||||
@@ -73,6 +77,18 @@ for (const pattern of [
|
||||
assert.match(profile, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const benefits = read('miniapp/pages/benefits/index.js')
|
||||
+ read('miniapp/pages/benefits/index.wxml');
|
||||
for (const pattern of [
|
||||
'/profile/benefits',
|
||||
'coupons',
|
||||
'packages',
|
||||
'couponGrantId',
|
||||
'packageHoldingId'
|
||||
]) {
|
||||
assert.match(benefits, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const recharge = read('miniapp/pages/recharge/index.js')
|
||||
+ read('miniapp/pages/recharge/index.wxml');
|
||||
for (const pattern of [
|
||||
|
||||
Reference in New Issue
Block a user