Przeglądaj źródła

feat: 管理员定向发放会员升级优惠券给用户管理页新增「发放优惠券」操作,支持指定金额、有效期、备注;后端自动创建会员专属券模板并落发券流水,允许非会员首次开通/升级使用

E2E Test Bot 1 tydzień temu
rodzic
commit
63b45ec545

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -119,6 +119,47 @@ public class AdminCouponController {
         return Result.success("发放成功");
     }
 
+    /**
+     * 定向发放「会员升级优惠券」给指定用户(用户管理页使用)。
+     * 参数: { userId, amountYuan | amount, validUntil?, remark? }
+     * amountYuan 单位为元,amount 单位为分,二选一;validUntil 格式 yyyy-MM-dd,缺省 +30 天。
+     * 后端自动创建会员专属券模板并发放到该用户所在家庭。
+     */
+    @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("无权限");
+        }
+        Long userId = ParamUtils.getLong(params.get("userId"));
+        Integer amountFen = null;
+        if (params.get("amount") != null) {
+            amountFen = Integer.valueOf(params.get("amount").toString());
+        } else if (params.get("amountYuan") != null) {
+            amountFen = (int) Math.round(Double.parseDouble(params.get("amountYuan").toString()) * 100);
+        }
+        if (userId == null || amountFen == null || amountFen <= 0) {
+            return Result.error("参数错误:userId 与金额必填");
+        }
+        String validUntilStr = params.get("validUntil") == null ? null : params.get("validUntil").toString();
+        String remark = params.get("remark") == null ? null : params.get("remark").toString();
+        Date validUntil = null;
+        if (validUntilStr != null && !validUntilStr.isEmpty()) {
+            try {
+                java.sql.Date d = java.sql.Date.valueOf(validUntilStr);
+                // 有效期止按当天 23:59:59.999 计算,避免当天券被判过期
+                validUntil = new Date(d.getTime() + 24L * 3600 * 1000 - 1);
+            } catch (Exception e) {
+                return Result.error("有效期格式错误,应为 yyyy-MM-dd");
+            }
+        }
+        Map<String, Object> result = couponService.grantMemberUpgradeCoupon(userId, amountFen, validUntil, remark);
+        if (result.containsKey("error")) {
+            return Result.error(result.get("error").toString());
+        }
+        return Result.success(result);
+    }
+
     @PostMapping("/delete")
     public Result<String> delete(@RequestBody Map<String, Object> params,
                                  @RequestAttribute("role") String role) {
@@ -158,6 +199,8 @@ public class AdminCouponController {
         }
         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();
+        String source = params.get("source") == null ? null : params.get("source").toString();
         LambdaQueryWrapper<FamilyCouponGrantLog> wrapper = new LambdaQueryWrapper<>();
         if (familyId != null) {
             wrapper.eq(FamilyCouponGrantLog::getFamilyId, familyId);
@@ -165,6 +208,12 @@ public class AdminCouponController {
         if (couponId != null) {
             wrapper.eq(FamilyCouponGrantLog::getCouponId, couponId);
         }
+        if (grantType != null && !grantType.isEmpty()) {
+            wrapper.eq(FamilyCouponGrantLog::getGrantType, grantType);
+        }
+        if (source != null && !source.isEmpty()) {
+            wrapper.eq(FamilyCouponGrantLog::getSource, source);
+        }
         wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
         return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
     }

+ 86 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -20,6 +20,7 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import com.etotem.cfc.util.SortUtil;
@@ -192,7 +193,10 @@ public class CouponService {
             return null;
         }
         // 新增:会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用
-        if ("MEMBERSHIP".equals(coupon.getApplicableTo()) && !isMember(userId)) {
+        // 例外:手动发放的券(grantType=MANUAL)允许非会员使用,用于赠送升级场景
+        if ("MEMBERSHIP".equals(coupon.getApplicableTo())
+                && !Objects.equals(coupon.getGrantType(), "MANUAL")
+                && !isMember(userId)) {
             return null;
         }
         // 无门槛券(CASH)忽略起用门槛;满减/折扣券校验
@@ -258,6 +262,87 @@ public class CouponService {
         familyCouponMapper.insert(fc);
     }
 
+    /**
+     * 管理员定向发放「会员升级优惠券」给指定用户。
+     * 流程:校验用户 → 取该用户所属家庭 → 自动创建会员专属券模板(type=CASH 无门槛、
+     *       applicableTo=MEMBERSHIP 仅限会员订单)→ 发放到该家庭 → 落发券流水
+     *       (grantType=MANUAL, source=userId:{id})。
+     *
+     * @param userId     目标用户ID(users.id)
+     * @param amountFen  面额(分)
+     * @param validUntil 有效期止(null 则默认 +30 天)
+     * @param remark     备注(写入券名,便于区分)
+     * @return {couponId, familyId, couponName, amountFen};用户不存在/未加入家庭时返回含 error 的 Map
+     */
+    @Transactional
+    public Map<String, Object> grantMemberUpgradeCoupon(Long userId, Integer amountFen, Date validUntil, String remark) {
+        Map<String, Object> result = new HashMap<>();
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            result.put("error", "用户不存在");
+            return result;
+        }
+        Long familyId = user.getFamilyId();
+        if (familyId == null) {
+            result.put("error", "该用户未加入家庭,无法发放家庭券");
+            return result;
+        }
+        Date now = new Date();
+        if (validUntil == null) {
+            validUntil = new Date(now.getTime() + 30L * 24 * 3600 * 1000);
+        }
+        // 创建会员专属券模板(每次发放一张独立模板,便于追踪发放记录)
+        Coupon coupon = new Coupon();
+        coupon.setName(buildManualCouponName(amountFen, remark));
+        coupon.setType("CASH");
+        coupon.setValue(amountFen);
+        coupon.setMinSpend(0);
+        coupon.setApplicableTo("MEMBERSHIP");
+        coupon.setValidFrom(now);
+        coupon.setValidUntil(validUntil);
+        coupon.setTotalCount(1);
+        coupon.setUsedCount(0);
+        coupon.setStatus("ACTIVE");
+        coupon.setCreatedAt(now);
+        couponMapper.insert(coupon);
+
+        // 发放到用户所在家庭
+        issueToFamily(coupon.getId(), familyId);
+
+        // 落发券流水
+        FamilyCouponGrantLog log = new FamilyCouponGrantLog();
+        log.setFamilyId(familyId);
+        log.setCouponId(coupon.getId());
+        log.setGrantType("MANUAL");
+        log.setQuantity(1);
+        log.setSource("userId:" + userId);
+        log.setCreatedAt(now);
+        familyCouponGrantLogMapper.insert(log);
+
+        logger.info("管理员定向发放会员升级券: userId={}, familyId={}, couponId={}, amountFen={}, remark={}",
+                userId, familyId, coupon.getId(), amountFen, remark);
+        result.put("couponId", coupon.getId());
+        result.put("familyId", familyId);
+        result.put("couponName", coupon.getName());
+        result.put("amountFen", amountFen);
+        return result;
+    }
+
+    /** 生成手动发放券名称:会员升级券{金额}元[-备注] */
+    private String buildManualCouponName(Integer amountFen, String remark) {
+        String yuan;
+        if (amountFen % 100 == 0) {
+            yuan = String.valueOf(amountFen / 100);
+        } else {
+            yuan = String.format("%.2f", amountFen / 100.0);
+        }
+        String base = "会员升级券" + yuan + "元";
+        if (remark != null && !remark.trim().isEmpty()) {
+            return base + "-" + remark.trim();
+        }
+        return base;
+    }
+
     /**
      * 发放优惠券并落流水(幂等,家庭维度)。
      * period 非空时先查流水:已存在则跳过(PERIODIC 防重用);唯一键冲突由事务回滚保证一致性。

+ 4 - 0
cfc-web/src/api/coupon.js

@@ -24,6 +24,10 @@ export function issueFamilyCoupon(data) {
   return request({ url: '/api/admin/coupon/issue-family', method: 'post', data })
 }
 
+export function grantUserCoupon(data) {
+  return request({ url: '/api/admin/coupon/grant-user', method: 'post', data })
+}
+
 export function getFamilyCouponGrantLog(data) {
   return request({ url: '/api/admin/coupon/grant-log', method: 'post', data })
 }

+ 100 - 0
cfc-web/src/views/Users.vue

@@ -135,6 +135,7 @@
               </el-button>
               <el-dropdown-menu slot="dropdown">
                 <el-dropdown-item command="reminderConfig" icon="el-icon-setting">提醒设置</el-dropdown-item>
+                <el-dropdown-item command="grantCoupon" icon="el-icon-present">发放优惠券</el-dropdown-item>
                 <el-dropdown-item command="resetPassword" icon="el-icon-key">重置密码</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
@@ -288,12 +289,48 @@
         <el-button type="primary" @click="confirmResetPassword" :loading="submitting">确定</el-button>
       </div>
     </el-dialog>
+
+    <!-- 发放会员升级优惠券 -->
+    <el-dialog
+      title="发放会员升级优惠券"
+      :visible.sync="grantCouponDialogVisible"
+      width="450px"
+      :close-on-click-modal="false"
+    >
+      <div v-if="grantCouponUser" class="grant-target">
+        <i class="el-icon-user"></i>
+        <span>用户:{{ grantCouponUser.nickname || grantCouponUser.realName || ('ID:' + grantCouponUser.id) }}</span>
+        <span v-if="grantCouponUser.familyId" class="grant-family">家庭ID:{{ grantCouponUser.familyId }}</span>
+      </div>
+      <el-form :model="grantCouponForm" label-width="100px">
+        <el-form-item label="券面值(元)" required>
+          <el-input-number v-model="grantCouponForm.amountYuan" :min="0.01" :step="10" :precision="2" style="width: 200px" />
+        </el-form-item>
+        <el-form-item label="有效期止">
+          <el-date-picker v-model="grantCouponForm.validUntil" type="date" value-format="yyyy-MM-dd" placeholder="默认 +30 天" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="grantCouponForm.remark" type="textarea" :rows="2" maxlength="30" show-word-limit placeholder="选填,用于区分该券" />
+        </el-form-item>
+        <el-alert
+          title="该券仅限会员开通/续费/升级订单抵扣,将发放到该用户所在家庭"
+          type="info"
+          :closable="false"
+          style="margin-bottom: 10px"
+        />
+      </el-form>
+      <div slot="footer">
+        <el-button @click="grantCouponDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmGrantCoupon" :loading="grantCouponSubmitting">确认发放</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import { getUserList, updateUser, deleteUser, resetUserPassword, searchUsers, listMembershipLevels } from '@/api/admin'
+import { grantUserCoupon } from '@/api/coupon'
 
 import { getRoleLabel } from '@/utils/permissions'
 
@@ -360,6 +397,14 @@ export default {
       reminderConfig: null,
       reminderDialogVisible: false,
       currentUser: null,
+      grantCouponDialogVisible: false,
+      grantCouponUser: null,
+      grantCouponForm: {
+        amountYuan: 50,
+        validUntil: '',
+        remark: ''
+      },
+      grantCouponSubmitting: false,
     }
   },
   mounted() {
@@ -376,6 +421,43 @@ export default {
         case 'edit': this.handleEdit(row); break;
         case 'resetPassword': this.handleResetPassword(row); break;
         case 'delete': this.handleDelete(row); break;
+        case 'grantCoupon': this.handleGrantCoupon(row); break;
+      }
+    },
+    handleGrantCoupon(row) {
+      if (!row.familyId) {
+        this.$message.warning('该用户未加入家庭,无法发放家庭券')
+        return
+      }
+      this.grantCouponUser = row
+      const d = new Date()
+      d.setDate(d.getDate() + 30)
+      const pad = function(n) { return n < 10 ? '0' + n : '' + n }
+      this.grantCouponForm = {
+        amountYuan: 50,
+        validUntil: d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()),
+        remark: ''
+      }
+      this.grantCouponDialogVisible = true
+    },
+    async confirmGrantCoupon() {
+      const { amountYuan, validUntil, remark } = this.grantCouponForm
+      if (!amountYuan || amountYuan <= 0) {
+        this.$message.warning('请填写券面值')
+        return
+      }
+      this.grantCouponSubmitting = true
+      try {
+        const payload = { userId: this.grantCouponUser.id, amountYuan: amountYuan }
+        if (validUntil) payload.validUntil = validUntil
+        if (remark) payload.remark = remark
+        const res = await grantUserCoupon(payload)
+        this.$message.success('发放成功,已发送到该用户所在家庭')
+        this.grantCouponDialogVisible = false
+      } catch (e) {
+        this.$message.error((e && e.message) || '发放失败')
+      } finally {
+        this.grantCouponSubmitting = false
       }
     },
     async loadUsers() {
@@ -646,4 +728,22 @@ const res = await getUserList(params)
   cursor: pointer;
   white-space: nowrap;
 }
+.grant-target {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 10px 12px;
+  background: #f5f7fa;
+  border-radius: 4px;
+  margin-bottom: 16px;
+  font-size: 13px;
+  color: #303133;
+}
+.grant-target .el-icon-user {
+  color: #409eff;
+}
+.grant-family {
+  color: #909399;
+  font-size: 12px;
+}
 </style>

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

@@ -1043,7 +1043,8 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `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? }`(admin,返回 family_coupon_grant_log) |
+| `POST /api/admin/coupon/grant-log` | 家庭发券流水查询,`{ familyId?, couponId?, grantType?, source? }`(admin,返回 family_coupon_grant_log) |
+| `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` | 当前家庭可用优惠券(小程序) |
 | `POST /api/coupon/my` | 我的(家庭)优惠券(小程序) |