瀏覽代碼

feat(US-4.4): wave6 upgrade pricing flow

- backend: add POST /api/pricing/upgrade to calculate upgrade price
- frontend: detect upgrade scenario on payment page (annual->practitioner)
- frontend: display upgrade breakdown (original, remaining, final)
- frontend: pass isUpgrade flag to pay/create for commission handling
- OrderRepository: add method findByUserIdAndProductTypeAndStatusOrderByPaidAtDesc
- ensure profile fetch before upgrade detection
liaoxg 3 月之前
父節點
當前提交
6de0f56115

+ 96 - 11
client/pages/payment/index.vue

@@ -46,11 +46,26 @@
             <text class="price">{{ formatYuan(currentPrice) }}</text>
             <text class="per">/年</text>
           </view>
-          <view v-if="pricing.isSeedPrice" class="seed-badge">
-            种子价 · 仅剩 {{ pricing.remainingSeats }}/{{ pricing.seedLimit }} 个名额
-          </view>
-          <text v-else class="standard-label">标准定价</text>
-          <text class="price-daily">每天仅 ¥{{ (currentPrice / 100 / 365).toFixed(2) }}</text>
+      <view v-if="pricing.isSeedPrice" class="seed-badge">
+        种子价 · 仅剩 {{ pricing.remainingSeats }}/{{ pricing.seedLimit }} 个名额
+      </view>
+      <text v-else class="standard-label">标准定价</text>
+      <!-- US-4.4 Upgrade breakdown -->
+      <view v-if="isUpgrade" class="upgrade-info">
+        <view class="upgrade-row">
+          <text class="upgrade-label">原年费金额</text>
+          <text class="upgrade-value">¥{{ formatYuan(pricing.originalPaidAmount) }}</text>
+        </view>
+        <view class="upgrade-row">
+          <text class="upgrade-label">年费剩余价值</text>
+          <text class="upgrade-value minus">-¥{{ formatYuan(pricing.remainingValue) }}</text>
+        </view>
+        <view class="upgrade-row total">
+          <text class="upgrade-label">应付差价</text>
+          <text class="upgrade-value highlight">¥{{ formatYuan(pricing.upgradePrice) }}</text>
+        </view>
+      </view>
+      <text class="price-daily">每天仅 ¥{{ (currentPrice / 100 / 365).toFixed(2) }}</text>
         </view>
         <view class="plan-features">
           <text class="feature">✦ 包含 C 端全部权益</text>
@@ -72,11 +87,15 @@
 
 <script setup>
 import { ref, computed, onMounted } from 'vue'
+import { useUserStore } from '@/stores/user'
 import { payApi, pricingApi } from '@/utils/api'
 
+const userStore = useUserStore()
+
 const paying = ref(false)
 const loading = ref(true)
 const product = ref('annual')
