Ver Fonte

fix(coupon): COUPON-P2 优惠券 P2 技术债打包(核查报告 §四 P2 各项)

① AdminCouponController.list 支持真实分页与搜索:
   - 新增 CouponService.pageCoupons(keyword,page,size,sortSpecs),
     keyword 对名称/ID 模糊匹配,selectPage 真分页,无排序时按 id 倒序
   - /list 改返回 Result<Page<Coupon>>(records/total/current/size),
     size 上限 999、page/size 非法值兜底为 1/20
   - cfc-web CouponManagement.vue 原本已按 records/total 消费,无需改动

② 用户端券类型/适用对象枚举补全 + 发券流水家庭名 N+1 消除:
   - 小程序 coupons.vue:getTypeLabel 补 DISCOUNT/CASH,getScopeText 补
     PRODUCT/ASSESSMENT,getCouponDesc/getCardValue 兼容 PERCENT
     (折扣券不再显示原始值 PERCENT/PRODUCT,卡片面值区折扣券显示「x折」)
   - 小程序 upgrade.vue / wealth-cf/index.vue:券描述兼容 PERCENT
   - 后端 FamilyCouponGrantLog 增 @TableField(exist=false) familyName,
     /api/admin/coupon/grant-log 用 FamilyMapper.selectBatchIds 批量填充
   - cfc-web CouponGrantLog.vue 删除逐个 getFamilyDetail 的 resolveFamilyNames

③ 后台优惠券接口改用 @PreAuthorize("hasRole('admin')"):
   - 新增 spring-boot-starter-security 依赖 + SecurityConfig
     (STATELESS + anyRequest().permitAll() + @EnableGlobalMethodSecurity,
      认证仍由 JwtInterceptor 负责,不改变既有接口可达性)
   - JwtInterceptor 把角色发布为 ROLE_<role> 权限到 SecurityContextHolder,
     匿名请求发布 ROLE_ANONYMOUS;请求前后清理上下文防线程复用泄漏
   - GlobalExceptionHandler 增 AccessDeniedException → Result.error(403,"无权限"),
     与原「手写 if(!admin) 返回无权限」前端体验一致
   - AdminCouponController 全部 12 个接口移除手写角色判断

④ Coupon.java 与 schema.sql 已对齐:coupon.family_id 列已存在
   (schema.sql:2342 + 迁移 282/411),实体已有 familyId 字段,无需改动

⑤ DISCOUNT 抵扣整数截断分位误差修复:
   orderAmount*(10000-rate)/10000 → Math.round(double),
   19.99 元 8 折由 399 修正为 400(1 元 8 折仍为 20),并规避 int 溢出

⑥ apply applicableTo 为 null 时 NPE 修复:
   null 视为 ALL(全场通用),不再对 null 调用 equals

验证:cfc-backend mvn clean compile BUILD SUCCESS;cfc-web npm run build
DONE(该工程未配置 lint 脚本);测试库实跑:admin 分页/keyword/sort 正常、
operator 与匿名访问后台券接口均返回 403「无权限」、公开接口与 CORS 预检正常。
未新增 DB 迁移/表,未合并 cfclub。

Refs: COUPON-P2
Sisyphus há 2 semanas atrás
pai
commit
59bc4100bc

+ 6 - 0
cfc-backend/pom.xml

@@ -114,6 +114,12 @@
             <artifactId>spring-security-crypto</artifactId>
             <artifactId>spring-security-crypto</artifactId>
         </dependency>
         </dependency>
 
 
+        <!-- Spring Security(方法级 @PreAuthorize 权限控制,替代控制器内手写 if(!role) 检查) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+
         <!-- Quartz 调度(任务编排轮询) -->
         <!-- Quartz 调度(任务编排轮询) -->
         <dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <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.slf4j.LoggerFactory;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.http.converter.HttpMessageNotReadableException;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.security.access.AccessDeniedException;
 import org.springframework.web.HttpRequestMethodNotSupportedException;
 import org.springframework.web.HttpRequestMethodNotSupportedException;
 import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
