Explorar o código

管理员后端登录管理调整

liuchengsen hai 1 mes
pai
achega
95c6facf63
Modificáronse 64 ficheiros con 5078 adicións e 85 borrados
  1. 2 0
      .idea/encodings.xml
  2. 608 4
      docs/03-接口文档.md
  3. 9 2
      pom.xml
  4. 47 0
      zhijiayun-activity/pom.xml
  5. 88 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/ActivityController.java
  6. 127 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminActivityController.java
  7. 83 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminCheckInController.java
  8. 70 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/CheckInController.java
  9. 31 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityCheckinCycleConfigRequest.java
  10. 45 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityCheckinCycleStatusResponse.java
  11. 35 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityInviteConfigRequest.java
  12. 31 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityInviteStatusResponse.java
  13. 29 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryConfigRequest.java
  14. 32 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryPrizeRequest.java
  15. 39 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryResultResponse.java
  16. 34 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryStatusResponse.java
  17. 56 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/AdminCheckInRecordResponse.java
  18. 34 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInCalendarResponse.java
  19. 39 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInConfigRequest.java
  20. 39 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInResultResponse.java
  21. 45 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInStatsResponse.java
  22. 33 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInStatusResponse.java
  23. 50 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityCheckinCycleConfig.java
  24. 43 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityCheckinCycleRecord.java
  25. 53 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityInviteConfig.java
  26. 51 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityInviteRewardRecord.java
  27. 38 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryChance.java
  28. 44 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryConfig.java
  29. 50 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryPrize.java
  30. 43 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryRecord.java
  31. 59 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/CheckInConfig.java
  32. 50 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/CheckInRecord.java
  33. 9 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityCheckinCycleConfigMapper.java
  34. 15 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityCheckinCycleRecordMapper.java
  35. 9 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityInviteConfigMapper.java
  36. 21 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityInviteRewardRecordMapper.java
  37. 9 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryChanceMapper.java
  38. 9 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryConfigMapper.java
  39. 16 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryPrizeMapper.java
  40. 9 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryRecordMapper.java
  41. 12 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/CheckInConfigMapper.java
  42. 65 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/CheckInRecordMapper.java
  43. 98 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/scheduler/ActivityEventScheduler.java
  44. 242 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityCheckinCycleService.java
  45. 266 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityInviteService.java
  46. 400 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java
  47. 79 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInConfigService.java
  48. 267 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInService.java
  49. 70 0
      zhijiayun-activity/src/main/resources/mapper/CheckInRecordMapper.xml
  50. 10 4
      zhijiayun-common/pom.xml
  51. 27 0
      zhijiayun-common/src/main/java/com/xuekairui/common/activity/ActivityInviteGate.java
  52. 195 0
      zhijiayun-common/src/main/java/com/xuekairui/common/event/ActivityEventRedisService.java
  53. 8 3
      zhijiayun-gateway/pom.xml
  54. 42 0
      zhijiayun-gateway/src/main/resources/db/migration-v10.sql
  55. 157 0
      zhijiayun-gateway/src/main/resources/db/migration-v11.sql
  56. 1 2
      zhijiayun-invite/pom.xml
  57. 16 7
      zhijiayun-invite/src/main/java/com/xuekairui/invite/listener/UserRegisteredListener.java
  58. 24 51
      zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java
  59. 1 2
      zhijiayun-payment/pom.xml
  60. 1 2
      zhijiayun-test/pom.xml
  61. 917 0
      zhijiayun-test/src/main/resources/static/activity-test.html
  62. 1 2
      zhijiayun-user/pom.xml
  63. 25 2
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java
  64. 20 4
      zhijiayun-user/src/main/java/com/xuekairui/user/service/CrawlerService.java

+ 2 - 0
.idea/encodings.xml

@@ -3,6 +3,8 @@
   <component name="Encoding">
     <file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
+    <file url="file://$PROJECT_DIR$/zhijiayun-activity/src/main/java" charset="UTF-8" />
+    <file url="file://$PROJECT_DIR$/zhijiayun-activity/src/main/resources" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/zhijiayun-common/src/main/java" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/zhijiayun-common/src/main/resources" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/zhijiayun-gateway/src/main/java" charset="UTF-8" />

+ 608 - 4
docs/03-接口文档.md

@@ -1345,11 +1345,615 @@ DELETE /api/admin/trial-quota/config/{id}
 
 ---
 
-## ~~11. 用户等级配置管理 /api/user-level-config~~ (已下线)
+8002## 11. 活动模块 /api/activity & /api/checkin & /api/admin/activity & /api/admin/checkin
 
-> **⚠️ 已下线**:等级版本体系(NORMAL/VIP/SVIP)已下线,统一迁移至会员体系(PLUS/PRO/ULTRA)。
-> 相关接口已由 `/api/membership`(会员体系)和 `/api/level`(等级列表)接管。
-> 参见:第 5 节 用户等级、第 15 节 会员体系。
+> **模块说明**:活动模块(zhijiayun-activity)统一管理签到有礼、邀请有礼、签到送会员、比价抽奖等运营活动。  
+> 用户端接口前缀 `/api/checkin`、`/api/activity`;运营端接口前缀 `/api/admin/checkin`、`/api/admin/activity`。
+
+### 11.1 今日签到状态
+
+```
+GET /api/checkin/status
+```
+
+**需要认证**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "checkedToday": true,
+    "continuousDays": 5,
+    "todayReward": 2,
+    "baseReward": 1,
+    "continuousReward": 1,
+    "continuousThreshold": 7,
+    "continuousCap": 30
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| checkedToday | Boolean | 今日是否已签到 |
+| continuousDays | Integer | 连续签到天数 |
+| todayReward | Integer | 今日签到可得奖励(基础+连续额外) |
+| baseReward | Integer | 基础奖励 |
+| continuousReward | Integer | 连续达成后额外奖励 |
+| continuousThreshold | Integer | 连续阈值(达到后额外奖励生效) |
+| continuousCap | Integer | 连续额外奖励上限 |
+
+---
+
+### 11.2 执行签到
+
+```
+POST /api/checkin
+```
+
+**需要认证**
+
+**请求参数:** `clientSource`(可选:WINDOWS/ANDROID/MINIAPP)
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "id": 123,
+    "userId": 1,
+    "checkinDate": "2026-07-07",
+    "rewardCount": 2,
+    "continuousDays": 5,
+    "createTime": "2026-07-07T10:00:00"
+  }
+}
+```
+
+---
+
+### 11.3 当月签到日历
+
+```
+GET /api/checkin/calendar?year=2026&month=7
+```
+
+**需要认证**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| year | Integer | 否 | 年份,默认当前年 |
+| month | Integer | 否 | 月份,默认当前月 |
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "checkedDates": ["2026-07-01", "2026-07-02", "2026-07-05"],
+    "year": 2026,
+    "month": 7
+  }
+}
+```
+
+---
+
+### 11.4 我的签到记录
+
+```
+GET /api/checkin/records?limit=30
+```
+
+**需要认证**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| limit | Integer | 否 | 返回条数,默认30 |
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": [
+    {
+      "id": 123,
+      "userId": 1,
+      "checkinDate": "2026-07-07",
+      "rewardCount": 2,
+      "continuousDays": 5,
+      "clientSource": "WINDOWS",
+      "createTime": "2026-07-07T10:00:00"
+    }
+  ]
+}
+```
+
+---
+
+### 11.5 签到配置查询(运营端)
+
+```
+GET /api/admin/checkin/config
+```
+
+**需要管理员权限**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "id": 1,
+    "enabled": 1,
+    "rewardType": "CRAWLER",
+    "baseReward": 1,
+    "continuousReward": 1,
+    "continuousThreshold": 7,
+    "continuousCap": 30,
+    "rewardExpireDays": 30,
+    "remark": "默认签到配置"
+  }
+}
+```
+
+---
+
+### 11.6 更新签到配置(运营端)
+
+```
+PUT /api/admin/checkin/config
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "enabled": 1,
+  "rewardType": "CRAWLER",
+  "baseReward": 1,
+  "continuousReward": 1,
+  "continuousThreshold": 7,
+  "continuousCap": 30,
+  "rewardExpireDays": 30,
+  "remark": "默认签到配置"
+}
+```
+
+---
+
+### 11.7 签到统计概览(运营端)
+
+```
+GET /api/admin/checkin/stats
+```
+
+**需要管理员权限**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "todayCheckinCount": 50,
+    "yesterdayCheckinCount": 45,
+    "last7DaysCheckinCount": 320,
+    "last30DaysCheckinCount": 1200,
+    "totalCheckinCount": 5000,
+    "totalCheckinUsers": 800,
+    "totalRewardGranted": 6500
+  }
+}
+```
+
+---
+
+### 11.8 签到记录分页查询(运营端)
+
+```
+GET /api/admin/checkin/records?page=1&size=10&userId=1&startDate=2026-07-01&endDate=2026-07-07&clientSource=WINDOWS
+```
+
+**需要管理员权限**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| page | Long | 否 | 页码,默认1 |
+| size | Long | 否 | 每页条数,默认10 |
+| userId | Long | 否 | 用户ID筛选 |
+| startDate | Date | 否 | 起始日期 |
+| endDate | Date | 否 | 截止日期 |
+| clientSource | String | 否 | 签到端 |
+
+**响应:** 分页格式(含用户手机号、昵称、药店名)
+
+---
+
+### 11.9 指定用户签到记录(运营端)
+
+```
+GET /api/admin/checkin/user/{userId}/records
+```
+
+**需要管理员权限**
+
+---
+
+### 11.10 邀请有礼活动状态(用户端)
+
+```
+GET /api/activity/invite/status
+```
+
+**需要认证**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "active": true,
+    "startTime": "2026-07-10T00:00:00",
+    "endTime": "2026-07-31T23:59:59",
+    "rewardDays": 30,
+    "maxInviterReward": 30,
+    "rewardTrigger": "LICENSE_APPROVED",
+    "myReceivedCount": 3,
+    "myRemainingCount": 27
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| active | Boolean | 活动是否开启 |
+| rewardTrigger | String | REGISTRATION-注册即发 / LICENSE_APPROVED-资质审核通过后发 |
+| myReceivedCount | Long | 我已获得的奖励次数 |
+| myRemainingCount | Long | 我剩余可获得的奖励次数 |
+
+---
+
+### 11.11 签到送会员活动状态(用户端)
+
+```
+GET /api/activity/checkin-cycle/status
+```
+
+**需要认证**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "active": true,
+    "startTime": "2026-07-10T00:00:00",
+    "endTime": "2026-07-31T23:59:59",
+    "cycleStart": "2026-07-03",
+    "cycleEnd": "2026-07-09",
+    "requiredDays": 4,
+    "rewardDays": 7,
+    "maxRewardCount": 3,
+    "currentCycleCheckinDays": 2,
+    "cycleQualified": false,
+    "totalReceivedCount": 1,
+    "remainingCount": 2
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| active | Boolean | 活动是否开启 |
+| cycleStart | String | 当前周期起始日(周五) |
+| cycleEnd | String | 当前周期截止日(周四) |
+| requiredDays | Integer | 周期内需累计签到天数 |
+| rewardDays | Integer | 奖励高级会员天数 |
+| maxRewardCount | Integer | 每用户最多领取次数 |
+| currentCycleCheckinDays | Integer | 当前周期已签到天数 |
+| cycleQualified | Boolean | 当前周期是否达标 |
+| totalReceivedCount | Integer | 累计已领取次数 |
+| remainingCount | Integer | 剩余可领取次数 |
+
+---
+
+### 11.12 比价抽奖活动状态(用户端)
+
+```
+GET /api/activity/lottery/status
+```
+
+**需要认证**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "active": true,
+    "startTime": "2026-07-10T00:00:00",
+    "endTime": "2026-07-31T23:59:59",
+    "eligible": true,
+    "todayChance": 1,
+    "todayRemaining": 1
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| active | Boolean | 活动是否开启 |
+| eligible | Boolean | 是否满足参与条件(高级会员) |
+| todayChance | Integer | 今日获得的机会数 |
+| todayRemaining | Integer | 今日剩余可抽奖次数 |
+
+---
+
+### 11.13 执行抽奖(用户端)
+
+```
+POST /api/activity/lottery/draw
+```
+
+**需要认证**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "prizeId": 1,
+    "prizeName": "高级会员7天",
+    "rewardType": "MEMBERSHIP",
+    "rewardAmount": 0,
+    "rewardDays": 7,
+    "rewardLevel": "PRO",
+    "createTime": "2026-07-07T10:30:00"
+  }
+}
+```
+
+| rewardType | 说明 |
+|------------|------|
+| MEMBERSHIP | 会员奖励(rewardDays天,rewardLevel等级) |
+| CRAWLER | 爬虫次数(rewardAmount次) |
+| EMPTY | 谢谢参与 |
+
+---
+
+### 11.14 我的抽奖记录(用户端)
+
+```
+GET /api/activity/lottery/records?limit=20
+```
+
+**需要认证**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| limit | Integer | 否 | 返回条数,默认20 |
+
+---
+
+### 11.15 邀请有礼配置(运营端)
+
+```
+GET /api/admin/activity/invite/config
+```
+
+**需要管理员权限**
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": {
+    "id": 1,
+    "enabled": 1,
+    "startTime": "2026-07-10T00:00:00",
+    "endTime": "2026-07-31T23:59:59",
+    "inviterRewardDays": 30,
+    "inviteeRewardDays": 30,
+    "maxInviterReward": 30,
+    "rewardTrigger": "LICENSE_APPROVED",
+    "requireMiniappInviter": 1,
+    "remark": "邀请有礼活动"
+  }
+}
+```
+
+---
+
+### 11.16 更新邀请有礼配置(运营端)
+
+```
+PUT /api/admin/activity/invite/config
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "enabled": 1,
+  "startTime": "2026-07-10T00:00:00",
+  "endTime": "2026-07-31T23:59:59",
+  "inviterRewardDays": 30,
+  "inviteeRewardDays": 30,
+  "maxInviterReward": 30,
+  "rewardTrigger": "LICENSE_APPROVED",
+  "requireMiniappInviter": 1,
+  "remark": "邀请有礼活动"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| enabled | Integer | 是 | 0-停用 1-启用 |
+| rewardTrigger | String | 是 | REGISTRATION-注册即发 LICENSE_APPROVED-资质审核通过后发 |
+| requireMiniappInviter | Integer | 是 | 0-不要求 1-要求邀请人为小程序用户 |
+| maxInviterReward | Integer | 是 | 邀请人最多获得奖励次数(默认30) |
+
+---
+
+### 11.17 撤销邀请奖励(运营端,反作弊)
+
+```
+POST /api/admin/activity/invite/revoke
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "inviteeId": 123,
+  "reason": "冒用他人资质"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| inviteeId | Long | 是 | 被邀请人ID(作弊者) |
+| reason | String | 是 | 撤销原因 |
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": "已撤销 1 条邀请奖励记录"
+}
+```
+
+---
+
+### 11.18 签到送会员配置(运营端)
+
+```
+GET /api/admin/activity/checkin-cycle/config
+```
+
+**需要管理员权限**
+
+---
+
+### 11.19 更新签到送会员配置(运营端)
+
+```
+PUT /api/admin/activity/checkin-cycle/config
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "enabled": 1,
+  "startTime": "2026-07-10T00:00:00",
+  "endTime": "2026-07-31T23:59:59",
+  "cycleMode": "FRI_THU",
+  "requiredDays": 4,
+  "rewardDays": 7,
+  "maxRewardCount": 3,
+  "remark": "签到送会员"
+}
+```
+
+---
+
+### 11.20 比价抽奖配置(运营端)
+
+```
+GET /api/admin/activity/lottery/config
+```
+
+**需要管理员权限**
+
+---
+
+### 11.21 更新比价抽奖配置(运营端)
+
+```
+PUT /api/admin/activity/lottery/config
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "enabled": 1,
+  "startTime": "2026-07-10T00:00:00",
+  "endTime": "2026-07-31T23:59:59",
+  "dailyMaxChance": 1,
+  "requireMembershipLevel": "PRO",
+  "remark": "比价抽奖"
+}
+```
+
+---
+
+### 11.22 奖品池列表(运营端)
+
+```
+GET /api/admin/activity/lottery/prizes
+```
+
+**需要管理员权限**
+
+---
+
+### 11.23 新增奖品(运营端)
+
+```
+POST /api/admin/activity/lottery/prizes
+```
+
+**需要管理员权限**
+
+**请求体:**
+```json
+{
+  "name": "高级会员7天",
+  "rewardType": "MEMBERSHIP",
+  "rewardAmount": 0,
+  "rewardDays": 7,
+  "rewardLevel": "PRO",
+  "probabilityWeight": 10,
+  "enabled": 1,
+  "sort": 1
+}
+```
+
+| rewardType | 说明 |
+|------------|------|
+| MEMBERSHIP | 会员奖励(需填rewardDays、rewardLevel) |
+| CRAWLER | 爬虫次数(需填rewardAmount) |
+| EMPTY | 谢谢参与 |
+
+---
+
+### 11.24 更新奖品(运营端)
+
+```
+PUT /api/admin/activity/lottery/prizes/{id}
+```
+
+**需要管理员权限**
+
+---
+
+### 11.25 删除奖品(运营端)
+
+```
+DELETE /api/admin/activity/lottery/prizes/{id}
+```
+
+**需要管理员权限**
+
+---
 
 ---
 

+ 9 - 2
pom.xml

@@ -13,7 +13,7 @@
 
     <groupId>com.xuekairui</groupId>
     <artifactId>zhijiayun-pharmacy</artifactId>
-    <version>1.0-SNAPSHOT</version>
+    <version>1.0.0</version>
     <packaging>pom</packaging>
     <name>zhijiayun-pharmacy</name>
     <description>智价云(药店版)系统</description>
@@ -24,6 +24,7 @@
         <module>zhijiayun-user</module>
         <module>zhijiayun-payment</module>
         <module>zhijiayun-invite</module>
+        <module>zhijiayun-activity</module>
         <module>zhijiayun-gateway</module>
         <module>zhijiayun-test</module>
     </modules>
@@ -41,6 +42,7 @@
         <zhijiayun-user.version>1.0.0</zhijiayun-user.version>
         <zhijiayun-payment.version>1.0.0</zhijiayun-payment.version>
         <zhijiayun-invite.version>1.0.0</zhijiayun-invite.version>
+        <zhijiayun-activity.version>1.0.0</zhijiayun-activity.version>
         <zhijiayun-gateway.version>1.0.0</zhijiayun-gateway.version>
 
         <!-- 第三方依赖版本 -->
@@ -78,6 +80,11 @@
                 <artifactId>zhijiayun-invite</artifactId>
                 <version>${zhijiayun-invite.version}</version>
             </dependency>
+            <dependency>
+                <groupId>com.xuekairui</groupId>
+                <artifactId>zhijiayun-activity</artifactId>
+                <version>${zhijiayun-activity.version}</version>
+            </dependency>
             <dependency>
                 <groupId>com.xuekairui</groupId>
                 <artifactId>zhijiayun-gateway</artifactId>
@@ -235,4 +242,4 @@
         </pluginManagement>
     </build>
 
-</project>
+</project>

+ 47 - 0
zhijiayun-activity/pom.xml

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.xuekairui</groupId>
+        <artifactId>zhijiayun-pharmacy</artifactId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>zhijiayun-activity</artifactId>
+    <name>zhijiayun-activity</name>
+    <description>智价云(药店版)-活动模块(签到、抽奖等运营活动统一管理)</description>
+
+    <dependencies>
+        <!-- 公共模块 -->
+        <dependency>
+            <groupId>com.xuekairui</groupId>
+            <artifactId>zhijiayun-common</artifactId>
+        </dependency>
+
+        <!-- 用户模块(复用 CrawlerQuotaGrantService 发放爬虫次数奖励、User 实体等) -->
+        <dependency>
+            <groupId>com.xuekairui</groupId>
+            <artifactId>zhijiayun-user</artifactId>
+        </dependency>
+
+        <!-- Spring Boot -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <!-- MyBatis-Plus -->
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>

+ 88 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/ActivityController.java

@@ -0,0 +1,88 @@
+package com.xuekairui.activity.controller;
+
+import com.xuekairui.activity.dto.ActivityCheckinCycleStatusResponse;
+import com.xuekairui.activity.dto.ActivityInviteStatusResponse;
+import com.xuekairui.activity.dto.ActivityLotteryResultResponse;
+import com.xuekairui.activity.dto.ActivityLotteryStatusResponse;
+import com.xuekairui.activity.entity.ActivityLotteryRecord;
+import com.xuekairui.activity.service.ActivityCheckinCycleService;
+import com.xuekairui.activity.service.ActivityInviteService;
+import com.xuekairui.activity.service.ActivityLotteryService;
+import com.xuekairui.common.Result;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 活动接口(用户端)
+ * <p>
+ * 统一暴露邀请有礼、签到送会员、比价抽奖三类活动的用户端能力。
+ */
+@RestController
+@RequestMapping("/api/activity")
+@RequiredArgsConstructor
+public class ActivityController {
+
+    private final ActivityInviteService activityInviteService;
+    private final ActivityCheckinCycleService activityCheckinCycleService;
+    private final ActivityLotteryService activityLotteryService;
+
+    // ========== 邀请有礼 ==========
+
+    /**
+     * 邀请有礼活动状态(含邀请人已获奖励次数、剩余次数)
+     * GET /api/activity/invite/status
+     */
+    @GetMapping("/invite/status")
+    public Result<ActivityInviteStatusResponse> inviteStatus(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(activityInviteService.getStatus(userId));
+    }
+
+    // ========== 签到送会员 ==========
+
+    /**
+     * 签到送会员周期状态(当前周期签到天数、是否达标、累计领取次数、剩余次数)
+     * GET /api/activity/checkin-cycle/status
+     */
+    @GetMapping("/checkin-cycle/status")
+    public Result<ActivityCheckinCycleStatusResponse> checkinCycleStatus(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(activityCheckinCycleService.getStatus(userId));
+    }
+
+    // ========== 比价抽奖 ==========
+
+    /**
+     * 比价抽奖活动状态(今日可用机会、资格)
+     * GET /api/activity/lottery/status
+     */
+    @GetMapping("/lottery/status")
+    public Result<ActivityLotteryStatusResponse> lotteryStatus(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(activityLotteryService.getStatus(userId));
+    }
+
+    /**
+     * 执行抽奖
+     * POST /api/activity/lottery/draw
+     */
+    @PostMapping("/lottery/draw")
+    public Result<ActivityLotteryResultResponse> draw(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(activityLotteryService.draw(userId));
+    }
+
+    /**
+     * 我的抽奖记录
+     * GET /api/activity/lottery/records?limit=20
+     */
+    @GetMapping("/lottery/records")
+    public Result<List<ActivityLotteryRecord>> lotteryRecords(HttpServletRequest request,
+                                                              @RequestParam(required = false) Integer limit) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(activityLotteryService.listMyRecords(userId, limit));
+    }
+}

+ 127 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminActivityController.java

@@ -0,0 +1,127 @@
+package com.xuekairui.activity.controller;
+
+import com.xuekairui.activity.dto.ActivityCheckinCycleConfigRequest;
+import com.xuekairui.activity.dto.ActivityInviteConfigRequest;
+import com.xuekairui.activity.dto.ActivityLotteryConfigRequest;
+import com.xuekairui.activity.dto.ActivityLotteryPrizeRequest;
+import com.xuekairui.activity.entity.ActivityCheckinCycleConfig;
+import com.xuekairui.activity.entity.ActivityInviteConfig;
+import com.xuekairui.activity.entity.ActivityLotteryConfig;
+import com.xuekairui.activity.entity.ActivityLotteryPrize;
+import com.xuekairui.activity.service.ActivityCheckinCycleService;
+import com.xuekairui.activity.service.ActivityInviteService;
+import com.xuekairui.activity.service.ActivityLotteryService;
+import com.xuekairui.common.Result;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 活动管理接口(运营端)
+ * <p>
+ * 统一管理邀请有礼、签到送会员、比价抽奖三类活动的配置与奖品池。
+ */
+@RestController
+@RequestMapping("/api/admin/activity")
+@RequiredArgsConstructor
+public class AdminActivityController {
+
+    private final ActivityInviteService activityInviteService;
+    private final ActivityCheckinCycleService activityCheckinCycleService;
+    private final ActivityLotteryService activityLotteryService;
+
+    // ========== 邀请有礼配置 ==========
+
+    @GetMapping("/invite/config")
+    public Result<ActivityInviteConfig> getInviteConfig() {
+        return Result.success(activityInviteService.getConfig());
+    }
+
+    @PutMapping("/invite/config")
+    public Result<ActivityInviteConfig> updateInviteConfig(@Valid @RequestBody ActivityInviteConfigRequest request) {
+        return Result.success(activityInviteService.updateConfig(request));
+    }
+
+    // ========== 签到送会员配置 ==========
+
+    @GetMapping("/checkin-cycle/config")
+    public Result<ActivityCheckinCycleConfig> getCheckinCycleConfig() {
+        return Result.success(activityCheckinCycleService.getConfig());
+    }
+
+    @PutMapping("/checkin-cycle/config")
+    public Result<ActivityCheckinCycleConfig> updateCheckinCycleConfig(
+            @Valid @RequestBody ActivityCheckinCycleConfigRequest request) {
+        return Result.success(activityCheckinCycleService.updateConfig(request));
+    }
+
+    // ========== 比价抽奖配置 ==========
+
+    @GetMapping("/lottery/config")
+    public Result<ActivityLotteryConfig> getLotteryConfig() {
+        return Result.success(activityLotteryService.getConfig());
+    }
+
+    @PutMapping("/lottery/config")
+    public Result<ActivityLotteryConfig> updateLotteryConfig(
+            @Valid @RequestBody ActivityLotteryConfigRequest request) {
+        return Result.success(activityLotteryService.updateConfig(request));
+    }
+
+    // ========== 抽奖奖品池 ==========
+
+    @GetMapping("/lottery/prizes")
+    public Result<List<ActivityLotteryPrize>> listPrizes() {
+        return Result.success(activityLotteryService.listPrizes());
+    }
+
+    @PostMapping("/lottery/prizes")
+    public Result<ActivityLotteryPrize> createPrize(@Valid @RequestBody ActivityLotteryPrizeRequest request) {
+        return Result.success(activityLotteryService.createPrize(request));
+    }
+
+    @PutMapping("/lottery/prizes/{id}")
+    public Result<ActivityLotteryPrize> updatePrize(@PathVariable Long id,
+                                                     @Valid @RequestBody ActivityLotteryPrizeRequest request) {
+        return Result.success(activityLotteryService.updatePrize(id, request));
+    }
+
+    @DeleteMapping("/lottery/prizes/{id}")
+    public Result<Void> deletePrize(@PathVariable Long id) {
+        activityLotteryService.deletePrize(id);
+        return Result.success(null);
+    }
+
+    // ========== 反作弊:撤销邀请奖励 ==========
+
+    @PostMapping("/invite/revoke")
+    public Result<String> revokeInviteReward(@Valid @RequestBody RevokeRewardRequest request,
+                                              HttpServletRequest httpRequest) {
+        Long revokerId = (Long) httpRequest.getAttribute("userId");
+        int count = activityInviteService.revokeReward(
+                request.getInviteeId(), request.getReason(), revokerId);
+        return Result.success("已撤销 " + count + " 条邀请奖励记录");
+    }
+
+    @Data
+    public static class RevokeRewardRequest {
+        @NotNull(message = "被邀请人ID不能为空")
+        private Long inviteeId;
+
+        @NotBlank(message = "撤销原因不能为空")
+        private String reason;
+    }
+}

