From 903ccd307342ca9967ab9e5435b781e2315683c4 Mon Sep 17 00:00:00 2001
From: glz <694770232@qq.com>
Date: Thu, 4 Jun 2026 15:54:30 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=AD=BE=E5=88=B0?=
=?UTF-8?q?=E3=80=81=E5=AE=A0=E7=89=A9=E3=80=81=E6=9C=9F=E5=88=8A=E7=BB=91?=
=?UTF-8?q?=E5=AE=9A=E3=80=81=E8=A1=A5=E5=81=BF=E4=BB=BB=E5=8A=A1=E7=AD=89?=
=?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=A8=A1=E5=9D=97=EF=BC=8C=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95=E6=B5=81=E7=A8=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
本次提交完成了多个核心业务模块的开发与优化:
1. 宠物模块:新增宠物实体、服务接口与实现,支持创建默认宠物、激活、喂养、进化以及喂养记录查询
2. 签到模块:新增签到实体、服务接口、控制器以及相关DTO,支持用户签到和签到信息查询,新增成长值奖励字段
3. 期刊绑定模块:新增用户期刊关联实体、服务接口与控制器,支持扫码绑定期刊、解绑和查询绑定列表
4. 补偿任务模块:新增补偿任务实体、服务接口与实现,用于处理业务失败后的异步重试补偿
5. 优化微信登录流程:拆分登录与快捷登录接口,支持手机号获取,新增首次登录自动创建默认宠物逻辑
6. 调整基础路由与实体状态:修改微信控制器路由前缀,更新宠物状态枚举与默认值
---
.../ICheckInService.cs | 24 ++
.../ICompensationTaskService.cs | 46 +++
.../IPetService.cs | 47 +++
.../IUserJournalService.cs | 33 ++
.../IWeChatAuthService.cs | 11 +-
.../Dto/CheckIn/CheckInDto.cs | 136 +++++++++
.../Dto/Compensation/CompensationTaskDto.cs | 163 ++++++++++
.../Dto/Journal/JournalDto.cs | 84 ++++++
.../Dto/Pet/PetDto.cs | 155 ++++++++++
.../Dto/Pet/PetFeedingCompensationMessage.cs | 37 +++
.../Dto/WeChat/WeChatDto.cs | 96 +++++-
.../Entity/CheckInRecord.cs | 7 +
.../Entity/CompensationTask.cs | 108 +++++++
QYZH.InteractiveMagazine.Models/Entity/Pet.cs | 4 +-
.../Entity/UserJournal.cs | 56 ++++
.../CheckInService.cs | 277 +++++++++++++++++
.../CompensationTaskService.cs | 173 +++++++++++
.../PetService.cs | 282 ++++++++++++++++++
.../UserJournalService.cs | 208 +++++++++++++
.../WeChatAuthService.cs | 183 ++++++++++--
.../Controllers/WeChat/CheckInController.cs | 82 +++++
.../Controllers/WeChat/JournalController.cs | 83 ++++++
.../Controllers/WeChat/PetController.cs | 114 +++++++
.../WeChat/WeChatAuthController.cs | 32 +-
.../WeChat/WeChatBaseController.cs | 2 +-
25 files changed, 2411 insertions(+), 32 deletions(-)
create mode 100644 QYZH.InteractiveMagazine.IService/ICheckInService.cs
create mode 100644 QYZH.InteractiveMagazine.IService/ICompensationTaskService.cs
create mode 100644 QYZH.InteractiveMagazine.IService/IPetService.cs
create mode 100644 QYZH.InteractiveMagazine.IService/IUserJournalService.cs
create mode 100644 QYZH.InteractiveMagazine.Models/Dto/CheckIn/CheckInDto.cs
create mode 100644 QYZH.InteractiveMagazine.Models/Dto/Compensation/CompensationTaskDto.cs
create mode 100644 QYZH.InteractiveMagazine.Models/Dto/Journal/JournalDto.cs
create mode 100644 QYZH.InteractiveMagazine.Models/Dto/Pet/PetDto.cs
create mode 100644 QYZH.InteractiveMagazine.Models/Dto/Pet/PetFeedingCompensationMessage.cs
create mode 100644 QYZH.InteractiveMagazine.Models/Entity/CompensationTask.cs
create mode 100644 QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs
create mode 100644 QYZH.InteractiveMagazine.Service/CheckInService.cs
create mode 100644 QYZH.InteractiveMagazine.Service/CompensationTaskService.cs
create mode 100644 QYZH.InteractiveMagazine.Service/PetService.cs
create mode 100644 QYZH.InteractiveMagazine.Service/UserJournalService.cs
create mode 100644 QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CheckInController.cs
create mode 100644 QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/JournalController.cs
create mode 100644 QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/PetController.cs
diff --git a/QYZH.InteractiveMagazine.IService/ICheckInService.cs b/QYZH.InteractiveMagazine.IService/ICheckInService.cs
new file mode 100644
index 0000000..903e632
--- /dev/null
+++ b/QYZH.InteractiveMagazine.IService/ICheckInService.cs
@@ -0,0 +1,24 @@
+using QYZH.InteractiveMagazine.Models.Dto.CheckIn;
+using QYZH.InteractiveMagazine.Models.Entity;
+
+namespace QYZH.InteractiveMagazine.IService;
+
+///
+/// 签到服务接口
+///
+public interface ICheckInService : IBaseService
+{
+ ///
+ /// 用户签到
+ ///
+ /// 用户Id
+ /// 签到结果
+ Task CheckInAsync(long userId);
+
+ ///
+ /// 获取用户签到信息(今日状态 + 连续天数 + 最近记录)
+ ///
+ /// 用户Id
+ /// 签到信息
+ Task GetCheckInInfoAsync(long userId);
+}
diff --git a/QYZH.InteractiveMagazine.IService/ICompensationTaskService.cs b/QYZH.InteractiveMagazine.IService/ICompensationTaskService.cs
new file mode 100644
index 0000000..3898407
--- /dev/null
+++ b/QYZH.InteractiveMagazine.IService/ICompensationTaskService.cs
@@ -0,0 +1,46 @@
+using QYZH.InteractiveMagazine.Models.Dto.Compensation;
+using QYZH.InteractiveMagazine.Models.Entity;
+
+namespace QYZH.InteractiveMagazine.IService;
+
+///
+/// 补偿任务服务接口(仅负责记录和状态管理,处理逻辑由外部项目实现)
+///
+public interface ICompensationTaskService : IBaseService
+{
+ ///
+ /// 创建补偿任务(公共调用入口)
+ /// 当业务操作部分成功但某个后续步骤失败时调用,记录失败操作以便后续重试
+ ///
+ /// 补偿任务创建参数
+ /// 补偿任务Id
+ Task CreateTaskAsync(CreateCompensationTaskInput input);
+
+ ///
+ /// 获取待处理的补偿任务列表(Pending + 已过 ScheduledAt 的任务)
+ ///
+ /// 每次获取数量上限
+ /// 待处理任务列表
+ Task> GetPendingTasksAsync(int limit = 50);
+
+ ///
+ /// 按条件查询补偿任务(供外部项目按类型/状态/来源拉取)
+ ///
+ /// 查询条件
+ /// 任务列表
+ Task> GetTasksAsync(GetCompensationTasksInput input);
+
+ ///
+ /// 更新补偿任务状态(供外部处理项目回调更新处理结果)
+ ///
+ /// 任务Id
+ /// 状态更新参数
+ Task UpdateTaskStatusAsync(long taskId, UpdateCompensationStatusInput input);
+
+ ///
+ /// 取消补偿任务
+ ///
+ /// 任务Id
+ /// 取消原因
+ Task CancelTaskAsync(long taskId, string reason);
+}
diff --git a/QYZH.InteractiveMagazine.IService/IPetService.cs b/QYZH.InteractiveMagazine.IService/IPetService.cs
new file mode 100644
index 0000000..8e1563c
--- /dev/null
+++ b/QYZH.InteractiveMagazine.IService/IPetService.cs
@@ -0,0 +1,47 @@
+using QYZH.InteractiveMagazine.Models.Common;
+using QYZH.InteractiveMagazine.Models.Dto;
+using QYZH.InteractiveMagazine.Models.Dto.Pet;
+using QYZH.InteractiveMagazine.Models.Entity;
+
+namespace QYZH.InteractiveMagazine.IService;
+
+///
+/// 宠物服务接口
+///
+public interface IPetService : IBaseService
+{
+ ///
+ /// 获取用户宠物信息
+ ///
+ /// 用户Id
+ /// 宠物信息
+ Task GetPetByUserIdAsync(long userId);
+
+ ///
+ /// 为用户创建默认宠物(最低形态、成长值为0、未激活状态)
+ ///
+ /// 用户Id
+ Task CreateDefaultPetAsync(long userId);
+
+ ///
+ /// 激活宠物(将状态从 Inactive 改为 Active)
+ ///
+ /// 用户Id
+ Task ActivatePetAsync(long userId);
+
+ ///
+ /// 喂养宠物(增加成长值 + 记录喂养记录 + 触发进化检查)
+ ///
+ /// 用户Id
+ /// 喂养输入
+ /// 喂养结果
+ Task FeedPetAsync(long userId, FeedPetInput input);
+
+ ///
+ /// 获取宠物喂养记录列表
+ ///
+ /// 用户Id
+ /// 宠物Id
+ /// 喂养记录列表
+ Task> GetFeedingRecordsAsync(long userId, long petId, PageQueryModel pageQuery);
+}
diff --git a/QYZH.InteractiveMagazine.IService/IUserJournalService.cs b/QYZH.InteractiveMagazine.IService/IUserJournalService.cs
new file mode 100644
index 0000000..1a91763
--- /dev/null
+++ b/QYZH.InteractiveMagazine.IService/IUserJournalService.cs
@@ -0,0 +1,33 @@
+using QYZH.InteractiveMagazine.Models.Dto;
+using QYZH.InteractiveMagazine.Models.Entity;
+
+namespace QYZH.InteractiveMagazine.IService;
+
+///
+/// 用户期刊关联服务接口
+///
+public interface IUserJournalService : IBaseService
+{
+ ///
+ /// 用户绑定期刊(扫码绑定)
+ ///
+ /// 当前用户Id
+ /// 绑定输入
+ /// 绑定结果
+ Task BindJournalAsync(long userId, BindJournalInput input);
+
+ ///
+ /// 获取用户的期刊绑定列表
+ ///
+ /// 用户Id
+ /// 查询条件
+ /// 分页结果
+ Task> GetUserJournalsAsync(long userId, UserJournalQueryInput input);
+
+ ///
+ /// 取消期刊绑定
+ ///
+ /// 用户Id
+ /// 绑定记录Id
+ Task UnbindJournalAsync(long userId, long id);
+}
diff --git a/QYZH.InteractiveMagazine.IService/IWeChatAuthService.cs b/QYZH.InteractiveMagazine.IService/IWeChatAuthService.cs
index 02a8582..c868a30 100644
--- a/QYZH.InteractiveMagazine.IService/IWeChatAuthService.cs
+++ b/QYZH.InteractiveMagazine.IService/IWeChatAuthService.cs
@@ -9,12 +9,19 @@ namespace QYZH.InteractiveMagazine.IService;
public interface IWeChatAuthService : IBaseService
{
///
- /// 微信小程序一键登录
+ /// 微信小程序登录(首次创建用户,非首次直接登录)
///
- /// 登录输入(含微信 code)
+ /// 登录输入(含微信 code 和可选的手机号 code)
/// 登录结果(含 Token 和用户信息)
Task LoginAsync(WeChatLoginInput input);
+ ///
+ /// 微信小程序快捷登录(通过 OpenId 直接登录,用户需已存在)
+ ///
+ /// 快捷登录输入(含 OpenId)
+ /// 登录结果(含 Token 和用户列表)
+ Task QuickLoginAsync(WeChatQuickLoginInput input);
+
///
/// 切换用户(同一 OpenId 下切换身份)
///
diff --git a/QYZH.InteractiveMagazine.Models/Dto/CheckIn/CheckInDto.cs b/QYZH.InteractiveMagazine.Models/Dto/CheckIn/CheckInDto.cs
new file mode 100644
index 0000000..968c0dd
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Dto/CheckIn/CheckInDto.cs
@@ -0,0 +1,136 @@
+using QYZH.InteractiveMagazine.Models.Dto;
+
+namespace QYZH.InteractiveMagazine.Models.Dto.CheckIn;
+
+///
+/// 签到输出
+///
+public class CheckInOutput
+{
+ ///
+ /// 签到记录Id
+ ///
+ public long RecordId { get; set; }
+
+ ///
+ /// 签到日期
+ ///
+ public DateTime CheckInDate { get; set; }
+
+ ///
+ /// 连续签到天数
+ ///
+ public int ConsecutiveDays { get; set; }
+
+ ///
+ /// 本次获得积分
+ ///
+ public int PointsAwarded { get; set; }
+
+ ///
+ /// 本次获得成长值
+ ///
+ public int GrowthPointsAwarded { get; set; }
+
+ ///
+ /// 签到后用户积分余额
+ ///
+ public int PointsBalance { get; set; }
+
+ ///
+ /// 签到后用户成长值余额
+ ///
+ public int GrowthPointsBalance { get; set; }
+
+ ///
+ /// 是否有宠物(成长值是否喂养到宠物)
+ ///
+ public bool HasPet { get; set; }
+
+ ///
+ /// 宠物是否触发进化
+ ///
+ public bool HasEvolved { get; set; }
+
+ ///
+ /// 进化后的阶段名称(未进化则为空)
+ ///
+ public string? EvolvedStageName { get; set; }
+}
+
+///
+/// 签到信息输出(查询用)
+///
+public class CheckInInfoOutput
+{
+ ///
+ /// 今日是否已签到
+ ///
+ public bool HasCheckedInToday { get; set; }
+
+ ///
+ /// 当前连续签到天数
+ ///
+ public int ConsecutiveDays { get; set; }
+
+ ///
+ /// 累计签到天数
+ ///
+ public int TotalCheckInDays { get; set; }
+
+ ///
+ /// 用户当前积分余额
+ ///
+ public int PointsBalance { get; set; }
+
+ ///
+ /// 用户当前成长值余额
+ ///
+ public int GrowthPointsBalance { get; set; }
+
+ ///
+ /// 最近签到记录
+ ///
+ public List RecentRecords { get; set; } = [];
+}
+
+///
+/// 签到记录输出
+///
+public class CheckInRecordOutput
+{
+ ///
+ /// 记录Id
+ ///
+ public long Id { get; set; }
+
+ ///
+ /// 签到日期
+ ///
+ public DateTime CheckInDate { get; set; }
+
+ ///
+ /// 连续签到天数
+ ///
+ public int ConsecutiveDays { get; set; }
+
+ ///
+ /// 本次获得积分
+ ///
+ public int PointsAwarded { get; set; }
+
+ ///
+ /// 本次获得成长值
+ ///
+ public int GrowthPointsAwarded { get; set; }
+
+ ///
+ /// 签到类型: Normal, MakeUp
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// 状态
+ ///
+ public string Status { get; set; } = string.Empty;
+}
diff --git a/QYZH.InteractiveMagazine.Models/Dto/Compensation/CompensationTaskDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Compensation/CompensationTaskDto.cs
new file mode 100644
index 0000000..c0e286c
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Dto/Compensation/CompensationTaskDto.cs
@@ -0,0 +1,163 @@
+namespace QYZH.InteractiveMagazine.Models.Dto.Compensation;
+
+///
+/// 补偿任务类型常量
+///
+public static class CompensationTaskType
+{
+ ///
+ /// 宠物喂养补偿
+ /// Payload: { "PetId": long, "GrowthPoints": int }
+ ///
+ public const string PetFeeding = "PetFeeding";
+
+ ///
+ /// 用户积分补偿
+ /// Payload: { "Points": int, "ChangeType": string, "Description": string }
+ ///
+ public const string UserPoints = "UserPoints";
+
+ ///
+ /// 用户成长值补偿
+ /// Payload: { "GrowthPoints": int }
+ ///
+ public const string UserGrowth = "UserGrowth";
+
+ ///
+ /// 发送通知补偿
+ /// Payload: { "TemplateId": string, "Data": object }
+ ///
+ public const string SendNotification = "SendNotification";
+}
+
+///
+/// 补偿任务状态常量
+///
+public static class CompensationTaskStatus
+{
+ public const string Pending = "Pending";
+ public const string Processing = "Processing";
+ public const string Success = "Success";
+ public const string Failed = "Failed";
+ public const string Cancelled = "Cancelled";
+}
+
+///
+/// 创建补偿任务输入
+///
+public class CreateCompensationTaskInput
+{
+ ///
+ /// 任务类型(使用 CompensationTaskType 常量)
+ ///
+ public string TaskType { get; set; } = string.Empty;
+
+ ///
+ /// 业务来源(如 CheckIn、Purchase)
+ ///
+ public string BusinessSource { get; set; } = string.Empty;
+
+ ///
+ /// 关联业务记录Id
+ ///
+ public string? BusinessId { get; set; }
+
+ ///
+ /// 关联用户Id
+ ///
+ public long UserId { get; set; }
+
+ ///
+ /// 处理参数(匿名对象或字典,会自动序列化为JSON)
+ ///
+ public object Payload { get; set; } = new { };
+
+ ///
+ /// 异常消息
+ ///
+ public string ErrorMessage { get; set; } = string.Empty;
+
+ ///
+ /// 异常来源(类名.方法名,如 CheckInService.CheckInAsync)
+ ///
+ public string ErrorSource { get; set; } = string.Empty;
+
+ ///
+ /// 最大重试次数(默认3次)
+ ///
+ public int MaxRetries { get; set; } = 3;
+}
+
+///
+/// 补偿任务输出
+///
+public class CompensationTaskOutput
+{
+ public long Id { get; set; }
+ public string TaskType { get; set; } = string.Empty;
+ public string BusinessSource { get; set; } = string.Empty;
+ public string? BusinessId { get; set; }
+ public long UserId { get; set; }
+ public string Payload { get; set; } = string.Empty;
+ public string ErrorMessage { get; set; } = string.Empty;
+ public string ErrorSource { get; set; } = string.Empty;
+ public int RetryCount { get; set; }
+ public int MaxRetries { get; set; }
+ public string Status { get; set; } = string.Empty;
+ public DateTime? ProcessedAt { get; set; }
+ public DateTime? ScheduledAt { get; set; }
+ public string? ResultMessage { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
+
+///
+/// 查询补偿任务输入(供外部项目按条件拉取)
+///
+public class GetCompensationTasksInput
+{
+ ///
+ /// 按状态筛选
+ ///
+ public string? Status { get; set; }
+
+ ///
+ /// 按任务类型筛选
+ ///
+ public string? TaskType { get; set; }
+
+ ///
+ /// 按业务来源筛选
+ ///
+ public string? BusinessSource { get; set; }
+
+ ///
+ /// 获取数量上限(默认50)
+ ///
+ public int Limit { get; set; } = 50;
+}
+
+///
+/// 更新补偿任务状态输入(供外部处理项目回调)
+///
+public class UpdateCompensationStatusInput
+{
+ ///
+ /// 目标状态(使用 CompensationTaskStatus 常量)
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 处理结果描述
+ ///
+ public string? ResultMessage { get; set; }
+
+ ///
+ /// 更新重试次数(可选)
+ ///
+ public int? RetryCount { get; set; }
+
+ ///
+ /// 下次计划执行时间(用于退避重试,可选)
+ ///
+ public DateTime? ScheduledAt { get; set; }
+}
diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalDto.cs
new file mode 100644
index 0000000..f7e2166
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalDto.cs
@@ -0,0 +1,84 @@
+namespace QYZH.InteractiveMagazine.Models.Dto;
+
+///
+/// 绑定期刊输入DTO
+///
+public class BindJournalInput
+{
+ ///
+ /// 期刊模板Id(扫码解析的期刊定义Id)
+ ///
+ public long JournalId { get; set; }
+
+ ///
+ /// 实例化期刊Id(扫码解析的具体期刊实例Id,可选)
+ ///
+ public long? JournalInstanceId { get; set; }
+
+ ///
+ /// 关联类型: Read(已读), Favorite(收藏), Subscribe(订阅),默认 Subscribe
+ ///
+ public string Type { get; set; } = "Subscribe";
+}
+
+///
+/// 绑定期刊输出DTO
+///
+public class BindJournalOutput
+{
+ ///
+ /// 绑定记录Id
+ ///
+ public long Id { get; set; }
+
+ ///
+ /// 用户Id
+ ///
+ public long UserId { get; set; }
+
+ ///
+ /// 期刊模板Id
+ ///
+ public long JournalId { get; set; }
+
+ ///
+ /// 实例化期刊Id
+ ///
+ public long? JournalInstanceId { get; set; }
+
+ ///
+ /// 关联类型
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// 状态
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 绑定时间
+ ///
+ public DateTime CreatedAt { get; set; }
+}
+
+///
+/// 用户期刊关联查询输入DTO
+///
+public class UserJournalQueryInput : PageQueryModel
+{
+ ///
+ /// 期刊模板Id
+ ///
+ public long? JournalId { get; set; }
+
+ ///
+ /// 实例化期刊Id
+ ///
+ public long? JournalInstanceId { get; set; }
+
+ ///
+ /// 关联类型: Read, Favorite, Subscribe
+ ///
+ public string? Type { get; set; }
+}
diff --git a/QYZH.InteractiveMagazine.Models/Dto/Pet/PetDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Pet/PetDto.cs
new file mode 100644
index 0000000..d82dd3e
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Dto/Pet/PetDto.cs
@@ -0,0 +1,155 @@
+namespace QYZH.InteractiveMagazine.Models.Dto.Pet;
+
+///
+/// 宠物信息输出
+///
+public class PetOutput
+{
+ ///
+ /// 主键ID
+ ///
+ public long Id { get; set; }
+
+ ///
+ /// 用户Id
+ ///
+ public long UserId { get; set; }
+
+ ///
+ /// 宠物昵称
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// 当前进化形态Id
+ ///
+ public int CurrentEvolutionId { get; set; }
+
+ ///
+ /// 当前成长值
+ ///
+ public int GrowthPoints { get; set; }
+
+ ///
+ /// 累计喂养次数
+ ///
+ public int FeedingCount { get; set; }
+
+ ///
+ /// 宠物类型: Normal
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// 状态: Inactive, Active, Sleeping
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 创建时间
+ ///
+ public DateTime CreatedAt { get; set; }
+}
+
+///
+/// 喂养宠物输入
+///
+public class FeedPetInput
+{
+ ///
+ /// 宠物Id
+ ///
+ public long PetId { get; set; }
+
+ ///
+ /// 增加的成长值
+ ///
+ public int GrowthPoints { get; set; }
+}
+
+///
+/// 喂养宠物输出
+///
+public class FeedPetOutput
+{
+ ///
+ /// 宠物Id
+ ///
+ public long PetId { get; set; }
+
+ ///
+ /// 喂养前成长值
+ ///
+ public int GrowthBefore { get; set; }
+
+ ///
+ /// 喂养后成长值
+ ///
+ public int GrowthAfter { get; set; }
+
+ ///
+ /// 本次成长变化量
+ ///
+ public int GrowthChange { get; set; }
+
+ ///
+ /// 是否触发进化
+ ///
+ public bool HasEvolved { get; set; }
+
+ ///
+ /// 进化后的阶段名称(未进化则为空)
+ ///
+ public string? EvolvedStageName { get; set; }
+}
+
+///
+/// 喂养记录输出
+///
+public class FeedingRecordOutput
+{
+ ///
+ /// 记录Id
+ ///
+ public long Id { get; set; }
+
+ ///
+ /// 宠物Id
+ ///
+ public long PetId { get; set; }
+
+ ///
+ /// 用户Id
+ ///
+ public long UserId { get; set; }
+
+ ///
+ /// 成长值变化量
+ ///
+ public int GrowthChange { get; set; }
+
+ ///
+ /// 喂养前成长值
+ ///
+ public int GrowthBefore { get; set; }
+
+ ///
+ /// 喂养后成长值
+ ///
+ public int GrowthAfter { get; set; }
+
+ ///
+ /// 喂养类型: Normal, Special
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// 状态
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 创建时间
+ ///
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/QYZH.InteractiveMagazine.Models/Dto/Pet/PetFeedingCompensationMessage.cs b/QYZH.InteractiveMagazine.Models/Dto/Pet/PetFeedingCompensationMessage.cs
new file mode 100644
index 0000000..04ee0fd
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Dto/Pet/PetFeedingCompensationMessage.cs
@@ -0,0 +1,37 @@
+namespace QYZH.InteractiveMagazine.Models.Dto.Pet;
+
+///
+/// 宠物喂养补偿消息
+///
+public class PetFeedingCompensationMessage
+{
+ ///
+ /// 用户Id
+ ///
+ public long UserId { get; set; }
+
+ ///
+ /// 宠物Id
+ ///
+ public long PetId { get; set; }
+
+ ///
+ /// 需要补偿的成长值
+ ///
+ public int GrowthPoints { get; set; }
+
+ ///
+ /// 签到记录Id(关联来源)
+ ///
+ public long CheckInRecordId { get; set; }
+
+ ///
+ /// 补偿原因
+ ///
+ public string Reason { get; set; } = "签到成功但宠物喂养失败";
+
+ ///
+ /// 消息创建时间
+ ///
+ public DateTime CreatedAt { get; set; } = DateTime.Now;
+}
diff --git a/QYZH.InteractiveMagazine.Models/Dto/WeChat/WeChatDto.cs b/QYZH.InteractiveMagazine.Models/Dto/WeChat/WeChatDto.cs
index a01b22a..ed5e595 100644
--- a/QYZH.InteractiveMagazine.Models/Dto/WeChat/WeChatDto.cs
+++ b/QYZH.InteractiveMagazine.Models/Dto/WeChat/WeChatDto.cs
@@ -4,7 +4,7 @@ using Newtonsoft.Json;
namespace QYZH.InteractiveMagazine.IService.Dto;
///
-/// 微信小程序登录输入
+/// 微信小程序初次登录输入
///
public class WeChatLoginInput
{
@@ -12,6 +12,22 @@ public class WeChatLoginInput
/// 微信登录凭证(wx.login 获取的 code)
///
public string Code { get; set; } = string.Empty;
+
+ ///
+ /// 手机号获取凭证(getPhoneNumber 按钮回调中的 code,可选)
+ ///
+ public string? PhoneCode { get; set; }
+}
+
+///
+/// 微信小程序快捷登录输入(通过 OpenId 登录)
+///
+public class WeChatQuickLoginInput
+{
+ ///
+ /// 微信OpenId(初次登录后客户端缓存的 OpenId)
+ ///
+ public string OpenId { get; set; } = string.Empty;
}
///
@@ -225,6 +241,84 @@ public class WxCode2SessionResponse
public string? ErrMsg { get; set; }
}
+///
+/// 微信获取手机号接口响应
+///
+public class WxPhoneNumberResponse
+{
+ ///
+ /// 错误码
+ ///
+ [JsonProperty("errcode")]
+ public int ErrCode { get; set; }
+
+ ///
+ /// 错误信息
+ ///
+ [JsonProperty("errmsg")]
+ public string? ErrMsg { get; set; }
+
+ ///
+ /// 手机号信息
+ ///
+ [JsonProperty("phone_info")]
+ public WxPhoneInfo? PhoneInfo { get; set; }
+}
+
+///
+/// 微信手机号信息
+///
+public class WxPhoneInfo
+{
+ ///
+ /// 用户绑定的手机号(国外手机号会有区号)
+ ///
+ [JsonProperty("phoneNumber")]
+ public string? PhoneNumber { get; set; }
+
+ ///
+ /// 没有区号的手机号
+ ///
+ [JsonProperty("purePhoneNumber")]
+ public string? PurePhoneNumber { get; set; }
+
+ ///
+ /// 区号
+ ///
+ [JsonProperty("countryCode")]
+ public string? CountryCode { get; set; }
+}
+
+///
+/// 微信 access_token 接口响应
+///
+public class WxAccessTokenResponse
+{
+ ///
+ /// 获取到的凭证
+ ///
+ [JsonProperty("access_token")]
+ public string? AccessToken { get; set; }
+
+ ///
+ /// 凭证有效时间(秒)
+ ///
+ [JsonProperty("expires_in")]
+ public int ExpiresIn { get; set; }
+
+ ///
+ /// 错误码
+ ///
+ [JsonProperty("errcode")]
+ public int ErrCode { get; set; }
+
+ ///
+ /// 错误信息
+ ///
+ [JsonProperty("errmsg")]
+ public string? ErrMsg { get; set; }
+}
+
///
/// 微信切换用户输入
///
diff --git a/QYZH.InteractiveMagazine.Models/Entity/CheckInRecord.cs b/QYZH.InteractiveMagazine.Models/Entity/CheckInRecord.cs
index 32de233..38b5e28 100644
--- a/QYZH.InteractiveMagazine.Models/Entity/CheckInRecord.cs
+++ b/QYZH.InteractiveMagazine.Models/Entity/CheckInRecord.cs
@@ -33,6 +33,13 @@ namespace QYZH.InteractiveMagazine.Models.Entity
///
public int PointsAwarded {get;set;}
+ ///
+ /// Desc:本次签到获得成长值(喂养宠物)
+ /// Default:0
+ /// Nullable:False
+ ///
+ public int GrowthPointsAwarded {get;set;}
+
///
/// Desc:连续签到天数
/// Default:
diff --git a/QYZH.InteractiveMagazine.Models/Entity/CompensationTask.cs b/QYZH.InteractiveMagazine.Models/Entity/CompensationTask.cs
new file mode 100644
index 0000000..f549b24
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Entity/CompensationTask.cs
@@ -0,0 +1,108 @@
+using SqlSugar;
+
+namespace QYZH.InteractiveMagazine.Models.Entity
+{
+ ///
+ /// 补偿任务表 — 记录业务执行失败后需要异步重试的操作
+ ///
+ [SugarTable("CompensationTask")]
+ public partial class CompensationTask : SqlSugarBaseEntity
+ {
+ public CompensationTask() { }
+
+ ///
+ /// Desc:任务类型,决定补偿处理逻辑
+ /// PetFeeding — 宠物喂养补偿
+ /// UserPoints — 用户积分补偿
+ /// UserGrowth — 用户成长值补偿
+ /// SendNotification — 发送通知补偿
+ /// Default:
+ /// Nullable:False
+ ///
+ public string TaskType { get; set; }
+
+ ///
+ /// Desc:业务来源(如 CheckIn、Purchase、Activity)
+ /// Default:
+ /// Nullable:False
+ ///
+ public string BusinessSource { get; set; }
+
+ ///
+ /// Desc:关联业务记录Id(如签到记录Id、订单Id)
+ /// Default:
+ /// Nullable:True
+ ///
+ public string? BusinessId { get; set; }
+
+ ///
+ /// Desc:关联用户Id
+ /// Default:
+ /// Nullable:False
+ ///
+ public long UserId { get; set; }
+
+ ///
+ /// Desc:处理参数(JSON格式,不同TaskType对应不同结构)
+ /// Default:
+ /// Nullable:False
+ ///
+ public string Payload { get; set; }
+
+ ///
+ /// Desc:原始异常消息
+ /// Default:
+ /// Nullable:False
+ ///
+ public string ErrorMessage { get; set; }
+
+ ///
+ /// Desc:异常来源(如 CheckInService.CheckInAsync)
+ /// Default:
+ /// Nullable:False
+ ///
+ public string ErrorSource { get; set; }
+
+ ///
+ /// Desc:已重试次数
+ /// Default:0
+ /// Nullable:False
+ ///
+ public int RetryCount { get; set; }
+
+ ///
+ /// Desc:最大重试次数
+ /// Default:3
+ /// Nullable:False
+ ///
+ public int MaxRetries { get; set; }
+
+ ///
+ /// Desc:处理状态 Pending / Processing / Success / Failed / Cancelled
+ /// Default:Pending
+ /// Nullable:False
+ ///
+ public new string Status { get; set; }
+
+ ///
+ /// Desc:最后处理时间
+ /// Default:
+ /// Nullable:True
+ ///
+ public DateTime? ProcessedAt { get; set; }
+
+ ///
+ /// Desc:下次计划执行时间(用于退避重试)
+ /// Default:
+ /// Nullable:True
+ ///
+ public DateTime? ScheduledAt { get; set; }
+
+ ///
+ /// Desc:处理结果描述(成功/失败原因)
+ /// Default:
+ /// Nullable:True
+ ///
+ public string? ResultMessage { get; set; }
+ }
+}
diff --git a/QYZH.InteractiveMagazine.Models/Entity/Pet.cs b/QYZH.InteractiveMagazine.Models/Entity/Pet.cs
index 950ce04..9a804de 100644
--- a/QYZH.InteractiveMagazine.Models/Entity/Pet.cs
+++ b/QYZH.InteractiveMagazine.Models/Entity/Pet.cs
@@ -55,8 +55,8 @@ namespace QYZH.InteractiveMagazine.Models.Entity
public string Type {get;set;}
///
- /// Desc:状态: Active, Sleeping
- /// Default:Active
+ /// Desc:状态: Inactive, Active, Sleeping
+ /// Default:Inactive
/// Nullable:False
///
public string Status {get;set;}
diff --git a/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs b/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs
new file mode 100644
index 0000000..bea9529
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Entity/UserJournal.cs
@@ -0,0 +1,56 @@
+using SqlSugar;
+
+namespace QYZH.InteractiveMagazine.Models.Entity
+{
+ ///
+ ///用户与期刊关联表
+ ///
+ [SugarTable("UserJournal")]
+ public partial class UserJournal : SqlSugarBaseEntity
+ {
+ public UserJournal()
+ {
+
+ }
+
+ ///
+ /// Desc:用户Id
+ /// Default:
+ /// Nullable:False
+ ///
+ [SugarColumn(ColumnName = "UserId")]
+ public long UserId { get; set; }
+
+ ///
+ /// Desc:期刊模板Id(对应Journal表的期刊定义)
+ /// Default:
+ /// Nullable:False
+ ///
+ [SugarColumn(ColumnName = "JournalId")]
+ public long JournalId { get; set; }
+
+ ///
+ /// Desc:实例化期刊Id(扫码获取的具体期刊实例,可为空表示绑定到期刊模板本身)
+ /// Default:
+ /// Nullable:True
+ ///
+ [SugarColumn(ColumnName = "JournalInstanceId", IsNullable = true)]
+ public long? JournalInstanceId { get; set; }
+
+ ///
+ /// Desc:关联类型: Read(已读), Favorite(收藏), Subscribe(订阅)
+ /// Default:Read
+ /// Nullable:False
+ ///
+ [SugarColumn(ColumnName = "Type")]
+ public string Type { get; set; }
+
+ ///
+ /// Desc:状态: Active(正常), Inactive(失效)
+ /// Default:Active
+ /// Nullable:False
+ ///
+ [SugarColumn(ColumnName = "Status")]
+ public new string Status { get; set; }
+ }
+}
diff --git a/QYZH.InteractiveMagazine.Service/CheckInService.cs b/QYZH.InteractiveMagazine.Service/CheckInService.cs
new file mode 100644
index 0000000..5ce4413
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Service/CheckInService.cs
@@ -0,0 +1,277 @@
+using Microsoft.Extensions.Logging;
+using QYZH.InteractiveMagazine.IService;
+using QYZH.InteractiveMagazine.Models.Common;
+using QYZH.InteractiveMagazine.Models.Dto.CheckIn;
+using QYZH.InteractiveMagazine.Models.Dto.Compensation;
+using QYZH.InteractiveMagazine.Models.Dto.Pet;
+using QYZH.InteractiveMagazine.Models.Entity;
+using QYZH.InteractiveMagazine.Repository;
+
+namespace QYZH.InteractiveMagazine.Service;
+
+///
+/// 签到服务实现
+///
+public class CheckInService(
+ BaseRepository checkInRecordRepository,
+ IPetService petService,
+ ICompensationTaskService compensationTaskService,
+ ILogger logger)
+ : BaseRepository, ICheckInService
+{
+ ///
+ /// 默认签到奖励积分(无配置时的兜底值)
+ ///
+ private const int DefaultRewardPoints = 10;
+
+ ///
+ /// 用户签到
+ ///
+ public async Task CheckInAsync(long userId)
+ {
+ logger.LogInformation("用户签到,UserId: {UserId}", userId);
+
+ var today = DateTime.Now.Date;
+
+ // 1. 检查今日是否已签到
+ var alreadyCheckedIn = await checkInRecordRepository.Context.Queryable()
+ .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= today && r.CheckInDate < today.AddDays(1))
+ .AnyAsync();
+
+ if (alreadyCheckedIn)
+ {
+ throw new BusinessException("今日已签到,请明天再来", 400);
+ }
+
+ // 2. 计算连续签到天数
+ var consecutiveDays = await CalculateConsecutiveDaysAsync(userId, today);
+
+ // 3. 查询签到配置,计算奖励
+ var (pointsReward, growthReward) = await CalculateRewardsAsync(consecutiveDays);
+
+ // 4. 查询用户信息
+ var user = await checkInRecordRepository.Context.Queryable()
+ .Where(u => u.Id == userId && !u.IsDeleted)
+ .FirstAsync();
+
+ if (user == null)
+ {
+ throw new BusinessException("用户不存在", 404);
+ }
+
+ // 5. 查询用户宠物(如果有)
+ var pet = await checkInRecordRepository.Context.Queryable()
+ .Where(p => p.UserId == userId && !p.IsDeleted)
+ .FirstAsync();
+
+ // 6. 事务执行签到相关写操作
+ var result = new CheckInOutput();
+
+ await checkInRecordRepository.UseTranAsync(async () =>
+ {
+ // 6a. 创建签到记录
+ var checkInRecord = new CheckInRecord
+ {
+ UserId = userId,
+ CheckInDate = today,
+ PointsAwarded = pointsReward,
+ GrowthPointsAwarded = growthReward,
+ ConsecutiveDays = consecutiveDays,
+ Type = "Normal",
+ Status = "Success"
+ };
+ var recordId = await checkInRecordRepository.Insertable(checkInRecord).ExecuteReturnIdentityAsync();
+ checkInRecord.Id = recordId;
+
+ // 6b. 更新用户积分余额
+ var newPointsBalance = user.Points + pointsReward;
+ var newGrowthBalance = user.GrowthPoints + growthReward;
+
+ await checkInRecordRepository.Context.Updateable()
+ .SetColumns(u => u.Points == newPointsBalance)
+ .SetColumns(u => u.GrowthPoints == newGrowthBalance)
+ .Where(u => u.Id == userId && !u.IsDeleted)
+ .ExecuteCommandAsync();
+
+ // 6c. 创建积分变动记录
+ var pointsRecord = new PointsRecord
+ {
+ UserId = userId,
+ ChangeAmount = pointsReward,
+ BalanceAfter = newPointsBalance,
+ ChangeType = "SignIn",
+ RelatedId = recordId,
+ Description = $"签到奖励(连续{consecutiveDays}天)",
+ Type = "Income",
+ Status = "Success"
+ };
+ await checkInRecordRepository.Context.Insertable(pointsRecord).ExecuteCommandAsync();
+
+ // 构建返回结果
+ result.RecordId = (long)recordId;
+ result.CheckInDate = today;
+ result.ConsecutiveDays = consecutiveDays;
+ result.PointsAwarded = pointsReward;
+ result.GrowthPointsAwarded = growthReward;
+ result.PointsBalance = newPointsBalance;
+ result.GrowthPointsBalance = newGrowthBalance;
+ result.HasPet = pet != null;
+ });
+
+ // 7. 如果用户有活跃宠物,调用 PetService 喂养(含进化检查),独立事务
+ if (pet != null && pet.Status == "Active" && growthReward > 0)
+ {
+ try
+ {
+ var feedResult = await petService.FeedPetAsync(userId, new FeedPetInput
+ {
+ PetId = pet.Id,
+ GrowthPoints = growthReward
+ });
+
+ result.HasEvolved = feedResult.HasEvolved;
+ result.EvolvedStageName = feedResult.EvolvedStageName;
+
+ logger.LogInformation("签到成长值已喂养宠物,PetId: {PetId}, 进化: {HasEvolved}",
+ pet.Id, feedResult.HasEvolved);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "签到后喂养宠物失败,PetId: {PetId},将创建补偿任务", pet.Id);
+
+ await compensationTaskService.CreateTaskAsync(new CreateCompensationTaskInput
+ {
+ TaskType = CompensationTaskType.PetFeeding,
+ BusinessSource = "CheckIn",
+ BusinessId = result.RecordId.ToString(),
+ UserId = userId,
+ Payload = new { PetId = pet.Id, GrowthPoints = growthReward },
+ ErrorMessage = ex.Message,
+ ErrorSource = "CheckInService.CheckInAsync → PetService.FeedPetAsync",
+ MaxRetries = 3
+ });
+ }
+ }
+
+ logger.LogInformation("用户签到成功,UserId: {UserId}, 连续{Days}天, 积分+{Points}, 成长值+{Growth}",
+ userId, consecutiveDays, pointsReward, growthReward);
+
+ return result;
+ }
+
+ ///
+ /// 获取用户签到信息
+ ///
+ public async Task GetCheckInInfoAsync(long userId)
+ {
+ logger.LogInformation("获取签到信息,UserId: {UserId}", userId);
+
+ var today = DateTime.Now.Date;
+
+ // 今日是否已签到
+ var hasCheckedInToday = await checkInRecordRepository.Context.Queryable()
+ .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= today && r.CheckInDate < today.AddDays(1))
+ .AnyAsync();
+
+ // 累计签到天数
+ var totalCheckInDays = await checkInRecordRepository.Context.Queryable()
+ .Where(r => r.UserId == userId && !r.IsDeleted)
+ .CountAsync();
+
+ // 最近一次签到记录(用于获取连续天数)
+ var lastRecord = await checkInRecordRepository.Context.Queryable()
+ .Where(r => r.UserId == userId && !r.IsDeleted)
+ .OrderBy(r => r.CheckInDate, SqlSugar.OrderByType.Desc)
+ .FirstAsync();
+
+ // 判断连续天数:如果最后一次签到是今天或昨天,则连续天数延续
+ var consecutiveDays = 0;
+ if (lastRecord != null)
+ {
+ var lastDate = lastRecord.CheckInDate.Date;
+ if (lastDate == today || lastDate == today.AddDays(-1))
+ {
+ consecutiveDays = lastRecord.ConsecutiveDays;
+ if (lastDate == today)
+ {
+ // 今天已签到,连续天数就是今天的值
+ }
+ // 如果是昨天,则连续天数保持(今天还没签到)
+ }
+ }
+
+ // 查询用户余额
+ var user = await checkInRecordRepository.Context.Queryable()
+ .Where(u => u.Id == userId && !u.IsDeleted)
+ .FirstAsync();
+
+ // 最近 30 天签到记录
+ var thirtyDaysAgo = today.AddDays(-29);
+ var recentRecords = await checkInRecordRepository.Context.Queryable()
+ .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= thirtyDaysAgo)
+ .OrderBy(r => r.CheckInDate, SqlSugar.OrderByType.Desc)
+ .Select(r => new CheckInRecordOutput
+ {
+ Id = (long)r.Id,
+ CheckInDate = r.CheckInDate,
+ ConsecutiveDays = r.ConsecutiveDays,
+ PointsAwarded = r.PointsAwarded,
+ GrowthPointsAwarded = r.GrowthPointsAwarded,
+ Type = r.Type,
+ Status = r.Status
+ })
+ .ToListAsync();
+
+ return new CheckInInfoOutput
+ {
+ HasCheckedInToday = hasCheckedInToday,
+ ConsecutiveDays = consecutiveDays,
+ TotalCheckInDays = totalCheckInDays,
+ PointsBalance = user?.Points ?? 0,
+ GrowthPointsBalance = user?.GrowthPoints ?? 0,
+ RecentRecords = recentRecords
+ };
+ }
+
+ ///
+ /// 计算连续签到天数
+ ///
+ private async Task CalculateConsecutiveDaysAsync(long userId, DateTime today)
+ {
+ var yesterday = today.AddDays(-1);
+
+ var yesterdayRecord = await checkInRecordRepository.Context.Queryable()
+ .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= yesterday && r.CheckInDate < today)
+ .FirstAsync();
+
+ // 昨天有签到记录,连续天数 +1;否则从 1 开始
+ return yesterdayRecord != null ? yesterdayRecord.ConsecutiveDays + 1 : 1;
+ }
+
+ ///
+ /// 根据连续签到天数计算奖励(积分 + 成长值)
+ ///
+ private async Task<(int PointsReward, int GrowthReward)> CalculateRewardsAsync(int consecutiveDays)
+ {
+ // 查询签到配置(按 DayNumber 升序)
+ var configs = await checkInRecordRepository.Context.Queryable()
+ .Where(c => c.Status == "Active" && !c.IsDeleted)
+ .OrderBy(c => c.DayNumber)
+ .ToListAsync();
+
+ if (configs.Count == 0)
+ {
+ // 无配置时使用默认值
+ return (DefaultRewardPoints, DefaultRewardPoints);
+ }
+
+ // 找到匹配的奖励档位:取 DayNumber <= 连续天数 的最大档位
+ var matchedConfig = configs.LastOrDefault(c => c.DayNumber <= consecutiveDays)
+ ?? configs.First();
+
+ var totalPoints = matchedConfig.RewardPoints + matchedConfig.BonusPoints;
+
+ // 成长值与积分相同(签到同时获得积分和成长值)
+ return (totalPoints, totalPoints);
+ }
+}
diff --git a/QYZH.InteractiveMagazine.Service/CompensationTaskService.cs b/QYZH.InteractiveMagazine.Service/CompensationTaskService.cs
new file mode 100644
index 0000000..4713198
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Service/CompensationTaskService.cs
@@ -0,0 +1,173 @@
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using QYZH.InteractiveMagazine.IService;
+using QYZH.InteractiveMagazine.Models.Dto.Compensation;
+using QYZH.InteractiveMagazine.Models.Entity;
+using QYZH.InteractiveMagazine.Repository;
+
+namespace QYZH.InteractiveMagazine.Service;
+
+///
+/// 补偿任务服务实现(仅负责记录,处理逻辑由外部 Hangfire 项目完成)
+///
+public class CompensationTaskService(
+ BaseRepository taskRepository,
+ ILogger logger)
+ : BaseRepository, ICompensationTaskService
+{
+ ///
+ /// 创建补偿任务 — 记录失败操作,供后续补偿处理
+ ///
+ public async Task CreateTaskAsync(CreateCompensationTaskInput input)
+ {
+ logger.LogWarning(
+ "创建补偿任务,TaskType: {TaskType}, BusinessSource: {BusinessSource}, UserId: {UserId}, ErrorSource: {ErrorSource}, Error: {ErrorMessage}",
+ input.TaskType, input.BusinessSource, input.UserId, input.ErrorSource, input.ErrorMessage);
+
+ var payloadJson = input.Payload is string str ? str : JsonConvert.SerializeObject(input.Payload);
+
+ var task = new CompensationTask
+ {
+ TaskType = input.TaskType,
+ BusinessSource = input.BusinessSource,
+ BusinessId = input.BusinessId,
+ UserId = input.UserId,
+ Payload = payloadJson,
+ ErrorMessage = input.ErrorMessage,
+ ErrorSource = input.ErrorSource,
+ RetryCount = 0,
+ MaxRetries = input.MaxRetries > 0 ? input.MaxRetries : 3,
+ Status = CompensationTaskStatus.Pending,
+ ScheduledAt = DateTime.Now,
+ IsDeleted = false,
+ CreatedBy = "System",
+ CreatedAt = DateTime.Now,
+ UpdatedBy = "System",
+ UpdatedAt = DateTime.Now
+ };
+
+ var result = await taskRepository.InsertReturnEntityAsync(task);
+
+ logger.LogInformation("补偿任务创建成功,TaskId: {TaskId}", result.Id);
+
+ return result.Id;
+ }
+
+ ///
+ /// 获取待处理的补偿任务列表
+ ///
+ public async Task> GetPendingTasksAsync(int limit = 50)
+ {
+ var now = DateTime.Now;
+
+ var tasks = await taskRepository.Queryable()
+ .Where(t => (t.Status == CompensationTaskStatus.Pending || t.Status == CompensationTaskStatus.Processing)
+ && !t.IsDeleted
+ && (t.ScheduledAt == null || t.ScheduledAt <= now))
+ .OrderBy(t => t.CreatedAt)
+ .Take(limit)
+ .Select(t => new CompensationTaskOutput
+ {
+ Id = t.Id,
+ TaskType = t.TaskType,
+ BusinessSource = t.BusinessSource,
+ BusinessId = t.BusinessId,
+ UserId = t.UserId,
+ Payload = t.Payload,
+ ErrorMessage = t.ErrorMessage,
+ ErrorSource = t.ErrorSource,
+ RetryCount = t.RetryCount,
+ MaxRetries = t.MaxRetries,
+ Status = t.Status,
+ ProcessedAt = t.ProcessedAt,
+ ScheduledAt = t.ScheduledAt,
+ ResultMessage = t.ResultMessage,
+ CreatedAt = t.CreatedAt
+ })
+ .ToListAsync();
+
+ return tasks;
+ }
+
+ ///
+ /// 根据业务来源和类型查询补偿任务(用于外部项目按条件拉取)
+ ///
+ public async Task> GetTasksAsync(GetCompensationTasksInput input)
+ {
+ var query = taskRepository.Queryable()
+ .Where(t => !t.IsDeleted);
+
+ if (!string.IsNullOrEmpty(input.Status))
+ query = query.Where(t => t.Status == input.Status);
+
+ if (!string.IsNullOrEmpty(input.TaskType))
+ query = query.Where(t => t.TaskType == input.TaskType);
+
+ if (!string.IsNullOrEmpty(input.BusinessSource))
+ query = query.Where(t => t.BusinessSource == input.BusinessSource);
+
+ var tasks = await query
+ .OrderBy(t => t.CreatedAt)
+ .Take(input.Limit > 0 ? input.Limit : 50)
+ .Select(t => new CompensationTaskOutput
+ {
+ Id = t.Id,
+ TaskType = t.TaskType,
+ BusinessSource = t.BusinessSource,
+ BusinessId = t.BusinessId,
+ UserId = t.UserId,
+ Payload = t.Payload,
+ ErrorMessage = t.ErrorMessage,
+ ErrorSource = t.ErrorSource,
+ RetryCount = t.RetryCount,
+ MaxRetries = t.MaxRetries,
+ Status = t.Status,
+ ProcessedAt = t.ProcessedAt,
+ ScheduledAt = t.ScheduledAt,
+ ResultMessage = t.ResultMessage,
+ CreatedAt = t.CreatedAt
+ })
+ .ToListAsync();
+
+ return tasks;
+ }
+
+ ///
+ /// 更新补偿任务状态(供外部处理项目回调更新结果)
+ ///
+ public async Task UpdateTaskStatusAsync(long taskId, UpdateCompensationStatusInput input)
+ {
+ var update = taskRepository.Context.Updateable()
+ .SetColumns(t => t.Status == input.Status)
+ .SetColumns(t => t.ResultMessage == input.ResultMessage)
+ .SetColumns(t => t.ProcessedAt == DateTime.Now)
+ .SetColumns(t => t.UpdatedAt == DateTime.Now);
+
+ // 如果外部传入了重试相关字段,一并更新
+ if (input.RetryCount.HasValue)
+ update = update.SetColumns(t => t.RetryCount == input.RetryCount.Value);
+
+ if (input.ScheduledAt.HasValue)
+ update = update.SetColumns(t => t.ScheduledAt == input.ScheduledAt.Value);
+
+ await update.Where(t => t.Id == taskId && !t.IsDeleted)
+ .ExecuteCommandAsync();
+
+ logger.LogInformation("补偿任务状态更新,TaskId: {TaskId}, Status: {Status}", taskId, input.Status);
+ }
+
+ ///
+ /// 取消补偿任务
+ ///
+ public async Task CancelTaskAsync(long taskId, string reason)
+ {
+ await taskRepository.Context.Updateable()
+ .SetColumns(t => t.Status == CompensationTaskStatus.Cancelled)
+ .SetColumns(t => t.ResultMessage == reason)
+ .SetColumns(t => t.UpdatedAt == DateTime.Now)
+ .Where(t => t.Id == taskId && !t.IsDeleted)
+ .ExecuteCommandAsync();
+
+ logger.LogInformation("补偿任务已取消,TaskId: {TaskId}, Reason: {Reason}", taskId, reason);
+ }
+}
diff --git a/QYZH.InteractiveMagazine.Service/PetService.cs b/QYZH.InteractiveMagazine.Service/PetService.cs
new file mode 100644
index 0000000..aebd326
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Service/PetService.cs
@@ -0,0 +1,282 @@
+using Microsoft.Extensions.Logging;
+using QYZH.InteractiveMagazine.IService;
+using QYZH.InteractiveMagazine.Models.Common;
+using QYZH.InteractiveMagazine.Models.Dto;
+using QYZH.InteractiveMagazine.Models.Dto.Pet;
+using QYZH.InteractiveMagazine.Models.Entity;
+using QYZH.InteractiveMagazine.Repository;
+using SqlSugar;
+
+namespace QYZH.InteractiveMagazine.Service;
+
+///
+/// 宠物服务实现
+///
+public class PetService(
+ BaseRepository petRepository,
+ BaseRepository feedingRecordRepository,
+ BaseRepository petEvolutionRepository,
+ ILogger logger)
+ : BaseRepository, IPetService
+{
+ ///
+ /// 获取用户宠物信息
+ ///
+ public async Task GetPetByUserIdAsync(long userId)
+ {
+ logger.LogInformation("获取用户宠物信息,UserId: {UserId}", userId);
+
+ var pet = await petRepository.Queryable()
+ .Where(p => p.UserId == userId)
+ .Select(p => new PetOutput
+ {
+ Id = p.Id,
+ UserId = p.UserId,
+ Name = p.Name,
+ CurrentEvolutionId = p.CurrentEvolutionId,
+ GrowthPoints = p.GrowthPoints,
+ FeedingCount = p.FeedingCount,
+ Type = p.Type,
+ Status = p.Status,
+ CreatedAt = p.CreatedAt
+ })
+ .FirstAsync();
+
+ return pet;
+ }
+
+ ///
+ /// 为用户创建默认宠物(最低形态、成长值为0、未激活状态)
+ ///
+ public async Task CreateDefaultPetAsync(long userId)
+ {
+ logger.LogInformation("为用户创建默认宠物,UserId: {UserId}", userId);
+
+ // 检查用户是否已有宠物
+ var exists = petRepository.Context.Queryable()
+ .Any(p => p.UserId == userId);
+ if (exists)
+ {
+ logger.LogWarning("用户已存在宠物,跳过创建,UserId: {UserId}", userId);
+ return;
+ }
+
+ var pet = new Pet
+ {
+ UserId = userId,
+ Name = "小精灵",
+ CurrentEvolutionId = 1,
+ GrowthPoints = 0,
+ FeedingCount = 0,
+ Type = "Normal",
+ Status = "Inactive",
+ IsDeleted = false,
+ CreatedBy = userId.ToString(),
+ CreatedAt = DateTime.Now,
+ UpdatedBy = userId.ToString(),
+ UpdatedAt = DateTime.Now
+ };
+
+ var result = await petRepository.InsertAsync(pet);
+ if (!result)
+ {
+ logger.LogError("创建默认宠物失败,UserId: {UserId}", userId);
+ throw new Exception("创建宠物失败");
+ }
+
+ logger.LogInformation("用户默认宠物创建成功,UserId: {UserId}, PetId: {PetId}", userId, pet.Id);
+ }
+
+ ///
+ /// 激活宠物(将状态从 Inactive 改为 Active)
+ ///
+ public async Task ActivatePetAsync(long userId)
+ {
+ logger.LogInformation("激活用户宠物,UserId: {UserId}", userId);
+
+ var pet = await petRepository.Queryable()
+ .Where(p => p.UserId == userId)
+ .FirstAsync();
+
+ if (pet == null)
+ {
+ logger.LogWarning("用户宠物不存在,无法激活,UserId: {UserId}", userId);
+ return;
+ }
+
+ if (pet.Status != "Inactive")
+ {
+ logger.LogInformation("用户宠物已非未激活状态,跳过激活,UserId: {UserId}, Status: {Status}", userId, pet.Status);
+ return;
+ }
+
+ var result = await petRepository.UpdateAsync(
+ p => new Pet { Status = "Active" },
+ p => p.UserId == userId);
+
+ if (!result)
+ {
+ logger.LogError("激活宠物失败,UserId: {UserId}", userId);
+ throw new Exception("激活宠物失败");
+ }
+
+ logger.LogInformation("用户宠物激活成功,UserId: {UserId}", userId);
+ }
+
+ ///
+ /// 喂养宠物(增加成长值 + 记录喂养记录 + 触发进化检查)
+ ///
+ public async Task FeedPetAsync(long userId, FeedPetInput input)
+ {
+ logger.LogInformation("喂养宠物,UserId: {UserId}, PetId: {PetId}, GrowthPoints: {GrowthPoints}",
+ userId, input.PetId, input.GrowthPoints);
+
+ if (input.PetId <= 0)
+ {
+ throw new BusinessException("宠物Id不能为空", 400);
+ }
+
+ if (input.GrowthPoints <= 0)
+ {
+ throw new BusinessException("成长值必须大于0", 400);
+ }
+
+ // 查询宠物
+ var pet = await petRepository.GetByIdAsync(input.PetId);
+ if (pet == null || pet.IsDeleted)
+ {
+ logger.LogWarning("喂养失败,宠物不存在,PetId: {PetId}", input.PetId);
+ throw new BusinessException("宠物不存在", 404);
+ }
+
+ // 校验宠物归属
+ if (pet.UserId != userId)
+ {
+ logger.LogWarning("喂养失败,无权操作该宠物,UserId: {UserId}, PetUserId: {PetUserId}", userId, pet.UserId);
+ throw new BusinessException("无权操作该宠物", 403);
+ }
+
+ // 校验宠物状态
+ if (pet.Status != "Active")
+ {
+ logger.LogWarning("喂养失败,宠物未激活,PetId: {PetId}, Status: {Status}", input.PetId, pet.Status);
+ throw new BusinessException("宠物未激活,无法喂养", 400);
+ }
+
+ var growthBefore = pet.GrowthPoints;
+ var growthAfter = growthBefore + input.GrowthPoints;
+ var hasEvolved = false;
+ string? evolvedStageName = null;
+
+ // 事务保证一致性
+ await UseTranAsync(async () =>
+ {
+ // 累加成长值和喂养次数
+ var updateResult = await petRepository.Context.Updateable()
+ .SetColumns(p => p.GrowthPoints == growthAfter)
+ .SetColumns(p => p.FeedingCount == p.FeedingCount + 1)
+ .SetColumns(p => p.UpdatedAt == DateTime.Now)
+ .SetColumns(p => p.UpdatedBy == userId.ToString())
+ .Where(p => p.Id == input.PetId)
+ .ExecuteCommandAsync();
+
+ if (updateResult <= 0)
+ {
+ throw new BusinessException("更新宠物成长值失败", 500);
+ }
+
+ // 进化检查:查找下一阶段进化形态
+ var nextEvolution = await petEvolutionRepository.Queryable()
+ .Where(e => e.PreviousEvolutionId == pet.CurrentEvolutionId
+ && e.RequiredGrowth <= growthAfter
+ && e.Status == "Active")
+ .OrderBy(e => e.RequiredGrowth, OrderByType.Desc)
+ .FirstAsync();
+
+ if (nextEvolution != null)
+ {
+ // 触发进化
+ var evolveResult = await petRepository.Context.Updateable()
+ .SetColumns(p => p.CurrentEvolutionId == nextEvolution.Id)
+ .SetColumns(p => p.UpdatedAt == DateTime.Now)
+ .SetColumns(p => p.UpdatedBy == userId.ToString())
+ .Where(p => p.Id == input.PetId)
+ .ExecuteCommandAsync();
+
+ if (evolveResult > 0)
+ {
+ hasEvolved = true;
+ evolvedStageName = nextEvolution.StageName;
+ logger.LogInformation("宠物进化成功,PetId: {PetId}, 新形态: {StageName} (Level {StageLevel})",
+ input.PetId, nextEvolution.StageName, nextEvolution.StageLevel);
+ }
+ }
+
+ // 写入喂养记录
+ var record = new PetFeedingRecord
+ {
+ PetId = input.PetId,
+ UserId = userId,
+ PointsUsed = 0, // 预留:后期可扩展为消耗积分喂养
+ GrowthChange = input.GrowthPoints,
+ GrowthBefore = growthBefore,
+ GrowthAfter = growthAfter,
+ Type = "Normal",
+ Status = "Success",
+ IsDeleted = false,
+ CreatedBy = userId.ToString(),
+ CreatedAt = DateTime.Now,
+ UpdatedBy = userId.ToString(),
+ UpdatedAt = DateTime.Now
+ };
+
+ var insertResult = await feedingRecordRepository.InsertAsync(record);
+ if (!insertResult)
+ {
+ throw new BusinessException("写入喂养记录失败", 500);
+ }
+ });
+
+ logger.LogInformation("喂养宠物成功,PetId: {PetId}, 成长值: {Before} -> {After}, 进化: {HasEvolved}",
+ input.PetId, growthBefore, growthAfter, hasEvolved);
+
+ return new FeedPetOutput
+ {
+ PetId = input.PetId,
+ GrowthBefore = growthBefore,
+ GrowthAfter = growthAfter,
+ GrowthChange = input.GrowthPoints,
+ HasEvolved = hasEvolved,
+ EvolvedStageName = evolvedStageName
+ };
+ }
+
+ ///
+ /// 获取宠物喂养记录列表
+ ///
+ public async Task> GetFeedingRecordsAsync(long userId, long petId, PageQueryModel pageQuery)
+ {
+ logger.LogInformation("查询喂养记录,UserId: {UserId}, PetId: {PetId}, PageIndex: {PageIndex}, PageSize: {PageSize}",
+ userId, petId, pageQuery.PageIndex, pageQuery.PageSize);
+
+ RefAsync totalNumber = 0;
+ var records = await feedingRecordRepository.Queryable()
+ .Where(r => r.UserId == userId && r.PetId == petId)
+ .OrderByDescending(r => r.CreatedAt)
+ .Select(r => new FeedingRecordOutput
+ {
+ Id = r.Id,
+ PetId = r.PetId,
+ UserId = r.UserId,
+ GrowthChange = r.GrowthChange,
+ GrowthBefore = r.GrowthBefore,
+ GrowthAfter = r.GrowthAfter,
+ Type = r.Type,
+ Status = r.Status,
+ CreatedAt = r.CreatedAt
+ }, true)
+ .ToPageListAsync(pageQuery.PageIndex, pageQuery.PageSize, totalNumber);
+
+ return new PageListModel(records, pageQuery.PageIndex, pageQuery.PageSize, totalNumber);
+ }
+}
diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs
new file mode 100644
index 0000000..321a022
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs
@@ -0,0 +1,208 @@
+using Microsoft.Extensions.Logging;
+using QYZH.InteractiveMagazine.IService;
+using QYZH.InteractiveMagazine.Models.Common;
+using QYZH.InteractiveMagazine.Models.Dto;
+using QYZH.InteractiveMagazine.Models.Entity;
+using QYZH.InteractiveMagazine.Repository;
+using SqlSugar;
+
+namespace QYZH.InteractiveMagazine.Service;
+
+///
+/// 用户期刊关联服务实现
+///
+public class UserJournalService(
+ BaseRepository userJournalRepository,
+ BaseRepository usersRepository,
+ BaseRepository journalRepository,
+ ILogger logger,
+ IPetService petService)
+ : BaseRepository, IUserJournalService
+{
+ ///
+ /// 用户绑定期刊(扫码绑定)
+ ///
+ public async Task BindJournalAsync(long userId, BindJournalInput input)
+ {
+ logger.LogInformation("用户绑定期刊,UserId: {UserId}, JournalId: {JournalId}, JournalInstanceId: {JournalInstanceId}, Type: {Type}",
+ userId, input.JournalId, input.JournalInstanceId, input.Type);
+
+ // 校验参数
+ if (input.JournalId <= 0)
+ {
+ throw new BusinessException("期刊Id不能为空", 400);
+ }
+
+ // 校验用户是否存在
+ var user = await usersRepository.GetByIdAsync(userId);
+ if (user == null || user.IsDeleted)
+ {
+ logger.LogWarning("绑定期刊失败,用户不存在,UserId: {UserId}", userId);
+ throw new BusinessException("用户不存在", 404);
+ }
+
+ // 校验期刊是否存在
+ var journal = await journalRepository.GetByIdAsync(input.JournalId);
+ if (journal == null || journal.IsDeleted)
+ {
+ logger.LogWarning("绑定期刊失败,期刊不存在,JournalId: {JournalId}", input.JournalId);
+ throw new BusinessException("期刊不存在", 404);
+ }
+
+ // 校验期刊状态
+ if (journal.Status != "Published")
+ {
+ logger.LogWarning("绑定期刊失败,期刊未发布,JournalId: {JournalId}, Status: {Status}", input.JournalId, journal.Status);
+ throw new BusinessException("该期刊暂未发布,无法绑定", 400);
+ }
+
+ // 校验实例化期刊是否存在(如果传入了 JournalInstanceId)
+ if (input.JournalInstanceId.HasValue && input.JournalInstanceId.Value > 0)
+ {
+ var instance = await journalRepository.GetByIdAsync(input.JournalInstanceId.Value);
+ if (instance == null || instance.IsDeleted)
+ {
+ logger.LogWarning("绑定期刊失败,实例化期刊不存在,JournalInstanceId: {JournalInstanceId}", input.JournalInstanceId);
+ throw new BusinessException("实例化期刊不存在", 404);
+ }
+ }
+
+ // 防重复绑定:同一用户 + 期刊 + 实例 + 类型
+ var isExist = userJournalRepository.Any(uj =>
+ uj.UserId == userId &&
+ uj.JournalId == input.JournalId &&
+ uj.JournalInstanceId == input.JournalInstanceId &&
+ uj.Type == input.Type &&
+ !uj.IsDeleted);
+
+ if (isExist)
+ {
+ logger.LogWarning("重复绑定期刊,UserId: {UserId}, JournalId: {JournalId}, Type: {Type}", userId, input.JournalId, input.Type);
+ throw new BusinessException("您已绑定过该期刊,无需重复操作", 400);
+ }
+
+ // 检查是否为首次绑定期刊(用于激活宠物)
+ var isFirstBind = !userJournalRepository.Context.Queryable()
+ .Any(uj => uj.UserId == userId);
+
+ // 创建绑定记录
+ var userJournal = new UserJournal
+ {
+ UserId = userId,
+ JournalId = input.JournalId,
+ JournalInstanceId = input.JournalInstanceId,
+ Type = input.Type,
+ Status = "Active",
+ IsDeleted = false,
+ CreatedBy = userId.ToString(),
+ CreatedAt = DateTime.Now,
+ UpdatedBy = userId.ToString(),
+ UpdatedAt = DateTime.Now
+ };
+
+ var result = await userJournalRepository.InsertAsync(userJournal);
+ if (!result)
+ {
+ logger.LogError("绑定期刊失败,写入数据库失败,UserId: {UserId}, JournalId: {JournalId}", userId, input.JournalId);
+ throw new BusinessException("绑定期刊失败,请稍后重试", 500);
+ }
+
+ logger.LogInformation("用户绑定期刊成功,UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, userJournal.Id);
+
+ // 首次绑定期刊时激活宠物
+ if (isFirstBind)
+ {
+ try
+ {
+ await petService.ActivatePetAsync(userId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "首次绑定期刊激活宠物失败,UserId: {UserId}", userId);
+ // 宠物激活失败不阻断绑定流程
+ }
+ }
+
+ return new BindJournalOutput
+ {
+ Id = userJournal.Id,
+ UserId = userJournal.UserId,
+ JournalId = userJournal.JournalId,
+ JournalInstanceId = userJournal.JournalInstanceId,
+ Type = userJournal.Type,
+ Status = userJournal.Status,
+ CreatedAt = userJournal.CreatedAt
+ };
+ }
+
+ ///
+ /// 获取用户的期刊绑定列表
+ ///
+ public async Task> GetUserJournalsAsync(long userId, UserJournalQueryInput input)
+ {
+ logger.LogInformation("查询用户期刊绑定列表,UserId: {UserId}, PageIndex: {PageIndex}, PageSize: {PageSize}",
+ userId, input.PageIndex, input.PageSize);
+
+ if (input.PageIndex <= 0)
+ {
+ throw new BusinessException("页码必须大于0", 400);
+ }
+
+ if (input.PageSize <= 0 || input.PageSize > 100)
+ {
+ throw new BusinessException("每页条数必须在1-100之间", 400);
+ }
+
+ RefAsync totalNumber = 0;
+ var pageResult = await userJournalRepository.Queryable()
+ .Where(uj => uj.UserId == userId)
+ .WhereIF(input.JournalId.HasValue, uj => uj.JournalId == input.JournalId.Value)
+ .WhereIF(input.JournalInstanceId.HasValue, uj => uj.JournalInstanceId == input.JournalInstanceId.Value)
+ .WhereIF(!string.IsNullOrWhiteSpace(input.Type), uj => uj.Type == input.Type)
+ .OrderByDescending(uj => uj.CreatedAt)
+ .Select(uj => new BindJournalOutput
+ {
+ Id = uj.Id,
+ UserId = uj.UserId,
+ JournalId = uj.JournalId,
+ JournalInstanceId = uj.JournalInstanceId,
+ Type = uj.Type,
+ Status = uj.Status,
+ CreatedAt = uj.CreatedAt
+ }, true)
+ .ToPageListAsync(input.PageIndex, input.PageSize, totalNumber);
+
+ return new PageListModel(pageResult, input.PageIndex, input.PageSize, totalNumber);
+ }
+
+ ///
+ /// 取消期刊绑定
+ ///
+ public async Task UnbindJournalAsync(long userId, long id)
+ {
+ logger.LogInformation("取消期刊绑定,UserId: {UserId}, Id: {Id}", userId, id);
+
+ var userJournal = await userJournalRepository.GetByIdAsync(id);
+ if (userJournal == null || userJournal.IsDeleted)
+ {
+ logger.LogWarning("取消绑定失败,记录不存在,Id: {Id}", id);
+ throw new BusinessException("绑定记录不存在", 404);
+ }
+
+ // 校验归属权:只能取消自己的绑定
+ if (userJournal.UserId != userId)
+ {
+ logger.LogWarning("取消绑定失败,无权操作,UserId: {UserId}, RecordUserId: {RecordUserId}", userId, userJournal.UserId);
+ throw new BusinessException("无权取消该绑定", 403);
+ }
+
+ var result = await userJournalRepository.DeleteByIdAsync(id);
+ if (!result)
+ {
+ logger.LogError("取消绑定失败,Id: {Id}", id);
+ throw new BusinessException("取消绑定失败,请稍后重试", 500);
+ }
+
+ logger.LogInformation("取消期刊绑定成功,UserId: {UserId}, Id: {Id}", userId, id);
+ }
+}
diff --git a/QYZH.InteractiveMagazine.Service/WeChatAuthService.cs b/QYZH.InteractiveMagazine.Service/WeChatAuthService.cs
index f6ceb1f..5818e59 100644
--- a/QYZH.InteractiveMagazine.Service/WeChatAuthService.cs
+++ b/QYZH.InteractiveMagazine.Service/WeChatAuthService.cs
@@ -15,27 +15,26 @@ namespace QYZH.InteractiveMagazine.Service;
///
/// 微信小程序认证服务实现
///
-public class WeChatAuthService(BaseRepository usersRepository, IConfiguration configuration, ILogger logger) : BaseRepository, IWeChatAuthService
+public class WeChatAuthService(BaseRepository usersRepository, IConfiguration configuration, ILogger logger, IPetService petService) : BaseRepository, IWeChatAuthService
{
private const string TokenKeyPrefix = "InteractiveMagazine:WeChatAuth:Token";
+ private const string AccessTokenCacheKey = "InteractiveMagazine:WeChat:AccessToken";
private const string Code2SessionUrl = "https://api.weixin.qq.com/sns/jscode2session?appid={0}&secret={1}&js_code={2}&grant_type=authorization_code";
+ private const string GetAccessTokenUrl = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={0}&secret={1}";
+ private const string GetPhoneNumberUrl = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={0}";
///
- /// 微信小程序一键登录
+ /// 微信小程序登录(首次创建用户,非首次直接登录)
///
- /// 登录输入(含微信 code)
- /// 登录结果(含 Token 和该 OpenId 下的用户列表)
public async Task LoginAsync(WeChatLoginInput input)
{
- logger.LogInformation("微信小程序登录尝试");
+ logger.LogInformation("微信小程序登录");
- // 参数校验
if (string.IsNullOrWhiteSpace(input.Code))
{
throw new BusinessException("微信登录凭证 code 不能为空", 400);
}
- // 获取微信配置
var weChatSettings = GetWeChatSettings();
// 调用微信 code2session 接口
@@ -56,16 +55,26 @@ public class WeChatAuthService(BaseRepository usersRepository, IConfigura
if (users.Count == 0)
{
- // 首次登录,创建新用户
+ // 首次登录,获取手机号(如果传入了 PhoneCode)
+ string? phone = null;
+ if (!string.IsNullOrWhiteSpace(input.PhoneCode))
+ {
+ phone = await GetPhoneNumberAsync(weChatSettings, input.PhoneCode);
+ logger.LogInformation("获取手机号成功,OpenId: {OpenId}, Phone: {Phone}", wxResponse.OpenId, phone);
+ }
+
+ // 创建新用户
var newUser = new Users
{
Name = $"wx_{wxResponse.OpenId[^8..]}",
OpenId = wxResponse.OpenId,
UnionId = wxResponse.UnionId,
+ Phone = phone,
Type = "Normal",
Status = "Active",
GrowthPoints = 0,
- Points = 0
+ Points = 0,
+ IsLastOnline = true
};
var insertResult = await usersRepository.Insertable(newUser).ExecuteReturnIdentityAsync();
@@ -78,29 +87,73 @@ public class WeChatAuthService(BaseRepository usersRepository, IConfigura
newUser.Id = insertResult;
users.Add(newUser);
logger.LogInformation("微信新用户创建成功,UserId: {UserId}, OpenId: {OpenId}", newUser.Id, wxResponse.OpenId);
+
+ // 为新用户创建默认宠物(未激活状态)
+ try
+ {
+ await petService.CreateDefaultPetAsync(newUser.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "新用户创建默认宠物失败,UserId: {UserId}", newUser.Id);
+ // 宠物创建失败不阻断注册流程
+ }
}
else
{
+ // 非首次登录,如果传入了 PhoneCode 则更新该 OpenId 下所有用户的手机号
+ if (!string.IsNullOrWhiteSpace(input.PhoneCode))
+ {
+ var phone = await GetPhoneNumberAsync(weChatSettings, input.PhoneCode);
+ if (!string.IsNullOrWhiteSpace(phone))
+ {
+ await usersRepository.Context.Updateable()
+ .SetColumns(u => u.Phone == phone)
+ .Where(u => u.OpenId == wxResponse.OpenId && !u.IsDeleted)
+ .ExecuteCommandAsync();
+
+ foreach (var u in users)
+ {
+ u.Phone = phone;
+ }
+
+ logger.LogInformation("更新 OpenId: {OpenId} 下所有用户手机号成功,Phone: {Phone}", wxResponse.OpenId, phone);
+ }
+ }
+
logger.LogInformation("微信登录成功,OpenId: {OpenId} 下存在 {Count} 个用户", wxResponse.OpenId, users.Count);
}
- // 使用第一个用户生成 JWT Token
- var primaryUser = users.First();
- var jwtSettings = GetJwtSettings();
- var token = JwtHelper.GenerateToken((long)primaryUser.Id, primaryUser.Name, jwtSettings);
+ // 构建登录输出
+ return await BuildLoginOutputAsync(wxResponse.OpenId, users);
+ }
- // 缓存 Token 到 Redis
- await RedisHelper.StringSetAsync($"{TokenKeyPrefix}:{primaryUser.Id}", token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
+ ///
+ /// 微信小程序快捷登录(通过 OpenId 直接登录,用户需已存在)
+ ///
+ public async Task QuickLoginAsync(WeChatQuickLoginInput input)
+ {
+ logger.LogInformation("微信快捷登录,OpenId: {OpenId}", input.OpenId);
- // 映射用户列表
- var userOutputs = users.Select(MapUserToOutput).ToList();
-
- return new WeChatLoginOutput
+ if (string.IsNullOrWhiteSpace(input.OpenId))
{
- Token = token,
- OpenId = wxResponse.OpenId,
- Users = userOutputs
- };
+ throw new BusinessException("OpenId 不能为空", 400);
+ }
+
+ // 查询该 OpenId 下的所有用户
+ var users = await usersRepository.Context.Queryable()
+ .Where(u => u.OpenId == input.OpenId && !u.IsDeleted)
+ .ToListAsync();
+
+ if (users.Count == 0)
+ {
+ logger.LogWarning("快捷登录失败,OpenId: {OpenId} 下无用户", input.OpenId);
+ throw new BusinessException("未找到该微信账号关联的用户,请先完成注册", 404);
+ }
+
+ logger.LogInformation("快捷登录成功,OpenId: {OpenId} 下存在 {Count} 个用户", input.OpenId, users.Count);
+
+ return await BuildLoginOutputAsync(input.OpenId, users);
}
///
@@ -193,6 +246,90 @@ public class WeChatAuthService(BaseRepository usersRepository, IConfigura
}
}
+ ///
+ /// 构建登录输出(生成 Token + 映射用户列表)
+ ///
+ private async Task BuildLoginOutputAsync(string openId, List users)
+ {
+ // 优先使用 IsLastOnline 的用户,否则取第一个
+ var primaryUser = users.FirstOrDefault(u => u.IsLastOnline) ?? users.First();
+
+ var jwtSettings = GetJwtSettings();
+ var token = JwtHelper.GenerateToken((long)primaryUser.Id, primaryUser.Name, jwtSettings);
+
+ // 缓存 Token 到 Redis
+ await RedisHelper.StringSetAsync($"{TokenKeyPrefix}:{primaryUser.Id}", token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
+
+ var userOutputs = users.Select(MapUserToOutput).ToList();
+
+ return new WeChatLoginOutput
+ {
+ Token = token,
+ OpenId = openId,
+ Users = userOutputs
+ };
+ }
+
+ ///
+ /// 获取微信 access_token(带 Redis 缓存)
+ ///
+ private async Task GetAccessTokenAsync(WeChatSettings settings)
+ {
+ // 先从 Redis 缓存获取
+ var cachedToken = await RedisHelper.StringGetAsync(AccessTokenCacheKey);
+ if (!string.IsNullOrWhiteSpace(cachedToken))
+ {
+ return cachedToken;
+ }
+
+ // 缓存未命中,调用微信接口获取
+ var url = string.Format(GetAccessTokenUrl, settings.AppId, settings.AppSecret);
+ var response = await HttpHelper.GetAsync(url);
+
+ if (response == null || response.ErrCode != 0 || string.IsNullOrWhiteSpace(response.AccessToken))
+ {
+ var errMsg = response?.ErrMsg ?? "未知错误";
+ logger.LogError("获取微信 access_token 失败,errcode: {ErrCode}, errmsg: {ErrMsg}", response?.ErrCode, errMsg);
+ throw new BusinessException("微信服务请求失败,请稍后重试", 500);
+ }
+
+ // 缓存 access_token,提前 5 分钟过期(微信默认 7200 秒)
+ var expiresIn = response.ExpiresIn > 300 ? response.ExpiresIn - 300 : response.ExpiresIn;
+ await RedisHelper.StringSetAsync(AccessTokenCacheKey, response.AccessToken, TimeSpan.FromSeconds(expiresIn));
+
+ logger.LogInformation("获取微信 access_token 成功,有效期: {ExpiresIn} 秒", expiresIn);
+ return response.AccessToken;
+ }
+
+ ///
+ /// 通过 phone_code 获取微信用户手机号
+ ///
+ private async Task GetPhoneNumberAsync(WeChatSettings settings, string phoneCode)
+ {
+ try
+ {
+ var accessToken = await GetAccessTokenAsync(settings);
+ var url = string.Format(GetPhoneNumberUrl, accessToken);
+ var response = await HttpHelper.PostAsync(url, new { code = phoneCode });
+
+ if (response == null || response.ErrCode != 0 || response.PhoneInfo == null)
+ {
+ var errMsg = response?.ErrMsg ?? "未知错误";
+ logger.LogWarning("获取手机号失败,errcode: {ErrCode}, errmsg: {ErrMsg}", response?.ErrCode, errMsg);
+ // 获取手机号失败不阻断登录流程,仅记录日志
+ return null;
+ }
+
+ return response.PhoneInfo.PurePhoneNumber ?? response.PhoneInfo.PhoneNumber;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "调用微信获取手机号接口异常");
+ // 获取手机号失败不阻断登录流程
+ return null;
+ }
+ }
+
///
/// 用户实体映射为输出 DTO
///
diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CheckInController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CheckInController.cs
new file mode 100644
index 0000000..30da273
--- /dev/null
+++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/CheckInController.cs
@@ -0,0 +1,82 @@
+using Microsoft.AspNetCore.Mvc;
+using QYZH.InteractiveMagazine.IService;
+using QYZH.InteractiveMagazine.Models.Common;
+using QYZH.InteractiveMagazine.Models.Dto;
+using QYZH.InteractiveMagazine.Models.Dto.CheckIn;
+
+namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat;
+
+///
+/// 签到控制器
+///
+public class CheckInController : WeChatBaseController
+{
+ private readonly ICheckInService _checkInService;
+ private readonly ILogger _logger;
+
+ public CheckInController(ICheckInService checkInService, ILogger logger)
+ {
+ _checkInService = checkInService;
+ _logger = logger;
+ }
+
+ ///
+ /// 用户签到
+ ///
+ /// 签到结果(含奖励详情和余额)
+ [HttpPost("checkIn")]
+ public async Task> CheckInAsync()
+ {
+ try
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息");
+ }
+
+ var result = await _checkInService.CheckInAsync(userId.Value);
+ return Success(result, "签到成功");
+ }
+ catch (BusinessException ex)
+ {
+ _logger.LogWarning(ex, "签到业务异常: {Message}", ex.Message);
+ return BaseResponse.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "签到系统异常");
+ return BaseResponse.Fail("签到失败,请稍后重试");
+ }
+ }
+
+ ///
+ /// 获取签到信息(今日状态、连续天数、最近记录)
+ ///
+ /// 签到信息
+ [HttpGet("info")]
+ public async Task> GetCheckInInfoAsync()
+ {
+ try
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息");
+ }
+
+ var result = await _checkInService.GetCheckInInfoAsync(userId.Value);
+ return Success(result);
+ }
+ catch (BusinessException ex)
+ {
+ _logger.LogWarning(ex, "获取签到信息业务异常: {Message}", ex.Message);
+ return BaseResponse.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "获取签到信息系统异常");
+ return BaseResponse.Fail("获取签到信息失败,请稍后重试");
+ }
+ }
+}
diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/JournalController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/JournalController.cs
new file mode 100644
index 0000000..a4ed32c
--- /dev/null
+++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/JournalController.cs
@@ -0,0 +1,83 @@
+using Microsoft.AspNetCore.Mvc;
+using QYZH.InteractiveMagazine.IService;
+using QYZH.InteractiveMagazine.Models.Common;
+using QYZH.InteractiveMagazine.Models.Dto;
+
+namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat;
+
+///
+/// 小程序期刊管理控制器
+///
+public class JournalController : WeChatBaseController
+{
+ private readonly IUserJournalService _userJournalService;
+ private readonly ILogger _logger;
+
+ public JournalController(IUserJournalService userJournalService, ILogger logger)
+ {
+ _userJournalService = userJournalService;
+ _logger = logger;
+ }
+
+ ///
+ /// 用户扫码绑定期刊
+ ///
+ /// 绑定输入(JournalId、JournalInstanceId 从扫码内容解析,Type 默认 Subscribe)
+ /// 绑定结果
+ [HttpPost("bind")]
+ public async Task> BindAsync([FromBody] BindJournalInput input)
+ {
+ try
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息");
+ }
+
+ var result = await _userJournalService.BindJournalAsync(userId.Value, input);
+ return Success(result, "绑定期刊成功");
+ }
+ catch (BusinessException ex)
+ {
+ _logger.LogWarning(ex, "绑定期刊业务异常: {Message}", ex.Message);
+ return BaseResponse.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "绑定期刊系统异常,参数:{Input}", input);
+ return BaseResponse.Fail("绑定期刊失败,请稍后重试");
+ }
+ }
+
+ ///
+ /// 获取当前用户的期刊绑定列表
+ ///
+ /// 查询条件(期刊Id、实例Id、关联类型)
+ /// 分页结果
+ [HttpPost("list")]
+ public async Task>> GetListAsync([FromBody] UserJournalQueryInput input)
+ {
+ try
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return BaseResponse>.Fail(ResultCode.DENY, "未获取到用户信息");
+ }
+
+ var result = await _userJournalService.GetUserJournalsAsync(userId.Value, input);
+ return Success(result);
+ }
+ catch (BusinessException ex)
+ {
+ _logger.LogWarning(ex, "查询期刊绑定列表业务异常: {Message}", ex.Message);
+ return BaseResponse>.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "查询期刊绑定列表系统异常");
+ return BaseResponse>.Fail("查询期刊绑定列表失败,请稍后重试");
+ }
+ }
+}
diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/PetController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/PetController.cs
new file mode 100644
index 0000000..882875f
--- /dev/null
+++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/PetController.cs
@@ -0,0 +1,114 @@
+using Microsoft.AspNetCore.Mvc;
+using QYZH.InteractiveMagazine.IService;
+using QYZH.InteractiveMagazine.Models.Common;
+using QYZH.InteractiveMagazine.Models.Dto;
+using QYZH.InteractiveMagazine.Models.Dto.Pet;
+
+namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat;
+
+///
+/// 小程序宠物管理控制器
+///
+public class PetController : WeChatBaseController
+{
+ private readonly IPetService _petService;
+ private readonly ILogger _logger;
+
+ public PetController(IPetService petService, ILogger logger)
+ {
+ _petService = petService;
+ _logger = logger;
+ }
+
+ ///
+ /// 获取当前用户的宠物信息
+ ///
+ [HttpGet("mine")]
+ public async Task> GetMyPetAsync()
+ {
+ try
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息");
+ }
+
+ var pet = await _petService.GetPetByUserIdAsync(userId.Value);
+ if (pet == null)
+ {
+ return BaseResponse.Fail("未找到宠物信息");
+ }
+
+ return Success(pet);
+ }
+ catch (BusinessException ex)
+ {
+ _logger.LogWarning(ex, "获取宠物信息业务异常: {Message}", ex.Message);
+ return BaseResponse.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "获取宠物信息系统异常");
+ return BaseResponse.Fail("获取宠物信息失败,请稍后重试");
+ }
+ }
+
+ ///
+ /// 喂养宠物(增加成长值,触发进化检查)
+ ///
+ [HttpPost("feed")]
+ public async Task> FeedPetAsync([FromBody] FeedPetInput input)
+ {
+ try
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息");
+ }
+
+ var result = await _petService.FeedPetAsync(userId.Value, input);
+ return Success(result, "喂养成功");
+ }
+ catch (BusinessException ex)
+ {
+ _logger.LogWarning(ex, "喂养宠物业务异常: {Message}", ex.Message);
+ return BaseResponse.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "喂养宠物系统异常,参数:{@Input}", input);
+ return BaseResponse.Fail("喂养宠物失败,请稍后重试");
+ }
+ }
+
+ ///
+ /// 获取宠物喂养记录列表
+ ///
+ [HttpGet("records/{petId}")]
+ public async Task>> GetFeedingRecordsAsync(long petId, [FromQuery] PageQueryModel pageQuery)
+ {
+ try
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return BaseResponse>.Fail(ResultCode.DENY, "未获取到用户信息");
+ }
+
+ var result = await _petService.GetFeedingRecordsAsync(userId.Value, petId, pageQuery);
+ return Success(result);
+ }
+ catch (BusinessException ex)
+ {
+ _logger.LogWarning(ex, "查询喂养记录业务异常: {Message}", ex.Message);
+ return BaseResponse>.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "查询喂养记录系统异常");
+ return BaseResponse>.Fail("查询喂养记录失败,请稍后重试");
+ }
+ }
+}
diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatAuthController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatAuthController.cs
index a50fd46..d4d51a8 100644
--- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatAuthController.cs
+++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatAuthController.cs
@@ -22,10 +22,10 @@ public class WeChatAuthController : WeChatBaseController
}
///
- /// 微信小程序一键登录
+ /// 微信小程序登录(首次创建用户,非首次直接登录)
///
- /// 登录输入(含微信 code)
- /// 登录结果(含 Token 和用户信息)
+ /// 登录输入(含微信 code 和可选的手机号 code)
+ /// 登录结果(含 Token 和用户列表)
[AllowAnonymous]
[HttpPost("login")]
public async Task> LoginAsync([FromBody] WeChatLoginInput input)
@@ -47,6 +47,32 @@ public class WeChatAuthController : WeChatBaseController
}
}
+ ///
+ /// 微信小程序快捷登录(通过 OpenId 直接登录,用户需已存在)
+ ///
+ /// 快捷登录输入(含 OpenId)
+ /// 登录结果(含 Token 和用户列表)
+ [AllowAnonymous]
+ [HttpPost("quickLogin")]
+ public async Task> QuickLoginAsync([FromBody] WeChatQuickLoginInput input)
+ {
+ try
+ {
+ var result = await _weChatAuthService.QuickLoginAsync(input);
+ return Success(result);
+ }
+ catch (BusinessException ex)
+ {
+ _logger.LogWarning(ex, "微信快捷登录业务异常: {Message}", ex.Message);
+ return BaseResponse.Fail(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "微信快捷登录系统异常");
+ return BaseResponse.Fail("快捷登录失败,请稍后重试");
+ }
+ }
+
///
/// 切换用户(同一 OpenId 下切换身份)
///
diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatBaseController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatBaseController.cs
index 0b71ac4..3c76f58 100644
--- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatBaseController.cs
+++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/WeChatBaseController.cs
@@ -11,7 +11,7 @@ namespace QYZH.InteractiveMagazine.WebApi.Controllers.WeChat;
///
[Authorize]
[ApiController]
-[Route("api/[controller]")]
+[Route("wechat/api/[controller]")]
[ApiExplorerSettings(GroupName = nameof(ApiVersionEnum.Wechat))]
public abstract class WeChatBaseController : ControllerBase
{