@@ -87,6 +88,16 @@ public class GlobalExceptionHandler {
         return null;
         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)
     @ExceptionHandler(Exception.class)
     public Result<Void> handleException(Exception e, HttpServletRequest request) {
     public Result<Void> handleException(Exception e, HttpServletRequest request) {
         log.error("未捕获异常: {} - {} | uri={}", e.getClass().getName(), e.getMessage(), request.getRequestURI(), e);
         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 io.jsonwebtoken.Claims;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
 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 org.springframework.stereotype.Component;
-import java.util.Collections;
-import java.util.List;
 import org.springframework.util.StringUtils;
 import org.springframework.util.StringUtils;
 import org.springframework.web.servlet.HandlerInterceptor;
 import org.springframework.web.servlet.HandlerInterceptor;
 
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponse;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 
 
 @Slf4j
 @Slf4j
 @Component
 @Component
@@ -89,6 +94,9 @@ private static final String[] PUBLIC_PATHS = {
             return true;
             return true;
         }
         }
 
 
+        // 每次请求从干净上下文开始:避免线程池复用导致上一请求的 SecurityContext 泄漏
+        SecurityContextHolder.clearContext();
+
         String path = request.getRequestURI();
         String path = request.getRequestURI();
         for (String publicPath : PUBLIC_PATHS) {
         for (String publicPath : PUBLIC_PATHS) {
             String boundaryPath = publicPath.endsWith("/**")
             String boundaryPath = publicPath.endsWith("/**")
@@ -123,6 +131,7 @@ private static final String[] PUBLIC_PATHS = {
             }
             }
             log.debug("请求 {} 无Token,设为匿名用户", path);
             log.debug("请求 {} 无Token,设为匿名用户", path);
             setAnonymousUser(request);
             setAnonymousUser(request);
+            setAnonymousSecurityContext();
             return true;
             return true;
         }
         }
 
 
