1280 lines
39 KiB
Markdown
1280 lines
39 KiB
Markdown
# 平台适配器模块设计方案
|
||
|
||
## 🏗️ 核心架构设计
|
||
|
||
基于NestJS依赖注入的平台适配器系统,支持微信小程序和抖音小程序的统一用户登录、注册、信息管理。
|
||
|
||
```
|
||
Platform Adapter 层次结构:
|
||
┌─────────────────────────────────────┐
|
||
│ Unified User Service │ ← 统一用户服务接口
|
||
├─────────────────────────────────────┤
|
||
│ Platform Adapter Factory │ ← 适配器工厂(策略模式)
|
||
├─────────────────────────────────────┤
|
||
│ WechatAdapter │ BytedanceAdapter │ ← 具体平台适配器
|
||
├─────────────────────────────────────┤
|
||
│ BaseAdapter (抽象基类) │ ← 通用适配器接口
|
||
├─────────────────────────────────────┤
|
||
│ External Platform APIs │ ← 微信/抖音API
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
## 📁 模块结构
|
||
|
||
```typescript
|
||
src/
|
||
├── platform/
|
||
│ ├── adapters/
|
||
│ │ ├── base.adapter.ts // 基础适配器抽象类
|
||
│ │ ├── wechat.adapter.ts // 微信适配器
|
||
│ │ ├── bytedance.adapter.ts // 抖音适配器
|
||
│ │ └── index.ts
|
||
│ ├── services/
|
||
│ │ ├── platform-adapter.factory.ts // 适配器工厂
|
||
│ │ └── unified-user.service.ts // 统一用户服务
|
||
│ ├── interfaces/
|
||
│ │ ├── platform.interface.ts // 平台接口定义
|
||
│ │ └── user-auth.interface.ts // 用户认证接口
|
||
│ ├── dto/
|
||
│ │ ├── platform-login.dto.ts // 平台登录DTO
|
||
│ │ └── user-info.dto.ts // 用户信息DTO
|
||
│ ├── guards/
|
||
│ │ └── platform-auth.guard.ts // 平台认证守卫
|
||
│ └── platform.module.ts // 平台模块
|
||
```
|
||
|
||
## 🔧 核心接口定义
|
||
|
||
### 平台适配器接口
|
||
|
||
```typescript
|
||
// platform/interfaces/platform.interface.ts
|
||
export interface IPlatformAdapter {
|
||
platform: PlatformType;
|
||
|
||
// 用户认证相关
|
||
login(loginData: PlatformLoginData): Promise<UserAuthResult>;
|
||
getUserInfo(token: string, platformUserId: string): Promise<PlatformUserInfo>;
|
||
refreshToken(refreshToken: string): Promise<TokenRefreshResult>;
|
||
|
||
// 用户注册相关
|
||
register(registerData: PlatformRegisterData): Promise<UserAuthResult>;
|
||
|
||
// 用户信息管理
|
||
updateUserInfo(userId: string, updateData: Partial<PlatformUserInfo>): Promise<void>;
|
||
|
||
// 平台特定功能
|
||
validateToken(token: string): Promise<boolean>;
|
||
revokeToken(token: string): Promise<void>;
|
||
}
|
||
|
||
export interface PlatformLoginData {
|
||
code: string;
|
||
encryptedData?: string; // 微信小程序加密用户信息
|
||
iv?: string; // 微信小程序加密向量
|
||
userInfo?: any; // 前端传递的基础用户信息
|
||
anonymousCode?: string; // 抖音小程序匿名code(可选)
|
||
}
|
||
|
||
export interface UserAuthResult {
|
||
user: UnifiedUserInfo;
|
||
tokens: {
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresAt: Date;
|
||
};
|
||
platformData: any;
|
||
}
|
||
|
||
export interface PlatformUserInfo {
|
||
platformUserId: string; // openid
|
||
nickname: string;
|
||
avatarUrl: string;
|
||
gender?: number;
|
||
country?: string;
|
||
province?: string;
|
||
city?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
unionid?: string; // 跨应用用户标识
|
||
anonymousOpenid?: string; // 抖音匿名用户标识
|
||
}
|
||
|
||
export interface TokenRefreshResult {
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresAt: Date;
|
||
}
|
||
|
||
// 注意:两个平台都不支持传统的token刷新机制
|
||
// 微信和抖音的session_key都需要通过重新登录获取
|
||
|
||
export interface UnifiedUserInfo {
|
||
id: string;
|
||
unifiedUserId: string;
|
||
nickname: string;
|
||
avatarUrl: string;
|
||
phone?: string;
|
||
email?: string;
|
||
status: number;
|
||
platforms: PlatformType[];
|
||
}
|
||
```
|
||
|
||
## 🏛️ 基础适配器抽象类
|
||
|
||
```typescript
|
||
// platform/adapters/base.adapter.ts
|
||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||
import { HttpService } from '@nestjs/axios';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { Repository } from 'typeorm';
|
||
import { JwtService } from '@nestjs/jwt';
|
||
import { User } from '../../entities/user.entity';
|
||
import { PlatformUser, PlatformType } from '../../entities/platform-user.entity';
|
||
import { IPlatformAdapter, PlatformLoginData, UserAuthResult, PlatformUserInfo, TokenRefreshResult } from '../interfaces/platform.interface';
|
||
|
||
@Injectable()
|
||
export abstract class BaseAdapter implements IPlatformAdapter {
|
||
abstract platform: PlatformType;
|
||
|
||
constructor(
|
||
protected readonly httpService: HttpService,
|
||
protected readonly configService: ConfigService,
|
||
protected readonly userRepository: Repository<User>,
|
||
protected readonly platformUserRepository: Repository<PlatformUser>,
|
||
protected readonly jwtService: JwtService,
|
||
) {}
|
||
|
||
/**
|
||
* 查找或创建统一用户
|
||
* 根据平台用户信息查找现有用户,如不存在则创建新用户
|
||
*/
|
||
async findOrCreateUnifiedUser(platformUserData: PlatformUserInfo): Promise<User> {
|
||
// 查找是否已存在平台用户绑定
|
||
const existingPlatformUser = await this.platformUserRepository.findOne({
|
||
where: {
|
||
platform: this.platform,
|
||
platformUserId: platformUserData.platformUserId,
|
||
},
|
||
relations: ['user'],
|
||
});
|
||
|
||
if (existingPlatformUser) {
|
||
// 更新平台用户信息
|
||
await this.updatePlatformUserInfo(existingPlatformUser, platformUserData);
|
||
return existingPlatformUser.user;
|
||
}
|
||
|
||
// 创建新的统一用户
|
||
const unifiedUser = await this.createUnifiedUser(platformUserData);
|
||
await this.createPlatformUser(unifiedUser.id, platformUserData);
|
||
|
||
return unifiedUser;
|
||
}
|
||
|
||
/**
|
||
* 创建统一用户记录
|
||
*/
|
||
protected async createUnifiedUser(platformData: PlatformUserInfo): Promise<User> {
|
||
const user = new User();
|
||
user.unifiedUserId = this.generateUnifiedUserId();
|
||
user.nickname = platformData.nickname;
|
||
user.avatarUrl = platformData.avatarUrl;
|
||
user.phone = platformData.phone;
|
||
user.email = platformData.email;
|
||
user.status = 1; // 正常状态
|
||
return this.userRepository.save(user);
|
||
}
|
||
|
||
/**
|
||
* 创建平台用户绑定记录
|
||
*/
|
||
protected async createPlatformUser(userId: string, platformData: PlatformUserInfo, authData?: any): Promise<PlatformUser> {
|
||
const platformUser = new PlatformUser();
|
||
platformUser.userId = userId;
|
||
platformUser.platform = this.platform;
|
||
platformUser.platformUserId = platformData.platformUserId;
|
||
platformUser.platformData = platformData;
|
||
|
||
if (authData) {
|
||
platformUser.accessToken = authData.access_token;
|
||
platformUser.refreshToken = authData.refresh_token;
|
||
platformUser.expiresAt = authData.expires_at ? new Date(authData.expires_at * 1000) : null;
|
||
}
|
||
|
||
return this.platformUserRepository.save(platformUser);
|
||
}
|
||
|
||
/**
|
||
* 更新平台用户信息
|
||
*/
|
||
protected async updatePlatformUserInfo(platformUser: PlatformUser, newData: PlatformUserInfo): Promise<void> {
|
||
platformUser.platformData = { ...platformUser.platformData, ...newData };
|
||
await this.platformUserRepository.save(platformUser);
|
||
}
|
||
|
||
/**
|
||
* 更新平台用户认证数据
|
||
*/
|
||
protected async updatePlatformUserData(userId: string, authData: any, userInfo?: any): Promise<void> {
|
||
const platformUser = await this.platformUserRepository.findOne({
|
||
where: { userId, platform: this.platform }
|
||
});
|
||
|
||
if (platformUser) {
|
||
// 统一存储session_key到accessToken字段
|
||
platformUser.accessToken = authData.session_key;
|
||
platformUser.refreshToken = null; // 两平台都不支持refresh
|
||
platformUser.expiresAt = null; // session_key无固定过期时间
|
||
|
||
if (userInfo) {
|
||
platformUser.platformData = { ...platformUser.platformData, ...userInfo };
|
||
}
|
||
|
||
await this.platformUserRepository.save(platformUser);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成统一用户ID
|
||
*/
|
||
protected generateUnifiedUserId(): string {
|
||
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
|
||
/**
|
||
* 生成JWT令牌
|
||
*/
|
||
protected async generateTokens(user: User, platform: PlatformType): Promise<{
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresAt: Date;
|
||
}> {
|
||
const payload = {
|
||
userId: user.id,
|
||
unifiedUserId: user.unifiedUserId,
|
||
platform,
|
||
};
|
||
|
||
const accessToken = this.jwtService.sign(payload, { expiresIn: '24h' });
|
||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });
|
||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24小时后
|
||
|
||
return { accessToken, refreshToken, expiresAt };
|
||
}
|
||
|
||
/**
|
||
* 格式化统一用户信息
|
||
*/
|
||
protected formatUnifiedUserInfo(user: User): UnifiedUserInfo {
|
||
return {
|
||
id: user.id,
|
||
unifiedUserId: user.unifiedUserId,
|
||
nickname: user.nickname,
|
||
avatarUrl: user.avatarUrl,
|
||
phone: user.phone,
|
||
email: user.email,
|
||
status: user.status,
|
||
platforms: user.platformUsers?.map(pu => pu.platform) || [this.platform],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 统一错误处理
|
||
*/
|
||
protected handlePlatformError(error: any, platform: string): Error {
|
||
// 统一不同平台的错误格式
|
||
if (error.response?.data) {
|
||
const errorData = error.response.data;
|
||
// 微信格式: {errcode, errmsg}
|
||
if (errorData.errcode) {
|
||
return new Error(`${platform}错误[${errorData.errcode}]: ${errorData.errmsg}`);
|
||
}
|
||
// 抖音格式: {err_no, err_tips}
|
||
if (errorData.err_no !== undefined) {
|
||
return new Error(`${platform}错误[${errorData.err_no}]: ${errorData.err_tips}`);
|
||
}
|
||
}
|
||
return new Error(`${platform}API调用失败: ${error.message}`);
|
||
}
|
||
|
||
// 抽象方法 - 子类必须实现
|
||
abstract login(loginData: PlatformLoginData): Promise<UserAuthResult>;
|
||
abstract getUserInfo(token: string, platformUserId: string): Promise<PlatformUserInfo>;
|
||
abstract refreshToken(refreshToken: string): Promise<TokenRefreshResult>;
|
||
abstract register(registerData: PlatformRegisterData): Promise<UserAuthResult>;
|
||
abstract updateUserInfo(userId: string, updateData: Partial<PlatformUserInfo>): Promise<void>;
|
||
abstract validateToken(token: string): Promise<boolean>;
|
||
abstract revokeToken(token: string): Promise<void>;
|
||
}
|
||
```
|
||
|
||
## 📱 微信适配器实现
|
||
|
||
```typescript
|
||
// platform/adapters/wechat.adapter.ts
|
||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||
import { BaseAdapter } from './base.adapter';
|
||
import { PlatformType } from '../../entities/platform-user.entity';
|
||
import { PlatformLoginData, UserAuthResult, PlatformUserInfo, TokenRefreshResult } from '../interfaces/platform.interface';
|
||
|
||
interface WechatAuthResponse {
|
||
openid: string;
|
||
session_key: string;
|
||
unionid?: string;
|
||
errcode?: number;
|
||
errmsg?: string;
|
||
}
|
||
|
||
interface WechatUserInfo {
|
||
openId: string;
|
||
nickName: string;
|
||
gender: number;
|
||
language: string;
|
||
city: string;
|
||
province: string;
|
||
country: string;
|
||
avatarUrl: string;
|
||
}
|
||
|
||
@Injectable()
|
||
export class WechatAdapter extends BaseAdapter {
|
||
platform = PlatformType.WECHAT;
|
||
|
||
private readonly wechatConfig = {
|
||
appId: this.configService.get('WECHAT_APP_ID'),
|
||
appSecret: this.configService.get('WECHAT_APP_SECRET'),
|
||
};
|
||
|
||
async login(loginData: PlatformLoginData): Promise<UserAuthResult> {
|
||
try {
|
||
// 1. 使用code换取session_key和openid
|
||
const authResult = await this.getWechatAuth(loginData.code);
|
||
|
||
// 2. 解密用户信息(如果提供)
|
||
let userInfo: WechatUserInfo;
|
||
if (loginData.encryptedData && loginData.iv) {
|
||
userInfo = this.decryptWechatUserInfo(
|
||
loginData.encryptedData,
|
||
loginData.iv,
|
||
authResult.session_key
|
||
);
|
||
} else {
|
||
// 使用基础信息创建用户
|
||
userInfo = {
|
||
openId: authResult.openid,
|
||
nickName: loginData.userInfo?.nickName || '微信用户',
|
||
avatarUrl: loginData.userInfo?.avatarUrl || '',
|
||
gender: loginData.userInfo?.gender || 0,
|
||
language: 'zh_CN',
|
||
city: '',
|
||
province: '',
|
||
country: 'CN',
|
||
};
|
||
}
|
||
|
||
// 3. 创建或查找统一用户
|
||
const platformUserInfo: PlatformUserInfo = {
|
||
platformUserId: authResult.openid,
|
||
nickname: userInfo.nickName,
|
||
avatarUrl: userInfo.avatarUrl,
|
||
gender: userInfo.gender,
|
||
country: userInfo.country,
|
||
province: userInfo.province,
|
||
city: userInfo.city,
|
||
};
|
||
|
||
const unifiedUser = await this.findOrCreateUnifiedUser(platformUserInfo);
|
||
|
||
// 4. 更新平台用户数据
|
||
await this.updatePlatformUserData(unifiedUser.id, authResult, userInfo);
|
||
|
||
// 5. 生成JWT令牌
|
||
const tokens = await this.generateTokens(unifiedUser, this.platform);
|
||
|
||
return {
|
||
user: this.formatUnifiedUserInfo(unifiedUser),
|
||
tokens,
|
||
platformData: userInfo,
|
||
};
|
||
} catch (error) {
|
||
const platformError = this.handlePlatformError(error, '微信');
|
||
throw new BadRequestException(`微信登录失败: ${platformError.message}`);
|
||
}
|
||
}
|
||
|
||
async register(registerData: PlatformRegisterData): Promise<UserAuthResult> {
|
||
// 微信小程序通常通过login方法完成注册
|
||
return this.login(registerData);
|
||
}
|
||
|
||
async getUserInfo(token: string, platformUserId: string): Promise<PlatformUserInfo> {
|
||
const platformUser = await this.platformUserRepository.findOne({
|
||
where: {
|
||
platform: this.platform,
|
||
platformUserId,
|
||
},
|
||
relations: ['user'],
|
||
});
|
||
|
||
if (!platformUser) {
|
||
throw new BadRequestException('用户不存在');
|
||
}
|
||
|
||
return {
|
||
platformUserId: platformUser.platformUserId,
|
||
nickname: platformUser.user.nickname,
|
||
avatarUrl: platformUser.user.avatarUrl,
|
||
phone: platformUser.user.phone,
|
||
email: platformUser.user.email,
|
||
...platformUser.platformData,
|
||
};
|
||
}
|
||
|
||
async updateUserInfo(userId: string, updateData: Partial<PlatformUserInfo>): Promise<void> {
|
||
// 更新统一用户信息
|
||
await this.userRepository.update(userId, {
|
||
nickname: updateData.nickname,
|
||
avatarUrl: updateData.avatarUrl,
|
||
phone: updateData.phone,
|
||
email: updateData.email,
|
||
});
|
||
|
||
// 更新平台用户数据
|
||
const platformUser = await this.platformUserRepository.findOne({
|
||
where: { userId, platform: this.platform }
|
||
});
|
||
|
||
if (platformUser) {
|
||
platformUser.platformData = { ...platformUser.platformData, ...updateData };
|
||
await this.platformUserRepository.save(platformUser);
|
||
}
|
||
}
|
||
|
||
async refreshToken(refreshToken: string): Promise<TokenRefreshResult> {
|
||
// 微信小程序的session_key通常不支持刷新,需要重新登录
|
||
throw new BadRequestException('微信平台需要重新登录');
|
||
}
|
||
|
||
async validateToken(token: string): Promise<boolean> {
|
||
try {
|
||
// 验证微信session_key是否有效
|
||
// 通常通过调用微信API验证
|
||
return true;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async revokeToken(token: string): Promise<void> {
|
||
// 微信平台令牌撤销逻辑
|
||
// 清除本地存储的session_key
|
||
}
|
||
|
||
/**
|
||
* 使用code获取微信授权信息
|
||
*/
|
||
private async getWechatAuth(code: string): Promise<WechatAuthResponse> {
|
||
const url = `https://api.weixin.qq.com/sns/jscode2session`;
|
||
const params = {
|
||
appid: this.wechatConfig.appId,
|
||
secret: this.wechatConfig.appSecret,
|
||
js_code: code,
|
||
grant_type: 'authorization_code',
|
||
};
|
||
|
||
const response = await this.httpService.axiosRef.get(url, { params });
|
||
|
||
if (response.data.errcode) {
|
||
throw new Error(`微信认证失败: ${response.data.errmsg}`);
|
||
}
|
||
|
||
return response.data;
|
||
}
|
||
|
||
/**
|
||
* 解密微信用户信息
|
||
*/
|
||
private decryptWechatUserInfo(encryptedData: string, iv: string, sessionKey: string): WechatUserInfo {
|
||
// 实现微信用户信息解密逻辑
|
||
// 使用crypto模块进行AES解密
|
||
const crypto = require('crypto');
|
||
const decipher = crypto.createDecipheriv('aes-128-cbc',
|
||
Buffer.from(sessionKey, 'base64'),
|
||
Buffer.from(iv, 'base64')
|
||
);
|
||
|
||
let decrypted = decipher.update(encryptedData, 'base64', 'utf8');
|
||
decrypted += decipher.final('utf8');
|
||
|
||
return JSON.parse(decrypted);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 📺 抖音适配器实现
|
||
|
||
```typescript
|
||
// platform/adapters/bytedance.adapter.ts
|
||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||
import { BaseAdapter } from './base.adapter';
|
||
import { PlatformType } from '../../entities/platform-user.entity';
|
||
import { PlatformLoginData, UserAuthResult, PlatformUserInfo, TokenRefreshResult } from '../interfaces/platform.interface';
|
||
|
||
interface BytedanceAuthResponse {
|
||
err_no: number;
|
||
err_tips: string;
|
||
log_id: string;
|
||
data: {
|
||
session_key: string;
|
||
openid: string;
|
||
anonymous_openid: string;
|
||
unionid: string;
|
||
};
|
||
}
|
||
|
||
@Injectable()
|
||
export class BytedanceAdapter extends BaseAdapter {
|
||
platform = PlatformType.BYTEDANCE;
|
||
|
||
private readonly bytedanceConfig = {
|
||
appId: this.configService.get('BYTEDANCE_APP_ID'),
|
||
appSecret: this.configService.get('BYTEDANCE_APP_SECRET'),
|
||
};
|
||
|
||
async login(loginData: PlatformLoginData): Promise<UserAuthResult> {
|
||
try {
|
||
// 1. 使用code换取session_key和openid(一步到位)
|
||
const authResult = await this.getBytedanceAuth(loginData.code);
|
||
|
||
// 2. 直接使用返回的信息创建用户(无需额外API调用)
|
||
const platformUserInfo: PlatformUserInfo = {
|
||
platformUserId: authResult.data.openid,
|
||
nickname: loginData.userInfo?.nickname || '抖音用户',
|
||
avatarUrl: loginData.userInfo?.avatarUrl || '',
|
||
};
|
||
|
||
const unifiedUser = await this.findOrCreateUnifiedUser(platformUserInfo);
|
||
|
||
// 3. 存储session_key和相关信息(支持匿名用户)
|
||
const authData = {
|
||
session_key: authResult.data.session_key,
|
||
};
|
||
|
||
const extendedUserInfo = {
|
||
...platformUserInfo,
|
||
unionid: authResult.data.unionid,
|
||
anonymousOpenid: authResult.data.anonymous_openid,
|
||
};
|
||
|
||
await this.updatePlatformUserData(unifiedUser.id, authData, extendedUserInfo);
|
||
|
||
// 4. 生成JWT令牌
|
||
const tokens = await this.generateTokens(unifiedUser, this.platform);
|
||
|
||
return {
|
||
user: this.formatUnifiedUserInfo(unifiedUser),
|
||
tokens,
|
||
platformData: authResult.data,
|
||
};
|
||
} catch (error) {
|
||
const platformError = this.handlePlatformError(error, '抖音');
|
||
throw new BadRequestException(`抖音登录失败: ${platformError.message}`);
|
||
}
|
||
}
|
||
|
||
async register(registerData: PlatformRegisterData): Promise<UserAuthResult> {
|
||
// 抖音小程序通常通过login方法完成注册
|
||
return this.login(registerData);
|
||
}
|
||
|
||
async getUserInfo(token: string, platformUserId: string): Promise<PlatformUserInfo> {
|
||
const platformUser = await this.platformUserRepository.findOne({
|
||
where: {
|
||
platform: this.platform,
|
||
platformUserId,
|
||
},
|
||
relations: ['user'],
|
||
});
|
||
|
||
if (!platformUser) {
|
||
throw new BadRequestException('用户不存在');
|
||
}
|
||
|
||
return {
|
||
platformUserId: platformUser.platformUserId,
|
||
nickname: platformUser.user.nickname,
|
||
avatarUrl: platformUser.user.avatarUrl,
|
||
phone: platformUser.user.phone,
|
||
email: platformUser.user.email,
|
||
...platformUser.platformData,
|
||
};
|
||
}
|
||
|
||
async updateUserInfo(userId: string, updateData: Partial<PlatformUserInfo>): Promise<void> {
|
||
// 更新统一用户信息
|
||
await this.userRepository.update(userId, {
|
||
nickname: updateData.nickname,
|
||
avatarUrl: updateData.avatarUrl,
|
||
phone: updateData.phone,
|
||
email: updateData.email,
|
||
});
|
||
|
||
// 更新平台用户数据
|
||
const platformUser = await this.platformUserRepository.findOne({
|
||
where: { userId, platform: this.platform }
|
||
});
|
||
|
||
if (platformUser) {
|
||
platformUser.platformData = { ...platformUser.platformData, ...updateData };
|
||
await this.platformUserRepository.save(platformUser);
|
||
}
|
||
}
|
||
|
||
async refreshToken(refreshToken: string): Promise<TokenRefreshResult> {
|
||
// 抖音小程序的session_key需要重新登录获取,不支持刷新
|
||
throw new BadRequestException('抖音平台需要重新登录');
|
||
}
|
||
|
||
async validateToken(token: string): Promise<boolean> {
|
||
try {
|
||
// 抖音小程序验证session_key有效性
|
||
// 通常通过checkSession或API调用验证
|
||
return true;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async revokeToken(token: string): Promise<void> {
|
||
// 抖音平台session_key撤销逻辑
|
||
// session_key无需特殊撤销,清除本地存储即可
|
||
}
|
||
|
||
/**
|
||
* 获取抖音授权信息
|
||
*/
|
||
private async getBytedanceAuth(code: string): Promise<BytedanceAuthResponse> {
|
||
const url = `https://developer.toutiao.com/api/apps/v2/jscode2session`;
|
||
const data = {
|
||
appid: this.bytedanceConfig.appId,
|
||
secret: this.bytedanceConfig.appSecret,
|
||
code: code,
|
||
};
|
||
|
||
const response = await this.httpService.axiosRef.post(url, data, {
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
if (response.data.err_no !== 0) {
|
||
throw new Error(`抖音认证失败: ${response.data.err_tips}`);
|
||
}
|
||
|
||
return response.data;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🏭 适配器工厂服务
|
||
|
||
```typescript
|
||
// platform/services/platform-adapter.factory.ts
|
||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||
import { PlatformType } from '../../entities/platform-user.entity';
|
||
import { IPlatformAdapter } from '../interfaces/platform.interface';
|
||
import { WechatAdapter } from '../adapters/wechat.adapter';
|
||
import { BytedanceAdapter } from '../adapters/bytedance.adapter';
|
||
|
||
@Injectable()
|
||
export class PlatformAdapterFactory {
|
||
private readonly adapters = new Map<PlatformType, IPlatformAdapter>();
|
||
|
||
constructor(
|
||
private readonly wechatAdapter: WechatAdapter,
|
||
private readonly bytedanceAdapter: BytedanceAdapter,
|
||
) {
|
||
// 注册所有可用的适配器
|
||
this.adapters.set(PlatformType.WECHAT, this.wechatAdapter);
|
||
this.adapters.set(PlatformType.BYTEDANCE, this.bytedanceAdapter);
|
||
}
|
||
|
||
/**
|
||
* 根据平台类型获取对应的适配器
|
||
*/
|
||
getAdapter(platform: PlatformType): IPlatformAdapter {
|
||
const adapter = this.adapters.get(platform);
|
||
if (!adapter) {
|
||
throw new BadRequestException(`不支持的平台: ${platform}`);
|
||
}
|
||
return adapter;
|
||
}
|
||
|
||
/**
|
||
* 获取所有支持的平台列表
|
||
*/
|
||
getSupportedPlatforms(): PlatformType[] {
|
||
return Array.from(this.adapters.keys());
|
||
}
|
||
|
||
/**
|
||
* 检查平台是否支持
|
||
*/
|
||
isPlatformSupported(platform: PlatformType): boolean {
|
||
return this.adapters.has(platform);
|
||
}
|
||
|
||
/**
|
||
* 注册新的平台适配器(用于动态扩展)
|
||
*/
|
||
registerAdapter(platform: PlatformType, adapter: IPlatformAdapter): void {
|
||
this.adapters.set(platform, adapter);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🌐 统一用户服务
|
||
|
||
```typescript
|
||
// platform/services/unified-user.service.ts
|
||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { PlatformAdapterFactory } from './platform-adapter.factory';
|
||
import { User } from '../../entities/user.entity';
|
||
import { PlatformUser, PlatformType } from '../../entities/platform-user.entity';
|
||
import { PlatformLoginData, UserAuthResult, PlatformUserInfo, UnifiedUserInfo } from '../interfaces/platform.interface';
|
||
|
||
@Injectable()
|
||
export class UnifiedUserService {
|
||
constructor(
|
||
private readonly platformAdapterFactory: PlatformAdapterFactory,
|
||
@InjectRepository(User)
|
||
private readonly userRepository: Repository<User>,
|
||
@InjectRepository(PlatformUser)
|
||
private readonly platformUserRepository: Repository<PlatformUser>,
|
||
) {}
|
||
|
||
/**
|
||
* 统一登录接口
|
||
*/
|
||
async login(platform: PlatformType, loginData: PlatformLoginData): Promise<UserAuthResult> {
|
||
const adapter = this.platformAdapterFactory.getAdapter(platform);
|
||
return adapter.login(loginData);
|
||
}
|
||
|
||
/**
|
||
* 统一注册接口
|
||
*/
|
||
async register(platform: PlatformType, registerData: PlatformRegisterData): Promise<UserAuthResult> {
|
||
const adapter = this.platformAdapterFactory.getAdapter(platform);
|
||
return adapter.register(registerData);
|
||
}
|
||
|
||
/**
|
||
* 获取用户信息
|
||
*/
|
||
async getUserInfo(userId: string): Promise<UnifiedUserInfo> {
|
||
const user = await this.userRepository.findOne({
|
||
where: { id: userId },
|
||
relations: ['platformUsers'],
|
||
});
|
||
|
||
if (!user) {
|
||
throw new NotFoundException('用户不存在');
|
||
}
|
||
|
||
return {
|
||
id: user.id,
|
||
unifiedUserId: user.unifiedUserId,
|
||
nickname: user.nickname,
|
||
avatarUrl: user.avatarUrl,
|
||
phone: user.phone,
|
||
email: user.email,
|
||
status: user.status,
|
||
platforms: user.platformUsers.map(pu => pu.platform),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 获取平台用户信息
|
||
*/
|
||
async getPlatformUserInfo(platform: PlatformType, token: string, platformUserId: string): Promise<PlatformUserInfo> {
|
||
const adapter = this.platformAdapterFactory.getAdapter(platform);
|
||
return adapter.getUserInfo(token, platformUserId);
|
||
}
|
||
|
||
/**
|
||
* 更新用户信息
|
||
*/
|
||
async updateUserInfo(platform: PlatformType, userId: string, updateData: Partial<PlatformUserInfo>): Promise<void> {
|
||
const adapter = this.platformAdapterFactory.getAdapter(platform);
|
||
return adapter.updateUserInfo(userId, updateData);
|
||
}
|
||
|
||
/**
|
||
* 绑定新平台账号
|
||
*/
|
||
async bindPlatform(userId: string, platform: PlatformType, loginData: PlatformLoginData): Promise<void> {
|
||
const user = await this.userRepository.findOne({
|
||
where: { id: userId },
|
||
});
|
||
|
||
if (!user) {
|
||
throw new NotFoundException('用户不存在');
|
||
}
|
||
|
||
// 检查是否已绑定该平台
|
||
const existingBinding = await this.platformUserRepository.findOne({
|
||
where: { userId, platform },
|
||
});
|
||
|
||
if (existingBinding) {
|
||
throw new BadRequestException('该平台账号已绑定');
|
||
}
|
||
|
||
// 使用适配器获取平台用户信息
|
||
const adapter = this.platformAdapterFactory.getAdapter(platform);
|
||
const authResult = await adapter.login(loginData);
|
||
|
||
// 创建新的平台用户绑定
|
||
const platformUser = new PlatformUser();
|
||
platformUser.userId = userId;
|
||
platformUser.platform = platform;
|
||
platformUser.platformUserId = authResult.platformData.openid || authResult.platformData.openId;
|
||
platformUser.platformData = authResult.platformData;
|
||
|
||
await this.platformUserRepository.save(platformUser);
|
||
}
|
||
|
||
/**
|
||
* 解绑平台账号
|
||
*/
|
||
async unbindPlatform(userId: string, platform: PlatformType): Promise<void> {
|
||
const platformUser = await this.platformUserRepository.findOne({
|
||
where: { userId, platform },
|
||
});
|
||
|
||
if (!platformUser) {
|
||
throw new NotFoundException('平台账号绑定不存在');
|
||
}
|
||
|
||
// 撤销平台令牌
|
||
const adapter = this.platformAdapterFactory.getAdapter(platform);
|
||
if (platformUser.accessToken) {
|
||
try {
|
||
await adapter.revokeToken(platformUser.accessToken);
|
||
} catch (error) {
|
||
// 忽略撤销令牌的错误
|
||
console.warn(`撤销${platform}令牌失败:`, error.message);
|
||
}
|
||
}
|
||
|
||
// 删除平台用户绑定
|
||
await this.platformUserRepository.remove(platformUser);
|
||
}
|
||
|
||
/**
|
||
* 刷新平台令牌
|
||
*/
|
||
async refreshPlatformToken(platform: PlatformType, refreshToken: string): Promise<TokenRefreshResult> {
|
||
const adapter = this.platformAdapterFactory.getAdapter(platform);
|
||
return adapter.refreshToken(refreshToken);
|
||
}
|
||
|
||
/**
|
||
* 获取支持的平台列表
|
||
*/
|
||
getSupportedPlatforms(): PlatformType[] {
|
||
return this.platformAdapterFactory.getSupportedPlatforms();
|
||
}
|
||
}
|
||
```
|
||
|
||
## 📋 DTO定义
|
||
|
||
```typescript
|
||
// platform/dto/platform-login.dto.ts
|
||
import { ApiProperty } from '@nestjs/swagger';
|
||
import { IsString, IsEnum, IsOptional, IsObject } from 'class-validator';
|
||
import { PlatformType } from '../../entities/platform-user.entity';
|
||
|
||
export class PlatformLoginDto {
|
||
@ApiProperty({
|
||
description: '平台类型',
|
||
enum: PlatformType,
|
||
example: PlatformType.WECHAT,
|
||
})
|
||
@IsEnum(PlatformType)
|
||
platform: PlatformType;
|
||
|
||
@ApiProperty({
|
||
description: '平台授权码',
|
||
example: '081234567890abcdef',
|
||
})
|
||
@IsString()
|
||
code: string;
|
||
|
||
@ApiProperty({
|
||
description: '加密用户数据(微信小程序)',
|
||
required: false,
|
||
})
|
||
@IsOptional()
|
||
@IsString()
|
||
encryptedData?: string;
|
||
|
||
@ApiProperty({
|
||
description: '加密向量(微信小程序)',
|
||
required: false,
|
||
})
|
||
@IsOptional()
|
||
@IsString()
|
||
iv?: string;
|
||
|
||
@ApiProperty({
|
||
description: '用户基础信息',
|
||
required: false,
|
||
})
|
||
@IsOptional()
|
||
@IsObject()
|
||
userInfo?: any;
|
||
}
|
||
|
||
export class UserInfoUpdateDto {
|
||
@ApiProperty({ description: '用户昵称', required: false })
|
||
@IsOptional()
|
||
@IsString()
|
||
nickname?: string;
|
||
|
||
@ApiProperty({ description: '头像URL', required: false })
|
||
@IsOptional()
|
||
@IsString()
|
||
avatarUrl?: string;
|
||
|
||
@ApiProperty({ description: '手机号码', required: false })
|
||
@IsOptional()
|
||
@IsString()
|
||
phone?: string;
|
||
|
||
@ApiProperty({ description: '电子邮箱', required: false })
|
||
@IsOptional()
|
||
@IsString()
|
||
email?: string;
|
||
}
|
||
```
|
||
|
||
## 🌐 平台模块配置
|
||
|
||
```typescript
|
||
// platform/platform.module.ts
|
||
import { Module } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
import { HttpModule } from '@nestjs/axios';
|
||
import { JwtModule } from '@nestjs/jwt';
|
||
import { ConfigService } from '@nestjs/config';
|
||
|
||
// 实体
|
||
import { User } from '../entities/user.entity';
|
||
import { PlatformUser } from '../entities/platform-user.entity';
|
||
import { ExtensionData } from '../entities/extension-data.entity';
|
||
|
||
// 适配器
|
||
import { WechatAdapter } from './adapters/wechat.adapter';
|
||
import { BytedanceAdapter } from './adapters/bytedance.adapter';
|
||
|
||
// 服务
|
||
import { PlatformAdapterFactory } from './services/platform-adapter.factory';
|
||
import { UnifiedUserService } from './services/unified-user.service';
|
||
|
||
// 控制器
|
||
import { UnifiedUserController } from './controllers/unified-user.controller';
|
||
|
||
// 守卫
|
||
import { PlatformAuthGuard } from './guards/platform-auth.guard';
|
||
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([User, PlatformUser, ExtensionData]),
|
||
HttpModule.register({
|
||
timeout: 5000,
|
||
maxRedirects: 3,
|
||
}),
|
||
JwtModule.registerAsync({
|
||
inject: [ConfigService],
|
||
useFactory: (config: ConfigService) => ({
|
||
secret: config.get('JWT_SECRET'),
|
||
signOptions: { expiresIn: '24h' },
|
||
}),
|
||
}),
|
||
],
|
||
providers: [
|
||
// 适配器实现
|
||
WechatAdapter,
|
||
BytedanceAdapter,
|
||
|
||
// 工厂和服务
|
||
PlatformAdapterFactory,
|
||
UnifiedUserService,
|
||
|
||
// 守卫
|
||
PlatformAuthGuard,
|
||
],
|
||
controllers: [
|
||
UnifiedUserController,
|
||
],
|
||
exports: [
|
||
UnifiedUserService,
|
||
PlatformAdapterFactory,
|
||
],
|
||
})
|
||
export class PlatformModule {}
|
||
```
|
||
|
||
## 🎮 控制器实现
|
||
|
||
```typescript
|
||
// platform/controllers/unified-user.controller.ts
|
||
import { Controller, Post, Body, Get, Put, UseGuards, Request } from '@nestjs/common';
|
||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||
import { UnifiedUserService } from '../services/unified-user.service';
|
||
import { PlatformLoginDto, UserInfoUpdateDto } from '../dto/platform-login.dto';
|
||
import { PlatformAuthGuard } from '../guards/platform-auth.guard';
|
||
|
||
@ApiTags('统一用户管理')
|
||
@Controller('api/v1/users')
|
||
export class UnifiedUserController {
|
||
constructor(
|
||
private readonly unifiedUserService: UnifiedUserService,
|
||
) {}
|
||
|
||
@Post('login')
|
||
@ApiOperation({ summary: '统一登录接口' })
|
||
@ApiResponse({ status: 200, description: '登录成功' })
|
||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||
@ApiResponse({ status: 401, description: '授权失败' })
|
||
async login(@Body() loginDto: PlatformLoginDto) {
|
||
const result = await this.unifiedUserService.login(loginDto.platform, loginDto);
|
||
return {
|
||
code: 200,
|
||
message: '登录成功',
|
||
data: result,
|
||
};
|
||
}
|
||
|
||
@Post('register')
|
||
@ApiOperation({ summary: '统一注册接口' })
|
||
@ApiResponse({ status: 200, description: '注册成功' })
|
||
async register(@Body() registerDto: PlatformLoginDto) {
|
||
const result = await this.unifiedUserService.register(registerDto.platform, registerDto);
|
||
return {
|
||
code: 200,
|
||
message: '注册成功',
|
||
data: result,
|
||
};
|
||
}
|
||
|
||
@Get('profile')
|
||
@UseGuards(PlatformAuthGuard)
|
||
@ApiBearerAuth()
|
||
@ApiOperation({ summary: '获取用户信息' })
|
||
@ApiResponse({ status: 200, description: '获取成功' })
|
||
async getProfile(@Request() req) {
|
||
const userInfo = await this.unifiedUserService.getUserInfo(req.user.userId);
|
||
return {
|
||
code: 200,
|
||
message: '获取成功',
|
||
data: userInfo,
|
||
};
|
||
}
|
||
|
||
@Put('profile')
|
||
@UseGuards(PlatformAuthGuard)
|
||
@ApiBearerAuth()
|
||
@ApiOperation({ summary: '更新用户信息' })
|
||
@ApiResponse({ status: 200, description: '更新成功' })
|
||
async updateProfile(@Request() req, @Body() updateDto: UserInfoUpdateDto) {
|
||
await this.unifiedUserService.updateUserInfo(req.user.platform, req.user.userId, updateDto);
|
||
return {
|
||
code: 200,
|
||
message: '更新成功',
|
||
};
|
||
}
|
||
|
||
@Get('platforms')
|
||
@ApiOperation({ summary: '获取支持的平台列表' })
|
||
@ApiResponse({ status: 200, description: '获取成功' })
|
||
async getSupportedPlatforms() {
|
||
const platforms = this.unifiedUserService.getSupportedPlatforms();
|
||
return {
|
||
code: 200,
|
||
message: '获取成功',
|
||
data: platforms,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🛡️ 认证守卫
|
||
|
||
```typescript
|
||
// platform/guards/platform-auth.guard.ts
|
||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||
import { JwtService } from '@nestjs/jwt';
|
||
import { PlatformAdapterFactory } from '../services/platform-adapter.factory';
|
||
|
||
@Injectable()
|
||
export class PlatformAuthGuard implements CanActivate {
|
||
constructor(
|
||
private readonly jwtService: JwtService,
|
||
private readonly platformAdapterFactory: PlatformAdapterFactory,
|
||
) {}
|
||
|
||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||
const request = context.switchToHttp().getRequest();
|
||
const token = this.extractTokenFromHeader(request);
|
||
|
||
if (!token) {
|
||
throw new UnauthorizedException('缺少访问令牌');
|
||
}
|
||
|
||
try {
|
||
// 验证JWT令牌
|
||
const payload = this.jwtService.verify(token);
|
||
|
||
// 验证平台令牌有效性
|
||
const adapter = this.platformAdapterFactory.getAdapter(payload.platform);
|
||
const isValid = await adapter.validateToken(token);
|
||
|
||
if (!isValid) {
|
||
throw new UnauthorizedException('令牌无效');
|
||
}
|
||
|
||
// 将用户信息附加到请求对象
|
||
request.user = {
|
||
userId: payload.userId,
|
||
unifiedUserId: payload.unifiedUserId,
|
||
platform: payload.platform,
|
||
};
|
||
|
||
return true;
|
||
} catch (error) {
|
||
throw new UnauthorizedException('令牌验证失败');
|
||
}
|
||
}
|
||
|
||
private extractTokenFromHeader(request: any): string | undefined {
|
||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||
return type === 'Bearer' ? token : undefined;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 📊 核心优势
|
||
|
||
### 🎯 设计优势
|
||
- **策略模式**: 通过工厂模式动态选择平台适配器,易于扩展
|
||
- **统一接口**: 所有平台遵循相同的接口规范,简化上层业务逻辑
|
||
- **依赖注入**: 充分利用NestJS的DI容器管理依赖关系
|
||
- **类型安全**: 全链路TypeScript类型检查,减少运行时错误
|
||
|
||
### 🔧 技术特点
|
||
- **异步处理**: 支持OAuth流程的异步令牌刷新机制
|
||
- **错误隔离**: 各平台错误不会相互影响,提高系统稳定性
|
||
- **配置分离**: 平台密钥通过环境变量管理,提高安全性
|
||
- **数据统一**: 不同平台用户数据映射到统一结构
|
||
|
||
### 📈 扩展能力
|
||
- **快速接入**: 新增平台只需实现BaseAdapter接口
|
||
- **功能扩展**: 通过ExtensionData表存储平台特有功能
|
||
- **多租户**: 通过平台隔离支持多租户架构
|
||
- **监控集成**: 便于集成日志、监控和性能追踪
|
||
|
||
## 📋 官方API验证结果
|
||
|
||
### 微信小程序 API (已验证)
|
||
- **接口地址**: `GET https://api.weixin.qq.com/sns/jscode2session`
|
||
- **请求参数**: `appid`, `secret`, `js_code`, `grant_type=authorization_code`
|
||
- **返回数据**: `openid`, `session_key`, `unionid`, `errcode`, `errmsg`
|
||
- **重要特性**:
|
||
- code只能使用一次,有效期5分钟
|
||
- session_key不支持刷新,需要重新登录
|
||
- 支持加密用户信息解密(encryptedData + iv)
|
||
|
||
### 抖音小程序 API (已验证)
|
||
- **接口地址**: `POST https://developer.toutiao.com/api/apps/v2/jscode2session`
|
||
- **请求参数**: `appid`, `secret`, `code`, `anonymous_code`(可选)
|
||
- **返回数据**: `session_key`, `openid`, `anonymous_openid`, `unionid`
|
||
- **重要特性**:
|
||
- 返回格式为 `{err_no: 0, err_tips: "success", data: {...}}`
|
||
- session_key每次登录都会刷新
|
||
- 支持匿名用户和实名用户
|
||
|
||
## ⚠️ 设计方案需要修正的问题
|
||
|
||
### 1. 抖音API返回格式错误
|
||
**问题**: 原设计中抖音返回格式不正确
|
||
**修正**: 抖音API返回格式应为:
|
||
```typescript
|
||
interface BytedanceAuthResponse {
|
||
err_no: number;
|
||
err_tips: string;
|
||
log_id: string;
|
||
data: {
|
||
session_key: string;
|
||
openid: string;
|
||
anonymous_openid: string;
|
||
unionid: string;
|
||
};
|
||
}
|
||
```
|
||
|
||
### 2. 抖音认证流程简化
|
||
**问题**: 抖音不需要separate的getUserInfo调用
|
||
**修正**: 抖音的code2session已经包含基本用户标识,不需要额外的用户信息接口
|
||
|
||
### 3. Token存储策略调整
|
||
**问题**: 微信存储session_key,抖音存储access_token,混淆了概念
|
||
**修正**: 统一存储策略,微信存储session_key到accessToken字段,抖音存储实际的session_key
|
||
|
||
## 🤔 关键讨论要点
|
||
|
||
1. **认证流程差异处理**:
|
||
- **微信**: code -> session_key+openid,可选解密用户信息
|
||
- **抖音**: code -> session_key+openid+unionid,一步到位
|
||
- **统一方案**: 都使用session_key作为身份凭证,存储到同一字段
|
||
|
||
2. **用户信息获取策略**:
|
||
- **微信**: 依赖前端传递基础信息或解密详细信息
|
||
- **抖音**: 服务端直接获取基础身份信息
|
||
- **建议**: 以openid为唯一标识,其他信息为可选补充
|
||
|
||
3. **Token刷新机制**:
|
||
- **微信**: 不支持刷新,需要重新登录
|
||
- **抖音**: session_key每次登录自动刷新
|
||
- **建议**: 统一使用重新登录策略,简化复杂度
|
||
|
||
4. **错误处理统一**:
|
||
- **微信**: errcode/errmsg格式
|
||
- **抖音**: err_no/err_tips格式
|
||
- **建议**: 在适配器层统一错误格式
|
||
|
||
5. **匿名用户支持**:
|
||
- **抖音**: 支持anonymous_openid
|
||
- **微信**: 不支持匿名模式
|
||
- **建议**: 在抖音适配器中处理匿名用户逻辑
|
||
|
||
6. **数据安全考虑**:
|
||
- 两平台都强调session_key不能下发到客户端
|
||
- 需要在设计中明确session_key的服务端存储和使用
|
||
|
||
## 📝 实施步骤建议
|
||
|
||
1. **Phase 1**: 实现基础架构和接口定义
|
||
2. **Phase 2**: 完成微信适配器和测试用例
|
||
3. **Phase 3**: 完成抖音适配器和测试用例
|
||
4. **Phase 4**: 集成测试和性能优化
|
||
5. **Phase 5**: 部署和监控配置
|
||
|
||
---
|
||
|
||
*本设计方案遵循NestJS最佳实践,采用依赖注入和模块化架构,确保代码质量和扩展性。* |