feat: 新增签到、宠物、期刊绑定、补偿任务等业务模块,优化微信登录流程

本次提交完成了多个核心业务模块的开发与优化:
1.  宠物模块:新增宠物实体、服务接口与实现,支持创建默认宠物、激活、喂养、进化以及喂养记录查询
2.  签到模块:新增签到实体、服务接口、控制器以及相关DTO,支持用户签到和签到信息查询,新增成长值奖励字段
3.  期刊绑定模块:新增用户期刊关联实体、服务接口与控制器,支持扫码绑定期刊、解绑和查询绑定列表
4.  补偿任务模块:新增补偿任务实体、服务接口与实现,用于处理业务失败后的异步重试补偿
5.  优化微信登录流程:拆分登录与快捷登录接口,支持手机号获取,新增首次登录自动创建默认宠物逻辑
6.  调整基础路由与实体状态:修改微信控制器路由前缀,更新宠物状态枚举与默认值
This commit is contained in:
glz
2026-06-04 15:54:30 +08:00
parent 018d7e1733
commit 903ccd3073
25 changed files with 2411 additions and 32 deletions

View File

@ -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;
/// <summary>
/// 签到服务实现
/// </summary>
public class CheckInService(
BaseRepository<CheckInRecord> checkInRecordRepository,
IPetService petService,
ICompensationTaskService compensationTaskService,
ILogger<CheckInService> logger)
: BaseRepository<CheckInRecord>, ICheckInService
{
/// <summary>
/// 默认签到奖励积分(无配置时的兜底值)
/// </summary>
private const int DefaultRewardPoints = 10;
/// <summary>
/// 用户签到
/// </summary>
public async Task<CheckInOutput> CheckInAsync(long userId)
{
logger.LogInformation("用户签到UserId: {UserId}", userId);
var today = DateTime.Now.Date;
// 1. 检查今日是否已签到
var alreadyCheckedIn = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.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<Users>()
.Where(u => u.Id == userId && !u.IsDeleted)
.FirstAsync();
if (user == null)
{
throw new BusinessException("用户不存在", 404);
}
// 5. 查询用户宠物(如果有)
var pet = await checkInRecordRepository.Context.Queryable<Pet>()
.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<Users>()
.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;
}
/// <summary>
/// 获取用户签到信息
/// </summary>
public async Task<CheckInInfoOutput> GetCheckInInfoAsync(long userId)
{
logger.LogInformation("获取签到信息UserId: {UserId}", userId);
var today = DateTime.Now.Date;
// 今日是否已签到
var hasCheckedInToday = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= today && r.CheckInDate < today.AddDays(1))
.AnyAsync();
// 累计签到天数
var totalCheckInDays = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted)
.CountAsync();
// 最近一次签到记录(用于获取连续天数)
var lastRecord = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.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<Users>()
.Where(u => u.Id == userId && !u.IsDeleted)
.FirstAsync();
// 最近 30 天签到记录
var thirtyDaysAgo = today.AddDays(-29);
var recentRecords = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.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
};
}
/// <summary>
/// 计算连续签到天数
/// </summary>
private async Task<int> CalculateConsecutiveDaysAsync(long userId, DateTime today)
{
var yesterday = today.AddDays(-1);
var yesterdayRecord = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= yesterday && r.CheckInDate < today)
.FirstAsync();
// 昨天有签到记录,连续天数 +1否则从 1 开始
return yesterdayRecord != null ? yesterdayRecord.ConsecutiveDays + 1 : 1;
}
/// <summary>
/// 根据连续签到天数计算奖励(积分 + 成长值)
/// </summary>
private async Task<(int PointsReward, int GrowthReward)> CalculateRewardsAsync(int consecutiveDays)
{
// 查询签到配置(按 DayNumber 升序)
var configs = await checkInRecordRepository.Context.Queryable<CheckInConfig>()
.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);
}
}

View File

@ -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;
/// <summary>
/// 补偿任务服务实现(仅负责记录,处理逻辑由外部 Hangfire 项目完成)
/// </summary>
public class CompensationTaskService(
BaseRepository<CompensationTask> taskRepository,
ILogger<CompensationTaskService> logger)
: BaseRepository<CompensationTask>, ICompensationTaskService
{
/// <summary>
/// 创建补偿任务 — 记录失败操作,供后续补偿处理
/// </summary>
public async Task<long> 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;
}
/// <summary>
/// 获取待处理的补偿任务列表
/// </summary>
public async Task<List<CompensationTaskOutput>> 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;
}
/// <summary>
/// 根据业务来源和类型查询补偿任务(用于外部项目按条件拉取)
/// </summary>
public async Task<List<CompensationTaskOutput>> 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;
}
/// <summary>
/// 更新补偿任务状态(供外部处理项目回调更新结果)
/// </summary>
public async Task UpdateTaskStatusAsync(long taskId, UpdateCompensationStatusInput input)
{
var update = taskRepository.Context.Updateable<CompensationTask>()
.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);
}
/// <summary>
/// 取消补偿任务
/// </summary>
public async Task CancelTaskAsync(long taskId, string reason)
{
await taskRepository.Context.Updateable<CompensationTask>()
.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);
}
}

