feat: 新增签到、宠物、期刊绑定、补偿任务等业务模块,优化微信登录流程
本次提交完成了多个核心业务模块的开发与优化: 1. 宠物模块:新增宠物实体、服务接口与实现,支持创建默认宠物、激活、喂养、进化以及喂养记录查询 2. 签到模块:新增签到实体、服务接口、控制器以及相关DTO,支持用户签到和签到信息查询,新增成长值奖励字段 3. 期刊绑定模块:新增用户期刊关联实体、服务接口与控制器,支持扫码绑定期刊、解绑和查询绑定列表 4. 补偿任务模块:新增补偿任务实体、服务接口与实现,用于处理业务失败后的异步重试补偿 5. 优化微信登录流程:拆分登录与快捷登录接口,支持手机号获取,新增首次登录自动创建默认宠物逻辑 6. 调整基础路由与实体状态:修改微信控制器路由前缀,更新宠物状态枚举与默认值
This commit is contained in:
277
QYZH.InteractiveMagazine.Service/CheckInService.cs
Normal file
277
QYZH.InteractiveMagazine.Service/CheckInService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
173
QYZH.InteractiveMagazine.Service/CompensationTaskService.cs
Normal file
173
QYZH.InteractiveMagazine.Service/CompensationTaskService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
282
QYZH.InteractiveMagazine.Service/PetService.cs
Normal file
282
QYZH.InteractiveMagazine.Service/PetService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
208
QYZH.InteractiveMagazine.Service/UserJournalService.cs
Normal file
208
QYZH.InteractiveMagazine.Service/UserJournalService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user