package com.jeesharp.business.modules.member.application;

import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.binarywang.wx.miniapp.bean.WxMaUserInfo;
import cn.hutool.core.net.URLEncodeUtil;
import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.jeesharp.business.modules.member.api.MemberPointsApi;
import com.jeesharp.business.modules.member.dto.*;
import com.jeesharp.business.modules.member.entity.LoginResultVO;
import com.jeesharp.business.modules.member.vo.QrLoginTokenVO;
import com.jeesharp.business.modules.member.vo.QrLoginStatusVO;
import com.jeesharp.business.modules.member.entity.Member;
import com.jeesharp.business.modules.member.constants.MobileAuthConstants;
import com.jeesharp.business.modules.member.service.MemberOauthService;
import com.jeesharp.business.modules.member.service.MemberService;
import com.jeesharp.business.modules.member.entity.MemberCredential;
import com.jeesharp.business.modules.member.mapper.MemberCredentialMapper;
import com.jeesharp.business.modules.member.service.EmailCodeService;
import com.jeesharp.business.modules.member.service.SmsCodeService;
import com.jeesharp.business.wechat.WeChatService;
import com.jeesharp.framework.justauth.core.AuthRequestFactory;
import com.jeesharp.framework.memberauth.utils.MemberAuthUtils;
import com.jeesharp.framework.utils.PasswordUtils;
import com.jeesharp.framework.utils.RequestUtils;
import com.jeesharp.framework.utils.StringUtils;
import com.jeesharp.framework.message.api.MessageReceiver;
import com.jeesharp.framework.message.api.MessageSendFacade;
import com.jeesharp.framework.utils.base.ExceptionUtil;
import com.jeesharp.framework.utils.security.RSAUtils;
import cn.dev33.satoken.stp.StpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * 移动端认证应用服务：短信/邮箱验证码登录、密码注册与登录、JustAuth 第三方登录、一键登录、Token 刷新、
 * 绑定手机与改密、扫码登录等；会话使用 Sa-Token。
 *
 * @author jeesharp
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class MobileAuthApplication {

    private final SmsCodeService smsCodeService;
    private final EmailCodeService emailCodeService;
    private final MemberCredentialMapper memberCredentialMapper;
    private final StringRedisTemplate redisTemplate;
    private final MemberOauthService memberOauthService;
    private final WeChatService weChatService;
    private final MemberService memberService;
    private final AuthRequestFactory authRequestFactory;
    private final MemberPointsApi memberPointsApi;
    private final MessageSendFacade messageSendFacade;

    /**
     * 发送短信验证码。
     *
     * @param mobile 手机号
     * @param scene  业务场景（如登录、绑定），与短信服务约定一致
     * @return 验证码有效剩余秒数
     */
    public int sendSmsCode(String mobile, String scene) {
        return smsCodeService.sendCode(mobile, scene);
    }

    /**
     * 发送邮箱验证码。
     *
     * @param email 邮箱地址
     * @param scene 业务场景，与邮件验证码服务约定一致
     * @return 验证码有效剩余秒数
     */
    public int sendEmailCode(String email, String scene) {
        return emailCodeService.sendCode(email, scene);
    }
    /**
     * 手机号 + 短信验证码登录；若手机号未注册则自动注册。
     *
     * @param dto      手机号、验证码、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果（Token、会员摘要等）
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByMobile(LoginByMobileDTO dto, String deviceId) {
        if (!smsCodeService.verifyAndRemove(dto.getMobile(), MobileAuthConstants.SMS_SCENE_LOGIN, dto.getSmsCode())) {
            ExceptionUtil.error("验证码错误或已过期");
        }
        Member member = memberService.getByMobile(dto.getMobile());
        boolean isNewUser = (member == null);
        if (member == null) {
            member = memberService.createMemberByMobileForAuth(dto.getMobile(), dto.getInviteCode(),
                    MobileAuthConstants.MEMBER_SOURCE_APP);
        }
        if (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED) {
            ExceptionUtil.error("账号已被禁用");
        }
        LoginResultVO result = doLogin(member, isNewUser, false, MobileAuthConstants.MEMBER_SOURCE_APP);
        return result;
    }
    /**
     * 邮箱 + 邮箱验证码登录；若邮箱未注册则创建仅邮箱会员。
     *
     * @param dto      邮箱、邮箱验证码、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByEmail(LoginByEmailDTO dto, String deviceId) {
        if (!emailCodeService.verifyAndRemove(dto.getEmail(), MobileAuthConstants.SMS_SCENE_LOGIN, dto.getEmailCode())) {
            ExceptionUtil.error("验证码错误或已过期");
        }
        Member member = memberService.getByEmail(dto.getEmail());
        boolean isNewUser = (member == null);
        if (member == null) {
            member = memberService.createMemberByEmailForAuth(dto.getEmail(), dto.getInviteCode(),
                    MobileAuthConstants.MEMBER_SOURCE_APP);
        }
        if (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED) {
            ExceptionUtil.error("账号已被禁用");
        }
        LoginResultVO result = doLogin(member, isNewUser, false, "EMAIL");
        return result;
    }
    /**
     * 账号（当前支持手机号）+ 密码登录，密码需经 RSA 公钥加密后传输。
     *
     * @param dto      账号、密文密码
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByPassword(LoginByPasswordDTO dto, String deviceId) {
        String plainPassword = decryptPassword(dto.getPassword());
        Member member = memberService.findMemberByAccountForAuth(dto.getAccount());
        if (member == null) {
            ExceptionUtil.error("账号或密码错误");
        }
        if (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED) {
            ExceptionUtil.error("账号已被禁用");
        }
        MemberCredential credential = memberCredentialMapper.selectById(member.getId());
        if (credential == null || !PasswordUtils.validatePassword(plainPassword, credential.getPasswordHash())) {
            ExceptionUtil.error("账号或密码错误");
        }
        LoginResultVO result = doLogin(member, false, false, "PASSWORD");
        return result;
    }
    /**
     * 使用手机号 + 密码注册并自动登录（当前实现仅支持手机号账号）。
     *
     * @param dto      账号、密文密码、邀请码、来源平台等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO registerByPassword(RegisterByPasswordDTO dto, String deviceId) {
        String account = dto.getAccount();
        if (StrUtil.isBlank(account)) {
            ExceptionUtil.error("账号不能为空");
        }
        
        // 解密密码
        String plainPassword = decryptPassword(dto.getPassword());
        
        // 创建会员
        Member member = null;
        String platform = dto.getPlatform() != null ? dto.getPlatform() : MobileAuthConstants.MEMBER_SOURCE_APP;
        if (account.matches(MobileAuthConstants.PATTERN_CN_MOBILE)) {
            // 手机号注册
            if (!memberService.checkMobileUnique(account, null)) {
                ExceptionUtil.error("该手机号已被注册");
            }
            member = memberService.createMemberByMobileForAuth(account, dto.getInviteCode(), platform);
        } else {
            // 邮箱注册（暂不支持，可扩展）
            ExceptionUtil.error("暂不支持邮箱注册，请使用手机号");
        }

        // 保存密码凭证
        saveCredential(member.getId(), plainPassword);

        // 自动登录
        LoginResultVO result = doLogin(member, true, false, "PASSWORD");
        return result;
    }
    /**
     * 微信小程序登录：通过 JustAuth 换 openId/unionId，绑定仅写入 {@code app_member_oauth}，必要时创建会员。
     *
     * @param dto      微信登录 code
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果；未配置或异常时可能返回 null（内部已抛业务异常）
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByWechatMini(LoginByWechatMiniDTO dto, String deviceId) {
        AuthRequest authRequest = authRequestFactory.get(MobileAuthConstants.JUSTAUTH_WECHAT_MINI_APP);
        if (authRequest == null) {
            log.warn("微信小程序未配置：请在 jeesharp.justauth.type 中配置 WECHAT_MINI_APP（client-id、client-secret，通常对应 wechat.miniapp）");
            ExceptionUtil.error("微信小程序登录功能暂未配置");
            return null;
        }
        String oauthAppIdKey = StrUtil.blankToDefault(RequestUtils.getAppID(), "");

        try {
            AuthCallback authCallback = new AuthCallback();
            authCallback.setCode(URLEncodeUtil.encode(dto.getCode()));
            authCallback.setAuthorization_code(URLEncodeUtil.encode(dto.getCode()));
            @SuppressWarnings("unchecked")
            AuthResponse<AuthUser> response = (AuthResponse<AuthUser>) authRequest.login(authCallback);
            if (!response.ok()) {
                log.error("微信小程序登录失败: {}", response.getMsg());
                ExceptionUtil.error("微信登录失败: " + response.getMsg());
            }
            AuthUser authUser = response.getData();
            if (authUser == null || authUser.getToken() == null) {
                ExceptionUtil.error("获取微信用户信息失败");
            }
            String openId = authUser.getToken().getOpenId();
            String unionId = authUser.getToken().getUnionId();
            if (StrUtil.isBlank(openId)) {
                ExceptionUtil.error("获取微信用户信息失败");
            }

            Member member = memberOauthService.resolveMemberForWechatMini(oauthAppIdKey, openId, unionId);
            boolean isNewUser;

            if (member == null) {
                isNewUser = true;
                MemberSaveDTO saveDto = new MemberSaveDTO();
                saveDto.setSource(MobileAuthConstants.MEMBER_SOURCE_WECHAT_MINI);
                memberService.saveData(saveDto);
                member = memberService.get(saveDto.getId());
            } else {
                isNewUser = false;
            }

            memberOauthService.upsertWechatMiniBinding(member.getId(), oauthAppIdKey, openId, unionId);

            if (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED) {
                ExceptionUtil.error("账号已被禁用");
            }

            boolean needBindMobile = StrUtil.isBlank(member.getMobile());

            LoginResultVO result = doLogin(member, isNewUser, needBindMobile, MobileAuthConstants.MEMBER_SOURCE_WECHAT_MINI);
            
            return result;

        } catch (Exception e) {
            log.error("微信小程序登录异常", e);
            ExceptionUtil.error("微信登录失败，请稍后重试");
        }
        return null;
    }

    /**
     * 通过微信小程序 getPhoneNumber 的 phoneCode 获取手机号（不执行登录）。
     *
     * @param phoneCode button open-type=getPhoneNumber 回调中的动态令牌 code
     * @return 手机号
     */
    public String getPhoneByCode(String phoneCode) {
        String appid = StrUtil.blankToDefault(RequestUtils.getAppID(), "");
        WxMaService wxMaService = weChatService.getWxMaService();
        if (StrUtil.isNotBlank(appid) && !wxMaService.switchover(appid)) {
            log.warn("WxMa 未配置多小程序 appid={}，使用默认小程序配置", appid);
        }
        try {
            WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(phoneCode);
            if (phoneInfo == null) {
                ExceptionUtil.error("获取手机号失败");
            }
            String pureMobile = StrUtil.blankToDefault(phoneInfo.getPurePhoneNumber(), phoneInfo.getPhoneNumber());
            if (StrUtil.isBlank(pureMobile) || !pureMobile.matches(MobileAuthConstants.PATTERN_CN_MOBILE)) {
                ExceptionUtil.error("手机号格式无效");
            }
            return pureMobile;
        } catch (WxErrorException e) {
            log.error("获取微信手机号失败", e);
            ExceptionUtil.error("获取手机号失败，请重试");
            throw new IllegalStateException(e);
        }
    }

    /**
     * 微信小程序手机号一键登录：{@code wx.login} 的 code 换 session；{@code phoneCode} 换手机号；
     * 若传入 {@code signature}/{@code rawData} 则校验用户数据完整性；若传入 {@code encryptedData}/{@code iv} 或与 rawData 组合则解析昵称头像等并写入会员。
     *
     * @param dto      登录 code、手机号 code、可选用户信息字段、邀请码
     * @param deviceId 设备 ID（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByWechatMiniPhone(LoginByWechatMiniPhoneDTO dto, String deviceId) {
        String appid = StrUtil.blankToDefault(RequestUtils.getAppID(), "");
        WxMaService wxMaService = weChatService.getWxMaService();
        if (StrUtil.isNotBlank(appid) && !wxMaService.switchover(appid)) {
            log.warn("WxMa 未配置多小程序 appid={}，使用默认小程序配置", appid);
        }

        WxMaJscode2SessionResult session;
        WxMaUserInfo userInfo = null;
        String openId;
        String unionId;
        String pureMobile;

        try {
            session = wxMaService.getUserService().getSessionInfo(URLEncodeUtil.encode(dto.getCode()));
            if (session == null || StrUtil.isBlank(session.getOpenid())) {
                ExceptionUtil.error("微信登录失败，请重试");
            }
            openId = session.getOpenid();
            unionId = session.getUnionid();
            String sessionKey = session.getSessionKey();

            if (StrUtil.isNotBlank(dto.getRawData()) && StrUtil.isNotBlank(dto.getSignature())) {
                if (!wxMaService.getUserService().checkUserInfo(sessionKey, dto.getRawData(), dto.getSignature())) {
                    ExceptionUtil.error("用户信息校验失败，请重新授权");
                }
            }

            if (StrUtil.isNotBlank(dto.getEncryptedData()) && StrUtil.isNotBlank(dto.getIv())) {
                userInfo = wxMaService.getUserService().getUserInfo(sessionKey, dto.getEncryptedData(), dto.getIv());
            } else if (StrUtil.isNotBlank(dto.getRawData())) {
                try {
                    userInfo = WxMaUserInfo.fromJson(dto.getRawData());
                } catch (Exception parseEx) {
                    log.warn("微信小程序 rawData 解析用户信息失败，将仅使用手机登录: {}", parseEx.getMessage());
                }
            }

            WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(dto.getPhoneCode());
            if (phoneInfo == null) {
                ExceptionUtil.error("获取手机号失败");
            }
            pureMobile = StrUtil.blankToDefault(phoneInfo.getPurePhoneNumber(), phoneInfo.getPhoneNumber());
        } catch (WxErrorException e) {
            log.error("微信小程序一键登录失败", e);
            ExceptionUtil.error("微信登录失败，请重试");
            throw new IllegalStateException(e);
        }

        if (StrUtil.isBlank(pureMobile) || !pureMobile.matches(MobileAuthConstants.PATTERN_CN_MOBILE)) {
            ExceptionUtil.error("手机号格式无效");
        }

        Member memberByMobile = memberService.getByMobile(pureMobile);
        Member memberByOauth = memberOauthService.resolveMemberForWechatMini(appid, openId, unionId);
        if (memberByMobile != null && memberByOauth != null && !memberByMobile.getId().equals(memberByOauth.getId())) {
            ExceptionUtil.error("该手机号与其它微信账号冲突，请联系客服");
        }

        Member member;
        boolean isNewUser;
        if (memberByMobile != null) {
            member = memberByMobile;
            isNewUser = false;
            memberOauthService.upsertWechatMiniBinding(member.getId(), appid, openId, unionId);
        } else if (memberByOauth != null) {
            member = memberByOauth;
            isNewUser = false;
            if (StrUtil.isNotBlank(member.getMobile()) && !pureMobile.equals(member.getMobile())) {
                ExceptionUtil.error("当前微信已绑定其它手机号");
            }
            if (StrUtil.isBlank(member.getMobile()) || !pureMobile.equals(member.getMobile())) {
                if (!memberService.checkMobileUnique(pureMobile, member.getId())) {
                    ExceptionUtil.error("该手机号已被占用");
                }
                member.setMobile(pureMobile);
                memberService.update(member);
            }
            memberOauthService.upsertWechatMiniBinding(member.getId(), appid, openId, unionId);
        } else {
            member = memberService.createMemberByMobileForAuth(pureMobile, dto.getInviteCode(),
                    MobileAuthConstants.MEMBER_SOURCE_WECHAT_MINI);
            isNewUser = true;
            memberOauthService.upsertWechatMiniBinding(member.getId(), appid, openId, unionId);
        }

        applyWechatMiniUserProfileFromWx(member, userInfo);

        if (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED) {
            ExceptionUtil.error("账号已被禁用");
        }
        LoginResultVO result = doLogin(member, isNewUser, false, MobileAuthConstants.MEMBER_SOURCE_WECHAT_MINI);
        try {
            messageSendFacade.send(
                    MobileAuthConstants.MESSAGE_SEND_CONFIG_APP_LOGIN,
                    List.of(MessageReceiver.member(member.getId())),
                    Map.of("loginDate", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)),
                    "wechat_mini_login:" + member.getId()
            );
        } catch (Exception e) {
            log.warn("微信小程序登录消息发送失败: memberId={}", member.getId(), e);
        }
        return result;
    }

    /**
     * 微信 App（移动应用）授权码登录。
     *
     * @param dto      授权 code、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByWechatApp(LoginByWechatDTO dto, String deviceId) {
        LoginResultVO result = handleThirdPartyLogin(dto.getCode(), MobileAuthConstants.JUSTAUTH_WECHAT_APP, dto.getInviteCode());
        return result;
    }
    /**
     * 微信公众号授权码登录。
     *
     * @param dto      授权 code、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByWechatMp(LoginByWechatDTO dto, String deviceId) {
        LoginResultVO result = handleThirdPartyLogin(dto.getCode(), MobileAuthConstants.JUSTAUTH_WECHAT_MP, dto.getInviteCode());
        return result;
    }
    /**
     * 支付宝授权登录。
     *
     * @param dto      授权码、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByAlipay(LoginByAlipayDTO dto, String deviceId) {
        LoginResultVO result = handleThirdPartyLogin(dto.getAuthCode(), MobileAuthConstants.JUSTAUTH_ALIPAY, dto.getInviteCode());
        return result;
    }
    /**
     * Apple 登录（待对接 Apple 官方 identityToken 校验，当前仅占位）。
     *
     * @param dto      Apple 身份令牌等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 不会成功返回；内部抛业务异常
     */
    public LoginResultVO loginByApple(LoginByAppleDTO dto, String deviceId) {
        // 苹果登录需要使用 Apple 官方 API，JustAuth 暂不支持
        // 这里需要实现 Apple ID Token 验证逻辑
        log.warn("苹果登录待对接 Apple 官方 API，identityToken={}", dto.getIdentityToken());
        ExceptionUtil.error("苹果登录功能暂未实现，请使用其他登录方式");
        return null;
    }
    /**
     * 华为账号（HUAWEI_APP）授权登录。
     *
     * @param dto      授权码、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByHuawei(LoginByThirdPartyDTO dto, String deviceId) {
        LoginResultVO result = handleThirdPartyLogin(dto.getAuthCode(), MobileAuthConstants.JUSTAUTH_HUAWEI_APP, dto.getInviteCode());
        return result;
    }

    /**
     * 华为原子化服务等场景授权登录（HUAWEI_ATOMIC）。
     *
     * @param dto      授权码、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByHuaweiAtomic(LoginByThirdPartyDTO dto, String deviceId) {
        LoginResultVO result = handleThirdPartyLogin(dto.getAuthCode(), MobileAuthConstants.JUSTAUTH_HUAWEI_ATOMIC, dto.getInviteCode());
        return result;
    }
    /**
     * QQ 授权登录。
     *
     * @param dto      授权码、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByQQ(LoginByThirdPartyDTO dto, String deviceId) {
        LoginResultVO result = handleThirdPartyLogin(dto.getAuthCode(), MobileAuthConstants.JUSTAUTH_QQ, dto.getInviteCode());
        return result;
    }
    /**
     * 微博授权登录。
     *
     * @param dto      授权码、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByWeibo(LoginByThirdPartyDTO dto, String deviceId) {
        LoginResultVO result = handleThirdPartyLogin(dto.getAuthCode(), MobileAuthConstants.JUSTAUTH_WEIBO, dto.getInviteCode());
        return result;
    }
    /**
     * 抖音授权登录。
     *
     * @param dto      授权码、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByDouyin(LoginByThirdPartyDTO dto, String deviceId) {
        LoginResultVO result = handleThirdPartyLogin(dto.getAuthCode(), MobileAuthConstants.JUSTAUTH_DOUYIN, dto.getInviteCode());
        return result;
    }

    /**
     * 运营商一键登录：通过运营商返回的 token 解析手机号再登录。
     * <p>生产环境需对接移动/联通/电信 SDK；当前仅支持测试场景（token 为合法手机号格式时直接使用）。</p>
     *
     * @param dto      运营商 token、运营商类型、邀请码等
     * @param deviceId 请求头 X-Device-Id（可选）
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    public LoginResultVO loginByOneClick(LoginByOneClickDTO dto, String deviceId) {

        String token = dto.getToken();
        String operator = dto.getOperator();
        
        if (StrUtil.isBlank(token) || StrUtil.isBlank(operator)) {
            ExceptionUtil.error("token和运营商不能为空");
        }
        
        // TODO: 对接运营商 SDK 验证 token 并获取手机号

        String mobile = null;
        try {
            // 这里应该调用运营商 API 获取手机号
            // 示例代码（需要替换为真实的运营商 SDK 调用）：
            // mobile = getMobileFromOperator(token, operator);
            
            // 临时处理：如果 token 看起来像手机号（用于测试），直接使用
            // 生产环境必须删除此逻辑，使用真实的运营商 API
            if (token.matches(MobileAuthConstants.PATTERN_CN_MOBILE)) {
                // 测试环境：如果 token 是手机号格式，直接使用
                log.warn("一键登录使用测试模式，token 被当作手机号使用");
                mobile = token;
            } else {
                // 生产环境：调用运营商 API
                ExceptionUtil.error("一键登录功能需要对接运营商 SDK，请联系管理员配置");
            }
        } catch (Exception e) {
            log.error("一键登录获取手机号失败: operator={}", operator, e);
            ExceptionUtil.error("获取手机号失败，请稍后重试");
        }
        if (StrUtil.isBlank(mobile)) {
            ExceptionUtil.error("无法获取手机号，请检查 token 是否有效");
        }
        // 验证手机号格式
        if (!mobile.matches(MobileAuthConstants.PATTERN_CN_MOBILE)) {
            ExceptionUtil.error("手机号格式不正确");
        }
        // 查找或创建会员（复用手机验证码登录的逻辑）
        Member member = memberService.getByMobile(mobile);
        boolean isNewUser = (member == null);
        if (member == null) {
            // 创建新会员
            member = memberService.createMemberByMobileForAuth(mobile, dto.getInviteCode(),
                    MobileAuthConstants.MEMBER_SOURCE_ONE_CLICK);
        }
        if (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED) {
            ExceptionUtil.error("账号已被禁用");
        }
        // 一键登录不需要绑定手机号（因为已经通过运营商验证）
        LoginResultVO result = doLogin(member, isNewUser, false, MobileAuthConstants.MEMBER_SOURCE_ONE_CLICK);
        return result;
    }
    /**
     * 使用刷新令牌换新访问令牌（当前实现：校验旧 token 后登出再签发新会话）。
     *
     * @param refreshToken 客户端持有的刷新令牌（与 Sa-Token 配置一致）
     * @return 新的登录结果；失败时返回 null 或抛业务异常
     */
    public LoginResultVO refreshToken(String refreshToken) {
        if (StrUtil.isBlank(refreshToken)) {
            ExceptionUtil.error("刷新令牌不能为空");
        }
        try {
            Object loginId = StpUtil.getLoginIdByToken(refreshToken);
            if (loginId == null) {
                ExceptionUtil.error("刷新令牌已失效，请重新登录");
            }
            String memberId = loginId.toString();
            Member member = memberService.get(memberId);
            if (member == null || (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED)) {
                ExceptionUtil.error("账号不存在或已禁用");
            }
            StpUtil.logoutByTokenValue(refreshToken);
            return doLogin(member, false, false, null, false);
        } catch (Exception e) {
            log.debug("刷新Token失败: {}", e.getMessage());
            ExceptionUtil.error("刷新令牌已失效，请重新登录");
        }
        return null;
    }
    /**
     * 当前会话退出登录（Sa-Token）。
     */
    public void logout() {
        try {
            if (StpUtil.isLogin()) {
                StpUtil.logout();
            }
        } catch (Exception e) {
            log.debug("退出登录: {}", e.getMessage());
        }
    }
    /**
     * 注销当前账号：将会员状态置为已注销并退出登录。
     *
     * @param dto 预留参数（可后续增加短信校验等）
     */
    @Transactional(rollbackFor = Exception.class)
    public void cancelAccount(CancelAccountDTO dto) {
        String memberId = MemberAuthUtils.getLoginId();
        if (StrUtil.isBlank(memberId)) {
            ExceptionUtil.error("请先登录");
        }
        // TODO: 可增加短信验证码校验 3-已注销
        memberService.updateStatus(memberId, MobileAuthConstants.MEMBER_STATUS_CANCELLED);
        StpUtil.logout();
    }
    /**
     * 为当前登录会员绑定手机号（需短信验证码，场景为绑定）。
     *
     * @param dto 手机号、短信验证码
     */
    @Transactional(rollbackFor = Exception.class)
    public void bindMobile(BindMobileDTO dto) {
        String memberId = MemberAuthUtils.getLoginId();
        if (StrUtil.isBlank(memberId)) {
            ExceptionUtil.error("请先登录");
        }
        if (!smsCodeService.verifyAndRemove(dto.getMobile(), MobileAuthConstants.SMS_SCENE_BIND, dto.getSmsCode())) {
            ExceptionUtil.error("验证码错误或已过期");
        }
        Member member = memberService.get(memberId);
        if (member == null) {
            ExceptionUtil.error("会员不存在");
        }
        if (!memberService.checkMobileUnique(dto.getMobile(), memberId)) {
            ExceptionUtil.error("该手机号已被其他账号绑定");
        }
        member.setMobile(dto.getMobile());
        memberService.update(member);
    }
    /**
     * 修改登录密码（需校验旧密码，新密码为 RSA 密文）。
     *
     * @param dto 旧密码、新密码（密文）
     */
    @Transactional(rollbackFor = Exception.class)
    public void changePassword(ChangePasswordDTO dto) {
        String memberId = MemberAuthUtils.getLoginId();
        if (StrUtil.isBlank(memberId)) {
            ExceptionUtil.error("请先登录");
        }
        String oldPlain = decryptPassword(dto.getOldPassword());
        String newPlain = decryptPassword(dto.getNewPassword());
        MemberCredential credential = memberCredentialMapper.selectById(memberId);
        if (credential == null || !PasswordUtils.validatePassword(oldPlain, credential.getPasswordHash())) {
            ExceptionUtil.error("旧密码错误");
        }
        saveCredential(memberId, newPlain);
    }
    /**
     * 通过手机号短信验证码重置密码。
     *
     * @param dto 手机号、短信验证码、新密码（密文）
     */
    @Transactional(rollbackFor = Exception.class)
    public void resetPassword(ResetPasswordDTO dto) {
        if (!smsCodeService.verifyAndRemove(dto.getMobile(), MobileAuthConstants.SMS_SCENE_RESET_PASSWORD, dto.getSmsCode())) {
            ExceptionUtil.error("验证码错误或已过期");
        }
        Member member = memberService.getByMobile(dto.getMobile());
        if (member == null) {
            ExceptionUtil.error("该手机号未注册");
        }
        String newPlain = decryptPassword(dto.getNewPassword());
        saveCredential(member.getId(), newPlain);
    }
    /**
     * 获取用于密码加密的 RSA 公钥（客户端加密后再调用登录/注册接口）。
     *
     * @return PEM 或框架约定格式的公钥字符串；失败时可能返回空串
     */
    public String getPublicKey() {
        try {
            String key = RSAUtils.getPublicKey();
            return key != null ? key : "";
        } catch (Exception e) {
            log.warn("获取RSA公钥失败", e);
            return "";
        }
    }

    // ==================== 私有方法 ====================

    /**
     * 将微信小程序解密/下发的用户信息合并到会员（昵称、头像、性别、省市）。
     */
    private void applyWechatMiniUserProfileFromWx(Member member, WxMaUserInfo wxUser) {
        if (wxUser == null || member == null || StrUtil.isBlank(member.getId())) {
            return;
        }
        boolean changed = false;
        if (StrUtil.isNotBlank(wxUser.getNickName())) {
            member.setNickname(wxUser.getNickName());
            changed = true;
        }
        if (StrUtil.isNotBlank(wxUser.getAvatarUrl())) {
            member.setAvatar(wxUser.getAvatarUrl());
            changed = true;
        }
        Integer gender = parseWechatMiniGender(wxUser.getGender());
        if (gender != null) {
            member.setGender(gender);
            changed = true;
        }
        if (StrUtil.isNotBlank(wxUser.getProvince())) {
            member.setProvince(wxUser.getProvince());
            changed = true;
        }
        if (StrUtil.isNotBlank(wxUser.getCity())) {
            member.setCity(wxUser.getCity());
            changed = true;
        }
        if (changed) {
            memberService.update(member);
        }
    }

    /**
     * 微信侧 gender 为字符串（常见为 0/1/2），映射为会员表整型性别。
     */
    private static Integer parseWechatMiniGender(String gender) {
        if (StrUtil.isBlank(gender)) {
            return null;
        }
        try {
            int v = Integer.parseInt(gender.trim());
            if (v >= 0 && v <= 2) {
                return v;
            }
        } catch (NumberFormatException ignored) {
            // 非数字则忽略
        }
        return null;
    }

    /**
     * 为会员建立 Sa-Token 会话并组装登录返回体（含脱敏手机号）。
     *
     * @param member         会员实体
     * @param isNewUser      是否新注册用户
     * @param needBindMobile 是否需要引导绑定手机号
     * @param loginType      登录方式，写入会员最后登录方式
     * @return 登录结果 VO
     */
    private LoginResultVO doLogin(Member member, boolean isNewUser, boolean needBindMobile, String loginType) {
        return doLogin(member, isNewUser, needBindMobile, loginType, true);
    }

    /**
     * 为会员建立 Sa-Token 会话并组装登录返回体（含脱敏手机号）。
     *
     * @param recordLogin 是否回写最后登录时间、IP 与登录次数（刷新 Token 时不记录）
     */
    private LoginResultVO doLogin(Member member, boolean isNewUser, boolean needBindMobile, String loginType,
                                  boolean recordLogin) {
        if (recordLogin) {
            memberService.recordLoginSuccess(member.getId(), loginType);
        }
        if (isNewUser) {
            grantRegisterPoints(member);
        }
        StpUtil.login(member.getId());
        String token = StpUtil.getTokenValue();
        LoginResultVO vo = new LoginResultVO();
        vo.setAccessToken(token);
        vo.setTokenType(MobileAuthConstants.TOKEN_TYPE_BEARER);
        vo.setExpiresIn(MobileAuthConstants.TOKEN_EXPIRE_SECONDS);
        vo.setRefreshToken(token); // Sa-Token 可配置独立 refreshToken，此处简化
        vo.setIsNewUser(isNewUser);
        vo.setNeedBindMobile(needBindMobile);
        vo.setMemberId(member.getId());
        vo.setMemberNo(member.getMemberNo());
        vo.setNickname(member.getNickname());
        vo.setRealName(member.getRealName());
        vo.setAvatar(member.getAvatar());
        vo.setMobile(DesensitizedUtil.mobilePhone(member.getMobile()));
        vo.setLevelId(member.getLevelId());
        vo.setLevelCode(member.getLevelCode());
        vo.setGrowthValue(member.getGrowthValue());
        vo.setLevelExpireDate(member.getLevelExpireDate());
        vo.setIsVip(member.getIsVip());
        vo.setVipLevelCode(member.getVipLevelCode());
        vo.setVipExpireDate(member.getVipExpireDate());
        return vo;
    }

    /**
     * 新会员注册积分（失败仅记日志，不影响登录主流程）。
     */
    private void grantRegisterPoints(Member member) {
        if (member == null || StrUtil.isBlank(member.getId())) {
            return;
        }
        try {
            memberPointsApi.grantRegisterPoints(member.getId());
        } catch (Exception e) {
            log.warn("注册赠送积分失败: memberId={}", member.getId(), e);
        }
    }

    /**
     * 使用服务端 RSA 私钥解密密文密码。
     *
     * @param encrypted 前端使用公钥加密后的密文
     * @return 明文密码
     */
    private String decryptPassword(String encrypted) {
        if (StrUtil.isBlank(encrypted)) {
            ExceptionUtil.error("密码不能为空");
        }
        try {
            return RSAUtils.decryptByPrivateKey(encrypted);
        } catch (Exception e) {
            log.warn("密码解密失败", e);
            ExceptionUtil.error("密码格式错误，请使用公钥加密后传输");
        }
        return null;
    }

    /**
     * 保存或更新会员密码哈希。
     *
     * @param memberId      会员 ID
     * @param plainPassword 明文密码
     */
    private void saveCredential(String memberId, String plainPassword) {
        String hash = PasswordUtils.encryptPassword(plainPassword);
        MemberCredential credential = memberCredentialMapper.selectById(memberId);
        if (credential == null) {
            credential = new MemberCredential();
            credential.setMemberId(memberId);
            credential.setPasswordHash(hash);
            memberCredentialMapper.insert(credential);
        } else {
            credential.setPasswordHash(hash);
            memberCredentialMapper.updateById(credential);
        }
    }

    /**
     * JustAuth 通用第三方登录：用授权码换用户信息，再查找或创建会员并登录。
     *
     * @param code       第三方授权码
     * @param source     JustAuth 工厂中的类型键，见 {@link MobileAuthConstants}
     * @param inviteCode 邀请码，可空
     * @return 登录结果
     */
    @Transactional(rollbackFor = Exception.class)
    protected LoginResultVO handleThirdPartyLogin(String code, String source, String inviteCode) {
        AuthRequest authRequest = null;
        if (StringUtils.isNotBlank(RequestUtils.getAppID())) {
            authRequest = authRequestFactory.get(source, RequestUtils.getAppID());
        } else {
            authRequest = authRequestFactory.get(source);
        }
        if (authRequest == null) {
            ExceptionUtil.error("第三方登录功能暂未配置");
        }
        try {
            // 构造 AuthCallback 对象
            AuthCallback authCallback = new AuthCallback();
            authCallback.setCode(URLEncodeUtil.encode(code));
            authCallback.setAuthorization_code(URLEncodeUtil.encode(code));
            AuthResponse<AuthUser> response = (AuthResponse<AuthUser>) authRequest.login(authCallback);
            if (!response.ok()) {
                log.error("第三方登录失败: {}", response.getMsg());
                ExceptionUtil.error("第三方登录失败: " + response.getMsg());
            }
            AuthUser authUser = response.getData();
            if (authUser == null) {
                ExceptionUtil.error("获取第三方用户信息失败");
            }
            String unionId = authUser.getToken().getUnionId();
            String openId = authUser.getToken().getOpenId();
            String nickname = authUser.getNickname();
            String avatar = authUser.getAvatar();
            String oauthAppId = StrUtil.blankToDefault(RequestUtils.getAppID(), "");

            MemberThirdPartyLoginOutcome outcome = memberService.findOrCreateMemberForThirdParty(
                    source, inviteCode, unionId, openId, nickname, avatar, oauthAppId);
            Member member = outcome.getMember();
            boolean isNewUser = outcome.isNewlyCreated();
            if (isNewUser
                    && (MobileAuthConstants.JUSTAUTH_HUAWEI_APP.equals(source)
                    || MobileAuthConstants.JUSTAUTH_HUAWEI.equals(source))) {
                try {
                    log.info("华为登录新用户，已创建默认卡组和卡片: memberId={}", member.getId());
                } catch (Exception e) {
                    log.error("华为登录新用户，创建默认卡组和卡片失败: memberId={}", member.getId(), e);
                }
            }
            if (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED) {
                ExceptionUtil.error("账号已被禁用");
            }
            boolean needBindMobile = StrUtil.isBlank(member.getMobile());
            return doLogin(member, isNewUser, needBindMobile, source);
        } catch (Exception e) {
            log.error("第三方登录异常: source={}", source, e);
            ExceptionUtil.error("第三方登录失败，请稍后重试");
        }
        return null;
    }

    // ==================== 二维码登录 ====================

    /**
     * 生成 PC/Web 端展示用的二维码登录令牌，并在 Redis 中写入初始状态（pending）及过期时间。
     *
     * @return 二维码 token 字符串及过期秒数
     */
    public QrLoginTokenVO generateQrLoginToken() {
        // 生成唯一的Token
        String token = MobileAuthConstants.QR_TOKEN_ID_PREFIX + System.currentTimeMillis()
                + MobileAuthConstants.QR_TOKEN_PART_SEPARATOR
                + RandomUtil.randomString(MobileAuthConstants.QR_TOKEN_RANDOM_LENGTH);

        // 存储到Redis，状态为 pending，过期时间10分钟
        String key = MobileAuthConstants.QR_LOGIN_REDIS_PREFIX + token;
        redisTemplate.opsForValue().set(key, MobileAuthConstants.QR_STATUS_PENDING,
                MobileAuthConstants.QR_TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);
        
        log.info("生成二维码登录Token: token={}, expireSeconds={}", token, MobileAuthConstants.QR_TOKEN_EXPIRE_SECONDS);
        
        QrLoginTokenVO vo = new QrLoginTokenVO();
        vo.setToken(token);
        vo.setExpireTime(MobileAuthConstants.QR_TOKEN_EXPIRE_SECONDS);
        return vo;
    }

    /**
     * 轮询二维码登录状态：pending / scanned / confirmed；确认后返回临时写入的登录信息并清理 Redis。
     *
     * @param token {@link #generateQrLoginToken()} 返回的 token
     * @return 当前状态，若已确认则包含 accessToken 与会员摘要
     */
    public QrLoginStatusVO checkQrLoginStatus(String token) {
        if (StrUtil.isBlank(token)) {
            ExceptionUtil.error("二维码Token不能为空");
        }
        
        String key = MobileAuthConstants.QR_LOGIN_REDIS_PREFIX + token;
        String status = redisTemplate.opsForValue().get(key);
        
        QrLoginStatusVO vo = new QrLoginStatusVO();
        
        if (StrUtil.isBlank(status)) {
            vo.setStatus(MobileAuthConstants.QR_STATUS_EXPIRED);
            return vo;
        }

        if (MobileAuthConstants.QR_STATUS_CONFIRMED.equals(status)) {
            // 已确认登录，获取登录信息
            String loginInfoKey = MobileAuthConstants.QR_LOGIN_REDIS_PREFIX + token
                    + MobileAuthConstants.QR_LOGIN_INFO_KEY_SUFFIX;
            String loginInfoJson = redisTemplate.opsForValue().get(loginInfoKey);
            
            if (StrUtil.isNotBlank(loginInfoJson)) {
                try {
                    JSONObject loginInfo = JSONUtil.parseObj(loginInfoJson);
                    vo.setStatus(MobileAuthConstants.QR_STATUS_CONFIRMED);
                    vo.setAccessToken(loginInfo.getStr(MobileAuthConstants.QR_JSON_ACCESS_TOKEN));
                    vo.setRefreshToken(loginInfo.getStr(MobileAuthConstants.QR_JSON_REFRESH_TOKEN));

                    // 构建会员信息
                    LoginResultVO memberInfo = new LoginResultVO();
                    memberInfo.setAccessToken(loginInfo.getStr(MobileAuthConstants.QR_JSON_ACCESS_TOKEN));
                    memberInfo.setTokenType(loginInfo.getStr(MobileAuthConstants.QR_JSON_TOKEN_TYPE,
                            MobileAuthConstants.TOKEN_TYPE_BEARER));
                    memberInfo.setExpiresIn(loginInfo.getLong(MobileAuthConstants.QR_JSON_EXPIRES_IN));
                    memberInfo.setRefreshToken(loginInfo.getStr(MobileAuthConstants.QR_JSON_REFRESH_TOKEN));
                    memberInfo.setIsNewUser(loginInfo.getBool(MobileAuthConstants.QR_JSON_IS_NEW_USER));
                    memberInfo.setNeedBindMobile(loginInfo.getBool(MobileAuthConstants.QR_JSON_NEED_BIND_MOBILE));
                    memberInfo.setMemberId(loginInfo.getStr(MobileAuthConstants.QR_JSON_MEMBER_ID));
                    memberInfo.setMemberNo(loginInfo.getStr(MobileAuthConstants.QR_JSON_MEMBER_NO));
                    memberInfo.setNickname(loginInfo.getStr(MobileAuthConstants.QR_JSON_NICKNAME));
                    memberInfo.setAvatar(loginInfo.getStr(MobileAuthConstants.QR_JSON_AVATAR));
                    memberInfo.setMobile(loginInfo.getStr(MobileAuthConstants.QR_JSON_MOBILE));
                    memberInfo.setLevelId(loginInfo.getStr(MobileAuthConstants.QR_JSON_LEVEL_ID));
                    memberInfo.setLevelCode(loginInfo.getStr(MobileAuthConstants.QR_JSON_LEVEL_CODE));
                    if (loginInfo.containsKey(MobileAuthConstants.QR_JSON_GROWTH_VALUE)) {
                        memberInfo.setGrowthValue(loginInfo.getInt(MobileAuthConstants.QR_JSON_GROWTH_VALUE));
                    }
                    memberInfo.setLevelExpireDate(parseQrOptionalLocalDateTime(
                            loginInfo.getStr(MobileAuthConstants.QR_JSON_LEVEL_EXPIRE_DATE)));
                    if (loginInfo.containsKey(MobileAuthConstants.QR_JSON_IS_VIP)) {
                        memberInfo.setIsVip(loginInfo.getInt(MobileAuthConstants.QR_JSON_IS_VIP));
                    }
                    memberInfo.setVipLevelCode(loginInfo.getStr(MobileAuthConstants.QR_JSON_VIP_LEVEL_CODE));
                    memberInfo.setVipExpireDate(parseQrOptionalLocalDateTime(
                            loginInfo.getStr(MobileAuthConstants.QR_JSON_VIP_EXPIRE_DATE)));
                    vo.setMemberInfo(memberInfo);
                    
                    // 清理Redis数据
                    redisTemplate.delete(key);
                    redisTemplate.delete(loginInfoKey);
                } catch (Exception e) {
                    log.error("解析登录信息失败: token={}", token, e);
                    vo.setStatus(MobileAuthConstants.QR_STATUS_EXPIRED);
                }
            } else {
                vo.setStatus(MobileAuthConstants.QR_STATUS_EXPIRED);
            }
        } else {
            // pending 或 scanned
            vo.setStatus(status);
        }
        
        return vo;
    }

    /**
     * 确认二维码登录（移动端扫码后调用）
     */
    @Transactional(rollbackFor = Exception.class)
    public void confirmQrLogin(String token) {
        if (StrUtil.isBlank(token)) {
            ExceptionUtil.error("二维码Token不能为空");
        }
        
        String key = MobileAuthConstants.QR_LOGIN_REDIS_PREFIX + token;
        String status = redisTemplate.opsForValue().get(key);
        
        if (StrUtil.isBlank(status)) {
            ExceptionUtil.error("二维码已过期，请重新生成");
        }
        
        if (!MobileAuthConstants.QR_STATUS_SCANNED.equals(status)) {
            ExceptionUtil.error("二维码状态不正确，请重新扫码");
        }

        // 获取当前登录会员
        String memberId = MemberAuthUtils.getLoginId();
        if (StrUtil.isBlank(memberId)) {
            ExceptionUtil.error("请先登录");
        }

        Member member = memberService.get(memberId);
        if (member == null || (member.getStatus() != null && member.getStatus() == MobileAuthConstants.MEMBER_STATUS_DISABLED)) {
            ExceptionUtil.error("账号不存在或已禁用");
        }

        // 执行登录，获取登录信息
        LoginResultVO loginResult = doLogin(member, false, false, "QR_LOGIN");

        // 更新状态为 confirmed，并存储登录信息
        redisTemplate.opsForValue().set(key, MobileAuthConstants.QR_STATUS_CONFIRMED,
                MobileAuthConstants.QR_TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);

        String loginInfoKey = MobileAuthConstants.QR_LOGIN_REDIS_PREFIX + token
                + MobileAuthConstants.QR_LOGIN_INFO_KEY_SUFFIX;
        JSONObject loginInfo = new JSONObject();
        loginInfo.set(MobileAuthConstants.QR_JSON_ACCESS_TOKEN, loginResult.getAccessToken());
        loginInfo.set(MobileAuthConstants.QR_JSON_TOKEN_TYPE, loginResult.getTokenType());
        loginInfo.set(MobileAuthConstants.QR_JSON_EXPIRES_IN, loginResult.getExpiresIn());
        loginInfo.set(MobileAuthConstants.QR_JSON_REFRESH_TOKEN, loginResult.getRefreshToken());
        loginInfo.set(MobileAuthConstants.QR_JSON_IS_NEW_USER, loginResult.getIsNewUser());
        loginInfo.set(MobileAuthConstants.QR_JSON_NEED_BIND_MOBILE, loginResult.getNeedBindMobile());
        loginInfo.set(MobileAuthConstants.QR_JSON_MEMBER_ID, loginResult.getMemberId());
        loginInfo.set(MobileAuthConstants.QR_JSON_MEMBER_NO, loginResult.getMemberNo());
        loginInfo.set(MobileAuthConstants.QR_JSON_NICKNAME, loginResult.getNickname());
        loginInfo.set(MobileAuthConstants.QR_JSON_AVATAR, loginResult.getAvatar());
        loginInfo.set(MobileAuthConstants.QR_JSON_MOBILE, loginResult.getMobile());
        loginInfo.set(MobileAuthConstants.QR_JSON_LEVEL_ID, loginResult.getLevelId());
        loginInfo.set(MobileAuthConstants.QR_JSON_LEVEL_CODE, loginResult.getLevelCode());
        loginInfo.set(MobileAuthConstants.QR_JSON_GROWTH_VALUE, loginResult.getGrowthValue());
        loginInfo.set(MobileAuthConstants.QR_JSON_LEVEL_EXPIRE_DATE,
                loginResult.getLevelExpireDate() != null ? loginResult.getLevelExpireDate().toString() : null);
        loginInfo.set(MobileAuthConstants.QR_JSON_IS_VIP, loginResult.getIsVip());
        loginInfo.set(MobileAuthConstants.QR_JSON_VIP_LEVEL_CODE, loginResult.getVipLevelCode());
        loginInfo.set(MobileAuthConstants.QR_JSON_VIP_EXPIRE_DATE,
                loginResult.getVipExpireDate() != null ? loginResult.getVipExpireDate().toString() : null);
        redisTemplate.opsForValue().set(loginInfoKey, loginInfo.toString(),
                MobileAuthConstants.QR_TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);
        
        log.info("确认二维码登录成功: token={}, memberId={}", token, memberId);
    }

    /**
     * 解析扫码登录 Redis 中存储的 ISO-8601 时间字符串。
     */
    private static LocalDateTime parseQrOptionalLocalDateTime(String raw) {
        if (StrUtil.isBlank(raw)) {
            return null;
        }
        try {
            return LocalDateTime.parse(raw);
        } catch (Exception e) {
            log.debug("解析扫码登录时间字段失败: raw={}", raw);
            return null;
        }
    }

    /**
     * 将二维码状态从 pending 更新为 scanned（用户已扫码、尚未在 APP 内确认登录）。
     *
     * @param token 二维码 token；空则直接返回
     */
    public void markQrCodeScanned(String token) {
        if (StrUtil.isBlank(token)) {
            return;
        }
        
        String key = MobileAuthConstants.QR_LOGIN_REDIS_PREFIX + token;
        String status = redisTemplate.opsForValue().get(key);
        
        if (MobileAuthConstants.QR_STATUS_PENDING.equals(status)) {
            // 更新状态为 scanned
            redisTemplate.opsForValue().set(key, MobileAuthConstants.QR_STATUS_SCANNED,
                    MobileAuthConstants.QR_TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);
            log.info("二维码已扫码: token={}", token);
        }
    }
}