+const isUpgrade = ref(false)
 const pricing = ref({
   seedPrice: 131400,
   standardPrice: 198600,
@@ -95,9 +114,11 @@ const pageDesc = computed(() => {
 
 const currentPrice = computed(() => {
   if (product.value === 'annual') return pricing.value.annualFee
+  if (isUpgrade.value) return pricing.value.upgradePrice
   return pricing.value.isSeedPrice ? pricing.value.seedPrice : pricing.value.standardPrice
 })
 const priceLabel = computed(() => {
+  if (isUpgrade.value) return '升级价'
   return pricing.value.isSeedPrice ? '种子价' : '标准价'
 })
 
@@ -107,17 +128,38 @@ function formatYuan(cents) {
 }
 
 onMounted(async () => {
+  // Ensure user profile is loaded (vipType, etc.)
+  try {
+    await userStore.fetchProfile()
+  } catch (e) {
+    // ignore, use cached
+  }
+
   // Read product param from URL
   const pages = getCurrentPages()
   const page = pages[pages.length - 1]
   const params = page?.options || {}
   product.value = params.product || 'annual'
 
-  try {
-    const res = await pricingApi.current()
-    if (res) pricing.value = res
-  } catch (e) {
-    // Use defaults if API fails
+  // Determine upgrade scenario: annual member upgrading to practitioner
+  if (product.value === 'practitioner' && userStore.vipType === 'annual') {
+    isUpgrade.value = true
+    try {
+      const res = await pricingApi.upgrade()
+      if (res) pricing.value = { ...pricing.value, ...res }
+    } catch (e) {
+      // Fallback to normal pricing on error
+      isUpgrade.value = false
+    }
+  }
+
+  if (!isUpgrade.value) {
+    try {
+      const res = await pricingApi.current()
+      if (res) pricing.value = res
+    } catch (e) {
+      // Use defaults if API fails
+    }
   }
   loading.value = false
 })
@@ -127,7 +169,12 @@ async function onPay() {
   try {
     const totalFee = currentPrice.value
     const productType = product.value
-    await payApi.create({ totalFee, payType: 'wxpay', productType })
+    await payApi.create({
+      totalFee,
+      payType: 'wxpay',
+      productType,
+      isUpgrade: isUpgrade.value
+    })
     uni.showToast({ title: '开通成功!', icon: 'success' })
     setTimeout(() => uni.navigateBack(), 1500)
   } catch (e) {
@@ -263,6 +310,44 @@ async function onPay() {
   border: 1px solid rgba(255,255,255,0.1);
 }
 
+/* US-4.4 Upgrade breakdown */
+.upgrade-info {
+  margin-top: 12px;
+  padding: 10px 12px;
+  background: rgba(255,255,255,0.04);
+  border-radius: 8px;
+  border: 1px dashed rgba(255,255,255,0.15);
+}
+.upgrade-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 13px;
+  margin-bottom: 6px;
+}
+.upgrade-row:last-child {
+  margin-bottom: 0;
+}
+.upgrade-row.total {
+  margin-top: 6px;
+  padding-top: 6px;
+  border-top: 1px solid rgba(255,255,255,0.1);
+}
+.upgrade-label {
+  color: rgba(255,255,255,0.5);
+}
+.upgrade-value {
+  color: rgba(255,255,255,0.9);
+  font-weight: 500;
+}
+.upgrade-value.minus {
+  color: #fbbf24;
+}
+.upgrade-value.highlight {
+  color: #a78bfa;
+  font-weight: 600;
+}
+
 .plan-features {
   margin-top: 16px;
 }

+ 2 - 1
client/utils/api.js

@@ -81,7 +81,8 @@ export const payApi = {
 }
 
 export const pricingApi = {
-  current: () => requestInstance.post('/pricing/current', {})
+  current: () => requestInstance.post('/pricing/current', {}),
+  upgrade: () => requestInstance.post('/pricing/upgrade', {})
 }
 
 export const profileApi = {

+ 55 - 0
num-server/src/main/java/com/etotem/num/controller/PricingController.java

@@ -1,14 +1,19 @@
 package com.etotem.num.controller;
 
 import com.etotem.num.common.Result;
+import com.etotem.num.entity.Order;
 import com.etotem.num.repository.OrderRepository;
 import com.etotem.num.service.ConfigService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import javax.servlet.http.HttpServletRequest;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 
 /**
  * US-8.1 §9-10: Pricing API for mini-program frontend.
@@ -47,4 +52,54 @@ public class PricingController {
 
         return Result.success(data);
     }
+
+    /**
+     * US-4.4: Upgrade from annual to practitioner.
+     * Calculates the upgrade price based on remaining annual value.
+     */
+    @PostMapping("/upgrade")
+    public Result<Map<String, Object>> getUpgradePricing(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+
+        // Find the latest paid annual order for this user
+        Optional<Order> annualOrderOpt = orderRepository.findByUserIdAndProductTypeAndStatusOrderByPaidAtDesc(userId, "annual", "paid");
+
+        // Get current practitioner pricing
+        int seedPrice = configService.getInt("pricing.practitioner.seed", 131400);
+        int standardPrice = configService.getInt("pricing.practitioner.standard", 198600);
+        long paidPractitioners = orderRepository.countByProductTypeAndStatus("practitioner", "paid");
+        int seedLimit = configService.getInt("pricing.practitioner.seed_limit", 300);
+        boolean isSeedPrice = paidPractitioners < seedLimit;
+        int targetPrice = isSeedPrice ? seedPrice : standardPrice;
+
+        int originalPaidAmount = 0;
+        long usedDays = 0;
+        int remainingValue = 0;
+        int upgradePrice = targetPrice;
+
+        if (annualOrderOpt.isPresent()) {
+            Order annualOrder = annualOrderOpt.get();
+            originalPaidAmount = annualOrder.getTotalFee();
+            LocalDateTime paidAt = annualOrder.getPaidAt();
+            LocalDateTime now = LocalDateTime.now();
+            usedDays = ChronoUnit.DAYS.between(paidAt, now);
+            if (usedDays > 365) usedDays = 365;
+            // Remaining value: proportional to unused days
+            remainingValue = (int) (originalPaidAmount * (365 - usedDays) / 365L);
+            upgradePrice = targetPrice - remainingValue;
+            if (upgradePrice <= 0) upgradePrice = 1; // minimum 1 cent
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("originalPaidAmount", originalPaidAmount);
+        data.put("usedDays", usedDays);
+        data.put("remainingValue", remainingValue);
+        data.put("practitionerSeedPrice", seedPrice);
+        data.put("practitionerStandardPrice", standardPrice);
+        data.put("isSeedPrice", isSeedPrice);
+        data.put("upgradePrice", upgradePrice);
+        data.put("annualFee", originalPaidAmount); // for reference
+
+        return Result.success(data);
+    }
 }

+ 2 - 0
num-server/src/main/java/com/etotem/num/repository/OrderRepository.java

@@ -4,6 +4,7 @@ import com.etotem.num.entity.Order;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.stereotype.Repository;
 
+import java.time.LocalDateTime;
 import java.util.Optional;
 
 @Repository
@@ -11,4 +12,5 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
     Optional<Order> findByOutTradeNo(String outTradeNo);
     long countByProductTypeAndStatus(String productType, String status);
     long countByProductType(String productType);
+    Optional<Order> findByUserIdAndProductTypeAndStatusOrderByPaidAtDesc(Long userId, String productType, String status);
 }