From 30ebc1c344b8de05689edc5906213afbd7766a58 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 8 Sep 2026 22:31:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/invite-marketing.md | 32 ++ packages/app/src/components/CardShop.vue | 5 +- .../app/src/components/FlashSaleSection.vue | 5 +- packages/app/src/components/InviteCard.vue | 57 ++ packages/app/src/pages/card/detail.vue | 69 ++- packages/app/src/pages/flash-sale/detail.vue | 18 +- packages/app/src/pages/profile/index.vue | 10 +- packages/app/src/pages/profile/invite.vue | 508 +----------------- packages/app/src/pages/profile/membership.vue | 2 +- packages/app/src/stores/invite.ts | 46 +- packages/app/src/stores/user.ts | 4 + .../20260908000000_invite_codes/migration.sql | 4 + packages/server/prisma/schema.prisma | 3 + .../__tests__/flash-sale-invite.spec.ts | 25 + .../src/flash-sale/flash-sale.service.ts | 5 +- .../invite/__tests__/invite.service.spec.ts | 71 +++ .../server/src/invite/invite.constants.ts | 2 +- .../server/src/invite/invite.controller.ts | 19 +- packages/server/src/invite/invite.service.ts | 151 +++--- .../payment/__tests__/payment.service.spec.ts | 27 +- .../server/src/payment/payment.service.ts | 14 +- 21 files changed, 445 insertions(+), 632 deletions(-) create mode 100644 docs/invite-marketing.md create mode 100644 packages/app/src/components/InviteCard.vue create mode 100644 packages/server/prisma/migrations/20260908000000_invite_codes/migration.sql create mode 100644 packages/server/src/flash-sale/__tests__/flash-sale-invite.spec.ts create mode 100644 packages/server/src/invite/__tests__/invite.service.spec.ts diff --git a/docs/invite-marketing.md b/docs/invite-marketing.md new file mode 100644 index 0000000..7f8ac0e --- /dev/null +++ b/docs/invite-marketing.md @@ -0,0 +1,32 @@ +# 邀请好友领课时 + +个人中心为所有登录用户展示 6 位邀请码;首次访问 `/invite/code` 时惰性生成,字符避开 0、1、I、O。数据库唯一索引保证唯一,条件更新和冲突重试保证并发安全,无需回填存量用户。 + +分享入口使用微信 `button open-type="share"`,分享落地为 `/pages/card/detail?showAll=1&inviteCode=XXXXXX`。落地弹窗自动填码,未领取的列表价格标注「领取后 95 折」。用户确认后绑定一次,禁止自邀和更换邀请人;绑定后体验卡、次卡、期限卡及限时购卡均按当前售价享 95 折,按分四舍五入,后续购买自动享受。订单金额只由服务端计算。 + +每位好友首次支付非体验卡后,邀请人获得独立的 1 次赠课会员卡,365 天有效;邀请人无需先持有会员卡。赠课卡隐藏于商品列表,可从「我的会员卡」预约。体验卡购买与上课均不发奖。每人只奖励一次,续购不重复奖励。 + +订单记录下单时的邀请人和卡片类别。支付到账、购卡权益、邀请资格更新、赠课及奖励记录处于同一数据库事务。条件更新防止并发回调、同一好友多笔订单重复发奖。历史无归因快照的订单不补发奖励;历史已 QUALIFIED 的邀请保留原资格,不重复奖励。 + +## 发布 + +先备份数据库,并在服务端运行 `pnpm exec prisma migrate deploy`,应用 `20260908000000_invite_codes`。该迁移新增可空邀请码、唯一索引、订单归因字段及隐藏赠课卡类型,不更新用户历史数据。 + +然后运行 `pnpm prisma:generate`,从仓库根目录运行 `pnpm build:shared`、`pnpm build:server`、`pnpm build:app`。先发布服务端,再发布小程序。无需新增环境变量。已于 2026-09-08 22:29(北京时间)执行生产迁移并发布后台;小程序尚未发布。 + +## 真机验收 + +用两个微信账号验证:个人中心复制和好友分享;分享卡片落地列表与自动填码;未登录确认领取;重启后保留服务端优惠;原价和 95 折金额一致;体验卡支付不赠课;次卡/期限卡首次支付赠课到账;后续购买不再赠课;赠课可预约。还应验证支付失败、取消和重复回调。微信分享与真实微信支付仍需在部署后进行真机验证。 + +## 验证限制 + +邀请、支付和限时购卡价格测试通过。已在 390 × 844 的 H5 预览中检查个人中心入口和自动填码落地弹窗;H5 预览不代替微信真机验收。全量测试中的旧 SchedulerService 测试缺少 FlashSaleService mock,8 项失败,与本次修改无关。`pnpm lint` 因仓库未安装 ESLint 无法运行。 + +## 本次后台发布记录 + +- 目标:`129.204.155.94`,目录 `/usr/local/web/mp-pilates-server`,PM2 服务 `mp-pilates-server`,端口 3008。 +- 使用服务器现有 Node 22.16.0 和 Prisma 5.22.0;现有部署脚本中的 Node 22.17.1 路径不适用于当前服务器,因此按步骤发布构建,未修改线上环境配置及证书。 +- 数据库迁移状态正常,新增 3 个字段及独立隐藏赠课卡核验通过。 +- 公网 `/api/health` 和 `/api/membership/card-types` 返回 200;商品列表不包含赠课卡;未登录访问 `/api/invite/code` 返回预期的 401。 +- 旧构建和 Prisma schema 已归档在服务器 `/usr/local/web/releases/mp-pilates-invite-20260908/previous-build.tgz`,新构建归档为同目录 `release.tgz`。 +- 本次只进行发布及只读核验,未制造测试用户、邀请关系或支付订单。微信真机分享与支付仍待小程序发布后联调。 diff --git a/packages/app/src/components/CardShop.vue b/packages/app/src/components/CardShop.vue index ee6dccb..ef54524 100644 --- a/packages/app/src/components/CardShop.vue +++ b/packages/app/src/components/CardShop.vue @@ -58,7 +58,7 @@ 课时 - ¥{{ formatPrice(card.price) }} + ¥{{ formatPrice(invite.price(card.price)) }} + 95 折 @@ -82,6 +83,7 @@ + diff --git a/packages/app/src/pages/card/detail.vue b/packages/app/src/pages/card/detail.vue index 66629d4..c7f8597 100644 --- a/packages/app/src/pages/card/detail.vue +++ b/packages/app/src/pages/card/detail.vue @@ -5,6 +5,21 @@ :style="{ paddingTop: navBarHeight }" > + + {{ invite.eligible ? '好友礼遇 · 已享 95 折' : '好友礼遇 · 领取 95 折购卡优惠' }} + {{ invite.eligible ? '体验卡、次卡、期限卡均适用' : '填写邀请码,和朋友一起开始练习 ›' }} + + + + A GIFT FROM YOUR FRIEND + 朋友送你一份 95 折礼遇 + 确认邀请码后,购卡自动减免 5%。体验卡也可享受。 + + {{ inviteError }} + 每人仅绑定一位好友。首次成功购买非体验卡,好友获得 1 节免费课;体验卡不触发赠课。 + + + @@ -64,10 +79,11 @@ {{ cardAccessLabel(c) }} - ¥{{ formatPrice(c.price) }} - {{ getSavingsLabel(c) }} + ¥{{ formatPrice(invite.pendingCode && !invite.eligible ? Math.round(c.price * 95 / 100) : invite.price(c.price)) }} + 领取后 95 折 + {{ getSavingsLabel(c) }} - {{ getUnitPriceLabel(c) }} + {{ getUnitPriceLabel(c) }} @@ -129,7 +145,7 @@ ¥ - {{ formatPrice(cardData.price) }} + {{ formatPrice(invite.price(cardData.price)) }} {{ isRenewal ? '续卡金额' : '实付金额' }} - ¥{{ formatPrice(cardData.price) }} + ¥{{ formatPrice(invite.price(cardData.price)) }} {{ bottomPriceHint }}