feat: 初始化项目

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

View 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)处理

View 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: 数据库名称

View 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) - 生产环境启动脚本

View 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配置文件用于生产环境部署

View 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/` - 包含数据库模型

View 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
View 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
View 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
View 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 密钥用于用户关联
- 恢复购买不会重复收费,只是将现有购买记录与当前账号关联

View 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
View 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
View 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
View 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

View 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)

View 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
View 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`: 服务器内部错误

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

106
package.json Normal file
View 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"
}
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,10 @@
export enum ResponseCode {
SUCCESS = 0,
ERROR = 1,
}
export interface BaseResponseDto<T> {
code: ResponseCode;
message: string;
data: T;
}

View 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;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View 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);
});
});

View 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')
};
}
}

View 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('无效的访问令牌');
}
}
}

View 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 { }

View 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);

View 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
View 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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

45
start-dev.sh Executable file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View 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
}
}

7444
yarn.lock Normal file

File diff suppressed because it is too large Load Diff