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:
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>;
|
||||
Reference in New Issue
Block a user