133 lines
4.0 KiB
TypeScript
133 lines
4.0 KiB
TypeScript
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 promise(singleflight) */
|
||
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 API(false = 走 list 或共用 in-flight 已完成後的讀取) */
|
||
fetched: boolean;
|
||
/** 因 TTL/past-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);
|
||
}
|