303 lines
7.4 KiB
TypeScript
303 lines
7.4 KiB
TypeScript
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null;
|
|
deliveryReportedAt?: string | null;
|
|
}) {
|
|
const id = `smsev_${crypto.randomBytes(16).toString('hex')}`;
|
|
const createdAt = new Date().toISOString();
|
|
const result = await this.db.query<SmsAuthEventRow>(
|
|
`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<SmsAuthEventRow>(
|
|
`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<SmsAuthEventRow>(
|
|
`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<SmsAuthEventRow>(
|
|
`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<SmsAuthEventRow>(
|
|
`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<string, unknown> | null;
|
|
deliveryReportedAt: string;
|
|
}) {
|
|
const result = await this.db.query<SmsAuthEventRow>(
|
|
`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]);
|
|
}
|
|
}
|