Sfoglia il codice sorgente

Phase4: 沙龙流程对齐cfc - endAt+时间窗+积分+状态同步

liaoxg 1 settimana fa
parent
commit
1644106780

+ 74 - 5
train-backend/src/main/java/com/train/controller/SalonController.java

@@ -18,6 +18,7 @@ import com.train.mapper.TrainSalonCheckinMapper;
 import com.train.mapper.TrainSalonFeedbackMapper;
 import com.train.mapper.TrainSalonMapper;
 import com.train.mapper.TrainUserMapper;
+import com.train.service.ScoreService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.util.StringUtils;
@@ -30,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -55,6 +57,8 @@ public class SalonController {
     private TrainSalonCheckinMapper trainSalonCheckinMapper;
     @Resource
     private TrainSalonFeedbackMapper trainSalonFeedbackMapper;
+    @Resource
+    private ScoreService scoreService;
 
     /** 占用名额的报名状态 */
     private static final List<String> OCCUPY_STATUS = Arrays.asList("pending", "paid", "confirmed");
@@ -103,6 +107,7 @@ public class SalonController {
             row.put("num", num);
             row.put("status", salon.getStatus());
             row.put("startAt", salon.getStartAt());
+            row.put("endAt", salon.getEndAt());
             row.put("createdAt", salon.getCreatedAt());
             result.add(row);
         }
@@ -230,6 +235,10 @@ public class SalonController {
             row.put("orderNo", order == null ? null : order.getOrderNo());
             // 沙龙状态(前端据此决定是否显示签到入口)
             row.put("salonStatus", s == null ? null : s.getStatus());
+            row.put("salonStartAt", s == null ? null : s.getStartAt());
+            row.put("salonEndAt", s == null ? null : s.getEndAt());
+            // 沙龙展示状态(对齐 cfc applyDisplayState 语义):ended/ongoing/pending_start/active/draft
+            row.put("displayStatus", calcDisplayStatus(s));
             // 已签到标记
             Long checkedIn = trainSalonCheckinMapper.selectCount(
                     new LambdaQueryWrapper<TrainSalonCheckin>()
@@ -250,11 +259,12 @@ public class SalonController {
 
     /**
      * 沙龙现场签到(需已报名且已支付/已确认;幂等,重复签到返回 true)
+     * 规则对齐 cfc 活动签到:仅在时间窗 [startAt, endAt] 内可签,签到发放积分(checkinPoints)
      */
     @Operation(summary = "沙龙签到")
     @PostMapping("/checkin")
-    public Result<Boolean> checkin(@RequestBody Map<String, Object> body,
-                                   @RequestAttribute("userId") Long userId) {
+    public Result<Map<String, Object>> checkin(@RequestBody Map<String, Object> body,
+                                               @RequestAttribute("userId") Long userId) {
         if (body.get("salonId") == null) {
             return Result.error("请选择沙龙期次");
         }
@@ -266,6 +276,14 @@ public class SalonController {
         if (!"active".equals(salon.getStatus())) {
             return Result.error("该沙龙未在进行中");
         }
+        // 时间窗校验(对齐 cfc 活动签到):开始前/结束后均不可签到
+        Date now = new Date();
+        if (salon.getStartAt() != null && now.before(salon.getStartAt())) {
+            return Result.error("沙龙尚未开始,暂不能签到");
+        }
+        if (salon.getEndAt() != null && now.after(salon.getEndAt())) {
+            return Result.error("沙龙已结束,不能签到");
+        }
         // 报名且已支付/已确认才可签到
         Long enrolled = trainEnrollmentMapper.selectCount(
                 new LambdaQueryWrapper<TrainEnrollment>()
@@ -275,13 +293,16 @@ public class SalonController {
         if (enrolled == null || enrolled == 0) {
             return Result.error("请先报名并完成支付");
         }
-        // 幂等:已签到直接返回成功
+        // 幂等:已签到直接返回成功(积分已被发放,pointsEarned=0)
         Long existed = trainSalonCheckinMapper.selectCount(
                 new LambdaQueryWrapper<TrainSalonCheckin>()
                         .eq(TrainSalonCheckin::getUid, userId)
                         .eq(TrainSalonCheckin::getSalonId, salonId));
         if (existed != null && existed > 0) {
-            return Result.success(true);
+            Map<String, Object> done = new HashMap<>();
+            done.put("pointsEarned", 0);
+            done.put("alreadyCompleted", true);
+            return Result.success(done);
         }
         TrainSalonCheckin checkin = new TrainSalonCheckin();
         checkin.setUid(userId);
@@ -291,7 +312,29 @@ public class SalonController {
         } catch (Exception ex) {
             // 唯一键冲突(并发重复签到)按已签到处理
         }
-        return Result.success(true);
+        // 签到积分:取关联 cfc 活动的 checkinPoints(创建时默认 5),未关联则用默认 5
+        int points = 5;
+        if (salon.getActivityId() != null) {
+            try {
+                CfcActivity activity = cfcActivityMapper.selectById(salon.getActivityId());
+                if (activity != null && activity.getCheckinPoints() != null && activity.getCheckinPoints() > 0) {
+                    points = activity.getCheckinPoints();
+                }
+            } catch (Exception e) {
+                // 读取 cfc 活动失败用默认积分
+            }
+        }
+        String title = salon.getTitle() == null ? "" : salon.getTitle();
+        try {
+            scoreService.addUserScoreIfAbsent(userId, "salon_checkin_" + salonId, points, userId, "沙龙签到:" + title);
+        } catch (Exception e) {
+            // 积分发放失败不阻塞签到
+            org.slf4j.LoggerFactory.getLogger(getClass()).warn("沙龙签到积分发放失败: {}", e.getMessage());
+        }
+        Map<String, Object> result = new HashMap<>();
+        result.put("pointsEarned", points);
+        result.put("alreadyCompleted", false);
+        return Result.success(result);
     }
 
     /**
@@ -352,4 +395,30 @@ public class SalonController {
         }
         return Result.success(true);
     }
+
+    /**
+     * 沙龙展示状态(对齐 cfc 活动 applyDisplayState 语义):
+     * ended=已结束 / ongoing=进行中(时间窗内) / pending_start=未开始 / active=进行中(无时间窗) / draft=草稿
+     */
+    private String calcDisplayStatus(TrainSalon s) {
+        if (s == null) {
+            return null;
+        }
+        String status = s.getStatus();
+        if ("draft".equals(status)) {
+            return "draft";
+        }
+        Date now = new Date();
+        if ("finished".equals(status)
+                || (s.getEndAt() != null && now.after(s.getEndAt()))) {
+            return "ended";
+        }
+        if (!"active".equals(status)) {
+            return status;
+        }
+        if (s.getStartAt() != null && now.before(s.getStartAt())) {
+            return "pending_start";
+        }
+        return "ongoing";
+    }
 }

+ 41 - 3
train-backend/src/main/java/com/train/controller/admin/AdminSalonController.java

@@ -67,7 +67,7 @@ public class AdminSalonController {
                 price = 6800;
             }
         }
-        // 开始时间:优先 yyyy-MM-dd HH:mm:ss,其次 yyyy-MM-dd,解析失败留空
+// 开始时间:优先 yyyy-MM-dd HH:mm:ss,其次 yyyy-MM-dd,解析失败留空
         Date startAt = null;
         if (body.get("startAt") != null && !body.get("startAt").toString().trim().isEmpty()) {
             String startAtStr = body.get("startAt").toString().trim();
@@ -81,6 +81,20 @@ public class AdminSalonController {
                 }
             }
         }
+        // 结束时间:同 startAt 解析规则,可留空(留空则签到不限结束时间,需手动结束沙龙)
+        Date endAt = null;
+        if (body.get("endAt") != null && !body.get("endAt").toString().trim().isEmpty()) {
+            String endAtStr = body.get("endAt").toString().trim();
+            try {
+                endAt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(endAtStr);
+            } catch (ParseException e) {
+                try {
+                    endAt = new SimpleDateFormat("yyyy-MM-dd").parse(endAtStr);
+                } catch (ParseException ex) {
+                    endAt = null;
+                }
+            }
+        }
         TrainSalon salon = new TrainSalon();
         salon.setTheme(theme);
         salon.setTitle(title);
@@ -90,6 +104,7 @@ public class AdminSalonController {
         salon.setStatus("active");
         salon.setPrice(price);
         salon.setStartAt(startAt);
+        salon.setEndAt(endAt);
         Date now = new Date();
         salon.setCreatedAt(now);
         salon.setUpdatedAt(now);
@@ -104,6 +119,8 @@ public class AdminSalonController {
             activity.setStatus("published");
             activity.setAuditStatus("approved");
             activity.setLocation(place);
+            activity.setStartTime(startAt);
+            activity.setEndTime(endAt);
             activity.setMaxParticipants(capacity);
             activity.setPrice(price);
             activity.setMemberPrice(price);
@@ -167,8 +184,9 @@ public class AdminSalonController {
             row.put("memberPrice", memberPrice);
             row.put("occupied", occupied);
             row.put("num", num);
-            row.put("status", s.getStatus());
+row.put("status", s.getStatus());
             row.put("startAt", s.getStartAt());
+            row.put("endAt", s.getEndAt());
             row.put("createdAt", s.getCreatedAt());
             result.add(row);
         }
@@ -189,13 +207,33 @@ public class AdminSalonController {
         if (!"draft".equals(status) && !"active".equals(status) && !"finished".equals(status)) {
             return Result.error("非法状态");
         }
-        TrainSalon salon = trainSalonMapper.selectById(id);
+TrainSalon salon = trainSalonMapper.selectById(id);
         if (salon == null) {
             return Result.error("沙龙不存在");
         }
         salon.setStatus(status);
         salon.setUpdatedAt(new Date());
         trainSalonMapper.updateById(salon);
+        // 状态联动 cfc 活动:finished→ended、active→published,与 cfc 活动流程保持同步
+        if (salon.getActivityId() != null) {
+            try {
+                CfcActivity activity = cfcActivityMapper.selectById(salon.getActivityId());
+                if (activity != null) {
+                    String cfcStatus = "draft";
+                    if ("active".equals(status)) {
+                        cfcStatus = "published";
+                    } else if ("finished".equals(status)) {
+                        cfcStatus = "ended";
+                    }
+                    activity.setStatus(cfcStatus);
+                    activity.setUpdatedAt(new Date());
+                    cfcActivityMapper.updateById(activity);
+                }
+            } catch (Exception e) {
+                org.slf4j.LoggerFactory.getLogger(getClass())
+                        .warn("同步 cfc 活动状态失败: {}", e.getMessage());
+            }
+        }
         return Result.success(true);
     }
 

+ 1 - 0
train-backend/src/main/java/com/train/entity/TrainSalon.java

@@ -28,6 +28,7 @@ public class TrainSalon implements Serializable {
     private Integer price; // 价格(分),默认 6800
     private String status; // draft/active/finished
     private Date startAt;
+    private Date endAt;
     private Date createdAt;
     private Date updatedAt;
 }

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

@@ -492,12 +492,16 @@ CREATE TABLE IF NOT EXISTS train_salon (
     price INT DEFAULT 6800 COMMENT '价格(分)',
     status VARCHAR(20) DEFAULT 'active' COMMENT 'draft/active/finished',
     start_at DATETIME COMMENT '开始时间',
+    end_at DATETIME COMMENT '结束时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_status (status),
     INDEX idx_activity_id (activity_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='沙龙期次';
 
+-- 沙龙结束时间补列(存量库幂等迁移:重复执行报错被吞,属预期)
+ALTER TABLE train_salon ADD COLUMN end_at DATETIME COMMENT '结束时间';
+
 -- 沙龙报名补列(存量库幂等迁移:重复执行报错被吞,属预期)
 ALTER TABLE train_enrollment ADD COLUMN salon_id BIGINT COMMENT '关联沙龙期次(train_salon.id)';
 

+ 28 - 7
train-frontend/pages/salon/mine.vue

@@ -10,7 +10,10 @@
         <view class="salon-card" v-for="item in list" :key="item.enrollmentId">
           <view class="salon-head">
             <text class="salon-theme">{{ mapTheme(item.theme) }}</text>
-            <text class="salon-status" v-if="item.checkedIn">已签到</text>
+            <view class="head-right">
+              <text class="salon-state" :class="stateClass(item.displayStatus)">{{ stateText(item.displayStatus) }}</text>
+              <text class="salon-status" v-if="item.checkedIn">已签到</text>
+            </view>
           </view>
           <text class="salon-title">{{ item.title }}</text>
 
@@ -94,10 +97,20 @@ export default {
       }
       return map[status] || status || ''
     },
+    stateText: function(s) {
+      var map = { ended: '已结束', ongoing: '进行中', pending_start: '未开始', active: '进行中', draft: '草稿' }
+      return map[s] || s || ''
+    },
+    stateClass: function(s) {
+      if (s === 'ended' || s === 'draft') return 'state-end'
+      if (s === 'pending_start') return 'state-wait'
+      return 'state-go'
+    },
     canCheckin: function(item) {
-      // 已支付/已确认 且 沙龙进行中 且 未签到
-      return (item.status === 'paid' || item.status === 'confirmed') &&
-        item.salonStatus === 'active' && !item.checkedIn
+      // 已支付/已确认 + 时间窗内(displayStatus=ongoing)+ 未签到
+      var inTime = item.displayStatus === 'ongoing'
+      if (!inTime && !item.displayStatus) { inTime = item.salonStatus === 'active' }
+      return (item.status === 'paid' || item.status === 'confirmed') && inTime && !item.checkedIn
     },
     canFeedback: function(item) {
       // 已支付/已确认即可反馈(已提交则入口改为查看)
@@ -121,12 +134,15 @@ export default {
       var self = this
       if (self.checking) return
       self.checking = true
-      salonCheckin(item.salonId).then(function() {
-        uni.showToast({ title: '签到成功', icon: 'success' })
+      salonCheckin(item.salonId).then(function(resp) {
+        var data = resp && resp.data ? resp.data : {}
+        var pts = data.pointsEarned || 0
+        var msg = pts > 0 ? '签到成功,获得 ' + pts + ' 积分' : '签到成功'
+        uni.showToast({ title: msg, icon: 'success' })
         item.checkedIn = true
+        self.loadMine()
       }).catch(function(err) {
         uni.showToast({ title: (err && err.message) || '签到失败', icon: 'none' })
-        self.loadMine()
       }).finally(function() {
         self.checking = false
       })
@@ -154,6 +170,11 @@ export default {
 .empty-tip { font-size: 26rpx; color: #94A3B8; padding: 32rpx 0; text-align: center; }
 .salon-card { border: 2rpx solid #E2E8F0; border-radius: 12rpx; padding: 24rpx; margin-bottom: 20rpx; }
 .salon-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }
+  .head-right { display: flex; align-items: center; gap: 12rpx; }
+  .salon-state { font-size: 22rpx; font-weight: 600; padding: 2rpx 12rpx; border-radius: 4rpx; }
+  .state-go { background: #DCFCE7; color: #16A34A; }
+  .state-wait { background: #FFF7ED; color: #F97316; }
+  .state-end { background: #F1F5F9; color: #94A3B8; }
 .salon-theme { display: block; background: #FFF7ED; color: #F97316; font-size: 22rpx; font-weight: 600; padding: 4rpx 12rpx; border-radius: 4rpx; }
 .salon-status { font-size: 22rpx; color: #22C55E; font-weight: 600; }
 .salon-title { display: block; font-size: 30rpx; font-weight: 600; color: #1E293B; margin-bottom: 12rpx; }

+ 14 - 2
train-web/src/views/Salons.vue

@@ -26,6 +26,7 @@
         </template>
       </el-table-column>
       <el-table-column prop="startAt" label="开始时间" width="170" :formatter="formatTime" />
+      <el-table-column prop="endAt" label="结束时间" width="170" :formatter="formatTime" />
       <el-table-column prop="createdAt" label="创建时间" width="170" :formatter="formatTime" />
       <el-table-column label="操作" width="200">
         <template slot-scope="{ row }">
@@ -70,6 +71,15 @@
             style="width:100%"
           />
         </el-form-item>
+        <el-form-item label="结束时间">
+          <el-date-picker 
+            v-model="createForm.endAt" 
+            type="datetime" 
+            placeholder="选择结束时间" 
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width:100%"
+          />
+        </el-form-item>
       </el-form>
       <div slot="footer">
         <el-button @click="showCreateDialog = false">取消</el-button>
@@ -148,7 +158,8 @@ export default {
         place: '',
         capacity: 30,
         price: 68,
-        startAt: ''
+        startAt: '',
+        endAt: ''
       },
       createRules: {
         theme: [{ required: true, message: '请选择主题', trigger: 'change' }],
@@ -194,7 +205,8 @@ export default {
         place: '',
         capacity: 30,
         price: 68,
-        startAt: ''
+        startAt: '',
+        endAt: ''
       }
     },
     handleCreate: function () {