feat: 初始化项目

This commit is contained in:
richarjiang
2025-08-13 15:17:33 +08:00
commit 4f9d648a50
72 changed files with 29051 additions and 0 deletions

228
src/users/cos.service.ts Normal file
View File

@@ -0,0 +1,228 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as STS from 'qcloud-cos-sts';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class CosService {
private readonly logger = new Logger(CosService.name);
private readonly secretId: string;
private readonly secretKey: string;
private readonly bucket: string;
private readonly region: string;
private readonly cdnDomain: string;
private readonly allowPrefix: string;
constructor(private configService: ConfigService) {
this.secretId = this.configService.get<string>('TENCENT_SECRET_ID') || '';
this.secretKey = this.configService.get<string>('TENCENT_SECRET_KEY') || '';
this.bucket = this.configService.get<string>('COS_BUCKET') || '';
this.region = this.configService.get<string>('COS_REGION') || 'ap-guangzhou';
this.cdnDomain = this.configService.get<string>('COS_CDN_DOMAIN') || 'https://cdn.richarjiang.com';
this.allowPrefix = this.configService.get<string>('COS_ALLOW_PREFIX') || 'tennis-uploads/*';
if (!this.secretId || !this.secretKey || !this.bucket) {
throw new Error('腾讯云COS配置缺失TENCENT_SECRET_ID, TENCENT_SECRET_KEY, COS_BUCKET 是必需的');
}
}
/**
* 获取上传临时密钥
*/
async getUploadToken(userId: string): Promise<any> {
try {
this.logger.log(`获取上传临时密钥用户ID: ${userId}`);
// 生成上传路径前缀
const prefix = this.generateUploadPrefix();
// 配置临时密钥策略
const policy = this.generateUploadPolicy(prefix);
console.log('policy', policy);
// 获取临时密钥
const stsResult = await this.getStsToken(policy);
const response: any = {
tmpSecretId: stsResult.credentials.tmpSecretId,
tmpSecretKey: stsResult.credentials.tmpSecretKey,
sessionToken: stsResult.credentials.sessionToken,
startTime: stsResult.startTime,
expiredTime: stsResult.expiredTime,
bucket: this.bucket,
region: this.region,
prefix: prefix,
cdnDomain: this.cdnDomain,
};
this.logger.log(`临时密钥获取成功用户ID: ${userId}, 有效期至: ${new Date(stsResult.expiredTime * 1000)}`);
return response;
} catch (error) {
this.logger.error(`获取上传临时密钥失败: ${error.message}`, error.stack);
throw new BadRequestException(`获取上传密钥失败: ${error.message}`);
}
}
/**
* 上传完成回调
*/
async uploadComplete(userId: string, completeDto: any): Promise<any> {
try {
this.logger.log(`文件上传完成用户ID: ${userId}, 文件Key: ${completeDto.fileKey}`);
// 验证文件Key是否符合用户权限
const expectedPrefix = this.generateUploadPrefix();
if (!completeDto.fileKey.startsWith(expectedPrefix)) {
throw new BadRequestException('文件路径不符合权限要求');
}
// 生成文件访问URL
const fileUrl = this.generateFileUrl(completeDto.fileKey);
const response: any = {
fileUrl,
fileKey: completeDto.fileKey,
fileType: completeDto.fileType,
uploadTime: new Date(),
};
this.logger.log(`文件上传完成处理成功文件URL: ${fileUrl}`);
return response;
} catch (error) {
this.logger.error(`处理上传完成失败: ${error.message}`, error.stack);
throw new BadRequestException(`处理上传完成失败: ${error.message}`);
}
}
/**
* 生成上传路径前缀
*/
private generateUploadPrefix(): string {
const now = new Date();
return `uploads/*`;
}
/**
* 生成上传策略
*/
private generateUploadPolicy(prefix: string): any {
var shortBucketName = this.bucket.substr(0, this.bucket.lastIndexOf('-'));
var appId = this.bucket.substr(1 + this.bucket.lastIndexOf('-'));
const allowActions = [
'name/cos:PutObject',
'name/cos:PostObject',
'name/cos:InitiateMultipartUpload',
'name/cos:ListMultipartUploads',
'name/cos:ListParts',
'name/cos:UploadPart',
'name/cos:CompleteMultipartUpload',
];
const policy = {
version: '2.0',
statement: [
{
effect: 'allow',
principal: { qcs: ['*'] },
action: allowActions,
resource: [
'qcs::cos:' + this.region + ':uid/' + appId + ':prefix//' + appId + '/' + shortBucketName + '/' + this.allowPrefix,
],
condition: {},
},
],
};
return policy;
}
/**
* 获取STS临时密钥
*/
private async getStsToken(policy: any): Promise<any> {
return new Promise((resolve, reject) => {
const config = {
secretId: this.secretId,
secretKey: this.secretKey,
policy: policy,
durationSeconds: 3600, // 1小时有效期
bucket: this.bucket,
region: this.region,
allowPrefix: this.allowPrefix,
endpoint: 'sts.tencentcloudapi.com', // 域名非必须与host二选一默认为 sts.tencentcloudapi.com
};
console.log('config', config);
STS.getCredential(config, (err: any, data: any) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
/**
* 生成文件访问URL
*/
private generateFileUrl(fileKey: string): string {
if (this.cdnDomain) {
return `${this.cdnDomain}/${fileKey}`;
} else {
return `https://${this.bucket}.cos.${this.region}.myqcloud.com/${fileKey}`;
}
}
/**
* 生成唯一文件名
*/
generateUniqueFileName(originalName: string, fileExtension?: string): string {
const uuid = uuidv4();
const ext = fileExtension || this.getFileExtension(originalName);
const timestamp = Date.now();
return `${timestamp}-${uuid}.${ext}`;
}
/**
* 获取文件扩展名
*/
private getFileExtension(filename: string): string {
const parts = filename.split('.');
return parts.length > 1 ? parts.pop()!.toLowerCase() : '';
}
/**
* 验证文件类型
*/
validateFileType(fileType: string, fileExtension: string): boolean {
const allowedTypes = {
video: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'],
image: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'],
document: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
};
const allowed = allowedTypes[fileType as keyof typeof allowedTypes];
return allowed ? allowed.includes(fileExtension.toLowerCase()) : false;
}
/**
* 获取文件大小限制(字节)
*/
getFileSizeLimit(fileType: string): number {
const limits = {
video: 500 * 1024 * 1024, // 500MB
image: 10 * 1024 * 1024, // 10MB
document: 50 * 1024 * 1024, // 50MB
};
return limits[fileType as keyof typeof limits] || 10 * 1024 * 1024;
}
}

View File

@@ -0,0 +1,163 @@
import { IsString, IsOptional, IsObject, IsEnum, IsNumber, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ResponseCode } from 'src/base.dto';
// App Store Server Notification V2 的通知类型
export enum NotificationType {
CONSUMPTION_REQUEST = 'CONSUMPTION_REQUEST',
DID_CHANGE_RENEWAL_PREF = 'DID_CHANGE_RENEWAL_PREF',
DID_CHANGE_RENEWAL_STATUS = 'DID_CHANGE_RENEWAL_STATUS',
DID_FAIL_TO_RENEW = 'DID_FAIL_TO_RENEW',
DID_RENEW = 'DID_RENEW',
EXPIRED = 'EXPIRED',
GRACE_PERIOD_EXPIRED = 'GRACE_PERIOD_EXPIRED',
OFFER_REDEEMED = 'OFFER_REDEEMED',
PRICE_INCREASE = 'PRICE_INCREASE',
REFUND = 'REFUND',
REFUND_DECLINED = 'REFUND_DECLINED',
RENEWAL_EXTENDED = 'RENEWAL_EXTENDED',
RENEWAL_EXTENSION = 'RENEWAL_EXTENSION',
REVOKE = 'REVOKE',
SUBSCRIBED = 'SUBSCRIBED',
TEST = 'TEST',
}
// 通知子类型
export enum NotificationSubtype {
INITIAL_BUY = 'INITIAL_BUY',
RESUBSCRIBE = 'RESUBSCRIBE',
DOWNGRADE = 'DOWNGRADE',
UPGRADE = 'UPGRADE',
AUTO_RENEW_ENABLED = 'AUTO_RENEW_ENABLED',
AUTO_RENEW_DISABLED = 'AUTO_RENEW_DISABLED',
VOLUNTARY = 'VOLUNTARY',
BILLING_RETRY = 'BILLING_RETRY',
PRICE_INCREASE = 'PRICE_INCREASE',
GRACE_PERIOD = 'GRACE_PERIOD',
BILLING_RECOVERY = 'BILLING_RECOVERY',
PENDING = 'PENDING',
ACCEPTED = 'ACCEPTED',
PRODUCT_NOT_FOR_SALE = 'PRODUCT_NOT_FOR_SALE',
SUMMARY = 'SUMMARY',
FAILURE = 'FAILURE',
}
// App Store Server Notification V2 数据结构
export class AppStoreNotificationDto {
@ApiProperty({ description: '通知类型', enum: NotificationType })
@IsEnum(NotificationType)
notificationType: NotificationType;
@ApiProperty({ description: '通知子类型', enum: NotificationSubtype, required: false })
@IsOptional()
@IsEnum(NotificationSubtype)
subtype?: NotificationSubtype;
@ApiProperty({ description: '通知唯一标识符' })
@IsString()
notificationUUID: string;
@ApiProperty({ description: '通知版本' })
@IsString()
version: string;
@ApiProperty({ description: '签名的事务信息JWS格式' })
@IsString()
signedTransactionInfo: string;
@ApiProperty({ description: '签名的续订信息JWS格式', required: false })
@IsOptional()
@IsString()
signedRenewalInfo?: string;
@ApiProperty({ description: 'App Bundle ID' })
@IsString()
bundleId: string;
@ApiProperty({ description: '产品ID' })
@IsString()
productId: string;
@ApiProperty({ description: '通知发生的时间戳(毫秒)' })
@IsNumber()
notificationTimestamp: number;
@ApiProperty({ description: '环境Sandbox 或 Production' })
@IsString()
environment: string;
@ApiProperty({ description: '应用商店Connect的App ID' })
@IsNumber()
appAppleId: number;
@ApiProperty({ description: '签名的通知负载JWS格式', required: false })
@IsOptional()
@IsString()
signedPayload?: string;
}
// 完整的App Store Server Notification V2请求体
export class AppStoreServerNotificationDto {
@ApiProperty({ description: '签名的负载JWS格式' })
@IsString()
signedPayload: string;
}
// 处理通知的响应DTO
export class ProcessNotificationResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '处理结果' })
data: {
processed: boolean;
notificationType?: NotificationType;
notificationUUID?: string;
userId?: string;
transactionId?: string;
};
}
// 解码后的交易信息
export interface DecodedTransactionInfo {
originalTransactionId: string;
transactionId: string;
webOrderLineItemId: string;
bundleId: string;
productId: string;
subscriptionGroupIdentifier: string;
purchaseDate: number;
originalPurchaseDate: number;
expiresDate?: number;
quantity: number;
type: string;
appAccountToken?: string;
inAppOwnershipType: string;
signedDate: number;
environment: string;
transactionReason?: string;
storefront: string;
storefrontId: string;
price?: number;
currency?: string;
}
// 解码后的续订信息
export interface DecodedRenewalInfo {
originalTransactionId: string;
autoRenewProductId: string;
productId: string;
autoRenewStatus: number;
isInBillingRetryPeriod?: boolean;
priceIncreaseStatus?: number;
gracePeriodExpiresDate?: number;
offerType?: number;
offerIdentifier?: string;
signedDate: number;
environment: string;
recentSubscriptionStartDate?: number;
renewalDate?: number;
}

View File

@@ -0,0 +1,113 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { ResponseCode } from 'src/base.dto';
export class AppleLoginDto {
@ApiProperty({
description: 'Apple Identity Token (JWT)',
example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
identityToken: string;
@ApiProperty({
description: '用户标识符(可选,首次登录时提供)',
example: 'user123',
required: false,
})
@IsString()
@IsOptional()
name?: string;
@ApiProperty({
description: '用户邮箱(可选,首次登录时提供)',
example: 'user@example.com',
required: false,
})
@IsString()
@IsOptional()
email?: string;
}
export class AppleLoginResponseDto {
@ApiProperty({
description: '响应代码',
example: ResponseCode.SUCCESS,
})
code: ResponseCode;
@ApiProperty({
description: '响应消息',
example: 'success',
})
message: string;
@ApiProperty({
description: '登录结果数据',
example: {
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
expiresIn: 2592000,
user: {
id: 'apple_000123.456789abcdef',
name: '张三',
email: 'user@example.com',
isNew: false,
isVip: true,
membershipExpiration: '2024-12-31T23:59:59.000Z'
}
},
})
data: {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: {
id: string;
name: string;
email: string;
isNew: boolean;
isVip: boolean;
membershipExpiration?: Date;
[key: string]: any;
};
};
}
export class RefreshTokenDto {
@ApiProperty({
description: '刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
refreshToken: string;
}
export class RefreshTokenResponseDto {
@ApiProperty({
description: '响应代码',
example: ResponseCode.SUCCESS,
})
code: ResponseCode;
@ApiProperty({
description: '响应消息',
example: 'success',
})
message: string;
@ApiProperty({
description: '新的访问令牌数据',
example: {
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
expiresIn: 2592000
},
})
data: {
accessToken: string;
expiresIn: number;
};
}

View File

@@ -0,0 +1,99 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, IsDateString, IsNumber, Min } from 'class-validator';
import { LogLevel } from '../models/client-log.model';
import { BaseResponseDto } from '../../base.dto';
// 创建客户端日志请求DTO
export class CreateClientLogDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsString()
@IsNotEmpty()
logContent: string;
@IsOptional()
@IsEnum(LogLevel)
logLevel?: LogLevel;
@IsOptional()
@IsString()
clientVersion?: string;
@IsOptional()
@IsString()
deviceModel?: string;
@IsOptional()
@IsString()
iosVersion?: string;
@IsOptional()
@IsDateString()
clientTimestamp?: string;
}
// 批量创建客户端日志请求DTO
export class CreateBatchClientLogDto {
@IsString()
@IsNotEmpty()
userId: string;
logs: Omit<CreateClientLogDto, 'userId'>[];
}
// 查询客户端日志请求DTO
export class GetClientLogsDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsOptional()
@IsNumber()
@Min(1)
page?: number;
@IsOptional()
@IsNumber()
@Min(1)
pageSize?: number;
@IsOptional()
@IsEnum(LogLevel)
logLevel?: LogLevel;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
}
// 客户端日志响应DTO
export class ClientLogResponseDto {
id: number;
userId: string;
logContent: string;
logLevel: LogLevel;
clientVersion?: string;
deviceModel?: string;
iosVersion?: string;
clientTimestamp?: Date;
createdAt: Date;
}
// 创建客户端日志响应DTO
export interface CreateClientLogResponseDto extends BaseResponseDto<ClientLogResponseDto> {}
// 批量创建客户端日志响应DTO
export interface CreateBatchClientLogResponseDto extends BaseResponseDto<ClientLogResponseDto[]> {}
// 获取客户端日志列表响应DTO
export interface GetClientLogsResponseDto extends BaseResponseDto<{
total: number;
list: ClientLogResponseDto[];
page: number;
pageSize: number;
}> {}

View File

@@ -0,0 +1,48 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ResponseCode } from 'src/base.dto';
import { Type } from 'class-transformer';
export interface TopicLibrary {
topic: string;
keywords: string;
opening: string;
}
export class CreatePyTopicDto {
@IsNotEmpty()
@ApiProperty({
description: '话题',
example: {
topic: '话题',
keywords: '关键词',
opening: '开场白',
},
type: 'object',
properties: {
topic: {
type: 'string',
},
keywords: {
type: 'string',
},
opening: { type: 'string' },
},
})
data: TopicLibrary;
@IsString()
@IsNotEmpty()
@ApiProperty({ description: '用户ID', example: '123' })
userId: string;
}
export class CreatePyTopicResponseDto {
@ApiProperty({ description: '状态码', example: ResponseCode.SUCCESS })
code: ResponseCode;
@ApiProperty({ description: '消息', example: 'success' })
message: string;
@ApiProperty({ description: '数据', example: {} })
data: any;
}

View File

@@ -0,0 +1,21 @@
import { IsString, IsEmail, IsOptional, MinLength, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@IsString({ message: '用户ID必须是字符串' })
@IsNotEmpty({ message: '用户ID不能为空' })
@ApiProperty({ description: '用户ID', example: '1234567890' })
id: string;
@IsString({ message: '用户名必须是字符串' })
@MinLength(1, { message: '用户名长度不能少于1个字符' })
@IsOptional()
@ApiProperty({ description: '用户名', example: '张三' })
name: string;
@IsString({ message: '邮箱必须是字符串' })
@MinLength(1, { message: '邮箱长度不能少于1个字符' })
@IsEmail({}, { message: '邮箱格式不正确' })
@IsOptional()
@ApiProperty({ description: '邮箱', example: 'zhangsan@example.com' })
mail: string;
}

View File

@@ -0,0 +1,16 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { BaseResponseDto, ResponseCode } from '../../base.dto';
import { ApiProperty } from '@nestjs/swagger';
export class DeleteAccountDto {
@ApiProperty({ description: '用户ID', example: 'user123' })
@IsString()
@IsNotEmpty()
userId: string;
}
export class DeleteAccountResponseDto implements BaseResponseDto<{ success: boolean }> {
code: ResponseCode;
message: string;
data: { success: boolean };
}

View File

@@ -0,0 +1,23 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class EncryptedCreateUserDto {
@IsString({ message: '加密数据必须是字符串' })
@IsNotEmpty({ message: '加密数据不能为空' })
@ApiProperty({
description: '加密的用户数据',
example: 'eyJpdiI6IjEyMzQ1Njc4OTAiLCJ0YWciOiJhYmNkZWZnaCIsImRhdGEiOiIuLi4ifQ=='
})
encryptedData: string;
}
export class EncryptedResponseDto {
@ApiProperty({ description: '是否成功', example: true })
success: boolean;
@ApiProperty({ description: '响应消息', example: '操作成功' })
message: string;
@ApiProperty({ description: '加密的响应数据', required: false })
encryptedData?: string;
}

View File

@@ -0,0 +1,85 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
import { BaseResponseDto, ResponseCode } from 'src/base.dto';
export class GuestLoginDto {
@ApiProperty({ description: '设备ID', example: 'ABCD-1234-EFGH-5678' })
@IsNotEmpty({ message: '设备ID不能为空' })
@IsString({ message: '设备ID必须是字符串' })
deviceId: string;
@ApiProperty({ description: '设备名称', example: 'iPhone 15 Pro' })
@IsNotEmpty({ message: '设备名称不能为空' })
@IsString({ message: '设备名称必须是字符串' })
deviceName: string;
@ApiProperty({ description: '设备型号', example: 'iPhone16,1', required: false })
@IsOptional()
@IsString({ message: '设备型号必须是字符串' })
deviceModel?: string;
@ApiProperty({ description: 'iOS版本', example: '17.0.1', required: false })
@IsOptional()
@IsString({ message: 'iOS版本必须是字符串' })
iosVersion?: string;
}
export interface GuestLoginData {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: {
id: string;
name: string;
mail: string;
gender: string;
avatar?: string;
birthDate?: Date;
freeUsageCount: number;
membershipExpiration?: Date;
lastLogin: Date;
createdAt: Date;
updatedAt: Date;
isNew: boolean;
isVip: boolean;
isGuest: boolean;
deviceId?: string;
deviceName?: string;
deviceModel?: string;
iosVersion?: string;
};
}
export class GuestLoginResponseDto implements BaseResponseDto<GuestLoginData> {
@ApiProperty({ description: '响应码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '游客登录响应数据' })
data: GuestLoginData;
}
export class RefreshGuestTokenDto {
@ApiProperty({ description: '刷新令牌' })
@IsNotEmpty({ message: '刷新令牌不能为空' })
@IsString({ message: '刷新令牌必须是字符串' })
refreshToken: string;
}
export interface RefreshGuestTokenData {
accessToken: string;
expiresIn: number;
}
export class RefreshGuestTokenResponseDto implements BaseResponseDto<RefreshGuestTokenData> {
@ApiProperty({ description: '响应码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '刷新游客令牌响应数据' })
data: RefreshGuestTokenData;
}

View File

@@ -0,0 +1,32 @@
import { User } from '../models/user.model';
import { BaseResponseDto, ResponseCode } from '../../base.dto';
import { IsString, IsNotEmpty, IsDate } from 'class-validator';
import { PurchasePlatform, PurchaseType } from '../models/user-purchase.model';
export class UpdateMembershipDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsString()
@IsNotEmpty()
purchaseType: PurchaseType;
@IsString()
@IsNotEmpty()
platform: PurchasePlatform;
@IsString()
@IsNotEmpty()
transactionId: string;
@IsString()
@IsNotEmpty()
productId: string;
}
export class UpdateMembershipResponseDto implements BaseResponseDto<User> {
code: ResponseCode;
message: string;
data: User;
}

View File

@@ -0,0 +1,145 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PurchasePlatform, PurchaseType } from '../models/user-purchase.model';
import { ResponseCode } from 'src/base.dto';
export class VerifyPurchaseDto {
@ApiProperty({
description: '用户ID',
example: 'user123',
})
@IsString()
@IsNotEmpty()
userId: string;
@ApiProperty({
description: '内购类型',
enum: PurchaseType,
example: PurchaseType.LIFETIME,
})
@IsEnum(PurchaseType)
@IsNotEmpty()
purchaseType: PurchaseType;
@ApiProperty({
description: '平台',
enum: PurchasePlatform,
example: PurchasePlatform.IOS,
})
@IsEnum(PurchasePlatform)
@IsNotEmpty()
platform: PurchasePlatform;
@ApiProperty({
description: '交易ID',
example: '1000000123456789',
})
@IsString()
@IsNotEmpty()
transactionId: string;
@ApiProperty({
description: '产品ID',
example: 'com.app.pro.lifetime',
})
@IsString()
@IsNotEmpty()
productId: string;
@ApiProperty({
description: '收据数据base64编码',
example: 'MIIT0QYJU...',
})
@IsString()
@IsNotEmpty()
receipt: string;
}
export class PurchaseResponseDto {
@ApiProperty({
description: '响应代码',
example: ResponseCode.SUCCESS,
})
code: ResponseCode;
@ApiProperty({
description: '响应消息',
example: 'success',
})
message: string;
@ApiProperty({
description: '购买信息',
example: {
id: 'uuid-purchase-id',
userId: 'user123',
purchaseType: PurchaseType.LIFETIME,
status: 'ACTIVE',
platform: PurchasePlatform.IOS,
transactionId: '1000000123456789',
productId: 'com.app.pro.lifetime',
createdAt: '2023-06-15T08:00:00Z',
updatedAt: '2023-06-15T08:00:00Z',
},
})
data: any;
}
export class CheckPurchaseStatusDto {
@ApiProperty({
description: '用户ID',
example: 'user123',
})
@IsString()
@IsNotEmpty()
userId: string;
@ApiProperty({
description: '购买类型(可选)',
enum: PurchaseType,
example: PurchaseType.LIFETIME,
required: false,
})
@IsEnum(PurchaseType)
@IsOptional()
purchaseType?: PurchaseType;
}
export class PurchaseStatusResponseDto {
@ApiProperty({
description: '响应代码',
example: ResponseCode.SUCCESS,
})
code: ResponseCode;
@ApiProperty({
description: '响应消息',
example: 'success',
})
message: string;
@ApiProperty({
description: '购买状态信息',
example: {
hasLifetime: true,
hasActiveSubscription: false,
subscriptionExpiresAt: null,
purchases: [
{
id: 'uuid-purchase-id',
purchaseType: PurchaseType.LIFETIME,
status: 'ACTIVE',
platform: PurchasePlatform.IOS,
productId: 'com.app.pro.lifetime',
createdAt: '2023-06-15T08:00:00Z',
}
]
},
})
data: {
hasLifetime: boolean;
hasActiveSubscription: boolean;
subscriptionExpiresAt: Date | null;
purchases: any[];
};
}

View File

@@ -0,0 +1,69 @@
import { BaseResponseDto } from '../../base.dto';
// 活跃权益信息
export interface ActiveEntitlement {
isActive: boolean;
expirationDate?: string;
productIdentifier: string;
willRenew: boolean;
originalPurchaseDate: string;
periodType: string;
productPlanIdentifier?: string;
isSandbox: boolean;
latestPurchaseDateMillis: number;
identifier: string;
ownershipType: string;
verification: string;
store: string;
latestPurchaseDate: string;
originalPurchaseDateMillis: number;
billingIssueDetectedAtMillis?: number;
expirationDateMillis?: number;
unsubscribeDetectedAt?: string;
unsubscribeDetectedAtMillis?: number;
billingIssueDetectedAt?: string;
}
// 非订阅交易信息
export interface NonSubscriptionTransaction {
transactionIdentifier: string;
revenueCatId: string;
purchaseDateMillis: number;
productIdentifier: string;
purchaseDate: string;
productId: string;
}
// 客户信息
export interface CustomerInfo {
originalAppUserId: string;
activeEntitlements: { [key: string]: ActiveEntitlement };
nonSubscriptionTransactions: NonSubscriptionTransaction[];
activeSubscriptions: string[];
restoredProducts: string[];
}
// 恢复购买请求 DTO
export interface RestorePurchaseDto {
customerInfo: CustomerInfo;
}
// 恢复的购买信息
export interface RestoredPurchaseInfo {
productId: string;
transactionId: string;
purchaseDate: string;
expirationDate?: string;
entitlementId: string;
store: string;
purchaseType: string; // LIFETIME, WEEKLY, QUARTERLY
}
export interface RestorePurchaseResponseData {
restoredPurchases: RestoredPurchaseInfo[];
membershipExpiration?: string;
message: string;
totalRestoredCount: number;
}
export interface RestorePurchaseResponseDto extends BaseResponseDto<RestorePurchaseResponseData> { }

View File

@@ -0,0 +1,104 @@
import { Type } from 'class-transformer';
import {
IsString,
IsNumber,
IsObject,
IsOptional,
IsEnum,
IsBoolean,
ValidateNested,
IsArray,
} from 'class-validator';
export enum RevenueCatEventType {
TEST = 'TEST',
INITIAL_PURCHASE = 'INITIAL_PURCHASE',
RENEWAL = 'RENEWAL',
CANCELLATION = 'CANCELLATION',
UNCANCELLATION = 'UNCANCELLATION',
NON_RENEWING_PURCHASE = 'NON_RENEWING_PURCHASE',
SUBSCRIPTION_PAUSED = 'SUBSCRIPTION_PAUSED',
EXPIRATION = 'EXPIRATION',
BILLING_ISSUE = 'BILLING_ISSUE',
PRODUCT_CHANGE = 'PRODUCT_CHANGE',
TRANSFER = 'TRANSFER',
}
export class RevenueCatEventDto {
@IsString()
id: string;
@IsEnum(RevenueCatEventType)
type: RevenueCatEventType;
@IsNumber()
event_timestamp_ms: number;
@IsString()
app_user_id: string;
@IsString()
original_app_user_id: string;
@IsArray()
@IsString({ each: true })
aliases: string[];
@IsString()
@IsOptional()
product_id?: string;
@IsArray()
@IsString({ each: true })
entitlement_ids: string[];
@IsString()
@IsOptional()
period_type?: string;
@IsNumber()
@IsOptional()
purchased_at_ms?: number;
@IsNumber()
@IsOptional()
expiration_at_ms?: number;
@IsString()
@IsOptional()
store?: string;
@IsString()
@IsOptional()
environment?: string;
@IsBoolean()
@IsOptional()
is_trial_conversion?: boolean;
@IsString()
@IsOptional()
cancel_reason?: string;
@IsString()
@IsOptional()
new_product_id?: string;
@IsNumber()
@IsOptional()
price?: number;
@IsString()
@IsOptional()
currency?: string;
}
export class RevenueCatWebhookDto {
@IsString()
api_version: string;
@IsObject()
@ValidateNested()
@Type(() => RevenueCatEventDto)
event: RevenueCatEventDto;
}

View File

@@ -0,0 +1,60 @@
import { BaseResponseDto } from '../../base.dto';
import { RestoreStatus, RestoreSource } from '../models/purchase-restore-log.model';
export interface SecurityAlertDto {
id: string;
userId: string;
transactionId: string;
productId: string;
originalAppUserId: string;
status: RestoreStatus;
source: RestoreSource;
clientIp: string;
userAgent: string;
failureReason?: string;
createdAt: Date;
riskScore: number;
alertType: 'DUPLICATE_TRANSACTION' | 'SUSPICIOUS_IP' | 'RAPID_REQUESTS' | 'CROSS_ACCOUNT_FRAUD';
}
export interface SecurityStatsDto {
totalRestoreAttempts: number;
successfulRestores: number;
failedRestores: number;
duplicateAttempts: number;
fraudDetected: number;
uniqueUsers: number;
uniqueIPs: number;
topRiskTransactions: SecurityAlertDto[];
}
export interface GetSecurityAlertsDto {
page?: number;
limit?: number;
status?: RestoreStatus;
alertType?: string;
startDate?: string;
endDate?: string;
userId?: string;
riskScoreMin?: number;
}
export interface SecurityAlertsResponseDto extends BaseResponseDto<{
alerts: SecurityAlertDto[];
total: number;
page: number;
limit: number;
}> { }
export interface SecurityStatsResponseDto extends BaseResponseDto<SecurityStatsDto> { }
export interface BlockTransactionDto {
transactionId: string;
reason: string;
}
export interface BlockTransactionResponseDto extends BaseResponseDto<{
blocked: boolean;
transactionId: string;
reason: string;
}> { }

View File

@@ -0,0 +1,81 @@
import { IsNumber, IsBoolean } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ResponseCode } from 'src/base.dto';
// 收藏话题请求DTO
export class FavoriteTopicDto {
@ApiProperty({ description: '话题ID' })
@IsNumber()
topicId: number;
}
// 取消收藏话题请求DTO
export class UnfavoriteTopicDto {
@ApiProperty({ description: '话题ID' })
@IsNumber()
topicId: number;
}
// 收藏操作响应DTO
export class FavoriteResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '操作结果' })
data: {
success: boolean;
isFavorited: boolean;
topicId: number;
};
}
// 扩展的话题响应DTO包含收藏状态
export class TopicWithFavoriteDto {
@ApiProperty({ description: '话题ID' })
id: number;
@ApiProperty({ description: '话题标题' })
topic: string;
@ApiProperty({ description: '开场白' })
opening: string | object;
@ApiProperty({ description: '脚本类型' })
scriptType: string;
@ApiProperty({ description: '脚本话题' })
scriptTopic: string;
@ApiProperty({ description: '关键词' })
keywords: string;
@ApiProperty({ description: '是否已收藏' })
@IsBoolean()
isFavorited: boolean;
@ApiProperty({ description: '创建时间' })
createdAt: Date;
@ApiProperty({ description: '更新时间' })
updatedAt: Date;
}
// 话题列表响应DTO包含收藏状态
export class TopicListWithFavoriteResponseDto {
@ApiProperty({ description: '响应代码' })
code: ResponseCode;
@ApiProperty({ description: '响应消息' })
message: string;
@ApiProperty({ description: '话题列表数据' })
data: {
list: TopicWithFavoriteDto[];
total: number;
page: number;
pageSize: number;
};
}