@@ -149,6 +158,21 @@ private static final String[] PUBLIC_PATHS = {
             request.setAttribute("role", role);
             request.setAttribute("role", role);
             request.setAttribute("roles", roles);
             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(查询用户所属家庭)
  // 设置familyId(查询用户所属家庭)
             try {
             try {
                 User user = userMapper.selectById(userId);
                 User user = userMapper.selectById(userId);
@@ -220,4 +244,22 @@ private static final String[] PUBLIC_PATHS = {
         // familyId 属性必须始终存在,避免 @RequestAttribute("familyId") 抛 ServletRequestBindingException
         // familyId 属性必须始终存在,避免 @RequestAttribute("familyId") 抛 ServletRequestBindingException
         request.setAttribute("familyId", null);
         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();
+    }
+}

+ 72 - 66
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -4,14 +4,16 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.Coupon;
-import com.etotem.cfc.entity.CouponGrantLog;
+import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
-import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponMapper;
 import com.etotem.cfc.mapper.CouponMapper;
 import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
 import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
 import com.etotem.cfc.mapper.FamilyCouponMapper;
 import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.service.CouponService;
 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 org.springframework.web.bind.annotation.*;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
@@ -22,8 +24,6 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.Set;
 import java.util.Set;
-import com.etotem.cfc.util.SortUtil;
-import com.etotem.cfc.util.ParamUtils;
 
 
 @RestController
 @RestController
 @RequestMapping("/api/admin/coupon")
 @RequestMapping("/api/admin/coupon")
@@ -35,34 +35,41 @@ public class AdminCouponController {
     @Resource
     @Resource
     private CouponMapper couponMapper;
     private CouponMapper couponMapper;
 
 
-    @Resource
-    private CouponGrantLogMapper couponGrantLogMapper;
-
     @Resource
     @Resource
     private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
     private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
 
 
     @Resource
     @Resource
     private FamilyCouponMapper familyCouponMapper;
     private FamilyCouponMapper familyCouponMapper;
 
 
+    @Resource
+    private FamilyMapper familyMapper;
+
+    /**
+     * 优惠券模板分页列表(COUPON-P2 ①:支持 keyword 搜索 + 真实分页 + 排序)。
+     * 请求: { page, size, keyword?, sort? }
+     * 返回: Result<Page<Coupon>>(records/total/current/size)
+     */
     @PostMapping("/list")
     @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")
         @SuppressWarnings("unchecked")
         List<Map<String, String>> sortSpecs = params != null && params.get("sort") != null
         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")
     @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.getType() == null) coupon.setType("FIXED");
         if (coupon.getMinSpend() == null) coupon.setMinSpend(0);
         if (coupon.getMinSpend() == null) coupon.setMinSpend(0);
         if (coupon.getApplicableTo() == null) coupon.setApplicableTo("ALL");
         if (coupon.getApplicableTo() == null) coupon.setApplicableTo("ALL");
@@ -73,21 +80,15 @@ public class AdminCouponController {
     }
     }
 
 
     @PostMapping("/update")
     @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);
         couponMapper.updateById(coupon);
         return Result.success("更新成功");
         return Result.success("更新成功");
     }
     }
 
 
     @PostMapping("/issue")
     @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"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         Object familyIdsObj = params.get("familyIds");
         Object familyIdsObj = params.get("familyIds");
         if (familyIdsObj instanceof List) {
         if (familyIdsObj instanceof List) {
@@ -101,11 +102,8 @@ public class AdminCouponController {
 
 
     /** 单家庭发放(家庭管理页使用) */
     /** 单家庭发放(家庭管理页使用) */
     @PostMapping("/issue-family")
     @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 familyId = ParamUtils.getLong(params.get("familyId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         Integer quantity = params.get("quantity") == null ? 1 : Integer.valueOf(params.get("quantity").toString());
         Integer quantity = params.get("quantity") == null ? 1 : Integer.valueOf(params.get("quantity").toString());
@@ -126,11 +124,8 @@ public class AdminCouponController {
      * 后端自动创建会员专属券模板并发放到该用户所在家庭。
      * 后端自动创建会员专属券模板并发放到该用户所在家庭。
      */
      */
     @PostMapping("/grant-user")
     @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"));
         Long userId = ParamUtils.getLong(params.get("userId"));
         Integer amountFen = null;
         Integer amountFen = null;
         if (params.get("amount") != null) {
         if (params.get("amount") != null) {
@@ -161,22 +156,16 @@ public class AdminCouponController {
     }
     }
 
 
     @PostMapping("/delete")
     @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"));
         Long id = ParamUtils.getLong(params.get("id"));
         couponMapper.deleteById(id);
         couponMapper.deleteById(id);
         return Result.success("删除成功");
         return Result.success("删除成功");
     }
     }
 
 
     @PostMapping("/toggle-status")
     @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"));
         Long id = ParamUtils.getLong(params.get("id"));
         String status = params.get("status") == null ? null : params.get("status").toString();
         String status = params.get("status") == null ? null : params.get("status").toString();
         if (id == null || !("ACTIVE".equals(status) || "DISABLED".equals(status))) {
         if (id == null || !("ACTIVE".equals(status) || "DISABLED".equals(status))) {
@@ -191,12 +180,13 @@ public class AdminCouponController {
         return Result.success("ACTIVE".equals(status) ? "启用成功" : "停用成功");
         return Result.success("ACTIVE".equals(status) ? "启用成功" : "停用成功");
     }
     }
 
 
+    /**
+     * 发券流水(COUPON-P2 ②:批量填充家庭名,消除前端逐个请求 getFamilyDetail 的 N+1)。
+     * 返回 List<FamilyCouponGrantLog>,每条带 familyName(batch 一次查 families 表)。
+     */
     @PostMapping("/grant-log")
     @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 familyId = ParamUtils.getLong(params.get("familyId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         String grantType = params.get("grantType") == null ? null : params.get("grantType").toString();
         String grantType = params.get("grantType") == null ? null : params.get("grantType").toString();
@@ -215,16 +205,35 @@ public class AdminCouponController {
             wrapper.eq(FamilyCouponGrantLog::getSource, source);
             wrapper.eq(FamilyCouponGrantLog::getSource, source);
         }
         }
         wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
         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());
+        }
     }
     }
 
 
     /** 家庭券列表(family_coupon 分页,JOIN coupon 取模板信息) */
     /** 家庭券列表(family_coupon 分页,JOIN coupon 取模板信息) */
     @PostMapping("/family-coupons")
     @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"));
         Long familyId = ParamUtils.getLong(params.get("familyId"));
         String status = params.get("status") == null ? null : params.get("status").toString();
         String status = params.get("status") == null ? null : params.get("status").toString();
         int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
         int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
@@ -283,11 +292,8 @@ public class AdminCouponController {
 
 
     /** 家庭券使用记录(status=USED,JOIN coupon 取模板信息) */
     /** 家庭券使用记录(status=USED,JOIN coupon 取模板信息) */
     @PostMapping("/family-coupon-usage")
     @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"));
         Long familyId = ParamUtils.getLong(params.get("familyId"));
         int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
         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());
         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;
 package com.etotem.cfc.entity;
 
 
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import lombok.Data;
@@ -31,4 +32,8 @@ public class FamilyCouponGrantLog implements Serializable {
     private String source;
     private String source;
 
 
     private Date createdAt;
     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

@@ -2,6 +2,7 @@ package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
@@ -189,7 +190,10 @@ public class CouponService {
         if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
         if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
             return null;
             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;
             return null;
         }
         }
         // 新增:会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用
         // 新增:会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用
@@ -206,7 +210,9 @@ public class CouponService {
         // 返回抵扣金额(调用方以 originalAmount - discount 计算最终价)
         // 返回抵扣金额(调用方以 originalAmount - discount 计算最终价)
         if ("DISCOUNT".equals(coupon.getType())) {
         if ("DISCOUNT".equals(coupon.getType())) {
             int rate = coupon.getDiscountRate() != null ? coupon.getDiscountRate() : 9000;
             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.max(discount, 0);
         }
         }
         return Math.min(coupon.getValue(), orderAmount);
         return Math.min(coupon.getValue(), orderAmount);
@@ -261,6 +267,24 @@ public class CouponService {
         return couponMapper.selectList(wrapper);
         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) {
     public Coupon getById(Long couponId) {
         return couponMapper.selectById(couponId);
         return couponMapper.selectById(couponId);
     }
     }

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

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

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

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

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

@@ -271,7 +271,7 @@ export default {
       return map[type] || type
       return map[type] || type
     },
     },
     couponDesc(coupon) {
     couponDesc(coupon) {
-      if (coupon.type === 'DISCOUNT') {
+      if (coupon.type === 'DISCOUNT' || coupon.type === 'PERCENT') {
         return (coupon.discountRate / 100).toFixed(1) + '折'
         return (coupon.discountRate / 100).toFixed(1) + '折'
       }
       }
       return '立减' + (coupon.value / 100).toFixed(2) + '元'
       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 SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
 import { getFamilyCouponGrantLog } from '@/api/coupon'
 import { getFamilyCouponGrantLog } from '@/api/coupon'
-import { getFamilyDetail } from '@/api/admin'
 
 
 export default {
 export default {
   mixins: [SortMixin, ListStateMixin],
   mixins: [SortMixin, ListStateMixin],
@@ -121,7 +120,6 @@ export default {
         if (res.data) {
         if (res.data) {
           this.list = res.data
           this.list = res.data
           this.total = this.list.length
           this.total = this.list.length
-          await this.resolveFamilyNames(this.list)
         }
         }
       } catch (e) {
       } catch (e) {
         console.error(e)
         console.error(e)
@@ -129,29 +127,6 @@ export default {
         this.loading = false
         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() {
     handleSearch() {
       this.page = 1
       this.page = 1
       this.loadData()
       this.loadData()

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

@@ -289,7 +289,7 @@ export default {
       return map[val] || val
       return map[val] || val
     },
     },
     typeLabel(val) {
     typeLabel(val) {
-      const map = { FIXED: '满减券', CASH: '无门槛券', DISCOUNT: '折扣券' }
+      const map = { FIXED: '满减券', CASH: '无门槛券', DISCOUNT: '折扣券', PERCENT: '折扣券' }
       return map[val] || val
       return map[val] || val
     },
     },
     grantTypeLabel(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/create` | 新建优惠券模板(admin) |
 | `POST /api/admin/coupon/update` | 更新优惠券模板(admin) |
 | `POST /api/admin/coupon/update` | 更新优惠券模板(admin) |
 | `POST /api/admin/coupon/issue` | 批量发放到家庭,`{ couponId, familyIds: [Long] }`(admin) |
 | `POST /api/admin/coupon/issue` | 批量发放到家庭,`{ couponId, familyIds: [Long] }`(admin) |
 | `POST /api/admin/coupon/issue-family` | 单家庭发放,`{ familyId, couponId, quantity }`(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/grant-user` | 管理员定向发放「会员升级券」给指定用户,`{ userId, amountYuan \| amount, validUntil?, remark? }`(admin,自动创建 applicableTo=MEMBERSHIP 无门槛券模板并发放到该用户家庭,落 grantType=MANUAL 流水) |
 | `POST /api/admin/coupon/toggle-status` | 启用/停用优惠券模板(admin) |
 | `POST /api/admin/coupon/toggle-status` | 启用/停用优惠券模板(admin) |
 | `POST /api/coupon/list` | 当前家庭可用优惠券(小程序) |
 | `POST /api/coupon/list` | 当前家庭可用优惠券(小程序) |