diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3d43f2f..b1f1a7e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,12 @@ "permissions": { "allow": [ "Bash(mkdir:*)", - "Bash(git init:*)" + "Bash(git init:*)", + "Bash(git remote:*)", + "Bash(git push:*)", + "Bash(git add:*)", + "Bash(git add:*)", + "Bash(git push:*)" ], "deny": [] } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c619985 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +```bash +# Development +npm run dev # Start development server with tsx watch +npm run build # Compile TypeScript to JavaScript +npm start # Run the production build + +# Database Operations +npm run db:create # Create database if it doesn't exist +npm run db:generate # Generate Drizzle migration files +npm run db:migrate # Run database migrations +npm run db:studio # Open Drizzle Studio (database management UI) +npm run db:setup # One-command database setup (create + migrate) +``` + +## Architecture Overview + +This is a lightweight account management platform built with TypeScript, Fastify, and PostgreSQL. The system uses a **single table design** with the `accounts` table storing all account data. + +### Core Design Principles +- **Single Table Architecture**: All account data stored in one `accounts` table with proper indexing +- **Dual API Design**: Separate optimized APIs for automation scripts (`/s/v1/`) and web management (`/web/v1/`) +- **Concurrent Safety**: Account locking mechanism prevents simultaneous access to the same account +- **Background Jobs**: Automatic cleanup of stale locks via scheduled tasks + +### Key Components + +**Database Layer** (`src/db/`): +- `schema.ts`: Single accounts table with unique constraints on platform+customId +- `index.ts`: PostgreSQL connection using Drizzle ORM + +**Core Business Logic** (`src/core/AccountService.ts`): +- Account acquisition with automatic locking +- Status management and batch operations +- Statistics generation and stale lock cleanup +- All database operations are transactional where needed + +**API Routes**: +- `src/api/v1/script/actions.ts`: Lightweight script API for automation +- `src/api/v1/web/accounts.ts`: Full-featured web management API +- `src/api/v1/web/stats.ts`: Statistics and analytics endpoints + +**Background Jobs** (`src/jobs/staleLockCleanup.ts`): +- Automatic cleanup of accounts locked longer than `LOCK_TIMEOUT_MINUTES` + +### Database Schema + +The `accounts` table structure: +- `id`: Primary key (serial) +- `ownerId`: Account owner identifier +- `platform`: Platform name (e.g., "twitter", "facebook") +- `customId`: Custom identifier for the account +- `data`: JSON data storage for account information +- `status`: Current status ("available", "locked", "failed", etc.) +- `notes`: Optional notes field +- `lockedAt`: Timestamp when account was locked +- Unique constraint: `platform + customId` +- Indexes on: `ownerId + status`, `platform + ownerId` + +### API Patterns + +**Script API** (`/s/v1/{ownerId}/`): GET-based, optimized for automation +- `GET /acquire?platform=X&count=N` - Get and lock accounts +- `GET /update/{accountId}/{status}?notes=X` - Update account status +- `POST /upload` - Bulk upload/update accounts + +**Web API** (`/web/v1/`): POST-based with rich payloads +- `POST /accounts/list` - Complex filtering, pagination, sorting +- `POST /accounts/delete-batch` - Bulk delete operations +- `POST /accounts/update-batch` - Bulk update operations + +### Environment Configuration + +Required environment variables (see `.env.example`): +- `DATABASE_URL`: PostgreSQL connection string +- `PORT`: Server port (default: 3000) +- `NODE_ENV`: Environment mode +- `LOCK_TIMEOUT_MINUTES`: Lock timeout duration (default: 5 minutes) + +### Development Notes + +- The application uses Fastify with full CORS enabled +- Graceful shutdown handling for SIGTERM/SIGINT +- Structured logging with different levels for dev/prod +- Health check endpoint at `/health` +- All API responses use standardized format from `lib/apiResponse.ts` \ No newline at end of file diff --git a/docs/API文档.md b/docs/API文档.md index d6eebb7..1da1fe7 100644 --- a/docs/API文档.md +++ b/docs/API文档.md @@ -9,7 +9,6 @@ - **基础URL**: `{API_BASE_URL}` - **数据格式**: JSON - **字符编码**: UTF-8 -- **认证方式**: 需要在请求头中包含认证信息 ### 通用响应格式 @@ -145,8 +144,7 @@ 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' + 'Content-Type': 'application/json' } }); @@ -219,8 +217,7 @@ async function updateAccountStatus(ownerId, accountId, newStatus, notes) { const response = await fetch(url, { method: 'GET', headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer YOUR_TOKEN' + 'Content-Type': 'application/json' } }); @@ -302,8 +299,7 @@ async function uploadAccounts(ownerId, accounts) { const response = await fetch(`/s/v1/${ownerId}/upload`, { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer YOUR_TOKEN' + 'Content-Type': 'application/json' }, body: JSON.stringify(accounts) }); @@ -440,8 +436,7 @@ 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' + 'Content-Type': 'application/json' }, body: JSON.stringify({ filters, @@ -526,8 +521,7 @@ async function batchDeleteAccounts(ids) { const response = await fetch('/web/v1/accounts/delete-batch', { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer YOUR_TOKEN' + 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }); @@ -607,8 +601,7 @@ 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' + 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, payload }) }); @@ -632,7 +625,136 @@ if (result.code === 0) { } ``` -### 7. 统计概览接口 +### 7. 账户导出接口 + +用于Web端批量导出账户并设置状态为已导出。 + +#### 请求 + +```http +POST /web/v1/accounts/export +``` + +#### 请求体 + +```json +{ + "ids": [1, 2, 3], + "mode": "text" +} +``` + +#### 参数 + +| 参数名 | 位置 | 类型 | 必填 | 说明 | +|--------|------|------|------|------| +| ids | 请求体 | number[] | 是 | 要导出的账户ID数组 | +| mode | 请求体 | string | 是 | 导出模式:"text"或"object" | + +#### 导出模式说明 + +- `text`: 文本模式,返回的data字段为字符串,每行一个账户的data字段内容 +- `object`: 对象模式,返回的data字段为完整的账户对象数组 + +#### 响应 + +文本模式成功响应: + +```json +{ + "code": 0, + "message": "Successfully exported 3 accounts.", + "data": { + "exportedCount": 3, + "data": "{\"username\":\"user1\",\"password\":\"pass1\"}\n{\"username\":\"user2\",\"password\":\"pass2\"}\n{\"username\":\"user3\",\"password\":\"pass3\"}" + } +} +``` + +对象模式成功响应: + +```json +{ + "code": 0, + "message": "Successfully exported 3 accounts.", + "data": { + "exportedCount": 3, + "data": [ + { + "id": 1, + "ownerId": "owner123", + "platform": "example", + "customId": "user1", + "data": "{\"username\":\"user1\",\"password\":\"pass1\"}", + "status": "exported", + "notes": "测试账户1", + "lockedAt": null, + "createdAt": "2023-01-01T00:00:00.000Z", + "updatedAt": "2023-01-01T12:00:00.000Z" + }, + { + "id": 2, + "ownerId": "owner123", + "platform": "example", + "customId": "user2", + "data": "{\"username\":\"user2\",\"password\":\"pass2\"}", + "status": "exported", + "notes": "测试账户2", + "lockedAt": null, + "createdAt": "2023-01-01T00:00:00.000Z", + "updatedAt": "2023-01-01T12:00:00.000Z" + } + ] + } +} +``` + +#### 示例 + +```javascript +// 使用fetch导出账户 +async function exportAccounts(ids, mode) { + const response = await fetch('/web/v1/accounts/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ids, mode }) + }); + + const result = await response.json(); + return result; +} + +// 文本模式导出示例 +const textResult = await exportAccounts([1, 2, 3], 'text'); +if (textResult.code === 0) { + console.log('导出成功:', textResult.data.exportedCount); + console.log('文本数据:', textResult.data.data); + // 可以直接保存为文件或复制到剪贴板 + const lines = textResult.data.data.split('\n'); + lines.forEach((line, index) => { + console.log(`账户${index + 1}:`, line); + }); +} else { + console.error('导出失败:', textResult.message); +} + +// 对象模式导出示例 +const objectResult = await exportAccounts([1, 2, 3], 'object'); +if (objectResult.code === 0) { + console.log('导出成功:', objectResult.data.exportedCount); + const accounts = objectResult.data.data; + accounts.forEach(account => { + console.log(`账户ID: ${account.id}, 状态: ${account.status}`); + console.log(`数据: ${account.data}`); + }); +} else { + console.error('导出失败:', objectResult.message); +} +``` + +### 8. 统计概览接口 用于Web端获取账户统计信息。 @@ -693,8 +815,7 @@ async function getStatsOverview() { const response = await fetch('/web/v1/stats/overview', { method: 'GET', headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer YOUR_TOKEN' + 'Content-Type': 'application/json' } }); @@ -734,23 +855,17 @@ if (result.code === 0) { | 1001 | 资源不存在 | 检查请求的资源是否存在 | | 2001 | 参数无效 | 检查请求参数是否符合要求 | | 3001 | 资源冲突 | 检查是否有重复数据 | -| 4001 | 权限不足 | 检查认证信息是否正确 | | 5001 | 业务错误 | 检查业务逻辑是否正确 | ## 最佳实践 -1. **认证**:所有API请求都需要在请求头中包含认证信息,例如: - ```http - Authorization: Bearer YOUR_TOKEN - ``` +1. **错误处理**:前端应该根据返回的`code`值进行相应的错误处理,而不是依赖`message`字段。 -2. **错误处理**:前端应该根据返回的`code`值进行相应的错误处理,而不是依赖`message`字段。 +2. **分页**:在获取列表数据时,合理设置分页大小,建议不超过100条。 -3. **分页**:在获取列表数据时,合理设置分页大小,建议不超过100条。 +3. **批量操作**:批量操作时,建议限制单次操作的数量,避免服务器压力过大。 -4. **批量操作**:批量操作时,建议限制单次操作的数量,避免服务器压力过大。 - -5. **数据格式**:账户数据字段`data`存储的是JSON格式字符串,前端需要进行相应的序列化和反序列化操作。 +4. **数据格式**:账户数据字段`data`存储的是JSON格式字符串,前端需要进行相应的序列化和反序列化操作。 ## 类型定义 @@ -845,6 +960,18 @@ interface BatchUpdateBody { payload: Partial>; } +// 账户导出请求体 +interface ExportAccountsBody { + ids: number[]; + mode: 'text' | 'object'; +} + +// 账户导出响应 +interface ExportAccountsResponse { + exportedCount: number; + data: string | Account[]; +} + // 统计概览 interface StatsOverview { totalAccounts: number; diff --git a/example/accounts-manager-sdk.js b/example/accounts-manager-sdk.js new file mode 100644 index 0000000..91c633f --- /dev/null +++ b/example/accounts-manager-sdk.js @@ -0,0 +1,574 @@ +import http from 'http'; +import https from 'https'; + +/** + * 账户管理系统 NodeJS SDK + * + * 该SDK提供了与账户管理系统API交互的功能,支持账号的获取、上传、更新等操作。 + * 使用ESM格式,兼容Node.js环境。 + * + * @example + * ```javascript + * import AccountsManagerSDK, { AccountStatus } from './accounts-manager-sdk.js'; + * + * // 基本用法 + * const sdk = new AccountsManagerSDK({ + * baseUrl: 'https://api.example.com' + * }); + * + * // 指定默认ownerId和platform + * const sdk = new AccountsManagerSDK({ + * baseUrl: 'https://api.example.com', + * ownerId: 'owner123', + * platform: 'platform1' + * }); + * + * // 使用默认ownerId和platform,获取2个账号 + * const accounts = await sdk.getAccounts(2); + * console.log(accounts); + * + * // 使用默认ownerId上传账号 + * const items = [ + * { + * platform: 'platform1', + * customId: 'user123', + * data: { username: 'user123', password: 'pass123' }, + * status: AccountStatus.AVAILABLE + * } + * ]; + * const result = await sdk.uploadAccounts(items); + * console.log(result); + * + * // 使用默认ownerId更新账号状态 + * const updateResult = await sdk.updateAccountStatus(1, AccountStatus.USED); + * console.log(updateResult); + * ``` + */ + +/** + * 业务状态码枚举 + * @enum {number} + */ +export const BusinessCode = { + /** 请求成功 */ + Success: 0, + /** 资源不存在 */ + NoResource: 1001, + /** 参数无效 */ + InvalidParams: 2001, + /** 资源冲突 */ + ResourceConflict: 3001, + /** 权限不足 */ + PermissionDenied: 4001, + /** 业务错误 */ + BusinessError: 5001, +}; + +/** + * 账户状态枚举 + * @enum {string} + */ +export const AccountStatus = { + /** 可用 */ + AVAILABLE: 'available', + /** 已使用 */ + USED: 'used', + /** 已锁定 */ + LOCKED: 'locked', + /** 已禁用 */ + DISABLED: 'disabled', + /** 已过期 */ + EXPIRED: 'expired', +}; + +/** + * 通用响应格式 + * @template T - 响应数据类型 + */ +export class ApiResponse { + /** + * 业务状态码 + * @type {BusinessCode} + */ + code; + + /** + * 响应消息 + * @type {string} + */ + message; + + /** + * 响应数据 + * @type {T|null} + */ + data; + + /** + * 创建API响应实例 + * @param {BusinessCode} code - 业务状态码 + * @param {string} message - 响应消息 + * @param {T|null} data - 响应数据 + */ + constructor(code, message, data = null) { + this.code = code; + this.message = message; + this.data = data; + } + + /** + * 检查响应是否成功 + * @returns {boolean} 是否成功 + */ + isSuccess() { + return this.code === BusinessCode.Success; + } +} + +/** + * 账户数据模型 + */ +export class Account { + /** + * 账户ID,自增主键 + * @type {number} + */ + id; + + /** + * 所有者ID + * @type {string} + */ + ownerId; + + /** + * 平台名称 + * @type {string} + */ + platform; + + /** + * 自定义ID + * @type {string} + */ + customId; + + /** + * 账户数据,JSON格式字符串 + * @type {string} + */ + data; + + /** + * 账户状态 + * @type {string} + */ + status; + + /** + * 备注,可选 + * @type {string|undefined} + */ + notes; + + /** + * 锁定时间,可选 + * @type {Date|undefined} + */ + lockedAt; + + /** + * 创建时间 + * @type {Date} + */ + createdAt; + + /** + * 更新时间 + * @type {Date} + */ + updatedAt; + + /** + * 创建账户实例 + * @param {object} data - 账户数据 + * @param {number} data.id - 账户ID + * @param {string} data.ownerId - 所有者ID + * @param {string} data.platform - 平台名称 + * @param {string} data.customId - 自定义ID + * @param {string} data.data - 账户数据 + * @param {string} data.status - 账户状态 + * @param {string} [data.notes] - 备注 + * @param {string} [data.lockedAt] - 锁定时间 + * @param {string} data.createdAt - 创建时间 + * @param {string} data.updatedAt - 更新时间 + */ + constructor({ id, ownerId, platform, customId, data, status, notes, lockedAt, createdAt, updatedAt }) { + this.id = id; + this.ownerId = ownerId; + this.platform = platform; + this.customId = customId; + this.data = data; + this.status = status; + this.notes = notes; + this.lockedAt = lockedAt ? new Date(lockedAt) : undefined; + this.createdAt = new Date(createdAt); + this.updatedAt = new Date(updatedAt); + } + + /** + * 解析账户数据为对象 + * @returns {object} 解析后的账户数据 + */ + parseData() { + try { + return JSON.parse(this.data); + } catch (error) { + console.error('Failed to parse account data:', error); + return {}; + } + } +} + +/** + * 脚本上传项 + */ +export class ScriptUploadItem { + /** + * 平台名称 + * @type {string} + */ + platform; + + /** + * 自定义ID + * @type {string} + */ + customId; + + /** + * 账户数据,JSON格式字符串 + * @type {string} + */ + data; + + /** + * 账户状态,可选,默认为"available" + * @type {string|undefined} + */ + status; + + /** + * 创建脚本上传项实例 + * @param {object} data - 上传项数据 + * @param {string} data.platform - 平台名称 + * @param {string} data.customId - 自定义ID + * @param {string|object} data.data - 账户数据,可以是对象或JSON字符串 + * @param {string} [data.status] - 账户状态 + */ + constructor({ platform, customId, data, status = 'available' }) { + this.platform = platform; + this.customId = customId; + this.data = typeof data === 'string' ? data : JSON.stringify(data); + this.status = status; + } + + /** + * 将实例转换为普通对象 + * @returns {object} 普通对象 + */ + toObject() { + return { + platform: this.platform, + customId: this.customId, + data: this.data, + status: this.status, + }; + } +} + +/** + * 账户管理系统SDK + */ +export default class AccountsManagerSDK { + /** + * API基础URL + * @type {string} + */ + baseUrl; + + + /** + * 创建SDK实例 + * @param {object} options - 配置选项 + * @param {string} options.baseUrl - API基础URL + * @param {string} [options.ownerId] - 默认所有者ID(可选) + * @param {string} [options.platform] - 默认平台名称(可选) + * @example + * ```javascript + * // 基本用法 + * const sdk = new AccountsManagerSDK({ + * baseUrl: 'https://api.example.com' + * }); + * + * // 指定默认ownerId和platform + * const sdk = new AccountsManagerSDK({ + * baseUrl: 'https://api.example.com', + * ownerId: 'owner123', + * platform: 'platform1' + * }); + * ``` + */ + constructor({ baseUrl, ownerId, platform }) { + if (!baseUrl) { + throw new Error('baseUrl is required'); + } + + this.baseUrl = baseUrl; + this.ownerId = ownerId; + this.platform = platform; + } + + /** + * 发送HTTP请求 + * @private + * @param {string} path - 请求路径 + * @param {object} options - 请求选项 + * @param {string} [options.method='GET'] - 请求方法 + * @param {object} [options.headers] - 请求头 + * @param {string|object} [options.body] - 请求体 + * @returns {Promise} 响应数据 + */ + async _request(path, options = {}) { + const { + method = 'GET', + headers = {}, + body = null, + } = options; + + const url = new URL(path, this.baseUrl); + const isHttps = url.protocol === 'https:'; + + const requestOptions = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + url.search, + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }; + + if (body) { + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + requestOptions.headers['Content-Length'] = Buffer.byteLength(bodyStr); + } + + return new Promise((resolve, reject) => { + const req = (isHttps ? https : http).request(requestOptions, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const jsonData = JSON.parse(data); + resolve(jsonData); + } catch (error) { + reject(new Error(`Failed to parse response: ${error.message}`)); + } + }); + }); + + req.on('error', (error) => { + reject(new Error(`Request failed: ${error.message}`)); + }); + + if (body) { + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + req.write(bodyStr); + } + + req.end(); + }); + } + + /** + * 获取账号 + * @param {number} [count=1] - 获取数量,默认1,最大100 + * @param {string} [ownerId] - 所有者ID(可选,如果未提供则使用初始化时设置的值) + * @param {string} [platform] - 平台名称(可选,如果未提供则使用初始化时设置的值) + * @returns {Promise>>} API响应 + * @example + * ```javascript + * // 使用初始化时设置的ownerId和platform,获取2个账号 + * const result = await sdk.getAccounts(2); + * + * // 覆盖初始化时设置的ownerId和platform + * const result = await sdk.getAccounts(2, 'owner456', 'platform2'); + * + * if (result.isSuccess()) { + * console.log('获取的账号:', result.data); + * } else { + * console.error('获取失败:', result.message); + * } + * ``` + */ + async getAccounts(count = 1, ownerId, platform) { + try { + // 使用传入的参数或默认值 + const finalOwnerId = ownerId || this.ownerId; + const finalPlatform = platform || this.platform; + + if (!finalOwnerId) { + throw new Error('ownerId is required (either in constructor or method call)'); + } + if (!finalPlatform) { + throw new Error('platform is required (either in constructor or method call)'); + } + + const query = new URLSearchParams({ + platform: finalPlatform, + count: Math.min(Math.max(count, 1), 100).toString(), + }); + + const response = await this._request(`/s/v1/${finalOwnerId}/acquire?${query}`); + return new ApiResponse(response.code, response.message, response.data); + } catch (error) { + return new ApiResponse(BusinessCode.BusinessError, error.message); + } + } + + /** + * 更新账户状态 + * @param {number} accountId - 账户ID + * @param {string} newStatus - 新状态 + * @param {string} [ownerId] - 所有者ID(可选,如果未提供则使用初始化时设置的值) + * @param {string} [notes] - 备注 + * @returns {Promise>} API响应 + * @example + * ```javascript + * // 使用初始化时设置的ownerId + * const result = await sdk.updateAccountStatus(1, 'used', undefined, '测试使用'); + * + * // 覆盖初始化时设置的ownerId + * const result = await sdk.updateAccountStatus(1, 'used', 'owner456', '测试使用'); + * + * if (result.isSuccess()) { + * console.log('更新成功:', result.data); + * } else { + * console.error('更新失败:', result.message); + * } + * ``` + */ + async updateAccountStatus(accountId, newStatus, ownerId, notes) { + try { + // 使用传入的参数或默认值 + const finalOwnerId = ownerId || this.ownerId; + + if (!finalOwnerId) { + throw new Error('ownerId is required (either in constructor or method call)'); + } + + let path = `/s/v1/${finalOwnerId}/update/${accountId}/${encodeURIComponent(newStatus)}`; + + if (notes) { + path += `?notes=${encodeURIComponent(notes)}`; + } + + const response = await this._request(path); + return new ApiResponse(response.code, response.message, response.data); + } catch (error) { + return new ApiResponse(BusinessCode.BusinessError, error.message); + } + } + + /** + * 上传账户 + * @param {Array} items - 账户数据数组,每个字符串是"----"分隔的数据,格式为"customId----data" + * @param {string} [status] - 账户状态(可选,默认为AccountStatus.AVAILABLE) + * @param {string} [ownerId] - 所有者ID(可选,如果未提供则使用初始化时设置的值) + * @param {string} [platform] - 平台名称(可选,如果未提供则使用初始化时设置的值) + * @returns {Promise>} API响应 + * @example + * ```javascript + * // 简化的账户数据字符串数组 + * const items = [ + * 'user123----{"username":"user123","password":"pass123"}', + * 'user456----{"username":"user456","password":"pass456"}', + * 'admin@qq.com----{"email":"admin@qq.com","role":"admin"}' + * ]; + * + * // 使用初始化时设置的ownerId和platform,默认状态 + * const result = await sdk.uploadAccounts(items); + * + * // 指定状态为used + * const result = await sdk.uploadAccounts(items, AccountStatus.USED); + * + * // 覆盖初始化时设置的ownerId和platform + * const result = await sdk.uploadAccounts(items, AccountStatus.USED, 'owner456', 'platform2'); + * + * if (result.isSuccess()) { + * console.log('上传成功:', result.data); + * } else { + * console.error('上传失败:', result.message); + * } + * ``` + */ + async uploadAccounts(items, status, ownerId, platform) { + try { + // 使用传入的参数或默认值 + const finalStatus = status || AccountStatus.AVAILABLE; + const finalOwnerId = ownerId || this.ownerId; + const finalPlatform = platform || this.platform; + + if (!finalOwnerId) { + throw new Error('ownerId is required (either in constructor or method call)'); + } + if (!finalPlatform) { + throw new Error('platform is required (either in constructor or method call)'); + } + + // 解析每个字符串并创建ScriptUploadItem实例 + const uploadItems = items.map(item => { + // 使用"----"分割字符串 + const parts = item.split('----'); + + if (parts.length < 2) { + throw new Error(`Invalid item format: "${item}". Expected format: "customId----data"`); + } + + const customId = parts[0].trim(); + // 使用完整的原始字符串作为data + const data = item; // 使用完整的原始字符串作为data + + // 创建新的ScriptUploadItem实例 + return new ScriptUploadItem({ + platform: finalPlatform, + customId, + data, + status: finalStatus, + }); + }); + + const body = uploadItems.map(item => ({ + platform: item.platform, + customId: item.customId, + data: item.data, + status: item.status, + })); + + const response = await this._request(`/s/v1/${finalOwnerId}/upload`, { + method: 'POST', + body, + }); + + return new ApiResponse(response.code, response.message, response.data); + } catch (error) { + return new ApiResponse(BusinessCode.BusinessError, error.message); + } + } +} \ No newline at end of file diff --git a/example/example.js b/example/example.js new file mode 100644 index 0000000..5a554ce --- /dev/null +++ b/example/example.js @@ -0,0 +1,119 @@ +import AccountsManagerSDK, { AccountStatus, BusinessCode } from './accounts-manager-sdk.js'; + +/** + * 账户管理系统 SDK 使用示例 + * + * 本文件展示了如何使用 AccountsManagerSDK 的主要功能: + * 1. 初始化 SDK + * 2. 获取账号 + * 3. 更新账户状态 + * 4. 上传账户 + */ + +// 示例配置 +const config = { + baseUrl: 'http://localhost:3006', // 替换为实际的API地址 + ownerId: 'example-owner', // 默认所有者ID + platform: 'example-platform' // 默认平台名称 +}; + +// 创建SDK实例 +const sdk = new AccountsManagerSDK(config) + +/** + * 示例:上传账号并使用上传的账号进行测试 + */ +async function exampleUploadAndTest() { + console.log('\n=== 示例:上传账号并测试 ==='); + + try { + // 1. 先上传一些测试账号 + console.log('步骤1:上传测试账号...'); + const testAccounts = [ + 'testuser1----password123', + 'testuser2----password456', + 'testuser3----password789', + 'admin1----admin123', + 'moderator1----mod123' + ]; + + const uploadResult = await sdk.uploadAccounts(testAccounts); + + if (uploadResult.isSuccess()) { + console.log('上传账号成功:'); + console.log(` 处理数量: ${uploadResult.data.processedCount}`); + console.log(` 创建数量: ${uploadResult.data.createdCount}`); + console.log(` 更新数量: ${uploadResult.data.updatedCount}`); + } else { + console.error('上传账号失败:', uploadResult.message); + return; // 如果上传失败,直接返回 + } + + // 2. 获取刚上传的账号进行测试 + console.log('\n步骤2:获取刚上传的账号进行测试...'); + const getAccountsResult = await sdk.getAccounts(3); // 获取3个账号 + + if (getAccountsResult.isSuccess()) { + console.log('获取账号成功:'); + const retrievedAccounts = getAccountsResult.data; + + retrievedAccounts.forEach((account, index) => { + console.log(`账号 ${index + 1}:`); + console.log(` ID: ${account.id}`); + console.log(` 自定义ID: ${account.customId}`); + console.log(` 数据: ${account.data}`); + }); + + // 3. 更新第一个获取的账号状态为"已使用" + let updatedAccountId = null; + if (retrievedAccounts.length > 0) { + console.log('\n步骤3:更新第一个账号状态为"已使用"...'); + const firstAccountId = retrievedAccounts[0].id; + updatedAccountId = firstAccountId; // 保存更新的账号ID,用于后续验证 + const updateResult = await sdk.updateAccountStatus(firstAccountId, AccountStatus.USED, undefined, '测试使用'); + + if (updateResult.isSuccess()) { + console.log('更新账户状态成功:'); + console.log(` 更新的账户ID: ${updateResult.data.updatedId}`); + } else { + console.error('更新账户状态失败:', updateResult.message); + } + } + + // 4. 验证状态更新 + if (updatedAccountId) { + console.log('\n步骤4:验证状态更新...'); + console.log(`注意:我们无法直接通过ID获取特定账号,因为API只支持获取可用状态的账号。`); + console.log(`账号ID ${updatedAccountId} 的状态已更新为"已使用",因此它不会出现在可用账号列表中。`); + console.log('✅ 状态更新验证完成!(账号已从可用列表中移除)'); + } else { + console.log('\n步骤4:没有账号被更新,跳过验证。'); + } + + } else { + console.error('获取账号失败:', getAccountsResult.message); + } + + } catch (error) { + console.error('上传并测试账号时发生错误:', error.message); + } +} + +/** + * 运行所有示例 + */ +async function runAllExamples() { + console.log('开始运行账户管理系统SDK示例...'); + + // 注意:这些示例需要实际的API服务器才能正常工作 + // 如果没有运行服务器,这些调用将会失败 + + // 运行上传并测试的示例 + await exampleUploadAndTest(); + + + console.log('\n所有示例运行完成!'); +} + + +runAllExamples().catch(console.error); \ No newline at end of file diff --git a/src/api/v1/web/accounts.ts b/src/api/v1/web/accounts.ts index bae1d8d..cb1ba0e 100644 --- a/src/api/v1/web/accounts.ts +++ b/src/api/v1/web/accounts.ts @@ -10,6 +10,7 @@ import { ListAccountsBody, BatchDeleteBody, BatchUpdateBody, + ExportAccountsBody, } from '../../../types/api'; const listAccountsBodySchema = z.object({ @@ -53,6 +54,11 @@ const batchUpdateBodySchema = z.object({ }), }); +const exportAccountsBodySchema = z.object({ + ids: z.array(z.number().int().positive()).min(1), + mode: z.enum(['text', 'object']), +}); + const accountsRoutes: FastifyPluginAsync = async function (fastify) { fastify.post<{ Body: ListAccountsBody; @@ -124,6 +130,29 @@ const accountsRoutes: FastifyPluginAsync = async function (fastify) { return reply.send(createBusinessErrorResponse('Failed to update accounts.')); } }); + + fastify.post<{ + Body: ExportAccountsBody; + }>('/web/v1/accounts/export', async (request, reply) => { + try { + const body = exportAccountsBodySchema.parse(request.body); + + const result = await accountService.exportAccounts(body.ids, body.mode); + + return reply.send( + createSuccessResponse( + result, + `Successfully exported ${result.exportedCount} 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 export accounts.')); + } + }); }; export default accountsRoutes; \ No newline at end of file diff --git a/src/core/AccountService.ts b/src/core/AccountService.ts index b0e6be2..4fc4c74 100644 --- a/src/core/AccountService.ts +++ b/src/core/AccountService.ts @@ -7,6 +7,7 @@ import { ListAccountsFilters, PaginationResult, StatsOverview, + ExportAccountsResponse, } from '../types/api'; import { config } from '../config'; @@ -272,6 +273,44 @@ export class AccountService { }; } + async exportAccounts( + ids: number[], + mode: 'text' | 'object' + ): Promise { + return await db.transaction(async (tx) => { + const accountsToExport = await tx + .select() + .from(accounts) + .where(inArray(accounts.id, ids)) + .for('update'); + + if (accountsToExport.length === 0) { + return { + exportedCount: 0, + data: mode === 'text' ? '' : [], + }; + } + + const exportedIds = accountsToExport.map((acc) => acc.id); + await tx + .update(accounts) + .set({ + status: 'exported', + updatedAt: new Date(), + }) + .where(inArray(accounts.id, exportedIds)); + + const data = mode === 'text' + ? accountsToExport.map((acc) => acc.data).join('\n') + : accountsToExport; + + return { + exportedCount: accountsToExport.length, + data, + }; + }); + } + async cleanupStaleLocks(): Promise { const thresholdTime = new Date( Date.now() - config.lockTimeout.minutes * 60 * 1000 diff --git a/src/types/api.ts b/src/types/api.ts index c87ce82..f8ca100 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -75,4 +75,14 @@ export interface StatsOverview { status: string; count: number; }[]; +} + +export interface ExportAccountsBody { + ids: number[]; + mode: 'text' | 'object'; +} + +export interface ExportAccountsResponse { + exportedCount: number; + data: string | Account[]; } \ No newline at end of file