import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Redis } from 'ioredis'; import { randomBytes } from 'crypto'; import { REDIS_CLIENT } from '../redis/redis.module'; import { JwtPayload } from '../common/decorators/current-user.decorator'; import { UsersService } from '../users/users.service'; import { WechatService } from '../wechat/wechat.service'; import { LoginDto, RegisterDto } from './dto/login.dto'; // 注册凭证(registerTicket)在 Redis 的存储键与有效期 const REG_TICKET_TTL = 10 * 60; // 10 分钟 // session_key 在 Redis 的存储键与有效期 const SESSION_KEY_TTL = 7 * 24 * 60 * 60; @Injectable() export class AuthService { constructor( private readonly wechat: WechatService, private readonly users: UsersService, private readonly jwt: JwtService, private readonly config: ConfigService, @Inject(REDIS_CLIENT) private readonly redis: Redis, ) {} /** * 登录:以 openid 为唯一标识,自动注册/续登。 * 只用前端 wx.login() 的 code 换取 openid(不信任前端传的任何 openid)。 * - openid 已存在 -> 直接签发 token(后续登录) * - openid 不存在 -> 用 openid 建用户后签发 token(首次登录,无需手机号) * * 说明:个人主体小程序无 getPhoneNumber 权限,故不强制手机号注册。 * 手机号采集留作未来换企业主体后可选补充(见 register())。 */ async login(dto: LoginDto): Promise<{ accessToken: string; isNewUser: boolean; nickname: string | null; avatar: string | null; }> { const session = await this.wechat.code2Session(dto.code); // 先查一次以判断是否为首次登录;随后仍以 upsert 防止并发登录重复建用户。 const existing = await this.users.findByOpenid(session.openid); const user = await this.users.findOrCreateByOpenid( session.openid, session.unionid, ); // 缓存 session_key(供后续解密 encryptedData / 数据签名校验) await this.redis.set( `wx:session:${user.id}`, session.sessionKey, 'EX', SESSION_KEY_TTL, ); const accessToken = await this.signToken(user.id, user.openid); return { accessToken, isNewUser: !existing, nickname: user.nickname, avatar: user.avatar, }; } /** * 首次注册:用 registerTicket 换回 openid,验证手机号后创建用户并签发 token。 * 手机号非空即视为注册完成。 */ async register(dto: RegisterDto): Promise<{ accessToken: string }> { const key = `wx:reg_ticket:${dto.registerTicket}`; const raw = await this.redis.get(key); if (!raw) { throw new UnauthorizedException('注册凭证无效或已过期,请重新登录'); } const ticket = JSON.parse(raw) as { openid: string; sessionKey?: string; unionid?: string | null; }; // 验证手机号;传 openid 让微信校验 code-openid 绑定关系,防串号 const phone = await this.wechat.getUserPhoneNumber(dto.phoneCode, ticket.openid); const user = await this.users.createWithOpenid(ticket.openid, { phone: phone.purePhoneNumber, nickname: dto.nickname, avatar: dto.avatar, unionid: ticket.unionid ?? undefined, }); // 一次性凭证,用完即弃 await this.redis.del(key); const accessToken = await this.signToken(user.id, user.openid); return { accessToken }; } /** 签发 JWT,payload 携带 userId(sub) 与 openid */ private async signToken(userId: string, openid: string): Promise { const payload: JwtPayload = { sub: userId, openid }; return this.jwt.signAsync(payload, { expiresIn: this.config.get('jwt.expires'), }); } /** 取出缓存的 session_key(供解密小程序加密数据用) */ async getSessionKey(userId: string): Promise { return this.redis.get(`wx:session:${userId}`); } }