mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 06:20:54 +08:00
feat(server): keep a per-book reading-progress history
reading_progress keeps one row per book and last-writer-wins, which answers "where am I" and loses "where was I": a stale device, a mistapped 目录 entry or a clock running ahead overwrote the place for good (P0: 进度历史). Every valid progress write now also lands in reading_progress_history, including a write that lost the merge. A device reading on through one chapter updates its latest entry (same chapter, within 5% and 30 minutes) instead of adding rows; another chapter, a jump, another device or an out-of-order write is a new entry. The newest 50 per book are kept. GET /api/v1/sync/progress/:bookId/history lists them, newest first, for the signed-in account only. The table is new (CREATE TABLE IF NOT EXISTS), so the schema change is additive and older builds sharing the database ignore it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67954ba592
commit
842331684a
5 files changed
+123
No files matched your search
@@ -829,6 +829,14 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
→ `{ "progress": {...} | null }`
|
||||
|
||||
### `GET /sync/progress/:bookId/history`
|
||||
|
||||
`?limit=`(默认 30,最大 50)→ `{ "history": [ {...}, ... ] }`,字段同上,按 `updatedAt` 倒序。
|
||||
|
||||
阅读记录:每次有效的进度写入都会记下,**包括在「后写者胜」合并中输掉的那次**(例如离线设备的旧位置)。
|
||||
同一设备在同一章、半小时内、进度相差不到 5% 的连续写入合并成一条并向前移动;换章、跳转、换设备、乱序到达的写入各成一条。
|
||||
每本书保留最新 50 条,只返回当前账号自己的记录。
|
||||
|
||||
### `PUT /sync/progress/:bookId`
|
||||
|
||||
单个进度上报的便捷写法:
|
||||
|
||||
@@ -237,6 +237,25 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_progress_updated ON reading_progress(user_id, updated_at);
|
||||
|
||||
-- Where the reader has been in a book, so a jump can be undone (P0: 进度历史).
|
||||
-- reading_progress keeps one row and last-writer-wins, which is right for "where am
|
||||
-- I" and wrong for "where was I": a stale device, a mistapped 目录 entry or a device
|
||||
-- whose clock runs fast all overwrite the place for good. Every valid progress write
|
||||
-- lands here too — including one that lost the merge — and a device reading on
|
||||
-- through one chapter updates its latest entry instead of adding rows. A new table,
|
||||
-- so older builds that share this database simply do not see it.
|
||||
CREATE TABLE IF NOT EXISTS reading_progress_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
||||
locator TEXT NOT NULL DEFAULT '',
|
||||
percentage REAL NOT NULL DEFAULT 0,
|
||||
chapter_title TEXT NOT NULL DEFAULT '',
|
||||
device TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_progress_history ON reading_progress_history(user_id, book_id, updated_at);
|
||||
|
||||
-- Notes, highlights and bookmarks. deleted is a tombstone rather than a hard
|
||||
-- delete so that an offline client cannot resurrect a note it removed elsewhere.
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
|
||||
@@ -55,6 +55,13 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void
|
||||
return { progress: ctx.sync.progressFor(user.id, bookId) };
|
||||
});
|
||||
|
||||
app.get('/api/v1/sync/progress/:bookId/history', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request);
|
||||
const { bookId } = request.params as { bookId: string };
|
||||
const limit = Number((request.query as { limit?: string }).limit) || 30;
|
||||
return { history: ctx.sync.progressHistory(user.id, bookId, limit) };
|
||||
});
|
||||
|
||||
app.put('/api/v1/sync/progress/:bookId', { preHandler: auth }, async (request) => {
|
||||
const user = currentUser(request);
|
||||
const { bookId } = request.params as { bookId: string };
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { BookDto, ShelfService } from './shelf.ts';
|
||||
* offline for a week can push its local changes and receive only what changed
|
||||
* on the server since `since`.
|
||||
*/
|
||||
/** Progress history: entries kept per book, and what still counts as reading on in one place. */
|
||||
const HISTORY_KEEP = 50;
|
||||
const HISTORY_SESSION_MS = 30 * 60 * 1000;
|
||||
const HISTORY_JUMP = 0.05;
|
||||
|
||||
export interface ProgressRecord {
|
||||
bookId: string;
|
||||
/** Opaque locator: EPUB CFI, pagination index or PDF page. Client-defined. */
|
||||
@@ -123,6 +128,61 @@ export class SyncService {
|
||||
userId, record.bookId, record.locator ?? '', percentage,
|
||||
record.chapterTitle ?? '', record.device ?? '', record.updatedAt,
|
||||
);
|
||||
this.recordHistory(userId, record, percentage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a progress write to the book's history (schema: reading_progress_history).
|
||||
*
|
||||
* Reading on through one chapter on one device is one entry that moves forward,
|
||||
* not a row per page: the latest entry of the same device is updated when the
|
||||
* write is newer, in the same chapter, within 5% of it and within half an hour.
|
||||
* Anything else — another chapter, a jump, another device, a write that arrives
|
||||
* out of order — is a new entry, because those are exactly the places a reader may
|
||||
* want back. The newest 50 per book are kept.
|
||||
*/
|
||||
private recordHistory(userId: string, record: ProgressRecord, percentage: number): void {
|
||||
const device = record.device ?? '', chapter = record.chapterTitle ?? '';
|
||||
const latest = this.db.get<{ id: number; chapter_title: string; percentage: number; updated_at: number }>(
|
||||
`SELECT id, chapter_title, percentage, updated_at FROM reading_progress_history
|
||||
WHERE user_id = ? AND book_id = ? AND device = ? ORDER BY updated_at DESC, id DESC LIMIT 1`,
|
||||
userId, record.bookId, device,
|
||||
);
|
||||
const continues = latest
|
||||
&& record.updatedAt >= latest.updated_at
|
||||
&& record.updatedAt - latest.updated_at <= HISTORY_SESSION_MS
|
||||
&& latest.chapter_title === chapter
|
||||
&& Math.abs(latest.percentage - percentage) < HISTORY_JUMP;
|
||||
if (continues) {
|
||||
this.db.run(
|
||||
'UPDATE reading_progress_history SET locator = ?, percentage = ?, updated_at = ? WHERE id = ?',
|
||||
record.locator ?? '', percentage, record.updatedAt, latest.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.db.run(
|
||||
`INSERT INTO reading_progress_history (user_id, book_id, locator, percentage, chapter_title, device, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
userId, record.bookId, record.locator ?? '', percentage, chapter, device, record.updatedAt,
|
||||
);
|
||||
this.db.run(
|
||||
`DELETE FROM reading_progress_history WHERE user_id = ? AND book_id = ? AND id NOT IN (
|
||||
SELECT id FROM reading_progress_history WHERE user_id = ? AND book_id = ?
|
||||
ORDER BY updated_at DESC, id DESC LIMIT ?)`,
|
||||
userId, record.bookId, userId, record.bookId, HISTORY_KEEP,
|
||||
);
|
||||
}
|
||||
|
||||
/** The book's reading history, newest first. */
|
||||
progressHistory(userId: string, bookId: string, limit = 30): ProgressRecord[] {
|
||||
return this.db.all<{ locator: string; percentage: number; chapter_title: string; device: string; updated_at: number }>(
|
||||
`SELECT locator, percentage, chapter_title, device, updated_at FROM reading_progress_history
|
||||
WHERE user_id = ? AND book_id = ? ORDER BY updated_at DESC, id DESC LIMIT ?`,
|
||||
userId, bookId, Math.max(1, Math.min(HISTORY_KEEP, limit)),
|
||||
).map((row) => ({
|
||||
bookId, locator: row.locator, percentage: row.percentage,
|
||||
chapterTitle: row.chapter_title, device: row.device, updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
private upsertNote(userId: string, record: NoteRecord): void {
|
||||
|
||||
@@ -294,6 +294,35 @@ test('a stale offline device cannot roll back newer progress', async () => {
|
||||
assert.equal((view.json() as { progress: { locator: string } }).progress.locator, 'fresh');
|
||||
});
|
||||
|
||||
test('progress history keeps the places a write replaced, one entry per stretch of reading', async () => {
|
||||
const owner = await login('owner');
|
||||
const member = await login('family');
|
||||
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
||||
const push = (items: Array<{ locator: string; percentage: number; chapterTitle: string; device: string; updatedAt: number }>, token = owner.token) =>
|
||||
app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(token), payload: { progress: items.map(item => ({ bookId: book.id, ...item })) } });
|
||||
const minute = 60_000, base = 1_000_000_000_000;
|
||||
// Reading on through chapter 3 on the phone: one entry that moves forward.
|
||||
await push([{ locator: 'c3-a', percentage: 0.30, chapterTitle: '第三章', device: 'phone', updatedAt: base }]);
|
||||
await push([{ locator: 'c3-b', percentage: 0.31, chapterTitle: '第三章', device: 'phone', updatedAt: base + minute }]);
|
||||
// A mistapped 目录 entry jumps to chapter 40: a new entry, and chapter 3 stays restorable.
|
||||
await push([{ locator: 'c40', percentage: 0.90, chapterTitle: '第四十章', device: 'phone', updatedAt: base + 2 * minute }]);
|
||||
// A stale tablet loses the merge, but where it was is history too.
|
||||
await push([{ locator: 'c2', percentage: 0.20, chapterTitle: '第二章', device: 'tablet', updatedAt: base - minute }]);
|
||||
const read = async (token = owner.token) => (await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history`, headers: auth(token) })).json() as { history: Array<{ locator: string; device: string }> };
|
||||
// Newest first; earlier tests in this file left older entries for the same book behind.
|
||||
assert.deepEqual((await read()).history.slice(0, 3).map(item => [item.locator, item.device]), [['c40', 'phone'], ['c3-b', 'phone'], ['c2', 'tablet']]);
|
||||
const current = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
|
||||
assert.equal((current.json() as { progress: { locator: string } }).progress.locator, 'c40');
|
||||
// Another account sees none of it (only its own places).
|
||||
assert.ok((await read(member.token)).history.every(item => !['c40', 'c3-b', 'c2'].includes(item.locator)));
|
||||
// Kept bounded: the newest 50.
|
||||
await push(Array.from({ length: 60 }, (_, index) => ({ locator: `x${index}`, percentage: index / 60, chapterTitle: `第${index}章`, device: 'phone', updatedAt: base + (10 + index) * minute })));
|
||||
const all = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history?limit=100`, headers: auth(owner.token) });
|
||||
const entries = (all.json() as { history: Array<{ locator: string }> }).history;
|
||||
assert.equal(entries.length, 50);
|
||||
assert.equal(entries[0]!.locator, 'x59');
|
||||
});
|
||||
|
||||
test('deleting a note leaves a tombstone that a stale device cannot undo', async () => {
|
||||
const owner = await login('owner');
|
||||
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
|
||||
|
||||
Reference in new issue
Block a user