Pārlūkot izejas kodu

Phase5: 完课证书编号体系与防伪查验

liaoxg 1 nedēļu atpakaļ
vecāks
revīzija
0c81bef

+ 1 - 0
train-backend/src/main/java/com/train/config/JwtInterceptor.java

@@ -37,6 +37,7 @@ public class JwtInterceptor implements HandlerInterceptor {
         "/api/course/list",           // 课程列表:未登录可浏览课程信息(我的报名状态置 none)
         "/api/course/current",        // 课程信息:未登录可浏览课程介绍(班次/报名状态留空)
         "/api/public/",
+        "/api/cert/verify",             // 证书防伪查验(公开,无需登录)
     };
 
     /** 管理后台账号token前缀标识(与学员token区分) */

+ 33 - 0
train-backend/src/main/java/com/train/controller/CertVerifyController.java

@@ -0,0 +1,33 @@
+package com.train.controller;
+
+import com.train.common.Result;
+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.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 完课证书公开查验接口(无需登录)。
+ * POST /api/cert/verify,入参 {certNo},返回真伪+持有人信息(脱敏)。
+ */
+@Tag(name = "证书查验", description = "完课证书编号防伪查验(公开)")
+@RestController
+@RequestMapping("/api/cert")
+public class CertVerifyController {
+
+    @Resource
+    private CertService certService;
+
+    @Operation(summary = "证书编号查验")
+    @PostMapping("/verify")
+    public Result<Map<String, Object>> verify(@RequestBody Map<String, Object> body) {
+        String certNo = body.get("certNo") != null ? body.get("certNo").toString() : null;
+        if (certNo == null || certNo.trim().isEmpty()) {
+            return Result.error("证书编号不能为空");
+        }
+        return Result.success(certService.verifyCertNo(certNo.trim()));
+    }
+}

+ 43 - 0
train-backend/src/main/java/com/train/entity/TrainCertificate.java

@@ -0,0 +1,43 @@
+package com.train.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("train_certificate")
+public class TrainCertificate implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 证书编号 CM-{课程码}-{年份}-{序号} */
+    private String certNo;
+
+    /** 持有人(train_user.id) */
+    private Long uid;
+
+    /** 班次ID(train_class.id) */
+    private Long classId;
+
+    /** 课程码 L0/L1/L2/L3 */
+    private String level;
+
+    /** 颁发年份 */
+    private Integer year;
+
+    /** 当年序号 */
+    private Integer seq;
+
+    /** 持有人姓名快照 */
+    private String holderName;
+
+    /** 颁发时间 */
+    private Date issuedAt;
+
+    private Date createdAt;
+}

+ 9 - 0
train-backend/src/main/java/com/train/mapper/TrainCertificateMapper.java

@@ -0,0 +1,9 @@
+package com.train.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.train.entity.TrainCertificate;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TrainCertificateMapper extends BaseMapper<TrainCertificate> {
+}

+ 111 - 26
train-backend/src/main/java/com/train/service/CertService.java

@@ -7,13 +7,12 @@ 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。
+ * 通过后签发证书编号(CM-{课程码}-{年份}-{序号},落库,幂等)。
+ * 同一学员同一课程(uid + level 维度)重复领取返回同一编号,不重复发号。
  */
 @Slf4j
 @Service