View File

@@ -0,0 +1,108 @@
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { ResponseCode } from 'src/base.dto';
import { TopicLibrary } from '../models/topic-library.model';
import { ApiProperty } from '@nestjs/swagger';
import { TopicCategory } from '../models/topic-category.model';
export class TopicLibraryResponseDto {
code: ResponseCode;
message: string;
data: TopicLibrary | TopicLibrary[] | null;
}
export class GetTopicLibraryRequestDto {
// 分页相关
@IsOptional()
@IsNumber()
@Min(1)
@ApiProperty({ description: '页码', example: 1 })
page?: number;
@IsOptional()
@IsNumber()
@Min(1)
@ApiProperty({ description: '每页条数', example: 10 })
pageSize?: number;
// 话题筛选
@IsString()
@ApiProperty({ description: '话题', example: '话题' })
topic: string;
// 用户ID用于查询用户自己的话题
@IsString()
@IsOptional()
@ApiProperty({ description: '用户ID', example: '123' })
userId: string;
@IsOptional()
@IsString()
@ApiProperty({ description: '加密参数', example: '加密参数' })
encryptedParameters?: string;
}
export class GetTopicLibraryResponseDto {
code: ResponseCode;
message: string;
data: TopicLibrary | TopicLibrary[] | null;
}
export class GenerateTopicRequestDto {
@ApiProperty({
description: '话题',
example: '话题',
required: false,
})
@IsOptional()
topic: string;
}
export class GenerateTopicResponseDto {
code: ResponseCode;
message: string;
data: TopicLibrary | TopicLibrary[] | null;
}
export class GetTopicCategoryResponseDto {
code: ResponseCode;
message: string;
data: TopicCategory | TopicCategory[] | null;
}
export class DeleteTopicRequestDto {
@ApiProperty({ description: '话题ID', example: 1 })
@IsNumber()
topicId: number;
}
export class DeleteTopicResponseDto {
@ApiProperty({ description: '响应码', example: 200 })
code: ResponseCode;
@ApiProperty({ description: '响应消息', example: '删除成功' })
message: string;
@ApiProperty({
description: '响应数据',
type: 'object',
properties: {
success: { type: 'boolean', example: true }
}
})
data: { success: boolean };
}
export class DislikeTopicRequestDto {
@ApiProperty({ description: '话题ID', example: 1 })
@IsNumber()
topicId: number;
}
export class DislikeTopicResponseDto {
@ApiProperty({ description: '响应码', example: 200 })
code: ResponseCode;
@ApiProperty({ description: '响应消息', example: '不喜欢成功' })
message: string;
@ApiProperty({ description: '响应数据', example: true })
data: boolean;
}

