Procházet zdrojové kódy

merge: COUPON-P2 技术债打包合入 cfclub(解 AdminCouponController + CouponService 冲突)

- AdminCouponController:@PreAuthorize 统一权限 + list 真分页/pageCoupons + familyMapper/fillFamilyNames 家庭名批量填充 + CouponGiftService/gift-transfers 保留
- CouponService:pageCoupons 分页 + applicableTo null→ALL 修复 + C2 LambdaUpdateWrapper used_count 修复并存
- 冲突解决:import 区 AdminCouponController(Family/CouponGiftService 保留)+ CouponService(LambdaUpdateWrapper + Page 保留)
Agnes Agent před 1 týdnem
rodič
revize
3a0d93bf72

+ 6 - 0
cfc-backend/pom.xml

@@ -114,6 +114,12 @@
             <artifactId>spring-security-crypto</artifactId>
         </dependency>
 
+        <!-- Spring Security(方法级 @PreAuthorize 权限控制,替代控制器内手写 if(!role) 检查) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+
         <!-- Quartz 调度(任务编排轮询) -->
         <dependency>
             <groupId>org.springframework.boot</groupId>

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/GlobalExceptionHandler.java

@@ -7,6 +7,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.security.access.AccessDeniedException;
 import org.springframework.web.HttpRequestMethodNotSupportedException;
 import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
@@ -87,6 +88,16 @@ public class GlobalExceptionHandler {
         return null;
     }
 
