feat: 初始化项目
This commit is contained in:
19
.cursor/rules/api-documentation.mdc
Normal file
19
.cursor/rules/api-documentation.mdc
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# API文档 (Swagger)
|
||||||
|
|
||||||
|
本项目使用Swagger自动生成API文档。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
- Swagger配置位于[src/main.ts](mdc:src/main.ts)文件中
|
||||||
|
- API文档在项目运行时可通过 `/api/docs` 路径访问
|
||||||
|
|
||||||
|
## API架构
|
||||||
|
- 所有API路由都有 `/api` 前缀
|
||||||
|
- API请求和响应使用DTO(数据传输对象)定义
|
||||||
|
- 主要API组:
|
||||||
|
- 用户API: `/api/users` - 由[src/users/users.controller.ts](mdc:src/users/users.controller.ts)处理
|
||||||
|
- 任务API: `/api/tasks` - 由[src/tasks/task.controller.ts](mdc:src/tasks/task.controller.ts)处理
|
||||||
20
.cursor/rules/database-module.mdc
Normal file
20
.cursor/rules/database-module.mdc
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# 数据库模块 (Database Module)
|
||||||
|
|
||||||
|
数据库模块负责配置和管理与MySQL数据库的连接,使用Sequelize ORM。
|
||||||
|
|
||||||
|
## 主要文件
|
||||||
|
- 模块定义: [src/database/database.module.ts](mdc:src/database/database.module.ts) - 配置Sequelize连接
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
- 该模块通过NestJS的ConfigService从环境变量获取数据库配置
|
||||||
|
- 支持的环境变量:
|
||||||
|
- DB_HOST: 数据库主机
|
||||||
|
- DB_PORT: 数据库端口
|
||||||
|
- DB_USERNAME: 数据库用户名
|
||||||
|
- DB_PASSWORD: 数据库密码
|
||||||
|
- DB_DATABASE: 数据库名称
|
||||||
25
.cursor/rules/deployment.mdc
Normal file
25
.cursor/rules/deployment.mdc
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# 部署与运行指南
|
||||||
|
|
||||||
|
本项目支持多种运行和部署方式,包括开发环境和生产环境。
|
||||||
|
|
||||||
|
## 脚本
|
||||||
|
- 开发环境启动: `yarn start:dev` 或 `npm run start:dev` - 带有热重载功能
|
||||||
|
- 生产环境构建: `yarn build` 或 `npm run build`
|
||||||
|
- 生产环境启动: `yarn start:prod` 或 `npm run start:prod`
|
||||||
|
|
||||||
|
## PM2部署
|
||||||
|
项目支持使用PM2进行部署和进程管理:
|
||||||
|
- PM2配置文件: [ecosystem.config.js](mdc:ecosystem.config.js)
|
||||||
|
- 启动命令:
|
||||||
|
- 生产环境: `yarn pm2:start` 或 `npm run pm2:start`
|
||||||
|
- 开发环境: `yarn pm2:start:dev` 或 `npm run pm2:start:dev`
|
||||||
|
|
||||||
|
## Shell脚本
|
||||||
|
项目包含两个启动脚本:
|
||||||
|
- [start-dev.sh](mdc:start-dev.sh) - 开发环境启动脚本
|
||||||
|
- [start.sh](mdc:start.sh) - 生产环境启动脚本
|
||||||
22
.cursor/rules/project-structure.mdc
Normal file
22
.cursor/rules/project-structure.mdc
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Love Tips Server - 项目结构
|
||||||
|
|
||||||
|
这是一个基于NestJS框架的服务端项目,提供了Love Tips应用的后端API服务。
|
||||||
|
|
||||||
|
## 主要入口文件
|
||||||
|
- 主入口点: [src/main.ts](mdc:src/main.ts) - 启动NestJS应用程序
|
||||||
|
- 主模块: [src/app.module.ts](mdc:src/app.module.ts) - 应用的根模块,引入所有其他模块
|
||||||
|
|
||||||
|
## 核心模块
|
||||||
|
- 数据库模块: [src/database/database.module.ts](mdc:src/database/database.module.ts) - 使用Sequelize连接MySQL数据库
|
||||||
|
- 用户模块: [src/users/users.module.ts](mdc:src/users/users.module.ts) - 处理用户相关功能
|
||||||
|
- 任务模块: [src/tasks/task.module.ts](mdc:src/tasks/task.module.ts) - 处理任务相关功能
|
||||||
|
|
||||||
|
## 项目配置文件
|
||||||
|
- [package.json](mdc:package.json) - 项目依赖和脚本配置
|
||||||
|
- [tsconfig.json](mdc:tsconfig.json) - TypeScript配置
|
||||||
|
- [ecosystem.config.js](mdc:ecosystem.config.js) - PM2配置文件,用于生产环境部署
|
||||||
17
.cursor/rules/tasks-module.mdc
Normal file
17
.cursor/rules/tasks-module.mdc
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# 任务模块 (Tasks Module)
|
||||||
|
|
||||||
|
任务模块处理所有与任务相关的功能,可能包括定时任务、队列任务或用户任务。
|
||||||
|
|
||||||
|
## 主要文件
|
||||||
|
- 模块定义: [src/tasks/task.module.ts](mdc:src/tasks/task.module.ts)
|
||||||
|
- 控制器: [src/tasks/task.controller.ts](mdc:src/tasks/task.controller.ts) - 处理HTTP请求
|
||||||
|
- 服务: [src/tasks/task.service.ts](mdc:src/tasks/task.service.ts) - 包含业务逻辑
|
||||||
|
|
||||||
|
## 子目录
|
||||||
|
- DTO目录: `src/tasks/dto/` - 包含数据传输对象
|
||||||
|
- 模型目录: `src/tasks/models/` - 包含数据库模型
|
||||||
18
.cursor/rules/users-module.mdc
Normal file
18
.cursor/rules/users-module.mdc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# 用户模块 (Users Module)
|
||||||
|
|
||||||
|
用户模块处理用户相关的所有功能,包括注册、登录和用户信息管理。
|
||||||
|
|
||||||
|
## 主要文件
|
||||||
|
- 模块定义: [src/users/users.module.ts](mdc:src/users/users.module.ts)
|
||||||
|
- 控制器: [src/users/users.controller.ts](mdc:src/users/users.controller.ts) - 处理HTTP请求
|
||||||
|
- 服务: [src/users/users.service.ts](mdc:src/users/users.service.ts) - 包含业务逻辑
|
||||||
|
|
||||||
|
## 子目录
|
||||||
|
- DTO目录: `src/users/dto/` - 包含数据传输对象
|
||||||
|
- 模型目录: `src/users/models/` - 包含数据库模型
|
||||||
|
- 服务目录: `src/users/services/` - 包含额外的服务类
|
||||||
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
w
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
224
DEPLOY.md
Normal file
224
DEPLOY.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# 部署说明
|
||||||
|
|
||||||
|
本项目提供了三个发布脚本用于部署到服务器:
|
||||||
|
|
||||||
|
## 脚本说明
|
||||||
|
|
||||||
|
### 1. `deploy-optimized.sh` - 优化版发布脚本 ⭐ 推荐
|
||||||
|
|
||||||
|
- 只上传源代码,服务器端构建
|
||||||
|
- 充分利用 `start.sh` 的逻辑
|
||||||
|
- 传输文件少,部署速度快
|
||||||
|
- 包含完整的错误处理和备份
|
||||||
|
|
||||||
|
### 2. `deploy.sh` - 完整版发布脚本
|
||||||
|
|
||||||
|
- 本地构建后上传
|
||||||
|
- 功能完整,包含错误处理、备份、日志等
|
||||||
|
- 支持干运行模式和帮助信息
|
||||||
|
- 自动清理临时文件
|
||||||
|
|
||||||
|
### 3. `deploy-simple.sh` - 简化版发布脚本
|
||||||
|
|
||||||
|
- 简单直接,适合快速部署
|
||||||
|
- 代码简洁,易于理解和修改
|
||||||
|
|
||||||
|
## 服务器配置
|
||||||
|
|
||||||
|
- **服务器地址**: 119.91.211.52
|
||||||
|
- **用户**: root(可在脚本中修改)
|
||||||
|
- **部署目录**: /usr/local/web/pilates-server
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
### 1. 配置SSH密钥认证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成SSH密钥(如果还没有)
|
||||||
|
ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
|
||||||
|
|
||||||
|
# 将公钥复制到服务器
|
||||||
|
ssh-copy-id root@119.91.211.52
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 服务器环境要求
|
||||||
|
|
||||||
|
确保服务器上已安装:
|
||||||
|
|
||||||
|
- Node.js (建议v18+)
|
||||||
|
- yarn
|
||||||
|
- pm2
|
||||||
|
- rsync
|
||||||
|
|
||||||
|
### 3. 本地环境要求
|
||||||
|
|
||||||
|
确保本地已安装:
|
||||||
|
|
||||||
|
- rsync
|
||||||
|
- ssh
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 方法一:使用优化版脚本(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 直接部署
|
||||||
|
./deploy-optimized.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法二:使用完整版脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看帮助
|
||||||
|
./deploy.sh --help
|
||||||
|
|
||||||
|
# 模拟运行(不实际部署)
|
||||||
|
./deploy.sh --dry-run
|
||||||
|
|
||||||
|
# 正式部署
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法三:使用简化版脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 直接部署
|
||||||
|
./deploy-simple.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署流程
|
||||||
|
|
||||||
|
### 优化版部署流程(推荐)
|
||||||
|
|
||||||
|
1. **测试连接**
|
||||||
|
|
||||||
|
- 验证SSH连接是否正常
|
||||||
|
|
||||||
|
2. **停止服务**
|
||||||
|
|
||||||
|
- 停止现有PM2服务
|
||||||
|
|
||||||
|
3. **备份部署**
|
||||||
|
|
||||||
|
- 自动备份现有部署
|
||||||
|
|
||||||
|
4. **同步源码**
|
||||||
|
|
||||||
|
- 只上传源代码和配置文件
|
||||||
|
- 排除 node_modules、dist、.git、.env 等敏感文件
|
||||||
|
|
||||||
|
5. **服务器构建**
|
||||||
|
|
||||||
|
- 执行 `start.sh` 脚本
|
||||||
|
- 自动安装依赖、构建项目、启动服务
|
||||||
|
|
||||||
|
6. **验证部署**
|
||||||
|
- 检查PM2服务状态
|
||||||
|
|
||||||
|
### 传统部署流程
|
||||||
|
|
||||||
|
1. **本地构建**
|
||||||
|
|
||||||
|
- 安装依赖 (`yarn install`)
|
||||||
|
- 构建项目 (`yarn build`)
|
||||||
|
|
||||||
|
2. **文件同步**
|
||||||
|
|
||||||
|
- 将构建后的文件同步到服务器
|
||||||
|
- 排除不必要的文件(node_modules、.git等)
|
||||||
|
|
||||||
|
3. **服务器操作**
|
||||||
|
|
||||||
|
- 停止现有服务
|
||||||
|
- 备份现有部署
|
||||||
|
- 执行 `start.sh` 启动服务
|
||||||
|
|
||||||
|
4. **验证部署**
|
||||||
|
- 检查PM2服务状态
|
||||||
|
- 显示常用管理命令
|
||||||
|
|
||||||
|
## 常用管理命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看服务状态
|
||||||
|
ssh root@119.91.211.52 'cd /usr/local/web/pilates-server && pm2 status'
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
ssh root@119.91.211.52 'cd /usr/local/web/pilates-server && pm2 logs'
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
ssh root@119.91.211.52 'cd /usr/local/web/pilates-server && pm2 restart ecosystem.config.js'
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
ssh root@119.91.211.52 'cd /usr/local/web/pilates-server && pm2 stop ecosystem.config.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义配置
|
||||||
|
|
||||||
|
如需修改服务器配置,请编辑脚本文件中的以下变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SERVER_HOST="119.91.211.52" # 服务器地址
|
||||||
|
SERVER_USER="root" # SSH用户名
|
||||||
|
SERVER_PATH="/usr/local/web/pilates-server" # 部署目录
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 1. SSH连接失败
|
||||||
|
|
||||||
|
- 检查服务器地址是否正确
|
||||||
|
- 确认SSH密钥是否配置正确
|
||||||
|
- 检查服务器是否可访问
|
||||||
|
|
||||||
|
### 2. 文件同步失败
|
||||||
|
|
||||||
|
- 检查rsync是否安装
|
||||||
|
- 确认网络连接正常
|
||||||
|
- 检查服务器磁盘空间
|
||||||
|
|
||||||
|
### 3. 服务启动失败
|
||||||
|
|
||||||
|
- 检查服务器上的Node.js版本
|
||||||
|
- 确认pm2是否正确安装
|
||||||
|
- 查看服务器上的错误日志
|
||||||
|
|
||||||
|
### 4. 权限问题
|
||||||
|
|
||||||
|
- 确保对目标目录有写权限
|
||||||
|
- 检查SSH用户的权限设置
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **首次部署**:确保服务器上的目标目录存在且有权限
|
||||||
|
2. **环境变量**:
|
||||||
|
- 本地的 `.env` 文件不会上传到服务器(出于安全考虑)
|
||||||
|
- 需要在服务器上手动创建 `.env` 文件并配置相应的环境变量
|
||||||
|
- 或者通过其他方式管理生产环境的环境变量
|
||||||
|
3. **数据库**:确保服务器上的数据库配置正确
|
||||||
|
4. **防火墙**:确保服务器端口(默认3000)已开放
|
||||||
|
5. **备份**:重要数据建议定期备份
|
||||||
|
|
||||||
|
## 环境变量管理
|
||||||
|
|
||||||
|
发布脚本会自动忽略以下环境变量文件:
|
||||||
|
|
||||||
|
- `.env`
|
||||||
|
- `.env.local`
|
||||||
|
- `.env.*.local`
|
||||||
|
|
||||||
|
**首次部署后,请在服务器上手动创建环境变量文件:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 登录服务器
|
||||||
|
ssh root@119.91.211.52
|
||||||
|
|
||||||
|
# 进入项目目录
|
||||||
|
cd /usr/local/web/pilates-server
|
||||||
|
|
||||||
|
# 创建生产环境配置文件
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# 或者从模板复制
|
||||||
|
cp .env.example .env # 如果有模板文件
|
||||||
|
```
|
||||||
437
README.md
Normal file
437
README.md
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
# Pilates Server
|
||||||
|
|
||||||
|
一个基于 NestJS 的恋爱话题和聊天建议 API 服务,为移动应用提供后端支持,包含用户管理、Apple 登录、话题收藏、任务生成等功能。
|
||||||
|
|
||||||
|
## 🚀 功能特性
|
||||||
|
|
||||||
|
- **用户管理系统**
|
||||||
|
- Apple Sign-In 集成
|
||||||
|
- 游客登录支持
|
||||||
|
- JWT 认证和刷新令牌
|
||||||
|
- 用户资料管理
|
||||||
|
- 账户删除功能
|
||||||
|
|
||||||
|
- **话题管理**
|
||||||
|
- 话题库管理
|
||||||
|
- 话题收藏功能
|
||||||
|
- 话题分类和搜索
|
||||||
|
- 个性化推荐
|
||||||
|
|
||||||
|
- **任务系统**
|
||||||
|
- 任务创建和管理
|
||||||
|
- 任务重新生成
|
||||||
|
- 任务分类管理
|
||||||
|
|
||||||
|
- **支付集成**
|
||||||
|
- App Store 服务器通知处理
|
||||||
|
- RevenueCat Webhook 支持
|
||||||
|
- 订阅状态管理
|
||||||
|
|
||||||
|
- **安全特性**
|
||||||
|
- AES-256-GCM 端到端加密
|
||||||
|
- 数据传输加密
|
||||||
|
- 安全的密钥管理
|
||||||
|
|
||||||
|
- **云服务集成**
|
||||||
|
- 腾讯云 COS 存储
|
||||||
|
- 客户端日志收集
|
||||||
|
- 文件上传管理
|
||||||
|
|
||||||
|
## 🛠 技术栈
|
||||||
|
|
||||||
|
- **框架**: NestJS 11.x
|
||||||
|
- **语言**: TypeScript
|
||||||
|
- **数据库**: MySQL 3.x + Sequelize ORM
|
||||||
|
- **认证**: JWT + Apple Sign-In
|
||||||
|
- **文档**: Swagger/OpenAPI
|
||||||
|
- **部署**: PM2 + 自动化部署脚本
|
||||||
|
- **云服务**: 腾讯云 COS
|
||||||
|
- **加密**: AES-256-GCM
|
||||||
|
- **测试**: Jest
|
||||||
|
|
||||||
|
## 📋 环境要求
|
||||||
|
|
||||||
|
- Node.js >= 18.0.0
|
||||||
|
- MySQL >= 8.0
|
||||||
|
- yarn 或 npm
|
||||||
|
- PM2 (生产环境)
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd pilates-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
# 或
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 环境配置
|
||||||
|
|
||||||
|
创建 `.env` 文件并配置以下环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 数据库配置
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USERNAME=your_username
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_DATABASE=love_tips
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
JWT_SECRET=your_jwt_secret
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
REFRESH_TOKEN_SECRET=your_refresh_token_secret
|
||||||
|
REFRESH_TOKEN_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# Apple 配置
|
||||||
|
APPLE_BUNDLE_ID=com.yourcompany.yourapp
|
||||||
|
APPLE_KEY_ID=your_key_id
|
||||||
|
APPLE_ISSUER_ID=your_issuer_id
|
||||||
|
APPLE_PRIVATE_KEY_PATH=path/to/your/private/key.p8
|
||||||
|
APPLE_APP_SHARED_SECRET=your_shared_secret
|
||||||
|
|
||||||
|
# 加密配置
|
||||||
|
ENCRYPTION_KEY=your-32-character-secret-key-here
|
||||||
|
|
||||||
|
# 腾讯云 COS 配置
|
||||||
|
COS_SECRET_ID=your_secret_id
|
||||||
|
COS_SECRET_KEY=your_secret_key
|
||||||
|
COS_REGION=your_region
|
||||||
|
COS_BUCKET=your_bucket
|
||||||
|
|
||||||
|
# 服务配置
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 数据库初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建数据库
|
||||||
|
mysql -u root -p -e "CREATE DATABASE love_tips;"
|
||||||
|
|
||||||
|
# 运行迁移(如果有)
|
||||||
|
yarn run migration:run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发模式
|
||||||
|
yarn start:dev
|
||||||
|
|
||||||
|
# 或使用脚本
|
||||||
|
./start-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
服务器将在 `http://localhost:3000` 启动,API 文档可在 `http://localhost:3000/api/docs` 查看。
|
||||||
|
|
||||||
|
## 📖 API 文档
|
||||||
|
|
||||||
|
### 主要接口
|
||||||
|
|
||||||
|
#### 用户认证
|
||||||
|
- `POST /api/users/auth/apple/login` - Apple 登录
|
||||||
|
- `POST /api/users/auth/guest/login` - 游客登录
|
||||||
|
- `POST /api/users/auth/refresh` - 刷新令牌
|
||||||
|
- `POST /api/users/delete-account` - 删除账户
|
||||||
|
|
||||||
|
#### 用户管理
|
||||||
|
- `GET /api/users` - 获取用户资料
|
||||||
|
- `PUT /api/users/update` - 更新用户信息
|
||||||
|
- `PUT /api/users/relations` - 更新用户关系信息
|
||||||
|
- `GET /api/users/relations/:userId` - 获取用户关系信息
|
||||||
|
|
||||||
|
#### 话题管理
|
||||||
|
- `POST /api/topic/list` - 获取话题列表
|
||||||
|
- `POST /api/topic/favorite` - 收藏话题
|
||||||
|
- `POST /api/topic/unfavorite` - 取消收藏
|
||||||
|
- `POST /api/topic/favorites` - 获取收藏列表
|
||||||
|
|
||||||
|
#### 任务管理
|
||||||
|
- `POST /api/tasks` - 创建任务
|
||||||
|
- `GET /api/tasks/:id` - 获取任务详情
|
||||||
|
- `POST /api/tasks/recreate` - 重新生成任务
|
||||||
|
- `POST /api/tasks/categories` - 获取任务分类
|
||||||
|
|
||||||
|
#### 系统接口
|
||||||
|
- `POST /api/users/logs` - 创建客户端日志
|
||||||
|
- `POST /api/users/app-store-notifications` - App Store 通知
|
||||||
|
- `POST /api/users/revenuecat-webhook` - RevenueCat Webhook
|
||||||
|
|
||||||
|
完整的 API 文档请访问:`http://localhost:3000/api/docs`
|
||||||
|
|
||||||
|
## 🚀 部署指南
|
||||||
|
|
||||||
|
项目提供了三种部署方式:
|
||||||
|
|
||||||
|
### 1. 优化版部署(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy-optimized.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只上传源代码,服务器端构建
|
||||||
|
- 传输文件少,部署速度快
|
||||||
|
- 包含完整的错误处理和备份
|
||||||
|
|
||||||
|
### 2. 完整版部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看帮助
|
||||||
|
./deploy.sh --help
|
||||||
|
|
||||||
|
# 模拟运行
|
||||||
|
./deploy.sh --dry-run
|
||||||
|
|
||||||
|
# 正式部署
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 简化版部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy-simple.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务器配置
|
||||||
|
|
||||||
|
- **服务器地址**: 119.91.211.52
|
||||||
|
- **用户**: root
|
||||||
|
- **部署目录**: /usr/local/web/pilates-server
|
||||||
|
|
||||||
|
### 生产环境启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 PM2 启动
|
||||||
|
yarn pm2:start
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
yarn pm2:status
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
yarn pm2:logs
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
yarn pm2:restart
|
||||||
|
```
|
||||||
|
|
||||||
|
详细部署说明请参考 [`DEPLOY.md`](DEPLOY.md)
|
||||||
|
|
||||||
|
## 🏗 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
pilates-server/
|
||||||
|
├── src/ # 源代码目录
|
||||||
|
│ ├── app.module.ts # 应用主模块
|
||||||
|
│ ├── main.ts # 应用入口文件
|
||||||
|
│ ├── base.dto.ts # 基础 DTO
|
||||||
|
│ ├── common/ # 通用模块
|
||||||
|
│ │ ├── decorators/ # 装饰器
|
||||||
|
│ │ ├── guards/ # 守卫
|
||||||
|
│ │ └── encryption.service.ts # 加密服务
|
||||||
|
│ ├── database/ # 数据库模块
|
||||||
|
│ ├── users/ # 用户模块
|
||||||
|
│ │ ├── users.controller.ts # 用户控制器
|
||||||
|
│ │ ├── users.service.ts # 用户服务
|
||||||
|
│ │ ├── dto/ # 数据传输对象
|
||||||
|
│ │ ├── models/ # 数据模型
|
||||||
|
│ │ └── services/ # 业务服务
|
||||||
|
│ └── tasks/ # 任务模块
|
||||||
|
│ ├── task.controller.ts # 任务控制器
|
||||||
|
│ ├── task.service.ts # 任务服务
|
||||||
|
│ ├── dto/ # 数据传输对象
|
||||||
|
│ └── models/ # 数据模型
|
||||||
|
├── docs/ # 文档目录
|
||||||
|
│ ├── topic-favorite-api.md # 话题收藏 API 文档
|
||||||
|
│ ├── app-store-server-notifications.md # App Store 通知文档
|
||||||
|
│ └── ios-encryption-guide.md # iOS 加密对接指南
|
||||||
|
├── test/ # 测试文件
|
||||||
|
├── deploy*.sh # 部署脚本
|
||||||
|
├── start*.sh # 启动脚本
|
||||||
|
├── ecosystem.config.js # PM2 配置
|
||||||
|
├── package.json # 项目配置
|
||||||
|
└── README.md # 项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ 开发指南
|
||||||
|
|
||||||
|
### 开发环境设置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
yarn start:dev
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# 运行 E2E 测试
|
||||||
|
yarn test:e2e
|
||||||
|
|
||||||
|
# 代码格式化
|
||||||
|
yarn format
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
yarn lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
- 使用 TypeScript 严格模式
|
||||||
|
- 遵循 ESLint 配置规则
|
||||||
|
- 使用 Prettier 进行代码格式化
|
||||||
|
- 编写单元测试和集成测试
|
||||||
|
- 使用 Swagger 注解生成 API 文档
|
||||||
|
|
||||||
|
### Git 工作流
|
||||||
|
|
||||||
|
1. 从 `main` 分支创建功能分支
|
||||||
|
2. 提交代码前运行测试和代码检查
|
||||||
|
3. 创建 Pull Request
|
||||||
|
4. 代码审查通过后合并
|
||||||
|
|
||||||
|
## 🔒 安全特性
|
||||||
|
|
||||||
|
### 数据加密
|
||||||
|
|
||||||
|
项目支持 AES-256-GCM 端到端加密:
|
||||||
|
|
||||||
|
- **算法**: AES-256-GCM
|
||||||
|
- **密钥长度**: 256位 (32字节)
|
||||||
|
- **IV长度**: 96位 (12字节)
|
||||||
|
- **认证标签长度**: 128位 (16字节)
|
||||||
|
|
||||||
|
详细的 iOS 端对接指南请参考 [`docs/ios-encryption-guide.md`](docs/ios-encryption-guide.md)
|
||||||
|
|
||||||
|
### 认证授权
|
||||||
|
|
||||||
|
- JWT 令牌认证
|
||||||
|
- Apple Sign-In 集成
|
||||||
|
- 刷新令牌机制
|
||||||
|
- 角色权限控制
|
||||||
|
|
||||||
|
## 📊 监控和日志
|
||||||
|
|
||||||
|
### 日志管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看应用日志
|
||||||
|
pm2 logs pilates-server
|
||||||
|
|
||||||
|
# 查看错误日志
|
||||||
|
tail -f logs/error.log
|
||||||
|
|
||||||
|
# 查看访问日志
|
||||||
|
tail -f logs/output.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 性能监控
|
||||||
|
|
||||||
|
- PM2 进程监控
|
||||||
|
- 内存使用限制 (1GB)
|
||||||
|
- 自动重启机制
|
||||||
|
- 集群模式支持
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
1. Fork 项目
|
||||||
|
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||||
|
5. 创建 Pull Request
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
本项目采用 UNLICENSED 许可证。
|
||||||
|
|
||||||
|
## 📞 支持
|
||||||
|
|
||||||
|
如有问题或建议,请通过以下方式联系:
|
||||||
|
|
||||||
|
- 创建 Issue
|
||||||
|
- 发送邮件至项目维护者
|
||||||
|
- 查看项目文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意**: 生产环境部署前请确保所有环境变量已正确配置,特别是数据库连接和 Apple 相关配置。
|
||||||
|
|
||||||
|
## 恢复购买功能
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
恢复购买功能允许用户将之前在 RevenueCat 中的购买记录与当前登录的账号关联,确保用户能够访问他们已购买的内容。
|
||||||
|
|
||||||
|
### API 接口
|
||||||
|
|
||||||
|
**POST** `/users/restore-purchase`
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"revenueCatUserId": "可选的RevenueCat用户ID,如果不提供将使用当前登录用户ID"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "购买记录恢复成功",
|
||||||
|
"data": {
|
||||||
|
"restoredPurchases": [
|
||||||
|
{
|
||||||
|
"productId": "com.app.premium.monthly",
|
||||||
|
"transactionId": "1000000123456789",
|
||||||
|
"purchaseDate": "2024-01-15T10:30:00Z",
|
||||||
|
"expirationDate": "2024-02-15T10:30:00Z",
|
||||||
|
"entitlementId": "premium",
|
||||||
|
"store": "app_store"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"membershipExpiration": "2024-02-15T10:30:00Z",
|
||||||
|
"message": "成功恢复 1 个购买记录,会员有效期至 2024-02-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
需要在 `.env` 文件中配置以下 RevenueCat API 密钥:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# RevenueCat 公开 API 密钥(用于读取用户信息)
|
||||||
|
REVENUECAT_PUBLIC_API_KEY=your_public_api_key_here
|
||||||
|
|
||||||
|
# RevenueCat 私密 API 密钥(用于用户关联,可选)
|
||||||
|
REVENUECAT_SECRET_API_KEY=your_secret_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 工作原理
|
||||||
|
|
||||||
|
1. **获取购买历史**: 通过 RevenueCat REST API 获取指定用户的购买历史
|
||||||
|
2. **解析购买记录**: 解析订阅和非续费购买,提取产品信息和过期时间
|
||||||
|
3. **更新用户状态**: 将最新的会员过期时间更新到当前用户
|
||||||
|
4. **用户关联**: 如果提供了不同的 RevenueCat 用户ID,会尝试进行用户关联
|
||||||
|
|
||||||
|
### 使用场景
|
||||||
|
|
||||||
|
- 用户更换设备后需要恢复购买
|
||||||
|
- 用户重新安装应用后需要恢复会员状态
|
||||||
|
- 用户使用不同的账号登录但想关联之前的购买
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- 需要确保 RevenueCat 项目已正确配置
|
||||||
|
- 公开 API 密钥用于读取用户信息,私密 API 密钥用于用户关联
|
||||||
|
- 恢复购买不会重复收费,只是将现有购买记录与当前账号关联
|
||||||
6
SubscriptionKey_K3L2F8HFTS.p8
Normal file
6
SubscriptionKey_K3L2F8HFTS.p8
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgONlcciOyI4UqtLhW
|
||||||
|
4EwWvkjRybvNNg15/m6voi4vx0agCgYIKoZIzj0DAQehRANCAAQeTAmBTidpkDwT
|
||||||
|
FWUrxN+HfXhKbiDloQ68fc//+jeVQtC5iUKOZp38P/IqI+9lUIWoLKsryCxKeAkb
|
||||||
|
8U5D2WWu
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
105
deploy-optimized.sh
Executable file
105
deploy-optimized.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 优化版发布脚本 - 只上传源码,服务器端构建
|
||||||
|
SERVER_HOST="119.91.211.52"
|
||||||
|
SERVER_USER="root"
|
||||||
|
SERVER_PATH="/usr/local/web/love-tips-server"
|
||||||
|
|
||||||
|
# 定义颜色
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
print_message() {
|
||||||
|
echo -e "${GREEN}[INFO] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARN] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "🚀 开始优化部署流程..."
|
||||||
|
|
||||||
|
# 1. 测试SSH连接
|
||||||
|
print_message "测试SSH连接..."
|
||||||
|
if ! ssh -o ConnectTimeout=10 "$SERVER_USER@$SERVER_HOST" "echo 'SSH连接成功'" 2>/dev/null; then
|
||||||
|
print_error "无法连接到服务器,请检查SSH配置"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 停止现有服务(如果存在)
|
||||||
|
print_message "停止现有服务..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "
|
||||||
|
cd $SERVER_PATH 2>/dev/null
|
||||||
|
if [ -f 'ecosystem.config.js' ]; then
|
||||||
|
pm2 stop ecosystem.config.js 2>/dev/null || true
|
||||||
|
pm2 delete ecosystem.config.js 2>/dev/null || true
|
||||||
|
echo '已停止现有服务'
|
||||||
|
fi
|
||||||
|
" 2>/dev/null
|
||||||
|
|
||||||
|
# 3. 备份现有部署
|
||||||
|
print_message "备份现有部署..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "
|
||||||
|
if [ -d '$SERVER_PATH' ] && [ -f '$SERVER_PATH/package.json' ]; then
|
||||||
|
backup_dir='$SERVER_PATH.backup.$(date +%Y%m%d_%H%M%S)'
|
||||||
|
cp -r '$SERVER_PATH' \$backup_dir
|
||||||
|
echo \"已备份到: \$backup_dir\"
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
# 4. 创建目标目录
|
||||||
|
print_message "准备目标目录..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "mkdir -p $SERVER_PATH"
|
||||||
|
|
||||||
|
# 5. 同步源代码和配置文件(排除不必要的文件)
|
||||||
|
print_message "同步源代码到服务器..."
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='dist' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='.env.local' \
|
||||||
|
--exclude='.env.*.local' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='logs' \
|
||||||
|
--exclude='.DS_Store' \
|
||||||
|
--exclude='coverage' \
|
||||||
|
--exclude='.nyc_output' \
|
||||||
|
./ "$SERVER_USER@$SERVER_HOST:$SERVER_PATH/"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
print_error "文件同步失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "文件同步完成"
|
||||||
|
|
||||||
|
# 6. 在服务器上执行start.sh(它会处理依赖安装、构建和启动)
|
||||||
|
print_message "在服务器上执行start.sh..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "
|
||||||
|
cd $SERVER_PATH
|
||||||
|
chmod +x start.sh
|
||||||
|
echo '开始执行start.sh...'
|
||||||
|
./start.sh
|
||||||
|
"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
print_message ""
|
||||||
|
print_message "🎉 部署成功完成!"
|
||||||
|
print_message "服务器: $SERVER_HOST"
|
||||||
|
print_message "目录: $SERVER_PATH"
|
||||||
|
print_message ""
|
||||||
|
print_message "常用管理命令:"
|
||||||
|
print_message "查看状态: ssh $SERVER_USER@$SERVER_HOST 'cd $SERVER_PATH && pm2 status'"
|
||||||
|
print_message "查看日志: ssh $SERVER_USER@$SERVER_HOST 'cd $SERVER_PATH && pm2 logs'"
|
||||||
|
print_message "重启服务: ssh $SERVER_USER@$SERVER_HOST 'cd $SERVER_PATH && pm2 restart ecosystem.config.js'"
|
||||||
|
else
|
||||||
|
print_error "部署失败,请检查服务器日志"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
24
deploy-simple.sh
Executable file
24
deploy-simple.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 简化版发布脚本
|
||||||
|
SERVER_HOST="119.91.211.52"
|
||||||
|
SERVER_USER="root"
|
||||||
|
SERVER_PATH="/usr/local/web/love-tips-server"
|
||||||
|
|
||||||
|
echo "🚀 开始部署到服务器..."
|
||||||
|
|
||||||
|
# 1. 本地构建
|
||||||
|
echo "📦 本地构建项目..."
|
||||||
|
yarn install && yarn build
|
||||||
|
|
||||||
|
# 2. 同步文件到服务器
|
||||||
|
echo "📤 同步文件到服务器..."
|
||||||
|
rsync -avz --exclude=node_modules --exclude=.git --exclude=.env --exclude=.env.local --exclude=.env.*.local \
|
||||||
|
./ "$SERVER_USER@$SERVER_HOST:$SERVER_PATH/"
|
||||||
|
|
||||||
|
# 3. 在服务器上启动服务
|
||||||
|
echo "🔄 启动服务..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && chmod +x start.sh && ./start.sh"
|
||||||
|
|
||||||
|
echo "✅ 部署完成!"
|
||||||
|
echo "查看状态: ssh $SERVER_USER@$SERVER_HOST 'cd $SERVER_PATH && pm2 status'"
|
||||||
253
deploy.sh
Executable file
253
deploy.sh
Executable file
@@ -0,0 +1,253 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 发布脚本配置
|
||||||
|
SERVER_HOST="119.91.211.52"
|
||||||
|
SERVER_USER="root" # 根据实际情况修改用户名
|
||||||
|
SERVER_PATH="/usr/local/web/love-tips-server"
|
||||||
|
PROJECT_NAME="love-tips-server"
|
||||||
|
|
||||||
|
# 定义颜色
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 打印带颜色的消息
|
||||||
|
print_message() {
|
||||||
|
echo -e "${GREEN}[INFO] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARN] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_step() {
|
||||||
|
echo -e "${BLUE}[STEP] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查必要的工具
|
||||||
|
check_requirements() {
|
||||||
|
print_step "检查部署环境..."
|
||||||
|
|
||||||
|
# 检查rsync
|
||||||
|
if ! command -v rsync &> /dev/null; then
|
||||||
|
print_error "rsync未安装,请先安装rsync"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查ssh
|
||||||
|
if ! command -v ssh &> /dev/null; then
|
||||||
|
print_error "ssh未安装,请先安装ssh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "环境检查完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 本地构建
|
||||||
|
build_project() {
|
||||||
|
print_step "开始本地构建..."
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
print_message "安装依赖..."
|
||||||
|
if ! yarn install; then
|
||||||
|
print_error "依赖安装失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建项目
|
||||||
|
print_message "构建项目..."
|
||||||
|
if ! yarn build; then
|
||||||
|
print_error "项目构建失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "本地构建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 创建部署包
|
||||||
|
create_deployment_package() {
|
||||||
|
print_step "创建部署包..."
|
||||||
|
|
||||||
|
# 创建临时目录
|
||||||
|
TEMP_DIR=$(mktemp -d)
|
||||||
|
DEPLOY_DIR="$TEMP_DIR/$PROJECT_NAME"
|
||||||
|
|
||||||
|
print_message "创建临时目录: $DEPLOY_DIR"
|
||||||
|
mkdir -p "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
# 复制必要文件
|
||||||
|
print_message "复制项目文件..."
|
||||||
|
|
||||||
|
# 复制构建后的文件
|
||||||
|
cp -r dist "$DEPLOY_DIR/"
|
||||||
|
|
||||||
|
# 复制配置文件
|
||||||
|
cp package.json "$DEPLOY_DIR/"
|
||||||
|
cp yarn.lock "$DEPLOY_DIR/"
|
||||||
|
cp ecosystem.config.js "$DEPLOY_DIR/"
|
||||||
|
cp start.sh "$DEPLOY_DIR/"
|
||||||
|
|
||||||
|
# 复制其他必要文件
|
||||||
|
if [ -f "SubscriptionKey_K3L2F8HFTS.p8" ]; then
|
||||||
|
cp SubscriptionKey_K3L2F8HFTS.p8 "$DEPLOY_DIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建日志目录
|
||||||
|
mkdir -p "$DEPLOY_DIR/logs"
|
||||||
|
|
||||||
|
print_message "部署包创建完成: $DEPLOY_DIR"
|
||||||
|
echo "$DEPLOY_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 部署到服务器
|
||||||
|
deploy_to_server() {
|
||||||
|
local deploy_dir=$1
|
||||||
|
print_step "部署到服务器..."
|
||||||
|
|
||||||
|
# 测试SSH连接
|
||||||
|
print_message "测试SSH连接..."
|
||||||
|
if ! ssh -o ConnectTimeout=10 "$SERVER_USER@$SERVER_HOST" "echo 'SSH连接成功'"; then
|
||||||
|
print_error "无法连接到服务器 $SERVER_HOST"
|
||||||
|
print_warning "请检查:"
|
||||||
|
print_warning "1. 服务器地址是否正确"
|
||||||
|
print_warning "2. SSH密钥是否配置正确"
|
||||||
|
print_warning "3. 服务器是否可访问"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 在服务器上创建目录
|
||||||
|
print_message "在服务器上创建目录..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "mkdir -p $SERVER_PATH"
|
||||||
|
|
||||||
|
# 备份现有部署(如果存在)
|
||||||
|
print_message "备份现有部署..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "
|
||||||
|
if [ -d '$SERVER_PATH' ] && [ -f '$SERVER_PATH/package.json' ]; then
|
||||||
|
backup_dir='$SERVER_PATH.backup.$(date +%Y%m%d_%H%M%S)'
|
||||||
|
echo '创建备份目录: '\$backup_dir
|
||||||
|
cp -r '$SERVER_PATH' \$backup_dir
|
||||||
|
echo '备份完成'
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
# 停止现有服务
|
||||||
|
print_message "停止现有服务..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "
|
||||||
|
cd $SERVER_PATH
|
||||||
|
if [ -f 'ecosystem.config.js' ]; then
|
||||||
|
pm2 stop ecosystem.config.js 2>/dev/null || true
|
||||||
|
pm2 delete ecosystem.config.js 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
# 同步文件到服务器
|
||||||
|
print_message "同步文件到服务器..."
|
||||||
|
if ! rsync -avz --delete "$deploy_dir/" "$SERVER_USER@$SERVER_HOST:$SERVER_PATH/"; then
|
||||||
|
print_error "文件同步失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "文件同步完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 在服务器上启动服务
|
||||||
|
start_service() {
|
||||||
|
print_step "启动服务..."
|
||||||
|
|
||||||
|
# 在服务器上执行start.sh
|
||||||
|
print_message "在服务器上执行start.sh..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "
|
||||||
|
cd $SERVER_PATH
|
||||||
|
chmod +x start.sh
|
||||||
|
./start.sh
|
||||||
|
"
|
||||||
|
|
||||||
|
# 检查服务状态
|
||||||
|
print_message "检查服务状态..."
|
||||||
|
ssh "$SERVER_USER@$SERVER_HOST" "
|
||||||
|
cd $SERVER_PATH
|
||||||
|
pm2 status
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then
|
||||||
|
print_message "清理临时文件: $TEMP_DIR"
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
print_message "开始部署 $PROJECT_NAME 到 $SERVER_HOST"
|
||||||
|
print_message "目标目录: $SERVER_PATH"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 设置错误处理
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# 执行部署步骤
|
||||||
|
check_requirements
|
||||||
|
build_project
|
||||||
|
|
||||||
|
DEPLOY_DIR=$(create_deployment_package)
|
||||||
|
TEMP_DIR=$(dirname "$DEPLOY_DIR")
|
||||||
|
|
||||||
|
deploy_to_server "$DEPLOY_DIR"
|
||||||
|
start_service
|
||||||
|
|
||||||
|
print_message ""
|
||||||
|
print_message "🎉 部署成功完成!"
|
||||||
|
print_message "服务器: $SERVER_HOST"
|
||||||
|
print_message "目录: $SERVER_PATH"
|
||||||
|
print_message ""
|
||||||
|
print_message "常用命令:"
|
||||||
|
print_message "查看日志: ssh $SERVER_USER@$SERVER_HOST 'cd $SERVER_PATH && pm2 logs'"
|
||||||
|
print_message "查看状态: ssh $SERVER_USER@$SERVER_HOST 'cd $SERVER_PATH && pm2 status'"
|
||||||
|
print_message "重启服务: ssh $SERVER_USER@$SERVER_HOST 'cd $SERVER_PATH && pm2 restart ecosystem.config.js'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 参数处理
|
||||||
|
case "$1" in
|
||||||
|
--help|-h)
|
||||||
|
echo "用法: $0 [选项]"
|
||||||
|
echo ""
|
||||||
|
echo "选项:"
|
||||||
|
echo " --help, -h 显示帮助信息"
|
||||||
|
echo " --dry-run 仅显示将要执行的操作,不实际执行"
|
||||||
|
echo ""
|
||||||
|
echo "配置:"
|
||||||
|
echo " 服务器: $SERVER_HOST"
|
||||||
|
echo " 用户: $SERVER_USER"
|
||||||
|
echo " 路径: $SERVER_PATH"
|
||||||
|
echo ""
|
||||||
|
echo "注意事项:"
|
||||||
|
echo "1. 确保已配置SSH密钥认证"
|
||||||
|
echo "2. 确保服务器上已安装Node.js、yarn、pm2"
|
||||||
|
echo "3. 确保有足够的权限访问目标目录"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
print_message "这是一个模拟运行,不会实际执行部署操作"
|
||||||
|
print_message "将要执行的操作:"
|
||||||
|
print_message "1. 检查本地环境"
|
||||||
|
print_message "2. 构建项目"
|
||||||
|
print_message "3. 创建部署包"
|
||||||
|
print_message "4. 连接到服务器: $SERVER_HOST"
|
||||||
|
print_message "5. 备份现有部署"
|
||||||
|
print_message "6. 停止现有服务"
|
||||||
|
print_message "7. 同步文件到: $SERVER_PATH"
|
||||||
|
print_message "8. 执行start.sh启动服务"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
main
|
||||||
|
;;
|
||||||
|
esac
|
||||||
190
docs/app-store-server-notifications.md
Normal file
190
docs/app-store-server-notifications.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# App Store 服务器通知接收接口
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本接口用于接收来自苹果App Store的服务器通知(App Store Server Notifications V2),处理用户订阅状态的变化,包括订阅、续订、过期、退款等事件。
|
||||||
|
|
||||||
|
## 接口地址
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/users/app-store-notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置要求
|
||||||
|
|
||||||
|
### 1. 在App Store Connect中配置
|
||||||
|
|
||||||
|
1. 登录 [App Store Connect](https://appstoreconnect.apple.com)
|
||||||
|
2. 选择你的应用
|
||||||
|
3. 在左侧菜单中选择 "App信息"
|
||||||
|
4. 滚动到 "App Store服务器通知" 部分
|
||||||
|
5. 设置生产环境URL:`https://your-domain.com/api/users/app-store-notifications`
|
||||||
|
6. 设置沙盒环境URL:`https://your-domain.com/api/users/app-store-notifications`
|
||||||
|
7. 选择 "Version 2" 通知格式
|
||||||
|
8. 保存设置
|
||||||
|
|
||||||
|
### 2. 环境变量配置
|
||||||
|
|
||||||
|
确保以下环境变量已正确设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apple相关配置
|
||||||
|
APPLE_BUNDLE_ID=com.yourcompany.yourapp
|
||||||
|
APPLE_KEY_ID=your_key_id
|
||||||
|
APPLE_ISSUER_ID=your_issuer_id
|
||||||
|
APPLE_PRIVATE_KEY_PATH=path/to/your/private/key.p8
|
||||||
|
APPLE_APP_SHARED_SECRET=your_shared_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## 请求格式
|
||||||
|
|
||||||
|
### 请求头
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"signedPayload": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjEyMzQ1Njc4OTAiLCJ0eXAiOiJKV1QifQ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "通知处理成功",
|
||||||
|
"data": {
|
||||||
|
"processed": true,
|
||||||
|
"notificationType": "SUBSCRIBED",
|
||||||
|
"notificationUUID": "12345678-1234-1234-1234-123456789012",
|
||||||
|
"userId": "user_12345",
|
||||||
|
"transactionId": "1000000123456789"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 500,
|
||||||
|
"message": "处理通知失败: 无法解码通知负载",
|
||||||
|
"data": {
|
||||||
|
"processed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的通知类型
|
||||||
|
|
||||||
|
### 1. SUBSCRIBED(订阅)
|
||||||
|
- **描述**: 用户首次订阅或重新订阅
|
||||||
|
- **处理**: 更新用户的订阅状态和到期时间
|
||||||
|
|
||||||
|
### 2. DID_RENEW(续订)
|
||||||
|
- **描述**: 订阅成功续订
|
||||||
|
- **处理**: 延长用户的订阅到期时间
|
||||||
|
|
||||||
|
### 3. EXPIRED(过期)
|
||||||
|
- **描述**: 订阅已过期
|
||||||
|
- **处理**: 可以发送邮件通知或降级用户权限
|
||||||
|
|
||||||
|
### 4. DID_FAIL_TO_RENEW(续订失败)
|
||||||
|
- **描述**: 订阅续订失败(通常是支付问题)
|
||||||
|
- **处理**: 发送付款提醒或进入宽限期
|
||||||
|
|
||||||
|
### 5. REFUND(退款)
|
||||||
|
- **描述**: 用户获得退款
|
||||||
|
- **处理**: 立即撤销用户的订阅权限
|
||||||
|
|
||||||
|
### 6. REVOKE(撤销)
|
||||||
|
- **描述**: 通过家庭共享获得的权限被撤销
|
||||||
|
- **处理**: 撤销用户的订阅权限
|
||||||
|
|
||||||
|
### 7. DID_CHANGE_RENEWAL_STATUS(续订状态变更)
|
||||||
|
- **描述**: 用户更改了自动续订设置
|
||||||
|
- **处理**: 记录用户的续订偏好
|
||||||
|
|
||||||
|
### 8. TEST(测试)
|
||||||
|
- **描述**: 测试通知
|
||||||
|
- **处理**: 仅记录日志,无特殊处理
|
||||||
|
|
||||||
|
## 安全考虑
|
||||||
|
|
||||||
|
### 1. 签名验证
|
||||||
|
- 接口会验证通知的JWS签名
|
||||||
|
- 确保通知来自苹果官方服务器
|
||||||
|
- 验证bundleId匹配
|
||||||
|
|
||||||
|
### 2. 重复处理
|
||||||
|
- 建议实现幂等性处理
|
||||||
|
- 根据notificationUUID去重
|
||||||
|
- 避免重复处理相同通知
|
||||||
|
|
||||||
|
### 3. 错误处理
|
||||||
|
- 对于无法处理的通知,返回适当的HTTP状态码
|
||||||
|
- 苹果会根据响应状态码决定是否重试
|
||||||
|
|
||||||
|
## 日志记录
|
||||||
|
|
||||||
|
接口会记录以下信息:
|
||||||
|
- 收到的通知类型和UUID
|
||||||
|
- 解码后的交易信息
|
||||||
|
- 处理结果和任何错误
|
||||||
|
- 用户ID和交易ID(如果可用)
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
### 1. 使用App Store Connect测试
|
||||||
|
1. 在App Store Connect中请求测试通知
|
||||||
|
2. 检查服务器日志确认收到通知
|
||||||
|
3. 验证通知处理逻辑是否正确
|
||||||
|
|
||||||
|
### 2. 沙盒环境测试
|
||||||
|
1. 使用沙盒环境进行购买测试
|
||||||
|
2. 观察通知是否正确触发
|
||||||
|
3. 验证用户状态是否正确更新
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **收不到通知**
|
||||||
|
- 检查App Store Connect中的URL配置
|
||||||
|
- 确保服务器可以从外网访问
|
||||||
|
- 检查防火墙设置
|
||||||
|
|
||||||
|
2. **通知解码失败**
|
||||||
|
- 验证私钥文件路径和格式
|
||||||
|
- 检查环境变量配置
|
||||||
|
- 确认bundleId匹配
|
||||||
|
|
||||||
|
3. **用户状态未更新**
|
||||||
|
- 检查appAccountToken是否正确设置
|
||||||
|
- 验证用户ID映射逻辑
|
||||||
|
- 查看数据库更新日志
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
```bash
|
||||||
|
# 查看相关日志
|
||||||
|
grep "App Store" /path/to/your/logs/app.log
|
||||||
|
grep "processAppStoreNotification" /path/to/your/logs/app.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展功能
|
||||||
|
|
||||||
|
可以根据业务需求扩展以下功能:
|
||||||
|
|
||||||
|
1. **邮件通知**: 在特定事件发生时发送邮件
|
||||||
|
2. **数据分析**: 收集订阅数据用于分析
|
||||||
|
3. **第三方集成**: 将事件发送到其他系统
|
||||||
|
4. **自定义业务逻辑**: 根据不同的通知类型执行特定操作
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications)
|
||||||
|
- [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi)
|
||||||
|
- [StoreKit 2](https://developer.apple.com/documentation/storekit)
|
||||||
223
docs/ios-encryption-guide.md
Normal file
223
docs/ios-encryption-guide.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# iOS端加密对接指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档描述了如何在iOS端实现与服务端兼容的AES-256-GCM加解密功能。
|
||||||
|
|
||||||
|
## 加密规格
|
||||||
|
|
||||||
|
- **算法**: AES-256-GCM
|
||||||
|
- **密钥长度**: 256位 (32字节)
|
||||||
|
- **IV长度**: 96位 (12字节)
|
||||||
|
- **认证标签长度**: 128位 (16字节)
|
||||||
|
- **数据格式**: Base64编码的 `IV + AuthTag + Ciphertext`
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
在服务端设置环境变量 `ENCRYPTION_KEY`,确保密钥安全:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ENCRYPTION_KEY="your-32-character-secret-key-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
## iOS Swift 实现
|
||||||
|
|
||||||
|
### 1. 创建加密工具类
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
class EncryptionHelper {
|
||||||
|
private static let keyLength = 32
|
||||||
|
private static let ivLength = 12
|
||||||
|
private static let tagLength = 16
|
||||||
|
private static let additionalData = "additional-data".data(using: .utf8)!
|
||||||
|
|
||||||
|
// 从配置或钥匙串获取密钥
|
||||||
|
private static let encryptionKey: SymmetricKey = {
|
||||||
|
let keyString = "your-32-character-secret-key-here" // 应从安全存储获取
|
||||||
|
let keyData = keyString.prefix(keyLength).padding(toLength: keyLength, withPad: "0", startingAt: 0).data(using: .utf8)!
|
||||||
|
return SymmetricKey(data: keyData)
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// 加密数据
|
||||||
|
/// - Parameter plaintext: 要加密的明文字符串
|
||||||
|
/// - Returns: Base64编码的加密数据,格式:iv + tag + ciphertext
|
||||||
|
static func encrypt(_ plaintext: String) throws -> String {
|
||||||
|
guard let data = plaintext.data(using: .utf8) else {
|
||||||
|
throw EncryptionError.invalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成随机IV
|
||||||
|
let iv = Data((0..<ivLength).map { _ in UInt8.random(in: 0...255) })
|
||||||
|
|
||||||
|
// 使用AES-GCM加密
|
||||||
|
let sealedBox = try AES.GCM.seal(data, using: encryptionKey, nonce: AES.GCM.Nonce(data: iv), authenticating: additionalData)
|
||||||
|
|
||||||
|
// 组合数据:IV + Tag + Ciphertext
|
||||||
|
var combined = Data()
|
||||||
|
combined.append(iv)
|
||||||
|
combined.append(sealedBox.tag)
|
||||||
|
combined.append(sealedBox.ciphertext)
|
||||||
|
|
||||||
|
return combined.base64EncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解密数据
|
||||||
|
/// - Parameter encryptedData: Base64编码的加密数据
|
||||||
|
/// - Returns: 解密后的明文字符串
|
||||||
|
static func decrypt(_ encryptedData: String) throws -> String {
|
||||||
|
guard let combined = Data(base64Encoded: encryptedData) else {
|
||||||
|
throw EncryptionError.invalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
guard combined.count >= ivLength + tagLength else {
|
||||||
|
throw EncryptionError.invalidDataLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取各部分
|
||||||
|
let iv = combined.prefix(ivLength)
|
||||||
|
let tag = combined.dropFirst(ivLength).prefix(tagLength)
|
||||||
|
let ciphertext = combined.dropFirst(ivLength + tagLength)
|
||||||
|
|
||||||
|
// 创建密封盒进行解密
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), ciphertext: ciphertext, tag: tag)
|
||||||
|
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey, authenticating: additionalData)
|
||||||
|
|
||||||
|
guard let plaintext = String(data: decryptedData, encoding: .utf8) else {
|
||||||
|
throw EncryptionError.decodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EncryptionError: Error {
|
||||||
|
case invalidInput
|
||||||
|
case invalidDataLength
|
||||||
|
case decodingFailed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 使用示例
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// 创建用户数据
|
||||||
|
struct CreateUserRequest: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let mail: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密请求体
|
||||||
|
struct EncryptedRequest: Codable {
|
||||||
|
let encryptedData: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密响应体
|
||||||
|
struct EncryptedResponse: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let message: String
|
||||||
|
let encryptedData: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
func createUserWithEncryption() async {
|
||||||
|
do {
|
||||||
|
// 1. 准备用户数据
|
||||||
|
let userData = CreateUserRequest(
|
||||||
|
id: "1234567890",
|
||||||
|
name: "张三",
|
||||||
|
mail: "zhangsan@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. 序列化为JSON
|
||||||
|
let jsonData = try JSONEncoder().encode(userData)
|
||||||
|
let jsonString = String(data: jsonData, encoding: .utf8)!
|
||||||
|
|
||||||
|
// 3. 加密数据
|
||||||
|
let encryptedData = try EncryptionHelper.encrypt(jsonString)
|
||||||
|
|
||||||
|
// 4. 创建请求体
|
||||||
|
let request = EncryptedRequest(encryptedData: encryptedData)
|
||||||
|
|
||||||
|
// 5. 发送请求
|
||||||
|
let response = try await sendEncryptedRequest(request)
|
||||||
|
|
||||||
|
// 6. 解密响应(如果有加密数据)
|
||||||
|
if let encryptedResponseData = response.encryptedData {
|
||||||
|
let decryptedResponse = try EncryptionHelper.decrypt(encryptedResponseData)
|
||||||
|
print("解密后的响应: \(decryptedResponse)")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("加密通信失败: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEncryptedRequest(_ request: EncryptedRequest) async throws -> EncryptedResponse {
|
||||||
|
let url = URL(string: "https://your-server.com/users/encrypted")!
|
||||||
|
var urlRequest = URLRequest(url: url)
|
||||||
|
urlRequest.httpMethod = "POST"
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let requestData = try JSONEncoder().encode(request)
|
||||||
|
urlRequest.httpBody = requestData
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: urlRequest)
|
||||||
|
return try JSONDecoder().decode(EncryptedResponse.self, from: data)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 加密版创建用户接口
|
||||||
|
|
||||||
|
**端点**: `POST /users/encrypted`
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"encryptedData": "base64编码的加密数据"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应体**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "用户创建成功",
|
||||||
|
"encryptedData": "base64编码的加密响应数据"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. **密钥管理**:
|
||||||
|
|
||||||
|
- 生产环境必须使用环境变量或安全密钥管理服务
|
||||||
|
- iOS端应将密钥存储在钥匙串中
|
||||||
|
- 定期轮换密钥
|
||||||
|
|
||||||
|
2. **传输安全**:
|
||||||
|
|
||||||
|
- 始终使用HTTPS
|
||||||
|
- 考虑添加请求签名验证
|
||||||
|
|
||||||
|
3. **错误处理**:
|
||||||
|
|
||||||
|
- 不要在错误信息中暴露加密细节
|
||||||
|
- 记录加密失败日志用于监控
|
||||||
|
|
||||||
|
4. **测试**:
|
||||||
|
- 确保iOS端和服务端使用相同的密钥
|
||||||
|
- 测试各种边界情况和错误场景
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
1. **解密失败**: 检查密钥是否一致
|
||||||
|
2. **格式错误**: 确认Base64编码格式正确
|
||||||
|
3. **数据长度错误**: 验证IV和Tag长度是否正确
|
||||||
230
docs/topic-favorite-api.md
Normal file
230
docs/topic-favorite-api.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# 话题收藏功能 API 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
话题收藏功能允许用户收藏喜欢的话题,方便后续查看和使用。本文档描述了话题收藏相关的所有API接口。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 收藏话题
|
||||||
|
- ✅ 取消收藏话题
|
||||||
|
- ✅ 获取收藏话题列表
|
||||||
|
- ✅ 话题列表中显示收藏状态
|
||||||
|
- ✅ 防重复收藏
|
||||||
|
- ✅ 完整的错误处理
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 1. 收藏话题
|
||||||
|
|
||||||
|
**接口地址:** `POST /api/topic/favorite`
|
||||||
|
|
||||||
|
**请求参数:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topicId": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "收藏成功",
|
||||||
|
"data": {
|
||||||
|
"success": true,
|
||||||
|
"isFavorited": true,
|
||||||
|
"topicId": 123
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误情况:**
|
||||||
|
- 话题不存在:`{ "code": 400, "message": "话题不存在" }`
|
||||||
|
- 已收藏:`{ "code": 200, "message": "已经收藏过该话题" }`
|
||||||
|
|
||||||
|
### 2. 取消收藏话题
|
||||||
|
|
||||||
|
**接口地址:** `POST /api/topic/unfavorite`
|
||||||
|
|
||||||
|
**请求参数:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topicId": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "取消收藏成功",
|
||||||
|
"data": {
|
||||||
|
"success": true,
|
||||||
|
"isFavorited": false,
|
||||||
|
"topicId": 123
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 获取收藏话题列表
|
||||||
|
|
||||||
|
**接口地址:** `POST /api/topic/favorites`
|
||||||
|
|
||||||
|
**请求参数:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"topic": "约会话题",
|
||||||
|
"opening": {
|
||||||
|
"text": "今天天气真不错...",
|
||||||
|
"scenarios": ["咖啡厅", "公园"]
|
||||||
|
},
|
||||||
|
"scriptType": "初识破冰",
|
||||||
|
"scriptTopic": "天气",
|
||||||
|
"keywords": "天气,约会,轻松",
|
||||||
|
"isFavorited": true,
|
||||||
|
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 5,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 获取话题列表(已包含收藏状态)
|
||||||
|
|
||||||
|
**接口地址:** `POST /api/topic/list`
|
||||||
|
|
||||||
|
现在所有话题列表都会包含 `isFavorited` 字段,表示当前用户是否已收藏该话题。
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"topic": "约会话题",
|
||||||
|
"opening": {
|
||||||
|
"text": "今天天气真不错...",
|
||||||
|
"scenarios": ["咖啡厅", "公园"]
|
||||||
|
},
|
||||||
|
"scriptType": "初识破冰",
|
||||||
|
"scriptTopic": "天气",
|
||||||
|
"keywords": "天气,约会,轻松",
|
||||||
|
"isFavorited": true, // ← 新增的收藏状态字段
|
||||||
|
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 124,
|
||||||
|
"topic": "工作话题",
|
||||||
|
"opening": "关于工作的开场白...",
|
||||||
|
"scriptType": "深度交流",
|
||||||
|
"scriptTopic": "职业",
|
||||||
|
"keywords": "工作,职业,发展",
|
||||||
|
"isFavorited": false, // ← 未收藏
|
||||||
|
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 20,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
### 新增表:t_topic_favorites
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `t_topic_favorites` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
|
||||||
|
`topic_id` int NOT NULL COMMENT '话题ID',
|
||||||
|
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `unique_user_topic_favorite` (`user_id`, `topic_id`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_topic_id` (`topic_id`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `t_users` (`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`topic_id`) REFERENCES `t_topic_library` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 1. 用户收藏话题
|
||||||
|
```javascript
|
||||||
|
// 收藏话题
|
||||||
|
const response = await fetch('/api/topic/favorite', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer your-jwt-token'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ topicId: 123 })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 取消收藏话题
|
||||||
|
```javascript
|
||||||
|
// 取消收藏
|
||||||
|
const response = await fetch('/api/topic/unfavorite', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer your-jwt-token'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ topicId: 123 })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 查看收藏列表
|
||||||
|
```javascript
|
||||||
|
// 获取收藏的话题
|
||||||
|
const response = await fetch('/api/topic/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer your-jwt-token'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ page: 1, pageSize: 10 })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **防重复收藏:** 数据库层面通过唯一索引保证同一用户不能重复收藏同一话题
|
||||||
|
2. **级联删除:** 用户删除或话题删除时,相关收藏记录会自动删除
|
||||||
|
3. **性能优化:** 获取话题列表时通过单次查询获取用户所有收藏状态,避免N+1查询问题
|
||||||
|
4. **权限控制:** 所有接口都需要用户登录(JWT认证)
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
- `200`: 操作成功
|
||||||
|
- `400`: 请求参数错误(如话题不存在)
|
||||||
|
- `401`: 未授权(需要登录)
|
||||||
|
- `500`: 服务器内部错误
|
||||||
233
docs/winston-logger-guide.md
Normal file
233
docs/winston-logger-guide.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# Winston Logger 配置指南
|
||||||
|
|
||||||
|
本项目已配置了基于 Winston 的日志系统,支持日志文件输出、按日期滚动和自动清理。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ **日志文件输出**: 自动将日志写入文件
|
||||||
|
- ✅ **按日期滚动**: 每天生成新的日志文件
|
||||||
|
- ✅ **自动清理**: 保留最近7天的日志文件
|
||||||
|
- ✅ **分级日志**: 支持不同级别的日志分离
|
||||||
|
- ✅ **结构化日志**: 支持JSON格式的结构化日志
|
||||||
|
- ✅ **异常处理**: 自动记录未捕获的异常和Promise拒绝
|
||||||
|
|
||||||
|
## 日志文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
logs/
|
||||||
|
├── app-2025-07-21.log # 应用日志 (info级别及以上)
|
||||||
|
├── error-2025-07-21.log # 错误日志 (error级别)
|
||||||
|
├── debug-2025-07-21.log # 调试日志 (仅开发环境)
|
||||||
|
├── exceptions-2025-07-21.log # 未捕获异常
|
||||||
|
├── rejections-2025-07-21.log # 未处理的Promise拒绝
|
||||||
|
└── .audit-*.json # 日志轮转审计文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 日志级别
|
||||||
|
- **生产环境**: `info` 及以上级别
|
||||||
|
- **开发环境**: `debug` 及以上级别
|
||||||
|
|
||||||
|
### 文件轮转配置
|
||||||
|
- **日期模式**: `YYYY-MM-DD`
|
||||||
|
- **保留天数**: 7天
|
||||||
|
- **单文件大小**: 最大20MB
|
||||||
|
- **自动压缩**: 支持
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 1. 在服务中使用 NestJS Logger
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class YourService {
|
||||||
|
private readonly logger = new Logger(YourService.name);
|
||||||
|
|
||||||
|
someMethod() {
|
||||||
|
this.logger.log('这是一条信息日志');
|
||||||
|
this.logger.warn('这是一条警告日志');
|
||||||
|
this.logger.error('这是一条错误日志');
|
||||||
|
this.logger.debug('这是一条调试日志');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 直接使用 Winston Logger (推荐用于结构化日志)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
|
import { Logger as WinstonLogger } from 'winston';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class YourService {
|
||||||
|
constructor(
|
||||||
|
@Inject(WINSTON_MODULE_PROVIDER)
|
||||||
|
private readonly winstonLogger: WinstonLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
someMethod() {
|
||||||
|
// 结构化日志
|
||||||
|
this.winstonLogger.info('用户登录', {
|
||||||
|
context: 'AuthService',
|
||||||
|
userId: 'user123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误日志
|
||||||
|
this.winstonLogger.error('数据库连接失败', {
|
||||||
|
context: 'DatabaseService',
|
||||||
|
error: 'Connection timeout',
|
||||||
|
retryCount: 3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 在控制器中使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Controller, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
private readonly logger = new Logger(UsersController.name);
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
this.logger.log('获取用户列表请求');
|
||||||
|
// 业务逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 日志格式
|
||||||
|
|
||||||
|
### 控制台输出格式
|
||||||
|
```
|
||||||
|
2025-07-21 10:08:38 info [ServiceName] 日志消息
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件输出格式
|
||||||
|
```
|
||||||
|
2025-07-21 10:08:38 [INFO] [ServiceName] 日志消息
|
||||||
|
```
|
||||||
|
|
||||||
|
### 结构化日志格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2025-07-21 10:08:38",
|
||||||
|
"level": "info",
|
||||||
|
"message": "用户登录",
|
||||||
|
"context": "AuthService",
|
||||||
|
"userId": "user123",
|
||||||
|
"email": "user@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
可以通过环境变量调整日志行为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 日志级别 (development: debug, production: info)
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# 自定义日志目录 (可选)
|
||||||
|
LOG_DIR=/var/log/love-tips-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 使用合适的日志级别
|
||||||
|
- `error`: 错误和异常
|
||||||
|
- `warn`: 警告信息
|
||||||
|
- `info`: 重要的业务信息
|
||||||
|
- `debug`: 调试信息 (仅开发环境)
|
||||||
|
|
||||||
|
### 2. 结构化日志
|
||||||
|
对于重要的业务事件,使用结构化日志:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.winstonLogger.info('订单创建', {
|
||||||
|
context: 'OrderService',
|
||||||
|
orderId: order.id,
|
||||||
|
userId: user.id,
|
||||||
|
amount: order.amount,
|
||||||
|
currency: 'CNY'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 错误日志包含上下文
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
// 业务逻辑
|
||||||
|
} catch (error) {
|
||||||
|
this.winstonLogger.error('处理订单失败', {
|
||||||
|
context: 'OrderService',
|
||||||
|
orderId: order.id,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 避免敏感信息
|
||||||
|
不要在日志中记录密码、令牌等敏感信息:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 错误
|
||||||
|
this.logger.log(`用户登录: ${JSON.stringify(loginData)}`);
|
||||||
|
|
||||||
|
// ✅ 正确
|
||||||
|
this.logger.log(`用户登录: ${loginData.email}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 监控和维护
|
||||||
|
|
||||||
|
### 查看实时日志
|
||||||
|
```bash
|
||||||
|
# 查看应用日志
|
||||||
|
tail -f logs/app-$(date +%Y-%m-%d).log
|
||||||
|
|
||||||
|
# 查看错误日志
|
||||||
|
tail -f logs/error-$(date +%Y-%m-%d).log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志分析
|
||||||
|
```bash
|
||||||
|
# 统计错误数量
|
||||||
|
grep -c "ERROR" logs/app-*.log
|
||||||
|
|
||||||
|
# 查找特定用户的日志
|
||||||
|
grep "userId.*user123" logs/app-*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 清理旧日志
|
||||||
|
日志系统会自动清理7天前的日志文件,无需手动维护。
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 1. 日志文件未生成
|
||||||
|
- 检查 `logs` 目录权限
|
||||||
|
- 确认应用有写入权限
|
||||||
|
- 查看控制台是否有错误信息
|
||||||
|
|
||||||
|
### 2. 日志级别不正确
|
||||||
|
- 检查 `NODE_ENV` 环境变量
|
||||||
|
- 确认 winston 配置中的日志级别设置
|
||||||
|
|
||||||
|
### 3. 日志文件过大
|
||||||
|
- 检查日志轮转配置
|
||||||
|
- 确认 `maxSize` 和 `maxFiles` 设置
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
- [`src/common/logger/winston.config.ts`](../src/common/logger/winston.config.ts) - Winston 配置
|
||||||
|
- [`src/common/logger/logger.module.ts`](../src/common/logger/logger.module.ts) - Logger 模块
|
||||||
|
- [`src/main.ts`](../src/main.ts) - 应用启动配置
|
||||||
|
- [`src/app.module.ts`](../src/app.module.ts) - 应用模块配置
|
||||||
26
ecosystem.config.js
Normal file
26
ecosystem.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'pilates-server',
|
||||||
|
script: 'dist/main.js',
|
||||||
|
instances: 'max',
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
error_file: 'logs/error.log',
|
||||||
|
out_file: 'logs/output.log',
|
||||||
|
log_file: 'logs/combined.log',
|
||||||
|
merge_logs: true,
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000
|
||||||
|
},
|
||||||
|
env_development: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
PORT: 3001
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
},
|
||||||
|
{
|
||||||
|
},
|
||||||
|
);
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
13528
package-lock.json
generated
Normal file
13528
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
package.json
Normal file
106
package.json
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"name": "pilates-server",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "NODE_ENV=production nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "NODE_ENV=development nest start --watch",
|
||||||
|
"start:debug": "NODE_ENV=development nest start --debug --watch",
|
||||||
|
"start:prod": "NODE_ENV=production node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"pm2:start": "pm2 start ecosystem.config.js --env production",
|
||||||
|
"pm2:start:dev": "pm2 start ecosystem.config.js --env development",
|
||||||
|
"pm2:stop": "pm2 stop ecosystem.config.js",
|
||||||
|
"pm2:delete": "pm2 delete ecosystem.config.js",
|
||||||
|
"pm2:restart": "pm2 restart ecosystem.config.js",
|
||||||
|
"pm2:logs": "pm2 logs",
|
||||||
|
"pm2:status": "pm2 status"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/sequelize": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "^11.1.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"cos-nodejs-sdk-v5": "^2.14.7",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"fs": "^0.0.1-security",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jwks-rsa": "^3.2.0",
|
||||||
|
"mysql2": "^3.14.0",
|
||||||
|
"nest-winston": "^1.10.2",
|
||||||
|
"openai": "^4.103.0",
|
||||||
|
"qcloud-cos-sts": "^3.1.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"sequelize": "^6.37.6",
|
||||||
|
"sequelize-typescript": "^2.1.6",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@swc/cli": "^0.6.0",
|
||||||
|
"@swc/core": "^1.10.7",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/sequelize": "^4.28.20",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"pm2": "^6.0.5",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app.module.ts
Normal file
22
src/app.module.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AppController } from "./app.controller";
|
||||||
|
import { AppService } from "./app.service";
|
||||||
|
import { DatabaseModule } from "./database/database.module";
|
||||||
|
import { UsersModule } from "./users/users.module";
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: '.env',
|
||||||
|
}),
|
||||||
|
LoggerModule,
|
||||||
|
DatabaseModule,
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
})
|
||||||
|
export class AppModule { }
|
||||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/base.dto.ts
Normal file
10
src/base.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export enum ResponseCode {
|
||||||
|
SUCCESS = 0,
|
||||||
|
ERROR = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseResponseDto<T> {
|
||||||
|
code: ResponseCode;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
9
src/common/decorators/current-user.decorator.ts
Normal file
9
src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AccessTokenPayload } from '../../users/services/apple-auth.service';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: unknown, ctx: ExecutionContext): AccessTokenPayload => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
68
src/common/encryption.service.spec.ts
Normal file
68
src/common/encryption.service.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { EncryptionService } from './encryption.service';
|
||||||
|
|
||||||
|
describe('EncryptionService', () => {
|
||||||
|
let service: EncryptionService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [EncryptionService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<EncryptionService>(EncryptionService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encrypt and decrypt successfully', () => {
|
||||||
|
const originalText = 'Hello, World!';
|
||||||
|
const encrypted = service.encrypt(originalText);
|
||||||
|
const decrypted = service.decrypt(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(originalText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encrypt JSON data successfully', () => {
|
||||||
|
const userData = {
|
||||||
|
id: '1234567890',
|
||||||
|
name: '张三',
|
||||||
|
mail: 'zhangsan@example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(userData);
|
||||||
|
const encrypted = service.encrypt(jsonString);
|
||||||
|
const decrypted = service.decrypt(encrypted);
|
||||||
|
const parsedData = JSON.parse(decrypted);
|
||||||
|
|
||||||
|
expect(parsedData).toEqual(userData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate different ciphertexts for same plaintext', () => {
|
||||||
|
const plaintext = 'test message';
|
||||||
|
const encrypted1 = service.encrypt(plaintext);
|
||||||
|
const encrypted2 = service.encrypt(plaintext);
|
||||||
|
|
||||||
|
// 由于IV随机生成,每次加密结果应该不同
|
||||||
|
expect(encrypted1).not.toBe(encrypted2);
|
||||||
|
|
||||||
|
// 但解密结果应该相同
|
||||||
|
expect(service.decrypt(encrypted1)).toBe(plaintext);
|
||||||
|
expect(service.decrypt(encrypted2)).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid encrypted data', () => {
|
||||||
|
expect(() => {
|
||||||
|
service.decrypt('invalid-base64-data');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Chinese characters correctly', () => {
|
||||||
|
const chineseText = '这是一个中文测试消息 🎉';
|
||||||
|
const encrypted = service.encrypt(chineseText);
|
||||||
|
const decrypted = service.decrypt(encrypted);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(chineseText);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/common/encryption.service.ts
Normal file
105
src/common/encryption.service.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EncryptionService {
|
||||||
|
private readonly logger = new Logger(EncryptionService.name);
|
||||||
|
private readonly algorithm = 'aes-256-cbc';
|
||||||
|
private readonly keyLength = 32; // 256 bits for AES
|
||||||
|
private readonly hmacKeyLength = 32; // 256 bits for HMAC
|
||||||
|
private readonly ivLength = 16; // 128 bits for CBC
|
||||||
|
private readonly hmacLength = 32; // 256 bits for SHA256
|
||||||
|
|
||||||
|
// 从环境变量获取密钥,或使用默认密钥(生产环境必须使用环境变量)
|
||||||
|
private readonly encryptionKey: Buffer;
|
||||||
|
private readonly hmacKey: Buffer;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const encryptionKeyString = process.env.ENCRYPTION_KEY || 'your-default-32-char-aes-secret-key!';
|
||||||
|
const hmacKeyString = process.env.HMAC_KEY || 'your-default-32-char-hmac-secret-key';
|
||||||
|
this.logger.log(`EncryptionService constructor encryptionKeyString: ${encryptionKeyString}`);
|
||||||
|
this.logger.log(`EncryptionService constructor hmacKeyString: ${hmacKeyString}`);
|
||||||
|
|
||||||
|
// 两个独立的密钥
|
||||||
|
this.encryptionKey = Buffer.from(encryptionKeyString.slice(0, 32).padEnd(32, '0'), 'utf8');
|
||||||
|
this.hmacKey = Buffer.from(hmacKeyString.slice(0, 32).padEnd(32, '0'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密数据
|
||||||
|
* @param plaintext 要加密的明文
|
||||||
|
* @returns 加密后的base64字符串,格式:iv.ciphertext.hmac
|
||||||
|
*/
|
||||||
|
encrypt(plaintext: string): string {
|
||||||
|
try {
|
||||||
|
const iv = crypto.randomBytes(this.ivLength);
|
||||||
|
const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv);
|
||||||
|
|
||||||
|
let ciphertext = cipher.update(plaintext, 'utf8');
|
||||||
|
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
|
||||||
|
|
||||||
|
// 计算 HMAC (对 iv + ciphertext 进行HMAC)
|
||||||
|
const dataToHmac = Buffer.concat([iv, ciphertext]);
|
||||||
|
const hmac = crypto.createHmac('sha256', this.hmacKey);
|
||||||
|
hmac.update(dataToHmac);
|
||||||
|
const hmacDigest = hmac.digest();
|
||||||
|
|
||||||
|
// 组合 iv + ciphertext + hmac 并编码为base64
|
||||||
|
const combined = Buffer.concat([iv, ciphertext, hmacDigest]);
|
||||||
|
return combined.toString('base64');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`加密失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密数据
|
||||||
|
* @param encryptedData 加密的base64字符串
|
||||||
|
* @returns 解密后的明文
|
||||||
|
*/
|
||||||
|
decrypt(encryptedData: string): string {
|
||||||
|
try {
|
||||||
|
const combined = Buffer.from(encryptedData, 'base64');
|
||||||
|
|
||||||
|
// 检查数据长度是否足够
|
||||||
|
if (combined.length < this.ivLength + this.hmacLength) {
|
||||||
|
throw new Error('加密数据格式无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 iv, ciphertext, hmac
|
||||||
|
const iv = combined.slice(0, this.ivLength);
|
||||||
|
const hmacDigest = combined.slice(-this.hmacLength);
|
||||||
|
const ciphertext = combined.slice(this.ivLength, -this.hmacLength);
|
||||||
|
|
||||||
|
// 验证 HMAC
|
||||||
|
const dataToHmac = Buffer.concat([iv, ciphertext]);
|
||||||
|
const hmac = crypto.createHmac('sha256', this.hmacKey);
|
||||||
|
hmac.update(dataToHmac);
|
||||||
|
const expectedHmac = hmac.digest();
|
||||||
|
|
||||||
|
if (!crypto.timingSafeEqual(hmacDigest, expectedHmac)) {
|
||||||
|
throw new Error('HMAC验证失败,数据可能被篡改');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密
|
||||||
|
const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv);
|
||||||
|
let plaintext = decipher.update(ciphertext, undefined, 'utf8');
|
||||||
|
plaintext += decipher.final('utf8');
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`解密失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成新的加密密钥(用于初始化)
|
||||||
|
* 返回两个独立的密钥
|
||||||
|
*/
|
||||||
|
generateKey(): { encryptionKey: string; hmacKey: string } {
|
||||||
|
return {
|
||||||
|
encryptionKey: crypto.randomBytes(this.keyLength).toString('base64'),
|
||||||
|
hmacKey: crypto.randomBytes(this.hmacKeyLength).toString('base64')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/common/guards/jwt-auth.guard.ts
Normal file
46
src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AppleAuthService } from '../../users/services/apple-auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(JwtAuthGuard.name);
|
||||||
|
constructor(
|
||||||
|
private readonly appleAuthService: AppleAuthService,
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
// 检查是否标记为公开接口
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
|
||||||
|
this.logger.log(`authHeader: ${authHeader}`);
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new UnauthorizedException('缺少授权头');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = this.appleAuthService.extractTokenFromHeader(authHeader);
|
||||||
|
const payload = this.appleAuthService.verifyAccessToken(token);
|
||||||
|
|
||||||
|
this.logger.log(`鉴权成功: ${JSON.stringify(payload)}, token: ${token}`);
|
||||||
|
// 将用户信息添加到请求对象中
|
||||||
|
request.user = payload;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException('无效的访问令牌');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/common/logger/logger.module.ts
Normal file
9
src/common/logger/logger.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WinstonModule } from 'nest-winston';
|
||||||
|
import { winstonConfig } from './winston.config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [WinstonModule.forRoot(winstonConfig)],
|
||||||
|
exports: [WinstonModule],
|
||||||
|
})
|
||||||
|
export class LoggerModule { }
|
||||||
136
src/common/logger/winston.config.ts
Normal file
136
src/common/logger/winston.config.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import * as winston from 'winston';
|
||||||
|
import * as DailyRotateFile from 'winston-daily-rotate-file';
|
||||||
|
import { WinstonModule } from 'nest-winston';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// 日志目录
|
||||||
|
const LOG_DIR = path.join(process.cwd(), 'logs');
|
||||||
|
|
||||||
|
// 日志格式
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
}),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.printf((info) => {
|
||||||
|
const { timestamp, level, message, context, stack, ...meta } = info;
|
||||||
|
const contextStr = context ? `[${context}] ` : '';
|
||||||
|
const stackStr = stack ? `\n${stack}` : '';
|
||||||
|
|
||||||
|
// 如果有额外的元数据,将其格式化为JSON字符串
|
||||||
|
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : '';
|
||||||
|
|
||||||
|
return `${timestamp} [${level.toUpperCase()}] ${contextStr}${message}${metaStr}${stackStr}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 控制台格式(带颜色)
|
||||||
|
const consoleFormat = winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.timestamp({
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
}),
|
||||||
|
winston.format.printf((info) => {
|
||||||
|
const { timestamp, level, message, context, stack, ...meta } = info;
|
||||||
|
const contextStr = context ? `[${context}] ` : '';
|
||||||
|
const stackStr = stack ? `\n${stack}` : '';
|
||||||
|
|
||||||
|
// 如果有额外的元数据,将其格式化为JSON字符串
|
||||||
|
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : '';
|
||||||
|
|
||||||
|
return `${timestamp} ${level} ${contextStr}${message}${metaStr}${stackStr}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建日志传输器
|
||||||
|
const createTransports = () => {
|
||||||
|
const transports: winston.transport[] = [];
|
||||||
|
|
||||||
|
// 控制台输出
|
||||||
|
transports.push(
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: consoleFormat,
|
||||||
|
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 错误日志文件(按日期滚动,保留7天)
|
||||||
|
transports.push(
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(LOG_DIR, 'error-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
level: 'error',
|
||||||
|
format: logFormat,
|
||||||
|
maxFiles: '7d', // 保留7天
|
||||||
|
maxSize: '20m', // 单个文件最大20MB
|
||||||
|
auditFile: path.join(LOG_DIR, '.audit-error.json'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 应用日志文件(按日期滚动,保留7天)
|
||||||
|
transports.push(
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(LOG_DIR, 'app-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
level: 'info',
|
||||||
|
format: logFormat,
|
||||||
|
maxFiles: '7d', // 保留7天
|
||||||
|
maxSize: '20m', // 单个文件最大20MB
|
||||||
|
auditFile: path.join(LOG_DIR, '.audit-app.json'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 调试日志文件(仅在开发环境)
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
transports.push(
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(LOG_DIR, 'debug-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
level: 'debug',
|
||||||
|
format: logFormat,
|
||||||
|
maxFiles: '7d', // 保留7天
|
||||||
|
maxSize: '20m', // 单个文件最大20MB
|
||||||
|
auditFile: path.join(LOG_DIR, '.audit-debug.json'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transports;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Winston配置
|
||||||
|
export const winstonConfig = {
|
||||||
|
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||||
|
format: logFormat,
|
||||||
|
transports: createTransports(),
|
||||||
|
// 处理未捕获的异常
|
||||||
|
exceptionHandlers: [
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(LOG_DIR, 'exceptions-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
format: logFormat,
|
||||||
|
maxFiles: '7d',
|
||||||
|
maxSize: '20m',
|
||||||
|
auditFile: path.join(LOG_DIR, '.audit-exceptions.json'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
// 处理未处理的Promise拒绝
|
||||||
|
rejectionHandlers: [
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(LOG_DIR, 'rejections-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
format: logFormat,
|
||||||
|
maxFiles: '7d',
|
||||||
|
maxSize: '20m',
|
||||||
|
auditFile: path.join(LOG_DIR, '.audit-rejections.json'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建Winston Logger实例
|
||||||
|
export const createWinstonLogger = () => {
|
||||||
|
return WinstonModule.createLogger(winstonConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出winston实例供直接使用
|
||||||
|
export const logger = winston.createLogger(winstonConfig);
|
||||||
22
src/database/database.module.ts
Normal file
22
src/database/database.module.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SequelizeModule } from '@nestjs/sequelize';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
SequelizeModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
dialect: 'mysql',
|
||||||
|
host: configService.get('DB_HOST'),
|
||||||
|
port: configService.get<number>('DB_PORT'),
|
||||||
|
username: configService.get('DB_USERNAME'),
|
||||||
|
password: configService.get('DB_PASSWORD'),
|
||||||
|
database: configService.get('DB_DATABASE'),
|
||||||
|
autoLoadModels: true,
|
||||||
|
synchronize: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DatabaseModule { }
|
||||||
62
src/main.ts
Normal file
62
src/main.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import * as bodyParser from 'body-parser';
|
||||||
|
import { createWinstonLogger } from './common/logger/winston.config';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: createWinstonLogger(),
|
||||||
|
});
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(bodyParser.json({ limit: '10mb' }));
|
||||||
|
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
// 使用全局中间件记录请求日志
|
||||||
|
const logger = new Logger('HTTP');
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const logMessage = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
|
||||||
|
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
logger.error(`${logMessage} - Body: ${JSON.stringify(req.body)}`);
|
||||||
|
} else {
|
||||||
|
logger.log(`${logMessage} - Body: ${JSON.stringify(req.body)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// swigger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Love Tips API')
|
||||||
|
.setDescription('Love Tips API description')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.build();
|
||||||
|
const documentFactory = () => SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, documentFactory);
|
||||||
|
|
||||||
|
|
||||||
|
const port = process.env.PORT ?? 3000;
|
||||||
|
await app.listen(port);
|
||||||
|
|
||||||
|
const appLogger = new Logger('Bootstrap');
|
||||||
|
appLogger.log(`Server is running on port ${port}`);
|
||||||
|
appLogger.log(`Swagger documentation available at http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
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
45
start-dev.sh
Executable file
45
start-dev.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 定义颜色
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 打印带颜色的消息
|
||||||
|
print_message() {
|
||||||
|
echo -e "${GREEN}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查PM2是否安装
|
||||||
|
if ! command -v pm2 &> /dev/null; then
|
||||||
|
print_warning "PM2未安装,正在全局安装PM2..."
|
||||||
|
npm install -g pm2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建日志目录
|
||||||
|
print_message "创建日志目录..."
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
print_message "安装依赖..."
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# 构建项目
|
||||||
|
print_message "构建项目..."
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# 使用PM2启动项目(开发环境)
|
||||||
|
print_message "使用PM2启动项目(开发环境)..."
|
||||||
|
yarn pm2:start:dev
|
||||||
|
|
||||||
|
# 显示PM2状态
|
||||||
|
print_message "PM2状态:"
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
print_message "应用已在开发环境中启动!"
|
||||||
|
print_message "可以使用以下命令查看日志: yarn pm2:logs"
|
||||||
|
print_message "日志文件位置: ./logs/output.log 和 ./logs/error.log"
|
||||||
45
start.sh
Executable file
45
start.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 定义颜色
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 打印带颜色的消息
|
||||||
|
print_message() {
|
||||||
|
echo -e "${GREEN}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查PM2是否安装
|
||||||
|
if ! command -v pm2 &> /dev/null; then
|
||||||
|
print_warning "PM2未安装,正在全局安装PM2..."
|
||||||
|
npm install -g pm2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建日志目录
|
||||||
|
print_message "创建日志目录..."
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
print_message "安装依赖..."
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# 构建项目
|
||||||
|
print_message "构建项目..."
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# 使用PM2启动项目
|
||||||
|
print_message "使用PM2启动项目..."
|
||||||
|
yarn pm2:start
|
||||||
|
|
||||||
|
# 显示PM2状态
|
||||||
|
print_message "PM2状态:"
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
print_message "应用已成功部署并启动!"
|
||||||
|
print_message "可以使用以下命令查看日志: yarn pm2:logs"
|
||||||
|
print_message "日志文件位置: ./logs/output.log 和 ./logs/error.log"
|
||||||
25
test/app.e2e-spec.ts
Normal file
25
test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user