@@ -39,10 +38,16 @@ public class CertService {
     private TrainPlanMapper trainPlanMapper;
     @Resource
     private TrainAuditLogMapper trainAuditLogMapper;
+    @Resource
+    private TrainCertificateMapper trainCertificateMapper;
+    @Resource
+    private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainCourseMapper trainCourseMapper;
 
     /**
-     * 校验离场验收 6 项并生成证书号。
-     * 返回结构:{eligible, certNo, issuedAt, items:[{key,label,ok}]}
+     * 校验离场验收 6 项并签发证书编号。
+     * 返回结构:{eligible, certNo, issuedAt, items:[{key,label,ok}], level, year, seq}
      */
     public Map<String, Object> issue(Long uid) {
         List<Map<String, Object>> items = checkItemsOf(uid);
@@ -54,17 +59,93 @@ public class CertService {
         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);
+            // 解析课程等级:user.classId → train_class.courseId → train_course.level(任一为空则默认 L0)
+            String level = resolveLevel(user);
+
+            // 幂等:同一学员同一课程已发过则直接返回
+            TrainCertificate existing = trainCertificateMapper.selectOne(
+                    new LambdaQueryWrapper<TrainCertificate>()
+                            .eq(TrainCertificate::getUid, uid)
+                            .eq(TrainCertificate::getLevel, level)
+                            .last("LIMIT 1"));
+            if (existing != null) {
+                result.put("certNo", existing.getCertNo());
+                result.put("issuedAt", existing.getIssuedAt());
+                result.put("level", existing.getLevel());
+                result.put("year", existing.getYear());
+                result.put("seq", existing.getSeq());
+            } else {
+                // 发号:当年该 level 最大 seq + 1
+                int year = Calendar.getInstance().get(Calendar.YEAR);
+                int maxSeq = trainCertificateMapper.selectCount(
+                        new LambdaQueryWrapper<TrainCertificate>()
+                                .eq(TrainCertificate::getLevel, level)
+                                .eq(TrainCertificate::getYear, year));
+                int seq = maxSeq + 1;
+                String certNo = String.format("CM-%s-%d-%03d", level, year, seq);
+
+                Date now = new Date();
+                TrainCertificate cert = new TrainCertificate();
+                cert.setCertNo(certNo);
+                cert.setUid(uid);
+                cert.setClassId(user != null ? user.getClassId() : null);
+                cert.setLevel(level);
+                cert.setYear(year);
+                cert.setSeq(seq);
+                cert.setHolderName(user != null ? user.getName() : null);
+                cert.setIssuedAt(now);
+                trainCertificateMapper.insert(cert);
+
+                result.put("certNo", certNo);
+                result.put("issuedAt", now);
+                result.put("level", level);
+                result.put("year", year);
+                result.put("seq", seq);
+
+                ensureAuditLog(uid, "cert_issued", "领取完课证书 " + certNo);
+            }
         } else {
             result.put("certNo", null);
         }
         return result;
     }
 
