import crypto from 'node:crypto'; import type { QueryResultRow } from 'pg'; import type { AppDatabase } from '../db.js'; export type SmsAuthScene = 'login' | 'bind_phone' | 'change_phone'; export type SmsAuthAction = 'send_code' | 'verify_code'; export type SmsDeliveryStatus = 'pending' | 'delivered' | 'failed' | 'unknown'; export type SmsAuthEventRecord = { id: string; phoneNumber: string; scene: SmsAuthScene; action: SmsAuthAction; success: boolean; ip: string | null; userAgent: string | null; provider: string | null; providerRequestId: string | null; providerBizId: string | null; providerOutId: string | null; deliveryStatus: SmsDeliveryStatus; deliveryReportRawJson: Record | null; deliveryReportedAt: string | null; createdAt: string; }; type SmsAuthEventRow = QueryResultRow & { id: string; phone_number: string; scene: SmsAuthScene; action: SmsAuthAction; success: boolean; ip: string | null; user_agent: string | null; provider: string | null; provider_request_id: string | null; provider_biz_id: string | null; provider_out_id: string | null; delivery_status: SmsDeliveryStatus; delivery_report_raw_json: Record | null; delivery_reported_at: string | null; created_at: string; total: number; }; function toSmsAuthEventRecord( row: SmsAuthEventRow | undefined, ): SmsAuthEventRecord | null { if (!row) { return null; } return { id: row.id, phoneNumber: row.phone_number, scene: row.scene, action: row.action, success: row.success, ip: row.ip, userAgent: row.user_agent, provider: row.provider, providerRequestId: row.provider_request_id, providerBizId: row.provider_biz_id, providerOutId: row.provider_out_id, deliveryStatus: row.delivery_status, deliveryReportRawJson: row.delivery_report_raw_json, deliveryReportedAt: row.delivery_reported_at, createdAt: row.created_at, }; } export class SmsAuthEventRepository { constructor(private readonly db: AppDatabase) {} async create(input: { phoneNumber: string; scene: SmsAuthScene; action: SmsAuthAction; success: boolean; ip: string | null; userAgent: string | null; provider?: string | null; providerRequestId?: string | null; providerBizId?: string | null; providerOutId?: string | null; deliveryStatus?: SmsDeliveryStatus; deliveryReportRawJson?: Record | null; deliveryReportedAt?: string | null; }) { const id = `smsev_${crypto.randomBytes(16).toString('hex')}`; const createdAt = new Date().toISOString(); const result = await this.db.query( `INSERT INTO sms_auth_events ( id, phone_number, scene, action, success, ip, user_agent, provider, provider_request_id, provider_biz_id, provider_out_id, delivery_status, delivery_report_raw_json, delivery_reported_at, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 ) RETURNING id, phone_number, scene, action, success, ip, user_agent, provider, provider_request_id, provider_biz_id, provider_out_id, delivery_status, delivery_report_raw_json, delivery_reported_at, created_at`, [ id, input.phoneNumber, input.scene, input.action, input.success, input.ip, input.userAgent, input.provider ?? null, input.providerRequestId ?? null, input.providerBizId ?? null, input.providerOutId ?? null, input.deliveryStatus ?? 'pending', input.deliveryReportRawJson ?? null, input.deliveryReportedAt ?? null, createdAt, ], ); return toSmsAuthEventRecord(result.rows[0]); } async countSinceByPhone(params: { phoneNumber: string; action: SmsAuthAction; success?: boolean; since: string; }) { const result = await this.db.query( `SELECT COUNT(*)::int AS total FROM sms_auth_events WHERE phone_number = $1 AND action = $2 AND ($3::boolean IS NULL OR success = $3) AND created_at >= $4`, [ params.phoneNumber, params.action, params.success ?? null, params.since, ], ); return result.rows[0]?.total ?? 0; } async countSinceByIp(params: { ip: string | null; action: SmsAuthAction; success?: boolean; since: string; }) { if (!params.ip) { return 0; } const result = await this.db.query( `SELECT COUNT(*)::int AS total FROM sms_auth_events WHERE ip = $1 AND action = $2 AND ($3::boolean IS NULL OR success = $3) AND created_at >= $4`, [ params.ip, params.action, params.success ?? null, params.since, ], ); return result.rows[0]?.total ?? 0; } async findLatestByProviderBizId(providerBizId: string) { const result = await this.db.query( `SELECT id, phone_number, scene, action, success, ip, user_agent, provider, provider_request_id, provider_biz_id, provider_out_id, delivery_status, delivery_report_raw_json, delivery_reported_at, created_at, 0::int AS total FROM sms_auth_events WHERE provider_biz_id = $1 ORDER BY created_at DESC LIMIT 1`, [providerBizId], ); return toSmsAuthEventRecord(result.rows[0]); } async findLatestByProviderOutId(providerOutId: string) { const result = await this.db.query( `SELECT id, phone_number, scene, action, success, ip, user_agent, provider, provider_request_id, provider_biz_id, provider_out_id, delivery_status, delivery_report_raw_json, delivery_reported_at, created_at, 0::int AS total FROM sms_auth_events WHERE provider_out_id = $1 ORDER BY created_at DESC LIMIT 1`, [providerOutId], ); return toSmsAuthEventRecord(result.rows[0]); } async updateDeliveryStatus(params: { id: string; deliveryStatus: SmsDeliveryStatus; deliveryReportRawJson: Record | null; deliveryReportedAt: string; }) { const result = await this.db.query( `UPDATE sms_auth_events SET delivery_status = $1, delivery_report_raw_json = $2, delivery_reported_at = $3 WHERE id = $4 RETURNING id, phone_number, scene, action, success, ip, user_agent, provider, provider_request_id, provider_biz_id, provider_out_id, delivery_status, delivery_report_raw_json, delivery_reported_at, created_at, 0::int AS total`, [ params.deliveryStatus, params.deliveryReportRawJson, params.deliveryReportedAt, params.id, ], ); return toSmsAuthEventRecord(result.rows[0]); } }