+ 83 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminCheckInController.java

@@ -0,0 +1,83 @@
+package com.xuekairui.activity.controller;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.activity.dto.AdminCheckInRecordResponse;
+import com.xuekairui.activity.dto.CheckInConfigRequest;
+import com.xuekairui.activity.dto.CheckInStatsResponse;
+import com.xuekairui.activity.entity.CheckInConfig;
+import com.xuekairui.activity.service.CheckInConfigService;
+import com.xuekairui.activity.service.CheckInService;
+import com.xuekairui.common.Result;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 签到管理接口(运营端)
+ * <p>
+ * 提供签到配置管理、签到记录查询、签到统计等运营能力。
+ */
+@RestController
+@RequestMapping("/api/admin/checkin")
+@RequiredArgsConstructor
+public class AdminCheckInController {
+
+    private final CheckInConfigService checkInConfigService;
+    private final CheckInService checkInService;
+
+    /**
+     * 获取签到配置
+     * GET /api/admin/checkin/config
+     */
+    @GetMapping("/config")
+    public Result<CheckInConfig> getConfig() {
+        return Result.success(checkInConfigService.getConfig());
+    }
+
+    /**
+     * 更新签到配置
+     * PUT /api/admin/checkin/config
+     */
+    @PutMapping("/config")
+    public Result<CheckInConfig> updateConfig(@Valid @RequestBody CheckInConfigRequest request) {
+        return Result.success(checkInConfigService.updateConfig(request));
+    }
+
+    /**
+     * 签到统计概览
+     * GET /api/admin/checkin/stats
+     */
+    @GetMapping("/stats")
+    public Result<CheckInStatsResponse> getStats() {
+        return Result.success(checkInService.adminGetStats());
+    }
+
+    /**
+     * 分页查询签到记录(关联用户信息)
+     * GET /api/admin/checkin/records?page=1&size=10&userId=1&startDate=2026-07-01&endDate=2026-07-07&clientSource=WINDOWS
+     */
+    @GetMapping("/records")
+    public Result<IPage<AdminCheckInRecordResponse>> listRecords(
+            @RequestParam(defaultValue = "1") long page,
+            @RequestParam(defaultValue = "10") long size,
+            @RequestParam(required = false) Long userId,
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
+            @RequestParam(required = false) String clientSource) {
+        Page<AdminCheckInRecordResponse> p = new Page<>(page, size);
+        return Result.success(checkInService.adminListRecords(p, userId, startDate, endDate, clientSource));
+    }
+
+    /**
+     * 查询指定用户的签到记录(含用户基础信息)
+     * GET /api/admin/checkin/user/{userId}/records
+     */
+    @GetMapping("/user/{userId}/records")
+    public Result<List<AdminCheckInRecordResponse>> listUserRecords(@PathVariable Long userId) {
+        return Result.success(checkInService.adminListUserRecords(userId));
+    }
+}

+ 70 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/CheckInController.java

@@ -0,0 +1,70 @@
+package com.xuekairui.activity.controller;
+
+import com.xuekairui.activity.dto.CheckInCalendarResponse;
+import com.xuekairui.activity.dto.CheckInResultResponse;
+import com.xuekairui.activity.dto.CheckInStatusResponse;
+import com.xuekairui.activity.entity.CheckInRecord;
+import com.xuekairui.activity.service.CheckInService;
+import com.xuekairui.common.Result;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 签到接口(用户端)
+ * <p>
+ * 提供今日签到状态查询、执行签到、签到日历、签到记录等能力。
+ */
+@RestController
+@RequestMapping("/api/checkin")
+@RequiredArgsConstructor
+public class CheckInController {
+
+    private final CheckInService checkInService;
+
+    /**
+     * 今日签到状态
+     * GET /api/checkin/status
+     */
+    @GetMapping("/status")
+    public Result<CheckInStatusResponse> getStatus(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(checkInService.getCheckInStatus(userId));
+    }
+
+    /**
+     * 执行签到
+     * POST /api/checkin
+     */
+    @PostMapping
+    public Result<CheckInResultResponse> doCheckIn(HttpServletRequest request,
+                                                    @RequestParam(required = false) String clientSource) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(checkInService.doCheckIn(userId, clientSource));
+    }
+
+    /**
+     * 当月签到日历
+     * GET /api/checkin/calendar?year=2026&month=7
+     */
+    @GetMapping("/calendar")
+    public Result<CheckInCalendarResponse> getCalendar(HttpServletRequest request,
+                                                       @RequestParam(required = false) Integer year,
+                                                       @RequestParam(required = false) Integer month) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(checkInService.getMonthlyCalendar(userId, year, month));
+    }
+
+    /**
+     * 我的签到记录
+     * GET /api/checkin/records?limit=30
+     */
+    @GetMapping("/records")
+    public Result<List<CheckInRecord>> listRecords(HttpServletRequest request,
+                                                    @RequestParam(required = false) Integer limit) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(checkInService.listRecords(userId, limit));
+    }
+}

+ 31 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityCheckinCycleConfigRequest.java

@@ -0,0 +1,31 @@
+package com.xuekairui.activity.dto;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+/**
+ * 签到送会员活动配置更新请求(运营端)
+ */
+@Data
+public class ActivityCheckinCycleConfigRequest {
+
+    @NotNull(message = "启用状态不能为空")
+    private Integer enabled;
+
+    @NotNull(message = "开始时间不能为空")
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+    private LocalDateTime startTime;
+
+    @NotNull(message = "结束时间不能为空")
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+    private LocalDateTime endTime;
+
+    private String cycleMode;
+    private Integer requiredDays;
+    private Integer rewardDays;
+    private Integer maxRewardCount;
+    private String remark;
+}

+ 45 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityCheckinCycleStatusResponse.java

@@ -0,0 +1,45 @@
+package com.xuekairui.activity.dto;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDate;
+
+/**
+ * 签到送会员活动状态响应(用户端)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ActivityCheckinCycleStatusResponse {
+
+    private Boolean enabled;
+    private Boolean inWindow;
+
+    /** 当前周期起始日(周五) */
+    private LocalDate currentCycleStart;
+
+    /** 当前周期截止日(周四) */
+    private LocalDate currentCycleEnd;
+
+    /** 当前周期已签到天数 */
+    private Integer currentCycleCheckinCount;
+
+    /** 周期需达签到天数 */
+    private Integer requiredDays;
+
+    /** 当前周期是否已达标 */
+    private Boolean reachedTarget;
+
+    /** 累计已领取奖励次数 */
+    private Long rewardedCount;
+
+    /** 最多可领取次数 */
+    private Integer maxRewardCount;
+
+    /** 剩余可领取次数 */
+    private Long remainingCount;
+}

+ 35 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityInviteConfigRequest.java

@@ -0,0 +1,35 @@
+package com.xuekairui.activity.dto;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+/**
+ * 邀请有礼活动配置更新请求(运营端)
+ */
+@Data
+public class ActivityInviteConfigRequest {
+
+    @NotNull(message = "启用状态不能为空")
+    private Integer enabled;
+
+    @NotNull(message = "开始时间不能为空")
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+    private LocalDateTime startTime;
+
+    @NotNull(message = "结束时间不能为空")
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+    private LocalDateTime endTime;
+
+    private Integer inviterRewardDays;
+    private Integer inviteeRewardDays;
+    private Integer maxInviterReward;
+
+    /** REGISTRATION / LICENSE_APPROVED */
+    private String rewardTrigger;
+
+    private Integer requireMiniappInviter;
+    private String remark;
+}

+ 31 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityInviteStatusResponse.java

@@ -0,0 +1,31 @@
+package com.xuekairui.activity.dto;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+/**
+ * 邀请有礼活动状态响应(用户端)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ActivityInviteStatusResponse {
+
+    /** 活动是否启用 */
+    private Boolean enabled;
+
+    /** 是否在活动时间窗内 */
+    private Boolean inWindow;
+
+    /** 邀请人已获活动奖励次数 */
+    private Long rewardedCount;
+
+    /** 邀请人最多可获奖励次数 */
+    private Integer maxInviterReward;
+
+    /** 剩余可获奖励次数 */
+    private Long remainingCount;
+}

+ 29 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryConfigRequest.java

@@ -0,0 +1,29 @@
+package com.xuekairui.activity.dto;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+/**
+ * 比价抽奖活动配置更新请求(运营端)
+ */
+@Data
+public class ActivityLotteryConfigRequest {
+
+    @NotNull(message = "启用状态不能为空")
+    private Integer enabled;
+
+    @NotNull(message = "开始时间不能为空")
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+    private LocalDateTime startTime;
+
+    @NotNull(message = "结束时间不能为空")
+    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+    private LocalDateTime endTime;
+
+    private Integer dailyMaxChance;
+    private String requireMembershipLevel;
+    private String remark;
+}

+ 32 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryPrizeRequest.java

@@ -0,0 +1,32 @@
+package com.xuekairui.activity.dto;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 抽奖奖品创建/更新请求(运营端)
+ */
+@Data
+public class ActivityLotteryPrizeRequest {
+
+    @NotBlank(message = "奖品名称不能为空")
+    private String name;
+
+    @NotBlank(message = "奖励类型不能为空")
+    private String rewardType;
+
+    @Min(value = 0, message = "奖励数量不能小于0")
+    private Integer rewardAmount;
+
+    private Integer rewardDays;
+    private String rewardLevel;
+
+    @NotNull(message = "概率权重不能为空")
+    @Min(value = 0, message = "概率权重不能小于0")
+    private Integer probabilityWeight;
+
+    private Integer enabled;
+    private Integer sort;
+}

+ 39 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryResultResponse.java

@@ -0,0 +1,39 @@
+package com.xuekairui.activity.dto;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 抽奖结果响应
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ActivityLotteryResultResponse {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long recordId;
+
+    private String prizeName;
+
+    /** MEMBERSHIP / CRAWLER / EMPTY */
+    private String rewardType;
+
+    private Integer rewardAmount;
+
+    private Integer rewardDays;
+
+    private String rewardLevel;
+
+    /** 是否中奖(EMPTY 为未中奖) */
+    private Boolean win;
+
+    private LocalDateTime createTime;
+}

+ 34 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryStatusResponse.java

@@ -0,0 +1,34 @@
+package com.xuekairui.activity.dto;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+/**
+ * 比价抽奖活动状态响应(用户端)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ActivityLotteryStatusResponse {
+
+    private Boolean enabled;
+    private Boolean inWindow;
+
+    /** 用户是否具备参与资格(会员等级达标) */
+    private Boolean eligible;
+
+    /** 当前会员等级 */
+    private String membershipLevel;
+
+    /** 所需最低会员等级 */
+    private String requireMembershipLevel;
+
+    /** 今日可用抽奖机会数 */
+    private Integer availableChances;
+
+    /** 今日已使用抽奖机会数 */
+    private Integer usedChances;
+}

+ 56 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/AdminCheckInRecordResponse.java

@@ -0,0 +1,56 @@
+package com.xuekairui.activity.dto;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 运营端签到记录响应(关联用户基础信息)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class AdminCheckInRecordResponse {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long userId;
+
+    /** 用户手机号 */
+    private String phone;
+
+    /** 用户昵称 */
+    private String nickname;
+
+    /** 药店名称 */
+    private String pharmacyName;
+
+    /** 签到日期 */
+    private LocalDate checkinDate;
+
+    /** 签到时连续天数 */
+    private Integer continuousDays;
+
+    /** 奖励类型 */
+    private String rewardType;
+
+    /** 本次奖励数量 */
+    private Integer rewardAmount;
+
+    /** 关联配额发放记录ID */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long grantId;
+
+    /** 签到端 */
+    private String clientSource;
+
+    /** 签到时间 */
+    private LocalDateTime createTime;
+}

+ 34 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInCalendarResponse.java

@@ -0,0 +1,34 @@
+package com.xuekairui.activity.dto;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 签到日历响应(当月)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CheckInCalendarResponse {
+
+    /** 年-月,如 2026-07 */
+    private String month;
+
+    /** 当月已签到天数 */
+    private Integer checkedDays;
+
+    /** 当前连续签到天数 */
+    private Integer continuousDays;
+
+    /** 已签到的日期列表 */
+    private List<LocalDate> checkedDates;
+
+    /** 今日是否已签到 */
+    private Boolean checkedToday;
+}

+ 39 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInConfigRequest.java

@@ -0,0 +1,39 @@
+package com.xuekairui.activity.dto;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 签到配置更新请求(运营端)
+ */
+@Data
+public class CheckInConfigRequest {
+
+    @NotNull(message = "启用状态不能为空")
+    private Integer enabled;
+
+    /** 奖励类型,目前固定 CRAWLER */
+    private String rewardType;
+
+    @NotNull(message = "基础奖励不能为空")
+    @Min(value = 0, message = "基础奖励不能小于0")
+    private Integer baseReward;
+
+    @NotNull(message = "连续奖励不能为空")
+    @Min(value = 0, message = "连续奖励不能小于0")
+    private Integer continuousReward;
+
+    @NotNull(message = "连续奖励阈值不能为空")
+    @Min(value = 1, message = "连续奖励阈值至少为1")
+    private Integer continuousThreshold;
+
+    @NotNull(message = "连续奖励上限不能为空")
+    @Min(value = 1, message = "连续奖励上限至少为1")
+    private Integer continuousCap;
+
+    /** 奖励有效天数(NULL=永不过期) */
+    private Integer rewardExpireDays;
+
+    private String remark;
+}

+ 39 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInResultResponse.java

@@ -0,0 +1,39 @@
+package com.xuekairui.activity.dto;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 签到结果响应
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CheckInResultResponse {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    /** 签到日期 */
+    private LocalDate checkinDate;
+
+    /** 签到时连续天数 */
+    private Integer continuousDays;
+
+    /** 本次奖励数量 */
+    private Integer rewardAmount;
+
+    /** 奖励类型 */
+    private String rewardType;
+
+    /** 签到时间 */
+    private LocalDateTime createTime;
+}

+ 45 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInStatsResponse.java

@@ -0,0 +1,45 @@
+package com.xuekairui.activity.dto;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDate;
+
+/**
+ * 签到统计响应(运营端)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CheckInStatsResponse {
+
+    /** 今日签到人数 */
+    private Long todayCheckinCount;
+
+    /** 昨日签到人数 */
+    private Long yesterdayCheckinCount;
+
+    /** 近7天签到人数 */
+    private Long last7DaysCount;
+
+    /** 近30天签到人数 */
+    private Long last30DaysCount;
+
+    /** 累计签到总次数 */
+    private Long totalCheckinCount;
+
+    /** 累计签到用户数(去重) */
+    private Long totalCheckinUsers;
+
+    /** 累计发放奖励次数 */
+    private Long totalRewardAmount;
+
+    /** 统计日期 */
+    private LocalDate statDate;
+
+    /** 签到功能是否启用 */
+    private Boolean enabled;
+}

+ 33 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInStatusResponse.java

@@ -0,0 +1,33 @@
+package com.xuekairui.activity.dto;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDate;
+
+/**
+ * 签到状态响应
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CheckInStatusResponse {
+
+    /** 今日是否已签到 */
+    private Boolean checkedToday;
+
+    /** 当前连续签到天数 */
+    private Integer continuousDays;
+
+    /** 今日签到可获奖励(基础+连续) */
+    private Integer todayReward;
+
+    /** 下一次签到日(今日未签到则为今天) */
+    private LocalDate nextCheckinDate;
+
+    /** 签到功能是否开启 */
+    private Boolean enabled;
+}

+ 50 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityCheckinCycleConfig.java

