重构为Monorepo:拆分xhs/xhh应用与core包并完成双服务部署改造
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { config } from '@social/core/config/index.js';
|
||||
import { logger } from '@social/core/utils/logger.js';
|
||||
import { DatabaseSync } from '@social/core/utils/sqlite.js';
|
||||
import type { CommentNotification } from './types.js';
|
||||
|
||||
export type NotificationTaskStatus =
|
||||
| 'new'
|
||||
| 'pending'
|
||||
| 'replied'
|
||||
| 'failed'
|
||||
| 'ignored';
|
||||
|
||||
interface NotificationRow {
|
||||
fingerprint: string;
|
||||
user_id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
content: string;
|
||||
type: string;
|
||||
time: string;
|
||||
feed_id: string;
|
||||
xsec_token: string;
|
||||
note_image: string;
|
||||
status: NotificationTaskStatus;
|
||||
first_seen_at: number;
|
||||
last_seen_at: number;
|
||||
retry_count: number;
|
||||
last_attempt_at: number | null;
|
||||
replied_at: number | null;
|
||||
reply_content: string | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationTask {
|
||||
fingerprint: string;
|
||||
notification: CommentNotification;
|
||||
status: NotificationTaskStatus;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
retryCount: number;
|
||||
lastAttemptAt?: string;
|
||||
repliedAt?: string;
|
||||
replyContent?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface NotificationUpsertResult {
|
||||
fetched: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
export interface NotificationKeysetCursor {
|
||||
firstSeenAt: number;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
const PLATFORM = 'xiaohongshu';
|
||||
const DB_FILENAME = 'automation.db';
|
||||
const log = logger.child({ module: 'xhs-notification-state' });
|
||||
|
||||
export class NotificationStateStore {
|
||||
private readonly db: InstanceType<typeof DatabaseSync>;
|
||||
private readonly dbPath: string;
|
||||
|
||||
constructor(baseDir = config.cookieDir, dbFilename = DB_FILENAME) {
|
||||
const dir = path.join(baseDir, PLATFORM);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
this.dbPath = path.join(dir, dbFilename);
|
||||
|
||||
this.db = new DatabaseSync(this.dbPath);
|
||||
this.db.exec('PRAGMA journal_mode = WAL;');
|
||||
this.db.exec('PRAGMA synchronous = NORMAL;');
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_tasks (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
nickname TEXT NOT NULL,
|
||||
avatar TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
time TEXT NOT NULL,
|
||||
feed_id TEXT NOT NULL,
|
||||
xsec_token TEXT NOT NULL,
|
||||
note_image TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
first_seen_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
replied_at INTEGER,
|
||||
reply_content TEXT,
|
||||
error_message TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_tasks_status_first_seen
|
||||
ON notification_tasks(status, first_seen_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_tasks_user_content_status
|
||||
ON notification_tasks(user_id, content, status);
|
||||
`);
|
||||
|
||||
log.info({ dbPath: this.dbPath }, 'Notification state store initialized');
|
||||
}
|
||||
|
||||
buildFingerprint(notification: CommentNotification): string {
|
||||
const payload = [
|
||||
notification.feedId,
|
||||
notification.userId,
|
||||
notification.content.trim(),
|
||||
notification.time.trim(),
|
||||
notification.type.trim(),
|
||||
].join('|');
|
||||
|
||||
return crypto.createHash('sha256').update(payload).digest('hex');
|
||||
}
|
||||
|
||||
upsertNotifications(notifications: CommentNotification[]): NotificationUpsertResult {
|
||||
if (notifications.length === 0) {
|
||||
return { fetched: 0, inserted: 0, updated: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
||||
const selectStmt = this.db.prepare(
|
||||
'SELECT fingerprint FROM notification_tasks WHERE fingerprint = ?',
|
||||
);
|
||||
const insertStmt = this.db.prepare(`
|
||||
INSERT INTO notification_tasks (
|
||||
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?)
|
||||
`);
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE notification_tasks
|
||||
SET
|
||||
nickname = ?,
|
||||
avatar = ?,
|
||||
type = ?,
|
||||
time = ?,
|
||||
feed_id = ?,
|
||||
xsec_token = ?,
|
||||
note_image = ?,
|
||||
last_seen_at = ?
|
||||
WHERE fingerprint = ?
|
||||
`);
|
||||
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
for (const n of notifications) {
|
||||
const fp = this.buildFingerprint(n);
|
||||
const exists = selectStmt.get(fp) as { fingerprint: string } | undefined;
|
||||
|
||||
if (!exists) {
|
||||
insertStmt.run(
|
||||
fp,
|
||||
n.userId,
|
||||
n.nickname,
|
||||
n.avatar,
|
||||
n.content,
|
||||
n.type,
|
||||
n.time,
|
||||
n.feedId,
|
||||
n.xsecToken,
|
||||
n.noteImage,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
inserted++;
|
||||
} else {
|
||||
updateStmt.run(
|
||||
n.nickname,
|
||||
n.avatar,
|
||||
n.type,
|
||||
n.time,
|
||||
n.feedId,
|
||||
n.xsecToken,
|
||||
n.noteImage,
|
||||
now,
|
||||
fp,
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
fetched: notifications.length,
|
||||
inserted,
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
listByStatuses(
|
||||
statuses: NotificationTaskStatus[],
|
||||
maxCount: number,
|
||||
offset = 0,
|
||||
): NotificationTask[] {
|
||||
if (statuses.length === 0) return [];
|
||||
|
||||
const placeholders = statuses.map(() => '?').join(', ');
|
||||
const query = `
|
||||
SELECT
|
||||
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at,
|
||||
retry_count, last_attempt_at, replied_at, reply_content, error_message
|
||||
FROM notification_tasks
|
||||
WHERE status IN (${placeholders})
|
||||
ORDER BY first_seen_at ASC
|
||||
LIMIT ?
|
||||
OFFSET ?
|
||||
`;
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...statuses, maxCount, offset) as unknown as NotificationRow[];
|
||||
return rows.map((r) => this.rowToTask(r));
|
||||
}
|
||||
|
||||
listByStatusesKeyset(
|
||||
statuses: NotificationTaskStatus[],
|
||||
maxCount: number,
|
||||
cursor?: NotificationKeysetCursor,
|
||||
): { tasks: NotificationTask[]; hasMore: boolean; nextCursor?: NotificationKeysetCursor } {
|
||||
if (statuses.length === 0 || maxCount <= 0) {
|
||||
return { tasks: [], hasMore: false };
|
||||
}
|
||||
|
||||
const placeholders = statuses.map(() => '?').join(', ');
|
||||
const condition = cursor
|
||||
? `
|
||||
AND (
|
||||
first_seen_at > ?
|
||||
OR (first_seen_at = ? AND fingerprint > ?)
|
||||
)
|
||||
`
|
||||
: '';
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at,
|
||||
retry_count, last_attempt_at, replied_at, reply_content, error_message
|
||||
FROM notification_tasks
|
||||
WHERE status IN (${placeholders})
|
||||
${condition}
|
||||
ORDER BY first_seen_at ASC, fingerprint ASC
|
||||
LIMIT ?
|
||||
`;
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const limitWithSentinel = maxCount + 1;
|
||||
const rows = cursor
|
||||
? stmt.all(
|
||||
...statuses,
|
||||
cursor.firstSeenAt,
|
||||
cursor.firstSeenAt,
|
||||
cursor.fingerprint,
|
||||
limitWithSentinel,
|
||||
) as unknown as NotificationRow[]
|
||||
: stmt.all(...statuses, limitWithSentinel) as unknown as NotificationRow[];
|
||||
|
||||
const hasMore = rows.length > maxCount;
|
||||
const pageRows = hasMore ? rows.slice(0, maxCount) : rows;
|
||||
const tasks = pageRows.map((r) => this.rowToTask(r));
|
||||
|
||||
if (!hasMore || pageRows.length === 0) {
|
||||
return { tasks, hasMore };
|
||||
}
|
||||
|
||||
const last = pageRows[pageRows.length - 1]!;
|
||||
return {
|
||||
tasks,
|
||||
hasMore,
|
||||
nextCursor: {
|
||||
firstSeenAt: last.first_seen_at,
|
||||
fingerprint: last.fingerprint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
countByStatuses(statuses: NotificationTaskStatus[]): number {
|
||||
if (statuses.length === 0) return 0;
|
||||
|
||||
const placeholders = statuses.map(() => '?').join(', ');
|
||||
const query = `
|
||||
SELECT COUNT(1) AS count
|
||||
FROM notification_tasks
|
||||
WHERE status IN (${placeholders})
|
||||
`;
|
||||
const stmt = this.db.prepare(query);
|
||||
const row = stmt.get(...statuses) as { count?: number } | undefined;
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
getByFingerprint(fingerprint: string): NotificationTask | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at,
|
||||
retry_count, last_attempt_at, replied_at, reply_content, error_message
|
||||
FROM notification_tasks
|
||||
WHERE fingerprint = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = stmt.get(fingerprint) as NotificationRow | undefined;
|
||||
return row ? this.rowToTask(row) : null;
|
||||
}
|
||||
|
||||
findOpenFingerprint(userId: string, content: string): string | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT fingerprint
|
||||
FROM notification_tasks
|
||||
WHERE user_id = ?
|
||||
AND content = ?
|
||||
AND status IN ('new', 'failed', 'pending')
|
||||
ORDER BY first_seen_at ASC
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = stmt.get(userId, content) as { fingerprint: string } | undefined;
|
||||
return row?.fingerprint ?? null;
|
||||
}
|
||||
|
||||
markPending(fingerprint: string): void {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE notification_tasks
|
||||
SET status = 'pending', last_attempt_at = ?, error_message = NULL
|
||||
WHERE fingerprint = ?
|
||||
`);
|
||||
stmt.run(now, fingerprint);
|
||||
}
|
||||
|
||||
markReplied(fingerprint: string, replyContent: string): void {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE notification_tasks
|
||||
SET
|
||||
status = 'replied',
|
||||
replied_at = ?,
|
||||
last_attempt_at = ?,
|
||||
reply_content = ?,
|
||||
error_message = NULL
|
||||
WHERE fingerprint = ?
|
||||
`);
|
||||
stmt.run(now, now, replyContent, fingerprint);
|
||||
}
|
||||
|
||||
markFailed(fingerprint: string, errorMessage: string): void {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE notification_tasks
|
||||
SET
|
||||
status = 'failed',
|
||||
retry_count = retry_count + 1,
|
||||
last_attempt_at = ?,
|
||||
error_message = ?
|
||||
WHERE fingerprint = ?
|
||||
`);
|
||||
stmt.run(now, errorMessage, fingerprint);
|
||||
}
|
||||
|
||||
markIgnored(fingerprint: string, reason?: string): void {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE notification_tasks
|
||||
SET status = 'ignored', error_message = ?
|
||||
WHERE fingerprint = ?
|
||||
`);
|
||||
stmt.run(reason ?? 'Ignored by operator', fingerprint);
|
||||
}
|
||||
|
||||
setStatus(
|
||||
fingerprint: string,
|
||||
status: NotificationTaskStatus,
|
||||
note?: string,
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE notification_tasks
|
||||
SET
|
||||
status = ?,
|
||||
last_attempt_at = CASE WHEN ? IN ('pending', 'failed', 'replied') THEN ? ELSE last_attempt_at END,
|
||||
replied_at = CASE WHEN ? = 'replied' THEN ? ELSE replied_at END,
|
||||
error_message = CASE WHEN ? = 'failed' THEN COALESCE(?, 'Marked as failed') WHEN ? = 'ignored' THEN COALESCE(?, 'Ignored by operator') ELSE error_message END,
|
||||
reply_content = CASE WHEN ? = 'replied' THEN COALESCE(?, reply_content) ELSE reply_content END
|
||||
WHERE fingerprint = ?
|
||||
`);
|
||||
stmt.run(
|
||||
status,
|
||||
status,
|
||||
now,
|
||||
status,
|
||||
now,
|
||||
status,
|
||||
note ?? null,
|
||||
status,
|
||||
note ?? null,
|
||||
status,
|
||||
note ?? null,
|
||||
fingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
private rowToTask(row: NotificationRow): NotificationTask {
|
||||
return {
|
||||
fingerprint: row.fingerprint,
|
||||
notification: {
|
||||
userId: row.user_id,
|
||||
nickname: row.nickname,
|
||||
avatar: row.avatar,
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
time: row.time,
|
||||
feedId: row.feed_id,
|
||||
xsecToken: row.xsec_token,
|
||||
noteImage: row.note_image,
|
||||
},
|
||||
status: row.status,
|
||||
firstSeenAt: new Date(row.first_seen_at).toISOString(),
|
||||
lastSeenAt: new Date(row.last_seen_at).toISOString(),
|
||||
retryCount: row.retry_count,
|
||||
...(row.last_attempt_at ? { lastAttemptAt: new Date(row.last_attempt_at).toISOString() } : {}),
|
||||
...(row.replied_at ? { repliedAt: new Date(row.replied_at).toISOString() } : {}),
|
||||
...(row.reply_content ? { replyContent: row.reply_content } : {}),
|
||||
...(row.error_message ? { errorMessage: row.error_message } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let storeSingleton: NotificationStateStore | null = null;
|
||||
|
||||
export function getNotificationStateStore(): NotificationStateStore {
|
||||
if (!storeSingleton) {
|
||||
storeSingleton = new NotificationStateStore();
|
||||
}
|
||||
return storeSingleton;
|
||||
}
|
||||
Reference in New Issue
Block a user