feat: add Queue-based async server provisioning
- Add Cloudflare Queue for async server provisioning workflow - Implement VPS provider abstraction (Linode, Vultr) - Add provisioning API endpoints with API key authentication - Fix race condition in balance deduction (atomic query) - Remove root_password from Queue for security (fetch from DB) - Add IP assignment wait logic after server creation - Add rollback/refund on all failure cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
272
src/repositories/ProvisioningRepository.ts
Normal file
272
src/repositories/ProvisioningRepository.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Repository for provisioning-related database operations
|
||||
* Uses two databases:
|
||||
* - DB (cloud-instances-db): server specs, pricing, providers
|
||||
* - USER_DB (telegram-conversations): users, deposits, orders
|
||||
*/
|
||||
|
||||
import type { TelegramUser, UserDeposit, ServerOrder, PricingWithProvider } from '../types';
|
||||
|
||||
export class ProvisioningRepository {
|
||||
constructor(
|
||||
private db: D1Database, // cloud-instances-db
|
||||
private userDb: D1Database // telegram-conversations
|
||||
) {}
|
||||
|
||||
// ============================================
|
||||
// User Operations (telegram-conversations.users)
|
||||
// ============================================
|
||||
|
||||
async getUserByTelegramId(telegramId: string): Promise<TelegramUser | null> {
|
||||
const result = await this.userDb
|
||||
.prepare('SELECT * FROM users WHERE telegram_id = ?')
|
||||
.bind(telegramId)
|
||||
.first();
|
||||
|
||||
return result as unknown as TelegramUser | null;
|
||||
}
|
||||
|
||||
async getUserById(userId: number): Promise<TelegramUser | null> {
|
||||
const result = await this.userDb
|
||||
.prepare('SELECT * FROM users WHERE id = ?')
|
||||
.bind(userId)
|
||||
.first();
|
||||
|
||||
return result as unknown as TelegramUser | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Deposit Operations (telegram-conversations.user_deposits)
|
||||
// Balance is in KRW (원), INTEGER type
|
||||
// ============================================
|
||||
|
||||
async getUserDeposit(userId: number): Promise<UserDeposit | null> {
|
||||
const result = await this.userDb
|
||||
.prepare('SELECT * FROM user_deposits WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.first();
|
||||
|
||||
return result as unknown as UserDeposit | null;
|
||||
}
|
||||
|
||||
async getBalance(userId: number): Promise<number> {
|
||||
const deposit = await this.getUserDeposit(userId);
|
||||
return deposit?.balance ?? 0;
|
||||
}
|
||||
|
||||
async deductBalance(userId: number, amount: number): Promise<boolean> {
|
||||
// Atomic balance deduction - prevents race condition
|
||||
const result = await this.userDb
|
||||
.prepare(
|
||||
`UPDATE user_deposits
|
||||
SET balance = balance - ?, updated_at = datetime('now')
|
||||
WHERE user_id = ? AND balance >= ?
|
||||
RETURNING balance`
|
||||
)
|
||||
.bind(amount, userId, amount)
|
||||
.first();
|
||||
|
||||
// If no row returned, balance was insufficient
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async refundBalance(userId: number, amount: number): Promise<void> {
|
||||
await this.userDb
|
||||
.prepare(
|
||||
`UPDATE user_deposits
|
||||
SET balance = balance + ?, updated_at = datetime('now')
|
||||
WHERE user_id = ?`
|
||||
)
|
||||
.bind(amount, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Server Order Operations (telegram-conversations.server_orders)
|
||||
// ============================================
|
||||
|
||||
async createServerOrder(
|
||||
userId: number,
|
||||
specId: number,
|
||||
region: string,
|
||||
pricePaid: number,
|
||||
label: string | null,
|
||||
image: string | null
|
||||
): Promise<ServerOrder> {
|
||||
const result = await this.userDb
|
||||
.prepare(
|
||||
`INSERT INTO server_orders
|
||||
(user_id, spec_id, status, region, price_paid, label, image, billing_type)
|
||||
VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly')
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(userId, specId, region, pricePaid, label, image)
|
||||
.first();
|
||||
|
||||
return result as unknown as ServerOrder;
|
||||
}
|
||||
|
||||
async updateOrderStatus(
|
||||
orderId: number,
|
||||
status: ServerOrder['status'],
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
if (errorMessage) {
|
||||
await this.userDb
|
||||
.prepare(
|
||||
`UPDATE server_orders
|
||||
SET status = ?, error_message = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(status, errorMessage, orderId)
|
||||
.run();
|
||||
} else {
|
||||
await this.userDb
|
||||
.prepare(
|
||||
`UPDATE server_orders
|
||||
SET status = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(status, orderId)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrderRootPassword(orderId: number, rootPassword: string): Promise<void> {
|
||||
await this.userDb
|
||||
.prepare(
|
||||
`UPDATE server_orders
|
||||
SET root_password = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(rootPassword, orderId)
|
||||
.run();
|
||||
}
|
||||
|
||||
async updateOrderProviderInfo(
|
||||
orderId: number,
|
||||
providerInstanceId: string,
|
||||
ipAddress: string | null,
|
||||
rootPassword: string
|
||||
): Promise<void> {
|
||||
await this.userDb
|
||||
.prepare(
|
||||
`UPDATE server_orders
|
||||
SET provider_instance_id = ?,
|
||||
ip_address = ?,
|
||||
root_password = ?,
|
||||
status = 'active',
|
||||
provisioned_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(providerInstanceId, ipAddress, rootPassword, orderId)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getOrderById(orderId: number): Promise<ServerOrder | null> {
|
||||
const result = await this.userDb
|
||||
.prepare('SELECT * FROM server_orders WHERE id = ?')
|
||||
.bind(orderId)
|
||||
.first();
|
||||
|
||||
return result as unknown as ServerOrder | null;
|
||||
}
|
||||
|
||||
async getOrdersByUserId(userId: number, limit: number = 20): Promise<ServerOrder[]> {
|
||||
const result = await this.userDb
|
||||
.prepare(
|
||||
'SELECT * FROM server_orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'
|
||||
)
|
||||
.bind(userId, limit)
|
||||
.all();
|
||||
|
||||
return result.results as unknown as ServerOrder[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Pricing Lookup (cloud-instances-db)
|
||||
// Uses anvil_pricing, anvil_instances, anvil_regions
|
||||
// ============================================
|
||||
|
||||
async getPricingWithProvider(pricingId: number): Promise<PricingWithProvider | null> {
|
||||
const result = await this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
ap.id as pricing_id,
|
||||
0 as provider_id,
|
||||
'Anvil' as provider_name,
|
||||
'https://api.anvil.cloud' as api_base_url,
|
||||
ai.name as instance_id,
|
||||
ai.display_name as instance_name,
|
||||
ar.name as region_code,
|
||||
ar.display_name as region_name,
|
||||
ap.monthly_price,
|
||||
ai.vcpus as vcpu,
|
||||
CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb,
|
||||
ai.disk_gb as storage_gb
|
||||
FROM anvil_pricing ap
|
||||
JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id
|
||||
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
||||
WHERE ap.id = ? AND ai.active = 1 AND ar.active = 1`
|
||||
)
|
||||
.bind(pricingId)
|
||||
.first();
|
||||
|
||||
return result as unknown as PricingWithProvider | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Atomic Operations (balance deduction + order creation)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Deduct balance and create order atomically
|
||||
* Returns order ID on success, null on insufficient balance
|
||||
*/
|
||||
async createOrderWithPayment(
|
||||
userId: number,
|
||||
specId: number,
|
||||
region: string,
|
||||
priceKrw: number,
|
||||
label: string | null,
|
||||
image: string | null
|
||||
): Promise<{ orderId: number | null; error?: string }> {
|
||||
try {
|
||||
// Step 1: Check and deduct balance
|
||||
const deducted = await this.deductBalance(userId, priceKrw);
|
||||
if (!deducted) {
|
||||
return { orderId: null, error: 'INSUFFICIENT_BALANCE' };
|
||||
}
|
||||
|
||||
// Step 2: Create order
|
||||
const order = await this.createServerOrder(
|
||||
userId,
|
||||
specId,
|
||||
region,
|
||||
priceKrw,
|
||||
label,
|
||||
image
|
||||
);
|
||||
|
||||
return { orderId: order.id };
|
||||
} catch (error) {
|
||||
// Attempt to refund on error
|
||||
try {
|
||||
await this.refundBalance(userId, priceKrw);
|
||||
} catch {
|
||||
// Log but don't throw - manual intervention needed
|
||||
console.error('[ProvisioningRepository] Failed to refund on error:', error);
|
||||
}
|
||||
return { orderId: null, error: 'ORDER_CREATION_FAILED' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund balance and update order status on provisioning failure
|
||||
*/
|
||||
async rollbackOrder(orderId: number, userId: number, amount: number, errorMessage: string): Promise<void> {
|
||||
await this.refundBalance(userId, amount);
|
||||
await this.updateOrderStatus(orderId, 'failed', errorMessage);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user