Просмотр исходного кода

docs: 会员支付/升级实现计划(upgrade→pay→result 三段式 + 原价配置)

Xiaogang Liao 2 месяцев назад
Родитель
Сommit
c5977e0afd
1 измененных файлов с 1326 добавлено и 0 удалено
  1. 1326 0
      docs/superpowers/plans/2026-08-09-membership-pay-upgrade.md

+ 1326 - 0
docs/superpowers/plans/2026-08-09-membership-pay-upgrade.md

@@ -0,0 +1,1326 @@
+# 会员支付/升级页面实现计划(方案 B)
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 将会员开通/续费链路拆分为「套餐选择 upgrade → 订单确认 pay → 支付结果 result」三段式,并支持会员价格「原价/划线价」配置展示。
+
+**架构:** 后端仅增强 `/api/membership/plans` 返回原价字段 + sys_config 新增 4 个原价键(零表结构变更);前端新增 pay.vue、result.vue,改造 upgrade.vue(移除支付逻辑),api.js 修复 createOrder 透传 couponId。
+
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus(后端)/ uni-app Vue 2 Options API(小程序前端)/ 微信虚拟支付 `requestVirtualPayment`
+
+**设计文档:** `docs/superpowers/specs/2026-08-09-membership-pay-upgrade-design.md`
+
+**验证方式(本项目约定):**
+- 后端:`cd cfc-backend && mvn clean compile`(唯一验证方式,不做运行时验证)
+- 前端:提取 `<script>` 块后 `node --check` 做语法校验;**禁止**执行 `npm run build:mp-weixin`(必须用 HBuilderX 打包,Agent 不做)
+
+---
+
+## 文件结构
+
+| 文件 | 动作 | 职责 |
+|------|------|------|
+| `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 修改 | SysConfig 种子区加 4 个原价键(幂等) |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java` | 修改 | `getPricingPlans()` 增加 originalYearly/originalMonthly/originalQuarterly |
+| `cfc-frontend/utils/api.js` | 修改 | `createOrder` 增加 couponId 透传(第 4 参数) |
+| `cfc-frontend/pages/membership/upgrade.vue` | 修改 | 移除支付/优惠券逻辑;按钮改「下一步」;修会员状态判断 bug;价格区划线原价 |
+| `cfc-frontend/pages/membership/pay.vue` | 创建 | 订单确认:商品明细 + 优惠券 Picker + 拆单提示 + 支付调用 |
+| `cfc-frontend/pages/membership/result.vue` | 创建 | 支付结果:成功态(赠券)/失败态(续付入口) |
+| `cfc-frontend/pages.json` | 修改 | `pages/membership` 分包注册 pay、result 两页 |
+
+---
+
+## 任务 1:后端 sys_config 新增原价键
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(SysConfig 种子区,约第 1040 行 `member_fee_premium_monthly` 之后)
+
+- [ ] **步骤 1:在 SysConfig 种子区添加 4 个原价键**
+
+在 `insertSysConfigSeed("member_fee_premium_monthly", "131400", "高级家庭会员月费(分)");` 这一行之后追加:
+
+```java
+            insertSysConfigSeed("member_fee_family_original_yearly", "199900", "家庭会员年费原价(分)");
+            insertSysConfigSeed("member_fee_family_original", "199900", "家庭会员原价兜底(分)");
+            insertSysConfigSeed("member_fee_premium_original", "1599900", "高级会员年费原价(分)");
+            insertSysConfigSeed("member_fee_premium_original_monthly", "159900", "高级会员月费原价(分)");
+```
+
+注意缩进与相邻行一致(`insertSysConfigSeed` 调用在 try 块内,前导 12 空格)。
+
+- [ ] **步骤 2:验证编译**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`(无编译错误)
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "feat: 会员原价配置(sys_config 新增4键: family/premium 年费月费原价)"
+```
+
+---
+
+## 任务 2:后端 `/api/membership/plans` 增加原价字段
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java`(`getPricingPlans()` 方法,第 317-348 行)
+
+- [ ] **步骤 1:修改 getPricingPlans() 增加原价字段**
+
+将 `getPricingPlans()` 中三个价格分支各补充原价字段:
+
+```java
+            // 付费等级价格从 sys_config 读取
+            if ("FAMILY".equals(level.getLevelCode())) {
+                // 家庭会员仅支持年付
+                String yearlyStr = sysConfigService.getValue("member_fee_family_yearly");
+                if (yearlyStr == null) yearlyStr = sysConfigService.getValue("member_fee_family");
+                plan.put("yearly", yearlyStr != null ? Integer.parseInt(yearlyStr) : 131400);
+                plan.put("monthly", null);
+                plan.put("quarterly", null);
+                // 原价(划线价,仅展示)
+                String origYearlyStr = sysConfigService.getValue("member_fee_family_original_yearly");
+                if (origYearlyStr == null) origYearlyStr = sysConfigService.getValue("member_fee_family_original");
+                plan.put("originalYearly", origYearlyStr != null ? Integer.parseInt(origYearlyStr) : null);
+                plan.put("originalMonthly", null);
+                plan.put("originalQuarterly", null);
+            } else if ("PREMIUM".equals(level.getLevelCode())) {
+                String yearlyStr = sysConfigService.getValue("member_fee_premium");
+                String monthlyStr = sysConfigService.getValue("member_fee_premium_monthly");
+                plan.put("yearly", yearlyStr != null ? Integer.parseInt(yearlyStr) : 1316800);
+                plan.put("monthly", monthlyStr != null ? Integer.parseInt(monthlyStr) : 131400);
+                plan.put("quarterly", 0);
+                // 原价(划线价,仅展示)
+                String origYearlyStr = sysConfigService.getValue("member_fee_premium_original");
+                String origMonthlyStr = sysConfigService.getValue("member_fee_premium_original_monthly");
+                plan.put("originalYearly", origYearlyStr != null ? Integer.parseInt(origYearlyStr) : null);
+                plan.put("originalMonthly", origMonthlyStr != null ? Integer.parseInt(origMonthlyStr) : null);
+                plan.put("originalQuarterly", null);
+            } else {
+                plan.put("monthly", level.getPriceMonthly());
+                plan.put("quarterly", level.getPriceQuarterly());
+                plan.put("yearly", level.getPriceYearly());
+                // 其他等级无原价定义
+                plan.put("originalYearly", null);
+                plan.put("originalMonthly", null);
+                plan.put("originalQuarterly", null);
+            }
+```
+
+- [ ] **步骤 2:验证编译**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
+git commit -m "feat: /api/membership/plans 返回原价字段(originalYearly/Monthly/Quarterly)"
+```
+
+---
+
+## 任务 3:前端 api.js createOrder 增加 couponId 透传
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`(第 369-375 行 `createOrder`)
+
+- [ ] **步骤 1:修改 createOrder 增加 couponId 参数**
+
+```js
+export const createOrder = (levelCode, paymentType, period, couponId) => {
+	var data = { levelCode: levelCode, paymentType: paymentType }
+	if (period) {
+		data.period = period
+	}
+	if (couponId) {
+		data.couponId = couponId
+	}
+	return request('/api/membership/orders', 'POST', data)
+}
+```
+
+(保持现有调用 `createOrder(levelCode, 'pay', period)` 兼容,couponId 可选)
+
+- [ ] **步骤 2:语法校验**
+
+运行:`node -e "const fs=require('fs');const s=fs.readFileSync('cfc-frontend/utils/api.js','utf8');const m=s.match(/export const createOrder =[\s\S]*?\n}/);if(!m){console.error('createOrder not found');process.exit(1)};new Function(m[0].replace(/^export const /,'const ').replace(/^const /,'const '));console.log('createOrder syntax OK')"`
+预期:`createOrder syntax OK`
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git commit -m "fix: createOrder 透传 couponId(会员优惠券此前未生效)"
+```
+
+---
+
+## 任务 4:改造 upgrade.vue(套餐选择页)
+
+**文件:**
+- 修改:`cfc-frontend/pages/membership/upgrade.vue`(整文件)
+
+**要点:** 移除优惠券选择 + 支付调用 + 支付结果轮询;底部主按钮改「下一步」跳转 pay;修复会员状态判断 bug(`m.memberLevel` 而非 `m.levelCode`);价格区展示划线原价。
+
+- [ ] **步骤 1:改写模板 — 移除优惠券行,主按钮改「下一步」**
+
+模板中两处(开通卡片 + 续费卡片)的优惠券行删除,按钮改为:
+
+```html
+        <!-- 开通卡片 -->
+        <button class="btn-upgrade" @click="goNext">下一步</button>
+        ...
+        <!-- 续费卡片 -->
+        <button class="btn-upgrade btn-renew" @click="goNext">下一步</button>
+```
+
+删除整个 `coupon-row` 块(两处)和底部 `modal-mask`(Coupon Picker Bottom Sheet)整块。
+
+- [ ] **步骤 2:改写 script — 移除支付/优惠券逻辑,加 goNext 与划线原价**
+
+script 变更:
+1. 删除 import 中 `createOrder`、`getCouponList`、`requestVirtualPayment`、`nextOrder`
+2. data 中删除 `coupons`、`selectedCoupon`、`showCouponPicker`、`joinRules`(joinRules 保留,开通赠券仍展示)
+3. 删除 methods:`openCouponPicker`、`selectCoupon`、`removeCoupon`、`getCouponCondition`、`handleUpgrade`、`doUpgrade`、`pollOrderStatus`、`payInstallments`、`handleRenew`、`doRenew`
+4. 新增 `goNext`:
+
+```js
+    goNext: function() {
+      var levelCode = this.selectedLevel
+      var period = this.selectedPeriod
+      uni.navigateTo({ url: '/pages/membership/pay?levelCode=' + levelCode + '&period=' + period })
+    },
+```
+
+5. 新增划线原价 computed:
+
+```js
+  computed: {
+    visiblePeriods() { /* 保留现有 */ },
+    availableCoupons() { /* 删除此 computed */ }
+  },
+```
+
+在 `methods` 中新增原价查询方法(替代 `availableCoupons` 的位置):
+
+```js
+    getOriginalPriceForPeriod: function(periodKey) {
+      var plan = null
+      for (var i = 0; i < this.levels.length; i++) {
+        if (this.levels[i].levelCode === this.selectedLevel) {
+          plan = this.levels[i]
+          break
+        }
+      }
+      if (!plan) return null
+      if (periodKey === 'monthly') return plan.originalMonthly || null
+      if (periodKey === 'quarterly') return plan.originalQuarterly || null
+      if (periodKey === 'yearly') return plan.originalYearly || null
+      return null
+    },
+    showOriginalPrice: function(periodKey) {
+      var original = this.getOriginalPriceForPeriod(periodKey)
+      var current = this.getPriceForPeriod(periodKey)
+      if (!original || !current) return false
+      return original > current * 100
+    },
+```
+
+6. **修复会员状态判断 bug**:`loadData()` 中:
+
+```js
+        if (this.membership && this.membership.levelCode && this.membership.levelCode !== 'FREE') {
+```
+改为:
+```js
+        if (this.membership && this.membership.memberLevel && this.membership.memberLevel !== 'FREE') {
+```
+并同步下方 `this.selectedLevel = this.membership.levelCode` → `this.membership.memberLevel`,`this.currentLevelName` 取值逻辑不变(`m.memberLevel` 时用 levelName 映射或保留 '家庭会员')。
+
+- [ ] **步骤 3:改写样式 — 划线原价样式**
+
+在样式区追加:
+
+```css
+/* ===== 划线原价 ===== */
+.period-original {
+  font-size: 20rpx;
+  color: #94A3B8;
+  text-decoration: line-through;
+  margin-top: 2rpx;
+}
+.price-original-line {
+  font-size: 26rpx;
+  color: #94A3B8;
+  text-decoration: line-through;
+  margin-right: 12rpx;
+}
+```
+
+- [ ] **步骤 4:模板价格区加划线原价**
+
+period-tab 内价格下方(`period-tab-price` 之后)加:
+
+```html
+            <text class="period-original" v-if="showOriginalPrice(p.key)">¥{{ getOriginalPriceForPeriod(p.key) / 100 }}</text>
+```
+
+升级价格大数字旁(`price-unit` 之前)加:
+
+```html
+          <text class="price-original-line" v-if="showOriginalPrice(selectedPeriod)">¥{{ getOriginalPriceForPeriod(selectedPeriod) / 100 }}</text>
+```
+
+- [ ] **步骤 5:语法校验**
+
+提取 script 块运行 `node --check`(用 node 脚本提取 `<script>` 与 `</script>` 之间的内容,写临时文件后 `node --check`)。
+预期:无语法错误。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-frontend/pages/membership/upgrade.vue
+git commit -m "refactor: upgrade页移除支付/优惠券逻辑,改下一步跳转pay,修复会员状态判断bug,支持划线原价"
+```
+
+---
+
+## 任务 5:新增 pay.vue(订单确认页)
+
+**文件:**
+- 创建:`cfc-frontend/pages/membership/pay.vue`
+
+**要点:** 接收 levelCode/period 参数 → plans 取价(含原价)→ couponList 取券 → 展示明细 + 优惠券 Picker → createOrder(couponId) → 微信虚拟支付(拆单逐期)→ 跳转 result。
+
+- [ ] **步骤 1:创建 pay.vue 完整文件**
+
+```vue
+<template>
+  <view class="pay-page">
+    <scroll-view class="page" scroll-y>
+      <!-- 商品明细卡 -->
+      <view class="order-card">
+        <view class="order-header">
+          <text class="order-title">{{ levelName }} · {{ periodLabel }}</text>
+          <text class="order-sub">{{ levelCode }}</text>
+        </view>
+        <view class="price-row" v-if="showOriginal">
+          <text class="price-label">原价</text>
+          <text class="price-original">¥{{ originalPriceText }}</text>
+        </view>
+        <view class="price-row">
+          <text class="price-label">现价</text>
+          <text class="price-now">¥{{ priceText }}</text>
+        </view>
+        <view class="coupon-row" @click="openCouponPicker">
+          <text class="coupon-row-label">优惠券</text>
+          <text v-if="selectedCoupon" class="coupon-row-discount">-¥{{ couponDiscountText }} {{ selectedCoupon.name }}</text>
+          <text v-else class="coupon-row-placeholder">选择优惠券</text>
+          <text class="coupon-row-arrow">›</text>
+        </view>
+        <view class="divider"></view>
+        <view class="total-row">
+          <text class="total-label">实付</text>
+          <text class="total-amount">¥{{ finalPriceText }}</text>
+        </view>
+      </view>
+
+      <!-- 拆单提示卡 -->
+      <view class="split-card" v-if="totalPeriods > 1">
+        <text class="split-title">💡 本订单将分 {{ totalPeriods }} 期支付</text>
+        <text class="split-desc">每期 ¥{{ splitAmountText }},全部支付完成后自动开通会员</text>
+      </view>
+
+      <!-- 支付进度(拆单逐期时显示) -->
+      <view class="progress-card" v-if="paying && totalPeriods > 1">
+        <text class="progress-text">第 {{ currentPeriod }} / {{ totalPeriods }} 期</text>
+      </view>
+    </scroll-view>
+
+    <!-- 底部固定支付栏 -->
+    <view class="pay-bar">
+      <button class="btn-pay" :loading="paying" :disabled="paying" @click="handlePay">
+        {{ paying ? '支付中…' : '立即支付 ¥' + finalPriceText }}
+      </button>
+    </view>
+
+    <!-- Coupon Picker Bottom Sheet -->
+    <view class="modal-mask" v-if="showCouponPicker" @click="showCouponPicker = false">
+      <view class="coupon-picker" @click.stop>
+        <view class="picker-header">
+          <text class="picker-title">选择优惠券</text>
+          <text class="picker-close" @click="showCouponPicker = false">关闭</text>
+        </view>
+        <scroll-view scroll-y class="picker-list">
+          <view
+            v-for="coupon in availableCoupons"
+            :key="coupon.id"
+            class="picker-item"
+            :class="selectedCoupon && selectedCoupon.id === coupon.id ? 'picker-item-active' : ''"
+            @click="selectCoupon(coupon)"
+          >
+            <view class="picker-item-left">
+              <text class="picker-item-value">{{ formatPrice(coupon.value) }}</text>
+            </view>
+            <view class="picker-item-right">
+              <text class="picker-item-name">{{ coupon.name }}</text>
+              <text class="picker-item-condition">{{ getCouponCondition(coupon.minSpend) }}</text>
+            </view>
+            <view class="picker-item-check" v-if="selectedCoupon && selectedCoupon.id === coupon.id">
+              <text>✓</text>
+            </view>
+          </view>
+          <view class="picker-empty" v-if="availableCoupons.length === 0">
+            <text>暂无可用优惠券</text>
+          </view>
+        </scroll-view>
+        <view class="picker-footer" v-if="selectedCoupon">
+          <button class="picker-btn-remove" @click="removeCoupon">不使用优惠券</button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMembershipPlans, getCouponList, createOrder, requestVirtualPayment, nextOrder } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      levelCode: 'FAMILY',
+      period: 'yearly',
+      levelName: '家庭会员',
+      periodLabel: '年度',
+      levels: [],
+      coupons: [],
+      selectedCoupon: null,
+      showCouponPicker: false,
+      originalPrice: null,
+      price: 0,
+      totalPeriods: 1,
+      currentPeriod: 1,
+      splitAmount: 0,
+      orderNo: null,
+      paying: false
+    }
+  },
+  computed: {
+    showOriginal: function() {
+      return this.originalPrice != null && this.originalPrice > this.price
+    },
+    priceText: function() {
+      return (this.price / 100).toFixed(2)
+    },
+    originalPriceText: function() {
+      if (this.originalPrice == null) return '0.00'
+      return (this.originalPrice / 100).toFixed(2)
+    },
+    couponDiscount: function() {
+      if (!this.selectedCoupon) return 0
+      if (this.selectedCoupon.type === 'DISCOUNT') {
+        var rate = this.selectedCoupon.discountRate || 100
+        return Math.round(this.price * (100 - rate) / 100)
+      }
+      return this.selectedCoupon.value || 0
+    },
+    couponDiscountText: function() {
+      return (this.couponDiscount / 100).toFixed(2)
+    },
+    finalPrice: function() {
+      return Math.max(0, this.price - this.couponDiscount)
+    },
+    finalPriceText: function() {
+      return (this.finalPrice / 100).toFixed(2)
+    },
+    splitAmountText: function() {
+      return (this.splitAmount / 100).toFixed(2)
+    },
+    availableCoupons: function() {
+      var self = this
+      return this.coupons.filter(function(c) {
+        if (c.status && c.status !== 'AVAILABLE') return false
+        if (c.applicableTo && c.applicableTo !== 'ALL' && c.applicableTo !== 'MEMBERSHIP') return false
+        return true
+      })
+    }
+  },
+  onLoad: function(options) {
+    this.levelCode = options.levelCode || 'FAMILY'
+    this.period = options.period || 'yearly'
+    this.periodLabel = this.getPeriodLabel(this.period)
+    this.loadPlans()
+    this.loadCoupons()
+  },
+  methods: {
+    getPeriodLabel: function(period) {
+      var map = { monthly: '月度', quarterly: '季度', yearly: '年度' }
+      return map[period] || '年度'
+    },
+    loadPlans: function() {
+      var self = this
+      getMembershipPlans().then(function(res) {
+        var levels = res.data || []
+        for (var i = 0; i < levels.length; i++) {
+          if (levels[i].levelCode === self.levelCode) {
+            self.levelName = levels[i].levelName || self.levelName
+            if (self.period === 'monthly') {
+              self.price = levels[i].monthly || 0
+              self.originalPrice = levels[i].originalMonthly || null
+            } else if (self.period === 'quarterly') {
+              self.price = levels[i].quarterly || 0
+              self.originalPrice = levels[i].originalQuarterly || null
+            } else {
+              self.price = levels[i].yearly || 0
+              self.originalPrice = levels[i].originalYearly || null
+            }
+            break
+          }
+        }
+      }).catch(function(e) {
+        uni.showToast({ title: '加载价格失败', icon: 'none' })
+        console.error('加载价格失败', e)
+      })
+    },
+    loadCoupons: function() {
+      var self = this
+      getCouponList().then(function(res) {
+        self.coupons = res.data || []
+      }).catch(function() {})
+    },
+    openCouponPicker: function() {
+      if (this.availableCoupons.length === 0 && !this.selectedCoupon) {
+        uni.showToast({ title: '暂无可用优惠券', icon: 'none' })
+        return
+      }
+      this.showCouponPicker = true
+    },
+    selectCoupon: function(coupon) {
+      this.selectedCoupon = coupon
+      this.showCouponPicker = false
+    },
+    removeCoupon: function() {
+      this.selectedCoupon = null
+      this.showCouponPicker = false
+    },
+    getCouponCondition: function(minSpend) {
+      if (!minSpend || minSpend <= 0) return '无门槛'
+      return '满' + (minSpend / 100).toFixed(2) + '元可用'
+    },
+    formatPrice: function(value) {
+      if (value == null) return '0.00'
+      return (value / 100).toFixed(2)
+    },
+    handlePay: function() {
+      var self = this
+      if (this.paying) return
+      this.paying = true
+      var couponId = this.selectedCoupon ? this.selectedCoupon.id : null
+      createOrder(this.levelCode, 'pay', this.period, couponId).then(function(res) {
+        var data = res.data || {}
+        if (data.orderInfo && data.sign) {
+          self.orderNo = data.orderNo || (data.orderInfo && data.orderInfo.outTradeNo)
+          self.totalPeriods = data.totalPeriods || 1
+          self.currentPeriod = data.currentPeriod || 1
+          self.splitAmount = data.orderInfo && data.orderInfo.goodsPrice ? data.orderInfo.goodsPrice : (self.finalPrice / self.totalPeriods)
+          self.payInstallments(data)
+        } else {
+          // testMode 或试用:模拟支付成功
+          self.orderNo = data.orderNo
+          uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
+          setTimeout(function() {
+            self.paying = false
+            uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
+          }, 800)
+        }
+      }).catch(function(e) {
+        self.paying = false
+        if (e.code === 46001) {
+          uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
+        } else {
+          uni.showToast({ title: e.message || '下单失败', icon: 'none' })
+        }
+      })
+    },
+    payInstallments: function(dto) {
+      var self = this
+      var current = dto.currentPeriod || 1
+      var total = dto.totalPeriods || 1
+      requestVirtualPayment(dto, {
+        success: function() {
+          if (current < total) {
+            uni.showToast({ title: '第' + current + '期支付成功', icon: 'none' })
+            nextOrder({ orderNo: dto.orderNo }).then(function(res) {
+              var nd = res.data
+              if (nd && nd.done) {
+                self.finishPay()
+              } else if (nd && nd.orderInfo && nd.sign) {
+                self.currentPeriod = nd.currentPeriod || 1
+                self.payInstallments(nd)
+              } else {
+                self.finishPay()
+              }
+            }).catch(function() {
+              self.finishPay()
+            })
+          } else {
+            self.finishPay()
+          }
+        },
+        fail: function(err) {
+          self.paying = false
+          var errCode = err && err.errCode
+          var msg = ''
+          if (errCode === -15007 || errCode === -15005) msg = '登录态已过期,请重新登录后支付'
+          else if (errCode === -15010) msg = '商品未发布,请联系客服'
+          else if (errCode === -15013) msg = '商品价格异常'
+          else if (errCode === -2) msg = '支付已取消'
+          else if (err && err.errMsg) msg = err.errMsg
+          var status = (errCode === -2 || errCode === -15007 || errCode === -15005) ? 'fail' : 'fail'
+          uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=' + status })
+        }
+      })
+    },
+    finishPay: function() {
+      var self = this
+      uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
+      setTimeout(function() {
+        self.paying = false
+        uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
+      }, 800)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pay-page {
+  min-height: 100vh;
+  background: #FFF7ED;
+  padding-bottom: 160rpx;
+  box-sizing: border-box;
+}
+.page {
+  padding: 30rpx;
+}
+
+/* ===== 商品明细卡 ===== */
+.order-card {
+  background: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 40rpx;
+  box-shadow: 0 4rpx 24rpx rgba(249, 115, 22, 0.08);
+}
+.order-header {
+  margin-bottom: 24rpx;
+}
+.order-title {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #1E293B;
+  display: block;
+}
+.order-sub {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-top: 6rpx;
+  display: block;
+}
+.price-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+}
+.price-label {
+  font-size: 26rpx;
+  color: #64748B;
+}
+.price-original {
+  font-size: 28rpx;
+  color: #94A3B8;
+  text-decoration: line-through;
+}
+.price-now {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+.coupon-row {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 0;
+  border-top: 1rpx solid #f5f5f5;
+  margin-top: 16rpx;
+}
+.coupon-row:active {
+  opacity: 0.7;
+}
+.coupon-row-label {
+  font-size: 26rpx;
+  color: #666;
+  margin-right: 16rpx;
+}
+.coupon-row-discount {
+  flex: 1;
+  font-size: 26rpx;
+  color: #F97316;
+  font-weight: 600;
+  text-align: right;
+}
+.coupon-row-placeholder {
+  flex: 1;
+  font-size: 26rpx;
+  color: #ccc;
+  text-align: right;
+}
+.coupon-row-arrow {
+  font-size: 32rpx;
+  color: #ccc;
+  margin-left: 8rpx;
+}
+.divider {
+  height: 1rpx;
+  background: #F1F5F9;
+  margin: 16rpx 0;
+}
+.total-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.total-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.total-amount {
+  font-size: 44rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+
+/* ===== 拆单提示 ===== */
+.split-card {
+  background: #FEF3C7;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-top: 30rpx;
+}
+.split-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #92400E;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.split-desc {
+  font-size: 24rpx;
+  color: #B45309;
+  display: block;
+}
+
+/* ===== 支付进度 ===== */
+.progress-card {
+  background: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 20rpx;
+  margin-top: 20rpx;
+  text-align: center;
+}
+.progress-text {
+  font-size: 26rpx;
+  color: #F97316;
+  font-weight: 600;
+}
+
+/* ===== 底部支付栏 ===== */
+.pay-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #FFFFFF;
+  padding: 20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));
+  box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
+}
+.btn-pay {
+  width: 100%;
+  height: 96rpx;
+  line-height: 96rpx;
+  background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
+  color: #fff;
+  font-size: 34rpx;
+  font-weight: 600;
+  border-radius: 48rpx;
+  border: none;
+}
+.btn-pay::after {
+  border: none;
+}
+.btn-pay[disabled] {
+  opacity: 0.7;
+  color: #fff;
+}
+
+/* ===== Coupon Picker(复用 upgrade 样式) ===== */
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+}
+.coupon-picker {
+  background: #fff;
+  border-radius: 24rpx 24rpx 0 0;
+  width: 100%;
+  max-height: 70vh;
+  display: flex;
+  flex-direction: column;
+}
+.picker-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 30rpx;
+  border-bottom: 1rpx solid #eee;
+}
+.picker-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.picker-close {
+  font-size: 26rpx;
+  color: #999;
+}
+.picker-list {
+  max-height: 50vh;
+  padding: 0 20rpx;
+}
+.picker-item {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 16rpx;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.picker-item:active {
+  background: #fafafa;
+}
+.picker-item-active {
+  background: #FFF7ED;
+}
+.picker-item-left {
+  width: 120rpx;
+  text-align: center;
+  flex-shrink: 0;
+}
+.picker-item-value {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+.picker-item-right {
+  flex: 1;
+  margin: 0 16rpx;
+}
+.picker-item-name {
+  font-size: 26rpx;
+  color: #333;
+  display: block;
+  margin-bottom: 4rpx;
+}
+.picker-item-condition {
+  font-size: 22rpx;
+  color: #999;
+}
+.picker-item-check {
+  width: 40rpx;
+  height: 40rpx;
+  background: #F97316;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  color: #fff;
+  font-size: 24rpx;
+  font-weight: bold;
+}
+.picker-empty {
+  text-align: center;
+  padding: 60rpx 0;
+  font-size: 26rpx;
+  color: #999;
+}
+.picker-footer {
+  padding: 20rpx;
+  border-top: 1rpx solid #eee;
+}
+.picker-btn-remove {
+  background: #f5f5f5;
+  color: #666;
+  font-size: 26rpx;
+  border-radius: 12rpx;
+  border: none;
+}
+.picker-btn-remove::after {
+  border: none;
+}
+</style>
+```
+
+- [ ] **步骤 2:语法校验**
+
+提取 script 块运行 `node --check`。
+预期:无语法错误。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/pages/membership/pay.vue
+git commit -m "feat: 新增订单确认页 pay.vue(明细/优惠券/拆单提示/虚拟支付逐期)"
+```
+
+---
+
+## 任务 6:新增 result.vue(支付结果页)
+
+**文件:**
+- 创建:`cfc-frontend/pages/membership/result.vue`
+
+**要点:** 读 orderNo/status → 成功态轮询确认开通 + 展示赠券;失败态展示原因 + 重新支付/续付入口(拆单未付清调 nextOrder)。
+
+- [ ] **步骤 1:创建 result.vue 完整文件**
+
+```vue
+<template>
+  <view class="result-page">
+    <!-- 成功态 -->
+    <view class="result-body" v-if="status === 'success'">
+      <view class="result-icon success">
+        <text class="icon-text">✅</text>
+      </view>
+      <text class="result-title">开通成功</text>
+      <text class="result-sub">{{ levelName }} {{ levelCode }}</text>
+      <view class="info-card">
+        <view class="info-row">
+          <text class="info-label">有效期至</text>
+          <text class="info-value">{{ expireText }}</text>
+        </view>
+        <view class="info-row" v-if="giftCoupons.length > 0">
+          <text class="info-label">开通赠券</text>
+          <text class="info-value gift">{{ giftCouponsText }}</text>
+        </view>
+      </view>
+      <view class="btn-group">
+        <button class="btn-primary" @click="goBenefits">查看我的权益</button>
+        <button class="btn-secondary" @click="goHome">返回会员中心</button>
+      </view>
+    </view>
+
+    <!-- 失败态 -->
+    <view class="result-body" v-else>
+      <view class="result-icon fail">
+        <text class="icon-text">❌</text>
+      </view>
+      <text class="result-title">支付未完成</text>
+      <text class="result-sub">{{ failReason }}</text>
+      <view class="btn-group">
+        <button class="btn-primary" v-if="canContinue" @click="continuePay">继续支付</button>
+        <button class="btn-primary" v-else @click="retryPay">重新支付</button>
+        <button class="btn-secondary" @click="goUpgrade">返回套餐选择</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMyMembership, getJoinCouponRules, nextOrder, requestVirtualPayment } from '../../utils/api.js'
+import { parseDate } from '../../utils/format.js'
+
+export default {
+  data() {
+    return {
+      orderNo: '',
+      status: 'success',
+      levelName: '会员',
+      levelCode: '',
+      expireText: '',
+      giftCoupons: [],
+      failReason: '支付未完成,请重试',
+      canContinue: false,
+      nextPayParams: null
+    }
+  },
+  computed: {
+    giftCouponsText: function() {
+      var self = this
+      return this.giftCoupons.map(function(r) {
+        return '「' + r.name + '」×' + (r.grantQuantity || 1)
+      }).join('、')
+    }
+  },
+  onLoad: function(options) {
+    this.orderNo = options.orderNo || ''
+    this.status = options.status || 'success'
+    if (this.status === 'success') {
+      this.loadSuccess()
+    } else {
+      this.loadFail()
+    }
+  },
+  methods: {
+    loadSuccess: function() {
+      var self = this
+      // 轮询确认开通
+      var times = 0
+      var timer = setInterval(function() {
+        times++
+        getMyMembership().then(function(res) {
+          var m = res.data || {}
+          var mem = m.membership
+          if (mem && mem.isActive) {
+            clearInterval(timer)
+            self.levelCode = mem.levelCode || ''
+            self.levelName = mem.levelName || self.levelName
+            self.expireText = self.formatDate(mem.endDate)
+            self.loadGiftCoupons()
+          } else if (times >= 8) {
+            clearInterval(timer)
+            self.loadGiftCoupons()
+          }
+        }).catch(function() {
+          if (times >= 8) {
+            clearInterval(timer)
+            self.loadGiftCoupons()
+          }
+        })
+      }, 1500)
+    },
+    loadGiftCoupons: function() {
+      var self = this
+      getJoinCouponRules().then(function(res) {
+        var rules = res.data || []
+        self.giftCoupons = rules.filter(function(r) {
+          return r.grantLevelCode === self.levelCode
+        })
+      }).catch(function() {})
+    },
+    loadFail: function() {
+      var self = this
+      if (!this.orderNo) {
+        this.failReason = '订单信息缺失'
+        return
+      }
+      // 尝试获取下一期支付参数(拆单未付清时可用)
+      nextOrder({ orderNo: this.orderNo }).then(function(res) {
+        var nd = res.data
+        if (nd && nd.done) {
+          self.failReason = '订单已全部支付,请稍后查看会员状态'
+          self.canContinue = false
+        } else if (nd && nd.orderInfo && nd.sign) {
+          self.canContinue = true
+          self.nextPayParams = nd
+          self.failReason = '第 ' + (nd.currentPeriod || 1) + ' 期尚未支付'
+        } else {
+          self.failReason = '支付未完成,请重试'
+          self.canContinue = false
+        }
+      }).catch(function() {
+        self.failReason = '支付未完成,请重试'
+      })
+    },
+    continuePay: function() {
+      var self = this
+      if (!this.nextPayParams) {
+        this.retryPay()
+        return
+      }
+      requestVirtualPayment(this.nextPayParams, {
+        success: function() {
+          uni.redirectTo({ url: '/pages/membership/result?orderNo=' + self.orderNo + '&status=success' })
+        },
+        fail: function(err) {
+          var errCode = err && err.errCode
+          if (errCode === -2) {
+            uni.showToast({ title: '支付已取消', icon: 'none' })
+          } else {
+            uni.showToast({ title: '支付未完成', icon: 'none' })
+          }
+        }
+      })
+    },
+    retryPay: function() {
+      uni.redirectTo({ url: '/pages/membership/upgrade' })
+    },
+    goBenefits: function() {
+      uni.redirectTo({ url: '/pages/membership/benefits' })
+    },
+    goHome: function() {
+      uni.switchTab({ url: '/pages/index/index' })
+    },
+    goUpgrade: function() {
+      uni.redirectTo({ url: '/pages/membership/upgrade' })
+    },
+    formatDate: function(date) {
+      if (!date) return ''
+      var d = parseDate(date)
+      if (!d) return ''
+      return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.result-page {
+  min-height: 100vh;
+  background: #FFF7ED;
+  display: flex;
+  justify-content: center;
+  padding: 120rpx 30rpx 60rpx;
+  box-sizing: border-box;
+}
+.result-body {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.result-icon {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 32rpx;
+}
+.result-icon.success {
+  background: #D1FAE5;
+}
+.result-icon.fail {
+  background: #FEE2E2;
+}
+.icon-text {
+  font-size: 80rpx;
+}
+.result-title {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #1E293B;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.result-sub {
+  font-size: 28rpx;
+  color: #64748B;
+  display: block;
+  margin-bottom: 48rpx;
+}
+.info-card {
+  width: 100%;
+  background: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  box-shadow: 0 4rpx 24rpx rgba(249, 115, 22, 0.08);
+  margin-bottom: 60rpx;
+}
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+}
+.info-label {
+  font-size: 28rpx;
+  color: #64748B;
+}
+.info-value {
+  font-size: 28rpx;
+  color: #1E293B;
+  font-weight: 600;
+}
+.info-value.gift {
+  color: #F97316;
+}
+.btn-group {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+}
+.btn-primary {
+  width: 100%;
+  height: 96rpx;
+  line-height: 96rpx;
+  background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: 600;
+  border-radius: 48rpx;
+  border: none;
+  margin-bottom: 24rpx;
+}
+.btn-primary::after {
+  border: none;
+}
+.btn-secondary {
+  width: 100%;
+  height: 96rpx;
+  line-height: 96rpx;
+  background: #FFFFFF;
+  color: #F97316;
+  font-size: 32rpx;
+  font-weight: 600;
+  border-radius: 48rpx;
+  border: 2rpx solid #FED7AA;
+}
+.btn-secondary::after {
+  border: none;
+}
+</style>
+```
+
+- [ ] **步骤 2:语法校验**
+
+提取 script 块运行 `node --check`。
+预期:无语法错误。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/pages/membership/result.vue
+git commit -m "feat: 新增支付结果页 result.vue(成功态+赠券/失败态+续付)"
+```
+
+---
+
+## 任务 7:pages.json 注册 pay、result 页面
+
+**文件:**
+- 修改:`cfc-frontend/pages.json`(`pages/membership` 分包,第 669-691 行)
+
+- [ ] **步骤 1:在 upgrade 页面注册后追加两页**
+
+```json
+    {
+      "root": "pages/membership",
+      "pages": [
+        {
+          "path": "index",
+          "style": {
+            "navigationBarTitleText": "会员中心"
+          }
+        },
+        {
+          "path": "benefits",
+          "style": {
+            "navigationBarTitleText": "我的权益"
+          }
+        },
+        {
+          "path": "upgrade",
+          "style": {
+            "navigationBarTitleText": "开通会员"
+          }
+        },
+        {
+          "path": "pay",
+          "style": {
+            "navigationBarTitleText": "确认订单"
+          }
+        },
+        {
+          "path": "result",
+          "style": {
+            "navigationBarTitleText": "支付结果"
+          }
+        }
+      ]
+    },
+```
+
+- [ ] **步骤 2:JSON 语法校验**
+
+运行:`node -e "JSON.parse(require('fs').readFileSync('cfc-frontend/pages.json','utf8'));console.log('pages.json OK')"`
+预期:`pages.json OK`
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/pages.json
+git commit -m "feat: pages.json 注册会员 pay/result 页面"
+```
+
+---
+
+## 任务 8:整体验证与收尾
+
+- [ ] **步骤 1:后端编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+- [ ] **步骤 2:前端语法校验(三个改动页面 + api.js + pages.json)**
+
+依次对 `upgrade.vue`、`pay.vue`、`result.vue` 提取 script 块做 `node --check`;对 `pages.json` 做 JSON.parse。
+预期:全部无错误。
+
+- [ ] **步骤 3:自查设计文档覆盖度**
+
+对照 `docs/superpowers/specs/2026-08-09-membership-pay-upgrade-design.md` 第 6 节改动清单逐项确认:
+- [x] DatabaseInitializer 原价键(任务 1)
+- [x] MembershipController plans 原价字段(任务 2)
+- [x] api.js createOrder couponId(任务 3)
+- [x] upgrade.vue 改造(任务 4)
+- [x] pay.vue 新增(任务 5)
+- [x] result.vue 新增(任务 6)
+- [x] pages.json 注册(任务 7)
+
+- [ ] **步骤 4:最终 Commit(如有遗漏改动)**
+
+```bash
+git add -A cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java cfc-frontend/utils/api.js cfc-frontend/pages/membership/ cfc-frontend/pages.json
+git status
+git commit -m "feat: 会员支付升级页面上线(upgrade→pay→result三段式+原价+优惠券修复)"
+```
+
+> 注意:`git add -A <paths>` 只添加列出的路径,不会带入工作区其他未提交改动(如他人 Product 相关修改)。提交前必须 `git diff --cached --stat` 核对仅含目标文件。
+
+---
+
+## 自检记录(编写时已执行)
+
+**1. 规格覆盖度:**
+- 设计第 3.1 sys_config 原价键 → 任务 1 ✅
+- 设计第 3.2 plans 接口原价字段 → 任务 2 ✅
+- 设计第 4.1 upgrade 改造(移除支付/优惠券、下一步、修 bug、划线原价)→ 任务 4 ✅
+- 设计第 4.2 pay.vue(明细/优惠券 Picker/拆单提示/支付调用)→ 任务 5 ✅
+- 设计第 4.3 result.vue(成功+赠券/失败+续付)→ 任务 6 ✅
+- 设计第 4.4 划线原价展示规则 → 任务 4 步骤 4 + 任务 5 步骤 1(showOriginal)✅
+- 设计第 6 改动清单 api.js couponId → 任务 3 ✅
+- pages.json 注册 → 任务 7 ✅
+
+**2. 占位符扫描:** 无「待定/TODO/后续实现」;所有步骤含完整代码;命令精确。
+
+**3. 类型一致性:**
+- `getOriginalPriceForPeriod` 返回分(int),比较用 `original > current * 100`(current 为元字符串 → 注意:`getPriceForPeriod` 返回 `(plan.yearly / 100).toFixed(0)` 是**元字符串**!showOriginalPrice 中 `current * 100` 会把字符串隐式转数字再乘——正确。但更稳妥:直接用 plan 原始分值比较。实现时可改为读取 plan 原始字段,见下方备注)
+- 备注:任务 4 步骤 2 的 `showOriginalPrice` 中 `current` 来自 `getPriceForPeriod()`(元字符串),`original > current * 100` 数学上等价于「原价分 > 现价分」✅;`getOriginalPriceForPeriod` 返回分,模板中 `getOriginalPriceForPeriod(p.key) / 100` 显示元 ✅
+- pay.vue:`price` 存分(从 `levels[i].yearly` 取,plans 接口返回分),`priceText = (price/100).toFixed(2)` ✅;`originalPrice` 存分,`showOriginal` 用 `originalPrice > price` 比较 ✅;`couponDiscount` 折扣券按现价比例、满减按 value(分)✅;`finalPrice = price - couponDiscount`(分)✅
+- `createOrder(levelCode, 'pay', period, couponId)` 与任务 3 签名一致 ✅
+- `requestVirtualPayment(dto)` 接收 VirtualPayParamsDTO(orderInfo/sign/signature/currentPeriod/totalPeriods)✅
+- `nextOrder({orderNo})` 返回 `{done}` 或新支付参数 ✅
+- result.vue `getJoinCouponRules()` 返回 `grantLevelCode/grantQuantity/name` ✅(与 upgrade.vue 现有用法一致)
+- `uni.redirectTo` 用于 result 返回,避免页面栈堆积 ✅
+
+**4. 小程序限制合规:** 无 `?.`、无 CSS Grid(全 flex)、`:key` 用 `coupon.id`(纯属性,无表达式)✅、日期用 `parseDate()` ✅、Vue 2 Options API ✅。