feat: initial commit with accounts manager project structure
- TypeScript项目基础架构 - API路由和账户管理服务 - 数据库模式和迁移 - 基础配置文件和文档 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(git init:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgresql://username:password@localhost:5432/accounts_db
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3006
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Lock Timeout (in minutes)
|
||||||
|
LOCK_TIMEOUT_MINUTES=30
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids/
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Accounts Manager
|
||||||
|
|
||||||
|
轻量化账号管理平台
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **运行时**: Node.js (v18+)
|
||||||
|
- **语言**: TypeScript
|
||||||
|
- **Web框架**: Fastify
|
||||||
|
- **数据库**: PostgreSQL (v14+)
|
||||||
|
- **ORM**: Drizzle ORM
|
||||||
|
- **数据校验**: Zod
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
复制环境变量模板并配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env` 文件配置数据库连接:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://username:password@localhost:5432/accounts_db
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
LOCK_TIMEOUT_MINUTES=5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 数据库设置
|
||||||
|
|
||||||
|
运行数据库迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 启动服务
|
||||||
|
|
||||||
|
开发模式:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
生产模式:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
### 脚本专用 API
|
||||||
|
|
||||||
|
**Base URL**: `/s/v1/{ownerId}`
|
||||||
|
|
||||||
|
#### 1. 获取账号
|
||||||
|
- **GET** `/s/v1/{ownerId}/acquire?platform={platform}&count={count}`
|
||||||
|
- 获取可用账号并锁定
|
||||||
|
|
||||||
|
#### 2. 更新账号状态
|
||||||
|
- **GET** `/s/v1/{ownerId}/update/{accountId}/{newStatus}?notes={notes}`
|
||||||
|
- 更新指定账号状态
|
||||||
|
|
||||||
|
#### 3. 上传账号
|
||||||
|
- **POST** `/s/v1/{ownerId}/upload`
|
||||||
|
- 批量创建或更新账号
|
||||||
|
|
||||||
|
### 前端管理 API
|
||||||
|
|
||||||
|
**Base URL**: `/web/v1`
|
||||||
|
|
||||||
|
#### 1. 获取账号列表
|
||||||
|
- **POST** `/web/v1/accounts/list`
|
||||||
|
- 支持复杂查询、分页、排序
|
||||||
|
|
||||||
|
#### 2. 批量删除账号
|
||||||
|
- **POST** `/web/v1/accounts/delete-batch`
|
||||||
|
|
||||||
|
#### 3. 批量更新账号
|
||||||
|
- **POST** `/web/v1/accounts/update-batch`
|
||||||
|
|
||||||
|
#### 4. 统计概览
|
||||||
|
- **GET** `/web/v1/stats/overview`
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
- **GET** `/health`
|
||||||
|
|
||||||
|
## 数据库命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成迁移文件
|
||||||
|
npm run db:generate
|
||||||
|
|
||||||
|
# 运行迁移
|
||||||
|
npm run db:migrate
|
||||||
|
|
||||||
|
# 打开数据库管理界面
|
||||||
|
npm run db:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/v1/
|
||||||
|
│ ├── script/ # 脚本API
|
||||||
|
│ └── web/ # 前端API
|
||||||
|
├── core/ # 业务逻辑
|
||||||
|
├── db/ # 数据库配置
|
||||||
|
├── jobs/ # 后台任务
|
||||||
|
├── lib/ # 工具函数
|
||||||
|
├── types/ # 类型定义
|
||||||
|
├── config.ts # 配置
|
||||||
|
└── index.ts # 入口文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- **单表设计**: 简化数据模型
|
||||||
|
- **OwnerID认证**: 基于URL的身份验证
|
||||||
|
- **自动锁定管理**: 防止并发冲突
|
||||||
|
- **后台清理任务**: 自动释放超时锁定
|
||||||
|
- **统一响应格式**: 标准化API响应
|
||||||
|
- **类型安全**: 完整的TypeScript支持
|
||||||
867
docs/API文档.md
Normal file
867
docs/API文档.md
Normal file
@@ -0,0 +1,867 @@
|
|||||||
|
# 账户管理系统 API 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档描述了账户管理系统的API接口,用于Web前端对接。系统提供了账户的获取、上传、更新、删除和统计等功能。
|
||||||
|
|
||||||
|
### 基础信息
|
||||||
|
|
||||||
|
- **基础URL**: `{API_BASE_URL}`
|
||||||
|
- **数据格式**: JSON
|
||||||
|
- **字符编码**: UTF-8
|
||||||
|
- **认证方式**: 需要在请求头中包含认证信息
|
||||||
|
|
||||||
|
### 通用响应格式
|
||||||
|
|
||||||
|
所有API接口都使用统一的响应格式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
code: BusinessCode; // 业务状态码
|
||||||
|
message: string; // 响应消息
|
||||||
|
data: T | null; // 响应数据
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 业务状态码 (BusinessCode)
|
||||||
|
|
||||||
|
| 状态码 | 名称 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 0 | Success | 请求成功 |
|
||||||
|
| 1001 | NoResource | 资源不存在 |
|
||||||
|
| 2001 | InvalidParams | 参数无效 |
|
||||||
|
| 3001 | ResourceConflict | 资源冲突 |
|
||||||
|
| 4001 | PermissionDenied | 权限不足 |
|
||||||
|
| 5001 | BusinessError | 业务错误 |
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### 账户 (Account)
|
||||||
|
|
||||||
|
账户是系统的核心数据模型,包含以下字段:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Account {
|
||||||
|
id: number; // 账户ID,自增主键
|
||||||
|
ownerId: string; // 所有者ID
|
||||||
|
platform: string; // 平台名称
|
||||||
|
customId: string; // 自定义ID
|
||||||
|
data: string; // 账户数据,JSON格式字符串
|
||||||
|
status: string; // 账户状态
|
||||||
|
notes?: string; // 备注,可选
|
||||||
|
lockedAt?: Date; // 锁定时间,可选
|
||||||
|
createdAt: Date; // 创建时间
|
||||||
|
updatedAt: Date; // 更新时间
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 账户状态
|
||||||
|
|
||||||
|
- `available`: 可用
|
||||||
|
- `locked`: 已锁定
|
||||||
|
- 其他自定义状态
|
||||||
|
|
||||||
|
### 分页结果 (PaginationResult)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PaginationResult {
|
||||||
|
page: number; // 当前页码
|
||||||
|
pageSize: number; // 每页条数
|
||||||
|
total: number; // 总记录数
|
||||||
|
totalPages: number; // 总页数
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 统计概览 (StatsOverview)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StatsOverview {
|
||||||
|
totalAccounts: number; // 总账户数
|
||||||
|
platformSummary: Record<string, number>; // 平台统计
|
||||||
|
ownerSummary: Record<string, number>; // 所有者统计
|
||||||
|
statusSummary: Record<string, number>; // 状态统计
|
||||||
|
detailedBreakdown: { // 详细统计
|
||||||
|
platform: string;
|
||||||
|
ownerId: string;
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 1. 账户获取接口
|
||||||
|
|
||||||
|
用于脚本端获取可用账户。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /s/v1/{ownerId}/acquire?platform={platform}&count={count}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数
|
||||||
|
|
||||||
|
| 参数名 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|------|
|
||||||
|
| ownerId | 路径 | string | 是 | 所有者ID |
|
||||||
|
| platform | 查询 | string | 是 | 平台名称 |
|
||||||
|
| count | 查询 | number | 否 | 获取数量,默认1,最大100 |
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Successfully acquired 1 account.",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"customId": "custom123",
|
||||||
|
"data": "{\"username\":\"test\",\"password\":\"123456\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
无可用账户响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1001,
|
||||||
|
"message": "No available accounts found for platform 'example'.",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 使用fetch获取账户
|
||||||
|
async function acquireAccounts(ownerId, platform, count = 1) {
|
||||||
|
const response = await fetch(`/s/v1/${ownerId}/acquire?platform=${platform}&count=${count}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_TOKEN'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
const result = await acquireAccounts('owner123', 'example', 2);
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log('获取成功:', result.data);
|
||||||
|
} else {
|
||||||
|
console.error('获取失败:', result.message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 账户状态更新接口
|
||||||
|
|
||||||
|
用于脚本端更新账户状态。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /s/v1/{ownerId}/update/{accountId}/{newStatus}?notes={notes}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数
|
||||||
|
|
||||||
|
| 参数名 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|------|
|
||||||
|
| ownerId | 路径 | string | 是 | 所有者ID |
|
||||||
|
| accountId | 路径 | number | 是 | 账户ID |
|
||||||
|
| newStatus | 路径 | string | 是 | 新状态 |
|
||||||
|
| notes | 查询 | string | 否 | 备注 |
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Account status updated to 'used'.",
|
||||||
|
"data": {
|
||||||
|
"updatedId": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
权限不足响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 4001,
|
||||||
|
"message": "Account not found or permission denied.",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 使用fetch更新账户状态
|
||||||
|
async function updateAccountStatus(ownerId, accountId, newStatus, notes) {
|
||||||
|
const url = `/s/v1/${ownerId}/update/${accountId}/${newStatus}`;
|
||||||
|
if (notes) {
|
||||||
|
url += `?notes=${encodeURIComponent(notes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_TOKEN'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
const result = await updateAccountStatus('owner123', 1, 'used', '测试使用');
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log('更新成功:', result.data);
|
||||||
|
} else {
|
||||||
|
console.error('更新失败:', result.message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 账户上传接口
|
||||||
|
|
||||||
|
用于脚本端批量上传账户。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /s/v1/{ownerId}/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"platform": "string",
|
||||||
|
"customId": "string",
|
||||||
|
"data": "string",
|
||||||
|
"status": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数
|
||||||
|
|
||||||
|
| 参数名 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|------|
|
||||||
|
| ownerId | 路径 | string | 是 | 所有者ID |
|
||||||
|
| body | 请求体 | ScriptUploadItem[] | 是 | 账户数据数组 |
|
||||||
|
|
||||||
|
#### ScriptUploadItem 类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ScriptUploadItem {
|
||||||
|
platform: string; // 平台名称
|
||||||
|
customId: string; // 自定义ID
|
||||||
|
data: string; // 账户数据,JSON格式字符串
|
||||||
|
status?: string; // 账户状态,可选,默认为"available"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Successfully processed 2 accounts (1 created, 1 updated).",
|
||||||
|
"data": {
|
||||||
|
"processedCount": 2,
|
||||||
|
"createdCount": 1,
|
||||||
|
"updatedCount": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 使用fetch上传账户
|
||||||
|
async function uploadAccounts(ownerId, accounts) {
|
||||||
|
const response = await fetch(`/s/v1/${ownerId}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_TOKEN'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(accounts)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
const accounts = [
|
||||||
|
{
|
||||||
|
platform: 'example',
|
||||||
|
customId: 'user123',
|
||||||
|
data: JSON.stringify({ username: 'user123', password: 'pass123' }),
|
||||||
|
status: 'available'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'example',
|
||||||
|
customId: 'user456',
|
||||||
|
data: JSON.stringify({ username: 'user456', password: 'pass456' })
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await uploadAccounts('owner123', accounts);
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log('上传成功:', result.data);
|
||||||
|
} else {
|
||||||
|
console.error('上传失败:', result.message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 账户列表接口
|
||||||
|
|
||||||
|
用于Web端获取账户列表,支持筛选、分页和排序。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /web/v1/accounts/list
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filters": {
|
||||||
|
"platform": "string",
|
||||||
|
"status": ["string"],
|
||||||
|
"ownerId": "string",
|
||||||
|
"search": "string"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 10
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"field": "id",
|
||||||
|
"order": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数
|
||||||
|
|
||||||
|
| 参数名 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|------|
|
||||||
|
| filters | 请求体 | ListAccountsFilters | 否 | 筛选条件 |
|
||||||
|
| pagination | 请求体 | object | 是 | 分页参数 |
|
||||||
|
| sort | 请求体 | object | 是 | 排序参数 |
|
||||||
|
|
||||||
|
#### ListAccountsFilters 类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ListAccountsFilters {
|
||||||
|
platform?: string; // 平台名称筛选
|
||||||
|
status?: string[]; // 状态筛选,多选
|
||||||
|
ownerId?: string; // 所有者ID筛选
|
||||||
|
search?: string; // 搜索关键词,搜索customId和notes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 排序字段
|
||||||
|
|
||||||
|
支持以下字段排序:
|
||||||
|
- `id`: 账户ID
|
||||||
|
- `ownerId`: 所有者ID
|
||||||
|
- `platform`: 平台名称
|
||||||
|
- `customId`: 自定义ID
|
||||||
|
- `data`: 账户数据
|
||||||
|
- `status`: 账户状态
|
||||||
|
- `notes`: 备注
|
||||||
|
- `lockedAt`: 锁定时间
|
||||||
|
- `createdAt`: 创建时间
|
||||||
|
- `updatedAt`: 更新时间
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"ownerId": "owner123",
|
||||||
|
"platform": "example",
|
||||||
|
"customId": "user123",
|
||||||
|
"data": "{\"username\":\"user123\",\"password\":\"pass123\"}",
|
||||||
|
"status": "available",
|
||||||
|
"notes": "测试账户",
|
||||||
|
"lockedAt": null,
|
||||||
|
"createdAt": "2023-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2023-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 10,
|
||||||
|
"total": 1,
|
||||||
|
"totalPages": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 使用fetch获取账户列表
|
||||||
|
async function getAccountsList(filters, pagination, sort) {
|
||||||
|
const response = await fetch('/web/v1/accounts/list', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_TOKEN'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
filters,
|
||||||
|
pagination,
|
||||||
|
sort
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
const filters = {
|
||||||
|
platform: 'example',
|
||||||
|
status: ['available'],
|
||||||
|
search: 'test'
|
||||||
|
};
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const sort = {
|
||||||
|
field: 'createdAt',
|
||||||
|
order: 'desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getAccountsList(filters, pagination, sort);
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log('账户列表:', result.data.list);
|
||||||
|
console.log('分页信息:', result.data.pagination);
|
||||||
|
} else {
|
||||||
|
console.error('获取失败:', result.message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 批量删除账户接口
|
||||||
|
|
||||||
|
用于Web端批量删除账户。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /web/v1/accounts/delete-batch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ids": [1, 2, 3]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数
|
||||||
|
|
||||||
|
| 参数名 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|------|
|
||||||
|
| ids | 请求体 | number[] | 是 | 要删除的账户ID数组 |
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Successfully deleted 3 accounts.",
|
||||||
|
"data": {
|
||||||
|
"deletedCount": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 使用fetch批量删除账户
|
||||||
|
async function batchDeleteAccounts(ids) {
|
||||||
|
const response = await fetch('/web/v1/accounts/delete-batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_TOKEN'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
const result = await batchDeleteAccounts([1, 2, 3]);
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log('删除成功:', result.data);
|
||||||
|
} else {
|
||||||
|
console.error('删除失败:', result.message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 批量更新账户接口
|
||||||
|
|
||||||
|
用于Web端批量更新账户。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /web/v1/accounts/update-batch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ids": [1, 2, 3],
|
||||||
|
"payload": {
|
||||||
|
"status": "available",
|
||||||
|
"ownerId": "newOwner",
|
||||||
|
"notes": "批量更新备注"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 参数
|
||||||
|
|
||||||
|
| 参数名 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|------|
|
||||||
|
| ids | 请求体 | number[] | 是 | 要更新的账户ID数组 |
|
||||||
|
| payload | 请求体 | object | 是 | 更新数据 |
|
||||||
|
|
||||||
|
#### payload 类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UpdatePayload {
|
||||||
|
status?: string; // 新状态
|
||||||
|
ownerId?: string; // 新所有者ID
|
||||||
|
notes?: string; // 新备注
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Successfully updated 3 accounts.",
|
||||||
|
"data": {
|
||||||
|
"updatedCount": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 使用fetch批量更新账户
|
||||||
|
async function batchUpdateAccounts(ids, payload) {
|
||||||
|
const response = await fetch('/web/v1/accounts/update-batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_TOKEN'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids, payload })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
const ids = [1, 2, 3];
|
||||||
|
const payload = {
|
||||||
|
status: 'available',
|
||||||
|
notes: '批量更新为可用状态'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await batchUpdateAccounts(ids, payload);
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log('更新成功:', result.data);
|
||||||
|
} else {
|
||||||
|
console.error('更新失败:', result.message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 统计概览接口
|
||||||
|
|
||||||
|
用于Web端获取账户统计信息。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /web/v1/stats/overview
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"totalAccounts": 100,
|
||||||
|
"platformSummary": {
|
||||||
|
"example": 50,
|
||||||
|
"test": 30,
|
||||||
|
"demo": 20
|
||||||
|
},
|
||||||
|
"ownerSummary": {
|
||||||
|
"owner1": 40,
|
||||||
|
"owner2": 35,
|
||||||
|
"owner3": 25
|
||||||
|
},
|
||||||
|
"statusSummary": {
|
||||||
|
"available": 70,
|
||||||
|
"locked": 20,
|
||||||
|
"used": 10
|
||||||
|
},
|
||||||
|
"detailedBreakdown": [
|
||||||
|
{
|
||||||
|
"platform": "example",
|
||||||
|
"ownerId": "owner1",
|
||||||
|
"status": "available",
|
||||||
|
"count": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "example",
|
||||||
|
"ownerId": "owner1",
|
||||||
|
"status": "locked",
|
||||||
|
"count": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 使用fetch获取统计概览
|
||||||
|
async function getStatsOverview() {
|
||||||
|
const response = await fetch('/web/v1/stats/overview', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_TOKEN'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
const result = await getStatsOverview();
|
||||||
|
if (result.code === 0) {
|
||||||
|
console.log('总账户数:', result.data.totalAccounts);
|
||||||
|
console.log('平台统计:', result.data.platformSummary);
|
||||||
|
console.log('所有者统计:', result.data.ownerSummary);
|
||||||
|
console.log('状态统计:', result.data.statusSummary);
|
||||||
|
console.log('详细统计:', result.data.detailedBreakdown);
|
||||||
|
} else {
|
||||||
|
console.error('获取失败:', result.message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
所有API接口在发生错误时都会返回统一的错误响应格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "错误码",
|
||||||
|
"message": "错误信息",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见错误码
|
||||||
|
|
||||||
|
| 错误码 | 说明 | 处理建议 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| 1001 | 资源不存在 | 检查请求的资源是否存在 |
|
||||||
|
| 2001 | 参数无效 | 检查请求参数是否符合要求 |
|
||||||
|
| 3001 | 资源冲突 | 检查是否有重复数据 |
|
||||||
|
| 4001 | 权限不足 | 检查认证信息是否正确 |
|
||||||
|
| 5001 | 业务错误 | 检查业务逻辑是否正确 |
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **认证**:所有API请求都需要在请求头中包含认证信息,例如:
|
||||||
|
```http
|
||||||
|
Authorization: Bearer YOUR_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **错误处理**:前端应该根据返回的`code`值进行相应的错误处理,而不是依赖`message`字段。
|
||||||
|
|
||||||
|
3. **分页**:在获取列表数据时,合理设置分页大小,建议不超过100条。
|
||||||
|
|
||||||
|
4. **批量操作**:批量操作时,建议限制单次操作的数量,避免服务器压力过大。
|
||||||
|
|
||||||
|
5. **数据格式**:账户数据字段`data`存储的是JSON格式字符串,前端需要进行相应的序列化和反序列化操作。
|
||||||
|
|
||||||
|
## 类型定义
|
||||||
|
|
||||||
|
以下是完整的TypeScript类型定义,可以直接在前端项目中使用:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 业务状态码
|
||||||
|
enum BusinessCode {
|
||||||
|
Success = 0,
|
||||||
|
NoResource = 1001,
|
||||||
|
InvalidParams = 2001,
|
||||||
|
ResourceConflict = 3001,
|
||||||
|
PermissionDenied = 4001,
|
||||||
|
BusinessError = 5001,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用响应格式
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
code: BusinessCode;
|
||||||
|
message: string;
|
||||||
|
data: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账户类型
|
||||||
|
interface Account {
|
||||||
|
id: number;
|
||||||
|
ownerId: string;
|
||||||
|
platform: string;
|
||||||
|
customId: string;
|
||||||
|
data: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
lockedAt?: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页结果
|
||||||
|
interface PaginationResult {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脚本获取响应
|
||||||
|
type ScriptAcquireResponse = Pick<Account, 'id' | 'customId' | 'data'>[];
|
||||||
|
|
||||||
|
// 脚本上传项
|
||||||
|
interface ScriptUploadItem {
|
||||||
|
platform: string;
|
||||||
|
customId: string;
|
||||||
|
data: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账户列表筛选条件
|
||||||
|
interface ListAccountsFilters {
|
||||||
|
platform?: string;
|
||||||
|
status?: string[];
|
||||||
|
ownerId?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账户列表请求体
|
||||||
|
interface ListAccountsBody {
|
||||||
|
filters: ListAccountsFilters;
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
sort: {
|
||||||
|
field: keyof Account;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账户列表响应
|
||||||
|
interface ListAccountsResponse {
|
||||||
|
list: Account[];
|
||||||
|
pagination: PaginationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除请求体
|
||||||
|
interface BatchDeleteBody {
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新请求体
|
||||||
|
interface BatchUpdateBody {
|
||||||
|
ids: number[];
|
||||||
|
payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes'>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计概览
|
||||||
|
interface StatsOverview {
|
||||||
|
totalAccounts: number;
|
||||||
|
platformSummary: Record<string, number>;
|
||||||
|
ownerSummary: Record<string, number>;
|
||||||
|
statusSummary: Record<string, number>;
|
||||||
|
detailedBreakdown: {
|
||||||
|
platform: string;
|
||||||
|
ownerId: string;
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1.0.0 | 2023-01-01 | 初始版本 |
|
||||||
328
docs/设计.md
Normal file
328
docs/设计.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
### **轻量化账号管理平台 - 工程设计蓝图**
|
||||||
|
|
||||||
|
#### 1. 核心设计哲学
|
||||||
|
|
||||||
|
* **单表驱动**: 所有核心数据聚合在 `accounts` 表,简化数据库模型。
|
||||||
|
* **OwnerID 即身份**: `ownerId` 同时作为拥有者标识和API认证凭证。
|
||||||
|
* **配置硬编码**: 关键状态(如 `available`, `locked`)硬编码在业务逻辑中,降低系统复杂性。
|
||||||
|
* **职责分离的API**: 为自动化脚本和管理后台提供两套独立的、高度优化的API。
|
||||||
|
* **规范化通信**: 所有API遵循统一的响应结构,业务结果通过 `code` 字段传递。
|
||||||
|
* **高性能与健壮性**: 依赖PostgreSQL原生特性(事务、行锁、索引)和专用的后台任务确保系统稳定。
|
||||||
|
|
||||||
|
#### 2. 技术栈
|
||||||
|
|
||||||
|
* **运行时**: Node.js (v18+)
|
||||||
|
* **语言**: TypeScript
|
||||||
|
* **Web 框架**: **Fastify**
|
||||||
|
* **数据库**: PostgreSQL (v14+)
|
||||||
|
* **ORM/查询构建器**: **Drizzle ORM**
|
||||||
|
* **数据校验**: **Zod**
|
||||||
|
|
||||||
|
#### 3. 项目结构
|
||||||
|
|
||||||
|
采用模块化、可扩展的目录结构,清晰分离各项职责。
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── drizzle/ # Drizzle ORM 迁移文件
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # API 路由定义
|
||||||
|
│ │ └── v1/
|
||||||
|
│ │ ├── web/ # 前端管理后台 API
|
||||||
|
│ │ │ ├── accounts.ts # 账号增删改查
|
||||||
|
│ │ │ └── stats.ts # 统计数据
|
||||||
|
│ │ └── script/ # 自动化脚本 API
|
||||||
|
│ │ └── actions.ts # 获取、更新、上传
|
||||||
|
│ ├── core/ # 核心业务逻辑 (Services)
|
||||||
|
│ │ └── AccountService.ts # 封装所有与账号相关的业务操作
|
||||||
|
│ ├── db/ # 数据库配置与 Schema
|
||||||
|
│ │ ├── index.ts # Drizzle 客户端实例
|
||||||
|
│ │ └── schema.ts # 数据库表定义
|
||||||
|
│ ├── jobs/ # 后台定时任务
|
||||||
|
│ │ └── staleLockCleanup.ts # 清理超时锁定的任务
|
||||||
|
│ ├── lib/ # 通用库、工具函数
|
||||||
|
│ │ └── apiResponse.ts # 统一响应格式的辅助函数
|
||||||
|
│ ├── types/ # 全局类型定义
|
||||||
|
│ │ ├── api.ts # API 请求/响应体类型
|
||||||
|
│ │ └── index.ts # 核心业务模型类型
|
||||||
|
│ ├── index.ts # 应用入口,启动 Fastify 服务器
|
||||||
|
│ └── config.ts # 应用配置 (数据库连接、端口等)
|
||||||
|
├── .env # 环境变量
|
||||||
|
├── .env.example
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 数据库设计 (单表模型)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// file: src/db/schema.ts
|
||||||
|
import { pgTable, serial, varchar, timestamp, uniqueIndex, text, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const accounts = pgTable('accounts', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
ownerId: varchar('owner_id', { length: 128 }).notNull(),
|
||||||
|
platform: varchar('platform', { length: 100 }).notNull(),
|
||||||
|
customId: varchar('custom_id', { length: 255 }).notNull(),
|
||||||
|
data: text('data').notNull(),
|
||||||
|
status: varchar('status', { length: 50 }).notNull(),
|
||||||
|
notes: text('notes'),
|
||||||
|
lockedAt: timestamp('locked_at'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
platformCustomIdIdx: uniqueIndex('platform_custom_id_idx').on(table.platform, table.customId),
|
||||||
|
ownerIdStatusIdx: index('owner_id_status_idx').on(table.ownerId, table.status),
|
||||||
|
platformOwnerIdx: index('platform_owner_idx').on(table.platform, table.ownerId),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. 核心类型与接口定义
|
||||||
|
|
||||||
|
##### 5.1. 业务模型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// file: src/types/index.ts
|
||||||
|
import { accounts } from '../db/schema';
|
||||||
|
import { InferSelectModel } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// 从数据库 Schema 自动推断出的 Account 类型,确保与数据库一致
|
||||||
|
export type Account = InferSelectModel<typeof accounts>;
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 5.2. API 通信规范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// file: src/types/api.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务状态码枚举,避免使用魔法数字
|
||||||
|
*/
|
||||||
|
export enum BusinessCode {
|
||||||
|
Success = 0,
|
||||||
|
NoResource = 1001,
|
||||||
|
InvalidParams = 2001,
|
||||||
|
ResourceConflict = 3001,
|
||||||
|
PermissionDenied = 4001, // 业务层权限拒绝
|
||||||
|
BusinessError = 5001,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的 API 响应体结构
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: BusinessCode;
|
||||||
|
message: string;
|
||||||
|
data: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询结果
|
||||||
|
*/
|
||||||
|
export interface PaginationResult {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 5.3. API 请求/响应体类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// file: src/types/api.ts
|
||||||
|
|
||||||
|
// --- 脚本 API ---
|
||||||
|
|
||||||
|
export type ScriptAcquireResponse = Pick<Account, 'id' | 'customId' | 'data'>[];
|
||||||
|
|
||||||
|
export interface ScriptUploadItem {
|
||||||
|
platform: string;
|
||||||
|
customId: string;
|
||||||
|
data: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 前端管理 API ---
|
||||||
|
|
||||||
|
export interface ListAccountsFilters {
|
||||||
|
platform?: string;
|
||||||
|
status?: string[];
|
||||||
|
ownerId?: string;
|
||||||
|
search?: string; // 模糊搜索 customId 或 notes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAccountsBody {
|
||||||
|
filters: ListAccountsFilters;
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
sort: {
|
||||||
|
field: keyof Account;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAccountsResponse {
|
||||||
|
list: Account[];
|
||||||
|
pagination: PaginationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchDeleteBody {
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchUpdateBody {
|
||||||
|
ids: number[];
|
||||||
|
payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes'>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsOverview {
|
||||||
|
totalAccounts: number;
|
||||||
|
platformSummary: Record<string, number>;
|
||||||
|
ownerSummary: Record<string, number>;
|
||||||
|
statusSummary: Record<string, number>;
|
||||||
|
detailedBreakdown: {
|
||||||
|
platform: string;
|
||||||
|
ownerId: string;
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **6. API 设计**
|
||||||
|
|
||||||
|
#### A. 脚本专用 API (Base URL: `/s/v1/{ownerId}`)
|
||||||
|
|
||||||
|
**认证**: 所有请求通过 URL 中的 `{ownerId}` 进行身份验证和授权。
|
||||||
|
|
||||||
|
1. **获取账号**
|
||||||
|
* **Endpoint**: `GET /acquire`
|
||||||
|
* **描述**: 获取一个或多个可用的账号,并将其状态锁定。
|
||||||
|
* **Query Params**:
|
||||||
|
* `platform` (string, **必须**): 平台标识。
|
||||||
|
* `count` (number, 可选, 默认 1): 获取数量。
|
||||||
|
* **成功响应 (200 OK)**:
|
||||||
|
```json
|
||||||
|
// Body: ApiResponse<ScriptAcquireResponse>
|
||||||
|
{
|
||||||
|
"code": 0, // BusinessCode.Success
|
||||||
|
"message": "Successfully acquired 2 accounts.",
|
||||||
|
"data": [
|
||||||
|
{ "id": 101, "customId": "user1@gmail.com", "data": "cookie_string_1" },
|
||||||
|
{ "id": 102, "customId": "user2@gmail.com", "data": "cookie_string_2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **业务失败响应 (200 OK)**:
|
||||||
|
```json
|
||||||
|
// Body: ApiResponse<null>
|
||||||
|
{
|
||||||
|
"code": 1001, // BusinessCode.NoResource
|
||||||
|
"message": "No available accounts found for platform 'google'.",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **更新账号状态**
|
||||||
|
* **Endpoint**: `GET /update/{accountId}/{newStatus}`
|
||||||
|
* **描述**: 快速更新指定账号的状态。
|
||||||
|
* **Query Params**:
|
||||||
|
* `notes` (string, 可选): 添加备注信息。
|
||||||
|
* **成功响应 (200 OK)**:
|
||||||
|
```json
|
||||||
|
// Body: ApiResponse<{ updatedId: number }>
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Account status updated to 'banned'.",
|
||||||
|
"data": { "updatedId": 12345 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **传输错误响应 (403 Forbidden)**: 当 URL 中的 `ownerId` 与 `accountId` 对应的账号所有者不匹配时返回。
|
||||||
|
|
||||||
|
3. **上传/更新账号**
|
||||||
|
* **Endpoint**: `POST /upload`
|
||||||
|
* **描述**: 批量创建或更新账号。
|
||||||
|
* **Request Body**: `ScriptUploadItem[]`
|
||||||
|
* **成功响应 (200 OK)**:
|
||||||
|
```json
|
||||||
|
// Body: ApiResponse<{ processedCount: number, createdCount: number, updatedCount: number }>
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Successfully processed 10 accounts (5 created, 5 updated).",
|
||||||
|
"data": { "processedCount": 10, "createdCount": 5, "updatedCount": 5 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. 前端管理 API (Base URL: `/web/v1`)
|
||||||
|
|
||||||
|
1. **获取账号列表 (复杂查询)**
|
||||||
|
* **Endpoint**: `POST /accounts/list`
|
||||||
|
* **描述**: 支持多维度筛选、分页和排序的账号查询。
|
||||||
|
* **Request Body**: `ListAccountsBody`
|
||||||
|
* **成功响应 (200 OK)**:
|
||||||
|
```json
|
||||||
|
// Body: ApiResponse<ListAccountsResponse>
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"list": [ /* ... Account 对象数组 ... */ ],
|
||||||
|
"pagination": { "page": 1, "pageSize": 50, "total": 123, "totalPages": 3 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **批量删除账号**
|
||||||
|
* **Endpoint**: `POST /accounts/delete-batch`
|
||||||
|
* **描述**: 根据 ID 列表批量删除账号。
|
||||||
|
* **Request Body**: `BatchDeleteBody`
|
||||||
|
* **成功响应 (200 OK)**:
|
||||||
|
```json
|
||||||
|
// Body: ApiResponse<{ deletedCount: number }>
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Successfully deleted 5 accounts.",
|
||||||
|
"data": { "deletedCount": 5 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **批量更新账号**
|
||||||
|
* **Endpoint**: `POST /accounts/update-batch`
|
||||||
|
* **描述**: 批量修改账号的状态、所有者或备注。
|
||||||
|
* **Request Body**: `BatchUpdateBody`
|
||||||
|
* **成功响应 (200 OK)**:
|
||||||
|
```json
|
||||||
|
// Body: ApiResponse<{ updatedCount: number }>
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Successfully updated 3 accounts.",
|
||||||
|
"data": { "updatedCount": 3 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **核心统计接口**
|
||||||
|
* **Endpoint**: `GET /stats/overview`
|
||||||
|
* **描述**: 一次性获取仪表盘所需的全部聚合统计数据。
|
||||||
|
* **成功响应 (200 OK)**:
|
||||||
|
```json
|
||||||
|
// Body: ApiResponse<StatsOverview>
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "Success",
|
||||||
|
"data": { /* ... StatsOverview 对象 ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **7. 关键后台任务**
|
||||||
|
|
||||||
|
* **任务名称**: Stale Lock Cleanup (超时锁定清理)
|
||||||
|
* **执行频率**: 建议每 1 分钟执行一次。
|
||||||
|
* **核心逻辑**:
|
||||||
|
* 查找所有 `status` 为 `locked` 且 `lockedAt` 时间早于预设阈值(例如 5 分钟前)的账号。
|
||||||
|
* 执行 SQL: `UPDATE accounts SET status = 'available', lockedAt = NULL WHERE status = 'locked' AND lockedAt < NOW() - INTERVAL '5 minutes';`
|
||||||
|
* **目的**: 自动释放因脚本异常中断而未能解锁的账号,保证账号资源的流转性。
|
||||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
16
drizzle/0000_initial_schema.sql
Normal file
16
drizzle/0000_initial_schema.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "accounts" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"owner_id" varchar(128) NOT NULL,
|
||||||
|
"platform" varchar(100) NOT NULL,
|
||||||
|
"custom_id" varchar(255) NOT NULL,
|
||||||
|
"data" text NOT NULL,
|
||||||
|
"status" varchar(50) NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"locked_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "platform_custom_id_idx" ON "accounts" ("platform","custom_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "owner_id_status_idx" ON "accounts" ("owner_id","status");
|
||||||
|
CREATE INDEX IF NOT EXISTS "platform_owner_idx" ON "accounts" ("platform","owner_id");
|
||||||
16
drizzle/0000_wet_joshua_kane.sql
Normal file
16
drizzle/0000_wet_joshua_kane.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "accounts" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"owner_id" varchar(128) NOT NULL,
|
||||||
|
"platform" varchar(100) NOT NULL,
|
||||||
|
"custom_id" varchar(255) NOT NULL,
|
||||||
|
"data" text NOT NULL,
|
||||||
|
"status" varchar(50) NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"locked_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "platform_custom_id_idx" ON "accounts" USING btree ("platform","custom_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "owner_id_status_idx" ON "accounts" USING btree ("owner_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "platform_owner_idx" ON "accounts" USING btree ("platform","owner_id");
|
||||||
152
drizzle/meta/0000_snapshot.json
Normal file
152
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
{
|
||||||
|
"id": "57f2c5ed-21e7-4918-8534-8cca691243fb",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.accounts": {
|
||||||
|
"name": "accounts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "varchar(128)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"name": "platform",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"custom_id": {
|
||||||
|
"name": "custom_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"name": "data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"locked_at": {
|
||||||
|
"name": "locked_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"platform_custom_id_idx": {
|
||||||
|
"name": "platform_custom_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "platform",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "custom_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"owner_id_status_idx": {
|
||||||
|
"name": "owner_id_status_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "owner_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "status",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"platform_owner_idx": {
|
||||||
|
"name": "platform_owner_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "platform",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "owner_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1758524929427,
|
||||||
|
"tag": "0000_wet_joshua_kane",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2242
package-lock.json
generated
Normal file
2242
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "accounts-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Lightweight account management platform",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:create": "tsx src/scripts/createDatabase.ts",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:setup": "npm run db:create && npm run db:migrate"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"account",
|
||||||
|
"management",
|
||||||
|
"fastify",
|
||||||
|
"drizzle"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
|
"drizzle-orm": "^0.33.0",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"postgres": "^3.4.4",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.9",
|
||||||
|
"drizzle-kit": "^0.24.0",
|
||||||
|
"tsx": "^4.16.0",
|
||||||
|
"typescript": "^5.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/api/v1/script/actions.ts
Normal file
145
src/api/v1/script/actions.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { accountService } from '../../../core/AccountService';
|
||||||
|
import {
|
||||||
|
createSuccessResponse,
|
||||||
|
createNoResourceResponse,
|
||||||
|
createInvalidParamsResponse,
|
||||||
|
createPermissionDeniedResponse,
|
||||||
|
createBusinessErrorResponse,
|
||||||
|
} from '../../../lib/apiResponse';
|
||||||
|
import { ScriptUploadItem } from '../../../types/api';
|
||||||
|
|
||||||
|
const acquireQuerySchema = z.object({
|
||||||
|
platform: z.string().min(1),
|
||||||
|
count: z.coerce.number().int().min(1).max(100).default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateParamsSchema = z.object({
|
||||||
|
ownerId: z.string().min(1),
|
||||||
|
accountId: z.coerce.number().int().positive(),
|
||||||
|
newStatus: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateQuerySchema = z.object({
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadParamsSchema = z.object({
|
||||||
|
ownerId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadBodySchema = z.array(
|
||||||
|
z.object({
|
||||||
|
platform: z.string().min(1),
|
||||||
|
customId: z.string().min(1),
|
||||||
|
data: z.string().min(1),
|
||||||
|
status: z.string().optional(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const scriptActions: FastifyPluginAsync = async function (fastify) {
|
||||||
|
fastify.get<{
|
||||||
|
Params: { ownerId: string };
|
||||||
|
Querystring: z.infer<typeof acquireQuerySchema>;
|
||||||
|
}>('/s/v1/:ownerId/acquire', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { ownerId } = request.params;
|
||||||
|
const query = acquireQuerySchema.parse(request.query);
|
||||||
|
|
||||||
|
const accounts = await accountService.acquireAccounts(
|
||||||
|
ownerId,
|
||||||
|
query.platform,
|
||||||
|
query.count
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return reply.send(
|
||||||
|
createNoResourceResponse(
|
||||||
|
`No available accounts found for platform '${query.platform}'.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(
|
||||||
|
createSuccessResponse(
|
||||||
|
accounts,
|
||||||
|
`Successfully acquired ${accounts.length} account${accounts.length > 1 ? 's' : ''}.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return reply.send(createInvalidParamsResponse('Invalid query parameters.'));
|
||||||
|
}
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.send(createBusinessErrorResponse('Failed to acquire accounts.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get<{
|
||||||
|
Params: z.infer<typeof updateParamsSchema>;
|
||||||
|
Querystring: z.infer<typeof updateQuerySchema>;
|
||||||
|
}>('/s/v1/:ownerId/update/:accountId/:newStatus', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const params = updateParamsSchema.parse(request.params);
|
||||||
|
const query = updateQuerySchema.parse(request.query);
|
||||||
|
|
||||||
|
const success = await accountService.updateAccountStatus(
|
||||||
|
params.accountId,
|
||||||
|
params.ownerId,
|
||||||
|
params.newStatus,
|
||||||
|
query.notes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return reply
|
||||||
|
.code(403)
|
||||||
|
.send(createPermissionDeniedResponse('Account not found or permission denied.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(
|
||||||
|
createSuccessResponse(
|
||||||
|
{ updatedId: params.accountId },
|
||||||
|
`Account status updated to '${params.newStatus}'.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return reply.send(createInvalidParamsResponse('Invalid parameters.'));
|
||||||
|
}
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.send(createBusinessErrorResponse('Failed to update account status.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post<{
|
||||||
|
Params: z.infer<typeof uploadParamsSchema>;
|
||||||
|
Body: ScriptUploadItem[];
|
||||||
|
}>('/s/v1/:ownerId/upload', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { ownerId } = uploadParamsSchema.parse(request.params);
|
||||||
|
const items = uploadBodySchema.parse(request.body);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return reply.send(createInvalidParamsResponse('No items to upload.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await accountService.uploadAccounts(ownerId, items);
|
||||||
|
|
||||||
|
return reply.send(
|
||||||
|
createSuccessResponse(
|
||||||
|
result,
|
||||||
|
`Successfully processed ${result.processedCount} accounts (${result.createdCount} created, ${result.updatedCount} updated).`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return reply.send(createInvalidParamsResponse('Invalid request body.'));
|
||||||
|
}
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.send(createBusinessErrorResponse('Failed to upload accounts.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default scriptActions;
|
||||||
129
src/api/v1/web/accounts.ts
Normal file
129
src/api/v1/web/accounts.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { accountService } from '../../../core/AccountService';
|
||||||
|
import {
|
||||||
|
createSuccessResponse,
|
||||||
|
createInvalidParamsResponse,
|
||||||
|
createBusinessErrorResponse,
|
||||||
|
} from '../../../lib/apiResponse';
|
||||||
|
import {
|
||||||
|
ListAccountsBody,
|
||||||
|
BatchDeleteBody,
|
||||||
|
BatchUpdateBody,
|
||||||
|
} from '../../../types/api';
|
||||||
|
|
||||||
|
const listAccountsBodySchema = z.object({
|
||||||
|
filters: z.object({
|
||||||
|
platform: z.string().optional(),
|
||||||
|
status: z.array(z.string()).optional(),
|
||||||
|
ownerId: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
pagination: z.object({
|
||||||
|
page: z.number().int().min(1),
|
||||||
|
pageSize: z.number().int().min(1).max(10000),
|
||||||
|
}),
|
||||||
|
sort: z.object({
|
||||||
|
field: z.enum([
|
||||||
|
'id',
|
||||||
|
'ownerId',
|
||||||
|
'platform',
|
||||||
|
'customId',
|
||||||
|
'data',
|
||||||
|
'status',
|
||||||
|
'notes',
|
||||||
|
'lockedAt',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
]),
|
||||||
|
order: z.enum(['asc', 'desc']),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchDeleteBodySchema = z.object({
|
||||||
|
ids: z.array(z.number().int().positive()).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchUpdateBodySchema = z.object({
|
||||||
|
ids: z.array(z.number().int().positive()).min(1),
|
||||||
|
payload: z.object({
|
||||||
|
status: z.string().optional(),
|
||||||
|
ownerId: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountsRoutes: FastifyPluginAsync = async function (fastify) {
|
||||||
|
fastify.post<{
|
||||||
|
Body: ListAccountsBody;
|
||||||
|
}>('/web/v1/accounts/list', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = listAccountsBodySchema.parse(request.body);
|
||||||
|
|
||||||
|
const result = await accountService.listAccounts(
|
||||||
|
body.filters,
|
||||||
|
body.pagination,
|
||||||
|
body.sort
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.send(createSuccessResponse(result));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return reply.send(createInvalidParamsResponse('Invalid request body.'));
|
||||||
|
}
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.send(createBusinessErrorResponse('Failed to list accounts.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post<{
|
||||||
|
Body: BatchDeleteBody;
|
||||||
|
}>('/web/v1/accounts/delete-batch', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = batchDeleteBodySchema.parse(request.body);
|
||||||
|
|
||||||
|
const deletedCount = await accountService.batchDeleteAccounts(body.ids);
|
||||||
|
|
||||||
|
return reply.send(
|
||||||
|
createSuccessResponse(
|
||||||
|
{ deletedCount },
|
||||||
|
`Successfully deleted ${deletedCount} accounts.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return reply.send(createInvalidParamsResponse('Invalid request body.'));
|
||||||
|
}
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.send(createBusinessErrorResponse('Failed to delete accounts.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post<{
|
||||||
|
Body: BatchUpdateBody;
|
||||||
|
}>('/web/v1/accounts/update-batch', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = batchUpdateBodySchema.parse(request.body);
|
||||||
|
|
||||||
|
const updatedCount = await accountService.batchUpdateAccounts(
|
||||||
|
body.ids,
|
||||||
|
body.payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.send(
|
||||||
|
createSuccessResponse(
|
||||||
|
{ updatedCount },
|
||||||
|
`Successfully updated ${updatedCount} accounts.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return reply.send(createInvalidParamsResponse('Invalid request body.'));
|
||||||
|
}
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.send(createBusinessErrorResponse('Failed to update accounts.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default accountsRoutes;
|
||||||
21
src/api/v1/web/stats.ts
Normal file
21
src/api/v1/web/stats.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { accountService } from '../../../core/AccountService';
|
||||||
|
import {
|
||||||
|
createSuccessResponse,
|
||||||
|
createBusinessErrorResponse,
|
||||||
|
} from '../../../lib/apiResponse';
|
||||||
|
|
||||||
|
const statsRoutes: FastifyPluginAsync = async function (fastify) {
|
||||||
|
fastify.get('/web/v1/stats/overview', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const stats = await accountService.getStatsOverview();
|
||||||
|
|
||||||
|
return reply.send(createSuccessResponse(stats));
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.send(createBusinessErrorResponse('Failed to get stats overview.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default statsRoutes;
|
||||||
14
src/config.ts
Normal file
14
src/config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
database: {
|
||||||
|
url: process.env.DATABASE_URL || 'postgresql://localhost:5432/accounts_db',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
lockTimeout: {
|
||||||
|
minutes: parseInt(process.env.LOCK_TIMEOUT_MINUTES || '5', 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
299
src/core/AccountService.ts
Normal file
299
src/core/AccountService.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { eq, and, sql, or, ilike, inArray } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/index';
|
||||||
|
import { accounts } from '../db/schema';
|
||||||
|
import { Account } from '../types/index';
|
||||||
|
import {
|
||||||
|
ScriptUploadItem,
|
||||||
|
ListAccountsFilters,
|
||||||
|
PaginationResult,
|
||||||
|
StatsOverview,
|
||||||
|
} from '../types/api';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export class AccountService {
|
||||||
|
async acquireAccounts(
|
||||||
|
ownerId: string,
|
||||||
|
platform: string,
|
||||||
|
count: number = 1
|
||||||
|
): Promise<Pick<Account, 'id' | 'customId' | 'data'>[]> {
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
const availableAccounts = await tx
|
||||||
|
.select({ id: accounts.id, customId: accounts.customId, data: accounts.data })
|
||||||
|
.from(accounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(accounts.ownerId, ownerId),
|
||||||
|
eq(accounts.platform, platform),
|
||||||
|
eq(accounts.status, 'available')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(count)
|
||||||
|
.for('update');
|
||||||
|
|
||||||
|
if (availableAccounts.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountIds = availableAccounts.map((acc) => acc.id);
|
||||||
|
await tx
|
||||||
|
.update(accounts)
|
||||||
|
.set({
|
||||||
|
status: 'locked',
|
||||||
|
lockedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(inArray(accounts.id, accountIds));
|
||||||
|
|
||||||
|
return availableAccounts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccountStatus(
|
||||||
|
accountId: number,
|
||||||
|
ownerId: string,
|
||||||
|
newStatus: string,
|
||||||
|
notes?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const updateData: Partial<Account> = {
|
||||||
|
status: newStatus,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newStatus !== 'locked') {
|
||||||
|
updateData.lockedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes) {
|
||||||
|
updateData.notes = notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(accounts)
|
||||||
|
.set(updateData)
|
||||||
|
.where(and(eq(accounts.id, accountId), eq(accounts.ownerId, ownerId)))
|
||||||
|
.returning({ id: accounts.id });
|
||||||
|
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadAccounts(
|
||||||
|
ownerId: string,
|
||||||
|
items: ScriptUploadItem[]
|
||||||
|
): Promise<{ processedCount: number; createdCount: number; updatedCount: number }> {
|
||||||
|
let createdCount = 0;
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const existingAccount = await db
|
||||||
|
.select({ id: accounts.id })
|
||||||
|
.from(accounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(accounts.platform, item.platform),
|
||||||
|
eq(accounts.customId, item.customId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingAccount.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(accounts)
|
||||||
|
.set({
|
||||||
|
data: item.data,
|
||||||
|
status: item.status || 'available',
|
||||||
|
ownerId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(accounts.id, existingAccount[0].id));
|
||||||
|
updatedCount++;
|
||||||
|
} else {
|
||||||
|
await db.insert(accounts).values({
|
||||||
|
ownerId,
|
||||||
|
platform: item.platform,
|
||||||
|
customId: item.customId,
|
||||||
|
data: item.data,
|
||||||
|
status: item.status || 'available',
|
||||||
|
});
|
||||||
|
createdCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processedCount: items.length,
|
||||||
|
createdCount,
|
||||||
|
updatedCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAccounts(
|
||||||
|
filters: ListAccountsFilters,
|
||||||
|
pagination: { page: number; pageSize: number },
|
||||||
|
sort: { field: keyof Account; order: 'asc' | 'desc' }
|
||||||
|
): Promise<{ list: Account[]; pagination: PaginationResult }> {
|
||||||
|
const conditions: any[] = [];
|
||||||
|
|
||||||
|
if (filters.platform) {
|
||||||
|
conditions.push(eq(accounts.platform, filters.platform));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status && filters.status.length > 0) {
|
||||||
|
conditions.push(inArray(accounts.status, filters.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.ownerId) {
|
||||||
|
conditions.push(eq(accounts.ownerId, filters.ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(accounts.customId, `%${filters.search}%`),
|
||||||
|
ilike(accounts.notes, `%${filters.search}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(accounts)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const total = Number(totalResult.count);
|
||||||
|
const totalPages = Math.ceil(total / pagination.pageSize);
|
||||||
|
const offset = (pagination.page - 1) * pagination.pageSize;
|
||||||
|
|
||||||
|
const orderColumn = accounts[sort.field];
|
||||||
|
const orderClause = sort.order === 'desc' ? sql`${orderColumn} DESC` : sql`${orderColumn} ASC`;
|
||||||
|
|
||||||
|
const list = await db
|
||||||
|
.select()
|
||||||
|
.from(accounts)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(orderClause)
|
||||||
|
.limit(pagination.pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
pagination: {
|
||||||
|
page: pagination.page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDeleteAccounts(ids: number[]): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.delete(accounts)
|
||||||
|
.where(inArray(accounts.id, ids))
|
||||||
|
.returning({ id: accounts.id });
|
||||||
|
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchUpdateAccounts(
|
||||||
|
ids: number[],
|
||||||
|
payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes'>>
|
||||||
|
): Promise<number> {
|
||||||
|
const updateData = { ...payload, updatedAt: new Date() };
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(accounts)
|
||||||
|
.set(updateData)
|
||||||
|
.where(inArray(accounts.id, ids))
|
||||||
|
.returning({ id: accounts.id });
|
||||||
|
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatsOverview(): Promise<StatsOverview> {
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(accounts);
|
||||||
|
|
||||||
|
const platformStats = await db
|
||||||
|
.select({
|
||||||
|
platform: accounts.platform,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(accounts)
|
||||||
|
.groupBy(accounts.platform);
|
||||||
|
|
||||||
|
const ownerStats = await db
|
||||||
|
.select({
|
||||||
|
ownerId: accounts.ownerId,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(accounts)
|
||||||
|
.groupBy(accounts.ownerId);
|
||||||
|
|
||||||
|
const statusStats = await db
|
||||||
|
.select({
|
||||||
|
status: accounts.status,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(accounts)
|
||||||
|
.groupBy(accounts.status);
|
||||||
|
|
||||||
|
const detailedStats = await db
|
||||||
|
.select({
|
||||||
|
platform: accounts.platform,
|
||||||
|
ownerId: accounts.ownerId,
|
||||||
|
status: accounts.status,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(accounts)
|
||||||
|
.groupBy(accounts.platform, accounts.ownerId, accounts.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAccounts: Number(totalResult.count),
|
||||||
|
platformSummary: platformStats.reduce((acc, item) => {
|
||||||
|
acc[item.platform] = Number(item.count);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
ownerSummary: ownerStats.reduce((acc, item) => {
|
||||||
|
acc[item.ownerId] = Number(item.count);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
statusSummary: statusStats.reduce((acc, item) => {
|
||||||
|
acc[item.status] = Number(item.count);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
detailedBreakdown: detailedStats.map((item) => ({
|
||||||
|
platform: item.platform,
|
||||||
|
ownerId: item.ownerId,
|
||||||
|
status: item.status,
|
||||||
|
count: Number(item.count),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupStaleLocks(): Promise<number> {
|
||||||
|
const thresholdTime = new Date(
|
||||||
|
Date.now() - config.lockTimeout.minutes * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(accounts)
|
||||||
|
.set({
|
||||||
|
status: 'available',
|
||||||
|
lockedAt: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(accounts.status, 'locked'),
|
||||||
|
sql`${accounts.lockedAt} < ${thresholdTime.toISOString()}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: accounts.id });
|
||||||
|
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountService = new AccountService();
|
||||||
7
src/db/index.ts
Normal file
7
src/db/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { config } from '../config';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
const client = postgres(config.database.url);
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
18
src/db/schema.ts
Normal file
18
src/db/schema.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { pgTable, serial, varchar, timestamp, uniqueIndex, text, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const accounts = pgTable('accounts', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
ownerId: varchar('owner_id', { length: 128 }).notNull(),
|
||||||
|
platform: varchar('platform', { length: 100 }).notNull(),
|
||||||
|
customId: varchar('custom_id', { length: 255 }).notNull(),
|
||||||
|
data: text('data').notNull(),
|
||||||
|
status: varchar('status', { length: 50 }).notNull(),
|
||||||
|
notes: text('notes'),
|
||||||
|
lockedAt: timestamp('locked_at'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
platformCustomIdIdx: uniqueIndex('platform_custom_id_idx').on(table.platform, table.customId),
|
||||||
|
ownerIdStatusIdx: index('owner_id_status_idx').on(table.ownerId, table.status),
|
||||||
|
platformOwnerIdx: index('platform_owner_idx').on(table.platform, table.ownerId),
|
||||||
|
}));
|
||||||
57
src/index.ts
Normal file
57
src/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import { config } from './config';
|
||||||
|
import { staleLockCleanup } from './jobs/staleLockCleanup';
|
||||||
|
|
||||||
|
import scriptActions from './api/v1/script/actions';
|
||||||
|
import accountsRoutes from './api/v1/web/accounts';
|
||||||
|
import statsRoutes from './api/v1/web/stats';
|
||||||
|
|
||||||
|
const server = fastify({
|
||||||
|
logger: {
|
||||||
|
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await server.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.register(scriptActions);
|
||||||
|
await server.register(accountsRoutes);
|
||||||
|
await server.register(statsRoutes);
|
||||||
|
|
||||||
|
server.get('/health', async (request, reply) => {
|
||||||
|
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||||
|
});
|
||||||
|
|
||||||
|
staleLockCleanup.start(1);
|
||||||
|
|
||||||
|
const gracefulShutdown = () => {
|
||||||
|
console.log('Received shutdown signal, stopping services...');
|
||||||
|
staleLockCleanup.stop();
|
||||||
|
server.close().then(() => {
|
||||||
|
console.log('Server stopped successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', gracefulShutdown);
|
||||||
|
process.on('SIGINT', gracefulShutdown);
|
||||||
|
|
||||||
|
await server.listen({
|
||||||
|
port: config.server.port,
|
||||||
|
host: config.server.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Server is running on http://${config.server.host}:${config.server.port}`);
|
||||||
|
} catch (error) {
|
||||||
|
server.log.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
59
src/jobs/staleLockCleanup.ts
Normal file
59
src/jobs/staleLockCleanup.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { accountService } from '../core/AccountService';
|
||||||
|
|
||||||
|
export class StaleLockCleanup {
|
||||||
|
private intervalId: NodeJS.Timeout | null = null;
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
start(intervalMinutes: number = 1): void {
|
||||||
|
if (this.isRunning) {
|
||||||
|
console.log('Stale lock cleanup job is already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
const intervalMs = intervalMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
console.log(`Starting stale lock cleanup job with ${intervalMinutes} minute(s) interval`);
|
||||||
|
|
||||||
|
this.intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const cleanedCount = await accountService.cleanupStaleLocks();
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
console.log(`Stale lock cleanup: Released ${cleanedCount} locked accounts`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during stale lock cleanup:', error);
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
this.runOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
this.isRunning = false;
|
||||||
|
console.log('Stale lock cleanup job stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const cleanedCount = await accountService.cleanupStaleLocks();
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
console.log(`Manual stale lock cleanup: Released ${cleanedCount} locked accounts`);
|
||||||
|
}
|
||||||
|
return cleanedCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during manual stale lock cleanup:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isJobRunning(): boolean {
|
||||||
|
return this.isRunning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const staleLockCleanup = new StaleLockCleanup();
|
||||||
40
src/lib/apiResponse.ts
Normal file
40
src/lib/apiResponse.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { ApiResponse, BusinessCode } from '../types/api';
|
||||||
|
|
||||||
|
export function createSuccessResponse<T>(data: T, message = 'Success'): ApiResponse<T> {
|
||||||
|
return {
|
||||||
|
code: BusinessCode.Success,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createErrorResponse(
|
||||||
|
code: BusinessCode,
|
||||||
|
message: string
|
||||||
|
): ApiResponse<null> {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNoResourceResponse(message = 'No resource found'): ApiResponse<null> {
|
||||||
|
return createErrorResponse(BusinessCode.NoResource, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInvalidParamsResponse(message = 'Invalid parameters'): ApiResponse<null> {
|
||||||
|
return createErrorResponse(BusinessCode.InvalidParams, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResourceConflictResponse(message = 'Resource conflict'): ApiResponse<null> {
|
||||||
|
return createErrorResponse(BusinessCode.ResourceConflict, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPermissionDeniedResponse(message = 'Permission denied'): ApiResponse<null> {
|
||||||
|
return createErrorResponse(BusinessCode.PermissionDenied, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBusinessErrorResponse(message = 'Business error'): ApiResponse<null> {
|
||||||
|
return createErrorResponse(BusinessCode.BusinessError, message);
|
||||||
|
}
|
||||||
51
src/scripts/createDatabase.ts
Normal file
51
src/scripts/createDatabase.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
async function createDatabase() {
|
||||||
|
const dbUrl = new URL(config.database.url);
|
||||||
|
const dbName = dbUrl.pathname.slice(1); // 去掉开头的 '/'
|
||||||
|
|
||||||
|
// 创建连接到 postgres 默认数据库的连接
|
||||||
|
const adminDbUrl = config.database.url.replace(`/${dbName}`, '/postgres');
|
||||||
|
const adminSql = postgres(adminDbUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Checking if database '${dbName}' exists...`);
|
||||||
|
|
||||||
|
// 检查数据库是否存在
|
||||||
|
const result = await adminSql`
|
||||||
|
SELECT 1 FROM pg_database WHERE datname = ${dbName}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.log(`Creating database '${dbName}'...`);
|
||||||
|
|
||||||
|
// 创建数据库
|
||||||
|
await adminSql.unsafe(`CREATE DATABASE "${dbName}"`);
|
||||||
|
|
||||||
|
console.log(`✅ Database '${dbName}' created successfully!`);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Database '${dbName}' already exists.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error creating database:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await adminSql.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果直接运行此脚本
|
||||||
|
if (require.main === module) {
|
||||||
|
createDatabase()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Database initialization completed.');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Database initialization failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createDatabase };
|
||||||
78
src/types/api.ts
Normal file
78
src/types/api.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Account } from './index';
|
||||||
|
|
||||||
|
export enum BusinessCode {
|
||||||
|
Success = 0,
|
||||||
|
NoResource = 1001,
|
||||||
|
InvalidParams = 2001,
|
||||||
|
ResourceConflict = 3001,
|
||||||
|
PermissionDenied = 4001,
|
||||||
|
BusinessError = 5001,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: BusinessCode;
|
||||||
|
message: string;
|
||||||
|
data: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationResult {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScriptAcquireResponse = Pick<Account, 'id' | 'customId' | 'data'>[];
|
||||||
|
|
||||||
|
export interface ScriptUploadItem {
|
||||||
|
platform: string;
|
||||||
|
customId: string;
|
||||||
|
data: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAccountsFilters {
|
||||||
|
platform?: string;
|
||||||
|
status?: string[];
|
||||||
|
ownerId?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAccountsBody {
|
||||||
|
filters: ListAccountsFilters;
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
sort: {
|
||||||
|
field: keyof Account;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAccountsResponse {
|
||||||
|
list: Account[];
|
||||||
|
pagination: PaginationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchDeleteBody {
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchUpdateBody {
|
||||||
|
ids: number[];
|
||||||
|
payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes'>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsOverview {
|
||||||
|
totalAccounts: number;
|
||||||
|
platformSummary: Record<string, number>;
|
||||||
|
ownerSummary: Record<string, number>;
|
||||||
|
statusSummary: Record<string, number>;
|
||||||
|
detailedBreakdown: {
|
||||||
|
platform: string;
|
||||||
|
ownerId: string;
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
4
src/types/index.ts
Normal file
4
src/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { accounts } from '../db/schema';
|
||||||
|
import { InferSelectModel } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export type Account = InferSelectModel<typeof accounts>;
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user