View File

@@ -0,0 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEmail, IsOptional, MinLength, IsNotEmpty, IsEnum } from 'class-validator';
import { ResponseCode } from 'src/base.dto';
import { Gender, User } from '../models/user.model';
export class UpdateUserDto {
@IsString({ message: '用户ID必须是字符串' })
@IsNotEmpty({ message: '用户ID不能为空' })
@ApiProperty({ description: '用户ID', example: '123' })
userId: string;
@IsString({ message: '用户名必须是字符串' })
@MinLength(1, { message: '用户名长度不能少于1个字符' })
@IsOptional()
@ApiProperty({ description: '用户名', example: '张三' })
name: string;
@IsString({ message: '头像必须是字符串' })
@IsOptional()
@ApiProperty({ description: '头像', example: 'base64' })
avatar: string;
@IsEnum(Gender, { message: '性别必须是枚举值' })
@IsOptional()
@ApiProperty({ description: '性别', example: 'male' })
gender: Gender;
// 时间戳
@IsOptional()
@ApiProperty({ description: '出生年月日', example: 1713859200 })
birthDate: number;
}
export class UpdateUserResponseDto {
@ApiProperty({ description: '状态码', example: ResponseCode.SUCCESS })
code: ResponseCode;
@ApiProperty({ description: '消息', example: 'success' })
message: string;
@ApiProperty({
description: '用户', example: {
id: '123',
name: '张三',
avatar: 'base64',
}
})
data: User | null;
}