@@ -0,0 +1,50 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 签到送会员活动配置(全局唯一,id=1)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_activity_checkin_cycle_config")
+public class ActivityCheckinCycleConfig {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @Builder.Default
+    private Integer enabled = 0;
+
+    private LocalDateTime startTime;
+    private LocalDateTime endTime;
+
+    /** 周期模式:FRI_THU */
+    @Builder.Default
+    private String cycleMode = "FRI_THU";
+
+    @Builder.Default
+    private Integer requiredDays = 4;
+
+    @Builder.Default
+    private Integer rewardDays = 7;
+
+    @Builder.Default
+    private Integer maxRewardCount = 3;
+
+    private String remark;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 43 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityCheckinCycleRecord.java

@@ -0,0 +1,43 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 签到周期奖励记录(每用户每周期一条)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_activity_checkin_cycle_record")
+public class ActivityCheckinCycleRecord {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    /** 周期起始日(周五) */
+    private LocalDate cycleStart;
+
+    /** 周期截止日(周四) */
+    private LocalDate cycleEnd;
+
+    @Builder.Default
+    private Integer checkinCount = 0;
+
+    @Builder.Default
+    private Integer rewardGranted = 0;
+
+    private LocalDateTime grantTime;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 53 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityInviteConfig.java

@@ -0,0 +1,53 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 邀请有礼活动配置(全局唯一,id=1)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_activity_invite_config")
+public class ActivityInviteConfig {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @Builder.Default
+    private Integer enabled = 0;
+
+    private LocalDateTime startTime;
+    private LocalDateTime endTime;
+
+    @Builder.Default
+    private Integer inviterRewardDays = 30;
+
+    @Builder.Default
+    private Integer inviteeRewardDays = 30;
+
+    @Builder.Default
+    private Integer maxInviterReward = 30;
+
+    /** 奖励触发模式:REGISTRATION / LICENSE_APPROVED */
+    @Builder.Default
+    private String rewardTrigger = "REGISTRATION";
+
+    @Builder.Default
+    private Integer requireMiniappInviter = 0;
+
+    private String remark;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 51 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityInviteRewardRecord.java

@@ -0,0 +1,51 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 邀请有礼活动奖励发放记录
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_activity_invite_reward_record")
+public class ActivityInviteRewardRecord {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long inviterId;
+    private Long inviteeId;
+    private Long licenseId;
+
+    @Builder.Default
+    private Integer rewardDays = 30;
+
+    private Long grantId;
+
+    /** 状态:0-正常 1-已撤销(反作弊) */
+    @Builder.Default
+    private Integer status = 0;
+
+    /** 撤销原因 */
+    private String revokeReason;
+
+    /** 撤销操作人ID */
+    private Long revokerId;
+
+    /** 撤销时间 */
+    private LocalDateTime revokeTime;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 38 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryChance.java

@@ -0,0 +1,38 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 抽奖机会(每用户每日每来源一条)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_activity_lottery_chance")
+public class ActivityLotteryChance {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private LocalDate chanceDate;
+
+    /** 机会来源:COMPARE */
+    @Builder.Default
+    private String source = "COMPARE";
+
+    @Builder.Default
+    private Integer used = 0;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 44 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryConfig.java

@@ -0,0 +1,44 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 比价抽奖活动配置(全局唯一,id=1)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_activity_lottery_config")
+public class ActivityLotteryConfig {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @Builder.Default
+    private Integer enabled = 0;
+
+    private LocalDateTime startTime;
+    private LocalDateTime endTime;
+
+    @Builder.Default
+    private Integer dailyMaxChance = 1;
+
+    /** 参与所需最低会员等级:PRO / ULTRA */
+    @Builder.Default
+    private String requireMembershipLevel = "PRO";
+
+    private String remark;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 50 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryPrize.java

@@ -0,0 +1,50 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 抽奖奖品
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_activity_lottery_prize")
+public class ActivityLotteryPrize {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String name;
+
+    /** MEMBERSHIP / CRAWLER / EMPTY */
+    private String rewardType;
+
+    @Builder.Default
+    private Integer rewardAmount = 0;
+
+    private Integer rewardDays;
+
+    private String rewardLevel;
+
+    @Builder.Default
+    private Integer probabilityWeight = 1;
+
+    @Builder.Default
+    private Integer enabled = 1;
+
+    @Builder.Default
+    private Integer sort = 0;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 43 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryRecord.java

@@ -0,0 +1,43 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 抽奖记录
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_activity_lottery_record")
+public class ActivityLotteryRecord {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long prizeId;
+
+    private String prizeName;
+
+    private String rewardType;
+
+    @Builder.Default
+    private Integer rewardAmount = 0;
+
+    private Integer rewardDays;
+
+    private String rewardLevel;
+
+    private Long chanceId;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 59 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/CheckInConfig.java

@@ -0,0 +1,59 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDateTime;
+
+/**
+ * 签到配置实体(全局唯一,id=1)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_checkin_config")
+public class CheckInConfig {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 是否启用签到:0-停用 1-启用 */
+    @Builder.Default
+    private Integer enabled = 1;
+
+    /** 奖励类型:CRAWLER-爬虫次数 */
+    @Builder.Default
+    private String rewardType = "CRAWLER";
+
+    /** 基础签到奖励(每日) */
+    @Builder.Default
+    private Integer baseReward = 1;
+
+    /** 连续签到额外奖励(满足阈值后每日叠加) */
+    @Builder.Default
+    private Integer continuousReward = 1;
+
+    /** 连续签到奖励触发阈值(天) */
+    @Builder.Default
+    private Integer continuousThreshold = 7;
+
+    /** 连续签到最大累计奖励次数上限 */
+    @Builder.Default
+    private Integer continuousCap = 30;
+
+    /** 奖励爬虫次数有效天数(NULL=永不过期) */
+    private Integer rewardExpireDays;
+
+    /** 备注 */
+    private String remark;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 50 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/CheckInRecord.java

@@ -0,0 +1,50 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 用户签到记录实体
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_checkin_record")
+public class CheckInRecord {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    /** 签到日期(自然日) */
+    private LocalDate checkinDate;
+
+    /** 签到时连续天数 */
+    @Builder.Default
+    private Integer continuousDays = 1;
+
+    /** 奖励类型 */
+    @Builder.Default
+    private String rewardType = "CRAWLER";
+
+    /** 本次奖励数量 */
+    @Builder.Default
+    private Integer rewardAmount = 0;
+
+    /** 关联 t_crawler_quota_grant 发放记录ID */
+    private Long grantId;
+
+    /** 签到端:WINDOWS/ANDROID/MINIAPP */
+    private String clientSource;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 9 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityCheckinCycleConfigMapper.java

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

+ 15 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityCheckinCycleRecordMapper.java

@@ -0,0 +1,15 @@
+package com.xuekairui.activity.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.activity.entity.ActivityCheckinCycleRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface ActivityCheckinCycleRecordMapper extends BaseMapper<ActivityCheckinCycleRecord> {
+
+    /** 统计用户已领取的周期奖励次数(用于3次上限校验) */
+    @Select("SELECT COUNT(*) FROM t_activity_checkin_cycle_record WHERE user_id = #{userId} AND reward_granted = 1")
+    long countRewardedByUser(@Param("userId") Long userId);
+}

+ 9 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityInviteConfigMapper.java

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

+ 21 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityInviteRewardRecordMapper.java

@@ -0,0 +1,21 @@
+package com.xuekairui.activity.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.activity.entity.ActivityInviteRewardRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface ActivityInviteRewardRecordMapper extends BaseMapper<ActivityInviteRewardRecord> {
+
+    /** 统计邀请人已获得的奖励次数(用于30次上限校验,仅统计正常状态) */
+    @Select("SELECT COUNT(*) FROM t_activity_invite_reward_record WHERE inviter_id = #{inviterId} AND status = 0")
+    long countByInviter(@Param("inviterId") Long inviterId);
+
+    /** 根据被邀请人ID查找奖励记录 */
+    @Select("SELECT * FROM t_activity_invite_reward_record WHERE invitee_id = #{inviteeId} AND status = 0")
+    List<ActivityInviteRewardRecord> findByInvitee(@Param("inviteeId") Long inviteeId);
+}

+ 9 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryChanceMapper.java

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

+ 9 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryConfigMapper.java

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

+ 16 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryPrizeMapper.java

@@ -0,0 +1,16 @@
+package com.xuekairui.activity.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.activity.entity.ActivityLotteryPrize;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface ActivityLotteryPrizeMapper extends BaseMapper<ActivityLotteryPrize> {
+
+    /** 查询所有启用的奖品,按 sort 升序 */
+    @Select("SELECT * FROM t_activity_lottery_prize WHERE enabled = 1 ORDER BY sort ASC, id ASC")
+    List<ActivityLotteryPrize> listEnabled();
+}

+ 9 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryRecordMapper.java

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

+ 12 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/CheckInConfigMapper.java

@@ -0,0 +1,12 @@
+package com.xuekairui.activity.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.activity.entity.CheckInConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 签到配置 Mapper
+ */
+@Mapper
+public interface CheckInConfigMapper extends BaseMapper<CheckInConfig> {
+}

+ 65 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/CheckInRecordMapper.java

@@ -0,0 +1,65 @@
+package com.xuekairui.activity.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.xuekairui.activity.dto.AdminCheckInRecordResponse;
+import com.xuekairui.activity.entity.CheckInRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 用户签到记录 Mapper
+ */
+@Mapper
+public interface CheckInRecordMapper extends BaseMapper<CheckInRecord> {
+
+    /**
+     * 查询用户某月的签到日期列表
+     */
+    @Select("SELECT * FROM t_checkin_record " +
+            "WHERE user_id = #{userId} " +
+            "AND checkin_date >= #{startDate} AND checkin_date <= #{endDate} " +
+            "ORDER BY checkin_date ASC")
+    List<CheckInRecord> selectMonthlyRecords(@Param("userId") Long userId,
+                                             @Param("startDate") LocalDate startDate,
+                                             @Param("endDate") LocalDate endDate);
+
+    /**
+     * 运营端:分页查询签到记录(关联用户基础信息),条件可为空
+     */
+    IPage<AdminCheckInRecordResponse> selectAdminRecordsPage(IPage<AdminCheckInRecordResponse> page,
+                                                             @Param("userId") Long userId,
+                                                             @Param("startDate") LocalDate startDate,
+                                                             @Param("endDate") LocalDate endDate,
+                                                             @Param("clientSource") String clientSource);
+
+    /**
+     * 运营端:统计指定时间范围的签到人数(按用户去重)
+     */
+    Long countByDateRange(@Param("startDate") LocalDate startDate,
+                          @Param("endDate") LocalDate endDate);
+
+    /**
+     * 运营端:统计指定日期的签到人数
+     */
+    Long countByDate(@Param("date") LocalDate date);
+
+    /**
+     * 运营端:累计签到总次数
+     */
+    Long countTotalRecords();
+
+    /**
+     * 运营端:累计签到用户数(去重)
+     */
+    Long countTotalUsers();
+
+    /**
+     * 运营端:累计发放奖励次数总和
+     */
+    Long sumTotalReward();
+}

+ 98 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/scheduler/ActivityEventScheduler.java

@@ -0,0 +1,98 @@
+package com.xuekairui.activity.scheduler;
+
+import com.xuekairui.activity.service.ActivityInviteService;
+import com.xuekairui.activity.service.ActivityLotteryService;
+import com.xuekairui.common.event.ActivityEventRedisService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 活动事件 Redis 消费调度器。
+ * <p>
+ * 定时轮询 Redis 中的活动事件队列,取出事件后分发给对应的 Service 处理。
+ * 使用 RPOPLPUSH 安全消费:取出即移到备份队列,处理成功后再从备份队列删除。
+ * 处理失败的事件保留在备份队列中,下次轮询时会从备份队列消费。
+ * <p>
+ * 相比 Spring 事件机制的优势:
+ * <ul>
+ *   <li>事件持久化:应用重启不丢事件</li>
+ *   <li>异步解耦:不阻塞发布方事务提交</li>
+ *   <li>容错不丢:处理失败事件留在备份队列可重试</li>
+ * </ul>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ActivityEventScheduler {
+
+    private final ActivityEventRedisService eventRedisService;
+    private final ActivityInviteService activityInviteService;
+    private final ActivityLotteryService activityLotteryService;
+
+    /**
+     * 每 5 秒轮询一次,消费资质审核通过事件。
+     * 优先消费备份队列中的失败事件(重试),再消费主队列中的新事件。
+     */
+    @Scheduled(fixedDelay = 5000)
+    public void consumeLicenseApprovedEvents() {
+        Map<String, Object> event;
+        while ((event = eventRedisService.popLicenseApprovedEvent()) != null) {
+            try {
+                Long userId = toLong(event.get("userId"));
+                Long licenseId = toLong(event.get("licenseId"));
+                String licenseType = (String) event.get("licenseType");
+                log.info("消费资质审核通过事件: userId={}, licenseId={}", userId, licenseId);
+                activityInviteService.onLicenseApproved(userId, licenseId, licenseType);
+                eventRedisService.ackLicenseApprovedEvent(event);
+            } catch (Exception e) {
+                log.error("处理资质审核通过事件失败,事件将保留在备份队列待重试: event={}", event, e);
+            }
+        }
+    }
+
+    /**
+     * 每 5 秒轮询一次,消费比价消耗事件。
+     * 优先消费备份队列中的失败事件(重试),再消费主队列中的新事件。
+     */
+    @Scheduled(fixedDelay = 5000)
+    public void consumeCrawlerConsumedEvents() {
+        Map<String, Object> event;
+        while ((event = eventRedisService.popCrawlerConsumedEvent()) != null) {
+            try {
+                Long userId = toLong(event.get("userId"));
+                int count = toInt(event.get("count"));
+                String platform = (String) event.get("platform");
+                String usageDate = (String) event.get("usageDate");
+                log.info("消费比价消耗事件: userId={}, count={}, platform={}", userId, count, platform);
+                activityLotteryService.onCrawlerConsumed(userId, count, platform, usageDate);
+                eventRedisService.ackCrawlerConsumedEvent(event);
+            } catch (Exception e) {
+                log.error("处理比价消耗事件失败,事件将保留在备份队列待重试: event={}", event, e);
+            }
+        }
+    }
+
+    private Long toLong(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        return Long.valueOf(value.toString());
+    }
+
+    private int toInt(Object value) {
+        if (value == null) {
+            return 0;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        return Integer.parseInt(value.toString());
+    }
+}

+ 242 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityCheckinCycleService.java

@@ -0,0 +1,242 @@
+package com.xuekairui.activity.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xuekairui.activity.dto.ActivityCheckinCycleConfigRequest;
+import com.xuekairui.activity.dto.ActivityCheckinCycleStatusResponse;
+import com.xuekairui.activity.entity.ActivityCheckinCycleConfig;
+import com.xuekairui.activity.entity.ActivityCheckinCycleRecord;
+import com.xuekairui.activity.entity.CheckInRecord;
+import com.xuekairui.activity.mapper.ActivityCheckinCycleConfigMapper;
+import com.xuekairui.activity.mapper.ActivityCheckinCycleRecordMapper;
+import com.xuekairui.activity.mapper.CheckInRecordMapper;
+import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.service.MembershipService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 签到送高级会员活动服务(周期制)。
+ * <p>
+ * 规则:
+ * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有用户可参加;
+ * 2. 每周五至下周四为一个周期,累计 4 天签到即获得 7 天高级会员;
+ * 3. 周期结束后的第一天(即下一周期首日周五)发放奖励;
+ * 4. 每用户最多领取 3 次。
+ * <p>
+ * 数据复用现有 t_checkin_record 签到记录统计周期内签到天数,不改动每日签到奖励逻辑。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ActivityCheckinCycleService {
+
+    private static final Long CONFIG_ID = 1L;
+    /** 周期模式:周五至下周四 */
+    private static final String CYCLE_MODE_FRI_THU = "FRI_THU";
+
+    private final ActivityCheckinCycleConfigMapper configMapper;
+    private final ActivityCheckinCycleRecordMapper cycleRecordMapper;
+    private final CheckInRecordMapper checkInRecordMapper;
+    private final MembershipService membershipService;
+
+    // ==========================================
+    // 配置管理
+    // ==========================================
+
+    public ActivityCheckinCycleConfig getConfig() {
+        ActivityCheckinCycleConfig config = configMapper.selectById(CONFIG_ID);
+        if (config == null) {
+            return ActivityCheckinCycleConfig.builder()
+                    .id(CONFIG_ID).enabled(0)
+                    .startTime(LocalDateTime.now()).endTime(LocalDateTime.now())
+                    .cycleMode(CYCLE_MODE_FRI_THU).requiredDays(4).rewardDays(7).maxRewardCount(3)
+                    .build();
+        }
+        return config;
+    }
+
+    @Transactional
+    public ActivityCheckinCycleConfig updateConfig(ActivityCheckinCycleConfigRequest request) {
+        ActivityCheckinCycleConfig config = getConfig();
+        config.setEnabled(request.getEnabled());
+        config.setStartTime(request.getStartTime());
+        config.setEndTime(request.getEndTime());
+        if (request.getCycleMode() != null) config.setCycleMode(request.getCycleMode());
+        if (request.getRequiredDays() != null) config.setRequiredDays(request.getRequiredDays());
+        if (request.getRewardDays() != null) config.setRewardDays(request.getRewardDays());
+        if (request.getMaxRewardCount() != null) config.setMaxRewardCount(request.getMaxRewardCount());
+        if (request.getRemark() != null) config.setRemark(request.getRemark());
+        if (config.getId() == null) {
+            config.setId(CONFIG_ID);
+            configMapper.insert(config);
+        } else {
+            configMapper.updateById(config);
+        }
+        log.info("签到送会员活动配置已更新: enabled={}, required={}, reward={}, max={}",
+                config.getEnabled(), config.getRequiredDays(), config.getRewardDays(), config.getMaxRewardCount());
+        return config;
+    }
+
+    // ==========================================
+    // 周期奖励发放(签到或查询状态时触发)
+    // ==========================================
+
+    /**
+     * 尝试发放最近一个已结束周期的奖励。
+     * <p>
+     * 在用户每日签到成功后、或查询周期状态时调用。
+     * 仅当活动开启、该周期累计签到达标、未发放过、且用户未达领取上限时发放。
+     */
+    @Transactional
+    public boolean tryGrantCycleReward(Long userId) {
+        ActivityCheckinCycleConfig config = getConfig();
+        if (!isActive(config)) {
+            return false;
+        }
+
+        LocalDate today = LocalDate.now();
+        LocalDate currentCycleStart = cycleStartOf(today);
+        LocalDate prevCycleStart = currentCycleStart.minusDays(7);
+        LocalDate prevCycleEnd = prevCycleStart.plusDays(6);
+
+        // 仅处理已完整结束的周期(周期截止日 < 今天)
+        if (!prevCycleEnd.isBefore(today)) {
+            return false;
+        }
+
+        int checkinCount = countCheckinInCycle(userId, prevCycleStart, prevCycleEnd);
+        int requiredDays = config.getRequiredDays() != null ? config.getRequiredDays() : 4;
+        if (checkinCount < requiredDays) {
+            return false;
+        }
+
+        // 校验领取上限
+        long rewarded = cycleRecordMapper.countRewardedByUser(userId);
+        int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3;
+        if (rewarded >= maxReward) {
+            log.info("用户{}已达签到周期奖励上限{}次", userId, maxReward);
+            return false;
+        }
+
+        // 幂等:插入周期记录(uk_user_cycle 兜底),已存在则更新为已发放
+        ActivityCheckinCycleRecord exist = cycleRecordMapper.selectOne(
+                new LambdaQueryWrapper<ActivityCheckinCycleRecord>()
+                        .eq(ActivityCheckinCycleRecord::getUserId, userId)
+                        .eq(ActivityCheckinCycleRecord::getCycleStart, prevCycleStart));
+        if (exist != null && exist.getRewardGranted() == 1) {
+            return false;
+        }
+
+        int rewardDays = config.getRewardDays() != null ? config.getRewardDays() : 7;
+        membershipService.grantOrExtendMembership(userId, MembershipLevel.PRO, rewardDays,
+                "ACTIVITY_CHECKIN_CYCLE", "签到送会员活动:周期" + prevCycleStart + "~" + prevCycleEnd + "累计签到" + checkinCount + "天奖励" + rewardDays + "天高级会员");
+
+        if (exist == null) {
+            ActivityCheckinCycleRecord record = ActivityCheckinCycleRecord.builder()
+                    .userId(userId)
+                    .cycleStart(prevCycleStart)
+                    .cycleEnd(prevCycleEnd)
+                    .checkinCount(checkinCount)
+                    .rewardGranted(1)
+                    .grantTime(LocalDateTime.now())
+                    .build();
+            try {
+                cycleRecordMapper.insert(record);
+            } catch (DuplicateKeyException e) {
+                // 并发兜底:已被其他线程处理
+                log.info("签到周期奖励并发处理,已跳过: userId={}, cycle={}", userId, prevCycleStart);
+                return false;
+            }
+        } else {
+            exist.setCheckinCount(checkinCount);
+            exist.setRewardGranted(1);
+            exist.setGrantTime(LocalDateTime.now());
+            cycleRecordMapper.updateById(exist);
+        }
+
+        log.info("签到周期奖励已发放: userId={}, cycle={}~{}, checkinCount={}, days={}",
+                userId, prevCycleStart, prevCycleEnd, checkinCount, rewardDays);
+        return true;
+    }
+
+    // ==========================================
+    // 用户端状态查询
+    // ==========================================
+
+    public ActivityCheckinCycleStatusResponse getStatus(Long userId) {
+        ActivityCheckinCycleConfig config = getConfig();
+
+        // 查询时顺带尝试发放上一周期奖励(用户若未在发放日签到,查询时补发)
+        if (userId != null && isActive(config)) {
+            try {
+                tryGrantCycleReward(userId);
+            } catch (Exception e) {
+                log.warn("查询签到周期状态时尝试发奖失败,不影响查询: userId={}", userId, e);
+            }
+        }
+
+        LocalDate today = LocalDate.now();
+        LocalDate currentCycleStart = cycleStartOf(today);
+        LocalDate currentCycleEnd = currentCycleStart.plusDays(6);
+
+        int currentCount = userId != null ? countCheckinInCycle(userId, currentCycleStart, currentCycleEnd) : 0;
+        int requiredDays = config.getRequiredDays() != null ? config.getRequiredDays() : 4;
+        long rewarded = userId != null ? cycleRecordMapper.countRewardedByUser(userId) : 0L;
+        int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3;
+        long remaining = Math.max(0, maxReward - rewarded);
+
+        return ActivityCheckinCycleStatusResponse.builder()
+                .enabled(isActive(config))
+                .inWindow(inWindow(config))
+                .currentCycleStart(currentCycleStart)
+                .currentCycleEnd(currentCycleEnd)
+                .currentCycleCheckinCount(currentCount)
+                .requiredDays(requiredDays)
+                .reachedTarget(currentCount >= requiredDays)
+                .rewardedCount(rewarded)
+                .maxRewardCount(maxReward)
+                .remainingCount(remaining)
+                .build();
+    }
+
+    // ==========================================
+    // 私有辅助
+    // ==========================================
+
+    private boolean isActive(ActivityCheckinCycleConfig config) {
+        return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config);
+    }
+
+    private boolean inWindow(ActivityCheckinCycleConfig config) {
+        LocalDateTime now = LocalDateTime.now();
+        return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
+    }
+
+    /**
+     * 计算日期所属周期的起始日(周五)。
+     * 周期为周五~下周四:以周五为起点,offset = (dayOfWeek - 5 + 7) % 7。
+     */
+    private LocalDate cycleStartOf(LocalDate date) {
+        int dow = date.getDayOfWeek().getValue(); // MON=1 ... SUN=7
+        int offset = (dow - 5 + 7) % 7;
+        return date.minusDays(offset);
+    }
+
+    private int countCheckinInCycle(Long userId, LocalDate start, LocalDate end) {
+        if (userId == null) {
+            return 0;
+        }
+        Long count = checkInRecordMapper.selectCount(
+                new LambdaQueryWrapper<CheckInRecord>()
+                        .eq(CheckInRecord::getUserId, userId)
+                        .ge(CheckInRecord::getCheckinDate, start)
+                        .le(CheckInRecord::getCheckinDate, end));
+        return count != null ? count.intValue() : 0;
+    }
+}

+ 266 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityInviteService.java

@@ -0,0 +1,266 @@
+package com.xuekairui.activity.service;
+
+import com.xuekairui.activity.dto.ActivityInviteConfigRequest;
+import com.xuekairui.activity.dto.ActivityInviteStatusResponse;
+import com.xuekairui.activity.entity.ActivityInviteConfig;
+import com.xuekairui.activity.entity.ActivityInviteRewardRecord;
+import com.xuekairui.activity.mapper.ActivityInviteConfigMapper;
+import com.xuekairui.activity.mapper.ActivityInviteRewardRecordMapper;
+import com.xuekairui.common.activity.ActivityInviteGate;
+import com.xuekairui.user.entity.User;
+import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.mapper.UserMapper;
+import com.xuekairui.user.service.MembershipService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 邀请有礼活动服务(统一管理所有邀请奖励发放)。
+ * <p>
+ * 通过实现 {@link ActivityInviteGate} 接管 InviteService 的奖励发放,
+ * 根据活动配置决定发放策略:
+ * <ul>
+ *   <li>活动未开启 → 按原逻辑发放 30 天高级会员(无活动限制)</li>
+ *   <li>活动开启 + REGISTRATION 模式 → 立即发放 30 天高级会员(受活动时间窗、30 次上限约束)</li>
+ *   <li>活动开启 + LICENSE_APPROVED 模式 → 暂不发放,等待被邀请人资质审核通过后由事件触发</li>
+ * </ul>
+ * <p>
+ * 规则要点:
+ * 1. 活动时间窗内有效;
+ * 2. LICENSE_APPROVED 模式:被邀请人需上传资质且审核通过后才发放;
+ * 3. 邀请人最多获得 30 次邀请奖励(活动期间内);
+ * 4. 每个被邀请人只触发一次邀请人奖励(DB 唯一索引兜底)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ActivityInviteService implements ActivityInviteGate {
+
+    /** 奖励触发模式:注册即发 */
+    public static final String TRIGGER_REGISTRATION = "REGISTRATION";
+    /** 奖励触发模式:资质审核通过后发 */
+    public static final String TRIGGER_LICENSE_APPROVED = "LICENSE_APPROVED";
+
+    private static final Long CONFIG_ID = 1L;
+
+    private final ActivityInviteConfigMapper configMapper;
+    private final ActivityInviteRewardRecordMapper rewardRecordMapper;
+    private final UserMapper userMapper;
+    private final MembershipService membershipService;
+
+    // ==========================================
+    // ActivityInviteGate 门面实现(供 InviteService 调用)
+    // ==========================================
+
+    @Override
+    public void grantInviteReward(Long inviterId, Long inviteeId) {
+        ActivityInviteConfig config = getConfig();
+
+        // 活动未开启 → 按原逻辑直接发奖,不受活动限制
+        if (!isActive(config)) {
+            doGrantReward(inviterId, inviteeId, 30);
+            return;
+        }
+
+        // 活动开启 → 校验参与条件:邀请人需为小程序用户
+        if (config.getRequireMiniappInviter() != null && config.getRequireMiniappInviter() == 1) {
+            User inviter = userMapper.selectById(inviterId);
+            if (inviter == null || inviter.getWechatMiniOpenId() == null) {
+                log.info("邀请有礼活动:邀请人{}非小程序用户,跳过发奖: invitee={}", inviterId, inviteeId);
+                return;
+            }
+        }
+
+        // 活动开启 → 根据触发模式决定
+        if (TRIGGER_LICENSE_APPROVED.equalsIgnoreCase(config.getRewardTrigger())) {
+            // LICENSE_APPROVED 模式:注册环节暂不发奖,等待资质审核通过后由 onLicenseApproved 触发
+            log.info("邀请有礼活动(LICENSE_APPROVED模式),注册环节暂不发奖: inviter={}, invitee={}", inviterId, inviteeId);
+            return;
+        }
+
+        // REGISTRATION 模式(默认):活动期间内注册即发奖,但受 30 次上限约束
+        grantWithLimit(inviterId, inviteeId, config);
+    }
+
+    // ==========================================
+    // 配置管理
+    // ==========================================
+
+    public ActivityInviteConfig getConfig() {
+        ActivityInviteConfig config = configMapper.selectById(CONFIG_ID);
+        if (config == null) {
+            return ActivityInviteConfig.builder()
+                    .id(CONFIG_ID).enabled(0)
+                    .startTime(LocalDateTime.now())
+                    .endTime(LocalDateTime.now())
+                    .inviterRewardDays(30).inviteeRewardDays(30).maxInviterReward(30)
+                    .rewardTrigger(TRIGGER_REGISTRATION).requireMiniappInviter(0)
+                    .build();
+        }
+        return config;
+    }
+
+    @Transactional
+    public ActivityInviteConfig updateConfig(ActivityInviteConfigRequest request) {
+        ActivityInviteConfig config = getConfig();
+        config.setEnabled(request.getEnabled());
+        config.setStartTime(request.getStartTime());
+        config.setEndTime(request.getEndTime());
+        if (request.getInviterRewardDays() != null) config.setInviterRewardDays(request.getInviterRewardDays());
+        if (request.getInviteeRewardDays() != null) config.setInviteeRewardDays(request.getInviteeRewardDays());
+        if (request.getMaxInviterReward() != null) config.setMaxInviterReward(request.getMaxInviterReward());
+        if (request.getRewardTrigger() != null) config.setRewardTrigger(request.getRewardTrigger());
+        if (request.getRequireMiniappInviter() != null) config.setRequireMiniappInviter(request.getRequireMiniappInviter());
+        if (request.getRemark() != null) config.setRemark(request.getRemark());
+        if (config.getId() == null) {
+            config.setId(CONFIG_ID);
+            configMapper.insert(config);
+        } else {
+            configMapper.updateById(config);
+        }
+        log.info("邀请有礼活动配置已更新: enabled={}, trigger={}, start={}, end={}",
+                config.getEnabled(), config.getRewardTrigger(), config.getStartTime(), config.getEndTime());
+        return config;
+    }
+
+    // ==========================================
+    // 事件处理:资质审核通过后发放邀请人奖励(LICENSE_APPROVED 模式专用)
+    // ==========================================
+
+    /**
+     * 资质审核通过后发放邀请人奖励。
+     * <p>
+     * 幂等:t_activity_invite_reward_record 的 uk_invitee 唯一索引保证同一被邀请人仅奖励一次。
+     */
+    @Transactional
+    public void onLicenseApproved(Long userId, Long licenseId, String licenseType) {
+        ActivityInviteConfig config = getConfig();
+        if (!isActive(config) || !TRIGGER_LICENSE_APPROVED.equalsIgnoreCase(config.getRewardTrigger())) {
+            return;
+        }
+
+        Long inviteeId = userId;
+        User invitee = userMapper.selectById(inviteeId);
+        if (invitee == null || invitee.getInviterId() == null) {
+            log.debug("被邀请人无邀请人,跳过邀请奖励: inviteeId={}", inviteeId);
+            return;
+        }
+
+        Long inviterId = invitee.getInviterId();
+        grantWithLimit(inviterId, inviteeId, config);
+    }
+
+    // ==========================================
+    // 用户端状态查询
+    // ==========================================
+
+    public ActivityInviteStatusResponse getStatus(Long userId) {
+        ActivityInviteConfig config = getConfig();
+        long rewarded = userId != null ? rewardRecordMapper.countByInviter(userId) : 0L;
+        int maxReward = config.getMaxInviterReward() != null ? config.getMaxInviterReward() : 30;
+        long remaining = Math.max(0, maxReward - rewarded);
+
+        return ActivityInviteStatusResponse.builder()
+                .enabled(isActive(config))
+                .inWindow(inWindow(config))
+                .rewardedCount(rewarded)
+                .maxInviterReward(maxReward)
+                .remainingCount(remaining)
+                .build();
+    }
+
+    // ==========================================
+    // 私有辅助
+    // ==========================================
+
+    /**
+     * 带活动限制的奖励发放(活动开启时使用)。
+     * 校验 30 次上限 + 幂等插入记录 + 发放会员。
+     */
+    private void grantWithLimit(Long inviterId, Long inviteeId, ActivityInviteConfig config) {
+        // 1. 校验邀请人奖励次数上限
+        long rewarded = rewardRecordMapper.countByInviter(inviterId);
+        int maxReward = config.getMaxInviterReward() != null ? config.getMaxInviterReward() : 30;
+        if (rewarded >= maxReward) {
+            log.warn("邀请人{}已达活动奖励上限{}次,不再发放: inviteeId={}", inviterId, maxReward, inviteeId);
+            return;
+        }
+
+        // 2. 幂等插入奖励记录
+        int days = config.getInviterRewardDays() != null ? config.getInviterRewardDays() : 30;
+        ActivityInviteRewardRecord record = ActivityInviteRewardRecord.builder()
+                .inviterId(inviterId)
+                .inviteeId(inviteeId)
+                .rewardDays(days)
+                .build();
+        try {
+            rewardRecordMapper.insert(record);
+        } catch (DuplicateKeyException e) {
+            log.info("被邀请人{}已触发过邀请奖励,跳过: inviterId={}", inviteeId, inviterId);
+            return;
+        }
+
+        // 3. 发放奖励
+        doGrantReward(inviterId, inviteeId, days);
+        log.info("邀请有礼活动奖励已发放: inviter={}, invitee={}, days={}", inviterId, inviteeId, days);
+    }
+
+    /**
+     * 直接发放会员奖励(不校验活动限制,活动关闭时兜底使用)。
+     */
+    private void doGrantReward(Long inviterId, Long inviteeId, int days) {
+        membershipService.grantOrExtendMembership(inviterId, MembershipLevel.PRO, days,
+                "ACTIVITY_INVITE", "邀请有礼:邀请用户" + inviteeId + "奖励" + days + "天高级会员");
+    }
+
+    private boolean isActive(ActivityInviteConfig config) {
+        return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config);
+    }
+
+    private boolean inWindow(ActivityInviteConfig config) {
+        LocalDateTime now = LocalDateTime.now();
+        return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
+    }
+
+    // ==========================================
+    // 反作弊:撤销奖励
+    // ==========================================
+
+    /**
+     * 撤销指定被邀请人触发的邀请奖励(反作弊)。
+     * 将奖励记录标记为已撤销,运营可据此手动剥夺邀请人的相应权益。
+     *
+     * @param inviteeId 被邀请人ID(作弊者)
+     * @param reason    撤销原因
+     * @param revokerId 操作人ID(运营)
+     * @return 撤销的记录数
+     */
+    @Transactional
+    public int revokeReward(Long inviteeId, String reason, Long revokerId) {
+        List<ActivityInviteRewardRecord> records = rewardRecordMapper.findByInvitee(inviteeId);
+        if (records.isEmpty()) {
+            log.warn("未找到被邀请人{}的奖励记录,无需撤销", inviteeId);
+            return 0;
+        }
+
+        int count = 0;
+        for (ActivityInviteRewardRecord record : records) {
+            record.setStatus(1);
+            record.setRevokeReason(reason);
+            record.setRevokerId(revokerId);
+            record.setRevokeTime(LocalDateTime.now());
+            rewardRecordMapper.updateById(record);
+            count++;
+            log.warn("反作弊:撤销邀请奖励记录 id={}, inviter={}, invitee={}, reason={}",
+                    record.getId(), record.getInviterId(), inviteeId, reason);
+        }
+
+        return count;
+    }
+}

+ 400 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java

@@ -0,0 +1,400 @@
+package com.xuekairui.activity.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.xuekairui.activity.dto.ActivityLotteryConfigRequest;
+import com.xuekairui.activity.dto.ActivityLotteryPrizeRequest;
+import com.xuekairui.activity.dto.ActivityLotteryResultResponse;
+import com.xuekairui.activity.dto.ActivityLotteryStatusResponse;
+import com.xuekairui.activity.entity.ActivityLotteryChance;
+import com.xuekairui.activity.entity.ActivityLotteryConfig;
+import com.xuekairui.activity.entity.ActivityLotteryPrize;
+import com.xuekairui.activity.entity.ActivityLotteryRecord;
+import com.xuekairui.activity.mapper.ActivityLotteryChanceMapper;
+import com.xuekairui.activity.mapper.ActivityLotteryConfigMapper;
+import com.xuekairui.activity.mapper.ActivityLotteryPrizeMapper;
+import com.xuekairui.activity.mapper.ActivityLotteryRecordMapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.user.entity.CrawlerQuotaGrant;
+import com.xuekairui.user.entity.User;
+import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.mapper.UserMapper;
+import com.xuekairui.user.service.CrawlerQuotaGrantService;
+import com.xuekairui.user.service.MembershipService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * 比价抽奖活动服务。
+ * <p>
+ * 规则:
+ * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有高级会员(PRO及以上)可参加;
+ * 2. 活动期间每自然日首次成功比价获得一次抽奖机会(先到先得,每日1次);
+ * 3. 抽奖按奖品权重随机中奖,中奖后即时发放对应奖励。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ActivityLotteryService {
+
+    private static final Long CONFIG_ID = 1L;
+    private static final String SOURCE_COMPARE = "COMPARE";
+    private static final String REWARD_TYPE_MEMBERSHIP = "MEMBERSHIP";
+    private static final String REWARD_TYPE_CRAWLER = "CRAWLER";
+    private static final String REWARD_TYPE_EMPTY = "EMPTY";
+
+    private final ActivityLotteryConfigMapper configMapper;
+    private final ActivityLotteryChanceMapper chanceMapper;
+    private final ActivityLotteryPrizeMapper prizeMapper;
+    private final ActivityLotteryRecordMapper recordMapper;
+    private final UserMapper userMapper;
+    private final MembershipService membershipService;
+    private final CrawlerQuotaGrantService crawlerQuotaGrantService;
+
+    // ==========================================
+    // 配置管理
+    // ==========================================
+
+    public ActivityLotteryConfig getConfig() {
+        ActivityLotteryConfig config = configMapper.selectById(CONFIG_ID);
+        if (config == null) {
+            return ActivityLotteryConfig.builder()
+                    .id(CONFIG_ID).enabled(0)
+                    .startTime(LocalDateTime.now()).endTime(LocalDateTime.now())
+                    .dailyMaxChance(1).requireMembershipLevel("PRO")
+                    .build();
+        }
+        return config;
+    }
+
+    @Transactional
+    public ActivityLotteryConfig updateConfig(ActivityLotteryConfigRequest request) {
+        ActivityLotteryConfig config = getConfig();
+        config.setEnabled(request.getEnabled());
+        config.setStartTime(request.getStartTime());
+        config.setEndTime(request.getEndTime());
+        if (request.getDailyMaxChance() != null) config.setDailyMaxChance(request.getDailyMaxChance());
+        if (request.getRequireMembershipLevel() != null) config.setRequireMembershipLevel(request.getRequireMembershipLevel());
+        if (request.getRemark() != null) config.setRemark(request.getRemark());
+        if (config.getId() == null) {
+            config.setId(CONFIG_ID);
+            configMapper.insert(config);
+        } else {
+            configMapper.updateById(config);
+        }
+        log.info("比价抽奖活动配置已更新: enabled={}, start={}, end={}",
+                config.getEnabled(), config.getStartTime(), config.getEndTime());
+        return config;
+    }
+
+    // ==========================================
+    // 事件处理:比价成功后发放抽奖机会
+    // ==========================================
+
+    /**
+     * 监听比价消耗事件:活动开启且用户具备资格时,每自然日首次比价发放一次抽奖机会。
+     * 幂等:t_activity_lottery_chance 的 uk_user_date_source 保证每日仅一次。
+     */
+    @Transactional
+    public void onCrawlerConsumed(Long userId, int count, String platform, String usageDateStr) {
+        ActivityLotteryConfig config = getConfig();
+        if (!isActive(config)) {
+            return;
+        }
+        if (!isEligible(userId, config)) {
+            return;
+        }
+
+        LocalDate usageDate = usageDateStr != null ? LocalDate.parse(usageDateStr) : LocalDate.now();
+
+        ActivityLotteryChance chance = ActivityLotteryChance.builder()
+                .userId(userId)
+                .chanceDate(usageDate)
+                .source(SOURCE_COMPARE)
+                .used(0)
+                .build();
+        try {
+            chanceMapper.insert(chance);
+            log.info("比价抽奖机会已发放: userId={}, date={}", userId, usageDate);
+        } catch (DuplicateKeyException e) {
+            // 今日已发放过机会,忽略
+        }
+    }
+
+    // ==========================================
+    // 用户端:抽奖
+    // ==========================================
+
+    /**
+     * 执行抽奖:消耗一个当日未使用的抽奖机会,按权重随机中奖并发放奖励。
+     */
+    @Transactional
+    public ActivityLotteryResultResponse draw(Long userId) {
+        ActivityLotteryConfig config = getConfig();
+        if (!isActive(config)) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "比价抽奖活动未开启或已结束");
+        }
+        if (!isEligible(userId, config)) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "仅高级会员可参与比价抽奖");
+        }
+
+        LocalDate today = LocalDate.now();
+        // 取一条未使用的机会
+        ActivityLotteryChance chance = chanceMapper.selectOne(
+                new LambdaQueryWrapper<ActivityLotteryChance>()
+                        .eq(ActivityLotteryChance::getUserId, userId)
+                        .eq(ActivityLotteryChance::getChanceDate, today)
+                        .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
+                        .eq(ActivityLotteryChance::getUsed, 0)
+                        .last("LIMIT 1"));
+        if (chance == null) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日暂无可用抽奖机会,完成首次比价即可获得");
+        }
+
+        // 乐观锁占用机会,防止并发重复抽奖
+        int affected = chanceMapper.update(null,
+                new LambdaUpdateWrapper<ActivityLotteryChance>()
+                        .eq(ActivityLotteryChance::getId, chance.getId())
+                        .eq(ActivityLotteryChance::getUsed, 0)
+                        .set(ActivityLotteryChance::getUsed, 1));
+        if (affected == 0) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "抽奖机会已被使用");
+        }
+
+        // 加权随机选中奖品
+        ActivityLotteryPrize prize = pickPrize();
+        if (prize == null) {
+            // 无可用奖品,兜底为未中奖
+            prize = ActivityLotteryPrize.builder()
+                    .name("谢谢参与").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
+        }
+
+        // 发放奖励
+        applyReward(userId, prize);
+
+        // 记录抽奖结果
+        ActivityLotteryRecord record = ActivityLotteryRecord.builder()
+                .userId(userId)
+                .prizeId(prize.getId())
+                .prizeName(prize.getName())
+                .rewardType(prize.getRewardType())
+                .rewardAmount(prize.getRewardAmount() != null ? prize.getRewardAmount() : 0)
+                .rewardDays(prize.getRewardDays())
+                .rewardLevel(prize.getRewardLevel())
+                .chanceId(chance.getId())
+                .build();
+        recordMapper.insert(record);
+
+        log.info("抽奖完成: userId={}, prize={}, type={}", userId, prize.getName(), prize.getRewardType());
+
+        boolean win = !REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType());
+        return ActivityLotteryResultResponse.builder()
+                .recordId(record.getId())
+                .prizeName(prize.getName())
+                .rewardType(prize.getRewardType())
+                .rewardAmount(record.getRewardAmount())
+                .rewardDays(prize.getRewardDays())
+                .rewardLevel(prize.getRewardLevel())
+                .win(win)
+                .createTime(record.getCreateTime())
+                .build();
+    }
+
+    /**
+     * 查询用户抽奖状态
+     */
+    public ActivityLotteryStatusResponse getStatus(Long userId) {
+        ActivityLotteryConfig config = getConfig();
+        LocalDate today = LocalDate.now();
+
+        Long available = chanceMapper.selectCount(
+                new LambdaQueryWrapper<ActivityLotteryChance>()
+                        .eq(ActivityLotteryChance::getUserId, userId)
+                        .eq(ActivityLotteryChance::getChanceDate, today)
+                        .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
+                        .eq(ActivityLotteryChance::getUsed, 0));
+        Long used = chanceMapper.selectCount(
+                new LambdaQueryWrapper<ActivityLotteryChance>()
+                        .eq(ActivityLotteryChance::getUserId, userId)
+                        .eq(ActivityLotteryChance::getChanceDate, today)
+                        .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
+                        .eq(ActivityLotteryChance::getUsed, 1));
+
+        MembershipLevel userLevel = getUserLevel(userId);
+        String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
+        boolean eligible = isLevelEnough(userLevel, requireLevel);
+
+        return ActivityLotteryStatusResponse.builder()
+                .enabled(isActive(config))
+                .inWindow(inWindow(config))
+                .eligible(eligible)
+                .membershipLevel(userLevel != null ? userLevel.getCode() : null)
+                .requireMembershipLevel(requireLevel)
+                .availableChances(available != null ? available.intValue() : 0)
+                .usedChances(used != null ? used.intValue() : 0)
+                .build();
+    }
+
+    /**
+     * 查询用户抽奖记录
+     */
+    public List<ActivityLotteryRecord> listMyRecords(Long userId, Integer limit) {
+        LambdaQueryWrapper<ActivityLotteryRecord> wrapper = new LambdaQueryWrapper<ActivityLotteryRecord>()
+                .eq(ActivityLotteryRecord::getUserId, userId)
+                .orderByDesc(ActivityLotteryRecord::getCreateTime);
+        if (limit != null && limit > 0) {
+            wrapper.last("LIMIT " + limit);
+        }
+        return recordMapper.selectList(wrapper);
+    }
+
+    // ==========================================
+    // 奖品管理(运营端)
+    // ==========================================
+
+    public List<ActivityLotteryPrize> listPrizes() {
+        return prizeMapper.selectList(
+                new LambdaQueryWrapper<ActivityLotteryPrize>().orderByAsc(ActivityLotteryPrize::getSort));
+    }
+
+    @Transactional
+    public ActivityLotteryPrize createPrize(ActivityLotteryPrizeRequest request) {
+        ActivityLotteryPrize prize = ActivityLotteryPrize.builder()
+                .name(request.getName())
+                .rewardType(request.getRewardType())
+                .rewardAmount(request.getRewardAmount() != null ? request.getRewardAmount() : 0)
+                .rewardDays(request.getRewardDays())
+                .rewardLevel(request.getRewardLevel())
+                .probabilityWeight(request.getProbabilityWeight())
+                .enabled(request.getEnabled() != null ? request.getEnabled() : 1)
+                .sort(request.getSort() != null ? request.getSort() : 0)
+                .build();
+        prizeMapper.insert(prize);
+        return prize;
+    }
+
+    @Transactional
+    public ActivityLotteryPrize updatePrize(Long id, ActivityLotteryPrizeRequest request) {
+        ActivityLotteryPrize prize = prizeMapper.selectById(id);
+        if (prize == null) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "奖品不存在");
+        }
+        prize.setName(request.getName());
+        prize.setRewardType(request.getRewardType());
+        if (request.getRewardAmount() != null) prize.setRewardAmount(request.getRewardAmount());
+        prize.setRewardDays(request.getRewardDays());
+        prize.setRewardLevel(request.getRewardLevel());
+        if (request.getProbabilityWeight() != null) prize.setProbabilityWeight(request.getProbabilityWeight());
+        if (request.getEnabled() != null) prize.setEnabled(request.getEnabled());
+        if (request.getSort() != null) prize.setSort(request.getSort());
+        prizeMapper.updateById(prize);
+        return prize;
+    }
+
+    @Transactional
+    public void deletePrize(Long id) {
+        prizeMapper.deleteById(id);
+    }
+
+    // ==========================================
+    // 私有辅助
+    // ==========================================
+
+    private boolean isActive(ActivityLotteryConfig config) {
+        return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config);
+    }
+
+    private boolean inWindow(ActivityLotteryConfig config) {
+        LocalDateTime now = LocalDateTime.now();
+        return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
+    }
+
+    private boolean isEligible(Long userId, ActivityLotteryConfig config) {
+        String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
+        return isLevelEnough(getUserLevel(userId), requireLevel);
+    }
+
+    private MembershipLevel getUserLevel(Long userId) {
+        User user = userMapper.selectById(userId);
+        return user != null ? user.getMembershipLevel() : null;
+    }
+
+    private boolean isLevelEnough(MembershipLevel userLevel, String requireCode) {
+        if (userLevel == null) {
+            return false;
+        }
+        MembershipLevel require = MembershipLevel.fromCode(requireCode);
+        if (require == null) {
+            return false;
+        }
+        return userLevel.getWeight() >= require.getWeight();
+    }
+
+    /**
+     * 按权重随机选择一个启用的奖品
+     */
+    private ActivityLotteryPrize pickPrize() {
+        List<ActivityLotteryPrize> prizes = prizeMapper.listEnabled();
+        if (prizes.isEmpty()) {
+            return null;
+        }
+        int totalWeight = prizes.stream()
+                .mapToInt(p -> p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0)
+                .sum();
+        if (totalWeight <= 0) {
+            return prizes.get(prizes.size() - 1);
+        }
+        int rand = ThreadLocalRandom.current().nextInt(totalWeight);
+        int acc = 0;
+        for (ActivityLotteryPrize p : prizes) {
+            acc += (p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0);
+            if (rand < acc) {
+                return p;
+            }
+        }
+        return prizes.get(prizes.size() - 1);
+    }
+
+    /**
+     * 根据奖品类型发放奖励
+     */
+    private void applyReward(Long userId, ActivityLotteryPrize prize) {
+        if (REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType())) {
+            return;
+        }
+        if (REWARD_TYPE_MEMBERSHIP.equalsIgnoreCase(prize.getRewardType())) {
+            int days = prize.getRewardDays() != null ? prize.getRewardDays() : 0;
+            if (days <= 0) {
+                return;
+            }
+            MembershipLevel level = MembershipLevel.fromCode(
+                    prize.getRewardLevel() != null ? prize.getRewardLevel() : "PRO");
+            if (level == null) {
+                level = MembershipLevel.PRO;
+            }
+            membershipService.grantOrExtendMembership(userId, level, days,
+                    "ACTIVITY_LOTTERY", "比价抽奖中奖:" + prize.getName());
+            return;
+        }
+        if (REWARD_TYPE_CRAWLER.equalsIgnoreCase(prize.getRewardType())) {
+            int amount = prize.getRewardAmount() != null ? prize.getRewardAmount() : 0;
+            if (amount <= 0) {
+                return;
+            }
+            LocalDateTime expireTime = LocalDateTime.now().plusDays(30);
+            CrawlerQuotaGrant grant = crawlerQuotaGrantService.grantAdminQuota(
+                    userId, amount, expireTime, "比价抽奖中奖:" + prize.getName());
+            log.info("抽奖爬虫次数奖励已发放: userId={}, amount={}, grantId={}", userId, amount,
+                    grant != null ? grant.getId() : null);
+        }
+    }
+}

