ActivityService.java 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. package com.etotem.cfc.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  4. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  5. import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
  6. import com.etotem.cfc.common.Result;
  7. import com.etotem.cfc.dto.ActivityDTO;
  8. import com.etotem.cfc.entity.Activity;
  9. import com.etotem.cfc.entity.ActivityRegistration;
  10. import com.etotem.cfc.entity.FamilyMember;
  11. import com.etotem.cfc.entity.PointsLog;
  12. import com.etotem.cfc.entity.Task;
  13. import com.etotem.cfc.entity.User;
  14. import com.etotem.cfc.mapper.ActivityMapper;
  15. import com.etotem.cfc.mapper.FamilyMemberMapper;
  16. import com.etotem.cfc.mapper.PointsLogMapper;
  17. import com.etotem.cfc.mapper.TaskMapper;
  18. import com.etotem.cfc.mapper.UserMapper;
  19. import lombok.extern.slf4j.Slf4j;
  20. import org.springframework.scheduling.annotation.Scheduled;
  21. import org.springframework.stereotype.Service;
  22. import org.springframework.transaction.annotation.Transactional;
  23. import org.springframework.util.StringUtils;
  24. import javax.annotation.Resource;
  25. import java.util.ArrayList;
  26. import java.util.Collections;
  27. import java.util.Date;
  28. import java.util.HashMap;
  29. import java.util.HashSet;
  30. import java.util.List;
  31. import java.util.Map;
  32. import java.util.Set;
  33. import java.util.stream.Collectors;
  34. import com.etotem.cfc.util.DimensionCodes;
  35. import com.etotem.cfc.util.SortUtil;
  36. @Slf4j
  37. @Service
  38. public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
  39. @Resource
  40. private TaskMapper taskMapper;
  41. @Resource
  42. private FamilyMemberMapper familyMemberMapper;
  43. @Resource
  44. private PointsLogMapper pointsLogMapper;
  45. @Resource
  46. private EnergyService energyService;
  47. @Resource
  48. private SysConfigService sysConfigService;
  49. @Resource
  50. private ActivityRegistrationService registrationService;
  51. @Resource
  52. private MembershipService membershipService;
  53. @Resource
  54. private UserMapper userMapper;
  55. @Resource
  56. private PlatformPointsService platformPointsService;
  57. public Result<Map<String, Object>> list(String dimensionCode, Integer page, Integer size, Long userId) {
  58. autoEndExpiredActivities();
  59. LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<Activity>()
  60. .eq(Activity::getStatus, "published")
  61. .orderByAsc(Activity::getStartTime);
  62. if (dimensionCode != null && !dimensionCode.isEmpty() && DimensionCodes.isValid(dimensionCode)) {
  63. String dim = dimensionCode.trim();
  64. // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 dimension_code
  65. query.and(w -> w
  66. .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
  67. .or().eq(Activity::getDimensionCode, dim)
  68. );
  69. }
  70. // 游客过滤:只返回公开活动
  71. if (userId == null) {
  72. query.and(w -> w.eq(Activity::getVisibility, "public")
  73. .or().eq(Activity::getVisibility, "all")
  74. .or().isNull(Activity::getVisibility));
  75. }
  76. SortUtil.applySort(query);
  77. Page<Activity> pageResult = this.page(new Page<>(page, size), query);
  78. boolean isGuest = (userId == null || userId <= 0);
  79. Long effectiveUserId = isGuest ? null : userId;
  80. String memberLevel = null;
  81. if (!isGuest) {
  82. try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
  83. }
  84. final String level = memberLevel;
  85. List<Activity> records = pageResult.getRecords();
  86. List<Long> activityIds = records.stream().map(Activity::getId).collect(Collectors.toList());
  87. Map<Long, String> regStatusMap = new HashMap<>();
  88. Set<Long> checkedInActivityIds = new HashSet<>();
  89. if (effectiveUserId != null && !activityIds.isEmpty()) {
  90. regStatusMap = registrationService.getRegistrationStatusMap(activityIds, effectiveUserId);
  91. List<Long> childIds = registrationService.getChildIdsByUser(effectiveUserId);
  92. if (!childIds.isEmpty()) {
  93. checkedInActivityIds = taskMapper.selectList(new LambdaQueryWrapper<Task>()
  94. .eq(Task::getSourceType, "activity")
  95. .in(Task::getSourceId, activityIds)
  96. .in(Task::getChildId, childIds)
  97. .eq(Task::getStatus, "completed"))
  98. .stream().map(Task::getSourceId).collect(Collectors.toSet());
  99. }
  100. }
  101. final Map<Long, String> regMap = regStatusMap;
  102. final Set<Long> checkedMap = checkedInActivityIds;
  103. List<ActivityDTO> dtos = records.stream().map(a -> {
  104. ActivityDTO dto = ActivityDTO.from(a, isGuest, level);
  105. String regStatus = regMap.getOrDefault(a.getId(), "not_registered");
  106. applyDisplayState(dto, a, regStatus, checkedMap.contains(a.getId()));
  107. return dto;
  108. }).collect(Collectors.toList());
  109. Map<String, Object> data = new HashMap<>();
  110. data.put("records", dtos);
  111. data.put("total", pageResult.getTotal());
  112. data.put("page", pageResult.getCurrent());
  113. data.put("size", pageResult.getSize());
  114. return Result.success(data);
  115. }
  116. public Result<ActivityDTO> detail(Long id, Long userId) {
  117. Activity activity = this.getById(id);
  118. if (activity == null) {
  119. return Result.error("活动不存在");
  120. }
  121. boolean isGuest = (userId == null || userId <= 0);
  122. String memberLevel = null;
  123. if (!isGuest) {
  124. try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
  125. }
  126. ActivityDTO dto = ActivityDTO.from(activity, isGuest, memberLevel);
  127. // applyDisplayState must run for ALL users (including guests) so that
  128. // displayStatus / displayLabel / registrationStatus are always populated.
  129. // For guests: registrationStatus=null, checkedIn=false → defaults to "open"/"not_registered".
  130. if (!isGuest) {
  131. String regStatus = registrationService.getUserRegistrationStatus(activity.getId(), userId);
  132. boolean checkedIn = hasCheckedIn(activity.getId(), userId);
  133. applyDisplayState(dto, activity, regStatus, checkedIn);
  134. } else {
  135. applyDisplayState(dto, activity, null, false);
  136. }
  137. return Result.success(dto);
  138. }
  139. public Result<ActivityDTO> create(Activity activity, Long creatorId) {
  140. activity.setStatus("draft");
  141. activity.setCurrentParticipants(0);
  142. activity.setApprovedCount(0);
  143. activity.setSignedCount(0);
  144. activity.setCreatorId(creatorId);
  145. activity.setCreatedAt(new Date());
  146. activity.setUpdatedAt(new Date());
  147. this.save(activity);
  148. return Result.success(ActivityDTO.from(activity));
  149. }
  150. public Result<ActivityDTO> update(Activity activity) {
  151. Activity existing = this.getById(activity.getId());
  152. if (existing == null) {
  153. return Result.error("活动不存在");
  154. }
  155. activity.setUpdatedAt(new Date());
  156. this.updateById(activity);
  157. return Result.success(ActivityDTO.from(this.getById(activity.getId())));
  158. }
  159. public Result<String> publish(Long id) {
  160. Activity activity = this.getById(id);
  161. if (activity == null) {
  162. return Result.error("活动不存在");
  163. }
  164. if (!"draft".equals(activity.getStatus())) {
  165. return Result.error("仅草稿状态可发布");
  166. }
  167. // P1-2: 发布前校验维度代码,两路(直发/审核流)统一要求 dimensionCode 非空
  168. if (activity.getDimensionCode() == null || activity.getDimensionCode().trim().isEmpty()) {
  169. return Result.error("请先设置活动维度(dimensionCode)");
  170. }
  171. activity.setStatus("published");
  172. activity.setUpdatedAt(new Date());
  173. this.updateById(activity);
  174. return Result.success("发布成功");
  175. }
  176. public Result<String> end(Long id) {
  177. Activity activity = this.getById(id);
  178. if (activity == null) {
  179. return Result.error("活动不存在");
  180. }
  181. activity.setStatus("ended");
  182. activity.setUpdatedAt(new Date());
  183. this.updateById(activity);
  184. return Result.success("活动已结束");
  185. }
  186. @Transactional
  187. public Map<String, Object> checkin(Long activityId, Long userId, Long memberId) {
  188. Map<String, Object> result = new HashMap<>();
  189. result.put("taskId", null);
  190. result.put("pointsEarned", 0);
  191. // 1. Validate activity exists and is active
  192. Activity activity = this.getById(activityId);
  193. if (activity == null) {
  194. result.put("error", "活动不存在");
  195. return result;
  196. }
  197. if (!"published".equals(activity.getStatus())) {
  198. result.put("error", "活动未发布或已结束");
  199. return result;
  200. }
  201. // 1.5 Time window check — checkin only allowed during the activity
  202. Date now = new Date();
  203. if (activity.getStartTime() != null && now.before(activity.getStartTime())) {
  204. result.put("error", "活动尚未开始,暂不能签到");
  205. return result;
  206. }
  207. if (activity.getEndTime() != null && now.after(activity.getEndTime())) {
  208. result.put("error", "活动已结束,不能签到");
  209. return result;
  210. }
  211. // 2. If no memberId, can't create task
  212. if (memberId == null) {
  213. result.put("error", "需要孩子身份签到");
  214. return result;
  215. }
  216. // 2.5 Identity: phone child self-checkin only; phone-less child allows same-family proxy
  217. FamilyMember child = familyMemberMapper.selectById(memberId);
  218. if (child == null) {
  219. result.put("error", "孩子不存在");
  220. return result;
  221. }
  222. boolean childHasPhone = StringUtils.hasText(child.getPhone());
  223. if (childHasPhone) {
  224. boolean isSelf = child.getUserId() != null && child.getUserId().equals(userId);
  225. if (!isSelf) {
  226. result.put("error", "有手机号的孩子只能本人签到");
  227. return result;
  228. }
  229. } else {
  230. User operator = userMapper.selectById(userId);
  231. boolean sameFamily = operator != null && operator.getFamilyId() != null
  232. && operator.getFamilyId().equals(child.getFamilyId());
  233. if (!sameFamily) {
  234. result.put("error", "无权限代签(需同一家庭)");
  235. return result;
  236. }
  237. }
  238. // 3. Registration check (if activity requires registration)
  239. boolean unregistered = false;
  240. if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 1) {
  241. boolean isApproved = registrationService.isApproved(activityId, memberId);
  242. boolean isRegistered = registrationService.isRegistered(activityId, memberId);
  243. if (!isApproved) {
  244. if (isRegistered) {
  245. result.put("error", "报名待审核,请等待审核通过");
  246. return result;
  247. }
  248. // Not registered at all — apply energy penalty, allow checkin anyway
  249. unregistered = true;
  250. Integer penaltyAmount = activity.getEnergyPenaltyUnregistered();
  251. if (penaltyAmount != null && penaltyAmount > 0) {
  252. try {
  253. int deducted = energyService.deductEnergyByCode(
  254. memberId,
  255. activity.getDimensionCode(),
  256. penaltyAmount,
  257. "未报名签到活动《" + activity.getTitle() + "》"
  258. );
  259. if (deducted >= 0) {
  260. result.put("energyPenalty", penaltyAmount);
  261. log.info("未报名签到能量扣减: memberId={}, activityId={}, penalty={}, dimension={}",
  262. memberId, activityId, penaltyAmount, activity.getDimensionCode());
  263. } else {
  264. result.put("energyPenaltyFailed", "余额不足");
  265. log.warn("未报名签到能量扣减失败(余额不足): memberId={}, activityId={}",
  266. memberId, activityId);
  267. }
  268. } catch (Exception e) {
  269. log.warn("能量扣减异常: memberId={}, activityId={}, error={}",
  270. memberId, activityId, e.getMessage());
  271. result.put("energyPenaltyFailed", e.getMessage());
  272. }
  273. }
  274. }
  275. }
  276. // 4. Check if already checked in (dedup by sourceType + sourceId + childId)
  277. Task existingTask = taskMapper.selectOne(
  278. new LambdaQueryWrapper<Task>()
  279. .eq(Task::getSourceType, "activity")
  280. .eq(Task::getSourceId, activityId)
  281. .eq(Task::getChildId, memberId)
  282. .last("LIMIT 1")
  283. );
  284. if (existingTask != null) {
  285. result.put("taskId", existingTask.getId());
  286. result.put("pointsEarned", 0);
  287. result.put("alreadyCompleted", true);
  288. return result;
  289. }
  290. // 5. Determine points — use activity's checkinPoints or sysconfig default
  291. int points = 5; // default
  292. if (activity.getCheckinPoints() != null && activity.getCheckinPoints() > 0) {
  293. points = activity.getCheckinPoints();
  294. } else {
  295. String defaultPointsStr = sysConfigService.getValue("activity_checkin_default_points");
  296. if (defaultPointsStr != null && !defaultPointsStr.isEmpty()) {
  297. try { points = Integer.parseInt(defaultPointsStr); } catch (NumberFormatException e) { /* use default */ }
  298. }
  299. }
  300. // 是否需要审核签到(活动级开关,默认免审)
  301. boolean needReview = activity.getRequireCheckinReview() != null && activity.getRequireCheckinReview() == 1;
  302. // 6. 创建签到任务;开启审核时进入 pending_review,待后台审核通过才发积分/能量
  303. Task task = new Task();
  304. if (child != null) {
  305. task.setFamilyId(child.getFamilyId());
  306. }
  307. task.setCreatorId(userId);
  308. task.setExecutorType("child"); // 非登录用户(不可登录成员)
  309. task.setExecutorId(memberId);
  310. task.setChildId(memberId);
  311. task.setTitle("活动签到: " + activity.getTitle());
  312. task.setDescription("自动生成 - 签到活动《" + activity.getTitle() + "》");
  313. task.setPoints(points);
  314. task.setRepeatType("none");
  315. task.setCategory("行动类");
  316. task.setNeedReview(needReview ? 1 : 0);
  317. task.setReviewByCategory(0);
  318. task.setTaskType("onetime");
  319. task.setSourceType("activity");
  320. task.setSourceId(activityId);
  321. if (needReview) {
  322. task.setStatus("pending_review");
  323. } else {
  324. task.setStatus("completed");
  325. task.setCompletedAt(new Date());
  326. }
  327. task.setCreatedAt(new Date());
  328. task.setUpdatedAt(new Date());
  329. // Set deadline to today end
  330. java.util.Calendar cal = java.util.Calendar.getInstance();
  331. cal.set(java.util.Calendar.HOUR_OF_DAY, 23);
  332. cal.set(java.util.Calendar.MINUTE, 59);
  333. cal.set(java.util.Calendar.SECOND, 59);
  334. task.setDeadline(cal.getTime());
  335. taskMapper.insert(task);
  336. // 7. 签到计数(无论是否需审核都计入 signedCount)
  337. activity.setSignedCount(activity.getSignedCount() != null ? activity.getSignedCount() + 1 : 1);
  338. activity.setUpdatedAt(new Date());
  339. this.updateById(activity);
  340. // 8. 发放奖励:免审直接发放;需审核在审核通过(reviewCheckin)时发放
  341. if (needReview) {
  342. result.put("needReview", true);
  343. result.put("pointsEarned", 0);
  344. log.info("活动签到进入待审核: activityId={}, memberId={}, taskId={}", activityId, memberId, task.getId());
  345. } else {
  346. awardCheckinRewards(activity, child, task, userId, points);
  347. result.put("needReview", false);
  348. result.put("pointsEarned", points);
  349. }
  350. // 9. Return result
  351. result.put("taskId", task.getId());
  352. return result;
  353. }
  354. /**
  355. * 签到奖励发放(积分/能量/CF)。免审签到在 checkin 内调用;需审核签到在审核通过时调用。
  356. */
  357. private void awardCheckinRewards(Activity activity, FamilyMember child, Task task, Long userId, int points) {
  358. // 更新孩子总积分
  359. if (child != null) {
  360. child.setTotalPoints(child.getTotalPoints() + points);
  361. child.setUpdatedAt(new Date());
  362. familyMemberMapper.updateById(child);
  363. }
  364. // 积分流水
  365. PointsLog pointsLog = new PointsLog();
  366. pointsLog.setChildId(task.getChildId());
  367. pointsLog.setTaskId(task.getId());
  368. pointsLog.setAmount(points);
  369. pointsLog.setType("earn");
  370. pointsLog.setCategory("task");
  371. pointsLog.setDescription("活动签到: " + activity.getTitle());
  372. pointsLog.setCreatedAt(new Date());
  373. pointsLogMapper.insert(pointsLog);
  374. // 发放能量
  375. try {
  376. energyService.awardEnergy(task.getChildId(), "activity", activity.getId(), points,
  377. "活动签到: " + activity.getTitle(), null);
  378. } catch (Exception e) {
  379. log.warn("能量发放失败(不影响积分): childId={}, activityId={}, error={}",
  380. task.getChildId(), activity.getId(), e.getMessage());
  381. }
  382. // 活动下发 CF 值(平台积分)
  383. if (activity.getPlatformPoints() != null && activity.getPlatformPoints() > 0 && userId != null) {
  384. try {
  385. platformPointsService.earn(userId, activity.getPlatformPoints(),
  386. "activity", activity.getId(), "活动签到: " + activity.getTitle());
  387. } catch (Exception e) {
  388. log.warn("活动CF值发放失败: userId={}, activityId={}, error={}",
  389. userId, activity.getId(), e.getMessage());
  390. }
  391. }
  392. }
  393. /**
  394. * 审核签到(活动级 requireCheckinReview 开启时):审批通过发放积分/能量/CF;驳回则不发。
  395. */
  396. @Transactional
  397. public Result<Map<String, Object>> reviewCheckin(Long taskId, Long reviewerId, boolean approve) {
  398. Task task = taskMapper.selectById(taskId);
  399. if (task == null) return Result.error("签到任务不存在");
  400. if (!"activity".equals(task.getSourceType())) return Result.error("非活动签到任务");
  401. if (!"pending_review".equals(task.getStatus())) return Result.error("当前状态不可审核");
  402. Map<String, Object> result = new HashMap<>();
  403. if (approve) {
  404. Activity activity = this.getById(task.getSourceId());
  405. FamilyMember child = task.getChildId() != null ? familyMemberMapper.selectById(task.getChildId()) : null;
  406. task.setStatus("completed");
  407. task.setCompletedAt(new Date());
  408. task.setUpdatedAt(new Date());
  409. taskMapper.updateById(task);
  410. int points = task.getPoints() != null ? task.getPoints() : 5;
  411. if (activity != null) {
  412. awardCheckinRewards(activity, child, task, task.getCreatorId(), points);
  413. }
  414. result.put("approved", true);
  415. return Result.success(result);
  416. } else {
  417. task.setStatus("rejected");
  418. task.setUpdatedAt(new Date());
  419. taskMapper.updateById(task);
  420. result.put("approved", false);
  421. return Result.success(result);
  422. }
  423. }
  424. /**
  425. * 自动结束过期活动
  426. * 逻辑:发布中且结束时间已过(endTime < now),将状态置为 ended。
  427. * 备注:正式上线建议改为 Spring @Scheduled(cron = "0 0 2 * * ?") 每日 02:00 执行(见 TODOs),此处保留方法供 list/detail 触发兜底。
  428. */
  429. /**
  430. * 自动结束过期活动(P2-1 定时化)
  431. * 每日 02:00 定时执行,结束阈值 = endTime < now()(去掉旧的 7 天延迟)。
  432. * 同时保留在 list/detail 查询时轻量调用兜底(实时状态判断)。
  433. */
  434. @Scheduled(cron = "0 0 2 * * ?")
  435. public void autoEndExpiredActivities() {
  436. try {
  437. Date now = new Date();
  438. this.update(new LambdaUpdateWrapper<Activity>()
  439. .eq(Activity::getStatus, "published")
  440. .lt(Activity::getEndTime, now)
  441. .set(Activity::getStatus, "ended")
  442. .set(Activity::getUpdatedAt, now));
  443. } catch (Exception e) {
  444. log.warn("自动结束过期活动失败: {}", e.getMessage());
  445. }
  446. }
  447. /**
  448. * 相关活动:按标签匹配(共享标签数降序 → 开始时间升序,与 {@link #list} 排序一致),
  449. * 排除自身,仅取已发布。游客(userId 为空)仅返回公开活动(与 list 的游客策略一致)。
  450. * 支持分页窗口截取。
  451. *
  452. * @param tags 源标签集合(来自文章/活动/商品)
  453. * @param excludeActivityId 需排除的活动ID(活动源排除自身;其他源为 null)
  454. * @param size 每页条数
  455. * @param page 页码(从1开始)
  456. * @param userId 当前用户ID(可为空)
  457. * @return 相关活动DTO列表,无匹配返回空列表
  458. */
  459. public List<ActivityDTO> getRelatedActivitiesByTags(List<String> tags, Long excludeActivityId,
  460. int size, int page, Long userId) {
  461. if (tags == null || tags.isEmpty() || size < 1) {
  462. return Collections.emptyList();
  463. }
  464. int p = page < 1 ? 1 : page;
  465. boolean isGuest = (userId == null || userId <= 0);
  466. LambdaQueryWrapper<Activity> wrapper = new LambdaQueryWrapper<Activity>()
  467. .eq(Activity::getStatus, "published");
  468. if (excludeActivityId != null) {
  469. wrapper.ne(Activity::getId, excludeActivityId);
  470. }
  471. wrapper.and(w -> {
  472. boolean first = true;
  473. for (String tag : tags) {
  474. if (first) {
  475. w.apply("FIND_IN_SET({0}, tags) > 0", tag);
  476. first = false;
  477. } else {
  478. w.or().apply("FIND_IN_SET({0}, tags) > 0", tag);
  479. }
  480. }
  481. });
  482. // 游客策略与 list() 一致:只返回公开/全部/未设置可见性的活动
  483. if (isGuest) {
  484. wrapper.and(w -> w.eq(Activity::getVisibility, "public")
  485. .or().eq(Activity::getVisibility, "all")
  486. .or().isNull(Activity::getVisibility));
  487. }
  488. List<Activity> candidates = this.list(wrapper);
  489. // 共享标签计数,剔除零共享(SQL 已限定至少一个标签命中,此处防御)
  490. Map<Long, Integer> score = new HashMap<>();
  491. List<Activity> matched = new ArrayList<>();
  492. for (Activity a : candidates) {
  493. if (excludeActivityId != null && a.getId() != null && a.getId().equals(excludeActivityId)) {
  494. continue;
  495. }
  496. int s = 0;
  497. if (a.getTags() != null) {
  498. for (String t : a.getTags().split(",")) {
  499. if (tags.contains(t.trim())) {
  500. s++;
  501. }
  502. }
  503. }
  504. if (s > 0) {
  505. matched.add(a);
  506. score.put(a.getId(), s);
  507. }
  508. }
  509. matched.sort((x, y) -> {
  510. int diff = score.getOrDefault(y.getId(), 0) - score.getOrDefault(x.getId(), 0);
  511. if (diff != 0) {
  512. return diff;
  513. }
  514. Date px = x.getStartTime();
  515. Date py = y.getStartTime();
  516. if (px == null && py == null) return 0;
  517. if (px == null) return 1;
  518. if (py == null) return -1;
  519. return px.compareTo(py);
  520. });
  521. // 分页窗口截取
  522. int from = (p - 1) * size;
  523. if (from >= matched.size()) {
  524. return Collections.emptyList();
  525. }
  526. int to = Math.min(from + size, matched.size());
  527. List<Activity> pageList = new ArrayList<>(matched.subList(from, to));
  528. // 会员价解析(与 list() 口径一致,失败静默降级为非会员价)
  529. String memberLevel = null;
  530. if (!isGuest) {
  531. try {
  532. memberLevel = membershipService.getMemberLevel(userId);
  533. } catch (Exception e) {
  534. // 会员查询失败不影响关联列表
  535. }
  536. }
  537. final String level = memberLevel;
  538. return pageList.stream()
  539. .map(a -> ActivityDTO.from(a, isGuest, level))
  540. .collect(Collectors.toList());
  541. }
  542. private boolean hasCheckedIn(Long activityId, Long userId) {
  543. List<Long> childIds = registrationService.getChildIdsByUser(userId);
  544. if (childIds.isEmpty()) {
  545. return false;
  546. }
  547. return taskMapper.selectCount(new LambdaQueryWrapper<Task>()
  548. .eq(Task::getSourceType, "activity")
  549. .eq(Task::getSourceId, activityId)
  550. .in(Task::getChildId, childIds)
  551. .eq(Task::getStatus, "completed")) > 0;
  552. }
  553. private void applyDisplayState(ActivityDTO dto, Activity activity, String registrationStatus, boolean checkedIn) {
  554. Date now = new Date();
  555. String displayStatus;
  556. String displayLabel;
  557. boolean ended = "ended".equals(activity.getStatus())
  558. || (activity.getEndTime() != null && now.after(activity.getEndTime()));
  559. if (ended) {
  560. displayStatus = "ended";
  561. displayLabel = "已结束";
  562. } else if (checkedIn) {
  563. displayStatus = "checked_in";
  564. displayLabel = "已签到";
  565. } else if (activity.getStartTime() != null && activity.getEndTime() != null
  566. && !now.before(activity.getStartTime()) && !now.after(activity.getEndTime())) {
  567. displayStatus = "ongoing";
  568. displayLabel = "进行中";
  569. } else if ("approved".equals(registrationStatus)) {
  570. displayStatus = "registered";
  571. displayLabel = "已报名";
  572. } else if (activity.getRegistrationDeadline() != null && now.after(activity.getRegistrationDeadline())) {
  573. displayStatus = "pending_start";
  574. displayLabel = "待开始";
  575. } else {
  576. displayStatus = "open";
  577. displayLabel = "报名中";
  578. }
  579. dto.setDisplayStatus(displayStatus);
  580. dto.setDisplayLabel(displayLabel);
  581. dto.setRegistrationStatus(checkedIn ? "checked_in" : (registrationStatus != null ? registrationStatus : "not_registered"));
  582. }
  583. }