View File

@@ -0,0 +1,62 @@
import { IsString, IsBoolean, IsOptional, IsNumber } from 'class-validator';
import { BaseResponseDto, ResponseCode } from 'src/base.dto';
import { UserRelationInfo } from '../models/user-relation-info.model';
export class UserRelationInfoDto {
@IsString()
userId: string;
@IsOptional()
@IsString()
myOccupation?: string;
@IsOptional()
@IsString()
myInterests?: string;
@IsOptional()
@IsString()
myCity?: string;
@IsOptional()
@IsString()
myCharacteristics?: string;
@IsOptional()
@IsString()
theirName?: string;
@IsOptional()
@IsString()
theirOccupation?: string;
@IsOptional()
@IsString()
theirBirthday?: string;
@IsOptional()
@IsString()
theirInterests?: string;
@IsOptional()
@IsString()
theirCity?: string;
@IsOptional()
@IsString()
currentStage?: string;
@IsOptional()
@IsBoolean()
isLongDistance?: boolean;
@IsOptional()
@IsString()
additionalDescription?: string;
}
export class UserRelationInfoResponseDto implements BaseResponseDto<UserRelationInfo> {
code: ResponseCode;
message: string;
data: UserRelationInfo;
}

View File

@@ -0,0 +1,33 @@
import { User } from '../models/user.model';
import { BaseResponseDto, ResponseCode } from '../../base.dto';
// 定义包含购买状态的用户数据接口
export interface PurchaseStatusInfo {
hasLifetime: boolean;
hasActiveSubscription: boolean;
subscriptionExpiresAt: Date | null;
purchases: any[];
}
export interface UserWithPurchaseStatus {
id: string;
name: string;
mail: string;
avatar: string;
createdAt: Date;
updatedAt: Date;
lastLogin: Date;
relationInfo?: any;
membershipExpiration: Date | null;
purchaseStatus?: PurchaseStatusInfo;
freeUsageCount: number;
maxUsageCount: number;
favoriteTopicCount: number;
isVip: boolean;
}
export class UserResponseDto implements BaseResponseDto<UserWithPurchaseStatus> {
code: ResponseCode;
message: string;
data: UserWithPurchaseStatus;
}

