Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.Service/WeChatAuthService.cs
glz 018d7e1733 feat(wechat-auth): 实现微信同一OpenId下多用户切换功能
1. 新增用户实体字段IsLastOnline标记上次在线用户
2. 重构微信登录逻辑,返回同一OpenId下的所有用户列表而非单用户
3. 新增SwitchUserAsync服务方法与控制器接口,支持同OpenId下切换用户
4. 添加用户实体到输出DTO的映射工具方法
5. 实现切换用户时的身份校验、Token刷新与在线状态更新
2026-06-03 18:28:45 +08:00

257 lines
10 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.Infrastructure.Cache;
using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.IService.Dto;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Settings;
using QYZH.InteractiveMagazine.Repository;
namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// 微信小程序认证服务实现
/// </summary>
public class WeChatAuthService(BaseRepository<Users> usersRepository, IConfiguration configuration, ILogger<WeChatAuthService> logger) : BaseRepository<Users>, IWeChatAuthService
{
private const string TokenKeyPrefix = "InteractiveMagazine:WeChatAuth:Token";
private const string Code2SessionUrl = "https://api.weixin.qq.com/sns/jscode2session?appid={0}&secret={1}&js_code={2}&grant_type=authorization_code";
/// <summary>
/// 微信小程序一键登录
/// </summary>
/// <param name="input">登录输入(含微信 code</param>
/// <returns>登录结果(含 Token 和该 OpenId 下的用户列表)</returns>
public async Task<WeChatLoginOutput> LoginAsync(WeChatLoginInput input)
{
logger.LogInformation("微信小程序登录尝试");
// 参数校验
if (string.IsNullOrWhiteSpace(input.Code))
{
throw new BusinessException("微信登录凭证 code 不能为空", 400);
}
// 获取微信配置
var weChatSettings = GetWeChatSettings();
// 调用微信 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}", 400);
}
logger.LogInformation("微信 code2session 成功OpenId: {OpenId}", wxResponse.OpenId);
// 查询该 OpenId 下的所有用户
var users = await usersRepository.Context.Queryable<Users>()
.Where(u => u.OpenId == wxResponse.OpenId && !u.IsDeleted)
.ToListAsync();
if (users.Count == 0)
{
// 首次登录,创建新用户
var newUser = new Users
{
Name = $"wx_{wxResponse.OpenId[^8..]}",
OpenId = wxResponse.OpenId,
UnionId = wxResponse.UnionId,
Type = "Normal",
Status = "Active",
GrowthPoints = 0,
Points = 0
};
var insertResult = await usersRepository.Insertable(newUser).ExecuteReturnIdentityAsync();
if (insertResult <= 0)
{
logger.LogError("创建微信用户失败OpenId: {OpenId}", wxResponse.OpenId);
throw new BusinessException("创建用户失败,请稍后重试", 500);
}
newUser.Id = insertResult;
users.Add(newUser);
logger.LogInformation("微信新用户创建成功UserId: {UserId}, OpenId: {OpenId}", newUser.Id, wxResponse.OpenId);
}
else
{
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);
// 缓存 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 = wxResponse.OpenId,
Users = userOutputs
};
}
/// <summary>
/// 切换用户(同一 OpenId 下切换身份)
/// </summary>
/// <param name="currentUserId">当前登录用户ID</param>
/// <param name="input">切换用户输入</param>
/// <returns>切换结果(含新 Token 和目标用户详情)</returns>
public async Task<WeChatSwitchUserOutput> SwitchUserAsync(long currentUserId, WeChatSwitchUserInput input)
{
logger.LogInformation("切换用户当前用户ID: {CurrentUserId}, 目标用户ID: {TargetUserId}", currentUserId, input.UserId);
// 获取当前用户,验证身份并获取 OpenId
var currentUser = await usersRepository.GetByIdAsync(currentUserId);
if (currentUser == null)
{
throw new BusinessException("当前用户不存在", 404);
}
var openId = currentUser.OpenId;
// 查询目标用户
var targetUser = await usersRepository.GetByIdAsync(input.UserId);
if (targetUser == null)
{
throw new BusinessException("目标用户不存在", 404);
}
// 校验目标用户与当前用户属于同一 OpenId
if (targetUser.OpenId != openId)
{
logger.LogWarning("切换用户失败,目标用户 OpenId 不匹配,当前: {CurrentOpenId}, 目标: {TargetOpenId}", openId, targetUser.OpenId);
throw new BusinessException("无法切换到该用户", 403);
}
if (targetUser.Status == "Disabled")
{
throw new BusinessException("目标账号已被禁用", 403);
}
// 更新 IsLastOnline目标用户设为 true同 OpenId 下其他用户设为 false
await usersRepository.Context.Updateable<Users>()
.SetColumns(u => u.IsLastOnline == false)
.Where(u => u.OpenId == openId && !u.IsDeleted)
.ExecuteCommandAsync();
await usersRepository.Context.Updateable<Users>()
.SetColumns(u => u.IsLastOnline == true)
.Where(u => u.Id == input.UserId && !u.IsDeleted)
.ExecuteCommandAsync();
logger.LogInformation("IsLastOnline 已更新,目标用户 {UserId} 设为 true", input.UserId);
// TODO: 预留扩展逻辑 —— 加载用户详情、用户关联信息等
// var userDetail = await LoadUserDetailAsync(targetUser.Id);
// var userRelations = await LoadUserRelationsAsync(targetUser.Id);
// 重新查询目标用户以获取最新数据(含 IsLastOnline
var refreshedUser = await usersRepository.GetByIdAsync(input.UserId);
// 为目标用户生成新的 JWT Token
var jwtSettings = GetJwtSettings();
var token = JwtHelper.GenerateToken((long)refreshedUser.Id, refreshedUser.Name, jwtSettings);
// 清除旧用户 Token 缓存,写入新用户 Token 缓存
await RedisHelper.KeyDeleteAsync($"{TokenKeyPrefix}:{currentUserId}");
await RedisHelper.StringSetAsync($"{TokenKeyPrefix}:{refreshedUser.Id}", token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
return new WeChatSwitchUserOutput
{
Token = token,
User = MapUserToOutput(refreshedUser)
};
}
/// <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("微信服务请求失败,请稍后重试", 500);
}
}
/// <summary>
/// 用户实体映射为输出 DTO
/// </summary>
private static WxUserOutput MapUserToOutput(Users user)
{
return new WxUserOutput
{
Id = (long)user.Id,
OpenId = user.OpenId,
UnionId = user.UnionId,
NickName = user.Name,
AvatarUrl = user.AvatarUrl,
Phone = user.Phone,
Points = user.Points,
GrowthPoints = user.GrowthPoints,
Type = user.Type,
Status = user.Status,
IsLastOnline = user.IsLastOnline,
CreatedAt = user.CreatedAt,
UpdatedAt = user.UpdatedAt,
CreatedBy = user.CreatedBy,
UpdatedBy = user.UpdatedBy
};
}
/// <summary>
/// 获取微信配置
/// </summary>
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("微信配置不完整,请联系系统管理员", 500);
}
return settings;
}
/// <summary>
/// 获取 JWT 配置
/// </summary>
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 配置不完整", 500);
}
return jwtSettings;
}
}