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

merge: COUPON-C3 领券中心数据源改为可取券模板(B1 修复)合入 cfclub

- 新增 POST /api/coupon/available-templates(CouponService.listAvailableTemplates)
- coupon-center.vue 数据源 getCouponList→getAvailableCouponTemplates,claim 传 item.id
- pages.json 注册 coupon-center + coupons.vue 空状态跳转 + api.js 封装
- API_REFERENCE.md 登记新端点
Agnes Agent 1 неделя назад
Родитель
Сommit
c5ce5e02e7

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CouponController.java

@@ -106,6 +106,16 @@ public class CouponController {
         return Result.success(couponService.listJoinRules());
         return Result.success(couponService.listJoinRules());
     }
     }
 
 
+    /**
+     * 领券中心:当前家庭可领取的券模板(COUPON-C3)。
+     * 数据源为「可取券模板」——上架、有效期内、未领完、非自动发放、全平台、本家庭未领取;
+     * 与 /claim 的去重口径对齐,保证「立即领取」不 500。
+     */
+    @PostMapping("/available-templates")
+    public Result<List<Coupon>> availableTemplates(@RequestAttribute("userId") Long userId) {
+        return Result.success(couponService.listAvailableTemplates(userId));
+    }
+
     @PostMapping("/my")
     @PostMapping("/my")
     public Result<List<Map<String, Object>>> my(@RequestAttribute("userId") Long userId) {
     public Result<List<Map<String, Object>>> my(@RequestAttribute("userId") Long userId) {
         return Result.success(couponService.listMyCoupons(userId));
         return Result.success(couponService.listMyCoupons(userId));

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -673,6 +673,53 @@ public class CouponService {
                         .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
                         .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
     }
     }
 
 
+    /**
+     * 领券中心:当前家庭可领取的券模板(COUPON-C3 修复,数据源替代 /api/coupon/list)。
+     *
+     * 与 {@link #claim(Long, Long)} 的去重口径完全对齐——同一家庭同一券模板只发一次
+     * (family_coupon 存在即视为已领取),保证「立即领取」不会命中 claim 的「已领取过该优惠券」。
+     *
+     * 过滤口径(全部满足):
+     * 1. 券模板状态 ACTIVE(status 为 NULL 视为 ACTIVE)
+     * 2. 在有效期内(valid_from <= now <= valid_until)
+     * 3. 未领完(total_count 为空/<=0 视为不限量,否则 used_count < total_count)
+     * 4. 非自动发放(grant_type 为 NULL,排除 JOIN/PERIODIC/POPULATION/REGISTER/EXCHANGE 等)
+     * 5. 全平台模板(family_id 为 NULL,排除家庭专属模板)
+     * 6. 本家庭尚未领取(family_coupon 无该 couponId 记录,任意状态)
+     */
+    public List<Coupon> listAvailableTemplates(Long userId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
+        Date now = new Date();
+        List<Long> ownedIds = familyCouponMapper.selectList(
+                        new LambdaQueryWrapper<FamilyCoupon>()
+                                .eq(FamilyCoupon::getFamilyId, familyId))
+                .stream().map(FamilyCoupon::getCouponId).distinct().collect(java.util.stream.Collectors.toList());
+        List<Coupon> candidates = couponMapper.selectList(
+                new LambdaQueryWrapper<Coupon>()
+                        .isNull(Coupon::getGrantType)
+                        .isNull(Coupon::getFamilyId)
+                        .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE"))
+                        .le(Coupon::getValidFrom, now)
+                        .ge(Coupon::getValidUntil, now)
+                        .notIn(!ownedIds.isEmpty(), Coupon::getId, ownedIds)
+                        .orderByAsc(Coupon::getId));
+        List<Coupon> result = new ArrayList<>();
+        for (Coupon c : candidates) {
+            Integer total = c.getTotalCount();
+            Integer used = c.getUsedCount();
+            // total_count 为空或 <=0 视为不限量
+            if (total == null || total <= 0) {
+                result.add(c);
+                continue;
+            }
+            if (used == null || used < total) {
+                result.add(c);
+            }
+        }
+        return result;
+    }
+
     /**
     /**
      * 我的优惠券:返回 family_coupon + coupon 联合数据,含 status/receivedAt/usedAt
      * 我的优惠券:返回 family_coupon + coupon 联合数据,含 status/receivedAt/usedAt
      * 对外字段名保持不变(userCouponId = family_coupon.id),前端不感知内部变化
      * 对外字段名保持不变(userCouponId = family_coupon.id),前端不感知内部变化

+ 6 - 0
cfc-frontend/pages.json

@@ -1794,6 +1794,12 @@
             "navigationBarTitleText": "我的优惠券"
             "navigationBarTitleText": "我的优惠券"
           }
           }
         },
         },
+        {
+          "path": "coupon-center",
+          "style": {
+            "navigationBarTitleText": "领券中心"
+          }
+        },
         {
         {
           "path": "gates",
           "path": "gates",
           "style": {
           "style": {

+ 281 - 0
cfc-frontend/pages/profile-extra/coupon-center.vue

@@ -0,0 +1,281 @@
+<template>
+  <view class="page" :style="{ '--font-scale': fontScale }">
+    <view v-if="loading" class="loading-wrap">
+      <text class="loading-text">加载中...</text>
+    </view>
+    <view v-else-if="couponList.length === 0" class="empty-state">
+      <text class="empty-icon">🎫</text>
+      <text class="empty-text">暂无可领取的优惠券</text>
+      <text class="empty-hint">平台发放的优惠券将在这里展示,敬请期待</text>
+    </view>
+    <scroll-view v-else scroll-y class="coupon-scroll">
+      <view class="coupon-list">
+        <view v-for="item in couponList" :key="item.id" class="coupon-card">
+          <view class="card-left">
+            <text class="card-value">{{ formatPriceWithSymbol(item.value) }}</text>
+            <text class="card-type-label">{{ getTypeLabel(item.type) }}</text>
+          </view>
+          <view class="card-right">
+            <view class="card-header">
+              <text class="card-name">{{ item.name }}</text>
+            </view>
+            <text class="card-condition">{{ getConditionText(item) }}</text>
+            <text class="card-scope">{{ getScopeText(item.applicableTo) }}</text>
+            <text class="card-expiry">有效期至 {{ formatDate(item.validUntil) }}</text>
+            <button class="claim-btn" @click="handleClaim(item)">立即领取</button>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { getAvailableCouponTemplates, claimCoupon } from '../../utils/api.js'
+import { parseDate } from '../../utils/format.js'
+import fontScaleMixin from '@/mixins/font-scale.js'
+
+export default {
+  mixins: [fontScaleMixin],
+  data() {
+    return {
+      couponList: [],
+      loading: false,
+      claiming: false
+    }
+  },
+  onLoad() {
+    this.loadCoupons()
+    this._onLoadFired = true
+  },
+  onShow() {
+    // 首次进入由 onLoad 加载,避免 onLoad/onShow 双发;返回本页时刷新
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadCoupons()
+    }
+  },
+  methods: {
+    loadCoupons() {
+      var self = this
+      this.loading = true
+      getAvailableCouponTemplates().then(function(res) {
+        if (res && res.code === 200) {
+          self.couponList = res.data || []
+        } else {
+          self.couponList = []
+        }
+      }).catch(function(e) {
+        console.error('加载领券中心失败', e)
+        self.couponList = []
+      }).then(function() {
+        self.loading = false
+      })
+    },
+    handleClaim(item) {
+      if (this.claiming) return
+      var self = this
+      uni.showModal({
+        title: '确认领取',
+        content: '确认领取「' + (item.name || '') + '」优惠券?',
+        success: function(r) {
+          if (!r.confirm) return
+          self.claiming = true
+          // /api/coupon/claim 契约:couponId 为券模板 id(available-templates 返回 List<Coupon>,字段为 id)
+          claimCoupon(item.id).then(function(res) {
+            self.claiming = false
+            if (res.code === 200) {
+              uni.showToast({ title: res.message || '领取成功', icon: 'success' })
+              self.loadCoupons()
+            } else {
+              uni.showToast({ title: res.message || '领取失败', icon: 'none' })
+            }
+          }).catch(function(e) {
+            self.claiming = false
+            uni.showToast({ title: (e && e.message) || '领取失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    getTypeLabel(type) {
+      if (type === 'FIXED') return '满减券'
+      if (type === 'CASH') return '无门槛券'
+      if (type === 'DISCOUNT') return '折扣券'
+      return type || ''
+    },
+    getConditionText(item) {
+      if (item.type === 'DISCOUNT') {
+        return '折扣券 ' + ((item.discountRate || 0) / 100).toFixed(1) + '折'
+      }
+      if (item.minSpend && item.minSpend > 0) {
+        return '满' + (item.minSpend / 100).toFixed(2) + '元可用'
+      }
+      return '无门槛'
+    },
+    getScopeText(applicableTo) {
+      if (applicableTo === 'ALL') return '全品类适用'
+      if (applicableTo === 'MEMBERSHIP') return '会员专享'
+      return applicableTo || '全品类适用'
+    },
+    formatDate(date) {
+      if (!date) return ''
+      var d = parseDate(date)
+      if (!d) return date
+      var y = d.getFullYear()
+      var m = String(d.getMonth() + 1).padStart(2, '0')
+      var day = String(d.getDate()).padStart(2, '0')
+      return y + '.' + m + '.' + day
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.page {
+  min-height: 100vh;
+  background: $facade-fill-2;
+}
+
+.coupon-scroll {
+  height: 100vh;
+}
+
+.loading-wrap {
+  display: flex;
+  justify-content: center;
+  padding-top: 200rpx;
+}
+
+.loading-text {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding-top: 200rpx;
+}
+
+.empty-icon {
+  font-size: calc(80rpx * var(--font-scale, 1));
+  margin-bottom: 20rpx;
+}
+
+.empty-text {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
+
+.empty-hint {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-ghost;
+  margin-top: 12rpx;
+}
+
+.coupon-list {
+  padding: 20rpx;
+}
+
+.coupon-card {
+  display: flex;
+  background: $app-surface;
+  border-radius: 16rpx;
+  margin-bottom: 20rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+
+.card-left {
+  width: 200rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
+  padding: 30rpx 12rpx;
+  flex-shrink: 0;
+}
+
+.card-value {
+  font-size: calc(40rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $app-surface;
+}
+
+.card-type-label {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: rgba(255, 255, 255, 0.8);
+  margin-top: 8rpx;
+}
+
+.card-right {
+  flex: 1;
+  padding: 24rpx 24rpx 20rpx;
+  position: relative;
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 10rpx;
+}
+
+.card-name {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-strong;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-condition {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  display: block;
+  margin-bottom: 6rpx;
+}
+
+.card-scope {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $uni-info;
+  background: $tint-sky-ghost;
+  display: inline-block;
+  padding: 2rpx 12rpx;
+  border-radius: 8rpx;
+  margin-bottom: 6rpx;
+}
+
+.card-expiry {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-ghost-light;
+  display: block;
+}
+
+.claim-btn {
+  position: absolute;
+  right: 24rpx;
+  bottom: 20rpx;
+  background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
+  color: $app-surface;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  padding: 10rpx 28rpx;
+  border-radius: 28rpx;
+  border: none;
+  line-height: 1.4;
+  height: auto;
+}
+
+.claim-btn::after {
+  border: none;
+}
+
+.claim-btn:active {
+  opacity: 0.85;
+}
+</style>

+ 12 - 1
cfc-frontend/pages/profile-extra/coupons.vue

@@ -57,7 +57,7 @@
       <view v-else-if="filteredList.length === 0" class="empty-state">
       <view v-else-if="filteredList.length === 0" class="empty-state">
         <text class="empty-icon">🎫</text>
         <text class="empty-icon">🎫</text>
         <text class="empty-text">{{ tabIndex === 0 ? '暂无可用优惠券' : tabIndex === 1 ? '暂无已使用优惠券' : '暂无已过期优惠券' }}</text>
         <text class="empty-text">{{ tabIndex === 0 ? '暂无可用优惠券' : tabIndex === 1 ? '暂无已使用优惠券' : '暂无已过期优惠券' }}</text>
-        <text class="empty-hint" v-if="tabIndex === 0">去领券中心看看有哪些优惠吧</text>
+        <text class="empty-hint empty-hint-link" v-if="tabIndex === 0" @click="goToCouponCenter">去领券中心看看有哪些优惠吧 ›</text>
       </view>
       </view>
       <view v-else class="coupon-list">
       <view v-else class="coupon-list">
         <view
         <view
@@ -238,6 +238,9 @@ export default {
     goGiftCoupon() {
     goGiftCoupon() {
       uni.navigateTo({ url: '/pages/membership/gift-coupon' })
       uni.navigateTo({ url: '/pages/membership/gift-coupon' })
     },
     },
+    goToCouponCenter() {
+      uni.navigateTo({ url: '/pages/profile-extra/coupon-center' })
+    },
     async handleClaim(coupon) {
     async handleClaim(coupon) {
       try {
       try {
         var res = await claimCoupon(coupon.id)
         var res = await claimCoupon(coupon.id)
@@ -368,6 +371,14 @@ export default {
   margin-top: 12rpx;
   margin-top: 12rpx;
 }
 }
 
 
+.empty-hint-link {
+  color: $brand-primary;
+}
+
+.empty-hint-link:active {
+  opacity: 0.8;
+}
+
 .coupon-list {
 .coupon-list {
   padding: 20rpx;
   padding: 20rpx;
 }
 }

+ 5 - 0
cfc-frontend/utils/api.js

@@ -2648,6 +2648,11 @@ export const getJoinCouponRules = () => {
   return request('/api/coupon/join-rules', 'POST', {})
   return request('/api/coupon/join-rules', 'POST', {})
 }
 }
 
 
+// 领券中心:当前家庭可领取的券模板(COUPON-C3,与 /claim 去重口径对齐)
+export const getAvailableCouponTemplates = () => {
+  return request('/api/coupon/available-templates', 'POST', {})
+}
+
 // ===== 会员优惠券转赠 =====
 // ===== 会员优惠券转赠 =====
 export const getGiftRecipients = () => {
 export const getGiftRecipients = () => {
   return request('/api/coupon/gift/recipients', 'POST', {})
   return request('/api/coupon/gift/recipients', 'POST', {})

+ 1 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1063,6 +1063,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/coupon/apply` | 下单抵扣(家庭券) |
 | `POST /api/coupon/apply` | 下单抵扣(家庭券) |
 | `POST /api/coupon/claim` | 领取优惠券(发到家庭) |
 | `POST /api/coupon/claim` | 领取优惠券(发到家庭) |
 | `POST /api/coupon/checkout-list` | 结算页可兑换未拥有券 |
 | `POST /api/coupon/checkout-list` | 结算页可兑换未拥有券 |
+| `POST /api/coupon/available-templates` | 领券中心可取券模板:上架(status 空/ACTIVE)、有效期内、未领完(total_count 空/≤0 视为不限量,否则 used_count<total_count)、grant_type 为空(非自动发放/兑换)、family_id 为空(全平台模板)、本家庭未领取(family_coupon 任意状态有记录即排除)。返回 `List<Coupon>`(字段为 `id` 等模板字段),与 `/claim` 去重口径对齐(COUPON-C3) |
 
 
 ---
 ---