View File

@@ -0,0 +1,89 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from './user.model';
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
}
@Table({
tableName: 't_client_logs',
underscored: true,
})
export class ClientLog extends Model {
@Column({
type: DataType.INTEGER,
autoIncrement: true,
primaryKey: true,
})
declare id: number;
@ForeignKey(() => User)
@Column({
type: DataType.STRING,
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.TEXT,
allowNull: false,
comment: '日志内容',
})
declare logContent: string;
@Column({
type: DataType.STRING,
allowNull: true,
defaultValue: LogLevel.INFO,
comment: '日志级别',
})
declare logLevel: LogLevel;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '客户端版本',
})
declare clientVersion: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '设备型号',
})
declare deviceModel: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: 'iOS版本',
})
declare iosVersion: string;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '客户端时间戳',
})
declare clientTimestamp: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
comment: '服务器接收时间',
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
@BelongsTo(() => User)
user: User;
}

View File

@@ -0,0 +1,125 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from './user.model';
export enum RestoreStatus {
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
DUPLICATE = 'DUPLICATE',
FRAUD_DETECTED = 'FRAUD_DETECTED'
}
export enum RestoreSource {
REVENUE_CAT = 'REVENUE_CAT',
APP_STORE = 'APP_STORE',
MANUAL = 'MANUAL'
}
@Table({
tableName: 't_purchase_restore_logs',
underscored: true,
indexes: [
{
unique: true,
fields: ['transaction_id', 'product_id'] // 全局唯一约束
},
{
fields: ['user_id', 'created_at']
},
{
fields: ['original_app_user_id']
}
]
})
export class PurchaseRestoreLog extends Model {
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING,
allowNull: false,
})
userId: string;
@BelongsTo(() => User)
user: User;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '交易ID - 全局唯一'
})
transactionId: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '产品ID'
})
productId: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: 'RevenueCat原始用户ID'
})
originalAppUserId: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '恢复状态'
})
status: RestoreStatus;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '恢复来源'
})
source: RestoreSource;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '原始请求数据'
})
rawData: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '客户端IP地址'
})
clientIp: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '用户代理'
})
userAgent: string;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '失败或欺诈原因'
})
failureReason: string;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}

View File

@@ -0,0 +1,54 @@
import { Column, Model, Table, DataType } from 'sequelize-typescript';
@Table({
tableName: 't_revenue_cat_events',
underscored: true,
})
export class RevenueCatEvent extends Model {
@Column({
type: DataType.STRING,
primaryKey: true,
comment: 'RevenueCat Event ID',
})
declare eventId: string;
@Column({
type: DataType.STRING,
allowNull: false,
})
declare type: string;
@Column({
type: DataType.STRING,
allowNull: false,
})
declare appUserId: string;
@Column({
type: DataType.DATE,
allowNull: false,
})
declare eventTimestamp: Date;
@Column({
type: DataType.JSON,
allowNull: false,
comment: 'Full event payload from RevenueCat',
})
declare payload: Record<string, any>;
@Column({
type: DataType.BOOLEAN,
defaultValue: false,
allowNull: false,
comment: 'Flag to indicate if the event has been processed',
})
declare processed: boolean;
@Column({
type: DataType.DATE,
allowNull: true,
comment: 'Timestamp when the event was processed',
})
declare processedAt: Date;
}

View File

@@ -0,0 +1,96 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
import { User } from './user.model';
export enum PurchaseType {
LIFETIME = 'LIFETIME',
WEEKLY = 'WEEKLY',
QUARTERLY = 'QUARTERLY'
}
export enum PurchaseStatus {
ACTIVE = 'ACTIVE', // 有效
EXPIRED = 'EXPIRED', // 已过期(针对订阅)
CANCELED = 'CANCELED', // 已取消(退款等情况)
PENDING = 'PENDING' // 处理中
}
export enum PurchasePlatform {
IOS = 'IOS',
ANDROID = 'ANDROID',
WEB = 'WEB'
}
@Table({
tableName: 't_user_purchases',
underscored: true,
})
export class UserPurchase extends Model {
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING,
allowNull: false,
})
userId: string;
@BelongsTo(() => User)
user: User;
@Column({
type: DataType.STRING,
allowNull: false,
})
purchaseType: PurchaseType;
@Column({
type: DataType.STRING,
allowNull: false,
defaultValue: PurchaseStatus.ACTIVE,
})
status: PurchaseStatus;
@Column({
type: DataType.STRING,
allowNull: false,
})
platform: PurchasePlatform;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '平台原始交易ID',
})
transactionId: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '产品ID例如com.app.lifetime',
})
productId: string;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '订阅过期时间买断类型为null',
})
expiresAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}

View File

@@ -0,0 +1,125 @@
import { Column, Model, Table, DataType } from 'sequelize-typescript';
import * as dayjs from 'dayjs';
export enum Gender {
MALE = 'male',
FEMALE = 'female',
}
@Table({
tableName: 't_users',
underscored: true,
})
export class User extends Model {
@Column({
type: DataType.STRING,
unique: true,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING,
allowNull: false,
})
declare name: string;
@Column({
type: DataType.STRING,
allowNull: true,
unique: true,
})
declare mail: string;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare avatar: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '性别',
})
declare gender: Gender;
// 会员有效期
@Column({
type: DataType.DATE,
allowNull: true,
comment: '会员有效期',
})
declare membershipExpiration: Date;
// 出生年月日
@Column({
type: DataType.DATE,
allowNull: true,
comment: '出生年月日',
})
declare birthDate: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare lastLogin: Date;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '试用次数',
})
declare freeUsageCount: number;
// 游客相关字段
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否为游客用户',
})
declare isGuest: boolean;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '设备ID游客用户',
})
declare deviceId: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '设备名称(游客用户)',
})
declare deviceName: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '用户过去生成的话题 id 列表',
})
declare lastTopicIds: string;
declare isNew?: boolean;
get isVip(): boolean {
return this.membershipExpiration ? dayjs(this.membershipExpiration).isAfter(dayjs()) : false;
}
}

View File