+ 79 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInConfigService.java

@@ -0,0 +1,79 @@
+package com.xuekairui.activity.service;
+
+import com.xuekairui.activity.dto.CheckInConfigRequest;
+import com.xuekairui.activity.entity.CheckInConfig;
+import com.xuekairui.activity.mapper.CheckInConfigMapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 签到配置服务(运营端)
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CheckInConfigService {
+
+    private static final Long CONFIG_ID = 1L;
+
+    private final CheckInConfigMapper checkinConfigMapper;
+
+    /**
+     * 获取签到配置(不存在则初始化)
+     */
+    public CheckInConfig getConfig() {
+        CheckInConfig config = checkinConfigMapper.selectById(CONFIG_ID);
+        if (config == null) {
+            config = CheckInConfig.builder()
+                    .id(CONFIG_ID)
+                    .enabled(1)
+                    .rewardType("CRAWLER")
+                    .baseReward(1)
+                    .continuousReward(1)
+                    .continuousThreshold(7)
+                    .continuousCap(30)
+                    .rewardExpireDays(30)
+                    .remark("默认签到配置")
+                    .build();
+            checkinConfigMapper.insert(config);
+        }
+        return config;
+    }
+
+    /**
+     * 更新签到配置
+     */
+    @Transactional
+    public CheckInConfig updateConfig(CheckInConfigRequest request) {
+        CheckInConfig config = getConfig();
+        config.setEnabled(request.getEnabled());
+        if (request.getRewardType() != null && !request.getRewardType().isBlank()) {
+            config.setRewardType(request.getRewardType());
+        }
+        config.setBaseReward(request.getBaseReward());
+        config.setContinuousReward(request.getContinuousReward());
+        config.setContinuousThreshold(request.getContinuousThreshold());
+        config.setContinuousCap(request.getContinuousCap());
+        config.setRewardExpireDays(request.getRewardExpireDays());
+        config.setRemark(request.getRemark());
+        checkinConfigMapper.updateById(config);
+        log.info("签到配置已更新: enabled={}, baseReward={}, continuousReward={}, threshold={}",
+                config.getEnabled(), config.getBaseReward(),
+                config.getContinuousReward(), config.getContinuousThreshold());
+        return config;
+    }
+
+    /**
+     * 校验签到功能是否开启
+     */
+    public void ensureEnabled() {
+        CheckInConfig config = getConfig();
+        if (config.getEnabled() == null || config.getEnabled() != 1) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "签到功能已关闭");
+        }
+    }
+}

+ 267 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInService.java

@@ -0,0 +1,267 @@
+package com.xuekairui.activity.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.activity.dto.AdminCheckInRecordResponse;
+import com.xuekairui.activity.dto.CheckInCalendarResponse;
+import com.xuekairui.activity.dto.CheckInResultResponse;
+import com.xuekairui.activity.dto.CheckInStatsResponse;
+import com.xuekairui.activity.dto.CheckInStatusResponse;
+import com.xuekairui.activity.entity.CheckInConfig;
+import com.xuekairui.activity.entity.CheckInRecord;
+import com.xuekairui.activity.mapper.CheckInRecordMapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.user.entity.CrawlerQuotaGrant;
+import com.xuekairui.user.service.CrawlerQuotaGrantService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 签到核心服务(用户端)
+ * <p>
+ * 业务规则:
+ * 1. 每个用户每个自然日仅可签到一次(DB 唯一索引兜底)
+ * 2. 连续签到:若昨日已签到,则连续天数 +1;否则重置为 1
+ * 3. 奖励计算:基础奖励 + (连续天数 >= 阈值 ? 连续奖励 : 0),且累计连续奖励不超过 continuousCap
+ * 4. 奖励以爬虫次数发放,复用 t_crawler_quota_grant(grant_type=CHECKIN)
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CheckInService {
+
+    /** 签到发放爬虫配额类型 */
+    public static final String GRANT_TYPE_CHECKIN = "CHECKIN";
+
+    private final CheckInConfigService checkinConfigService;
+    private final CheckInRecordMapper checkinRecordMapper;
+    private final CrawlerQuotaGrantService crawlerQuotaGrantService;
+    private final ActivityCheckinCycleService activityCheckinCycleService;
+
+    /**
+     * 查询今日签到状态
+     */
+    public CheckInStatusResponse getCheckInStatus(Long userId) {
+        CheckInConfig config = checkinConfigService.getConfig();
+        LocalDate today = LocalDate.now();
+
+        CheckInRecord todayRecord = findRecord(userId, today);
+        boolean checkedToday = todayRecord != null;
+
+        int continuousDays;
+        if (checkedToday) {
+            continuousDays = todayRecord.getContinuousDays();
+        } else {
+            continuousDays = calcContinuousDays(userId, today);
+        }
+
+        int todayReward = checkedToday ? todayRecord.getRewardAmount()
+                : calcReward(config, continuousDays + 1);
+
+        return CheckInStatusResponse.builder()
+                .checkedToday(checkedToday)
+                .continuousDays(continuousDays)
+                .todayReward(todayReward)
+                .nextCheckinDate(checkedToday ? today.plusDays(1) : today)
+                .enabled(config.getEnabled() != null && config.getEnabled() == 1)
+                .build();
+    }
+
+    /**
+     * 执行签到
+     */
+    @Transactional
+    public CheckInResultResponse doCheckIn(Long userId, String clientSource) {
+        checkinConfigService.ensureEnabled();
+        CheckInConfig config = checkinConfigService.getConfig();
+        LocalDate today = LocalDate.now();
+
+        CheckInRecord exist = findRecord(userId, today);
+        if (exist != null) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日已签到");
+        }
+
+        int continuousDays = calcContinuousDays(userId, today) + 1;
+        int rewardAmount = calcReward(config, continuousDays);
+
+        LocalDateTime expireTime = config.getRewardExpireDays() != null
+                ? LocalDateTime.now().plusDays(config.getRewardExpireDays())
+                : null;
+
+        CrawlerQuotaGrant grant = crawlerQuotaGrantService.grantAdminQuota(
+                userId, rewardAmount, expireTime,
+                "签到奖励 连续第" + continuousDays + "天");
+
+        CheckInRecord record = CheckInRecord.builder()
+                .userId(userId)
+                .checkinDate(today)
+                .continuousDays(continuousDays)
+                .rewardType(config.getRewardType())
+                .rewardAmount(rewardAmount)
+                .grantId(grant != null ? grant.getId() : null)
+                .clientSource(clientSource)
+                .build();
+        checkinRecordMapper.insert(record);
+
+        log.info("用户签到成功: userId={}, date={}, continuous={}, reward={}",
+                userId, today, continuousDays, rewardAmount);
+
+        // 签到送会员活动:尝试发放上一周期奖励(活动关闭时内部直接返回,不影响原逻辑)
+        try {
+            activityCheckinCycleService.tryGrantCycleReward(userId);
+        } catch (Exception e) {
+            log.warn("签到后尝试发放周期奖励失败,不影响签到主流程: userId={}", userId, e);
+        }
+
+        return CheckInResultResponse.builder()
+                .id(record.getId())
+                .checkinDate(record.getCheckinDate())
+                .continuousDays(record.getContinuousDays())
+                .rewardAmount(record.getRewardAmount())
+                .rewardType(record.getRewardType())
+                .createTime(record.getCreateTime())
+                .build();
+    }
+
+    /**
+     * 获取当月签到日历
+     */
+    public CheckInCalendarResponse getMonthlyCalendar(Long userId, Integer year, Integer month) {
+        YearMonth ym = (year != null && month != null)
+                ? YearMonth.of(year, month)
+                : YearMonth.now();
+        LocalDate start = ym.atDay(1);
+        LocalDate end = ym.atEndOfMonth();
+
+        List<CheckInRecord> records = checkinRecordMapper.selectMonthlyRecords(userId, start, end);
+        List<LocalDate> checkedDates = records.stream()
+                .map(CheckInRecord::getCheckinDate)
+                .collect(Collectors.toList());
+
+        int continuousDays = calcContinuousDays(userId, LocalDate.now());
+        boolean checkedToday = checkedDates.contains(LocalDate.now());
+
+        return CheckInCalendarResponse.builder()
+                .month(ym.toString())
+                .checkedDays(checkedDates.size())
+                .continuousDays(continuousDays)
+                .checkedDates(checkedDates)
+                .checkedToday(checkedToday)
+                .build();
+    }
+
+    /**
+     * 获取签到记录列表
+     */
+    public List<CheckInRecord> listRecords(Long userId, Integer limit) {
+        LambdaQueryWrapper<CheckInRecord> wrapper = new LambdaQueryWrapper<CheckInRecord>()
+                .eq(CheckInRecord::getUserId, userId)
+                .orderByDesc(CheckInRecord::getCheckinDate);
+        if (limit != null && limit > 0) {
+            wrapper.last("LIMIT " + limit);
+        }
+        return checkinRecordMapper.selectList(wrapper);
+    }
+
+    // ==========================================
+    // 运营端管理方法
+    // ==========================================
+
+    /**
+     * 运营端:分页查询签到记录(关联用户信息)
+     *
+     * @param page         分页对象
+     * @param userId       用户ID(可选)
+     * @param startDate    起始日期(可选)
+     * @param endDate      截止日期(可选)
+     * @param clientSource 签到端(可选)
+     */
+    public IPage<AdminCheckInRecordResponse> adminListRecords(Page<AdminCheckInRecordResponse> page,
+                                                              Long userId,
+                                                              LocalDate startDate,
+                                                              LocalDate endDate,
+                                                              String clientSource) {
+        return checkinRecordMapper.selectAdminRecordsPage(page, userId, startDate, endDate, clientSource);
+    }
+
+    /**
+     * 运营端:签到统计概览
+     */
+    public CheckInStatsResponse adminGetStats() {
+        LocalDate today = LocalDate.now();
+        CheckInConfig config = checkinConfigService.getConfig();
+
+        return CheckInStatsResponse.builder()
+                .todayCheckinCount(nvl(checkinRecordMapper.countByDate(today)))
+                .yesterdayCheckinCount(nvl(checkinRecordMapper.countByDate(today.minusDays(1))))
+                .last7DaysCount(nvl(checkinRecordMapper.countByDateRange(today.minusDays(6), today)))
+                .last30DaysCount(nvl(checkinRecordMapper.countByDateRange(today.minusDays(29), today)))
+                .totalCheckinCount(nvl(checkinRecordMapper.countTotalRecords()))
+                .totalCheckinUsers(nvl(checkinRecordMapper.countTotalUsers()))
+                .totalRewardAmount(nvl(checkinRecordMapper.sumTotalReward()))
+                .statDate(today)
+                .enabled(config.getEnabled() != null && config.getEnabled() == 1)
+                .build();
+    }
+
+    /**
+     * 运营端:查询指定用户的签到记录(含用户基础信息,最多500条)
+     */
+    public List<AdminCheckInRecordResponse> adminListUserRecords(Long userId) {
+        if (userId == null) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID不能为空");
+        }
+        Page<AdminCheckInRecordResponse> page = new Page<>(1, 500);
+        return checkinRecordMapper.selectAdminRecordsPage(page, userId, null, null, null).getRecords();
+    }
+
+    private Long nvl(Long v) {
+        return v == null ? 0L : v;
+    }
+
+    // ==========================================
+    // 私有辅助方法
+    // ==========================================
+
+    private CheckInRecord findRecord(Long userId, LocalDate date) {
+        return checkinRecordMapper.selectOne(
+                new LambdaQueryWrapper<CheckInRecord>()
+                        .eq(CheckInRecord::getUserId, userId)
+                        .eq(CheckInRecord::getCheckinDate, date));
+    }
+
+    /**
+     * 计算截至 today 的连续签到天数(不含 today)
+     * 若昨天签到则为昨天连续天数+1,否则为 0
+     */
+    private int calcContinuousDays(Long userId, LocalDate today) {
+        CheckInRecord yesterday = findRecord(userId, today.minusDays(1));
+        if (yesterday == null) {
+            return 0;
+        }
+        return yesterday.getContinuousDays() + 1;
+    }
+
+    /**
+     * 计算签到奖励:基础 + 连续奖励(达阈值后叠加,受上限约束)
+     */
+    private int calcReward(CheckInConfig config, int continuousDays) {
+        int reward = config.getBaseReward();
+        if (continuousDays >= config.getContinuousThreshold()) {
+            int extra = continuousDays - config.getContinuousThreshold() + 1;
+            extra = Math.min(extra, config.getContinuousCap());
+            reward += config.getContinuousReward() * Math.max(0, extra);
+        }
+        return reward;
+    }
+}

