Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.Service/WeChatAuthService.cs
glz 07614b5fe6 feat: 新增上传地址管理、答题评分及社区消息功能
1. 新增UploadDomain实体与上传地址分配逻辑,为用户分配可用上传域名
2. 新增绑定期刊消息推送,通过RabbitMQ传递绑定信息
3. 优化答题评分逻辑,新增完成度阈值判定与社区消息插入
4. 新增配置项用于评分阈值配置
5. 补充相关DTO与实体类字段,完善数据传输与存储
2026-07-01 14:50:37 +08:00

490 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using QYZH.InteractiveMagazine.Common.Helpers;
using QYZH.InteractiveMagazine.Infrastructure.Auth;
using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Models.Settings;
using QYZH.InteractiveMagazine.Models.WeChat;
using QYZH.InteractiveMagazine.Repository;
namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// 微信小程序认证服务实现
/// 登录基于 WxUser 表(微信身份),多用户管理基于 Users 表(角色/子用户)
/// </summary>
public class WeChatAuthService(
BaseRepository<WxUser> wxUserRepository,
BaseRepository<UploadDomain> uploadDomainRepository,
IConfiguration configuration,
ILogger<WeChatAuthService> logger,
IPetService petService)
: BaseRepository<WxUser>, IWeChatAuthService
{
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>
/// 微信小程序登录
/// 流程: code2session → 查找/创建 WxUser → 更新手机号 → 查询 Users 列表 → 生成 Token
/// </summary>
public async Task<WeChatLoginOutput> LoginAsync(WeChatLoginInput input)
{
logger.LogInformation("微信小程序登录");
if (string.IsNullOrWhiteSpace(input.Code))
throw new BusinessException("微信登录凭证 code 不能为空", ResultCode.BAD_REQUEST);
var weChatSettings = GetWeChatSettings();
// 1. 调用微信 code2session
var wxResponse = await CallCode2SessionAsync(weChatSettings, input.Code);
if (wxResponse == null || wxResponse.ErrCode != 0 || string.IsNullOrWhiteSpace(wxResponse.OpenId))
{
var errMsg = wxResponse?.ErrMsg ?? "未知错误";
logger.LogWarning("微信 code2session 接口调用失败errcode: {ErrCode}, errmsg: {ErrMsg}", wxResponse?.ErrCode, errMsg);
throw new BusinessException($"微信登录失败:{errMsg}", ResultCode.BAD_REQUEST);
}
logger.LogInformation("微信 code2session 成功OpenId: {OpenId}", wxResponse.OpenId);
// 2. 获取手机号(如果传入了 PhoneCode
string? phone = null;
if (!string.IsNullOrWhiteSpace(input.PhoneCode))
{
phone = await GetPhoneNumberAsync(weChatSettings, input.PhoneCode);
logger.LogInformation("获取手机号OpenId: {OpenId}, Phone: {Phone}", wxResponse.OpenId, phone ?? "null");
}
// 3. 查找或创建 WxUser
var wxUser = await wxUserRepository.Context.Queryable<WxUser>()
.Where(w => w.OpenId == wxResponse.OpenId && !w.IsDeleted)
.FirstAsync();
if (wxUser == null)
{
// 首次登录:仅创建 WxUser不自动创建 User用户需手动创建
wxUser = new WxUser
{
Name = $"wx_{wxResponse.OpenId[^8..]}",
AvatarUrl = string.Empty,
OpenId = wxResponse.OpenId,
UnionId = wxResponse.UnionId,
Phone = phone,
Status = 1,
IsDeleted = false,
CreatedBy = "WeChat",
CreatedAt = DateTime.Now,
UpdatedBy = "WeChat",
UpdatedAt = DateTime.Now
};
await wxUserRepository.InsertAsync(wxUser);
logger.LogInformation("WxUser 创建成功WxUserId: {WxUserId}, OpenId: {OpenId}", wxUser.Id, wxResponse.OpenId);
}
else
{
// 非首次登录:更新 UnionId 和手机号
if (!string.IsNullOrWhiteSpace(wxResponse.UnionId) && string.IsNullOrWhiteSpace(wxUser.UnionId))
{
await wxUserRepository.Context.Updateable<WxUser>()
.SetColumns(w => w.UnionId == wxResponse.UnionId)
.Where(w => w.Id == wxUser.Id)
.ExecuteCommandAsync();
wxUser.UnionId = wxResponse.UnionId;
}
if (!string.IsNullOrWhiteSpace(phone))
{
await wxUserRepository.Context.Updateable<WxUser>()
.SetColumns(w => w.Phone == phone)
.Where(w => w.Id == wxUser.Id)
.ExecuteCommandAsync();
wxUser.Phone = phone;
logger.LogInformation("更新 WxUser 手机号WxUserId: {WxUserId}, Phone: {Phone}", wxUser.Id, phone);
}
}
// 查询该 WxUser 下所有 Users首次登录时为空列表
var users = await wxUserRepository.Context.Queryable<Users>()
.Where(u => u.WxUserId == wxUser.Id && !u.IsDeleted)
.ToListAsync();
logger.LogInformation("微信登录成功WxUserId: {WxUserId} 下存在 {Count} 个用户", wxUser.Id, users.Count);
return await BuildLoginOutputAsync(wxUser, users);
}
/// <summary>
/// 微信小程序快捷登录(通过 OpenId 直接登录)
/// </summary>
public async Task<WeChatLoginOutput> QuickLoginAsync(WeChatQuickLoginInput input)
{
logger.LogInformation("微信快捷登录OpenId: {OpenId}", input.OpenId);
if (string.IsNullOrWhiteSpace(input.OpenId))
throw new BusinessException("OpenId 不能为空", ResultCode.BAD_REQUEST);
var wxUser = await wxUserRepository.Context.Queryable<WxUser>()
.Where(w => w.OpenId == input.OpenId && !w.IsDeleted)
.FirstAsync();
if (wxUser == null)
{
logger.LogWarning("快捷登录失败OpenId: {OpenId} 下无 WxUser", input.OpenId);
throw new BusinessException("未找到该微信账号关联的用户,请先完成注册", ResultCode.NOT_FOUND);
}
var users = await wxUserRepository.Context.Queryable<Users>()
.Where(u => u.WxUserId == wxUser.Id && !u.IsDeleted)
.ToListAsync();
logger.LogInformation("快捷登录成功WxUserId: {WxUserId}, {Count} 个用户", wxUser.Id, users.Count);
return await BuildLoginOutputAsync(wxUser, users);
}
/// <summary>
/// 切换用户(同一 WxUser 下切换 User 身份,重新生成 Token
/// </summary>
public async Task<WeChatSwitchUserOutput> SwitchUserAsync(long wxUserId, long currentUserId, WeChatSwitchUserInput input)
{
logger.LogInformation("切换用户WxUserId: {WxUserId}, 当前 UserId: {CurrentUserId}, 目标 UserId: {TargetUserId}",
wxUserId, currentUserId, input.UserId);
// 查询目标用户
var targetUser = await wxUserRepository.Context.Queryable<Users>()
.Where(u => u.Id == input.UserId && !u.IsDeleted)
.FirstAsync();
if (targetUser == null)
throw new BusinessException("目标用户不存在", ResultCode.NOT_FOUND);
// 校验目标用户属于同一 WxUser
if (targetUser.WxUserId != wxUserId)
{
logger.LogWarning("切换用户失败WxUserId 不匹配,当前: {Current}, 目标: {Target}", wxUserId, targetUser.WxUserId);
throw new BusinessException("无法切换到该用户", ResultCode.FORBIDDEN);
}
if (targetUser.Status == (int)UserStatusEnum.Disabled)
throw new BusinessException("目标账号已被禁用", ResultCode.FORBIDDEN);
// 更新 IsLastOnline清除所有设置目标为 true
await wxUserRepository.Context.Updateable<Users>()
.SetColumns(u => u.IsLastOnline == false)
.Where(u => u.WxUserId == wxUserId && !u.IsDeleted)
.ExecuteCommandAsync();
await wxUserRepository.Context.Updateable<Users>()
.SetColumns(u => u.IsLastOnline == true)
.Where(u => u.Id == input.UserId && !u.IsDeleted)
.ExecuteCommandAsync();
logger.LogInformation("IsLastOnline 已更新,目标用户 {UserId} 设为 true", input.UserId);
// 重新查询目标用户获取最新数据
var refreshedUser = await wxUserRepository.Context.Queryable<Users>()
.Where(u => u.Id == input.UserId && !u.IsDeleted)
.FirstAsync();
// 生成新 JWT TokenWxUserId + 新 UserId
var jwtSettings = GetJwtSettings();
var token = JwtHelper.GenerateToken(wxUserId, refreshedUser.Id, refreshedUser.Name ?? string.Empty, jwtSettings);
// 清除旧 Redis Token写入新 Token
await RedisHelper.DelAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, currentUserId));
await RedisHelper.SetAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, refreshedUser.Id), token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
return new WeChatSwitchUserOutput
{
Token = token,
User = MapUserToOutput(refreshedUser)
};
}
/// <summary>
/// 获取当前 WxUser 下所有用户列表
/// </summary>
public async Task<List<WxUserOutput>> GetUsersAsync(long wxUserId)
{
var users = await wxUserRepository.Context.Queryable<Users>()
.Where(u => u.WxUserId == wxUserId && !u.IsDeleted)
.OrderBy(u => u.IsLastOnline, SqlSugar.OrderByType.Desc)
.OrderBy(u => u.CreatedAt, SqlSugar.OrderByType.Desc)
.ToListAsync();
return users.Select(MapUserToOutput).ToList();
}
/// <summary>
/// 在当前 WxUser 下新增用户wxUserId 来自 JWT
/// </summary>
public async Task<WxUserOutput> CreateUserAsync(long wxUserId, CreateChildUserInput input)
{
logger.LogInformation("新增用户WxUserId: {WxUserId}, Name: {Name}", wxUserId, input.Name);
if (string.IsNullOrWhiteSpace(input.Name))
throw new BusinessException("昵称不能为空", ResultCode.BAD_REQUEST);
// 校验 WxUser 是否存在
var wxUser = await wxUserRepository.Context.Queryable<WxUser>()
.Where(w => w.Id == wxUserId)
.FirstAsync();
if (wxUser == null)
throw new BusinessException("微信用户不存在", ResultCode.NOT_FOUND);
// 创建新用户
var newUser = new Users
{
WxUserId = wxUserId,
Name = input.Name.Trim(),
AvatarUrl = input.AvatarUrl ?? string.Empty,
Type = UsersTypeEnum.Normal,
GrowthPoints = 0,
Points = 0,
IsLastOnline = false,
Status = (int)UserStatusEnum.Active,
IsDeleted = false,
CreatedBy = wxUserId.ToString(),
CreatedAt = DateTime.Now,
UpdatedBy = wxUserId.ToString(),
UpdatedAt = DateTime.Now
};
await UseTranAsync(async () =>
{
var uploadDomain = await uploadDomainRepository.Queryable()
.Where(d => d.Status == 1 && !d.IsDeleted)
.OrderBy(d => d.AssignedCount, SqlSugar.OrderByType.Asc)
.OrderBy(d => d.Id, SqlSugar.OrderByType.Asc)
.FirstAsync();
BusinessException.ThrowIf(uploadDomain == null, "暂无可用上传地址,请联系管理员", ResultCode.UNPROCESSABLE_ENTITY);
newUser.UploadDomain = uploadDomain!.Domain;
await wxUserRepository.Context.Insertable(newUser).ExecuteCommandAsync();
await uploadDomainRepository.Updateable()
.SetColumns(d => d.AssignedCount == d.AssignedCount + 1)
.SetColumns(d => d.UpdatedBy == wxUserId.ToString())
.SetColumns(d => d.UpdatedAt == DateTime.Now)
.Where(d => d.Id == uploadDomain.Id)
.ExecuteCommandAsync();
});
logger.LogInformation("新用户创建成功UserId: {UserId}, WxUserId: {WxUserId}, Name: {Name}",
newUser.Id, wxUserId, input.Name);
// 为新用户创建默认宠物
try { await petService.CreateDefaultPetAsync(newUser.Id); }
catch (Exception ex) { logger.LogError(ex, "新用户创建默认宠物失败UserId: {UserId}", newUser.Id); }
return MapUserToOutput(newUser);
}
/// <summary>
/// 修改家长名字WxUser.Name
/// </summary>
public async Task<WxUserInfoOutput> UpdateWxUserNameAsync(long wxUserId, UpdateWxUserNameInput input)
{
logger.LogInformation("修改家长名字WxUserId: {WxUserId}, NewName: {Name}", wxUserId, input.Name);
if (string.IsNullOrWhiteSpace(input.Name))
throw new BusinessException("名字不能为空", ResultCode.BAD_REQUEST);
var wxUser = await wxUserRepository.Context.Queryable<WxUser>()
.Where(w => w.Id == wxUserId && !w.IsDeleted)
.FirstAsync();
if (wxUser == null)
throw new BusinessException("微信用户不存在", ResultCode.NOT_FOUND);
await wxUserRepository.Context.Updateable<WxUser>()
.SetColumns(w => w.Name == input.Name.Trim())
.SetColumns(w => w.UpdatedAt == DateTime.Now)
.SetColumns(w => w.UpdatedBy == wxUserId.ToString())
.Where(w => w.Id == wxUserId)
.ExecuteCommandAsync();
wxUser.Name = input.Name.Trim();
logger.LogInformation("家长名字修改成功WxUserId: {WxUserId}, NewName: {Name}", wxUserId, input.Name);
return new WxUserInfoOutput
{
Id = wxUser.Id,
OpenId = wxUser.OpenId,
UnionId = wxUser.UnionId,
Name = wxUser.Name,
AvatarUrl = wxUser.AvatarUrl,
Phone = wxUser.Phone
};
}
#region
/// <summary>
/// 构建登录输出JWT 同时携带 WxUserId 和当前激活 UserId
/// </summary>
private async Task<WeChatLoginOutput> BuildLoginOutputAsync(WxUser wxUser, List<Users> users)
{
var activeUser = users.FirstOrDefault(u => u.IsLastOnline) ?? users.FirstOrDefault();
var userId = activeUser?.Id ?? 0L;
var userName = activeUser?.Name ?? wxUser.Name ?? wxUser.OpenId;
var jwtSettings = GetJwtSettings();
var token = JwtHelper.GenerateToken(wxUser.Id, userId, userName, jwtSettings);
await RedisHelper.SetAsync(JwtHelper.BuildWeChatTokenKey(wxUser.Id, userId), token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
return new WeChatLoginOutput
{
Token = token,
CurrentUserId = userId,
WxUser = new WxUserInfoOutput
{
Id = wxUser.Id,
OpenId = wxUser.OpenId,
UnionId = wxUser.UnionId,
Name = wxUser.Name,
AvatarUrl = wxUser.AvatarUrl,
Phone = wxUser.Phone
},
Users = users.Select(MapUserToOutput).ToList()
};
}
/// <summary>
/// 调用微信 code2session 接口
/// </summary>
private async Task<WxCode2SessionResponse?> CallCode2SessionAsync(WeChatSettings settings, string code)
{
var url = string.Format(Code2SessionUrl, settings.AppId, settings.AppSecret, code);
try
{
return await HttpHelper.GetAsync<WxCode2SessionResponse>(url);
}
catch (Exception ex)
{
logger.LogError(ex, "调用微信 code2session 接口异常URL: {Url}", url);
throw new BusinessException("微信服务请求失败,请稍后重试", ResultCode.GLOBAL_ERROR);
}
}
/// <summary>
/// 获取微信 access_token带 Redis 缓存)
/// </summary>
private async Task<string> GetAccessTokenAsync(WeChatSettings settings)
{
var cachedToken = await RedisHelper.GetAsync(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("微信服务请求失败,请稍后重试", ResultCode.GLOBAL_ERROR);
}
var expiresIn = response.ExpiresIn > 300 ? response.ExpiresIn - 300 : response.ExpiresIn;
await RedisHelper.SetAsync(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>
/// Users 实体映射为 WxUserOutput
/// </summary>
private static WxUserOutput MapUserToOutput(Users user)
{
return new WxUserOutput
{
Id = (long)user.Id,
WxUserId = user.WxUserId,
NickName = user.Name,
AvatarUrl = user.AvatarUrl,
Points = user.Points,
GrowthPoints = user.GrowthPoints,
Type = user.Type.ToString(),
Status = user.Status.ToString(),
IsLastOnline = user.IsLastOnline,
CreatedAt = user.CreatedAt,
UploadDomain = user.UploadDomain
};
}
private WeChatSettings GetWeChatSettings()
{
var settings = configuration.GetSection("WeChatSettings").Get<WeChatSettings>();
if (settings == null || string.IsNullOrWhiteSpace(settings.AppId) || string.IsNullOrWhiteSpace(settings.AppSecret))
{
logger.LogError("微信配置不完整,请检查 appsettings.json 中的 WeChatSettings 节点");
throw new BusinessException("微信配置不完整,请联系系统管理员", ResultCode.GLOBAL_ERROR);
}
return settings;
}
private JwtSettings GetJwtSettings()
{
var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>()
?? new JwtSettings
{
Issuer = "QYZH.InteractiveMagazine",
Audience = "QYZH.InteractiveMagazine",
SecretKey = "your-256-bit-secret-key-here-change-in-production",
ExpiryMinutes = 120
};
if (string.IsNullOrWhiteSpace(jwtSettings.SecretKey))
throw new BusinessException("JWT 配置不完整", ResultCode.GLOBAL_ERROR);
return jwtSettings;
}
#endregion
}