@@ -0,0 +1,241 @@
import { Injectable, Logger, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as jwt from 'jsonwebtoken';
import * as jwksClient from 'jwks-rsa';
import axios from 'axios';
export interface AppleTokenPayload {
iss: string; // 发行者,应该是 https://appleid.apple.com
aud: string; // 受众,应该是你的 bundle ID
exp: number; // 过期时间
iat: number; // 签发时间
sub: string; // 用户的唯一标识符
email?: string; // 用户邮箱(可选)
email_verified?: boolean; // 邮箱是否验证(可选)
is_private_email?: boolean; // 是否是私有邮箱(可选)
real_user_status?: number; // 真实用户状态
transfer_sub?: string; // 转移的用户标识符(可选)
}
export interface AccessTokenPayload {
sub: string; // 用户ID
email?: string; // 用户邮箱
iat: number; // 签发时间
exp: number; // 过期时间
type: 'access'; // token类型
}
export interface RefreshTokenPayload {
sub: string; // 用户ID
iat: number; // 签发时间
exp: number; // 过期时间
type: 'refresh'; // token类型
}
@Injectable()
export class AppleAuthService {
private readonly logger = new Logger(AppleAuthService.name);
private readonly bundleId: string;
private readonly jwksClient: jwksClient.JwksClient;
private readonly accessTokenSecret: string;
private readonly refreshTokenSecret: string;
private readonly accessTokenExpiresIn = 30 * 24 * 60 * 60; // 30天
private readonly refreshTokenExpiresIn = 90 * 24 * 60 * 60; // 90天
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
) {
this.bundleId = this.configService.get<string>('APPLE_BUNDLE_ID') || '';
this.accessTokenSecret = this.configService.get<string>('JWT_ACCESS_SECRET') || 'your-access-token-secret-key';
this.refreshTokenSecret = this.configService.get<string>('JWT_REFRESH_SECRET') || 'your-refresh-token-secret-key';
// 初始化 JWKS 客户端,用于获取 Apple 的公钥
this.jwksClient = jwksClient({
jwksUri: 'https://appleid.apple.com/auth/keys',
cache: true,
cacheMaxAge: 24 * 60 * 60 * 1000, // 24小时缓存
rateLimit: true,
jwksRequestsPerMinute: 10,
});
if (!this.bundleId) {
this.logger.warn('APPLE_BUNDLE_ID 环境变量未设置Apple登录验证可能失败');
}
}
/**
* 获取 Apple 的公钥
*/
private async getApplePublicKey(kid: string): Promise<string> {
try {
const key = await this.jwksClient.getSigningKey(kid);
return key.getPublicKey();
} catch (error) {
this.logger.error(`获取Apple公钥失败: ${error.message}`);
throw new UnauthorizedException('无法验证Apple身份令牌');
}
}
/**
* 验证 Apple Identity Token
*/
async verifyAppleToken(identityToken: string): Promise<AppleTokenPayload> {
try {
// 解码 token header 获取 kid
const decodedHeader = jwt.decode(identityToken, { complete: true });
if (!decodedHeader || !decodedHeader.header.kid) {
throw new BadRequestException('无效的身份令牌格式');
}
const kid = decodedHeader.header.kid;
// 获取 Apple 公钥
const publicKey = await this.getApplePublicKey(kid);
this.logger.log(`Apple 公钥: ${publicKey}`);
this.logger.log(`this.bundleId: ${this.bundleId}`);
// 验证 token
const payload = jwt.verify(identityToken, publicKey, {
algorithms: ['RS256'],
audience: this.bundleId,
issuer: 'https://appleid.apple.com',
}) as AppleTokenPayload;
// 验证必要字段
if (!payload.sub) {
throw new BadRequestException('身份令牌缺少用户标识符');
}
// 验证 token 是否过期
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
throw new UnauthorizedException('身份令牌已过期');
}
this.logger.log(`Apple身份令牌验证成功用户ID: ${payload.sub}`);
return payload;
} catch (error) {
if (error instanceof UnauthorizedException || error instanceof BadRequestException) {
throw error;
}
this.logger.error(`验证Apple身份令牌失败: ${error.message}`);
throw new UnauthorizedException('身份令牌验证失败');
}
}
/**
* 生成访问令牌
*/
generateAccessToken(userId: string, email?: string): string {
const payload: AccessTokenPayload = {
sub: userId,
email,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + this.accessTokenExpiresIn,
type: 'access',
};
return jwt.sign(payload, this.accessTokenSecret, {
algorithm: 'HS256',
});
}
/**
* 生成刷新令牌
*/
generateRefreshToken(userId: string): string {
const payload: RefreshTokenPayload = {
sub: userId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + this.refreshTokenExpiresIn,
type: 'refresh',
};
return jwt.sign(payload, this.refreshTokenSecret, {
algorithm: 'HS256',
});
}
/**
* 验证访问令牌
*/
verifyAccessToken(token: string): AccessTokenPayload {
try {
const payload = jwt.verify(token, this.accessTokenSecret, {
algorithms: ['HS256'],
}) as AccessTokenPayload;
if (payload.type !== 'access') {
throw new UnauthorizedException('无效的令牌类型');
}
return payload;
} catch (error) {
this.logger.error(`验证访问令牌失败: ${error.message}`);
throw new UnauthorizedException('访问令牌无效或已过期');
}
}
/**
* 验证刷新令牌
*/
verifyRefreshToken(token: string): RefreshTokenPayload {
try {
const payload = jwt.verify(token, this.refreshTokenSecret, {
algorithms: ['HS256'],
}) as RefreshTokenPayload;
if (payload.type !== 'refresh') {
throw new UnauthorizedException('无效的令牌类型');
}
return payload;
} catch (error) {
this.logger.error(`验证刷新令牌失败: ${error.message}`);
throw new UnauthorizedException('刷新令牌无效或已过期');
}
}
/**
* 刷新访问令牌
*/
async refreshAccessToken(refreshToken: string, userEmail?: string): Promise<{ accessToken: string; expiresIn: number }> {
const payload = this.verifyRefreshToken(refreshToken);
const newAccessToken = this.generateAccessToken(payload.sub, userEmail);
return {
accessToken: newAccessToken,
expiresIn: this.accessTokenExpiresIn,
};
}
/**
* 从 Bearer token 中提取 token
*/
extractTokenFromHeader(authHeader: string): string {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('无效的授权头格式');
}
return authHeader.substring(7);
}
/**
* 获取访问令牌的过期时间(秒)
*/
getAccessTokenExpiresIn(): number {
return this.accessTokenExpiresIn;
}
/**
* 获取刷新令牌的过期时间(秒)
*/
getRefreshTokenExpiresIn(): number {
return this.refreshTokenExpiresIn;
}
}

View File