+ 70 - 0
zhijiayun-activity/src/main/resources/mapper/CheckInRecordMapper.xml

@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.xuekairui.activity.mapper.CheckInRecordMapper">
+
+    <!-- 运营端:分页查询签到记录(关联用户基础信息) -->
+    <select id="selectAdminRecordsPage" resultType="com.xuekairui.activity.dto.AdminCheckInRecordResponse">
+        SELECT
+            r.id              AS id,
+            r.user_id         AS userId,
+            u.phone           AS phone,
+            u.nickname        AS nickname,
+            u.pharmacy_name   AS pharmacyName,
+            r.checkin_date    AS checkinDate,
+            r.continuous_days AS continuousDays,
+            r.reward_type     AS rewardType,
+            r.reward_amount   AS rewardAmount,
+            r.grant_id        AS grantId,
+            r.client_source   AS clientSource,
+            r.create_time     AS createTime
+        FROM t_checkin_record r
+        LEFT JOIN t_user u ON u.id = r.user_id
+        <where>
+            <if test="userId != null">
+                AND r.user_id = #{userId}
+            </if>
+            <if test="startDate != null">
+                AND r.checkin_date &gt;= #{startDate}
+            </if>
+            <if test="endDate != null">
+                AND r.checkin_date &lt;= #{endDate}
+            </if>
+            <if test="clientSource != null and clientSource != ''">
+                AND r.client_source = #{clientSource}
+            </if>
+        </where>
+        ORDER BY r.checkin_date DESC, r.id DESC
+    </select>
+
+    <!-- 运营端:统计指定时间范围的签到人数 -->
+    <select id="countByDateRange" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM t_checkin_record
+        WHERE checkin_date &gt;= #{startDate}
+          AND checkin_date &lt;= #{endDate}
+    </select>
+
+    <!-- 运营端:统计指定日期的签到人数 -->
+    <select id="countByDate" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM t_checkin_record
+        WHERE checkin_date = #{date}
+    </select>
+
+    <!-- 运营端:累计签到总次数 -->
+    <select id="countTotalRecords" resultType="java.lang.Long">
+        SELECT COUNT(*) FROM t_checkin_record
+    </select>
+
+    <!-- 运营端:累计签到用户数(去重) -->
+    <select id="countTotalUsers" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id) FROM t_checkin_record
+    </select>
+
+    <!-- 运营端:累计发放奖励次数总和 -->
+    <select id="sumTotalReward" resultType="java.lang.Long">
+        SELECT COALESCE(SUM(reward_amount), 0) FROM t_checkin_record
+    </select>
+
+</mapper>

+ 10 - 4
zhijiayun-common/pom.xml

@@ -7,12 +7,10 @@
     <parent>
         <groupId>com.xuekairui</groupId>
         <artifactId>zhijiayun-pharmacy</artifactId>
-        <version>1.0-SNAPSHOT</version>
+        <version>1.0.0</version>
     </parent>
 
     <artifactId>zhijiayun-common</artifactId>
-    <version>1.0.0</version>
-    <name>zhijiayun-common</name>
     <description>智价云(药店版)-公共模块</description>
 
     <dependencies>
@@ -24,6 +22,14 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-validation</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.data</groupId>
+            <artifactId>spring-data-redis</artifactId>
+        </dependency>
     </dependencies>
 
-</project>
+</project>

+ 27 - 0
zhijiayun-common/src/main/java/com/xuekairui/common/activity/ActivityInviteGate.java

@@ -0,0 +1,27 @@
+package com.xuekairui.common.activity;
+
+/**
+ * 邀请有礼活动门面(可选实现,由活动模块提供)。
+ * <p>
+ * 邀请模块不再直接发放邀请奖励,而是通过该接口委托给活动模块统一处理。
+ * 活动模块根据当前活动配置(是否开启、触发模式、时间窗、次数上限等)决定是否发放及如何发放。
+ * <p>
+ * 默认无实现时:邀请模块保持兼容,不发放奖励(活动模块未加载)。
+ */
+public interface ActivityInviteGate {
+
+    /**
+     * 发放邀请奖励(统一入口)。
+     * <p>
+     * 由活动模块根据当前活动配置决定:
+     * <ul>
+     *   <li>活动未开启 → 按原逻辑发放 30 天高级会员</li>
+     *   <li>活动开启 + REGISTRATION 模式 → 立即发放 30 天高级会员</li>
+     *   <li>活动开启 + LICENSE_APPROVED 模式 → 暂不发放,等待被邀请人资质审核通过后由事件触发</li>
+     * </ul>
+     *
+     * @param inviterId 邀请人ID
+     * @param inviteeId 被邀请人ID
+     */
+    void grantInviteReward(Long inviterId, Long inviteeId);
+}

+ 195 - 0
zhijiayun-common/src/main/java/com/xuekairui/common/event/ActivityEventRedisService.java

@@ -0,0 +1,195 @@
+package com.xuekairui.common.event;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 活动事件 Redis 存储服务(公共模块)。
+ * <p>
+ * 用 Redis List 实现生产者-消费者模式:
+ * <ul>
+ *   <li>发布方(user/invite 模块)调用 pushXxx 将事件 LPUSH 入队</li>
+ *   <li>消费方(activity 模块)通过定时任务取出事件处理</li>
+ * </ul>
+ * 相比 Spring ApplicationEvent 的优势:
+ * <ul>
+ *   <li>事件持久化,应用重启不丢失</li>
+ *   <li>跨模块解耦,发布方不依赖消费方</li>
+ * </ul>
+ * <p>
+ * 容错机制:消费失败的事件自动进入备份队列(backup),避免 RPOP 即删导致事件丢失。
+ * 备份队列中的事件可由定时任务重试消费。
+ */
+@Slf4j
+@Service
+public class ActivityEventRedisService {
+
+    private static final String KEY_LICENSE_APPROVED = "activity:events:license_approved";
+    private static final String KEY_CRAWLER_CONSUMED = "activity:events:crawler_consumed";
+    private static final String SUFFIX_BACKUP = ":backup";
+
+    /** 事件在 Redis 中的最大存活时间(防止异常情况下堆积) */
+    private static final Duration EVENT_TTL = Duration.ofDays(7);
+
+    private final StringRedisTemplate stringRedisTemplate;
+    private final ObjectMapper objectMapper;
+
+    public ActivityEventRedisService(StringRedisTemplate stringRedisTemplate) {
+        this.stringRedisTemplate = stringRedisTemplate;
+        this.objectMapper = new ObjectMapper();
+        this.objectMapper.registerModule(new JavaTimeModule());
+    }
+
+    /**
+     * 存入资质审核通过事件
+     */
+    public void pushLicenseApproved(Long userId, Long licenseId, String licenseType) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("type", "LICENSE_APPROVED");
+        data.put("userId", userId);
+        data.put("licenseId", licenseId);
+        data.put("licenseType", licenseType);
+        data.put("createTime", System.currentTimeMillis());
+        push(KEY_LICENSE_APPROVED, data);
+    }
+
+    /**
+     * 存入比价消耗事件
+     */
+    public void pushCrawlerConsumed(Long userId, int count, String platform, String usageDate) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("type", "CRAWLER_CONSUMED");
+        data.put("userId", userId);
+        data.put("count", count);
+        data.put("platform", platform);
+        data.put("usageDate", usageDate);
+        data.put("createTime", System.currentTimeMillis());
+        push(KEY_CRAWLER_CONSUMED, data);
+    }
+
+    /**
+     * 从资质审核通过队列安全取出一条事件(使用 RPOPLPUSH 到备份队列,容错不丢事件)
+     */
+    public Map<String, Object> popLicenseApprovedEvent() {
+        return popEvent(KEY_LICENSE_APPROVED);
+    }
+
+    /**
+     * 从比价消耗队列安全取出一条事件(使用 RPOPLPUSH 到备份队列,容错不丢事件)
+     */
+    public Map<String, Object> popCrawlerConsumedEvent() {
+        return popEvent(KEY_CRAWLER_CONSUMED);
+    }
+
+    /**
+     * 确认事件处理成功,从备份队列中删除。
+     * @param eventData 从 popXxxEvent 返回的 Map(内含 _raw 原始 JSON)
+     */
+    public void ackLicenseApprovedEvent(Map<String, Object> eventData) {
+        removeFromBackup(KEY_LICENSE_APPROVED, eventData);
+    }
+
+    /**
+     * 确认事件处理成功,从备份队列中删除。
+     */
+    public void ackCrawlerConsumedEvent(Map<String, Object> eventData) {
+        removeFromBackup(KEY_CRAWLER_CONSUMED, eventData);
+    }
+
+    /**
+     * 获取备份队列中待重试的事件数量(用于监控)
+     */
+    public long getLicenseApprovedBacklog() {
+        Long size = stringRedisTemplate.opsForList().size(KEY_LICENSE_APPROVED + SUFFIX_BACKUP);
+        return size != null ? size : 0;
+    }
+
+    /**
+     * 获取备份队列中待重试的事件数量(用于监控)
+     */
+    public long getCrawlerConsumedBacklog() {
+        Long size = stringRedisTemplate.opsForList().size(KEY_CRAWLER_CONSUMED + SUFFIX_BACKUP);
+        return size != null ? size : 0;
+    }
+
+    private void push(String key, Map<String, Object> data) {
+        try {
+            String json = objectMapper.writeValueAsString(data);
+            stringRedisTemplate.opsForList().leftPush(key, json);
+            stringRedisTemplate.expire(key, EVENT_TTL);
+            log.debug("活动事件已存入 Redis: key={}, type={}", key, data.get("type"));
+        } catch (JsonProcessingException e) {
+            log.error("活动事件序列化失败: key={}", key, e);
+        }
+    }
+
+    /**
+     * 安全取出一条事件:优先从备份队列重试失败事件,再从主队列读取新事件。
+     * 新事件使用 RPOPLPUSH 移到备份队列,成功处理后由 ack 删除。
+     */
+    private Map<String, Object> popEvent(String key) {
+        // 先尝试重试备份队列中的失败事件
+        Map<String, Object> retryEvent = popFromBackup(key);
+        if (retryEvent != null) {
+            return retryEvent;
+        }
+        // 再从主队列读取新事件(RPOPLPUSH 到备份队列)
+        try {
+            String backupKey = key + SUFFIX_BACKUP;
+            String json = stringRedisTemplate.opsForList().rightPopAndLeftPush(key, backupKey);
+            if (json == null) {
+                return null;
+            }
+            stringRedisTemplate.expire(backupKey, EVENT_TTL);
+            @SuppressWarnings("unchecked")
+            Map<String, Object> data = objectMapper.readValue(json, Map.class);
+            data.put("_raw", json);
+            data.put("_fromBackup", false);
+            return data;
+        } catch (JsonProcessingException e) {
+            log.error("活动事件反序列化失败: key={}", key, e);
+            return null;
+        }
+    }
+
+    /**
+     * 从备份队列取出一条失败事件(RPOPLPUSH 到自身尾部,实现循环重试)。
+     * 只在备份队列有数据时返回,避免空队列时的无效操作。
+     */
+    private Map<String, Object> popFromBackup(String key) {
+        String backupKey = key + SUFFIX_BACKUP;
+        Long backupSize = stringRedisTemplate.opsForList().size(backupKey);
+        if (backupSize == null || backupSize == 0) {
+            return null;
+        }
+        try {
+            String json = stringRedisTemplate.opsForList().rightPopAndLeftPush(backupKey, backupKey);
+            if (json == null) {
+                return null;
+            }
+            @SuppressWarnings("unchecked")
+            Map<String, Object> data = objectMapper.readValue(json, Map.class);
+            data.put("_raw", json);
+            data.put("_fromBackup", true);
+            return data;
+        } catch (JsonProcessingException e) {
+            log.error("备份队列事件反序列化失败: key={}", key, e);
+            return null;
+        }
+    }
+
+    private void removeFromBackup(String key, Map<String, Object> eventData) {
+        String rawJson = (String) eventData.get("_raw");
+        if (rawJson != null) {
+            stringRedisTemplate.opsForList().remove(key + SUFFIX_BACKUP, 1, rawJson);
+        }
+    }
+}

+ 8 - 3
zhijiayun-gateway/pom.xml

@@ -7,11 +7,10 @@
     <parent>
         <groupId>com.xuekairui</groupId>
         <artifactId>zhijiayun-pharmacy</artifactId>
-        <version>1.0-SNAPSHOT</version>
+        <version>1.0.0</version>
     </parent>
 
     <artifactId>zhijiayun-gateway</artifactId>
-    <version>1.0.0</version>
     <name>zhijiayun-gateway</name>
     <description>智价云(药店版)-统一对外服务网关(所有请求入口)</description>
 
@@ -36,6 +35,12 @@
             <artifactId>zhijiayun-invite</artifactId>
         </dependency>
 
+        <!-- 活动模块(签到/抽奖等) -->
+        <dependency>
+            <groupId>com.xuekairui</groupId>
+            <artifactId>zhijiayun-activity</artifactId>
+        </dependency>
+
         <!-- ========== 公共基础 ========== -->
         <dependency>
             <groupId>com.xuekairui</groupId>
@@ -100,4 +105,4 @@
         </plugins>
     </build>
 
-</project>
+</project>

+ 42 - 0
zhijiayun-gateway/src/main/resources/db/migration-v10.sql

