feat: 初始化项目
This commit is contained in:
228
src/users/cos.service.ts
Normal file
228
src/users/cos.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
163
src/users/dto/app-store-notification.dto.ts
Normal file
163
src/users/dto/app-store-notification.dto.ts
Normal 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;
|
||||
}
|
||||
113
src/users/dto/apple-login.dto.ts
Normal file
113
src/users/dto/apple-login.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
99
src/users/dto/client-log.dto.ts
Normal file
99
src/users/dto/client-log.dto.ts
Normal 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;
|
||||
}> {}
|
||||
48
src/users/dto/create-py-topic.dto.ts
Normal file
48
src/users/dto/create-py-topic.dto.ts
Normal 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;
|
||||
}
|
||||
21
src/users/dto/create-user.dto.ts
Normal file
21
src/users/dto/create-user.dto.ts
Normal 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;
|
||||
}
|
||||
16
src/users/dto/delete-account.dto.ts
Normal file
16
src/users/dto/delete-account.dto.ts
Normal 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 };
|
||||
}
|
||||
23
src/users/dto/encrypted-user.dto.ts
Normal file
23
src/users/dto/encrypted-user.dto.ts
Normal 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;
|
||||
}
|
||||
85
src/users/dto/guest-login.dto.ts
Normal file
85
src/users/dto/guest-login.dto.ts
Normal 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;
|
||||
}
|
||||
32
src/users/dto/membership.dto.ts
Normal file
32
src/users/dto/membership.dto.ts
Normal 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;
|
||||
}
|
||||
145
src/users/dto/purchase-verification.dto.ts
Normal file
145
src/users/dto/purchase-verification.dto.ts
Normal 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[];
|
||||
};
|
||||
}
|
||||
69
src/users/dto/restore-purchase.dto.ts
Normal file
69
src/users/dto/restore-purchase.dto.ts
Normal 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> { }
|
||||
104
src/users/dto/revenue-cat-webhook.dto.ts
Normal file
104
src/users/dto/revenue-cat-webhook.dto.ts
Normal 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;
|
||||
}
|
||||
60
src/users/dto/security-monitoring.dto.ts
Normal file
60
src/users/dto/security-monitoring.dto.ts
Normal 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;
|
||||
}> { }
|
||||
81
src/users/dto/topic-favorite.dto.ts
Normal file
81
src/users/dto/topic-favorite.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
108
src/users/dto/topic-library.dto.ts
Normal file
108
src/users/dto/topic-library.dto.ts
Normal 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;
|
||||
}
|
||||
|
||||
50
src/users/dto/update-user.dto.ts
Normal file
50
src/users/dto/update-user.dto.ts
Normal 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;
|
||||
}
|
||||
|
||||
62
src/users/dto/user-relation-info.dto.ts
Normal file
62
src/users/dto/user-relation-info.dto.ts
Normal 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;
|
||||
}
|
||||
33
src/users/dto/user-response.dto.ts
Normal file
33
src/users/dto/user-response.dto.ts
Normal 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;
|
||||
}
|
||||
89
src/users/models/client-log.model.ts
Normal file
89
src/users/models/client-log.model.ts
Normal 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;
|
||||
}
|
||||
125
src/users/models/purchase-restore-log.model.ts
Normal file
125
src/users/models/purchase-restore-log.model.ts
Normal 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;
|
||||
}
|
||||
54
src/users/models/revenue-cat-event.model.ts
Normal file
54
src/users/models/revenue-cat-event.model.ts
Normal 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;
|
||||
}
|
||||
96
src/users/models/user-purchase.model.ts
Normal file
96
src/users/models/user-purchase.model.ts
Normal 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;
|
||||
}
|
||||
125
src/users/models/user.model.ts
Normal file
125
src/users/models/user.model.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
241
src/users/services/apple-auth.service.ts
Normal file
241
src/users/services/apple-auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
494
src/users/services/apple-purchase.service.ts
Normal file
494
src/users/services/apple-purchase.service.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
244
src/users/users.controller.ts
Normal file
244
src/users/users.controller.ts
Normal 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
22
src/users/users.module.ts
Normal 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
2105
src/users/users.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user