+    /**
+     * COUPON-P2 ③:方法级权限校验失败(@PreAuthorize)。
+     * 转换为 403 + "无权限",与原「控制器内手写 if(!admin) 返回 Result.error("无权限")」的前端体验保持一致。
+     */
+    @ExceptionHandler(AccessDeniedException.class)
+    public Result<Void> handleAccessDenied(AccessDeniedException e, HttpServletRequest request) {
+        log.warn("权限校验失败: uri={}, error={}", request.getRequestURI(), e.getMessage());
+        return Result.error(403, "无权限");
+    }
+
     @ExceptionHandler(Exception.class)
     public Result<Void> handleException(Exception e, HttpServletRequest request) {
         log.error("未捕获异常: {} - {} | uri={}", e.getClass().getName(), e.getMessage(), request.getRequestURI(), e);

+ 44 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -7,14 +7,19 @@ import com.etotem.cfc.mapper.UserMapper;
 import io.jsonwebtoken.Claims;
 import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Component;
-import java.util.Collections;
-import java.util.List;
 import org.springframework.util.StringUtils;
 import org.springframework.web.servlet.HandlerInterceptor;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 
 @Slf4j
 @Component
@@ -95,6 +100,9 @@ private static final String[] PUBLIC_PATHS = {
             return true;
         }
 
+        // 每次请求从干净上下文开始:避免线程池复用导致上一请求的 SecurityContext 泄漏
+        SecurityContextHolder.clearContext();
+
         String path = request.getRequestURI();
         for (String publicPath : PUBLIC_PATHS) {
             String boundaryPath = publicPath.endsWith("/**")
@@ -129,6 +137,7 @@ private static final String[] PUBLIC_PATHS = {
             }
             log.debug("请求 {} 无Token,设为匿名用户", path);
             setAnonymousUser(request);
+            setAnonymousSecurityContext();
             return true;
         }
 
@@ -155,6 +164,21 @@ private static final String[] PUBLIC_PATHS = {
             request.setAttribute("role", role);
             request.setAttribute("roles", roles);
 
+            // COUPON-P2 ③:把角色列表发布到 Spring Security 上下文,供 @PreAuthorize 方法级校验使用
+            try {
+                List<GrantedAuthority> authorities = new ArrayList<>(roles.size());
+                for (String r : roles) {
+                    if (StringUtils.hasText(r)) {
+                        authorities.add(new SimpleGrantedAuthority("ROLE_" + r));
+                    }
+                }
+                Authentication auth = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
+                        userId, null, authorities);
+                SecurityContextHolder.getContext().setAuthentication(auth);
+            } catch (Exception e) {
+                log.warn("SecurityContext 设置失败(非阻塞,仅 @PreAuthorize 权限失效): {}", e.getMessage());
+            }
+
  // 设置familyId(查询用户所属家庭)
             try {
                 User user = userMapper.selectById(userId);
@@ -226,4 +250,22 @@ private static final String[] PUBLIC_PATHS = {
         // familyId 属性必须始终存在,避免 @RequestAttribute("familyId") 抛 ServletRequestBindingException
         request.setAttribute("familyId", null);
     }
+
+    /** 匿名请求:发布仅有 ROLE_ANONYMOUS 权限的上下文,使 @PreAuthorize("hasRole('admin')") 对匿名用户一律拒绝 */
+    private void setAnonymousSecurityContext() {
+        try {
+            Authentication auth = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
+                    -1L, null,
+                    Collections.singletonList(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
+            SecurityContextHolder.getContext().setAuthentication(auth);
+        } catch (Exception e) {
+            log.warn("匿名 SecurityContext 设置失败(非阻塞): {}", e.getMessage());
+        }
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        // 请求结束后清理,防止线程池复用泄漏上一请求的权限到下一请求
+        SecurityContextHolder.clearContext();
+    }
 }

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/SecurityConfig.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * 安全配置(COUPON-P2 ③)
+ *
+ * 认证仍由 {@link JwtInterceptor} 完成(请求属性 + SecurityContextHolder 填充 ROLE_<role> 权限),
+ * 因此这里保持 STATELESS + 全放行:Spring Security 不拦截任何 HTTP 请求,
+ * 仅启用方法级 {@code @PreAuthorize},把「控制器内手写 if(!admin)」替换为声明式权限检查。
+ *
+ * 不改变既有接口行为:匿名/登录用户都能到达 Controller,只有标注了 @PreAuthorize 的方法
+ * 才在进入前校验权限;无权限时抛出 AccessDeniedException,由 GlobalExceptionHandler
+ * 统一转换为 Result.error(403, "无权限")。
+ */
+@Configuration
+@EnableWebSecurity
+@EnableGlobalMethodSecurity(prePostEnabled = true)
+public class SecurityConfig {
+
+    @Bean
+    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+        http
+                .csrf().disable()
+                .httpBasic().disable()
+                .formLogin().disable()
+                .logout().disable()
+                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+                .and()
+                .authorizeRequests()
+                .anyRequest().permitAll();
+        return http.build();
+    }
+
+    /** BCrypt 编码器(spring-security-crypto 已有使用,此处显式暴露给方法安全/测试用) */
+    @Bean
+    public PasswordEncoder passwordEncoder() {
+        return new BCryptPasswordEncoder();
+    }
+}

+ 73 - 62
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -4,13 +4,17 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
-import com.etotem.cfc.service.CouponGiftService;
 import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.service.CouponGiftService;
 import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
 import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.service.CouponService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
@@ -21,8 +25,6 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import com.etotem.cfc.util.SortUtil;
-import com.etotem.cfc.util.ParamUtils;
 
 @RestController
 @RequestMapping("/api/admin/coupon")
@@ -43,25 +45,35 @@ public class AdminCouponController {
     @Resource
     private CouponGiftService couponGiftService;
 
+    @Resource
+    private FamilyMapper familyMapper;
+
+    /**
+     * 优惠券模板分页列表(COUPON-P2 ①:支持 keyword 搜索 + 真实分页 + 排序)。
+     * 请求: { page, size, keyword?, sort? }
+     * 返回: Result<Page<Coupon>>(records/total/current/size)
+     */
     @PostMapping("/list")
-    public Result<List<Coupon>> list(@RequestBody(required = false) Map<String, Object> params,
-                                     @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<Page<Coupon>> list(@RequestBody(required = false) Map<String, Object> params) {
+        String keyword = params != null && params.get("keyword") != null
+                ? params.get("keyword").toString().trim() : null;
+        int page = params != null && params.get("page") != null
+                ? Integer.parseInt(params.get("page").toString()) : 1;
+        int size = params != null && params.get("size") != null
+                ? Integer.parseInt(params.get("size").toString()) : 20;
+        if (page < 1) page = 1;
+        if (size < 1) size = 20;
+        if (size > 999) size = 999;
         @SuppressWarnings("unchecked")
         List<Map<String, String>> sortSpecs = params != null && params.get("sort") != null
-                ? (List<Map<String, String>>) params.get("sort")
-                : null;
-        return Result.success(couponService.listAll(sortSpecs));
+                ? (List<Map<String, String>>) params.get("sort") : null;
+        return Result.success(couponService.pageCoupons(keyword, page, size, sortSpecs));
     }
 
     @PostMapping("/create")
-    public Result<String> create(@RequestBody Coupon coupon,
-                                 @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<String> create(@RequestBody Coupon coupon) {
         if (coupon.getType() == null) coupon.setType("FIXED");
         if (coupon.getMinSpend() == null) coupon.setMinSpend(0);
         if (coupon.getApplicableTo() == null) coupon.setApplicableTo("ALL");
@@ -72,21 +84,15 @@ public class AdminCouponController {
     }
 
     @PostMapping("/update")
-    public Result<String> update(@RequestBody Coupon coupon,
-                                 @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<String> update(@RequestBody Coupon coupon) {
         couponMapper.updateById(coupon);
         return Result.success("更新成功");
     }
 
     @PostMapping("/issue")
-    public Result<String> issue(@RequestBody Map<String, Object> params,
-                                @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<String> issue(@RequestBody Map<String, Object> params) {
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         Object familyIdsObj = params.get("familyIds");
         if (familyIdsObj instanceof List) {
@@ -100,11 +106,8 @@ public class AdminCouponController {
 
     /** 单家庭发放(家庭管理页使用) */
     @PostMapping("/issue-family")
-    public Result<String> issueFamily(@RequestBody Map<String, Object> params,
-                                      @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<String> issueFamily(@RequestBody Map<String, Object> params) {
         Long familyId = ParamUtils.getLong(params.get("familyId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         Integer quantity = params.get("quantity") == null ? 1 : Integer.valueOf(params.get("quantity").toString());
@@ -125,11 +128,8 @@ public class AdminCouponController {
      * 后端自动创建会员专属券模板并发放到该用户所在家庭。
      */
     @PostMapping("/grant-user")
-    public Result<Map<String, Object>> grantUser(@RequestBody Map<String, Object> params,
-                                                 @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<Map<String, Object>> grantUser(@RequestBody Map<String, Object> params) {
         Long userId = ParamUtils.getLong(params.get("userId"));
         Integer amountFen = null;
         if (params.get("amount") != null) {
@@ -160,22 +160,16 @@ public class AdminCouponController {
     }
 
     @PostMapping("/delete")
-    public Result<String> delete(@RequestBody Map<String, Object> params,
-                                 @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
         Long id = ParamUtils.getLong(params.get("id"));
         couponMapper.deleteById(id);
         return Result.success("删除成功");
     }
 
     @PostMapping("/toggle-status")
-    public Result<String> toggleStatus(@RequestBody Map<String, Object> params,
-                                       @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<String> toggleStatus(@RequestBody Map<String, Object> params) {
         Long id = ParamUtils.getLong(params.get("id"));
         String status = params.get("status") == null ? null : params.get("status").toString();
         if (id == null || !("ACTIVE".equals(status) || "DISABLED".equals(status))) {
@@ -190,12 +184,13 @@ public class AdminCouponController {
         return Result.success("ACTIVE".equals(status) ? "启用成功" : "停用成功");
     }
 
+    /**
+     * 发券流水(COUPON-P2 ②:批量填充家庭名,消除前端逐个请求 getFamilyDetail 的 N+1)。
+     * 返回 List<FamilyCouponGrantLog>,每条带 familyName(batch 一次查 families 表)。
+     */
     @PostMapping("/grant-log")
-    public Result<List<FamilyCouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
-                                                       @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<List<FamilyCouponGrantLog>> grantLog(@RequestBody Map<String, Object> params) {
         Long familyId = ParamUtils.getLong(params.get("familyId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         String grantType = params.get("grantType") == null ? null : params.get("grantType").toString();
@@ -214,7 +209,29 @@ public class AdminCouponController {
             wrapper.eq(FamilyCouponGrantLog::getSource, source);
         }
         wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
-        return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
+        List<FamilyCouponGrantLog> logs = familyCouponGrantLogMapper.selectList(wrapper);
+        fillFamilyNames(logs);
+        return Result.success(logs);
+    }
+
+    /** 批量填充发券流水中的家庭名(单次 selectBatchIds,替代逐条查询) */
+    private void fillFamilyNames(List<FamilyCouponGrantLog> logs) {
+        if (logs == null || logs.isEmpty()) return;
+        Set<Long> familyIds = new LinkedHashSet<>();
+        for (FamilyCouponGrantLog log : logs) {
+            if (log.getFamilyId() != null) familyIds.add(log.getFamilyId());
+        }
+        Map<Long, String> nameMap = new HashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<Family> families = familyMapper.selectBatchIds(familyIds);
+            for (Family f : families) {
+                nameMap.put(f.getId(), f.getName());
+            }
+        }
+        for (FamilyCouponGrantLog log : logs) {
+            String name = log.getFamilyId() == null ? null : nameMap.get(log.getFamilyId());
+            log.setFamilyName(name != null ? name : "家庭" + log.getFamilyId());
+        }
     }
 
     /** 会员券转赠记录(分页,支持按赠送方/接收方家庭、状态、券模板过滤) */
@@ -235,11 +252,8 @@ public class AdminCouponController {
 
     /** 家庭券列表(family_coupon 分页,JOIN coupon 取模板信息) */
     @PostMapping("/family-coupons")
-    public Result<Page<Map<String, Object>>> familyCoupons(@RequestBody Map<String, Object> params,
-                                                           @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<Page<Map<String, Object>>> familyCoupons(@RequestBody Map<String, Object> params) {
         Long familyId = ParamUtils.getLong(params.get("familyId"));
         String status = params.get("status") == null ? null : params.get("status").toString();
         int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
@@ -298,11 +312,8 @@ public class AdminCouponController {
 
     /** 家庭券使用记录(status=USED,JOIN coupon 取模板信息) */
     @PostMapping("/family-coupon-usage")
-    public Result<Page<Map<String, Object>>> familyCouponUsage(@RequestBody Map<String, Object> params,
-                                                               @RequestAttribute("role") String role) {
-        if (!"admin".equals(role)) {
-            return Result.error("无权限");
-        }
+    @PreAuthorize("hasRole('admin')")
+    public Result<Page<Map<String, Object>>> familyCouponUsage(@RequestBody Map<String, Object> params) {
         Long familyId = ParamUtils.getLong(params.get("familyId"));
         int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
         int size = params.get("size") == null ? 20 : Integer.parseInt(params.get("size").toString());

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCouponGrantLog.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -31,4 +32,8 @@ public class FamilyCouponGrantLog implements Serializable {
     private String source;
 
     private Date createdAt;
+
+    /** COUPON-P2 ②:非持久化字段,仅用于发券流水列表展示家庭名(批量填充,消除 N+1) */
+    @TableField(exist = false)
+    private String familyName;
 }

+ 26 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
@@ -240,7 +241,10 @@ public class CouponService {
         if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
             return null;
         }
-        if (!"ALL".equals(coupon.getApplicableTo()) && !coupon.getApplicableTo().equals(orderType)) {
+        // 适用范围为空视为全场通用(COUPON-P2 ⑥:避免 null.equals() NPE)
+        String applicableTo = coupon.getApplicableTo();
+        if (applicableTo == null) applicableTo = "ALL";
+        if (!"ALL".equals(applicableTo) && !applicableTo.equals(orderType)) {
             return null;
         }
         // 商品券只能用于指定商品(防止商品券被用于非预期商品)
@@ -264,7 +268,9 @@ public class CouponService {
         // 返回抵扣金额(调用方以 originalAmount - discount 计算最终价)
         if ("DISCOUNT".equals(coupon.getType())) {
             int rate = coupon.getDiscountRate() != null ? coupon.getDiscountRate() : 9000;
-            int discount = orderAmount * (10000 - rate) / 10000;
+            // COUPON-P2 ⑤:整数除法会截断分位(如 19.99 元 8 折 → 399 而非 400),改为四舍五入
+            // 使用 double 计算避免 orderAmount * (10000 - rate) 的 int 溢出
+            int discount = (int) Math.round(orderAmount * (10000.0 - rate) / 10000.0);
             return Math.max(discount, 0);
         }
         return Math.min(coupon.getValue(), orderAmount);
@@ -331,6 +337,24 @@ public class CouponService {
         return couponMapper.selectList(wrapper);
     }
 
+    /**
+     * 优惠券模板分页查询(COUPON-P2 ①):keyword 模糊搜索(名称/ID)+ 真实分页 + 可选排序。
+     * 无排序条件时按 id 倒序(新建在前)。
+     */
+    public Page<Coupon> pageCoupons(String keyword, int page, int size, List<Map<String, String>> sortSpecs) {
+        LambdaQueryWrapper<Coupon> wrapper = new LambdaQueryWrapper<>();
+        if (keyword != null && !keyword.isEmpty()) {
+            wrapper.and(w -> w.like(Coupon::getName, keyword)
+                    .or().like(Coupon::getId, keyword));
+        }
+        if (sortSpecs != null && !sortSpecs.isEmpty()) {
+            SortUtil.applySort(wrapper, sortSpecs, null);
+        } else {
+            wrapper.orderByDesc(Coupon::getId);
+        }
+        return couponMapper.selectPage(new Page<>(page, size), wrapper);
+    }
+
     public Coupon getById(Long couponId) {
         return couponMapper.selectById(couponId);
     }

+ 1 - 1
cfc-frontend/pages/membership/upgrade.vue

@@ -190,7 +190,7 @@ export default {
     },
     getCouponDesc(item) {
       if (!item) return ''
-      if (item.type === 'DISCOUNT') {
+      if (item.type === 'DISCOUNT' || item.type === 'PERCENT') {
         return '折扣券 ' + (item.discountRate / 100).toFixed(1) + '折'
       }
       return '立减 ' + (item.value / 100).toFixed(2) + '元'

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

@@ -67,7 +67,7 @@
           :class="statusClassMap[coupon.id]"
         >
           <view class="card-left">
-            <text class="card-value">{{ formatPriceWithSymbol(coupon.value) }}</text>
+            <text class="card-value">{{ getCardValue(coupon) }}</text>
             <text class="card-type-label">{{ getTypeLabel(coupon.type) }}</text>
           </view>
           <view class="card-right">
@@ -204,11 +204,17 @@ export default {
       })
     },
     getCouponDesc(item) {
-      if (item.type === 'DISCOUNT') {
+      if (item.type === 'DISCOUNT' || item.type === 'PERCENT') {
         return '折扣券 ' + (item.discountRate / 100).toFixed(1) + '折'
       }
       return '立减 ' + (item.value / 100).toFixed(2) + '元'
     },
+    getCardValue(coupon) {
+      if (coupon.type === 'DISCOUNT' || coupon.type === 'PERCENT') {
+        return (coupon.discountRate / 100).toFixed(1) + '折'
+      }
+      return this.formatPriceWithSymbol(coupon.value)
+    },
     doExchange(item) {
       var self = this
       uni.showModal({
@@ -259,11 +265,15 @@ export default {
       if (applicableTo === 'MEMBERSHIP') {
         return isMember ? '会员专享' : '开通会员后可用'
       }
+      if (applicableTo === 'PRODUCT') return '特定商品'
+      if (applicableTo === 'ASSESSMENT') return '测评服务'
       return applicableTo || '全品类适用'
     },
     getTypeLabel(type) {
       if (type === 'FIXED') return '固定金额'
       if (type === 'PERCENT') return '折扣券'
+      if (type === 'DISCOUNT') return '折扣券'
+      if (type === 'CASH') return '无门槛券'
       return type || ''
     },
     getStatusLabel(coupon) {

+ 1 - 1
cfc-frontend/pages/wealth-cf/index.vue

@@ -271,7 +271,7 @@ export default {
       return map[type] || type
     },
     couponDesc(coupon) {
-      if (coupon.type === 'DISCOUNT') {
+      if (coupon.type === 'DISCOUNT' || coupon.type === 'PERCENT') {
         return (coupon.discountRate / 100).toFixed(1) + '折'
       }
       return '立减' + (coupon.value / 100).toFixed(2) + '元'

+ 0 - 25
cfc-web/src/views/admin/CouponGrantLog.vue

@@ -73,7 +73,6 @@
 import SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
 import { getFamilyCouponGrantLog } from '@/api/coupon'
-import { getFamilyDetail } from '@/api/admin'
 
 export default {
   mixins: [SortMixin, ListStateMixin],
@@ -121,7 +120,6 @@ export default {
         if (res.data) {
           this.list = res.data
           this.total = this.list.length
-          await this.resolveFamilyNames(this.list)
         }
       } catch (e) {
         console.error(e)
@@ -129,29 +127,6 @@ export default {
         this.loading = false
       }
     },
-    async resolveFamilyNames(items) {
-      if (!items || items.length === 0) return
-      var ids = []
-      items.forEach(function(item) {
-        if (item.familyId && ids.indexOf(item.familyId) === -1) ids.push(item.familyId)
-      })
-      var nameMap = {}
-      for (var i = 0; i < ids.length; i++) {
-        try {
-          var res = await getFamilyDetail(ids[i])
-          if (res.data && res.data.name) {
-            nameMap[ids[i]] = res.data.name
-          } else {
-            nameMap[ids[i]] = '家庭' + ids[i]
-          }
-        } catch (e) {
-          nameMap[ids[i]] = '家庭' + ids[i]
-        }
-      }
-      items.forEach(function(item) {
-        item.familyName = nameMap[item.familyId] || '家庭' + item.familyId
-      })
-    },
     handleSearch() {
       this.page = 1
       this.loadData()

+ 1 - 1
cfc-web/src/views/admin/CouponManagement.vue

@@ -289,7 +289,7 @@ export default {
       return map[val] || val
     },
     typeLabel(val) {
-      const map = { FIXED: '满减券', CASH: '无门槛券', DISCOUNT: '折扣券' }
+      const map = { FIXED: '满减券', CASH: '无门槛券', DISCOUNT: '折扣券', PERCENT: '折扣券' }
       return map[val] || val
     },
     grantTypeLabel(val) {

+ 2 - 2
docs/superpowers/api/API_REFERENCE.md

@@ -1050,12 +1050,12 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 | 路径 | 说明 |
 |------|------|
-| `POST /api/admin/coupon/list` | 优惠券模板列表(admin) |
+| `POST /api/admin/coupon/list` | 优惠券模板分页列表(admin),`{ page, size, keyword?, sort? }`,返回 `Page<Coupon>`(records/total/current/size),keyword 对名称/ID 模糊匹配 |
 | `POST /api/admin/coupon/create` | 新建优惠券模板(admin) |
 | `POST /api/admin/coupon/update` | 更新优惠券模板(admin) |
 | `POST /api/admin/coupon/issue` | 批量发放到家庭,`{ couponId, familyIds: [Long] }`(admin) |
 | `POST /api/admin/coupon/issue-family` | 单家庭发放,`{ familyId, couponId, quantity }`(admin,家庭管理页使用) |
-| `POST /api/admin/coupon/grant-log` | 家庭发券流水查询,`{ familyId?, couponId?, grantType?, source? }`(admin,返回 family_coupon_grant_log) |
+| `POST /api/admin/coupon/grant-log` | 家庭发券流水查询,`{ familyId?, couponId?, grantType?, source? }`(admin,返回 family_coupon_grant_log,每行含批量填充的 `familyName`,消除前端逐条查家庭名 N+1) |
 | `POST /api/admin/coupon/grant-user` | 管理员定向发放「会员升级券」给指定用户,`{ userId, amountYuan \| amount, validUntil?, remark? }`(admin,自动创建 applicableTo=MEMBERSHIP 无门槛券模板并发放到该用户家庭,落 grantType=MANUAL 流水) |
 | `POST /api/admin/coupon/toggle-status` | 启用/停用优惠券模板(admin) |
 | `POST /api/coupon/list` | 当前家庭可用优惠券(小程序) |