Răsfoiți Sursa

Phase7: 完课证书+卡券核销+T+跟进定时任务

liaoxg 2 săptămâni în urmă
părinte
comite
2fdc435

+ 43 - 0
train-backend/src/main/java/com/train/controller/PlanController.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.common.Result;
 import com.train.entity.*;
 import com.train.mapper.*;
+import com.train.service.CertService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -28,6 +29,8 @@ public class PlanController {
     private TrainGroupMemberMapper trainGroupMemberMapper;
     @Resource
     private TrainGroupMapper trainGroupMapper;
+    @Resource
+    private CertService certService;
 
     // ============ 7天行动计划 ============
     @Operation(summary = "提交7天行动计划")
@@ -85,6 +88,46 @@ public class PlanController {
         return Result.success(list);
     }
 
+    // ============ 完课证书 & 卡券核销 ============
+    @Operation(summary = "生成/领取完课证书")
+    @PostMapping("/cert")
+    public Result<Map<String, Object>> cert(@org.springframework.web.bind.annotation.RequestAttribute("userId") Long userId) {
+        return Result.success(certService.issue(userId));
+    }
+
+    @Operation(summary = "卡券核销")
+    @PostMapping("/coupon/redeem")
+    public Result<Boolean> couponRedeem(@RequestBody Map<String, Object> body,
+                                        @org.springframework.web.bind.annotation.RequestAttribute("userId") Long userId) {
+        Object idObj = body.get("id");
+        if (idObj == null) {
+            return Result.error("卡券ID不能为空");
+        }
+        Long couponId;
+        try {
+            couponId = Long.valueOf(idObj.toString());
+        } catch (NumberFormatException e) {
+            return Result.error("卡券ID不合法");
+        }
+        TrainCoupon coupon = trainCouponMapper.selectById(couponId);
+        if (coupon == null) {
+            return Result.error("卡券不存在");
+        }
+        if (!coupon.getUid().equals(userId)) {
+            return Result.error("无权核销该卡券");
+        }
+        if ("used".equals(coupon.getStatus())) {
+            return Result.success(true); // 幂等:已核销直接返回成功
+        }
+        if ("expired".equals(coupon.getStatus())) {
+            return Result.error("卡券已过期");
+        }
+        coupon.setStatus("used");
+        coupon.setUsedAt(new Date());
+        trainCouponMapper.updateById(coupon);
+        return Result.success(true);
+    }
+
     /** 发放卡券(幂等:同类型+同触发不重复发放) */
     public synchronized TrainCoupon grantCoupon(Long uid, String type, String trigger, String remark) {
         Long exists = trainCouponMapper.selectCount(

+ 155 - 0
train-backend/src/main/java/com/train/service/CertService.java

@@ -0,0 +1,155 @@
+package com.train.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.entity.*;
+import com.train.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+
+/**
+ * 完课证书服务:校验离场验收 6 项(课纲 V4 口径,映射到系统内数据),
+ * 通过后返回确定性证书号(CERT- 前缀,不落库,幂等)。领取动作写 audit_log。
+ */
+@Slf4j
+@Service
+public class CertService {
+
+    @Resource
+    private TrainUserMapper trainUserMapper;
+    @Resource
+    private TrainCheckinMapper trainCheckinMapper;
+    @Resource
+    private TrainPrepCheckMapper trainPrepCheckMapper;
+    @Resource
+    private TrainAssignmentMapper trainAssignmentMapper;
+    @Resource
+    private TrainSubmissionMapper trainSubmissionMapper;
+    @Resource
+    private TrainTeachingCardMapper trainTeachingCardMapper;
+    @Resource
+    private TrainRoadmapMapper trainRoadmapMapper;
+    @Resource
+    private TrainGroupMemberMapper trainGroupMemberMapper;
+    @Resource
+    private TrainPlanMapper trainPlanMapper;
+    @Resource
+    private TrainAuditLogMapper trainAuditLogMapper;
+
+    /**
+     * 校验离场验收 6 项并生成证书号。
+     * 返回结构:{eligible, certNo, issuedAt, items:[{key,label,ok}]}
+     */
+    public Map<String, Object> issue(Long uid) {
+        List<Map<String, Object>> items = new ArrayList<>();
+        TrainUser user = trainUserMapper.selectById(uid);
+
+        // 1 装机打卡:可独立打开 WorkBuddy 完成一次有效提问
+        boolean checkinOk = trainCheckinMapper.selectCount(
+                new LambdaQueryWrapper<TrainCheckin>()
+                        .eq(TrainCheckin::getUid, uid)
+                        .eq(TrainCheckin::getStatus, "done")) > 0;
+        items.add(item("checkin", "装机打卡(有效提问)", checkinOk));
+
+        // 2 数据自检:三本账数据自查且数据就绪
+        boolean prepOk = trainPrepCheckMapper.selectCount(
+                new LambdaQueryWrapper<TrainPrepCheck>().eq(TrainPrepCheck::getUid, uid)) > 0;
+        items.add(item("prep", "数据自检(三本账就绪)", prepOk));
+
+        // 3 三本账分工确认
+        boolean assignOk = trainAssignmentMapper.selectCount(
+                new LambdaQueryWrapper<TrainAssignment>()
+                        .eq(TrainAssignment::getUid, uid)
+                        .eq(TrainAssignment::getStatus, "confirmed")) > 0;
+        items.add(item("assignment", "三本账分工确认", assignOk));
+
+        // 4 成果核验通过(深度产出 / 互教跑通)
+        boolean submitOk = trainSubmissionMapper.selectCount(
+                new LambdaQueryWrapper<TrainSubmission>()
+                        .eq(TrainSubmission::getUid, uid)
+                        .eq(TrainSubmission::getStatus, "approved")) > 0;
+        items.add(item("submission", "成果上传并核验通过", submitOk));
+
+        // 5 教学卡 + 路演完成
+        boolean cardOk = trainTeachingCardMapper.selectCount(
+                new LambdaQueryWrapper<TrainTeachingCard>().eq(TrainTeachingCard::getUid, uid)) > 0;
+        boolean roadmapOk = false;
+        List<TrainGroupMember> members = trainGroupMemberMapper.selectList(
+                new LambdaQueryWrapper<TrainGroupMember>().eq(TrainGroupMember::getUid, uid));
+        for (TrainGroupMember m : members) {
+            if (trainRoadmapMapper.selectCount(
+                    new LambdaQueryWrapper<TrainRoadmap>().eq(TrainRoadmap::getGroupId, m.getGroupId())) > 0) {
+                roadmapOk = true;
+                break;
+            }
+        }
+        items.add(item("roadmap", "教学卡与路演完成", cardOk && roadmapOk));
+
+        // 6 7 天行动计划已提交
+        boolean planOk = trainPlanMapper.selectCount(
+                new LambdaQueryWrapper<TrainPlan>().eq(TrainPlan::getUid, uid)) > 0;
+        items.add(item("plan", "7天行动计划已提交", planOk));
+
+        boolean eligible = checkinOk && prepOk && assignOk && submitOk && cardOk && roadmapOk && planOk;
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("eligible", eligible);
+        result.put("items", items);
+        if (eligible) {
+            Long classId = user != null ? user.getClassId() : null;
+            String certNo = genCertNo(uid, classId);
+            result.put("certNo", certNo);
+            result.put("issuedAt", new Date());
+            ensureAuditLog(uid, "cert_issued", "领取完课证书 " + certNo);
+        } else {
+            result.put("certNo", null);
+        }
+        return result;
+    }
+
+    private Map<String, Object> item(String key, String label, boolean ok) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("key", key);
+        m.put("label", label);
+        m.put("ok", ok);
+        return m;
+    }
+
+    /** 确定性证书号:CERT-UC{classId}-{uid}-{MD5 前 8 位大写},同一学员重复调用结果一致 */
+    private String genCertNo(Long uid, Long classId) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(("cert:" + uid + ":" + classId).getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (int i = 0; i < 4; i++) {
+                sb.append(String.format("%02X", digest[i]));
+            }
+            return "CERT-UC" + (classId == null ? "0" : classId) + "-" + uid + "-" + sb;
+        } catch (Exception e) {
+            return "CERT-" + uid + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
+        }
+    }
+
+    /** 审计落账(幂等:同 action + targetId 已存在则跳过) */
+    private void ensureAuditLog(Long uid, String action, String detail) {
+        Long exists = trainAuditLogMapper.selectCount(
+                new LambdaQueryWrapper<TrainAuditLog>()
+                        .eq(TrainAuditLog::getAction, action)
+                        .eq(TrainAuditLog::getTargetId, uid));
+        if (exists != null && exists > 0) {
+            return;
+        }
+        TrainAuditLog entry = new TrainAuditLog();
+        entry.setAction(action);
+        entry.setActorUid(uid);
+        entry.setTargetType("user");
+        entry.setTargetId(uid);
+        entry.setDetail(detail);
+        entry.setTs(new Date());
+        trainAuditLogMapper.insert(entry);
+    }
+}

+ 74 - 0
train-backend/src/main/java/com/train/service/FollowUpTask.java

@@ -0,0 +1,74 @@
+package com.train.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.entity.TrainAuditLog;
+import com.train.entity.TrainPlan;
+import com.train.mapper.TrainAuditLogMapper;
+import com.train.mapper.TrainPlanMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * T+ 跟进定时任务:每日定时扫描已提交 7 天行动计划的学员,
+ * 相对计划提交日(T0)生成 T+1 / T+3 / T+7 提醒记录,落 audit_log(幂等)。
+ * 骨架版本以落账为主,生产可在此处改为推送订阅消息(复用 SubscribeMessageService)。
+ */
+@Slf4j
+@Component
+public class FollowUpTask {
+
+    @Resource
+    private TrainPlanMapper trainPlanMapper;
+    @Resource
+    private TrainAuditLogMapper trainAuditLogMapper;
+
+    private static final long DAY_MILLIS = 24L * 60 * 60 * 1000;
+
+    /** 每日 09:30 执行(课后跟进节奏:T+1 成果合集 / T+3 补齐 / T+7 回访) */
+    @Scheduled(cron = "0 30 9 * * ?")
+    public void runFollowUp() {
+        List<TrainPlan> plans = trainPlanMapper.selectList(
+                new LambdaQueryWrapper<TrainPlan>().orderByAsc(TrainPlan::getSubmittedAt));
+        Date now = new Date();
+        for (TrainPlan plan : plans) {
+            if (plan.getUid() == null || plan.getSubmittedAt() == null) {
+                continue;
+            }
+            long elapsedDays = (now.getTime() - plan.getSubmittedAt().getTime()) / DAY_MILLIS;
+            if (elapsedDays >= 1) {
+                ensureLog(plan.getUid(), "followup_t1", "T+1 跟进提醒:课堂成果合集与照片已同步,请查收");
+            }
+            if (elapsedDays >= 3) {
+                ensureLog(plan.getUid(), "followup_t3", "T+3 跟进提醒:未完成项建议补齐(三本账/自动化)");
+            }
+            if (elapsedDays >= 7) {
+                ensureLog(plan.getUid(), "followup_t7", "T+7 回访:7 天行动计划完成度自检,欢迎约 1v1");
+            }
+        }
+        log.info("FollowUpTask 完成一轮 T+ 跟进扫描,共 {} 条计划", plans.size());
+    }
+
+    /** 幂等写入 audit_log:同 action + targetId 已存在则跳过 */
+    private void ensureLog(Long uid, String action, String detail) {
+        Long exists = trainAuditLogMapper.selectCount(
+                new LambdaQueryWrapper<TrainAuditLog>()
+                        .eq(TrainAuditLog::getAction, action)
+                        .eq(TrainAuditLog::getTargetId, uid));
+        if (exists != null && exists > 0) {
+            return;
+        }
+        TrainAuditLog entry = new TrainAuditLog();
+        entry.setAction(action);
+        entry.setActorUid(null);
+        entry.setTargetType("user");
+        entry.setTargetId(uid);
+        entry.setDetail(detail);
+        entry.setTs(new Date());
+        trainAuditLogMapper.insert(entry);
+    }
+}

+ 132 - 3
train-frontend/pages/mine/index.vue

@@ -9,6 +9,40 @@
       </view>
     </view>
 
+    <view class="section-card">
+      <text class="section-title">🎓 完课证书</text>
+      <view v-if="certLoading" class="empty-text">加载中...</view>
+      <view v-else-if="cert.eligible && cert.certNo" class="cert-ok">
+        <text class="cert-no">{{ cert.certNo }}</text>
+        <text class="cert-tip">恭喜完成离场验收全部 6 项!</text>
+        <button class="mini-btn" @click="copyCertNo">复制证书号</button>
+      </view>
+      <view v-else-if="cert.items && cert.items.length">
+        <text class="cert-progress">已完成 {{ certDoneCount }}/6 项</text>
+        <view class="cert-items">
+          <view class="cert-item" v-for="(it, idx) in cert.items" :key="idx">
+            <text class="cert-icon">{{ it.ok ? '✅' : '⬜' }}</text>
+            <text class="cert-label">{{ it.label }}</text>
+          </view>
+        </view>
+      </view>
+      <view v-else class="empty-text">暂无证书信息,完成 6 项离场验收后可领取</view>
+    </view>
+
+    <view class="section-card">
+      <text class="section-title">🎟️ 卡券核销</text>
+      <view v-if="coupons.length === 0" class="empty-text">暂无卡券</view>
+      <view class="coupon-list">
+        <view class="coupon-item" v-for="(c, idx) in coupons" :key="idx">
+          <view class="coupon-info">
+            <text class="coupon-code">{{ c.code }}</text>
+            <text class="coupon-status" :class="c.status">{{ couponStatusText(c) }}</text>
+          </view>
+          <text v-if="c.status === 'unused'" class="redeem-btn" @click="redeem(c)">核销</text>
+        </view>
+      </view>
+    </view>
+
     <view class="menu-card">
       <view class="menu-item" @click="goTo('/pages/enroll/list')">
         <text class="menu-icon">🎫</text>
@@ -36,8 +70,8 @@
         <text class="menu-arrow">›</text>
       </view>
       <view class="menu-item" @click="goTo('/pages/survey/index')">
-        <text class="menu-icon">🎟️</text>
-        <text class="menu-text">我的卡券</text>
+        <text class="menu-icon">📝</text>
+        <text class="menu-text">测评调研</text>
         <text class="menu-arrow">›</text>
       </view>
       <view class="menu-item" @click="goTo('/pages/plan/index')">
@@ -57,7 +91,17 @@
 </template>
 
 <script>
+import { getMyCert, getMyCoupons, redeemCoupon } from '@/utils/api.js'
+
 export default {
+  data() {
+    return {
+      certLoading: true,
+      cert: {},
+      coupons: [],
+      redeemingId: 0
+    }
+  },
   computed: {
     name() {
       var stored = uni.getStorageSync('userInfo')
@@ -79,12 +123,74 @@ export default {
     verifyClass() {
       var v = this.$store.state.alumniVerify
       return v === 'accepted' ? 'verify-pass' : (v === 'pending' ? 'verify-pending' : 'verify-fail')
+    },
+    certDoneCount() {
+      var items = this.cert.items || []
+      var done = 0
+      for (var i = 0; i < items.length; i++) {
+        if (items[i].ok) done++
+      }
+      return done
     }
   },
+  onShow() {
+    this.loadCert()
+    this.loadCoupons()
+  },
   methods: {
     goTo(url) {
       uni.navigateTo({ url: url })
     },
+    loadCert() {
+      var self = this
+      self.certLoading = true
+      getMyCert().then(function(resp) {
+        self.cert = resp.data || {}
+      }).catch(function() {
+        self.cert = {}
+      }).finally(function() {
+        self.certLoading = false
+      })
+    },
+    loadCoupons() {
+      var self = this
+      getMyCoupons().then(function(resp) {
+        self.coupons = resp.data || []
+      }).catch(function() {})
+    },
+    copyCertNo() {
+      var certNo = this.cert.certNo || ''
+      uni.setClipboardData({
+        data: certNo,
+        success: function() {
+          uni.showToast({ title: '已复制', icon: 'success' })
+        }
+      })
+    },
+    couponStatusText(c) {
+      var map = { unused: '未使用', used: '已使用', expired: '已过期' }
+      return map[c.status] || c.status
+    },
+    redeem(c) {
+      var self = this
+      if (self.redeemingId === c.id) return
+      uni.showModal({
+        title: '核销卡券',
+        content: '确定核销卡券 ' + (c.code || '') + ' 吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          self.redeemingId = c.id
+          redeemCoupon({ id: c.id }).then(function() {
+            uni.showToast({ title: '核销成功', icon: 'success' })
+            self.loadCoupons()
+          }).catch(function(err) {
+            uni.showToast({ title: (err && err.message) || '核销失败', icon: 'none' })
+          }).finally(function() {
+            self.redeemingId = 0
+          })
+        }
+      })
+    },
     handleLogout() {
       var self = this
       uni.showModal({
@@ -109,10 +215,33 @@ export default {
 .profile-info { flex: 1; }
 .profile-name { display: block; font-size: 36rpx; font-weight: 700; color: #FFF; margin-bottom: 8rpx; }
 .profile-role { display: block; font-size: 26rpx; color: rgba(255,255,255,0.85); margin-bottom: 4rpx; }
-.profile-verify { display: block; font-size: 24rpx; padding: 4rpx 16rpx; border-radius: 6rpx; display: inline-block; margin-top: 8rpx; }
+.profile-verify { display: block; font-size: 24rpx; padding: 4rpx 16rpx; border-radius: 6rpx; margin-top: 8rpx; }
 .verify-pass { background: rgba(34,197,94,0.2); color: #86EFAC; }
 .verify-pending { background: rgba(255,183,77,0.2); color: #FFD54F; }
 .verify-fail { background: rgba(239,83,80,0.2); color: #E57373; }
+.section-card { background: #FFF; border-radius: 16rpx; padding: 28rpx 32rpx; margin-bottom: 24rpx; }
+.section-title { display: block; font-size: 30rpx; font-weight: 700; color: #1E293B; margin-bottom: 16rpx; }
+.empty-text { font-size: 26rpx; color: #94A3B8; text-align: center; padding: 24rpx 0; }
+.cert-ok { display: flex; flex-direction: column; align-items: center; padding: 16rpx 0; }
+.cert-no { font-size: 32rpx; font-weight: 700; color: #F97316; font-family: monospace; margin-bottom: 12rpx; }
+.cert-tip { font-size: 26rpx; color: #22C55E; margin-bottom: 20rpx; }
+.mini-btn { width: 240rpx; height: 64rpx; line-height: 64rpx; background: #F97316; color: #FFF; font-size: 26rpx; border-radius: 32rpx; border: none; padding: 0; text-align: center; }
+.mini-btn:active { opacity: 0.85; }
+.cert-progress { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 16rpx; }
+.cert-items { }
+.cert-item { display: flex; align-items: center; padding: 10rpx 0; }
+.cert-icon { font-size: 28rpx; margin-right: 12rpx; }
+.cert-label { font-size: 26rpx; color: #1E293B; }
+.coupon-list { }
+.coupon-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #F1F5F9; }
+.coupon-info { display: flex; align-items: center; flex-wrap: wrap; flex: 1; margin-right: 16rpx; }
+.coupon-code { font-size: 24rpx; color: #64748B; font-family: monospace; margin-right: 16rpx; }
+.coupon-status { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 6rpx; }
+.coupon-status.unused { background: #F0FDF4; color: #22C55E; }
+.coupon-status.used { background: #F1F5F9; color: #94A3B8; }
+.coupon-status.expired { background: #FEF2F2; color: #EF4444; }
+.redeem-btn { font-size: 26rpx; color: #F97316; font-weight: 600; padding: 8rpx 24rpx; border: 2rpx solid #FED7AA; border-radius: 28rpx; }
+.redeem-btn:active { background: #FFF7ED; }
 .menu-card { background: #FFF; border-radius: 16rpx; overflow: hidden; margin-bottom: 32rpx; }
 .menu-item { display: flex; align-items: center; padding: 28rpx 32rpx; border-bottom: 1rpx solid #F1F5F9; }
 .menu-item:active { background: #F8FAFC; }

+ 6 - 0
train-frontend/utils/api.js

@@ -197,6 +197,12 @@ export const submitSurvey = (data) => {
 export const getMyCoupons = () => {
   return request('/api/plan/coupons', 'POST')
 }
+export const getMyCert = () => {
+  return request('/api/plan/cert', 'POST')
+}
+export const redeemCoupon = (data) => {
+  return request('/api/plan/coupon/redeem', 'POST', data)
+}
 
 // 报名
 export const getEnrollClasses = () => {