thread-master/apps/web/src/lib/ownPostsSyncGate.ts

133 lines
4.0 KiB
TypeScript
Raw Normal View History

2026-07-13 03:18:08 +00:00
import type { OwnPost } from "../domain/types";
import { KEYS } from "../data/mock/keys";
import { readJson, writeJson } from "./storage";
/** 本月成效:距離上次真 API sync 不足此間隔則不重打5 分鐘) */
export const OWN_POSTS_CURRENT_TTL_MS = 5 * 60 * 1000;
type AccountSyncMeta = {
/** 上次成功呼叫 sync API 的時間ms */
lastSyncMs: number;
/** 是否已為「過去月份缺資料」補抓過一次 */
pastOnceDone: boolean;
};
type MetaStore = Record<string, AccountSyncMeta>;
/** accountId → in-flight sync promisesingleflight */
const inflight = new Map<string, Promise<OwnPost[]>>();
function loadStore(): MetaStore {
return readJson(KEYS.ownPostsSyncMeta, {} as MetaStore);
}
function saveStore(store: MetaStore): void {
writeJson(KEYS.ownPostsSyncMeta, store);
}
export function getOwnPostsSyncMeta(accountId: string): AccountSyncMeta {
const row = loadStore()[accountId];
return {
lastSyncMs: row?.lastSyncMs ?? 0,
pastOnceDone: Boolean(row?.pastOnceDone),
};
}
function patchMeta(accountId: string, patch: Partial<AccountSyncMeta>): void {
const store = loadStore();
const prev = store[accountId] || { lastSyncMs: 0, pastOnceDone: false };
store[accountId] = { ...prev, ...patch };
saveStore(store);
}
export function isCurrentSyncFresh(accountId: string, now = Date.now()): boolean {
const { lastSyncMs } = getOwnPostsSyncMeta(accountId);
return lastSyncMs > 0 && now - lastSyncMs < OWN_POSTS_CURRENT_TTL_MS;
}
export function isPastOnceDone(accountId: string): boolean {
return getOwnPostsSyncMeta(accountId).pastOnceDone;
}
export type OwnPostsSyncReason =
/** 使用者手動同步:一定打 API */
| "manual"
/** 過去月份缺資料:整帳只補抓一次 */
| "past-once"
/** 本月定期刷新5 分鐘) */
| "current-ttl";
export type OwnPostsSyncResult = {
posts: OwnPost[];
/** 是否真的打了 sync APIfalse = 走 list 或共用 in-flight 已完成後的讀取) */
fetched: boolean;
/** 因 TTLpast-once 已完成而跳過 */
skipped: boolean;
reason: OwnPostsSyncReason;
};
type Ops = {
list: (accountId: string) => Promise<OwnPost[]>;
sync: (accountId: string) => Promise<OwnPost[]>;
};
/**
* accountId key singleflight sync
* - manual sync promise
* - past-once pastOnceDone list
* - current-ttl 5 sync list
*/
export async function syncOwnPostsGated(
accountId: string,
ops: Ops,
reason: OwnPostsSyncReason,
): Promise<OwnPostsSyncResult> {
if (!accountId) {
return { posts: [], fetched: false, skipped: true, reason };
}
const existing = inflight.get(accountId);
if (existing) {
const posts = await existing;
return { posts, fetched: true, skipped: false, reason };
}
if (reason === "past-once" && isPastOnceDone(accountId)) {
const posts = await ops.list(accountId);
return { posts, fetched: false, skipped: true, reason };
}
if (reason === "current-ttl" && isCurrentSyncFresh(accountId)) {
const posts = await ops.list(accountId);
return { posts, fetched: false, skipped: true, reason };
}
const promise = (async () => {
try {
const posts = await ops.sync(accountId);
const prev = getOwnPostsSyncMeta(accountId);
patchMeta(accountId, {
lastSyncMs: Date.now(),
// 手動或過去月份補抓 → 標記 past-once 完成TTL 刷新不改旗標
pastOnceDone:
prev.pastOnceDone || reason === "past-once" || reason === "manual",
});
return posts;
} finally {
inflight.delete(accountId);
}
})();
inflight.set(accountId, promise);
const posts = await promise;
return { posts, fetched: true, skipped: false, reason };
}
/** 過去月份(不含本月)是否仍全空——用來決定是否觸發 past-once */
export function needsPastMonthBackfill(
months: Array<{ key: string; posts: number }>,
currentMonthKey: string,
): boolean {
return months.some((m) => m.key !== currentMonthKey && m.posts <= 0);
}