Files
wxmp_backend/src/auth/auth.service.ts
T

115 lines
4.0 KiB
TypeScript
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.
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 };
}
/** 签发 JWTpayload 携带 userId(sub) 与 openid */
private async signToken(userId: string, openid: string): Promise<string> {
const payload: JwtPayload = { sub: userId, openid };
return this.jwt.signAsync(payload, {
expiresIn: this.config.get<number>('jwt.expires'),
});
}
/** 取出缓存的 session_key(供解密小程序加密数据用) */
async getSessionKey(userId: string): Promise<string | null> {
return this.redis.get(`wx:session:${userId}`);
}
}