View File

@ -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;
/// <summary>
/// 宠物服务实现
/// </summary>
public class PetService(
BaseRepository<Pet> petRepository,
BaseRepository<PetFeedingRecord> feedingRecordRepository,
BaseRepository<PetEvolution> petEvolutionRepository,
ILogger<PetService> logger)
: BaseRepository<Pet>, IPetService
{
/// <summary>
/// 获取用户宠物信息
/// </summary>
public async Task<PetOutput?> 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;
}
/// <summary>
/// 为用户创建默认宠物最低形态、成长值为0、未激活状态
/// </summary>
public async Task CreateDefaultPetAsync(long userId)
{
logger.LogInformation("为用户创建默认宠物UserId: {UserId}", userId);
// 检查用户是否已有宠物
var exists = petRepository.Context.Queryable<Pet>()
.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);
}
/// <summary>
/// 激活宠物(将状态从 Inactive 改为 Active
/// </summary>
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);
}
/// <summary>
/// 喂养宠物(增加成长值 + 记录喂养记录 + 触发进化检查)
/// </summary>
public async Task<FeedPetOutput> 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<Pet>()
.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<Pet>()
.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
};
}
/// <summary>
/// 获取宠物喂养记录列表
/// </summary>
public async Task<PageListModel<FeedingRecordOutput>> GetFeedingRecordsAsync(long userId, long petId, PageQueryModel pageQuery)
{
logger.LogInformation("查询喂养记录UserId: {UserId}, PetId: {PetId}, PageIndex: {PageIndex}, PageSize: {PageSize}",
userId, petId, pageQuery.PageIndex, pageQuery.PageSize);
RefAsync<int> 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<FeedingRecordOutput>(records, pageQuery.PageIndex, pageQuery.PageSize, totalNumber);
}
}

View File

@ -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;
/// <summary>
/// 用户期刊关联服务实现
/// </summary>
public class UserJournalService(
BaseRepository<UserJournal> userJournalRepository,
BaseRepository<Users> usersRepository,
BaseRepository<Journal> journalRepository,
ILogger<UserJournalService> logger,
IPetService petService)
: BaseRepository<UserJournal>, IUserJournalService
{
/// <summary>
/// 用户绑定期刊(扫码绑定)
/// </summary>
public async Task<BindJournalOutput> 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<UserJournal>()
.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
};
}
/// <summary>
/// 获取用户的期刊绑定列表
/// </summary>
public async Task<PageListModel<BindJournalOutput>> 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<int> 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<BindJournalOutput>(pageResult, input.PageIndex, input.PageSize, totalNumber);
}
/// <summary>
/// 取消期刊绑定
/// </summary>
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);
}
}

View File

@ -15,27 +15,26 @@ namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// 微信小程序认证服务实现
/// </summary>
public class WeChatAuthService(BaseRepository<Users> usersRepository, IConfiguration configuration, ILogger<WeChatAuthService> logger) : BaseRepository<Users>, IWeChatAuthService
public class WeChatAuthService(BaseRepository<Users> usersRepository, IConfiguration configuration, ILogger<WeChatAuthService> logger, IPetService petService) : BaseRepository<Users>, 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}";
/// <summary>
/// 微信小程序一键登录
/// 微信小程序登录(首次创建用户,非首次直接登录)
/// </summary>
/// <param name="input">登录输入(含微信 code</param>
/// <returns>登录结果(含 Token 和该 OpenId 下的用户列表)</returns>
public async Task<WeChatLoginOutput> 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<Users> 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<Users> 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<Users>()
.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));
/// <summary>
/// 微信小程序快捷登录(通过 OpenId 直接登录,用户需已存在)
/// </summary>
public async Task<WeChatLoginOutput> 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<Users>()
.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);
}
/// <summary>
@ -193,6 +246,90 @@ public class WeChatAuthService(BaseRepository<Users> usersRepository, IConfigura
}
}
/// <summary>
/// 构建登录输出(生成 Token + 映射用户列表)
/// </summary>
private async Task<WeChatLoginOutput> BuildLoginOutputAsync(string openId, List<Users> 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
};
}
/// <summary>
/// 获取微信 access_token带 Redis 缓存)
/// </summary>
private async Task<string> 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<WxAccessTokenResponse>(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;
}
/// <summary>
/// 通过 phone_code 获取微信用户手机号
/// </summary>
private async Task<string?> GetPhoneNumberAsync(WeChatSettings settings, string phoneCode)
{
try
{
var accessToken = await GetAccessTokenAsync(settings);
var url = string.Format(GetPhoneNumberUrl, accessToken);
var response = await HttpHelper.PostAsync<WxPhoneNumberResponse>(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;
}
}
/// <summary>
/// 用户实体映射为输出 DTO
/// </summary>