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 {