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);
|
|||
|
|
}
|