@@ -0,0 +1,494 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as jwt from 'jsonwebtoken';
import axios from 'axios';
@Injectable()
export class ApplePurchaseService {
private readonly logger = new Logger(ApplePurchaseService.name);
private readonly appSharedSecret: string;
private readonly keyId: string;
private readonly issuerId: string;
private readonly bundleId: string;
private readonly privateKeyPath: string;
private privateKey: string;
constructor(private configService: ConfigService) {
this.appSharedSecret = this.configService.get<string>('APPLE_APP_SHARED_SECRET') || '';
this.keyId = this.configService.get<string>('APPLE_KEY_ID') || '';
this.issuerId = this.configService.get<string>('APPLE_ISSUER_ID') || '';
this.bundleId = this.configService.get<string>('APPLE_BUNDLE_ID') || '';
this.privateKeyPath = this.configService.get<string>('APPLE_PRIVATE_KEY_PATH') || '';
try {
// 读取私钥文件
this.privateKey = fs.readFileSync(this.privateKeyPath, 'utf8');
} catch (error) {
this.logger.error(`无法读取Apple私钥文件: ${error.message}`);
this.privateKey = '';
}
}
/**
* 使用共享密钥验证收据旧方法适用于StoreKit 1
*/
async verifyReceiptWithSharedSecret(receipt: string, isSandbox = false): Promise<any> {
try {
// 苹果验证URL
const productionUrl = 'https://buy.itunes.apple.com/verifyReceipt';
const sandboxUrl = 'https://sandbox.itunes.apple.com/verifyReceipt';
const verifyUrl = isSandbox ? sandboxUrl : productionUrl;
this.logger.log(`验证receipt: ${receipt}`);
// 发送验证请求
const response = await axios.post(verifyUrl, {
'receipt-data': receipt,
'password': this.appSharedSecret,
});
// 如果状态码为21007说明是沙盒收据需要在沙盒环境验证
if (response.data.status === 21007 && !isSandbox) {
return this.verifyReceiptWithSharedSecret(receipt, true);
}
this.logger.log(`Apple验证响应: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error) {
this.logger.error(`Apple验证失败: ${error.message}`);
throw error;
}
}
/**
* 生成用于App Store Connect API的JWT令牌
*/
private generateToken(): string {
try {
if (!this.privateKey) {
throw new Error('私钥未加载,无法生成令牌');
}
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: this.issuerId, // 发行者ID
iat: now, // 签发时间
exp: now + 3600, // 过期时间1小时后
aud: 'appstoreconnect-v1' // 受众,固定值
};
const signOptions = {
algorithm: 'ES256', // 要求使用ES256算法
header: {
alg: 'ES256',
kid: this.keyId, // 密钥ID
typ: 'JWT'
}
};
return jwt.sign(payload, this.privateKey, signOptions as jwt.SignOptions);
} catch (error) {
this.logger.error(`生成JWT令牌失败: ${error.message}`);
throw error;
}
}
/**
* 使用StoreKit 2 API验证交易新方法
*/
async verifyTransactionWithJWT(transactionId: string): Promise<any> {
try {
const token = this.generateToken();
// App Store Server API请求
const url = `https://api.storekit.itunes.apple.com/inApps/v1/transactions/${transactionId}`;
const response = await axios.get(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
this.logger.debug(`交易验证响应: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error) {
this.logger.error(`交易验证失败: ${error.message}`);
throw error;
}
}
/**
* 查询订阅状态
*/
async getSubscriptionStatus(originalTransactionId: string): Promise<any> {
try {
const token = this.generateToken();
// 查询订阅状态API
const url = `https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/${originalTransactionId}`;
const response = await axios.get(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
this.logger.debug(`订阅状态响应: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error) {
this.logger.error(`获取订阅状态失败: ${error.message}`);
throw error;
}
}
/**
* 验证收据
* 综合使用旧API和新API进行验证
*/
async verifyPurchase(receipt: string, transactionId: string): Promise<boolean> {
try {
// 首先使用共享密钥验证收据
const receiptVerification = await this.verifyReceiptWithSharedSecret(receipt, false);
if (receiptVerification.status !== 0) {
this.logger.warn(`收据验证失败,状态码: ${receiptVerification.status}`);
return false;
}
// 如果提供了transactionId则使用JWT验证交易
if (transactionId && this.privateKey) {
try {
const transactionVerification = await this.verifyTransactionWithJWT(transactionId);
// 验证bundleId
if (transactionVerification.bundleId !== this.bundleId) {
this.logger.warn(`交易绑定的bundleId不匹配: ${transactionVerification.bundleId} !== ${this.bundleId}`);
return false;
}
// 验证交易状态
if (transactionVerification.signedTransactionInfo?.transactionState !== 1) {
this.logger.warn(`交易状态无效: ${transactionVerification.signedTransactionInfo?.transactionState}`);
return false;
}
return true;
} catch (error) {
this.logger.error(`JWT交易验证失败: ${error.message}`);
// 如果JWT验证失败但收据验证成功仍然返回true
return true;
}
}
return true;
} catch (error) {
this.logger.error(`验证购买失败: ${error.message}`);
return false;
}
}
/**
* 处理App Store服务器通知
* 用于处理苹果服务器发送的通知事件
*/
async processServerNotification(signedPayload: string): Promise<any> {
try {
this.logger.log(`接收到App Store服务器通知: ${signedPayload.substring(0, 100)}...`);
// 解码JWS格式的负载
const notificationPayload = this.decodeJWS<{
// SUBSCRIBED
notificationType: string;
subtype: string;
notificationUUID: string;
data: {
appAppleId: number;
bundleId: string;
environment: string;
bundleVersion: string;
signedTransactionInfo: string;
signedRenewalInfo: string;
status: number;
};
// 时间戳
signedDate: number
}>(signedPayload);
if (!notificationPayload) {
throw new Error('无法解码通知负载');
}
this.logger.log(`解码后的通知负载: ${JSON.stringify(notificationPayload, null, 2)}`);
// 验证通知的合法性
if (!this.validateNotification(notificationPayload)) {
throw new Error('通知验证失败');
}
// 解码交易信息
let transactionInfo = null;
if (notificationPayload.data?.signedTransactionInfo) {
transactionInfo = this.decodeJWS(notificationPayload.data.signedTransactionInfo);
}
// 解码续订信息
let renewalInfo = null;
if (notificationPayload.data?.signedRenewalInfo) {
renewalInfo = this.decodeJWS(notificationPayload.data.signedRenewalInfo);
}
return {
notificationPayload,
transactionInfo,
renewalInfo,
};
} catch (error) {
this.logger.error(`处理App Store服务器通知失败: ${error.message}`);
throw error;
}
}
/**
* 解码JWS格式的数据
*/
private decodeJWS<T>(jwsString: string): T | null {
try {
// JWS格式: header.payload.signature
const parts = jwsString.split('.');
if (parts.length !== 3) {
throw new Error('无效的JWS格式');
}
// 解码payload部分Base64URL编码
const payload = parts[1];
const decodedPayload = Buffer.from(payload, 'base64url').toString('utf8');
return JSON.parse(decodedPayload);
} catch (error) {
this.logger.error(`解码JWS失败: ${error.message}`);
return null;
}
}
/**
* 验证通知的合法性
*/
private validateNotification(notificationPayload: any): boolean {
try {
// 验证必要字段
if (!notificationPayload.notificationType) {
this.logger.warn('通知缺少notificationType字段');
return false;
}
if (!notificationPayload.data?.bundleId) {
this.logger.warn('通知缺少bundleId字段');
return false;
}
// 验证bundleId是否匹配
if (notificationPayload.data.bundleId !== this.bundleId) {
this.logger.warn(`bundleId不匹配: ${notificationPayload.data.bundleId} !== ${this.bundleId}`);
return false;
}
return true;
} catch (error) {
this.logger.error(`验证通知失败: ${error.message}`);
return false;
}
}
/**
* 根据通知类型处理不同的业务逻辑
*/
async handleNotificationByType(notificationType: string, transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log(`处理通知类型: ${notificationType}`);
switch (notificationType) {
case 'SUBSCRIBED':
return this.handleSubscribed(transactionInfo, renewalInfo);
case 'DID_RENEW':
return this.handleDidRenew(transactionInfo, renewalInfo);
case 'EXPIRED':
return this.handleExpired(transactionInfo, renewalInfo);
case 'DID_FAIL_TO_RENEW':
return this.handleDidFailToRenew(transactionInfo, renewalInfo);
case 'DID_CHANGE_RENEWAL_STATUS':
return this.handleDidChangeRenewalStatus(transactionInfo, renewalInfo);
case 'REFUND':
return this.handleRefund(transactionInfo, renewalInfo);
case 'REVOKE':
return this.handleRevoke(transactionInfo, renewalInfo);
case 'TEST':
return this.handleTest(transactionInfo, renewalInfo);
default:
this.logger.warn(`未处理的通知类型: ${notificationType}`);
return { handled: false, notificationType };
}
}
/**
* 处理订阅通知
*/
private async handleSubscribed(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理订阅通知', transactionInfo, renewalInfo);
// 这里可以实现具体的订阅处理逻辑
// 例如:更新用户的订阅状态、发送欢迎邮件等
return { handled: true, action: 'subscribed' };
}
/**
* 处理续订通知
*/
private async handleDidRenew(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理续订通知');
// 这里可以实现具体的续订处理逻辑
// 例如:延长用户的订阅期限
return { handled: true, action: 'renewed' };
}
/**
* 处理过期通知
*/
private async handleExpired(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理过期通知');
// 这里可以实现具体的过期处理逻辑
// 例如:停止用户的订阅服务
return { handled: true, action: 'expired' };
}
/**
* 处理续订失败通知
*/
private async handleDidFailToRenew(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理续订失败通知');
// 这里可以实现具体的续订失败处理逻辑
// 例如:发送付款提醒邮件
return { handled: true, action: 'renewal_failed' };
}
/**
* 处理续订状态变更通知
*/
private async handleDidChangeRenewalStatus(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理续订状态变更通知');
// 这里可以实现具体的续订状态变更处理逻辑
return { handled: true, action: 'renewal_status_changed' };
}
/**
* 处理退款通知
*/
private async handleRefund(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理退款通知');
// 这里可以实现具体的退款处理逻辑
// 例如:撤销用户的订阅权限
return { handled: true, action: 'refunded' };
}
/**
* 处理撤销通知
*/
private async handleRevoke(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理撤销通知');
// 这里可以实现具体的撤销处理逻辑
return { handled: true, action: 'revoked' };
}
/**
* 处理测试通知
*/
private async handleTest(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理测试通知');
return { handled: true, action: 'test' };
}
async handleServerNotification(payload: any): Promise<any> {
try {
this.logger.log(`收到Apple服务器通知: ${JSON.stringify(payload)}`);
// 验证通知签名(生产环境应该实现)
// ...
const notificationType = payload.notificationType;
const subtype = payload.subtype;
const data = payload.data;
// 处理不同类型的通知
switch (notificationType) {
case 'CONSUMPTION_REQUEST':
// 消费请求,用于非消耗型项目
break;
case 'DID_CHANGE_RENEWAL_PREF':
// 订阅续订偏好改变
break;
case 'DID_CHANGE_RENEWAL_STATUS':
// 订阅续订状态改变
break;
case 'DID_FAIL_TO_RENEW':
// 订阅续订失败
break;
case 'DID_RENEW':
// 订阅已续订
break;
case 'EXPIRED':
// 订阅已过期
break;
case 'GRACE_PERIOD_EXPIRED':
// 宽限期已过期
break;
case 'OFFER_REDEEMED':
// 优惠已兑换
break;
case 'PRICE_INCREASE':
// 价格上涨
break;
case 'REFUND':
// 退款
break;
case 'REVOKE':
// 撤销
break;
default:
this.logger.warn(`未知的通知类型: ${notificationType}`);
}
return {
success: true
};
} catch (error) {
this.logger.error(`处理服务器通知失败: ${error.message}`);
throw error;
}
}
//从apple获取用户的订阅历史参考 storekit2 的示例
async getAppleSubscriptionHistory(userId: string): Promise<any> {
const token = this.generateToken();
const url = `https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/${userId}/history`;
const response = await axios.get(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
return response.data;
}
}

View File

@@ -0,0 +1,244 @@
import {
Controller,
Get,
Post,
Body,
Param,
HttpCode,
HttpStatus,
Put,
Logger,
UseGuards,
Inject,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger as WinstonLogger } from 'winston';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { UserRelationInfoDto, UserRelationInfoResponseDto } from './dto/user-relation-info.dto';
import { ApiOperation, ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
import {
CreateClientLogDto,
CreateBatchClientLogDto,
CreateClientLogResponseDto,
CreateBatchClientLogResponseDto,
} from './dto/client-log.dto';
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
import { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from './services/apple-auth.service';
import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard';
import { ResponseCode } from 'src/base.dto';
@ApiTags('users')
@Controller('users')
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(
private readonly usersService: UsersService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
) { }
@UseGuards(JwtAuthGuard)
@Get()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '创建用户' })
@ApiBody({ type: CreateUserDto })
@ApiResponse({ type: UserResponseDto })
async getProfile(@CurrentUser() user: AccessTokenPayload): Promise<UserResponseDto> {
this.logger.log(`get profile: ${JSON.stringify(user)}`);
return this.usersService.getProfile(user);
}
// 更新用户昵称、头像
@UseGuards(JwtAuthGuard)
@Put('update')
async updateUser(@Body() updateUserDto: UpdateUserDto): Promise<UpdateUserResponseDto> {
return this.usersService.updateUser(updateUserDto);
}
// 获取用户关系
@UseGuards(JwtAuthGuard)
@Put('relations')
async updateOrCreateRelationInfo(@Body() relationInfoDto: UserRelationInfoDto) {
return this.usersService.updateOrCreateRelationInfo(relationInfoDto);
}
@UseGuards(JwtAuthGuard)
@Get('relations/:userId')
async getRelationInfo(@Param('userId') userId: string): Promise<UserRelationInfoResponseDto> {
return this.usersService.getRelationInfo(userId);
}
// 创建客户端日志
@UseGuards(JwtAuthGuard)
@Post('logs')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '创建客户端日志' })
@ApiBody({ type: CreateClientLogDto })
async createClientLog(@Body() createClientLogDto: CreateClientLogDto): Promise<CreateClientLogResponseDto> {
return this.usersService.createClientLog(createClientLogDto);
}
// 批量创建客户端日志
@UseGuards(JwtAuthGuard)
@Post('logs/batch')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '批量创建客户端日志' })
@ApiBody({ type: CreateBatchClientLogDto })
async createBatchClientLog(@Body() createBatchClientLogDto: CreateBatchClientLogDto): Promise<CreateBatchClientLogResponseDto> {
return this.usersService.createBatchClientLog(createBatchClientLogDto);
}
// Apple 登录
@Public()
@Post('auth/apple/login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Apple 登录验证' })
@ApiBody({ type: AppleLoginDto })
@ApiResponse({ type: AppleLoginResponseDto })
async appleLogin(@Body() appleLoginDto: AppleLoginDto): Promise<AppleLoginResponseDto> {
return this.usersService.appleLogin(appleLoginDto);
}
// 刷新访问令牌
@Public()
@Post('auth/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新访问令牌' })
@ApiBody({ type: RefreshTokenDto })
@ApiResponse({ type: RefreshTokenResponseDto })
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto): Promise<RefreshTokenResponseDto> {
return this.usersService.refreshToken(refreshTokenDto);
}
// 删除用户账号
@UseGuards(JwtAuthGuard)
@Post('delete-account')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除用户账号' })
@ApiBody({ type: DeleteAccountDto })
@ApiResponse({ type: DeleteAccountResponseDto })
async deleteAccount(@CurrentUser() user: AccessTokenPayload): Promise<DeleteAccountResponseDto> {
const deleteAccountDto = {
userId: user.sub,
};
return this.usersService.deleteAccount(deleteAccountDto);
}
// 游客登录
@Public()
@Post('auth/guest/login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '游客登录' })
@ApiBody({ type: GuestLoginDto })
@ApiResponse({ type: GuestLoginResponseDto })
async guestLogin(@Body() guestLoginDto: GuestLoginDto): Promise<GuestLoginResponseDto> {
return this.usersService.guestLogin(guestLoginDto);
}
// 刷新游客令牌
@Public()
@Post('auth/guest/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新游客访问令牌' })
@ApiBody({ type: RefreshGuestTokenDto })
@ApiResponse({ type: RefreshGuestTokenResponseDto })
async refreshGuestToken(@Body() refreshGuestTokenDto: RefreshGuestTokenDto): Promise<RefreshGuestTokenResponseDto> {
return this.usersService.refreshGuestToken(refreshGuestTokenDto);
}
// App Store 服务器通知接收接口
@Public()
@Post('app-store-notifications')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '接收App Store服务器通知' })
@ApiBody({ type: AppStoreServerNotificationDto })
@ApiResponse({ type: ProcessNotificationResponseDto })
async handleAppStoreNotification(@Body() notificationDto: AppStoreServerNotificationDto): Promise<ProcessNotificationResponseDto> {
this.logger.log(`收到App Store服务器通知`);
return this.usersService.processAppStoreNotification(notificationDto);
}
// RevenueCat Webhook
@Public()
@Post('revenuecat-webhook')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '接收 RevenueCat webhook' })
async handleRevenueCatWebhook(@Body() webhook) {
// 使用结构化日志记录webhook接收
this.winstonLogger.info('RevenueCat webhook received', {
context: 'UsersController',
eventType: webhook.event?.type,
eventId: webhook.event?.id,
appUserId: webhook.event?.app_user_id,
timestamp: new Date().toISOString(),
webhookData: {
apiVersion: webhook.api_version,
eventTimestamp: webhook.event?.event_timestamp_ms
}
});
try {
await this.usersService.handleRevenueCatWebhook(webhook);
this.winstonLogger.info('RevenueCat webhook processed successfully', {
context: 'UsersController',
eventType: webhook.event?.type,
eventId: webhook.event?.id,
appUserId: webhook.event?.app_user_id
});
return { code: ResponseCode.SUCCESS, message: 'success' };
} catch (error) {
this.winstonLogger.error('RevenueCat webhook processing failed', {
context: 'UsersController',
eventType: webhook.event?.type,
eventId: webhook.event?.id,
appUserId: webhook.event?.app_user_id,
error: error.message,
stack: error.stack
});
return { code: ResponseCode.ERROR, message: error.message };
}
}
// 恢复购买
@UseGuards(JwtAuthGuard)
@Post('restore-purchase')
async restorePurchase(
@Body() restorePurchaseDto: RestorePurchaseDto,
@CurrentUser() user: AccessTokenPayload,
@Req() request: Request
): Promise<RestorePurchaseResponseDto> {
const clientIp = request.ip || request.connection.remoteAddress || 'unknown';
const userAgent = request.get('User-Agent') || 'unknown';
this.logger.log(`恢复购买请求 - 用户ID: ${user.sub}, IP: ${clientIp}`);
// 记录安全相关信息
this.winstonLogger.info('Purchase restore request', {
context: 'UsersController',
userId: user.sub,
clientIp,
userAgent,
originalAppUserId: restorePurchaseDto.customerInfo?.originalAppUserId,
entitlementsCount: Object.keys(restorePurchaseDto.customerInfo?.activeEntitlements || {}).length,
nonSubTransactionsCount: restorePurchaseDto.customerInfo?.nonSubscriptionTransactions?.length || 0
});
return this.usersService.restorePurchase(restorePurchaseDto, user.sub, clientIp, userAgent);
}
}

22
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Module } from "@nestjs/common";
import { SequelizeModule } from "@nestjs/sequelize";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
import { User } from "./models/user.model";
import { ApplePurchaseService } from "./services/apple-purchase.service";
import { EncryptionService } from "../common/encryption.service";
import { AppleAuthService } from "./services/apple-auth.service";
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
SequelizeModule.forFeature([User]),
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key',
signOptions: { expiresIn: '30d' },
}),
],
controllers: [UsersController],
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService],
exports: [UsersService, AppleAuthService],
})
export class UsersModule { }

2105
src/users/users.service.ts Normal file

File diff suppressed because it is too large Load Diff