+    /**
+     * 证书编号查验(公开接口复用):查得返回 {valid:true, certNo, level, year, seq, holderName(脱敏), issuedAt},
+     * 查无返回 {valid:false}。异常入参(null/空串)视为查无。
+     */
+    public Map<String, Object> verifyCertNo(String certNo) {
+        Map<String, Object> data = new HashMap<>();
+        if (certNo == null || certNo.trim().isEmpty()) {
+            data.put("valid", false);
+            return data;
+        }
+        TrainCertificate cert = trainCertificateMapper.selectOne(
+                new LambdaQueryWrapper<TrainCertificate>()
+                        .eq(TrainCertificate::getCertNo, certNo.trim())
+                        .last("LIMIT 1"));
+        if (cert == null) {
+            data.put("valid", false);
+        } else {
+            data.put("valid", true);
+            data.put("certNo", cert.getCertNo());
+            data.put("level", cert.getLevel());
+            data.put("year", cert.getYear());
+            data.put("seq", cert.getSeq());
+            data.put("holderName", maskName(cert.getHolderName()));
+            data.put("issuedAt", cert.getIssuedAt());
+        }
+        return data;
+    }
+
+    /** 姓名脱敏:仅保留首字 + *(如 张*、欧*),空值返回 * */
+    private String maskName(String name) {
+        if (name == null || name.isEmpty()) {
+            return "*";
+        }
+        return name.substring(0, 1) + "*";
+    }
+
     /**
      * 离场验收 6 项逐项校验(供证书领取与大屏完课进度复用)。
      * 返回:[{key,label,ok}]
@@ -121,6 +202,25 @@ public class CertService {
         return items;
     }
 
+    /**
+     * 根据 user → train_class → train_course 解析课程等级。
+     * 任一环节缺失时默认 L0。
+     */
+    private String resolveLevel(TrainUser user) {
+        if (user == null || user.getClassId() == null) {
+            return "L0";
+        }
+        TrainClass clazz = trainClassMapper.selectById(user.getClassId());
+        if (clazz == null || clazz.getCourseId() == null) {
+            return "L0";
+        }
+        TrainCourse course = trainCourseMapper.selectById(clazz.getCourseId());
+        if (course == null || course.getLevel() == null || course.getLevel().isEmpty()) {
+            return "L0";
+        }
+        return course.getLevel();
+    }
+
     private Map<String, Object> item(String key, String label, boolean ok) {
         Map<String, Object> m = new HashMap<>();
         m.put("key", key);
@@ -129,21 +229,6 @@ public class CertService {
         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(
@@ -162,4 +247,4 @@ public class CertService {
         entry.setTs(new Date());
         trainAuditLogMapper.insert(entry);
     }
-}
+}

+ 16 - 0
train-backend/src/main/resources/schema.sql

@@ -467,3 +467,19 @@ CREATE TABLE IF NOT EXISTS train_salon (
 
 -- 沙龙报名补列(存量库幂等迁移:重复执行报错被吞,属预期)
 ALTER TABLE train_enrollment ADD COLUMN salon_id BIGINT COMMENT '关联沙龙期次(train_salon.id)';
+
+-- 完课证书表(编号体系:CM-{课程码}-{年份}-{序号},幂等发号)
+CREATE TABLE IF NOT EXISTS train_certificate (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    cert_no VARCHAR(50) NOT NULL COMMENT '证书编号 CM-{课程码}-{年份}-{序号}',
+    uid BIGINT NOT NULL COMMENT '持有人(train_user.id)',
+    class_id BIGINT COMMENT '班次ID(train_class.id)',
+    level VARCHAR(10) COMMENT '课程码 L0/L1/L2/L3',
+    year INT COMMENT '颁发年份',
+    seq INT COMMENT '当年序号',
+    holder_name VARCHAR(50) COMMENT '持有人姓名快照',
+    issued_at DATETIME COMMENT '颁发时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_cert_no (cert_no),
+    INDEX idx_uid (uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='完课证书';

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

@@ -12,10 +12,22 @@
     <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">
+       <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 class="cert-verify-box">
+          <view class="verify-input-group">
+            <input class="verify-input" v-model="verifyInput" placeholder="请输入证书编号查验" />
+            <button class="verify-btn" @click="doVerify" :disabled="verifying">
+              {{ verifying ? '查验中' : '查验' }}
+            </button>
+          </view>
+          <text v-if="verifyResult" class="verify-result" :class="verifyResult.valid ? 'res-valid' : 'res-invalid'">
+            {{ verifyResult.valid ? '有效证书:' + verifyResult.certNo + '(持有人 ' + verifyResult.holderName + ')' : '未查询到该证书编号' }}
+          </text>
+        </view>
       </view>
       <view v-else-if="cert.items && cert.items.length">
         <text class="cert-progress">已完成 {{ certDoneCount }}/6 项</text>
@@ -81,7 +93,7 @@
 </template>
 
 <script>
-import { getMyCert, getMyCoupons, redeemCoupon } from '@/utils/api.js'
+import { getMyCert, getMyCoupons, redeemCoupon, verifyCert } from '@/utils/api.js'
 
 export default {
   data() {
@@ -89,7 +101,10 @@ export default {
       certLoading: true,
       cert: {},
       coupons: [],
-      redeemingId: 0
+      redeemingId: 0,
+      verifyInput: '',
+      verifyResult: null,
+      verifying: false
     }
   },
   computed: {
@@ -172,6 +187,23 @@ export default {
         }
       })
     },
+    doVerify() {
+      var self = this
+      var certNo = self.verifyInput.trim()
+      if (!certNo) {
+        uni.showToast({ title: '请输入证书编号', icon: 'none' })
+        return
+      }
+      self.verifying = true
+      self.verifyResult = null
+      verifyCert(certNo).then(function(resp) {
+        self.verifyResult = resp.data || { valid: false }
+      }).catch(function(err) {
+        uni.showToast({ title: (err && err.message) || '查验失败', icon: 'none' })
+      }).finally(function() {
+        self.verifying = false
+      })
+    },
     couponStatusText(c) {
       var map = { unused: '未使用', used: '已使用', expired: '已过期' }
       return map[c.status] || c.status
@@ -233,6 +265,14 @@ export default {
 .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-verify-box { width: 100%; margin-top: 32rpx; padding-top: 32rpx; border-top: 1rpx solid #F1F5F9; display: flex; flex-direction: column; align-items: center; }
+.verify-input-group { display: flex; width: 100%; max-width: 500rpx; align-items: center; margin-bottom: 16rpx; }
+.verify-input { flex: 1; height: 64rpx; background: #F8FAFC; border: 2rpx solid #E2E8F0; border-radius: 8rpx; padding: 0 20rpx; font-size: 26rpx; color: #1E293B; }
+.verify-btn { width: 120rpx; height: 64rpx; line-height: 64rpx; background: #F97316; color: #FFF; font-size: 26rpx; border-radius: 8rpx; border: none; padding: 0; margin-left: 16rpx; }
+.verify-btn:disabled { background: #CBD5E1; }
+.verify-result { font-size: 26rpx; text-align: center; padding: 12rpx 24rpx; border-radius: 8rpx; background: #F8FAFC; }
+.res-valid { color: #22C55E; border: 1rpx solid #BBF7D0; }
+.res-invalid { color: #94A3B8; border: 1rpx solid #E2E8F0; }
 .cert-progress { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 16rpx; }
 .cert-items { }
 .cert-item { display: flex; align-items: center; padding: 10rpx 0; }

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

@@ -214,6 +214,9 @@ export const getMyCoupons = () => {
 export const getMyCert = () => {
   return request('/api/plan/cert', 'POST')
 }
+export const verifyCert = (certNo) => {
+  return request('/api/cert/verify', 'POST', { certNo })
+}
 export const redeemCoupon = (data) => {
   return request('/api/plan/coupon/redeem', 'POST', data)
 }