@@ -0,0 +1,42 @@
+-- ============================================
+-- 迁移 v10:签到有礼(活动模块)
+-- 说明:签到奖励以爬虫次数形式发放,复用 t_crawler_quota_grant 表(grant_type=CHECKIN)
+-- ============================================
+
+-- 1. 签到配置表(全局唯一一条记录,运营后台可编辑)
+CREATE TABLE IF NOT EXISTS `t_checkin_config` (
+    `id`                   BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `enabled`              TINYINT       NOT NULL DEFAULT 1 COMMENT '是否启用签到:0-停用 1-启用',
+    `reward_type`          VARCHAR(20)   NOT NULL DEFAULT 'CRAWLER' COMMENT '奖励类型:CRAWLER-爬虫次数(预留 POINTS/COUPON)',
+    `base_reward`          INT           NOT NULL DEFAULT 1 COMMENT '基础签到奖励(每日)',
+    `continuous_reward`    INT           NOT NULL DEFAULT 1 COMMENT '连续签到额外奖励(满足连续天数后每日叠加)',
+    `continuous_threshold` INT           NOT NULL DEFAULT 7 COMMENT '连续签到奖励触发阈值(天)',
+    `continuous_cap`       INT           NOT NULL DEFAULT 30 COMMENT '连续签到最大累计奖励次数上限',
+    `reward_expire_days`   INT           DEFAULT NULL COMMENT '奖励爬虫次数有效天数(NULL=永不过期)',
+    `remark`               VARCHAR(500)  DEFAULT NULL COMMENT '备注说明',
+    `create_time`          DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`          DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='签到配置表(全局唯一)';
+
+-- 初始化默认配置(id=1)
+INSERT INTO `t_checkin_config` (`id`, `enabled`, `reward_type`, `base_reward`, `continuous_reward`,
+                                `continuous_threshold`, `continuous_cap`, `reward_expire_days`, `remark`)
+VALUES (1, 1, 'CRAWLER', 1, 1, 7, 30, 30, '默认签到配置:每日1次,连续7天起每日额外+1,单次奖励30天有效')
+ON DUPLICATE KEY UPDATE id = id;
+
+-- 2. 用户签到记录表(每日一条,唯一索引保证一人一天一次)
+CREATE TABLE IF NOT EXISTS `t_checkin_record` (
+    `id`               BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`          BIGINT        NOT NULL COMMENT '用户ID',
+    `checkin_date`     DATE          NOT NULL COMMENT '签到日期(自然日)',
+    `continuous_days`  INT           NOT NULL DEFAULT 1 COMMENT '签到时连续天数',
+    `reward_type`      VARCHAR(20)   NOT NULL DEFAULT 'CRAWLER' COMMENT '奖励类型',
+    `reward_amount`    INT           NOT NULL DEFAULT 0 COMMENT '本次奖励数量',
+    `grant_id`         BIGINT        DEFAULT NULL COMMENT '关联 t_crawler_quota_grant 发放记录ID',
+    `client_source`   VARCHAR(20)   DEFAULT NULL COMMENT '签到端:WINDOWS/ANDROID/MINIAPP',
+    `create_time`      DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '签到时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_user_date` (`user_id`, `checkin_date`),
+    KEY `idx_user_create` (`user_id`, `create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户签到记录表';

+ 157 - 0
zhijiayun-gateway/src/main/resources/db/migration-v11.sql

@@ -0,0 +1,157 @@
+-- ============================================
+-- 迁移 v11:运营活动统一配置(邀请有礼 / 签到送会员 / 比价抽奖)
+-- 设计原则:所有活动均"可关闭",关闭时各业务保持原有逻辑不变(往上加限制)
+-- 活动时间窗统一:2026-07-10 ~ 2026-07-31
+-- ============================================
+
+-- ========== 一、邀请有礼活动 ==========
+CREATE TABLE IF NOT EXISTS `t_activity_invite_config` (
+    `id`                     BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `enabled`                TINYINT      NOT NULL DEFAULT 0 COMMENT '是否启用活动:0-停用(走原逻辑) 1-启用',
+    `start_time`             DATETIME     NOT NULL COMMENT '活动开始时间',
+    `end_time`               DATETIME     NOT NULL COMMENT '活动结束时间',
+    `inviter_reward_days`    INT          NOT NULL DEFAULT 30 COMMENT '邀请人奖励会员天数',
+    `invitee_reward_days`    INT          NOT NULL DEFAULT 30 COMMENT '被邀请人奖励会员天数',
+    `max_inviter_reward`     INT          NOT NULL DEFAULT 30 COMMENT '邀请人最多获得邀请奖励次数',
+    `reward_trigger`         VARCHAR(20)  NOT NULL DEFAULT 'REGISTRATION' COMMENT '奖励触发模式:REGISTRATION-注册即发(原逻辑) LICENSE_APPROVED-资质审核通过后发',
+    `require_miniapp_inviter` TINYINT     NOT NULL DEFAULT 0 COMMENT '是否要求邀请人为开邻药惠购小程序用户:0-不要求 1-要求',
+    `remark`                 VARCHAR(500) DEFAULT NULL,
+    `create_time`            DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    `update_time`            DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='邀请有礼活动配置(全局唯一)';
+
+INSERT INTO `t_activity_invite_config` (`id`, `enabled`, `start_time`, `end_time`,
+    `inviter_reward_days`, `invitee_reward_days`, `max_inviter_reward`, `reward_trigger`, `require_miniapp_inviter`, `remark`)
+VALUES (1, 0, '2026-07-10 00:00:00', '2026-07-31 23:59:59', 30, 30, 30, 'LICENSE_APPROVED', 0,
+    '邀请有礼:默认关闭。启用后需被邀请人资质审核通过才发奖,邀请人最多30次')
+ON DUPLICATE KEY UPDATE id = id;
+
+-- 邀请有礼奖励发放记录(活动模式 LICENSE_APPROVED 下,邀请人在被邀请人资质通过后获得的奖励)
+CREATE TABLE IF NOT EXISTS `t_activity_invite_reward_record` (
+    `id`                  BIGINT       NOT NULL AUTO_INCREMENT,
+    `inviter_id`          BIGINT       NOT NULL COMMENT '邀请人ID',
+    `invitee_id`          BIGINT       NOT NULL COMMENT '被邀请人ID',
+    `license_id`          BIGINT       DEFAULT NULL COMMENT '触发奖励的资质记录ID',
+    `reward_days`         INT          NOT NULL COMMENT '奖励会员天数',
+    `grant_id`            BIGINT       DEFAULT NULL COMMENT '关联会员发放记录ID(预留)',
+    `status`              TINYINT      NOT NULL DEFAULT 0 COMMENT '状态:0-正常 1-已撤销(反作弊)',
+    `revoke_reason`       VARCHAR(500) DEFAULT NULL COMMENT '撤销原因',
+    `revoker_id`          BIGINT       DEFAULT NULL COMMENT '撤销操作人ID',
+    `revoke_time`         DATETIME     DEFAULT NULL COMMENT '撤销时间',
+    `create_time`         DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    `update_time`         DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_invitee` (`invitee_id`) COMMENT '同一被邀请人资质通过仅奖励邀请人一次'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='邀请有礼活动奖励发放记录';
+
+
+-- ========== 二、签到送高级会员活动(周期制) ==========
+CREATE TABLE IF NOT EXISTS `t_activity_checkin_cycle_config` (
+    `id`                BIGINT       NOT NULL AUTO_INCREMENT,
+    `enabled`           TINYINT      NOT NULL DEFAULT 0 COMMENT '是否启用:0-停用 1-启用',
+    `start_time`        DATETIME     NOT NULL COMMENT '活动开始时间',
+    `end_time`          DATETIME     NOT NULL COMMENT '活动结束时间',
+    `cycle_mode`        VARCHAR(20)  NOT NULL DEFAULT 'FRI_THU' COMMENT '周期模式:FRI_THU-周五至下周四',
+    `required_days`     INT          NOT NULL DEFAULT 4 COMMENT '周期内需累计签到天数',
+    `reward_days`       INT          NOT NULL DEFAULT 7 COMMENT '奖励高级会员天数',
+    `max_reward_count`  INT          NOT NULL DEFAULT 3 COMMENT '每用户最多领取次数',
+    `remark`            VARCHAR(500) DEFAULT NULL,
+    `create_time`       DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    `update_time`       DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='签到送会员活动配置(全局唯一)';
+
+INSERT INTO `t_activity_checkin_cycle_config` (`id`, `enabled`, `start_time`, `end_time`,
+    `cycle_mode`, `required_days`, `reward_days`, `max_reward_count`, `remark`)
+VALUES (1, 0, '2026-07-10 00:00:00', '2026-07-31 23:59:59', 'FRI_THU', 4, 7, 3,
+    '签到送会员:每周五至下周四为一周期,累计4天签到得7天高级会员,周期结束后第一天发放,最多3次')
+ON DUPLICATE KEY UPDATE id = id;
+
+-- 签到周期奖励记录(每用户每周期一条,记录是否已发放)
+CREATE TABLE IF NOT EXISTS `t_activity_checkin_cycle_record` (
+    `id`             BIGINT       NOT NULL AUTO_INCREMENT,
+    `user_id`        BIGINT       NOT NULL,
+    `cycle_start`    DATE         NOT NULL COMMENT '周期起始日(周五)',
+    `cycle_end`      DATE         NOT NULL COMMENT '周期截止日(周四)',
+    `checkin_count`  INT          NOT NULL DEFAULT 0 COMMENT '周期内签到天数',
+    `reward_granted` TINYINT      NOT NULL DEFAULT 0 COMMENT '是否已发放奖励:0-未 1-已',
+    `grant_time`     DATETIME     DEFAULT NULL COMMENT '发放时间',
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_user_cycle` (`user_id`, `cycle_start`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='签到周期奖励记录';
+
+
+-- ========== 三、比价抽奖活动 ==========
+CREATE TABLE IF NOT EXISTS `t_activity_lottery_config` (
+    `id`                       BIGINT       NOT NULL AUTO_INCREMENT,
+    `enabled`                  TINYINT      NOT NULL DEFAULT 0 COMMENT '是否启用:0-停用 1-启用',
+    `start_time`               DATETIME     NOT NULL,
+    `end_time`                 DATETIME     NOT NULL,
+    `daily_max_chance`         INT          NOT NULL DEFAULT 1 COMMENT '每用户每日最多抽奖机会数',
+    `require_membership_level` VARCHAR(20)  NOT NULL DEFAULT 'PRO' COMMENT '参与所需最低会员等级(PRO/ULTRA)',
+    `remark`                   VARCHAR(500) DEFAULT NULL,
+    `create_time`              DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    `update_time`              DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='比价抽奖活动配置(全局唯一)';
+
+INSERT INTO `t_activity_lottery_config` (`id`, `enabled`, `start_time`, `end_time`,
+    `daily_max_chance`, `require_membership_level`, `remark`)
+VALUES (1, 0, '2026-07-10 00:00:00', '2026-07-31 23:59:59', 1, 'PRO',
+    '比价抽奖:高级会员每自然日首次成功比价获得1次抽奖机会,先到先得')
+ON DUPLICATE KEY UPDATE id = id;
+
+-- 抽奖机会表(每用户每日首次比价产生一条机会)
+CREATE TABLE IF NOT EXISTS `t_activity_lottery_chance` (
+    `id`           BIGINT       NOT NULL AUTO_INCREMENT,
+    `user_id`      BIGINT       NOT NULL,
+    `chance_date`  DATE         NOT NULL COMMENT '获得机会的日期',
+    `source`       VARCHAR(20)  NOT NULL DEFAULT 'COMPARE' COMMENT '机会来源:COMPARE-比价',
+    `used`         TINYINT      NOT NULL DEFAULT 0 COMMENT '是否已使用:0-未 1-已',
+    `create_time`  DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_user_date_source` (`user_id`, `chance_date`, `source`) COMMENT '每日每来源仅1次机会'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='比价抽奖机会表';
+
+-- 奖品池
+CREATE TABLE IF NOT EXISTS `t_activity_lottery_prize` (
+    `id`                 BIGINT       NOT NULL AUTO_INCREMENT,
+    `name`               VARCHAR(50)  NOT NULL COMMENT '奖品名称',
+    `reward_type`        VARCHAR(20)  NOT NULL COMMENT '奖励类型:MEMBERSHIP-会员 CRAWLER-爬虫次数 EMPTY-未中奖',
+    `reward_amount`      INT          NOT NULL DEFAULT 0 COMMENT '奖励数量(爬虫次数等)',
+    `reward_days`        INT          DEFAULT NULL COMMENT '奖励会员天数(MEMBERSHIP类型)',
+    `reward_level`       VARCHAR(20)  DEFAULT NULL COMMENT '奖励会员等级(MEMBERSHIP类型)',
+    `probability_weight` INT          NOT NULL DEFAULT 1 COMMENT '中奖概率权重',
+    `enabled`            TINYINT      NOT NULL DEFAULT 1,
+    `sort`               INT          NOT NULL DEFAULT 0,
+    `create_time`        DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    `update_time`        DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='抽奖奖品池';
+
+-- 默认奖品池
+INSERT INTO `t_activity_lottery_prize` (`name`, `reward_type`, `reward_amount`, `reward_days`, `reward_level`, `probability_weight`, `enabled`, `sort`)
+VALUES
+('高级会员7天', 'MEMBERSHIP', 0, 7, 'PRO', 10, 1, 1),
+('高级会员3天', 'MEMBERSHIP', 0, 3, 'PRO', 20, 1, 2),
+('爬虫次数5次', 'CRAWLER', 5, NULL, NULL, 30, 1, 3),
+('爬虫次数2次', 'CRAWLER', 2, NULL, NULL, 40, 1, 4),
+('谢谢参与', 'EMPTY', 0, NULL, NULL, 100, 1, 5);
+
+-- 抽奖记录表
+CREATE TABLE IF NOT EXISTS `t_activity_lottery_record` (
+    `id`            BIGINT       NOT NULL AUTO_INCREMENT,
+    `user_id`       BIGINT       NOT NULL,
+    `prize_id`      BIGINT       DEFAULT NULL,
+    `prize_name`    VARCHAR(50)  NOT NULL,
+    `reward_type`   VARCHAR(20)  NOT NULL,
+    `reward_amount` INT          NOT NULL DEFAULT 0,
+    `reward_days`   INT          DEFAULT NULL,
+    `reward_level`  VARCHAR(20)  DEFAULT NULL,
+    `chance_id`     BIGINT       DEFAULT NULL COMMENT '关联抽奖机会ID',
+    `create_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_user` (`user_id`, `create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='抽奖记录表';

+ 1 - 2
zhijiayun-invite/pom.xml

@@ -7,11 +7,10 @@
     <parent>
         <groupId>com.xuekairui</groupId>
         <artifactId>zhijiayun-pharmacy</artifactId>
-        <version>1.0-SNAPSHOT</version>
+        <version>1.0.0</version>
     </parent>
 
     <artifactId>zhijiayun-invite</artifactId>
-    <version>1.0.0</version>
     <name>zhijiayun-invite</name>
     <description>智价云(药店版)-邀请码与邀请链接管理模块</description>
 

+ 16 - 7
zhijiayun-invite/src/main/java/com/xuekairui/invite/listener/UserRegisteredListener.java

@@ -4,12 +4,20 @@ import com.xuekairui.invite.service.InviteService;
 import com.xuekairui.user.event.UserRegisteredEvent;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
 
 /**
- * 用户注册事件监听器
- * 监听新用户注册,处理邀请码奖励逻辑
+ * 用户注册事件监听器。
+ * <p>
+ * 使用 {@link TransactionalEventListener}(AFTER_COMMIT 阶段)+ {@link Async}
+ * 确保邀请处理不在发布方事务内运行,且不阻塞注册/登录响应。
+ * <ul>
+ *   <li>AFTER_COMMIT:仅当发布方事务提交后触发,避免邀请处理失败回滚注册</li>
+ *   <li>Async:异步线程执行,不阻塞注册/登录接口响应</li>
+ * </ul>
  */
 @Slf4j
 @Component
@@ -19,17 +27,18 @@ public class UserRegisteredListener {
     private final InviteService inviteService;
 
     /**
-     * 处理用户注册事件:如果携带邀请码,建立邀请关系并发放奖励
+     * 处理用户注册事件:如果携带邀请码,建立邀请关系并发放奖励。
+     * 异步 + 事务提交后触发,失败不影响注册流程。
      */
-    @EventListener
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     public void onUserRegistered(UserRegisteredEvent event) {
         try {
             log.info("收到用户注册事件: userId={}, inviteCode={}",
                     event.getUserId(), event.getInviteCode());
             inviteService.processInviteRegistration(event.getInviteCode(), event.getUserId());
         } catch (Exception e) {
-            // 邀请码处理失败不阻断注册流程
-            log.error("邀请码处理失败: userId={}, error={}", event.getUserId(), e.getMessage());
+            log.error("邀请码处理失败: userId={}, error={}", event.getUserId(), e.getMessage(), e);
         }
     }
 }

+ 24 - 51
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
 import com.xuekairui.common.ValidateUtil;
+import com.xuekairui.common.activity.ActivityInviteGate;
 import com.xuekairui.invite.dto.*;
 import com.xuekairui.invite.entity.*;
 import com.xuekairui.invite.mapper.InviteCodeMapper;
@@ -46,8 +47,8 @@ public class InviteService {
     private final CrawlerQuotaGrantService crawlerQuotaGrantService;
     private final UserMapper userMapper;
     private final CrawlerUsageLogMapper crawlerUsageLogMapper;
-    private final com.xuekairui.user.service.MembershipService membershipService;
     private final com.xuekairui.user.service.CrawlerService crawlerService;
+    private final java.util.Optional<ActivityInviteGate> activityInviteGate;
 
     /** 邀请链接基础URL,可通过配置覆盖 */
     @Value("${invite.base-url:https://app.zhijiayun.com}")
@@ -520,25 +521,15 @@ public class InviteService {
             return;
         }
 
-        // 5.1 检查邀请人总邀请奖励上限(仍可继续邀请,但超过上限后不再发放奖励)
-        long totalInvited = Optional.ofNullable(inviteRelationMapper.selectCount(
-                new LambdaQueryWrapper<InviteRelation>()
-                        .eq(InviteRelation::getInviterId, inviterId)
-                        .eq(InviteRelation::getRegistered, 1))).orElse(0L);
-        int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30;
-        boolean rewardAllowed = totalInvited < maxTotal;
-        if (!rewardAllowed) {
-            log.warn("邀请人{}已达会员时长奖励上限{}人,继续邀请不再发放奖励", inviterId, maxTotal);
-        }
-
-        // 6. 创建邀请关系(直接标记为已注册,奖励类型为赠送会员时长)
+        // 5.1 奖励统一由活动模块处理(ActivityInviteGate),此处不再做上限校验
+        // 6. 创建邀请关系(直接标记为已注册)
         int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
         InviteRelation relation = InviteRelation.builder()
                 .inviterId(inviterId)
                 .inviteeId(inviteeId)
                 .inviteCodeId(inviteCode.getId())
                 .registered(1)
-                .rewardGranted(rewardAllowed ? rewardMonths : 0)
+                .rewardGranted(rewardMonths)
                 .status(1)
                 .build();
         inviteRelationMapper.insert(relation);
@@ -547,13 +538,9 @@ public class InviteService {
         inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
         inviteCodeMapper.updateById(inviteCode);
 
-        // 8. 给邀请人发放奖励(在奖励上限内)
-        if (rewardAllowed) {
-            grantInviteReward(inviterId, inviteeId, config);
-            log.info("邀请成功: inviter={}, invitee={}", inviterId, inviteeId);
-        } else {
-            log.info("邀请成功(已达奖励上限): inviter={}, invitee={}", inviterId, inviteeId);
-        }
+        // 8. 奖励发放统一委托给活动模块
+        grantInviteReward(inviterId, inviteeId, config);
+        log.info("邀请成功: inviter={}, invitee={}", inviterId, inviteeId);
 
         // 9. 写入被邀请人的 inviter_id(不可变,防止删 t_invite_relation 绕过)
         if (invitee != null && invitee.getInviterId() == null) {
@@ -648,13 +635,7 @@ public class InviteService {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请功能已停用");
         }
 
-        // 7.1 检查邀请人总邀请奖励上限(仍可继续绑定,但超过上限后不再发放奖励)
-        long totalInvited = inviteRelationMapper.selectCount(
-                new LambdaQueryWrapper<InviteRelation>()
-                        .eq(InviteRelation::getInviterId, inviterId)
-                        .eq(InviteRelation::getRegistered, 1));
-        int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30;
-        boolean rewardAllowed = totalInvited < maxTotal;
+        // 7.1 奖励统一由活动模块处理,此处不再做上限校验
 
         // 8. 创建邀请关系
         int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
@@ -663,7 +644,7 @@ public class InviteService {
                 .inviteeId(userId)
                 .inviteCodeId(inviteCode.getId())
                 .registered(1)
-                .rewardGranted(rewardAllowed ? rewardMonths : 0)
+                .rewardGranted(rewardMonths)
                 .status(1)
                 .build();
         inviteRelationMapper.insert(relation);
@@ -676,15 +657,10 @@ public class InviteService {
         user.setInviterId(inviterId);
         userMapper.updateById(user);
 
-        // 10. 发放奖励(在奖励上限内)
-        if (rewardAllowed) {
-            grantInviteReward(inviterId, userId, config);
-            log.info("补填邀请码成功: invitee={}, inviter={}", userId, inviterId);
-            return "补填邀请码成功!邀请人获赠1个月会员时长";
-        } else {
-            log.info("补填邀请码成功(已达奖励上限): invitee={}, inviter={}", userId, inviterId);
-            return "补填邀请码成功!邀请人已达奖励上限,本次不发放会员时长";
-        }
+        // 10. 奖励发放统一委托给活动模块
+        grantInviteReward(inviterId, userId, config);
+        log.info("补填邀请码成功: invitee={}, inviter={}", userId, inviterId);
+        return "补填邀请码成功!邀请人获赠1个月会员时长";
     }
 
     // ==========================================
@@ -692,23 +668,20 @@ public class InviteService {
     // ==========================================
 
     /**
-     * 发放邀请奖励(统一入口)
+     * 发放邀请奖励(统一入口)。
+     * <p>
+     * 全部委托给活动模块(ActivityInviteGate)处理,由活动模块根据当前活动配置
+     * (是否开启、触发模式、时间窗、次数上限等)决定是否发放及如何发放。
+     * 若活动模块未加载(无 gate 实现),则跳过发奖。
      * <p>
-     * 包含:1个月高级会员(PRO) + 可选额外爬虫配额。
      * 由 processInviteRegistration 和 bindInviteCode 统一调用。
      */
     private void grantInviteReward(Long inviterId, Long inviteeId, InviteConfig config) {
-        // 1. 会员奖励:1个月高级会员(PRO)
-        membershipService.grantInviteRewardMembership(inviterId, inviteeId);
-
-        // 2. 额外爬虫配额(由运营后台配置,默认0=不送)
-        int crawlerReward = config.getRewardCrawlerCount() != null ? config.getRewardCrawlerCount() : 0;
-        if (crawlerReward > 0) {
-            crawlerQuotaGrantService.grantInviteReward(inviterId, inviteeId, crawlerReward);
+        if (activityInviteGate.isPresent()) {
+            activityInviteGate.get().grantInviteReward(inviterId, inviteeId);
+        } else {
+            log.warn("活动模块未加载,无法发放邀请奖励: inviter={}, invitee={}", inviterId, inviteeId);
         }
-
-        log.info("邀请奖励已发放: inviter={}, invitee={}, 会员=PRO/30天, 配额={}次",
-                inviterId, inviteeId, crawlerReward);
     }
 
     // ==========================================
@@ -1221,4 +1194,4 @@ public class InviteService {
                 .totalReward(totalReward)
                 .build();
     }
-}
+}

+ 1 - 2
zhijiayun-payment/pom.xml

@@ -7,11 +7,10 @@
     <parent>
         <groupId>com.xuekairui</groupId>
         <artifactId>zhijiayun-pharmacy</artifactId>
-        <version>1.0-SNAPSHOT</version>
+        <version>1.0.0</version>
     </parent>
 
     <artifactId>zhijiayun-payment</artifactId>
-    <version>1.0.0</version>
     <name>zhijiayun-payment</name>
     <description>智价云(药店版)-支付模块</description>
 

+ 1 - 2
zhijiayun-test/pom.xml

@@ -7,11 +7,10 @@
     <parent>
         <groupId>com.xuekairui</groupId>
         <artifactId>zhijiayun-pharmacy</artifactId>
-        <version>1.0-SNAPSHOT</version>
+        <version>1.0.0</version>
     </parent>
 
     <artifactId>zhijiayun-test</artifactId>
-    <version>1.0.0</version>
     <name>zhijiayun-test</name>
     <description>测试模块 - 包含前端测试页面</description>
 

+ 917 - 0
zhijiayun-test/src/main/resources/static/activity-test.html

@@ -0,0 +1,917 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>智价云(药店版) - 活动模块API验收测试工具(签到+邀请+抽奖)</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f0f2f5; padding: 24px; font-size: 14px; }
+        .container { max-width: 1400px; margin: 0 auto; }
+        h1 { color: #1a1a2e; margin-bottom: 8px; text-align: center; font-size: 28px; }
+        h2 { color: #1a1a2e; margin-bottom: 6px; text-align: center; font-size: 16px; font-weight: 400; color: #666; }
+        .toolbar { display: flex; gap: 12px; align-items: center; background: white; padding: 16px 20px; border-radius: 8px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); flex-wrap: wrap; }
+        .toolbar label { font-weight: 600; color: #333; white-space: nowrap; font-size: 14px; }
+        .toolbar input, .toolbar select { padding: 10px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 120px; }
+        .toolbar .btn { font-size: 14px; }
+        .login-section { background: #f8f9fa; border: 1px dashed #ccc; border-radius: 6px; padding: 12px 14px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+        .login-section .login-title { font-weight: 700; font-size: 14px; color: #555; margin-right: 4px; }
+        .btn { display: inline-block; padding: 10px 18px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; }
+        .btn-primary { background: #2196f3; color: white; }
+        .btn-primary:hover { background: #1976d2; }
+        .btn-success { background: #4caf50; color: white; }
+        .btn-success:hover { background: #388e3c; }
+        .btn-warning { background: #ff9800; color: white; }
+        .btn-warning:hover { background: #f57c00; }
+        .btn-danger { background: #f44336; color: white; }
+        .btn-danger:hover { background: #d32f2f; }
+        .btn-outline { background: white; color: #2196f3; border: 1px solid #2196f3; }
+        .btn-outline:hover { background: #e3f2fd; }
+        .btn-sm { padding: 4px 10px; font-size: 11px; }
+
+        .module-tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
+        .module-tab { padding: 12px 20px; background: white; border: 1px solid #ddd; border-radius: 6px 6px 0 0; cursor: pointer; font-size: 14px; font-weight: 500; color: #666; transition: all 0.15s; border-bottom: 2px solid transparent; }
+        .module-tab:hover { background: #e3f2fd; color: #1976d2; }
+        .module-tab.active { background: white; color: #1976d2; border-bottom: 2px solid #2196f3; font-weight: 600; }
+
+        .test-section { background: white; border-radius: 8px; padding: 18px; margin-bottom: 14px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
+        .section-title { font-size: 17px; font-weight: 600; color: #333; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #e8e8e8; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+        .section-title .method { display: inline-block; padding: 3px 9px; border-radius: 3px; font-size: 12px; font-weight: 700; }
+        .method-get { background: #e8f5e9; color: #2e7d32; }
+        .method-post { background: #e3f2fd; color: #1565c0; }
+        .method-put { background: #fff3e0; color: #e65100; }
+        .method-delete { background: #fce4ec; color: #c62828; }
+        .section-title .path { font-family: Consolas, Monaco, monospace; color: #555; font-size: 15px; }
+        .section-title .auth-tag { font-size: 12px; padding: 3px 8px; border-radius: 3px; margin-left: auto; }
+        .auth-required { background: #ffcdd2; color: #b71c1c; }
+        .auth-public { background: #c8e6c9; color: #1b5e20; }
+        .side-tag { font-size: 12px; padding: 3px 8px; border-radius: 3px; margin-left: 4px; font-weight: 500; }
+        .side-admin { background: #e3f2fd; color: #1565c0; }
+        .side-user { background: #f3e5f5; color: #7b1fa2; }
+        .role-tag { font-size: 12px; padding: 3px 8px; border-radius: 3px; margin-left: 8px; font-weight: 600; background: #fff3e0; color: #e65100; }
+        .section-desc { font-size: 14px; color: #888; margin-bottom: 10px; }
+
+        .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; margin-bottom: 10px; }
+        .form-group { margin-bottom: 8px; }
+        .form-group label { display: block; margin-bottom: 4px; color: #555; font-weight: 500; font-size: 14px; }
+        .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 15px; }
+
+        .response-box { margin-top: 12px; background: #fafafa; border: 1px solid #e0e0e0; border-radius: 4px; padding: 12px; }
+        .response-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
+        .response-title { font-weight: 600; color: #333; font-size: 14px; }
+        .status-badge { display: inline-block; padding: 4px 10px; border-radius: 3px; font-size: 12px; font-weight: 500; }
+        .status-success { background: #c8e6c9; color: #2e7d32; }
+        .status-error { background: #ffcdd2; color: #c62828; }
+        .status-warn { background: #fff3e0; color: #e65100; }
+        .status-pending { background: #fff3e0; color: #e65100; }
+        .response-content { background: #263238; color: #aed581; padding: 14px; border-radius: 4px; font-family: Consolas, Monaco, monospace; font-size: 13px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 400px; overflow-y: auto; }
+
+        .field-desc-box { margin-top: 8px; background: #f5f7fa; border: 1px solid #e0e0e0; border-radius: 4px; padding: 10px; }
+        .field-desc-title { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 6px; }
+        .field-desc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
+        .field-desc-table th { background: #e8eaed; padding: 4px 8px; text-align: left; color: #333; font-weight: 600; border: 1px solid #ddd; }
+        .field-desc-table td { padding: 4px 8px; border: 1px solid #ddd; color: #555; }
+        .field-desc-table td code { background: #e8e8e8; padding: 1px 4px; border-radius: 2px; font-size: 11px; }
+
+        .summary-bar { display: flex; gap: 16px; padding: 12px 16px; background: white; border-radius: 8px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); align-items: center; flex-wrap: wrap; }
+        .summary-item { display: flex; align-items: center; gap: 6px; font-size: 14px; }
+        .summary-count { font-weight: 700; font-size: 20px; }
+        .summary-count.pass { color: #2e7d32; }
+        .summary-count.fail { color: #c62828; }
+        .summary-count.pending { color: #e65100; }
+
+        .batch-buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
+
+        .side-switcher { display: flex; gap: 0; margin-bottom: 16px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
+        .side-btn { flex: 1; padding: 16px 24px; border: none; cursor: pointer; font-size: 16px; font-weight: 600; transition: all 0.25s; text-align: center; }
+        .side-btn.user-side { background: #e3f2fd; color: #1565c0; }
+        .side-btn.user-side.active { background: #1565c0; color: white; }
+        .side-btn.admin-side { background: #fff3e0; color: #e65100; }
+        .side-btn.admin-side.active { background: #e65100; color: white; }
+        .side-btn:hover { opacity: 0.9; transform: translateY(-1px); }
+        .admin-login-area { background: #fff8e1; border: 1px solid #ffe0b2; border-radius: 6px; }
+        .admin-login-area .login-title { color: #e65100 !important; }
+        .admin-tabs .module-tab.active { color: #e65100; border-bottom-color: #e65100; }
+        .admin-tabs .module-tab:hover { background: #fff3e0; color: #e65100; }
+
+        @media (max-width: 768px) {
+            .form-grid { grid-template-columns: 1fr; }
+            .toolbar { flex-direction: column; align-items: stretch; }
+            .module-tab { font-size: 11px; padding: 8px 12px; }
+        }
+    </style>
+</head>
+<body>
+<div class="container">
+    <h1>智价云(药店版) - 活动模块API验收测试工具</h1>
+    <h2>覆盖签到有礼 + 邀请有礼 + 签到送会员 + 比价抽奖 用户端 &amp; 运营端 全量API | 独立登录管理 | 开发环境验证码固定 123456</h2>
+
+    <!-- 用户侧/运营侧 切换器 -->
+    <div class="side-switcher">
+        <button class="side-btn user-side active" onclick="switchSide('user')">用户侧 (C端)</button>
+        <button class="side-btn admin-side" onclick="switchSide('admin')">运营侧 (管理端)</button>
+    </div>
+
+    <!-- 全局工具栏 -->
+    <div class="toolbar">
+        <label>API 基础地址</label>
+        <label for="baseUrl"></label><input type="text" id="baseUrl" value="http://localhost:8002" style="flex:1;">
+    </div>
+
+    <!-- 用户侧登录区 -->
+    <div id="userLoginSection">
+        <div class="toolbar">
+            <label>用户Token</label>
+            <label for="token"></label><input type="text" id="token" placeholder="登录后自动填入" style="flex:1;" readonly>
+            <span id="roleBadge" class="role-tag" style="display:none;"></span>
+            <button class="btn btn-outline btn-sm" onclick="copyToken()">复制Token</button>
+        </div>
+        <div class="toolbar" style="background:#f8f9fa;">
+            <div class="login-section">
+                <span class="login-title">短信登录</span>
+                <label>手机号</label>
+                <label for="phone"></label><input type="text" id="phone" value="18776156917" style="width:140px;">
+                <label>验证码</label>
+                <label for="smsCode"></label><input type="text" id="smsCode" placeholder="123456" value="123456" style="width:100px;">
+                <button class="btn btn-primary btn-sm" onclick="sendSms()">发送验证码</button>
+                <button class="btn btn-success btn-sm" onclick="smsLogin()">短信登录</button>
+            </div>
+            <div class="login-section">
+                <span class="login-title">密码登录</span>
+                <label>手机号/用户名</label>
+                <label for="pwdAccount"></label><input type="text" id="pwdAccount" value="18776156917" placeholder="手机号或用户名" style="width:140px;">
+                <label>密码</label>
+                <label for="pwdPassword"></label><input type="password" id="pwdPassword" placeholder="输入密码" style="width:120px;">
+                <button class="btn btn-success btn-sm" onclick="passwordLogin()">密码登录</button>
+            </div>
+        </div>
+    </div>
+
+    <!-- 运营侧登录区 -->
+    <div id="adminLoginSection" style="display:none;">
+        <div class="toolbar">
+            <label>管理员Token</label>
+            <label for="adminToken"></label><input type="text" id="adminToken" placeholder="管理员登录后自动填入" style="flex:1;" readonly>
+            <span id="adminRoleBadge" class="role-tag" style="display:none;"></span>
+            <button class="btn btn-outline btn-sm" onclick="copyToken()">复制Token</button>
+        </div>
+        <div class="toolbar admin-login-area">
+            <div class="login-section">
+                <span class="login-title">管理员登录(t_admin表)</span>
+                <label>用户名</label>
+                <label for="adminPwdUsername"></label><input type="text" id="adminPwdUsername" value="admin" placeholder="管理员用户名" style="width:150px;">
+                <label>密码</label>
+                <label for="adminPwdPassword"></label><input type="password" id="adminPwdPassword" value="admin@123" placeholder="管理员密码" style="width:140px;">
+                <button class="btn btn-warning btn-sm" onclick="adminPasswordLogin()">管理员登录</button>
+            </div>
+        </div>
+    </div>
+
+    <!-- 测试统计栏 -->
+    <div class="summary-bar">
+        <span style="font-weight:600;">本页测试统计:</span>
+        <div class="summary-item"><span style="font-weight:700;font-size:20px;color:#1565c0;" id="totalCount">0</span><span>总计</span></div>
+        <div class="summary-item"><span class="summary-count pass" id="passCount">0</span><span>通过</span></div>
+        <div class="summary-item"><span class="summary-count fail" id="failCount">0</span><span>失败</span></div>
+        <div class="summary-item"><span class="summary-count pending" id="pendingCount">0</span><span>待测</span></div>
+        <button class="btn btn-warning btn-sm" onclick="runAllTests()" style="margin-left:auto;">一键全部测试</button>
+        <button class="btn btn-outline btn-sm" onclick="clearAllResults()">清除结果</button>
+    </div>
+
+    <!-- 用户侧模块标签栏 -->
+    <div class="module-tabs" id="moduleTabs">
+        <div class="module-tab active" data-module="checkin">1.签到有礼</div>
+        <div class="module-tab" data-module="activity-invite">2.邀请有礼</div>
+        <div class="module-tab" data-module="activity-checkin">3.签到送会员</div>
+        <div class="module-tab" data-module="activity-lottery">4.比价抽奖</div>
+    </div>
+
+    <!-- 运营侧模块标签栏 -->
+    <div class="module-tabs admin-tabs" id="adminModuleTabs" style="display:none;">
+        <div class="module-tab active" data-module="admin-checkin">1.签到管理</div>
+        <div class="module-tab" data-module="admin-activity-invite">2.邀请有礼管理</div>
+        <div class="module-tab" data-module="admin-activity-checkin">3.签到送会员管理</div>
+        <div class="module-tab" data-module="admin-activity-lottery">4.比价抽奖管理</div>
+    </div>
+
+    <div id="panels"></div>
+</div>
+
+<script>
+// ===================== 全局状态 =====================
+let activeSide = 'user';
+const BASE_URL = () => document.getElementById('baseUrl').value;
+const TOKEN = () => activeSide === 'admin'
+    ? (document.getElementById('adminToken')?.value || '')
+    : (document.getElementById('token')?.value || '');
+const PHONE = () => document.getElementById('phone')?.value || '';
+const SMS_CODE = () => document.getElementById('smsCode')?.value || '123456';
+
+let stats = { pass: 0, fail: 0, pending: 0, total: 0 };
+
+const USER_MODULES = ['checkin', 'activity-invite', 'activity-checkin', 'activity-lottery'];
+const ADMIN_MODULES = ['admin-checkin', 'admin-activity-invite', 'admin-activity-checkin', 'admin-activity-lottery'];
+
+// ===================== 大整数安全JSON解析 =====================
+function safeJsonParse(text) {
+    if (!text || text.trim() === '' || text.trim() === 'null') return null;
+    const safeText = text.replace(/([:,\[]\s*)(\d{16,})(\s*[,\]\}])/g, '$1"$2"$3');
+    return JSON.parse(safeText);
+}
+
+// ===================== HTTP 请求 =====================
+async function api(method, path, body, requireAuth = true) {
+    const headers = { 'Content-Type': 'application/json' };
+    if (requireAuth && TOKEN()) { headers['Authorization'] = 'Bearer ' + TOKEN(); }
+    const opts = { method, headers };
+    if (body && method !== 'GET') { opts.body = JSON.stringify(body); }
+    const startTime = Date.now();
+    try {
+        const res = await fetch(BASE_URL() + path, opts);
+        const text = await res.text();
+        let data;
+        try { data = safeJsonParse(text); } catch (e) { data = { _raw: text }; }
+        return { ok: res.ok, status: res.status, data, duration: Date.now() - startTime };
+    } catch (e) {
+        return { ok: false, status: 0, data: { error: e.message }, duration: Date.now() - startTime };
+    }
+}
+
+// ===================== 测试执行 =====================
+async function runTest(sectionId, method, path, body, requireAuth, validatorName) {
+    const box = document.getElementById(sectionId + '-box');
+    const statusEl = document.getElementById(sectionId + '-status');
+    const rawEl = document.getElementById(sectionId + '-raw');
+    const durEl = document.getElementById(sectionId + '-duration');
+
+    updateBadge(statusEl, 'pending', '运行中...');
+    if (box) box.style.display = 'block';
+
+    let resolvedPath = path;
+    document.querySelectorAll(`[id^="${sectionId}-"]`).forEach(input => {
+        if (input.tagName === 'INPUT') {
+            const fieldName = input.id.replace(sectionId + '-', '');
+            resolvedPath = resolvedPath.replace('{' + fieldName + '}', input.value || fieldName);
+            if (input.value) {
+                const queryVal = encodeURIComponent(input.value);
+                resolvedPath = resolvedPath.replace(
+                    new RegExp('([?&])' + fieldName + '=[^&]*', 'g'),
+                    '$1' + fieldName + '=' + queryVal
+                );
+            }
+        }
+    });
+
+    // 统计total(首次执行时计入)
+    if (!stats.hasOwnProperty(sectionId)) {
+        stats.total++;
+        stats.pending = Math.max(0, stats.total - stats.pass - stats.fail);
+        document.getElementById('totalCount').textContent = stats.total;
+        document.getElementById('pendingCount').textContent = stats.pending;
+    }
+
+    const result = await api(method, resolvedPath, body, requireAuth);
+    if (durEl) durEl.textContent = result.duration + 'ms';
+    if (rawEl) rawEl.textContent = JSON.stringify(result.data, null, 2);
+
+    let passed = false;
+    if (validatorName && typeof window[validatorName] === 'function') {
+        try { passed = window[validatorName](result); } catch (e) { passed = false; }
+    } else {
+        passed = result.ok && result.status === 200;
+    }
+
+    updateStats(sectionId, passed);
+    updateBadge(statusEl, passed ? 'success' : 'error', passed ? '通过 ' + result.status : '失败 ' + result.status);
+    if (box) box.style.display = 'block';
+}
+
+function updateBadge(el, cls, text) {
+    if (!el) return;
+    el.textContent = text;
+    el.className = 'status-badge status-' + cls;
+}
+
+function updateStats(id, passed) {
+    const old = stats[id];
+    if (old === 'pass') stats.pass--;
+    if (old === 'fail') stats.fail--;
+
+    stats[id] = passed ? 'pass' : 'fail';
+    if (passed) stats.pass++; else stats.fail++;
+
+    stats.pending = Math.max(0, stats.total - stats.pass - stats.fail);
+    document.getElementById('passCount').textContent = stats.pass;
+    document.getElementById('failCount').textContent = stats.fail;
+    document.getElementById('pendingCount').textContent = stats.pending;
+}
+
+// ===================== 登录相关 =====================
+async function sendSms() {
+    const res = await api('POST', '/api/auth/sms/send', { phone: PHONE(), scene: 'login' }, false);
+    if (res.ok) {
+        document.getElementById('smsCode').value = '123456';
+        alert('验证码已写入(开发测试模式,固定验证码: 123456)');
+    } else {
+        alert('发送失败: ' + JSON.stringify(res.data));
+    }
+}
+
+async function smsLogin() {
+    const phone = PHONE();
+    const code = SMS_CODE();
+    if (!phone) { alert('请填写手机号'); return; }
+    const payload = { phone, code, scene: 'login' };
+    const res = await api('POST', '/api/auth/sms/login', payload, false);
+    if (res.data?.code === 200 && res.data?.data?.accessToken) {
+        document.getElementById('token').value = res.data.data.accessToken;
+        updateRoleBadge(res.data.data.role);
+        alert('短信登录成功!\n用户ID: ' + res.data.data.userId + '\n角色: ' + (res.data.data.role || 'USER'));
+    } else {
+        alert('登录失败: ' + (res.data?.message || '未知错误'));
+    }
+}
+
+async function passwordLogin() {
+    const account = document.getElementById('pwdAccount').value.trim();
+    const password = document.getElementById('pwdPassword').value;
+    if (!account || !password) { alert('请填写手机号/用户名和密码'); return; }
+    let payload;
+    if (/^\d{11}$/.test(account)) {
+        payload = { phone: account, password };
+    } else {
+        payload = { username: account, password };
+    }
+    const res = await api('POST', '/api/auth/password/login', payload, false);
+    if (res.data?.code === 200 && res.data?.data?.accessToken) {
+        document.getElementById('token').value = res.data.data.accessToken;
+        updateRoleBadge(res.data.data.role);
+        alert('密码登录成功!\n用户ID: ' + res.data.data.userId + '\n角色: ' + (res.data.data.role || 'USER'));
+    } else {
+        alert('登录失败: ' + (res.data?.message || '未知错误'));
+    }
+}
+
+async function adminPasswordLogin() {
+    const username = document.getElementById('adminPwdUsername').value;
+    const password = document.getElementById('adminPwdPassword').value;
+    if (!username || !password) { alert('请填写管理员用户名和密码'); return; }
+    const payload = { username, password };
+    const res = await api('POST', '/api/auth/password/login', payload, false);
+    if (res.data?.code === 200 && res.data?.data?.accessToken) {
+        document.getElementById('adminToken').value = res.data.data.accessToken;
+        updateAdminRoleBadge(res.data.data.role);
+        alert('管理员登录成功!\n用户ID: ' + res.data.data.userId + '\n角色: ' + (res.data.data.role || 'ADMIN'));
+    } else {
+        alert('登录失败: ' + (res.data?.message || '未知错误'));
+    }
+}
+
+function updateRoleBadge(role) {
+    const badge = document.getElementById('roleBadge');
+    if (badge) {
+        if (role) { badge.textContent = role; badge.style.display = 'inline-block'; }
+        else { badge.style.display = 'none'; }
+    }
+}
+
+function updateAdminRoleBadge(role) {
+    const badge = document.getElementById('adminRoleBadge');
+    if (badge) {
+        if (role) { badge.textContent = role; badge.style.display = 'inline-block'; }
+        else { badge.style.display = 'none'; }
+    }
+}
+
+function copyToken() {
+    const token = TOKEN();
+    if (!token) { alert('当前无Token'); return; }
+    navigator.clipboard.writeText(token).then(() => alert('Token已复制到剪贴板'));
+}
+
+// ===================== 侧边切换 =====================
+function switchSide(side) {
+    activeSide = side;
+    const isAdmin = side === 'admin';
+    document.getElementById('userLoginSection').style.display = isAdmin ? 'none' : '';
+    document.getElementById('adminLoginSection').style.display = isAdmin ? '' : 'none';
+    document.getElementById('moduleTabs').style.display = isAdmin ? 'none' : '';
+    document.getElementById('adminModuleTabs').style.display = isAdmin ? '' : 'none';
+    if (isAdmin) {
+        const activeTab = document.querySelector('#adminModuleTabs .module-tab.active');
+        renderModule(activeTab ? activeTab.dataset.module : 'admin-checkin');
+    } else {
+        const activeTab = document.querySelector('#moduleTabs .module-tab.active');
+        renderModule(activeTab ? activeTab.dataset.module : 'checkin');
+    }
+}
+
+document.getElementById('moduleTabs').addEventListener('click', (e) => {
+    if (!e.target.classList.contains('module-tab')) return;
+    document.querySelectorAll('#moduleTabs .module-tab').forEach(t => t.classList.remove('active'));
+    e.target.classList.add('active');
+    renderModule(e.target.dataset.module);
+});
+document.getElementById('adminModuleTabs').addEventListener('click', (e) => {
+    if (!e.target.classList.contains('module-tab')) return;
+    document.querySelectorAll('#adminModuleTabs .module-tab').forEach(t => t.classList.remove('active'));
+    e.target.classList.add('active');
+    renderModule(e.target.dataset.module);
+});
+
+// ===================== 渲染模块面板 =====================
+function renderModule(module) {
+    const panels = document.getElementById('panels');
+    let html = '';
+    switch (module) {
+        case 'checkin': html = renderCheckinModule(); break;
+        case 'activity-invite': html = renderActivityInviteModule(); break;
+        case 'activity-checkin': html = renderActivityCheckinModule(); break;
+        case 'activity-lottery': html = renderActivityLotteryModule(); break;
+        case 'admin-checkin': html = renderAdminCheckinModule(); break;
+        case 'admin-activity-invite': html = renderAdminActivityInviteModule(); break;
+        case 'admin-activity-checkin': html = renderAdminActivityCheckinModule(); break;
+        case 'admin-activity-lottery': html = renderAdminActivityLotteryModule(); break;
+    }
+    panels.innerHTML = html;
+    panels.querySelectorAll('input[data-raw]').forEach(input => {
+        input.value = input.getAttribute('data-raw');
+        input.removeAttribute('data-raw');
+    });
+}
+
+function toValidatorName(id) {
+    const parts = id.split('-');
+    let camel = parts[0];
+    for (let i = 1; i < parts.length; i++) {
+        camel += parts[i].charAt(0).toUpperCase() + parts[i].slice(1);
+    }
+    return 'validate_' + camel;
+}
+
+// ==================== 字段说明数据 ====================
+const FIELD_DESC = {
+    'resultWrapper': [
+        ['code', 'Integer', '业务状态码(200=成功)'],
+        ['message', 'String', '提示信息(如:操作成功)'],
+        ['data', 'Object/Array/null', '返回数据(详见下方字段)'],
+        ['timestamp', 'Long', '响应时间戳(毫秒级)']
+    ],
+    'checkinStatus': [
+        ['data.checkedToday', 'Boolean', '今日是否已签到'],
+        ['data.continuousDays', 'Integer', '当前连续签到天数(今日未签到时为若签到将达成的天数-1)'],
+        ['data.todayReward', 'Integer', '今日签到可获奖励数量'],
+        ['data.nextCheckinDate', 'Date', '下一次可签到日期(今日未签到=今天)'],
+        ['data.enabled', 'Boolean', '签到功能是否启用']
+    ],
+    'checkinResult': [
+        ['data.id', 'Long', '签到记录ID'],
+        ['data.checkinDate', 'Date', '签到日期'],
+        ['data.continuousDays', 'Integer', '签到时连续天数'],
+        ['data.rewardAmount', 'Integer', '本次奖励数量(爬虫次数)'],
+        ['data.rewardType', 'String', '奖励类型(CRAWLER)'],
+        ['data.createTime', 'DateTime', '签到时间']
+    ],
+    'checkinCalendar': [
+        ['data.month', 'String', '年-月(如2026-07)'],
+        ['data.checkedDays', 'Integer', '当月已签到天数'],
+        ['data.continuousDays', 'Integer', '当前连续签到天数'],
+        ['data.checkedDates', 'Array<Date>', '已签到日期列表'],
+        ['data.checkedToday', 'Boolean', '今日是否已签到']
+    ],
+    'checkinConfig': [
+        ['data.id', 'Long', '配置ID(固定1)'],
+        ['data.enabled', 'Integer', '是否启用:0-停用 1-启用'],
+        ['data.rewardType', 'String', '奖励类型(CRAWLER)'],
+        ['data.baseReward', 'Integer', '基础签到奖励(每日)'],
+        ['data.continuousReward', 'Integer', '连续签到额外奖励'],
+        ['data.continuousThreshold', 'Integer', '连续奖励触发阈值(天)'],
+        ['data.continuousCap', 'Integer', '连续奖励累计上限'],
+        ['data.rewardExpireDays', 'Integer', '奖励有效天数(null=永久)'],
+        ['data.remark', 'String', '备注'],
+        ['data.createTime', 'DateTime', '创建时间'],
+        ['data.updateTime', 'DateTime', '更新时间']
+    ],
+    'checkinStats': [
+        ['data.todayCheckinCount', 'Long', '今日签到人数'],
+        ['data.yesterdayCheckinCount', 'Long', '昨日签到人数'],
+        ['data.last7DaysCount', 'Long', '近7天签到人数(去重)'],
+        ['data.last30DaysCount', 'Long', '近30天签到人数(去重)'],
+        ['data.totalCheckinCount', 'Long', '累计签到总次数'],
+        ['data.totalCheckinUsers', 'Long', '累计签到用户数(去重)'],
+        ['data.totalRewardAmount', 'Long', '累计发放奖励次数总和'],
+        ['data.statDate', 'Date', '统计日期'],
+        ['data.enabled', 'Boolean', '签到功能是否启用']
+    ],
+    'adminCheckinRecord': [
+        ['data.records[].id', 'Long', '签到记录ID'],
+        ['data.records[].userId', 'Long', '用户ID'],
+        ['data.records[].phone', 'String', '用户手机号'],
+        ['data.records[].nickname', 'String', '用户昵称'],
+        ['data.records[].pharmacyName', 'String', '药店名称'],
+        ['data.records[].checkinDate', 'Date', '签到日期'],
+        ['data.records[].continuousDays', 'Integer', '签到时连续天数'],
+        ['data.records[].rewardType', 'String', '奖励类型'],
+        ['data.records[].rewardAmount', 'Integer', '本次奖励数量'],
+        ['data.records[].grantId', 'Long', '关联配额发放记录ID'],
+        ['data.records[].clientSource', 'String', '签到端'],
+        ['data.records[].createTime', 'DateTime', '签到时间'],
+        ['data.total', 'Long', '总记录数'],
+        ['data.current', 'Long', '当前页'],
+        ['data.size', 'Long', '每页条数'],
+        ['data.pages', 'Long', '总页数']
+    ]
+};
+
+function renderFieldDesc(descKey) {
+    const fields = FIELD_DESC[descKey];
+    if (!fields) return '';
+    let rows = fields.map(f => `<tr><td><code>${f[0]}</code></td><td>${f[1]}</td><td>${f[2]}</td></tr>`).join('');
+    return `<div class="field-desc-box">
+        <div class="field-desc-title">返回字段说明</div>
+        <table class="field-desc-table"><tr><th>字段名</th><th>类型</th><th>说明</th></tr>${rows}</table>
+    </div>`;
+}
+
+// ==================== 测试用例区块渲染 ====================
+function testSection(id, title, method, methodClass, path, authTag, authClass, fields, hasValidator, descKey, desc) {
+    const authLabel = authTag === '需认证' ? '需认证' : '公开';
+    const ac = authTag === '需认证' ? 'auth-required' : 'auth-public';
+    const side = path.includes('/api/admin') ? 'admin' : 'user';
+    const sideClass = side === 'admin' ? 'side-admin' : 'side-user';
+    const sideLabel = side === 'admin' ? '运营侧' : '用户侧';
+    let fieldsHtml = '';
+    if (fields) {
+        fieldsHtml = '<div class="form-grid">';
+        fields.forEach(f => {
+            const escapedDefault = (f.default || '').replace(/"/g, '&quot;');
+            const escapedPlaceholder = (f.placeholder || '').replace(/"/g, '&quot;');
+            const hasRaw = f.default && f.default.includes('"');
+            fieldsHtml += `<div class="form-group"><label>${f.label}</label><input type="text" id="${id}-${f.name}" placeholder="${escapedPlaceholder}" value="${escapedDefault}"${hasRaw ? ` data-raw="${escapedDefault}"` : ''}></div>`;
+        });
+        fieldsHtml += '</div>';
+    }
+    const descHtml = desc ? `<div class="section-desc">${desc}</div>` : '';
+    const fieldDescHtml = descKey ? renderFieldDesc(descKey) : '';
+    return `
+    <div class="test-section">
+        <div class="section-title">
+            <span class="method method-${methodClass}">${method}</span>
+            <span class="path">${path}</span>
+            <span class="side-tag ${sideClass}">${sideLabel}</span>
+            <span class="${ac} auth-tag">${authLabel}</span>
+        </div>
+        ${descHtml}
+        ${fieldsHtml}
+        <button class="btn btn-primary" onclick="runTest('${id}', '${method}', '${path}', ${buildBody(id, fields)}, ${authTag === '公开' ? 'false' : 'true'}, ${hasValidator ? `'${toValidatorName(id)}'` : 'null'})">执行测试</button>
+        <div class="response-box" id="${id}-box" style="display:none;">
+            <div class="response-header">
+                <span class="response-title">响应 <span id="${id}-duration" style="color:#999;font-weight:400;"></span></span>
+                <span class="status-badge" id="${id}-status"></span>
+            </div>
+            <div class="response-content" id="${id}-raw"></div>
+            ${fieldDescHtml}
+        </div>
+    </div>`;
+}
+
+function buildBody(id, fields) {
+    if (!fields || fields.length === 0) return 'null';
+    let parts = [];
+    fields.forEach(f => {
+        const forceArray = f.type === 'array';
+        const asJson = f.type === 'json';
+        const forceString = f.type === 'string';
+        if (asJson) {
+            parts.push(`&quot;${f.name}&quot;: safeJsonParse(document.getElementById('${id}-${f.name}').value)`);
+        } else if (forceString) {
+            parts.push(`&quot;${f.name}&quot;: document.getElementById('${id}-${f.name}').value || null`);
+        } else {
+            parts.push(`&quot;${f.name}&quot;: parseFieldValue(document.getElementById('${id}-${f.name}').value, ${forceArray})`);
+        }
+    });
+    return `{${parts.join(',')}}`;
+}
+
+function parseFieldValue(val, forceArray) {
+    if (val === '' || val === undefined || val === null) return forceArray ? [] : null;
+    if (val === 'true') return forceArray ? [true] : true;
+    if (val === 'false') return forceArray ? [false] : false;
+    if (val.includes(',')) {
+        const items = val.split(',').map(v => v.trim()).filter(v => v !== '');
+        return items.map(v => toNumberIfSafe(v));
+    }
+    if (/^\d+(\.\d+)?$/.test(val)) return forceArray ? [toNumberIfSafe(val)] : toNumberIfSafe(val);
+    return forceArray ? [val] : val;
+}
+
+function toNumberIfSafe(v) {
+    if (!/^\d+(\.\d+)?$/.test(v)) return v;
+    const num = Number(v);
+    if (Number.isInteger(num) && !Number.isSafeInteger(num)) return v;
+    return num;
+}
+
+// ==================== 用户端:签到模块 ====================
+function renderCheckinModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runCheckinTests()">运行全部签到测试</button>
+    </div>
+    ${testSection('checkin-status', '今日签到状态', 'GET', 'get', '/api/checkin/status', '需认证', 'auth-required', null, true, 'checkinStatus', '获取当前用户今日签到状态、连续天数、可获奖励')}
+    ${testSection('checkin-do', '执行签到', 'POST', 'post', '/api/checkin', '需认证', 'auth-required',
+        [{label:'签到端(clientSource,可选)', name:'clientSource', placeholder:'WINDOWS/ANDROID/MINIAPP', default:'WINDOWS'}], true, 'checkinResult', '执行当日签到,发放爬虫次数奖励。每个自然日仅可签到一次')}
+    ${testSection('checkin-calendar', '当月签到日历', 'GET', 'get', '/api/checkin/calendar?year=2026&month=7', '需认证', 'auth-required',
+        [{label:'年份(year,可选)', name:'year', placeholder:'2026', default:'2026'},
+         {label:'月份(month,可选)', name:'month', placeholder:'7', default:'7'}], true, 'checkinCalendar', '获取指定月份的签到日历,不传默认当月')}
+    ${testSection('checkin-records', '我的签到记录', 'GET', 'get', '/api/checkin/records?limit=30', '需认证', 'auth-required',
+        [{label:'条数(limit,可选)', name:'limit', placeholder:'30', default:'30'}], true, null, '获取当前用户的签到历史记录,按签到日期倒序')}`;
+}
+
+// ==================== 用户端:邀请有礼模块 ====================
+function renderActivityInviteModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runActivityInviteTests()">运行全部邀请有礼测试</button>
+    </div>
+    ${testSection('activity-invite-status', '邀请有礼活动状态', 'GET', 'get', '/api/activity/invite/status', '需认证', 'auth-required', null, true, 'activityInviteStatus', '查询当前邀请有礼活动状态:是否开启、奖励模式、已获/剩余次数')}`;
+}
+
+// ==================== 用户端:签到送会员模块 ====================
+function renderActivityCheckinModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runActivityCheckinTests()">运行全部签到送会员测试</button>
+    </div>
+    ${testSection('activity-checkin-status', '签到送会员周期状态', 'GET', 'get', '/api/activity/checkin-cycle/status', '需认证', 'auth-required', null, true, 'activityCheckinStatus', '查询当前周期签到进度、是否达标、累计领取次数')}`;
+}
+
+// ==================== 用户端:比价抽奖模块 ====================
+function renderActivityLotteryModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runActivityLotteryTests()">运行全部比价抽奖测试</button>
+    </div>
+    ${testSection('activity-lottery-status', '比价抽奖活动状态', 'GET', 'get', '/api/activity/lottery/status', '需认证', 'auth-required', null, true, 'activityLotteryStatus', '查询抽奖活动状态:是否开启、是否满足资格、今日可用机会')}
+    ${testSection('activity-lottery-draw', '执行抽奖', 'POST', 'post', '/api/activity/lottery/draw', '需认证', 'auth-required', null, true, 'activityLotteryDraw', '消耗一次抽奖机会,随机抽取奖品(会员/爬虫次数/谢谢参与)')}
+    ${testSection('activity-lottery-records', '我的抽奖记录', 'GET', 'get', '/api/activity/lottery/records?limit=20', '需认证', 'auth-required',
+        [{label:'条数(limit,可选)', name:'limit', placeholder:'20', default:'20'}], true, null, '获取当前用户的抽奖历史记录')}`;
+}
+
+// ==================== 运营端:签到管理模块 ====================
+function renderAdminCheckinModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runAdminCheckinTests()">运行全部签到管理测试</button>
+    </div>
+    ${testSection('admin-checkin-config', '签到配置查询', 'GET', 'get', '/api/admin/checkin/config', '需认证', 'auth-required', null, true, 'checkinConfig', '获取当前签到配置(全局唯一)')}
+    ${testSection('admin-checkin-config-update', '更新签到配置', 'PUT', 'put', '/api/admin/checkin/config', '需认证', 'auth-required',
+        [
+         {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
+         {label:'奖励类型(rewardType)', name:'rewardType', placeholder:'CRAWLER', default:'CRAWLER'},
+         {label:'基础奖励(baseReward)', name:'baseReward', placeholder:'1', default:'1'},
+         {label:'连续奖励(continuousReward)', name:'continuousReward', placeholder:'1', default:'1'},
+         {label:'连续阈值(continuousThreshold)', name:'continuousThreshold', placeholder:'7', default:'7'},
+         {label:'连续上限(continuousCap)', name:'continuousCap', placeholder:'30', default:'30'},
+         {label:'有效天数(rewardExpireDays)', name:'rewardExpireDays', placeholder:'30', default:'30'},
+         {label:'备注(remark)', name:'remark', placeholder:'默认签到配置', default:'默认签到配置:每日1次,连续7天起每日额外+1'}
+        ], true, 'checkinConfig', '更新签到规则配置。连续签到满阈值后每日额外发放continuousReward,累计不超过continuousCap')}
+    ${testSection('admin-checkin-stats', '签到统计概览', 'GET', 'get', '/api/admin/checkin/stats', '需认证', 'auth-required', null, true, 'checkinStats', '签到数据统计:今日/昨日/近7天/近30天签到人数、累计签到次数与用户数、累计发放奖励')}
+    ${testSection('admin-checkin-records', '签到记录分页查询', 'GET', 'get', '/api/admin/checkin/records?page=1&size=10&userId=1&startDate=2026-07-01&endDate=2026-07-07&clientSource=WINDOWS', '需认证', 'auth-required',
+        [{label:'页码(page)', name:'page', placeholder:'1', default:'1'},
+         {label:'每页条数(size)', name:'size', placeholder:'10', default:'10'},
+         {label:'用户ID(userId,可选)', name:'userId', placeholder:'1', default:''},
+         {label:'起始日期(startDate,可选)', name:'startDate', placeholder:'2026-07-01', default:''},
+         {label:'截止日期(endDate,可选)', name:'endDate', placeholder:'2026-07-07', default:''},
+         {label:'签到端(clientSource,可选)', name:'clientSource', placeholder:'WINDOWS', default:''}], true, 'adminCheckinRecord', '分页查询全局签到记录,关联用户手机号/昵称/药店名。条件均可选')}
+    ${testSection('admin-checkin-user-records', '指定用户签到记录', 'GET', 'get', '/api/admin/checkin/user/{userId}/records', '需认证', 'auth-required',
+        [{label:'用户ID(userId)', name:'userId', placeholder:'1', default:'1'}], true, null, '查询指定用户的全部签到记录(最多500条),含用户基础信息')}`;
+}
+
+// ==================== 运营端:邀请有礼管理模块 ====================
+function renderAdminActivityInviteModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runAdminActivityInviteTests()">运行全部邀请有礼管理测试</button>
+    </div>
+    ${testSection('admin-activity-invite-config', '邀请有礼配置查询', 'GET', 'get', '/api/admin/activity/invite/config', '需认证', 'auth-required', null, true, 'adminActivityInviteConfig', '获取当前邀请有礼活动配置:启用状态、时间窗、奖励天数、触发模式、上限')}
+    ${testSection('admin-activity-invite-config-update', '更新邀请有礼配置', 'PUT', 'put', '/api/admin/activity/invite/config', '需认证', 'auth-required',
+        [
+         {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
+         {label:'开始时间(startTime)', name:'startTime', placeholder:'2026-07-10 00:00:00', default:'2026-07-10 00:00:00'},
+         {label:'结束时间(endTime)', name:'endTime', placeholder:'2026-07-31 23:59:59', default:'2026-07-31 23:59:59'},
+         {label:'邀请人奖励天数(inviterRewardDays)', name:'inviterRewardDays', placeholder:'30', default:'30'},
+         {label:'被邀请人奖励天数(inviteeRewardDays)', name:'inviteeRewardDays', placeholder:'30', default:'30'},
+         {label:'邀请人最多奖励次数(maxInviterReward)', name:'maxInviterReward', placeholder:'30', default:'30'},
+         {label:'奖励触发模式(rewardTrigger)', name:'rewardTrigger', placeholder:'REGISTRATION/LICENSE_APPROVED', default:'LICENSE_APPROVED'},
+         {label:'要求小程序用户(requireMiniappInviter)', name:'requireMiniappInviter', placeholder:'0不要求/1要求', default:'1'},
+         {label:'备注(remark)', name:'remark', placeholder:'邀请有礼活动', default:'邀请有礼:被邀请人资质审核通过后双方各得30天高级会员'}
+        ], true, 'adminActivityInviteConfig', '更新邀请有礼活动规则。rewardTrigger=REGISTRATION注册即发,LICENSE_APPROVED资质审核通过后发')}
+    ${testSection('admin-activity-invite-revoke', '撤销邀请奖励(反作弊)', 'POST', 'post', '/api/admin/activity/invite/revoke', '需认证', 'auth-required',
+        [
+         {label:'被邀请人ID(inviteeId)', name:'inviteeId', placeholder:'作弊者用户ID', default:''},
+         {label:'撤销原因(reason)', name:'reason', placeholder:'冒用他人资质', default:'冒用他人资质'}
+        ], true, 'adminActivityInviteRevoke', '反作弊:根据被邀请人ID撤销其对应的邀请奖励,标记为已撤销状态')}`;
+}
+
+// ==================== 运营端:签到送会员管理模块 ====================
+function renderAdminActivityCheckinModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runAdminActivityCheckinTests()">运行全部签到送会员管理测试</button>
+    </div>
+    ${testSection('admin-activity-checkin-config', '签到送会员配置查询', 'GET', 'get', '/api/admin/activity/checkin-cycle/config', '需认证', 'auth-required', null, true, 'adminActivityCheckinConfig', '获取当前签到送会员活动配置:周期模式、需签到天数、奖励天数、最多领取次数')}
+    ${testSection('admin-activity-checkin-config-update', '更新签到送会员配置', 'PUT', 'put', '/api/admin/activity/checkin-cycle/config', '需认证', 'auth-required',
+        [
+         {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
+         {label:'开始时间(startTime)', name:'startTime', placeholder:'2026-07-10 00:00:00', default:'2026-07-10 00:00:00'},
+         {label:'结束时间(endTime)', name:'endTime', placeholder:'2026-07-31 23:59:59', default:'2026-07-31 23:59:59'},
+         {label:'周期模式(cycleMode)', name:'cycleMode', placeholder:'FRI_THU', default:'FRI_THU'},
+         {label:'需签到天数(requiredDays)', name:'requiredDays', placeholder:'4', default:'4'},
+         {label:'奖励天数(rewardDays)', name:'rewardDays', placeholder:'7', default:'7'},
+         {label:'最多领取次数(maxRewardCount)', name:'maxRewardCount', placeholder:'3', default:'3'},
+         {label:'备注(remark)', name:'remark', placeholder:'签到送会员', default:'每周五至下周四为一周期,累计4天签到得7天高级会员,最多3次'}
+        ], true, 'adminActivityCheckinConfig', '更新签到送会员活动规则。周期结束后第一天自动发放奖励')}`;
+}
+
+// ==================== 运营端:比价抽奖管理模块 ====================
+function renderAdminActivityLotteryModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runAdminActivityLotteryTests()">运行全部比价抽奖管理测试</button>
+    </div>
+    ${testSection('admin-activity-lottery-config', '比价抽奖配置查询', 'GET', 'get', '/api/admin/activity/lottery/config', '需认证', 'auth-required', null, true, 'adminActivityLotteryConfig', '获取当前比价抽奖活动配置:每日机会数、所需会员等级')}
+    ${testSection('admin-activity-lottery-config-update', '更新比价抽奖配置', 'PUT', 'put', '/api/admin/activity/lottery/config', '需认证', 'auth-required',
+        [
+         {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
+         {label:'开始时间(startTime)', name:'startTime', placeholder:'2026-07-10 00:00:00', default:'2026-07-10 00:00:00'},
+         {label:'结束时间(endTime)', name:'endTime', placeholder:'2026-07-31 23:59:59', default:'2026-07-31 23:59:59'},
+         {label:'每日最多机会(dailyMaxChance)', name:'dailyMaxChance', placeholder:'1', default:'1'},
+         {label:'所需会员等级(requireMembershipLevel)', name:'requireMembershipLevel', placeholder:'PRO', default:'PRO'},
+         {label:'备注(remark)', name:'remark', placeholder:'比价抽奖', default:'高级会员每自然日首次成功比价获得1次抽奖机会'}
+        ], true, 'adminActivityLotteryConfig', '更新比价抽奖活动规则')}
+    ${testSection('admin-activity-lottery-prizes', '奖品池列表', 'GET', 'get', '/api/admin/activity/lottery/prizes', '需认证', 'auth-required', null, true, null, '获取所有奖品及其权重配置')}
+    ${testSection('admin-activity-lottery-prize-create', '新增奖品', 'POST', 'post', '/api/admin/activity/lottery/prizes', '需认证', 'auth-required',
+        [
+         {label:'奖品名称(name)', name:'name', placeholder:'高级会员3天', default:'爬虫次数+10'},
+         {label:'奖励类型(rewardType)', name:'rewardType', placeholder:'MEMBERSHIP/CRAWLER/EMPTY', default:'CRAWLER'},
+         {label:'奖励数量(rewardAmount)', name:'rewardAmount', placeholder:'爬虫次数填数量', default:'10'},
+         {label:'会员天数(rewardDays)', name:'rewardDays', placeholder:'MEMBERSHIP类型填', default:''},
+         {label:'会员等级(rewardLevel)', name:'rewardLevel', placeholder:'PRO/ULTRA', default:''},
+         {label:'概率权重(probabilityWeight)', name:'probabilityWeight', placeholder:'权重越大越容易中', default:'5'},
+         {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
+         {label:'排序(sort)', name:'sort', placeholder:'0', default:'6'}
+        ], true, 'adminActivityLotteryPrize', '新增奖品到奖品池。rewardType: MEMBERSHIP会员/CRAWLER爬虫/EMPTY谢谢参与')}
+    ${testSection('admin-activity-lottery-prize-update', '更新奖品', 'PUT', 'put', '/api/admin/activity/lottery/prizes/{id}', '需认证', 'auth-required',
+        [
+         {label:'奖品ID(id)', name:'id', placeholder:'1', default:''},
+         {label:'奖品名称(name)', name:'name', placeholder:'高级会员3天', default:'爬虫次数+10'},
+         {label:'奖励类型(rewardType)', name:'rewardType', placeholder:'MEMBERSHIP/CRAWLER/EMPTY', default:'CRAWLER'},
+         {label:'奖励数量(rewardAmount)', name:'rewardAmount', placeholder:'爬虫次数填数量', default:'10'},
+         {label:'会员天数(rewardDays)', name:'rewardDays', placeholder:'MEMBERSHIP类型填', default:''},
+         {label:'会员等级(rewardLevel)', name:'rewardLevel', placeholder:'PRO/ULTRA', default:''},
+         {label:'概率权重(probabilityWeight)', name:'probabilityWeight', placeholder:'权重越大越容易中', default:'5'},
+         {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
+         {label:'排序(sort)', name:'sort', placeholder:'0', default:'6'}
+        ], true, 'adminActivityLotteryPrize', '更新奖品池中的奖品配置')}
+    ${testSection('admin-activity-lottery-prize-delete', '删除奖品', 'DELETE', 'delete', '/api/admin/activity/lottery/prizes/{id}', '需认证', 'auth-required',
+        [{label:'奖品ID(id)', name:'id', placeholder:'需删除的奖品ID', default:''}], true, null, '从奖品池中删除指定奖品')}`;
+}
+
+// ===================== 批量测试 ====================
+function getSectionsInModule(modulePrefix) {
+    return Array.from(document.querySelectorAll('.test-section')).filter(s => {
+        const btn = s.querySelector('button[onclick*="runTest"]');
+        return btn && btn.getAttribute('onclick').includes("'" + modulePrefix + "-");
+    });
+}
+
+function countTestsInCurrentPanel() {
+    return document.querySelectorAll('.test-section button[onclick*="runTest"]').length;
+}
+
+async function runBatchTests(modulePrefix) {
+    const sections = getSectionsInModule(modulePrefix);
+    for (const section of sections) {
+        const btn = section.querySelector('button[onclick*="runTest"]');
+        if (btn) {
+            const onclick = btn.getAttribute('onclick') || '';
+            const match = onclick.match(/runTest\('([^']+)'/);
+            if (match && !stats.hasOwnProperty(match[1])) {
+                stats.total++;
+            }
+        }
+    }
+    stats.pending = Math.max(0, stats.total - stats.pass - stats.fail);
+    document.getElementById('totalCount').textContent = stats.total;
+    document.getElementById('pendingCount').textContent = stats.pending;
+    for (const section of sections) {
+        const btn = section.querySelector('button[onclick*="runTest"]');
+        if (btn) {
+            btn.click();
+            await new Promise(r => setTimeout(r, 600));
+        }
+    }
+}
+
+async function runAllTests() {
+    resetStats();
+    for (const m of USER_MODULES) {
+        const tab = document.querySelector(`#moduleTabs .module-tab[data-module="${m}"]`);
+        if (tab) tab.click();
+        await new Promise(r => setTimeout(r, 50));
+        stats.total += countTestsInCurrentPanel();
+    }
+    for (const m of ADMIN_MODULES) {
+        const tab = document.querySelector(`#adminModuleTabs .module-tab[data-module="${m}"]`);
+        if (tab) tab.click();
+        await new Promise(r => setTimeout(r, 50));
+        stats.total += countTestsInCurrentPanel();
+    }
+    document.getElementById('totalCount').textContent = stats.total;
+    stats.pending = stats.total;
+    document.getElementById('pendingCount').textContent = stats.pending;
+
+    switchSide('user');
+    for (const m of USER_MODULES) {
+        try {
+            const tab = document.querySelector(`#moduleTabs .module-tab[data-module="${m}"]`);
+            if (tab) tab.click();
+            await new Promise(r => setTimeout(r, 100));
+            await runBatchTests(m);
+        } catch (e) { console.error('模块 ' + m + ' 测试出错:', e); }
+    }
+    switchSide('admin');
+    for (const m of ADMIN_MODULES) {
+        try {
+            const tab = document.querySelector(`#adminModuleTabs .module-tab[data-module="${m}"]`);
+            if (tab) tab.click();
+            await new Promise(r => setTimeout(r, 100));
+            await runBatchTests(m);
+        } catch (e) { console.error('模块 ' + m + ' 测试出错:', e); }
+    }
+}
+
+function resetStats() {
+    const keys = Object.keys(stats);
+    for (const k of keys) {
+        if (k !== 'pass' && k !== 'fail' && k !== 'pending' && k !== 'total') {
+            delete stats[k];
+        }
+    }
+    stats.pass = 0;
+    stats.fail = 0;
+    stats.pending = 0;
+    stats.total = 0;
+    document.getElementById('totalCount').textContent = '0';
+    document.getElementById('passCount').textContent = '0';
+    document.getElementById('failCount').textContent = '0';
+    document.getElementById('pendingCount').textContent = '0';
+}
+
+function clearAllResults() {
+    document.querySelectorAll('.response-box').forEach(b => b.style.display = 'none');
+    document.querySelectorAll('.status-badge').forEach(b => { b.textContent = ''; b.className = 'status-badge'; });
+    resetStats();
+}
+
+// 用户侧/运营侧快捷批量运行
+function runCheckinTests() { runBatchTests('checkin'); }
+function runActivityInviteTests() { runBatchTests('activity-invite'); }
+function runActivityCheckinTests() { runBatchTests('activity-checkin'); }
+function runActivityLotteryTests() { runBatchTests('activity-lottery'); }
+function runAdminCheckinTests() { runBatchTests('admin-checkin'); }
+function runAdminActivityInviteTests() { runBatchTests('admin-activity-invite'); }
+function runAdminActivityCheckinTests() { runBatchTests('admin-activity-checkin'); }
+function runAdminActivityLotteryTests() { runBatchTests('admin-activity-lottery'); }
+
+// ===================== 验证器 =====================
+function validate_checkinStatus(result) { return result.data?.code === 200 && result.data?.data?.checkedToday !== undefined; }
+function validate_checkinDo(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_checkinCalendar(result) { return result.data?.code === 200 && result.data?.data?.checkedDates !== undefined; }
+function validate_checkinRecords(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+function validate_adminCheckinConfig(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminCheckinConfigUpdate(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminCheckinStats(result) { return result.data?.code === 200 && result.data?.data?.todayCheckinCount !== undefined; }
+function validate_adminCheckinRecords(result) { return result.data?.code === 200 && result.data?.data?.records !== undefined; }
+function validate_adminCheckinUserRecords(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+
+function validate_activityInviteStatus(result) { return result.data?.code === 200 && result.data?.data?.active !== undefined; }
+function validate_activityCheckinStatus(result) { return result.data?.code === 200 && result.data?.data?.active !== undefined; }
+function validate_activityLotteryStatus(result) { return result.data?.code === 200 && result.data?.data?.active !== undefined; }
+function validate_activityLotteryDraw(result) { return result.data?.code === 200 && result.data?.data?.prizeName !== undefined; }
+function validate_adminActivityInviteConfig(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminActivityInviteRevoke(result) { return result.data?.code === 200 && result.data?.data !== undefined; }
+function validate_adminActivityCheckinConfig(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminActivityLotteryConfig(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminActivityLotteryPrize(result) { return result.data?.code === 200 && result.data?.data?.id; }
+
+// ===================== 初始化 =====================
+window.addEventListener('DOMContentLoaded', () => {
+    const params = new URLSearchParams(window.location.search);
+    if (params.get('baseUrl')) document.getElementById('baseUrl').value = params.get('baseUrl');
+    renderModule('checkin');
+});
+</script>
+</body>
+</html>

+ 1 - 2
zhijiayun-user/pom.xml

@@ -7,11 +7,10 @@
     <parent>
         <groupId>com.xuekairui</groupId>
         <artifactId>zhijiayun-pharmacy</artifactId>
-        <version>1.0-SNAPSHOT</version>
+        <version>1.0.0</version>
     </parent>
 
     <artifactId>zhijiayun-user</artifactId>
-    <version>1.0.0</version>
     <name>zhijiayun-user</name>
     <description>智价云(药店版)-用户管理模块</description>
 

+ 25 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java

@@ -2,10 +2,11 @@ package com.xuekairui.user.controller;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.xuekairui.common.Result;
+import com.xuekairui.common.event.ActivityEventRedisService;
 import com.xuekairui.user.entity.BusinessLicense;
+import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.mapper.BusinessLicenseMapper;
-import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.service.MembershipService;
 import com.xuekairui.user.service.OperationAuditLogService;
 import jakarta.servlet.http.HttpServletRequest;
@@ -15,7 +16,15 @@ import jakarta.validation.constraints.NotNull;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -32,6 +41,7 @@ public class AdminLicenseController {
     private final BusinessLicenseMapper businessLicenseMapper;
     private final OperationAuditLogService auditLogService;
     private final MembershipService membershipService;
+    private final ActivityEventRedisService activityEventRedisService;
 
     @GetMapping("/review")
     public Result<List<BusinessLicense>> listPendingReviews(
@@ -44,6 +54,7 @@ public class AdminLicenseController {
         return Result.success(licenses);
     }
 
+    @Transactional
     @PostMapping("/review")
     public Result<Void> reviewLicense(
             @Valid @RequestBody ReviewRequest request,
@@ -72,6 +83,18 @@ public class AdminLicenseController {
                         "LICENSE",
                         "营业执照审核通过赠送30天高级会员");
                 log.info("营业执照审核通过,赠送用户{} 30天高级会员", userId);
+
+                // 存入 Redis 事件队列,事务提交后由活动模块消费(邀请有礼:被邀请人资质通过后发邀请人奖励)
+                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                    @Override
+                    public void afterCommit() {
+                        try {
+                            activityEventRedisService.pushLicenseApproved(userId, license.getId(), "BUSINESS_LICENSE");
+                        } catch (Exception e) {
+                            log.warn("存入资质审核通过事件失败,不影响主流程: userId={}", userId, e);
+                        }
+                    }
+                });
             }
         } else if ("REJECTED".equals(request.getAction())) {
             license.setReviewStatus("REJECTED");

+ 20 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/service/CrawlerService.java

@@ -3,13 +3,14 @@ package com.xuekairui.user.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
-import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.common.event.ActivityEventRedisService;
 import com.xuekairui.user.dto.CrawlerStatusResponse;
 import com.xuekairui.user.dto.QuotaBreakdownItem;
 import com.xuekairui.user.entity.Coupon;
 import com.xuekairui.user.entity.CrawlerUsageLog;
 import com.xuekairui.user.entity.UserCoupon;
 import com.xuekairui.user.entity.UserLevel;
+import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.mapper.CouponMapper;
 import com.xuekairui.user.mapper.CrawlerUsageLogMapper;
 import com.xuekairui.user.mapper.UserCouponMapper;
@@ -17,6 +18,8 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.time.LocalDate;
 import java.time.LocalDateTime;
@@ -44,6 +47,7 @@ public class CrawlerService {
     private final CouponService couponService;
     private final CrawlerQuotaGrantService crawlerQuotaGrantService;
     private final OperationAuditLogService auditLogService;
+    private final ActivityEventRedisService activityEventRedisService;
 
     /**
      * 获取用户爬虫权益状态(统一展示等级配额 + 额外配额来源明细)
@@ -72,7 +76,7 @@ public class CrawlerService {
 
         int todayRemaining = Math.max(0, totalDailyQuota - todayUsed);
         int monthRemaining = Math.max(0, totalMonthlyQuota - monthUsed);
-        int yearRemaining = totalYearlyQuota > 0 ? Math.max(0, totalYearlyQuota - yearUsed) : null;
+        int yearRemaining = totalYearlyQuota > 0 ? Math.max(0, totalYearlyQuota - yearUsed) : 0;
 
         // 配额来源明细
         List<QuotaBreakdownItem> breakdown = crawlerQuotaGrantService.getQuotaBreakdown(userId);
@@ -221,11 +225,10 @@ public class CrawlerService {
             }
 
             int couponValue = coupon.getValue();
+            couponService.useCoupon(userId, userCoupon.getId());
             if (couponValue >= remaining) {
-                couponService.useCoupon(userId, userCoupon.getId());
                 remaining = 0;
             } else {
-                couponService.useCoupon(userId, userCoupon.getId());
                 remaining -= couponValue;
             }
         }
@@ -270,6 +273,19 @@ public class CrawlerService {
                 .remark(remark)
                 .build();
         crawlerUsageLogMapper.insert(usageLog);
+
+        // 存入 Redis 事件队列,事务提交后由活动模块消费(比价抽奖:每日首次比价发抽奖机会)
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                try {
+                    activityEventRedisService.pushCrawlerConsumed(userId, count, platform,
+                            usageLog.getUsageDate().toString());
+                } catch (Exception e) {
+                    log.warn("存入比价消耗事件失败,不影响主流程: userId={}", userId, e);
+                }
+            }
+        });
     }
 
     /**