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:
Your Name
2025-09-23 01:42:50 +08:00
commit 891ae27689
27 changed files with 4828 additions and 0 deletions

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

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

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

@@ -0,0 +1,4 @@
import { accounts } from '../db/schema';
import { InferSelectModel } from 'drizzle-orm';
export type Account = InferSelectModel<typeof accounts>;