app · e7e0243

Dev index Recent changes / versions Changelog About TODO

feat(audience): 手動合併、別名與還原最近合併

e7e0243 · 2026-11-13 14:13 · app
AudienceRoutes.cjsDocs/AUDIENCE.mdScriptLib/audience/tracker.cjsTest/audience_routes.test.cjsTest/audience_tracker.test.cjsTest/audience_ui.cjsassets/audience.cssassets/audience.jsaudience.htmllang/en.jsonlang/zh-TW.jsontodo.md
commit e7e02431792fb5982993d8b2b8b306cd4ae9c67bAuthor: Nuclear0709 <agp05432@gmail.com>Date:   Fri Nov 13 14:13:44 2026 +0800     feat(audience): 手動合併、別名與還原最近合併        新增常客手動合併預覽與套用、audience_aliases 別名、最近一次合併 batch 還原;不做自動猜測,合併以場次 ID 聯集並保留目標顯示名稱。 diff --git a/AudienceRoutes.cjs b/AudienceRoutes.cjsindex e1def22..b50beab 100644--- a/AudienceRoutes.cjs+++ b/AudienceRoutes.cjs@@ -24,7 +24,10 @@ function serveLogin(res) { function createAudienceRoutes(tracker) {     return function serveAudience(req, res, authorized = false) {         const url = new URL(req.url, 'http://localhost');-        if (!['/audience', '/api/audience', '/api/audience/user'].includes(url.pathname)) return false;+        const paths = ['/audience', '/api/audience', '/api/audience/user',+            '/api/audience/merge/preview', '/api/audience/merge',+            '/api/audience/merge/latest', '/api/audience/merge/undo'];+        if (!paths.includes(url.pathname)) return false;         res.setHeader('Cache-Control', 'no-store');          if (url.pathname === '/audience') {@@ -37,17 +40,88 @@ function createAudienceRoutes(tracker) {         }          res.setHeader('Content-Type', 'application/json; charset=utf-8');-        if (req.method !== 'GET') {-            res.writeHead(405);-            res.end(JSON.stringify({ error: 'method' }));-            return true;-        }         if (!authorized) {             res.writeHead(401);             res.end(JSON.stringify({ error: 'auth' }));             return true;         } +        if (url.pathname === '/api/audience/merge/latest') {+            if (req.method !== 'GET') {+                res.writeHead(405);+                res.end(JSON.stringify({ error: 'method' }));+                return true;+            }+            try {+                res.writeHead(200);+                res.end(JSON.stringify({+                    ok: true,+                    latest: tracker.latestMerge(platformName(url.searchParams.get('platform') || ''))+                }));+            } catch (error) {+                res.writeHead(Number(error.status) || 400);+                res.end(JSON.stringify({ error: error.message }));+            }+            return true;+        }++        if (url.pathname === '/api/audience/merge/preview' || url.pathname === '/api/audience/merge'+            || url.pathname === '/api/audience/merge/undo') {+            const preview = url.pathname.endsWith('/preview');+            const undo = url.pathname.endsWith('/undo');+            if ((preview && req.method !== 'GET') || (!preview && req.method !== 'POST')) {+                res.writeHead(405);+                res.end(JSON.stringify({ error: 'method' }));+                return true;+            }+            const run = payload => {+                try {+                    const result = preview ? tracker.mergePreview(payload)+                        : undo ? tracker.undoLastMerge(payload) : tracker.merge(payload);+                    res.writeHead(200);+                    res.end(JSON.stringify(result));+                } catch (error) {+                    const status = Number(error.status) || 400;+                    res.writeHead(status);+                    res.end(JSON.stringify({ error: error.message }));+                }+            };+            if (preview) {+                run({+                    platform: platformName(url.searchParams.get('platform') || ''),+                    sourceKey: url.searchParams.get('source') || '',+                    targetKey: url.searchParams.get('target') || ''+                });+                return true;+            }+            let body = '';+            let tooLarge = false;+            req.on('data', chunk => {+                body += chunk;+                if (Buffer.byteLength(body) > 64 * 1024) tooLarge = true;+            });+            req.on('end', () => {+                if (tooLarge) {+                    res.writeHead(413);+                    res.end(JSON.stringify({ error: 'body too large' }));+                    return;+                }+                try {+                    run(JSON.parse(body));+                } catch {+                    res.writeHead(400);+                    res.end(JSON.stringify({ error: 'JSON 解析失敗' }));+                }+            });+            return true;+        }++        if (req.method !== 'GET') {+            res.writeHead(405);+            res.end(JSON.stringify({ error: 'method' }));+            return true;+        }+         try {             if (url.pathname === '/api/audience') {                 const result = tracker.list({diff --git a/Docs/AUDIENCE.md b/Docs/AUDIENCE.mdindex ffcced5..9533bf1 100644--- a/Docs/AUDIENCE.md+++ b/Docs/AUDIENCE.md@@ -11,13 +11,16 @@  - key 優先使用平台 `userId`;沒有時才用 `平台 + 正規化顯示名稱`。 - 顯示名稱會更新為最近一次看到的值;同一 key 的舊名稱不會另開新觀眾。-- 目前 socket payload 还没有統一的 `userId` 欄位;userscript 沒帶 ID 時會以名稱合併,這是已知限制。+- 目前 socket payload 還沒有統一的 `userId` 欄位;userscript 沒帶 ID 時會以名稱合併,這是已知限制。+- 名稱改變、或同一人一次帶 userId、一次只帶名稱時,仍可能被記成兩個 key。這種情況**只手動合併**,不做自動猜測。  ## 資料表  - `audience_users`:`platform`、`user_key`、`display_name`、`first_seen`、`last_seen`。 - `audience_attendance`:每個 `(platform, session, user_key)` 一列,存 `messages`、`gifts`、`joins`、`day`、`last_seen`。 - `audience_days`:`(platform, user_key, day)`,用來算活躍天數;沒有 traffic 場次時仍可記錄。+- `audience_aliases`:`(platform, source_key) → target_key`,保存手動合併後的舊身分;之後舊名稱的活動仍累計到 target。+- `audience_merge_batches`:每次合併的 batch、時間與受影響資料 snapshot;用於最近一次合併還原,保留 90 天。 - 不存原始訊息;只存聚合。預設保留 180 天,可用 tracker 的 `prune()` 清理。  ## 場次邊界@@ -26,6 +29,36 @@ - 沒有 traffic 場次時(例如只有 userscript)仍記 `audience_days` 與使用者最後出現,但不計出席/連續。 - 出席的定義是:該場至少有一則非系統互動(聊天、送禮、加入、點讚等)。只在線不算。 +## 手動合併++常客頁的觀眾明細有「合併資料」。目標選單以 `displayName · userKey · 分數` 顯示,選取後再顯示完整 `userKey`、場次與訊息數;顯示名稱相近不代表同一人。流程固定為:++1. 選擇同平台的目標觀眾。+2. 先看預覽:來源、目標、合併後統計、共同場次/來源新增場次與天數、別名關係。+3. 按「確認合併」才寫入。++合併只看場次 ID 與既有資料,**不看時間相似度**、不做自動猜測:++合併時:++- `first_seen` 取較早者、`last_seen` 取較晚者,顯示名稱保留目標。+- 出席場次取兩邊 session 的聯集,不是把兩個場次數直接相加;共同場次只算一次。+- 共同場次的訊息/送禮依單場上限相加,進房不重複計算。+- `audience_days` 取聯集。+- 來源 key 寫入 `audience_aliases` 並刪除來源 user 列;之後舊名稱仍會 resolve 到目標。+- 預覽使用 SQLite savepoint,結束後 rollback,不留資料。++只允許同平台合併;沒有自動合併、相似名稱猜測或背景遷移。確認前務必看預覽。++### 最近一次合併可還原++常客頁工具列會顯示「還原最近合併」,只針對該平台最近一次 batch:++- 合併後 target 或來源又有新活動時,拒絕還原,避免覆蓋新資料。+- 之後又合併到其他身分時,也拒絕還原。+- 還原會恢復來源與 target 的 preimage、別名與統計;之後舊名稱不再自動累計到 target。+- 只保留最近 90 天的合併 snapshot;不是任意歷史時間點還原。+ ## 分數(0–100)  最近 30 天,五個分量各自有上限,等級在讀取時計算,不存 XP:@@ -65,11 +98,15 @@ L0 是尚未達到常客門檻。關係(新朋友/朋友/老朋友/元  ## 頁面與 API -- `/audience`:常客列表、搜尋、平台/排序、分頁;點選觀眾可看分數組成、等級門檻與最近場次。+- `/audience`:常客列表、搜尋、平台/排序、分頁;點選觀眾可看分數組成、等級門檻、最近場次與「合併資料」。 - 列表以 tenure 色碼 avatar/關係 badge(新朋友/朋友/老朋友/元老)與等級 badge 區分關係;頁碼顯示「第 X / Y 頁」。 - 關係圖例可點擊多選篩選(例如只看新朋友,或新朋友+朋友);「全部」清除篩選。篩選套用在 API 與總頁數,不是只過濾目前頁。 - `GET /api/audience?platform=&q=&sort=&page=&tenures=`:列表;`tenures` 可逗號分隔 `newcomer,friend,oldFriend,veteran`。 - `GET /api/audience/user?platform=&user=`:單一觀眾的完整明細。+- `GET /api/audience/merge/preview?platform=&source=&target=`:合併預覽,不寫入。+- `POST /api/audience/merge`:`{platform, sourceKey, targetKey}`,套用手動合併。+- `GET /api/audience/merge/latest?platform=`:最近一次合併與是否可還原。+- `POST /api/audience/merge/undo`:`{platform, batchId}`,還原最近一次合併。 - 頁面與 API 都要求主服務登入;API 只回聚合,不回原始訊息。  ## 測試diff --git a/ScriptLib/audience/tracker.cjs b/ScriptLib/audience/tracker.cjsindex 07ea1b5..034e2d9 100644--- a/ScriptLib/audience/tracker.cjs+++ b/ScriptLib/audience/tracker.cjs@@ -1,5 +1,6 @@ const fs = require('node:fs'); const path = require('node:path');+const crypto = require('node:crypto'); const { DatabaseSync } = require('node:sqlite');  const DAY = 86400000;@@ -116,6 +117,18 @@ function createAudienceTracker({             platform TEXT NOT NULL, user_key TEXT NOT NULL, day TEXT NOT NULL,             PRIMARY KEY(platform,user_key,day)         );+        CREATE TABLE IF NOT EXISTS audience_aliases (+            platform TEXT NOT NULL, source_key TEXT NOT NULL, target_key TEXT NOT NULL,+            created_at INTEGER NOT NULL,+            PRIMARY KEY(platform,source_key)+        );+        CREATE TABLE IF NOT EXISTS audience_merge_batches (+            id TEXT PRIMARY KEY, platform TEXT NOT NULL, source_key TEXT NOT NULL,+            target_key TEXT NOT NULL, created_at INTEGER NOT NULL, undone_at INTEGER,+            snapshot TEXT NOT NULL+        );+        CREATE INDEX IF NOT EXISTS audience_merge_batches_platform+            ON audience_merge_batches(platform,created_at);         CREATE INDEX IF NOT EXISTS audience_attendance_user             ON audience_attendance(platform,user_key,last_seen);         CREATE INDEX IF NOT EXISTS audience_days_user@@ -139,6 +152,21 @@ function createAudienceTracker({     const summaryCache = new Map();     const sessionCache = new Map(); +    /** 合併後舊 key 仍是別名;解析到目前 canonical key,並防止循環。 */+    const resolveKey = (platform, key) => {+        let current = String(key || '');+        const seen = new Set();+        for (let index = 0; index < 32; index++) {+            const row = db.prepare(`SELECT target_key FROM audience_aliases+                WHERE platform=? AND source_key=?`).get(platform, current);+            if (!row) return current;+            if (seen.has(row.target_key)) return current;+            seen.add(row.target_key);+            current = row.target_key;+        }+        return current;+    };+     const recentFor = platform => {         const cached = sessionCache.get(platform);         if (cached && now() - cached.at < 5000) return cached.ids;@@ -165,6 +193,7 @@ function createAudienceTracker({      /** 彙總單一使用者最近 30 天的活動;分數與等級在讀取時計算。 */     const summary = (platform, key) => {+        key = resolveKey(platform, key);         const user = db.prepare('SELECT * FROM audience_users WHERE platform=? AND user_key=?').get(platform, key);         if (!user) return null;         const cutoff = now() - WINDOW_DAYS * DAY;@@ -261,6 +290,13 @@ function createAudienceTracker({         const platform = platformName(entry?.platform);         const user = identity(entry);         if (!platform || !user) return null;+        const originalKey = user.key;+        const key = resolveKey(platform, originalKey);+        const aliased = key !== originalKey;+        const existing = aliased+            ? db.prepare('SELECT display_name FROM audience_users WHERE platform=? AND user_key=?').get(platform, key)+            : null;+        const displayName = aliased && existing?.display_name ? existing.display_name : user.name;         const category = String(entry.category || '');         if (IGNORED_CATEGORIES.has(category)) return null;         const time = Number(entry.t) || now();@@ -278,28 +314,28 @@ function createAudienceTracker({             db.prepare(`INSERT INTO audience_users(platform,user_key,display_name,first_seen,last_seen)                 VALUES(?,?,?,?,?) ON CONFLICT(platform,user_key) DO UPDATE SET                 display_name=excluded.display_name,last_seen=excluded.last_seen`)-                .run(platform, user.key, user.name, time, time);+                .run(platform, key, displayName, time, time);             db.prepare('INSERT OR IGNORE INTO audience_days(platform,user_key,day) VALUES(?,?,?)')-                .run(platform, user.key, day);+                .run(platform, key, day);             if (session !== null) {                 const current = db.prepare(`SELECT messages,gifts,joins FROM audience_attendance-                    WHERE platform=? AND session=? AND user_key=?`).get(platform, session, user.key);+                    WHERE platform=? AND session=? AND user_key=?`).get(platform, session, key);                 const messages = Math.min(MAX_MESSAGES_PER_SESSION, (current?.messages || 0) +-                    (isMessage && allowMessage(platform, user.key, session, time) ? 1 : 0));+                    (isMessage && allowMessage(platform, key, session, time) ? 1 : 0));                 const gifts = Math.min(MAX_GIFTS_PER_SESSION, (current?.gifts || 0) + (isGift ? 1 : 0));                 const joins = Math.min(1, (current?.joins || 0) + (isJoin ? 1 : 0));                 db.prepare(`INSERT INTO audience_attendance(platform,session,user_key,day,messages,gifts,joins,last_seen)                     VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(platform,session,user_key) DO UPDATE SET                     day=excluded.day,messages=excluded.messages,gifts=excluded.gifts,                     joins=excluded.joins,last_seen=excluded.last_seen`)-                    .run(platform, session, user.key, day, messages, gifts, joins, time);+                    .run(platform, session, key, day, messages, gifts, joins, time);             }             db.exec('COMMIT');         } catch (error) {             db.exec('ROLLBACK');             throw error;         }-        const info = cachedSummary(platform, user.key);+        const info = cachedSummary(platform, key);         if (!info) return null;         return {             level: info.level,@@ -314,6 +350,299 @@ function createAudienceTracker({         };     }; +    const mergeError = (status, message) => {+        const error = new Error(message);+        error.status = status;+        throw error;+    };++    /** 生命週期累計,供合併預覽顯示「舊資料會繼承多少」。 */+    const lifetime = (platform, key) => {+        const user = db.prepare('SELECT first_seen,last_seen FROM audience_users WHERE platform=? AND user_key=?')+            .get(platform, key);+        const row = db.prepare(`SELECT COUNT(*) AS sessions, COALESCE(SUM(messages),0) AS messages,+            COALESCE(SUM(gifts),0) AS gifts, MIN(day) AS firstDay, MAX(day) AS lastDay+            FROM audience_attendance WHERE platform=? AND user_key=?`).get(platform, key);+        return {+            firstSeen: user?.first_seen ?? null,+            lastSeen: user?.last_seen ?? null,+            sessions: Number(row?.sessions) || 0,+            messages: Number(row?.messages) || 0,+            gifts: Number(row?.gifts) || 0,+            firstDay: row?.firstDay || null,+            lastDay: row?.lastDay || null+        };+    };++    const captureMergeSnapshot = (platform, source, target) => ({+        users: db.prepare(`SELECT * FROM audience_users+            WHERE platform=? AND user_key IN (?,?)`).all(platform, source, target),+        attendance: db.prepare(`SELECT * FROM audience_attendance+            WHERE platform=? AND user_key IN (?,?)`).all(platform, source, target),+        days: db.prepare(`SELECT * FROM audience_days+            WHERE platform=? AND user_key IN (?,?)`).all(platform, source, target),+        aliases: db.prepare(`SELECT * FROM audience_aliases+            WHERE platform=? AND (source_key IN (?,?) OR target_key IN (?,?))`)+            .all(platform, source, target, source, target)+    });++    const lifetimeFromSnapshot = (snapshot, key) => {+        const user = snapshot.users.find(row => row.user_key === key);+        const rows = snapshot.attendance.filter(row => row.user_key === key);+        return {+            firstSeen: user?.first_seen ?? null,+            lastSeen: user?.last_seen ?? null,+            sessions: rows.length,+            messages: rows.reduce((total, row) => total + (Number(row.messages) || 0), 0),+            gifts: rows.reduce((total, row) => total + (Number(row.gifts) || 0), 0)+        };+    };++    const latestMergeRow = platform => {+        const query = platform+            ? `SELECT * FROM audience_merge_batches WHERE platform=? AND undone_at IS NULL+                ORDER BY created_at DESC, rowid DESC LIMIT 1`+            : `SELECT * FROM audience_merge_batches WHERE undone_at IS NULL+                ORDER BY created_at DESC, rowid DESC LIMIT 1`;+        return platform ? db.prepare(query).get(platform) : db.prepare(query).get();+    };++    const undoBlocker = row => {+        if (!row) return '沒有可還原的合併';+        if (row.undone_at) return '這次合併已經還原';+        const platform = row.platform;+        const source = row.source_key;+        const target = row.target_key;+        if (resolveKey(platform, target) !== target) return '目標之後又被合併到其他身分';+        if (resolveKey(platform, source) !== target) return '舊身分的別名關係已改變';+        if (db.prepare('SELECT 1 FROM audience_users WHERE platform=? AND user_key=?').get(platform, source)) {+            return '舊身分已重新出現,不能直接還原';+        }+        const targetUser = db.prepare(`SELECT last_seen FROM audience_users+            WHERE platform=? AND user_key=?`).get(platform, target);+        if (!targetUser) return '目標身分已不存在';+        if (Number(targetUser.last_seen) > Number(row.created_at)) return '合併後已有新活動';+        return '';+    };++    const mergeBatchInfo = row => {+        let snapshot;+        try { snapshot = JSON.parse(row.snapshot); } catch { snapshot = { users: [], attendance: [], days: [], aliases: [] }; }+        const sourceUser = snapshot.users.find(user => user.user_key === row.source_key);+        const targetUser = snapshot.users.find(user => user.user_key === row.target_key);+        const reason = undoBlocker(row);+        return {+            id: row.id,+            platform: row.platform,+            createdAt: row.created_at,+            undoneAt: row.undone_at,+            source: {+                key: row.source_key,+                displayName: sourceUser?.display_name || row.source_key,+                lifetime: lifetimeFromSnapshot(snapshot, row.source_key)+            },+            target: {+                key: row.target_key,+                displayName: targetUser?.display_name || row.target_key,+                lifetime: lifetimeFromSnapshot(snapshot, row.target_key)+            },+            undoable: !reason,+            reason+        };+    };++    const latestMerge = (platform = '') => {+        const normalized = platformName(platform);+        const row = latestMergeRow(normalized);+        return row ? mergeBatchInfo(row) : null;+    };++    const undoLastMerge = ({ platform = '', batchId = '' } = {}) => {+        const normalized = platformName(platform);+        const row = latestMergeRow(normalized);+        if (!row) mergeError(404, '沒有可還原的合併');+        if (batchId && batchId !== row.id) mergeError(409, '只能還原最近一次合併');+        const reason = undoBlocker(row);+        if (reason) mergeError(409, reason);+        const snapshot = JSON.parse(row.snapshot);+        db.exec('SAVEPOINT audience_restore');+        try {+            db.prepare(`DELETE FROM audience_attendance+                WHERE platform=? AND user_key IN (?,?)`).run(row.platform, row.source_key, row.target_key);+            db.prepare(`DELETE FROM audience_days+                WHERE platform=? AND user_key IN (?,?)`).run(row.platform, row.source_key, row.target_key);+            db.prepare(`DELETE FROM audience_users+                WHERE platform=? AND user_key IN (?,?)`).run(row.platform, row.source_key, row.target_key);+            db.prepare(`DELETE FROM audience_aliases+                WHERE platform=? AND (source_key IN (?,?) OR target_key IN (?,?))`)+                .run(row.platform, row.source_key, row.target_key, row.source_key, row.target_key);++            const insertUser = db.prepare(`INSERT INTO audience_users+                (platform,user_key,display_name,first_seen,last_seen) VALUES(?,?,?,?,?)`);+            for (const user of snapshot.users) {+                insertUser.run(user.platform, user.user_key, user.display_name, user.first_seen, user.last_seen);+            }+            const insertAttendance = db.prepare(`INSERT INTO audience_attendance+                (platform,session,user_key,day,messages,gifts,joins,last_seen) VALUES(?,?,?,?,?,?,?,?)`);+            for (const item of snapshot.attendance) {+                insertAttendance.run(item.platform, item.session, item.user_key, item.day,+                    item.messages, item.gifts, item.joins, item.last_seen);+            }+            const insertDay = db.prepare(`INSERT INTO audience_days(platform,user_key,day) VALUES(?,?,?)`);+            for (const item of snapshot.days) insertDay.run(item.platform, item.user_key, item.day);+            const insertAlias = db.prepare(`INSERT INTO audience_aliases+                (platform,source_key,target_key,created_at) VALUES(?,?,?,?)`);+            for (const item of snapshot.aliases) {+                insertAlias.run(item.platform, item.source_key, item.target_key, item.created_at);+            }+            db.prepare('UPDATE audience_merge_batches SET undone_at=? WHERE id=?').run(now(), row.id);+            db.exec('RELEASE SAVEPOINT audience_restore');+        } catch (error) {+            try { db.exec('ROLLBACK TO SAVEPOINT audience_restore'); } catch { /* ignore */ }+            try { db.exec('RELEASE SAVEPOINT audience_restore'); } catch { /* ignore */ }+            throw error;+        } finally {+            summaryCache.clear();+            sessionCache.clear();+        }+        return {+            applied: true,+            batchId: row.id,+            platform: row.platform,+            source: { key: row.source_key, summary: summary(row.platform, row.source_key) },+            target: { key: row.target_key, summary: summary(row.platform, row.target_key) }+        };+    };++    /**+     * 手動合併兩個平台身分;預覽用 SAVEPOINT 執行後復原,不留下資料。+     * 合併後 source 會成為 alias,之後舊名稱仍會累計到 target。+     */+    const mergeOnce = (options = {}, apply = false) => {+        const platform = platformName(options.platform);+        const sourceInput = String(options.sourceKey || '').trim().toLowerCase();+        const targetInput = String(options.targetKey || '').trim().toLowerCase();+        if (!platform || !sourceInput || !targetInput) {+            mergeError(400, '需要 platform、sourceKey 與 targetKey');+        }+        const source = resolveKey(platform, sourceInput);+        const target = resolveKey(platform, targetInput);+        if (source === target) mergeError(409, '來源與目標是同一個身分');+        const sourceUser = db.prepare('SELECT * FROM audience_users WHERE platform=? AND user_key=?').get(platform, source);+        const targetUser = db.prepare('SELECT * FROM audience_users WHERE platform=? AND user_key=?').get(platform, target);+        if (!sourceUser) mergeError(404, '找不到來源觀眾');+        if (!targetUser) mergeError(404, '找不到目標觀眾');++        const createdAt = now();+        const batchId = 'mb_' + crypto.randomUUID();+        const snapshot = apply ? captureMergeSnapshot(platform, source, target) : null;+        const sourceBefore = summary(platform, source);+        const targetBefore = summary(platform, target);+        const sourceLifetime = lifetime(platform, source);+        const targetLifetime = lifetime(platform, target);+        const overlapSessions = db.prepare(`SELECT COUNT(*) AS n+            FROM audience_attendance s+            JOIN audience_attendance t ON t.platform=s.platform AND t.session=s.session+            WHERE s.platform=? AND s.user_key=? AND t.user_key=?`)+            .get(platform, source, target).n;+        const newSessions = Math.max(0, sourceLifetime.sessions - overlapSessions);+        const newDays = db.prepare(`SELECT COUNT(*) AS n FROM audience_days s+            WHERE s.platform=? AND s.user_key=? AND NOT EXISTS (+                SELECT 1 FROM audience_days t+                WHERE t.platform=s.platform AND t.user_key=? AND t.day=s.day+            )`).get(platform, source, target).n;++        db.exec('SAVEPOINT audience_merge');+        let merged;+        try {+            if (apply) {+                db.prepare(`INSERT INTO audience_merge_batches+                    (id,platform,source_key,target_key,created_at,snapshot) VALUES(?,?,?,?,?,?)`)+                    .run(batchId, platform, source, target, createdAt, JSON.stringify(snapshot));+            }+            // 原本指向 source 的舊別名一併改指向 target,縮短解析鏈。+            db.prepare(`UPDATE audience_aliases SET target_key=? WHERE platform=? AND target_key=?`)+                .run(target, platform, source);++            const rows = db.prepare(`SELECT session,messages,gifts,joins,day,last_seen+                FROM audience_attendance WHERE platform=? AND user_key=?`).all(platform, source);+            const updateTarget = db.prepare(`UPDATE audience_attendance+                SET messages=?,gifts=?,joins=?,day=?,last_seen=?+                WHERE platform=? AND session=? AND user_key=?`);+            const moveSource = db.prepare(`UPDATE audience_attendance SET user_key=?+                WHERE platform=? AND session=? AND user_key=?`);+            const deleteSource = db.prepare(`DELETE FROM audience_attendance+                WHERE platform=? AND session=? AND user_key=?`);+            for (const row of rows) {+                const existing = db.prepare(`SELECT messages,gifts,joins,day,last_seen+                    FROM audience_attendance WHERE platform=? AND session=? AND user_key=?`)+                    .get(platform, row.session, target);+                if (existing) {+                    updateTarget.run(+                        Math.min(MAX_MESSAGES_PER_SESSION, existing.messages + row.messages),+                        Math.min(MAX_GIFTS_PER_SESSION, existing.gifts + row.gifts),+                        Math.max(existing.joins, row.joins),+                        existing.day || row.day,+                        Math.max(existing.last_seen, row.last_seen),+                        platform, row.session, target+                    );+                    deleteSource.run(platform, row.session, source);+                } else {+                    moveSource.run(target, platform, row.session, source);+                }+            }++            db.prepare(`INSERT OR IGNORE INTO audience_days(platform,user_key,day)+                SELECT platform,?,day FROM audience_days WHERE platform=? AND user_key=?`)+                .run(target, platform, source);+            db.prepare('DELETE FROM audience_days WHERE platform=? AND user_key=?').run(platform, source);++            db.prepare(`INSERT INTO audience_users(platform,user_key,display_name,first_seen,last_seen)+                VALUES(?,?,?,?,?) ON CONFLICT(platform,user_key) DO UPDATE SET+                first_seen=MIN(audience_users.first_seen,excluded.first_seen),+                last_seen=MAX(audience_users.last_seen,excluded.last_seen)`)+                .run(platform, target, targetUser.display_name,+                    Math.min(sourceUser.first_seen, targetUser.first_seen),+                    Math.max(sourceUser.last_seen, targetUser.last_seen));+            db.prepare('DELETE FROM audience_users WHERE platform=? AND user_key=?').run(platform, source);+            db.prepare(`INSERT INTO audience_aliases(platform,source_key,target_key,created_at)+                VALUES(?,?,?,?) ON CONFLICT(platform,source_key) DO UPDATE SET+                target_key=excluded.target_key,created_at=excluded.created_at`)+                .run(platform, source, target, now());++            merged = summary(platform, target);+            if (!merged) throw new Error('合併後找不到目標觀眾');+            if (apply) db.exec('RELEASE SAVEPOINT audience_merge');+            else {+                db.exec('ROLLBACK TO SAVEPOINT audience_merge');+                db.exec('RELEASE SAVEPOINT audience_merge');+            }+        } catch (error) {+            try { db.exec('ROLLBACK TO SAVEPOINT audience_merge'); } catch { /* ignore */ }+            try { db.exec('RELEASE SAVEPOINT audience_merge'); } catch { /* ignore */ }+            throw error;+        } finally {+            summaryCache.clear();+            sessionCache.clear();+        }++        return {+            applied: apply,+            batchId: apply ? batchId : null,+            platform,+            source: { key: source, summary: sourceBefore, lifetime: sourceLifetime },+            target: { key: target, summary: targetBefore, lifetime: targetLifetime },+            merged,+            overlapSessions,+            newSessions,+            newDays,+            alias: { source, target }+        };+    };++    const mergePreview = options => mergeOnce(options, false);+    const merge = options => mergeOnce(options, true);+     const prune = (days = 180) => {         const cutoff = now() - days * DAY;         let changed = 0;@@ -322,6 +651,8 @@ function createAudienceTracker({             changed += db.prepare('DELETE FROM audience_attendance WHERE last_seen<?').run(cutoff).changes;             changed += db.prepare('DELETE FROM audience_days WHERE day<?').run(dayKey(cutoff)).changes;             changed += db.prepare('DELETE FROM audience_users WHERE last_seen<?').run(cutoff).changes;+            // 只保留最近 90 天的合併快照;舊快照不再提供還原,避免資料庫膨脹。+            db.prepare('DELETE FROM audience_merge_batches WHERE created_at<?').run(now() - 90 * DAY);             db.exec('COMMIT');         } catch (error) {             db.exec('ROLLBACK');@@ -330,7 +661,10 @@ function createAudienceTracker({         return changed;     }; -    return { observe, list, details, prune, close: () => db.close() };+    return {+        observe, list, details, mergePreview, merge, latestMerge, undoLastMerge,+        prune, close: () => db.close()+    }; }  module.exports = { createAudienceTracker, computeScore, levelFor, labelsFor, LEVELS, platformName };diff --git a/Test/audience_routes.test.cjs b/Test/audience_routes.test.cjsindex 7332206..b0efb1f 100644--- a/Test/audience_routes.test.cjs+++ b/Test/audience_routes.test.cjs@@ -13,21 +13,41 @@ function fixture() {         details(platform, user) {             calls.push(['details', { platform, user }]);             return user === 'alice' ? { platform, userKey: user, displayName: 'Alice' } : null;+        },+        mergePreview(value) {+            calls.push(['mergePreview', value]);+            return { applied: false, platform: value.platform };+        },+        merge(value) {+            calls.push(['merge', value]);+            return { applied: true, platform: value.platform };+        },+        latestMerge(platform) {+            calls.push(['latestMerge', platform]);+            return { id: 'mb_1', platform, undoable: true };+        },+        undoLastMerge(value) {+            calls.push(['undoLastMerge', value]);+            return { applied: true, batchId: value.batchId };         }     };     return { calls, serve: createAudienceRoutes(tracker) }; } -function request(serve, url, { method = 'GET', authorized = false } = {}) {+function request(serve, url, { method = 'GET', authorized = false, body = '' } = {}) {     return new Promise(resolve => {         const req = new EventEmitter();         Object.assign(req, { url, method, headers: {} });         const res = {             setHeader() {},             writeHead(status) { this.status = status; },-            end(body) { resolve({ status: this.status, body: body ? JSON.parse(body) : null }); }+            end(value) { resolve({ status: this.status, body: value ? JSON.parse(value) : null }); }         };         serve(req, res, authorized);+        setImmediate(() => {+            if (body) req.emit('data', Buffer.from(body));+            req.emit('end');+        });     }); } @@ -49,4 +69,29 @@ test('常客 API 需要登入,平台名稱會正規化並轉小寫查詢', asy      assert.equal((await request(f.serve, '/api/audience/user?platform=twitch&user=Bob', { authorized: true })).status, 404);     assert.equal((await request(f.serve, '/api/audience/user?platform=twitch', { authorized: true })).status, 404);++    assert.equal((await request(f.serve, '/api/audience/merge/preview?platform=tiktok&source=old&target=new')).status, 401);+    const preview = await request(f.serve,+        '/api/audience/merge/preview?platform=tiktok&source=old&target=new', { authorized: true });+    assert.equal(preview.status, 200);+    assert.deepEqual(f.calls.at(-1), ['mergePreview', { platform: 'TikTok', sourceKey: 'old', targetKey: 'new' }]);+    const applied = await request(f.serve, '/api/audience/merge', {+        method: 'POST', authorized: true,+        body: JSON.stringify({ platform: 'tiktok', sourceKey: 'old', targetKey: 'new' })+    });+    assert.equal(applied.status, 200);+    assert.equal(applied.body.applied, true);+    assert.deepEqual(f.calls.at(-1), ['merge', { platform: 'tiktok', sourceKey: 'old', targetKey: 'new' }]);+    assert.equal((await request(f.serve, '/api/audience/merge', { method: 'GET', authorized: true })).status, 405);++    const latest = await request(f.serve, '/api/audience/merge/latest?platform=tiktok', { authorized: true });+    assert.equal(latest.status, 200);+    assert.equal(latest.body.latest.id, 'mb_1');+    assert.deepEqual(f.calls.at(-1), ['latestMerge', 'TikTok']);+    const undo = await request(f.serve, '/api/audience/merge/undo', {+        method: 'POST', authorized: true, body: JSON.stringify({ platform: 'tiktok', batchId: 'mb_1' })+    });+    assert.equal(undo.status, 200);+    assert.equal(undo.body.applied, true);+    assert.deepEqual(f.calls.at(-1), ['undoLastMerge', { platform: 'tiktok', batchId: 'mb_1' }]); });diff --git a/Test/audience_tracker.test.cjs b/Test/audience_tracker.test.cjsindex 7523491..c0b8d7f 100644--- a/Test/audience_tracker.test.cjs+++ b/Test/audience_tracker.test.cjs@@ -101,6 +101,78 @@ test('訊息與送禮都有單場、每分鐘上限,指令不計訊息', t =>     assert.equal(afterCommand.activeStreams, 2); }); +test('手動合併預覽不寫入,套用後舊名稱成為別名並繼承歷史', t => {+    const f = fixture(t);+    f.tracker.observe(f.entry(0, 'chat', 'Old Name', { message: 'old' }));+    f.tracker.observe(f.entry(1, 'gift', 'Old Name', { message: 'rose' }));+    f.tracker.observe(f.entry(0, 'chat', 'New Name', { message: 'new' }));+    f.tracker.observe(f.entry(2, 'chat', 'New Name', { message: 'new2' }));++    const preview = f.tracker.mergePreview({+        platform: 'tiktok', sourceKey: 'old name', targetKey: 'new name'+    });+    assert.equal(preview.applied, false);+    assert.equal(preview.alias.source, 'old name');+    assert.equal(preview.alias.target, 'new name');+    assert.equal(preview.overlapSessions, 1);+    assert.equal(preview.newSessions, 1);+    assert.equal(preview.newDays, 1);+    assert.equal(preview.merged.activeStreams, 3);+    assert.equal(f.tracker.list({ platform: 'TikTok' }).total, 2);++    const applied = f.tracker.merge({+        platform: 'tiktok', sourceKey: 'old name', targetKey: 'new name'+    });+    assert.equal(applied.applied, true);+    assert.equal(f.tracker.list({ platform: 'TikTok' }).total, 1);+    assert.equal(f.tracker.details('TikTok', 'new name').activeStreams, 3);+    assert.equal(f.tracker.details('TikTok', 'old name').displayName, 'New Name');++    // 舊名稱之後仍然累計到 target,不會再長出第二個人。+    f.tracker.observe(f.entry(3, 'chat', 'Old Name', { message: 'after merge' }));+    assert.equal(f.tracker.list({ platform: 'TikTok' }).total, 1);+    assert.equal(f.tracker.details('TikTok', 'new name').activeStreams, 4);+    assert.equal(f.tracker.details('TikTok', 'new name').messages, 4);+});++test('最近一次合併可還原;合併後有新活動則拒絕', t => {+    const f = fixture(t);+    f.tracker.observe(f.entry(0, 'chat', 'Old Name', { message: 'old' }));+    f.tracker.observe(f.entry(1, 'chat', 'Old Name', { message: 'old2' }));+    f.tracker.observe(f.entry(0, 'chat', 'New Name', { message: 'new' }));++    const applied = f.tracker.merge({+        platform: 'tiktok', sourceKey: 'old name', targetKey: 'new name'+    });+    const latest = f.tracker.latestMerge('TikTok');+    assert.equal(latest.id, applied.batchId);+    assert.equal(latest.undoable, true);+    const restored = f.tracker.undoLastMerge({ platform: 'TikTok', batchId: latest.id });+    assert.equal(restored.applied, true);+    assert.equal(f.tracker.list({ platform: 'TikTok' }).total, 2);+    assert.equal(f.tracker.details('TikTok', 'old name').messages, 2);+    assert.equal(f.tracker.details('TikTok', 'new name').messages, 1);++    // 重新合併後,只要 target 有新活動,就不能用還原覆蓋掉。+    f.tracker.merge({ platform: 'TikTok', sourceKey: 'old name', targetKey: 'new name' });+    f.setNow(BASE + 20 * DAY);+    f.tracker.observe({ t: BASE + 20 * DAY, platform: 'tiktok', category: 'chat', user: 'New Name', message: 'later' });+    assert.throws(() => f.tracker.undoLastMerge({ platform: 'TikTok' }),+        error => error.status === 409 && /新活動/.test(error.message));+});++test('合併拒絕同身分、缺少來源與跨平台', t => {+    const f = fixture(t);+    f.tracker.observe(f.entry(0, 'chat', 'Alice', { message: 'a' }));+    f.tracker.observe(f.entry(0, 'chat', 'Bob', { message: 'b' }));+    assert.throws(() => f.tracker.mergePreview({ platform: 'TikTok', sourceKey: 'alice', targetKey: 'alice' }),+        error => error.status === 409);+    assert.throws(() => f.tracker.mergePreview({ platform: 'TikTok', sourceKey: 'missing', targetKey: 'bob' }),+        error => error.status === 404);+    assert.throws(() => f.tracker.mergePreview({ platform: 'Twitch', sourceKey: 'alice', targetKey: 'bob' }),+        error => error.status === 404);+});+ test('userId 優先於顯示名稱,列表可搜尋與分頁', t => {     const f = fixture(t);     f.tracker.observe(f.entry(0, 'chat', 'Alice', { userId: 'u1', message: 'hi' }));diff --git a/Test/audience_ui.cjs b/Test/audience_ui.cjsindex b3a985c..7c40330 100644--- a/Test/audience_ui.cjs+++ b/Test/audience_ui.cjs@@ -81,8 +81,28 @@ async function main() {         await page.setViewport({ width: 390, height: 844 });         assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);         await page.screenshot({ path: path.join(__dirname, '../output/playwright/audience-mobile.png'), fullPage: true });++        await page.setViewport({ width: 1100, height: 950 });+        await page.click('#audience-merge-open');+        await page.waitForFunction(() => document.querySelector('#audience-merge-dialog').open);+        await page.waitForFunction(() => [...document.querySelectorAll('#audience-merge-target option')]+            .some(option => option.value === 'bob'));+        await page.select('#audience-merge-target', 'bob');+        await page.waitForFunction(() => document.querySelector('#audience-merge-target-meta').textContent.includes('bob'));+        await page.click('#audience-merge-preview-button');+        await page.waitForFunction(() => document.querySelectorAll('.audience-merge-column').length === 3);+        assert.match(await page.$eval('#audience-merge-preview', el => el.textContent), /1/);+        await page.click('#audience-merge-apply');+        await page.waitForFunction(() => !document.querySelector('#audience-merge-dialog').open);+        await page.waitForFunction(() => document.querySelectorAll('#audience-rows tr').length === 1);+        assert.equal(await page.$eval('#audience-detail-name', el => el.textContent), 'Bob');+        await page.waitForFunction(() => !document.querySelector('#audience-undo').hidden);+        page.on('dialog', dialog => dialog.accept());+        await page.click('#audience-undo');+        await page.waitForFunction(() => document.querySelectorAll('#audience-rows tr').length === 2);+        assert.equal(await page.$eval('#audience-undo', el => el.hidden), true);         assert.deepEqual(errors, []);-        console.log('Audience UI passed: list, detail breakdown, levels, recent sessions, 390px overflow check; screenshots in output/playwright.');+        console.log('Audience UI passed: list, detail, merge preview/apply/undo, 390px overflow check; screenshots in output/playwright.');     } finally {         await browser?.close();         tracker.close();diff --git a/assets/audience.css b/assets/audience.cssindex d21ffcd..f6530a5 100644--- a/assets/audience.css+++ b/assets/audience.css@@ -163,6 +163,142 @@     gap: 12px; } +.audience-detail-actions {+    display: flex;+    align-items: center;+    gap: 10px;+    flex-wrap: wrap;+}++.audience-merge-open,+.audience-undo {+    padding: 6px 10px;+    border: 1px solid #475569;+    border-radius: 8px;+    background: #1e293b;+    color: #e2e8f0;+    font-size: .8rem;+    cursor: pointer;+}++.audience-undo:disabled {+    opacity: .55;+    cursor: not-allowed;+}++.audience-undo-status {+    color: #94a3b8;+    font-size: .75rem;+}++.audience-merge-open:hover { background: #334155; }++.audience-merge-dialog {+    width: min(860px, calc(100vw - 32px));+    max-height: calc(100vh - 48px);+    margin: auto;+    padding: 18px;+    border: 1px solid #334155;+    border-radius: 12px;+    background: #0f172a;+    color: #e2e8f0;+}++.audience-merge-dialog::backdrop { background: rgba(0, 0, 0, .6); }++.audience-merge-dialog h2 {+    margin-bottom: 8px;+    font-size: 1.15rem;+    font-weight: 700;+    color: #bfdbfe;+}++.audience-merge-field {+    display: flex;+    flex-direction: column;+    gap: 4px;+    margin: 10px 0;+    font-size: .8rem;+    color: #94a3b8;+}++.audience-merge-field input,+.audience-merge-target {+    width: 100%;+    padding: 8px 10px;+    border: 1px solid #334155;+    border-radius: 8px;+    background: #111827;+    color: #e2e8f0;+}++.audience-merge-target { min-height: 150px; margin-bottom: 6px; }+.audience-merge-target option { padding: 4px 6px; }++.audience-merge-identity {+    margin: 0 0 8px;+    font-size: .75rem;+    color: #94a3b8;+}++.audience-merge-target-meta {+    min-height: 1.2em;+    margin: 0 0 12px;+    color: #bfdbfe;+    font-size: .78rem;+    word-break: break-all;+}++.audience-merge-preview {+    display: grid;+    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));+    gap: 10px;+    padding: 12px;+    border: 1px solid #334155;+    border-radius: 8px;+    background: #111827;+}++.audience-merge-column h3 {+    margin: 0 0 8px;+    font-size: .85rem;+    font-weight: 600;+    color: #bfdbfe;+}++.audience-merge-metric {+    display: flex;+    justify-content: space-between;+    gap: 8px;+    padding: 3px 0;+    font-size: .8rem;+    color: #94a3b8;+}++.audience-merge-metric strong { color: #e2e8f0; font-weight: 500; }+.audience-merge-warning { grid-column: 1 / -1; margin: 0; font-size: .78rem; color: #fcd34d; }++.audience-merge-actions {+    display: flex;+    justify-content: flex-end;+    flex-wrap: wrap;+    gap: 8px;+    margin-top: 14px;+}++.audience-merge-actions button {+    padding: 7px 12px;+    border: 1px solid #475569;+    border-radius: 8px;+    background: #1e293b;+    color: #e2e8f0;+    cursor: pointer;+}++.audience-merge-actions button:hover { background: #334155; }+.audience-merge-actions button:disabled { opacity: .5; cursor: not-allowed; }+.audience-merge-actions .is-danger { border-color: #b91c1c; background: #7f1d1d; }+ .audience-detail h2 {     font-size: 1.25rem;     font-weight: 700;diff --git a/assets/audience.js b/assets/audience.jsindex a864be4..d40aeb7 100644--- a/assets/audience.js+++ b/assets/audience.js@@ -7,7 +7,12 @@     await window.TTWI18n.ready;     const t = (key, params = {}, fallback) => window.TTWI18n.t('audience.' + key, params, fallback);     const el = id => document.getElementById(id);-    const state = { page: 1, pageSize: 50, total: 0, platform: '', q: '', sort: 'score', tenures: new Set(), list: null, detail: null, timer: null };+    const state = {+        page: 1, pageSize: 50, total: 0, platform: '', q: '', sort: 'score',+        tenures: new Set(), list: null, detail: null, timer: null,+        latestMerge: null,+        merge: { source: null, preview: null, timer: null, candidates: [] }+    };      const fmtDate = value => Number.isFinite(Number(value))         ? new Date(Number(value)).toLocaleString(window.TTWI18n.language, { dateStyle: 'medium', timeStyle: 'short' })@@ -135,6 +140,7 @@         state.list = await response.json();         state.total = state.list.total;         renderRows();+        void loadMergeStatus();     }      function renderDetails(data) {@@ -219,6 +225,266 @@         history.replaceState(null, '', '?platform=' + encodeURIComponent(platform) + '&user=' + encodeURIComponent(userKey));     } +    async function loadMergeStatus() {+        const button = el('audience-undo');+        const status = el('audience-undo-status');+        const params = new URLSearchParams({ platform: state.platform || '' });+        try {+            const response = await fetch('/api/audience/merge/latest?' + params, { cache: 'no-store' });+            if (response.status === 401) return;+            const data = await response.json().catch(() => ({}));+            const latest = data.ok ? data.latest : null;+            state.latestMerge = latest;+            if (!latest) {+                button.hidden = true;+                status.textContent = '';+                return;+            }+            button.hidden = false;+            button.disabled = !latest.undoable;+            button.title = latest.undoable+                ? t('undo.ready', {+                    source: latest.source?.displayName || latest.source?.key || '',+                    target: latest.target?.displayName || latest.target?.key || ''+                }, '')+                : latest.reason || '';+            status.textContent = latest.undoable+                ? t('undo.available', { target: latest.target?.displayName || latest.target?.key || '' }, '')+                : latest.reason || '';+            status.style.color = latest.undoable ? '' : '#fca5a5';+        } catch {+            button.hidden = true;+            status.textContent = '';+        }+    }++    async function undoMerge() {+        const latest = state.latestMerge;+        if (!latest?.undoable) return;+        const message = t('undo.confirm', {+            source: latest.source?.displayName || latest.source?.key || '',+            target: latest.target?.displayName || latest.target?.key || ''+        }, '');+        if (!window.confirm(message)) return;+        const response = await fetch('/api/audience/merge/undo', {+            method: 'POST',+            headers: { 'Content-Type': 'application/json' },+            body: JSON.stringify({ platform: latest.platform, batchId: latest.id })+        });+        const data = await response.json().catch(() => ({}));+        if (response.status === 401) { location.href = '/'; return; }+        if (!response.ok) {+            el('audience-undo-status').textContent = data.error || t('undo.error', {}, '還原失敗。');+            el('audience-undo-status').style.color = '#fca5a5';+            return;+        }+        await loadList();+        if (state.detail) await loadDetails(state.detail.platform, state.detail.userKey);+    }++    function updateMergeTargetMeta() {+        const selected = el('audience-merge-target').value;+        const user = (state.merge.candidates || []).find(item => item.userKey === selected);+        const node = el('audience-merge-target-meta');+        if (!user) {+            node.textContent = '';+            return;+        }+        node.textContent = t('merge.targetMeta', {+            name: user.displayName,+            key: user.userKey,+            score: user.score,+            sessions: user.activeStreams,+            messages: user.messages+        }, `userKey: ${user.userKey}`);+    }++    function mergeStatus(message, error = false) {+        const node = el('audience-merge-status');+        node.textContent = message;+        node.style.color = error ? '#fca5a5' : '';+    }++    function mergeSourceText() {+        if (!state.merge.source) return '';+        return t('merge.source', {+            name: state.merge.source.displayName, key: state.merge.source.userKey+        }, state.merge.source.displayName);+    }++    function mergeSummaryColumn(title, summary, lifetime) {+        const column = document.createElement('div');+        column.className = 'audience-merge-column';+        const heading = document.createElement('h3');+        heading.textContent = title;+        column.append(heading);+        for (const [label, value] of [+            [t('merge.name', {}, '名稱'), summary?.displayName || '—'],+            [t('merge.sessions', {}, '出席場次'), summary?.activeStreams ?? '—'],+            [t('merge.messages', {}, '有效訊息'), summary?.messages ?? '—'],+            [t('merge.gifts', {}, '送禮'), summary?.gifts ?? '—'],+            [t('merge.firstSeen', {}, '首次出現'), fmtDate(lifetime?.firstSeen)],+            [t('merge.lastSeen', {}, '最後出現'), fmtDate(lifetime?.lastSeen)]+        ]) {+            const row = document.createElement('div');+            row.className = 'audience-merge-metric';+            const key = document.createElement('span');+            key.textContent = label;+            const valueNode = document.createElement('strong');+            valueNode.textContent = String(value);+            row.append(key, valueNode);+            column.append(row);+        }+        return column;+    }++    function renderMergePreview(data) {+        const box = el('audience-merge-preview');+        box.replaceChildren(+            mergeSummaryColumn(t('merge.sourceColumn', {}, '來源'), data.source?.summary, data.source?.lifetime),+            mergeSummaryColumn(t('merge.targetColumn', {}, '目標'), data.target?.summary, data.target?.lifetime),+            mergeSummaryColumn(t('merge.mergedColumn', {}, '合併後'), data.merged, {+                firstSeen: data.merged?.firstSeen, lastSeen: data.merged?.lastSeen+            })+        );+        const calculation = document.createElement('p');+        calculation.className = 'audience-merge-warning';+        calculation.textContent = t('merge.calculation', {+            overlap: data.overlapSessions,+            newSessions: data.newSessions,+            newDays: data.newDays+        }, '');+        box.append(calculation);+        const alias = document.createElement('p');+        alias.className = 'audience-merge-warning';+        alias.textContent = t('merge.alias', {+            source: data.alias?.source || data.source?.key || '',+            target: data.alias?.target || data.target?.key || ''+        }, '');+        box.append(alias);+        box.hidden = false;+        el('audience-merge-apply').disabled = false;+    }++    async function loadMergeCandidates(q = '') {+        if (!state.merge.source) return;+        const select = el('audience-merge-target');+        const previous = select.value;+        const loading = document.createElement('option');+        loading.textContent = t('merge.loading', {}, '載入中…');+        loading.disabled = true;+        select.replaceChildren(loading);+        const params = new URLSearchParams({+            platform: state.merge.source.platform, q, sort: 'recent', pageSize: '100'+        });+        const response = await fetch('/api/audience?' + params, { cache: 'no-store' });+        if (response.status === 401) { location.href = '/'; return; }+        if (!response.ok) {+            mergeStatus(t('merge.loadError', {}, '無法載入可合併的觀眾。'), true);+            return;+        }+        const data = await response.json();+        const users = (data.users || []).filter(user => user.userKey !== state.merge.source.userKey);+        state.merge.candidates = users;+        const options = [];+        if (!users.length) {+            const empty = document.createElement('option');+            empty.value = '';+            empty.textContent = t('merge.noTargets', {}, '沒有其他可合併的觀眾。');+            empty.disabled = true;+            options.push(empty);+        }+        for (const user of users) {+            const option = document.createElement('option');+            option.value = user.userKey;+            option.textContent = `${user.displayName} · ${user.userKey} · ${user.score} 分`;+            options.push(option);+        }+        select.replaceChildren(...options);+        if (users.some(user => user.userKey === previous)) select.value = previous;+        el('audience-merge-preview').hidden = true;+        el('audience-merge-apply').disabled = true;+        state.merge.preview = null;+        updateMergeTargetMeta();+    }++    function openMergeDialog() {+        if (!state.detail) return;+        state.merge.source = {+            platform: state.detail.platform,+            userKey: state.detail.userKey,+            displayName: state.detail.displayName+        };+        state.merge.preview = null;+        el('audience-merge-source').textContent = mergeSourceText();+        el('audience-merge-search').value = '';+        el('audience-merge-preview').hidden = true;+        el('audience-merge-apply').disabled = true;+        mergeStatus('');+        const dialog = el('audience-merge-dialog');+        if (typeof dialog.showModal === 'function') dialog.showModal();+        else dialog.setAttribute('open', '');+        loadMergeCandidates();+    }++    function closeMergeDialog() {+        const dialog = el('audience-merge-dialog');+        if (typeof dialog.close === 'function') dialog.close();+        else dialog.removeAttribute('open');+        state.merge.source = null;+        state.merge.preview = null;+    }++    async function previewMerge() {+        if (!state.merge.source) return;+        const targetKey = el('audience-merge-target').value;+        if (!targetKey) {+            mergeStatus(t('merge.chooseTarget', {}, '請先選擇目標觀眾。'), true);+            return;+        }+        const params = new URLSearchParams({+            platform: state.merge.source.platform,+            source: state.merge.source.userKey,+            target: targetKey+        });+        const response = await fetch('/api/audience/merge/preview?' + params, { cache: 'no-store' });+        const data = await response.json().catch(() => ({}));+        if (response.status === 401) { location.href = '/'; return; }+        if (!response.ok) {+            mergeStatus(data.error || t('merge.previewError', {}, '預覽失敗。'), true);+            return;+        }+        state.merge.preview = data;+        renderMergePreview(data);+        mergeStatus(t('merge.previewReady', {}, '預覽完成;確認後才會寫入。'));+    }++    async function applyMerge() {+        if (!state.merge.source || !state.merge.preview) return;+        const targetKey = el('audience-merge-target').value;+        if (!targetKey) return;+        el('audience-merge-apply').disabled = true;+        const response = await fetch('/api/audience/merge', {+            method: 'POST',+            headers: { 'Content-Type': 'application/json' },+            body: JSON.stringify({+                platform: state.merge.source.platform,+                sourceKey: state.merge.source.userKey,+                targetKey+            })+        });+        const data = await response.json().catch(() => ({}));+        if (response.status === 401) { location.href = '/'; return; }+        if (!response.ok) {+            mergeStatus(data.error || t('merge.applyError', {}, '合併失敗。'), true);+            el('audience-merge-apply').disabled = false;+            return;+        }+        closeMergeDialog();+        await loadList();+        await loadDetails(state.detail.platform, targetKey);+    }+     el('audience-search').addEventListener('input', () => {         clearTimeout(state.timer);         state.timer = setTimeout(() => {@@ -265,10 +531,33 @@     el('audience-next').addEventListener('click', () => {         if (state.page * state.pageSize < state.total) { state.page++; loadList(); }     });+    el('audience-undo').addEventListener('click', undoMerge);+    el('audience-merge-open').addEventListener('click', openMergeDialog);+    el('audience-merge-cancel').addEventListener('click', closeMergeDialog);+    el('audience-merge-preview-button').addEventListener('click', previewMerge);+    el('audience-merge-apply').addEventListener('click', applyMerge);+    el('audience-merge-dialog').addEventListener('close', () => {+        state.merge.source = null;+        state.merge.preview = null;+    });+    el('audience-merge-target').addEventListener('change', () => {+        state.merge.preview = null;+        el('audience-merge-preview').hidden = true;+        el('audience-merge-apply').disabled = true;+        updateMergeTargetMeta();+        mergeStatus('');+    });+    el('audience-merge-search').addEventListener('input', () => {+        clearTimeout(state.merge.timer);+        state.merge.timer = setTimeout(() => loadMergeCandidates(el('audience-merge-search').value.trim()), 250);+    });     document.addEventListener('languagechange', () => {         el('audience-search').placeholder = t('search', {}, '搜尋觀眾…');+        el('audience-merge-source').textContent = mergeSourceText();+        updateMergeTargetMeta();         if (state.list) renderRows();         if (state.detail) renderDetails(state.detail);+        void loadMergeStatus();     });      el('audience-search').placeholder = t('search', {}, '搜尋觀眾…');diff --git a/audience.html b/audience.htmlindex 26914a0..628fb2a 100644--- a/audience.html+++ b/audience.html@@ -39,6 +39,8 @@         <option value="recent" data-i18n="audience.sort.recent">依最近出現</option>       </select>       <span id="audience-count" class="audience-count"></span>+      <button id="audience-undo" type="button" class="audience-undo" hidden data-i18n="audience.undo.button">還原最近合併</button>+      <span id="audience-undo-status" class="audience-undo-status"></span>     </div>      <div class="audience-legend" aria-label="關係圖例">@@ -79,7 +81,10 @@     <section id="audience-detail" class="audience-detail" hidden>       <header class="audience-detail-head">         <h2 id="audience-detail-name"></h2>-        <span id="audience-detail-level" class="audience-level"></span>+        <div class="audience-detail-actions">+          <span id="audience-detail-level" class="audience-level"></span>+          <button id="audience-merge-open" type="button" class="audience-merge-open" data-i18n="audience.merge.open">合併資料</button>+        </div>       </header>       <p id="audience-detail-meta" class="audience-note"></p>       <div id="audience-detail-stats" class="audience-cards"></div>@@ -134,6 +139,26 @@     </section>   </main> +  <dialog id="audience-merge-dialog" class="audience-merge-dialog" aria-labelledby="audience-merge-title">+    <h2 id="audience-merge-title" data-i18n="audience.merge.title">合併常客資料</h2>+    <p id="audience-merge-source" class="audience-note"></p>+    <p class="audience-note" data-i18n="audience.merge.help">把重複出現的同一個人合併到目標身分;舊名稱會保留為別名,之後的活動仍會累計到目標。這個動作不會自動發生。</p>+    <label class="audience-merge-field">+      <span data-i18n="audience.merge.search">搜尋目標觀眾</span>+      <input id="audience-merge-search" type="search" autocomplete="off" spellcheck="false">+    </label>+    <p class="audience-note audience-merge-identity" data-i18n="audience.merge.identityHelp">選項以 userKey 區分;顯示名稱相近不代表同一人,請用 userKey 與預覽統計確認。</p>+    <select id="audience-merge-target" class="audience-merge-target" size="7" aria-label="合併目標"></select>+    <p id="audience-merge-target-meta" class="audience-merge-target-meta"></p>+    <div id="audience-merge-preview" class="audience-merge-preview" hidden></div>+    <p id="audience-merge-status" class="audience-note" role="status"></p>+    <div class="audience-merge-actions">+      <button id="audience-merge-preview-button" type="button" data-i18n="audience.merge.preview">預覽合併</button>+      <button id="audience-merge-apply" type="button" class="is-danger" disabled data-i18n="audience.merge.apply">確認合併</button>+      <button id="audience-merge-cancel" type="button" data-i18n="audience.merge.cancel">取消</button>+    </div>+  </dialog>+   <script src="/assets/i18n.js"></script>   <script src="/assets/audience.js"></script> </body>diff --git a/lang/en.json b/lang/en.jsonindex fb9301c..afc3e96 100644--- a/lang/en.json+++ b/lang/en.json@@ -448,6 +448,40 @@   "audience.recent.col.joins": "Joins",   "audience.recent.col.lastSeen": "Last activity",   "audience.meta": "{platform} · {tenure} · first {first} · last {last}",+  "audience.merge.open": "Merge data",+  "audience.merge.title": "Merge viewer records",+  "audience.merge.help": "Merge two records for the same person into the target identity. The old key becomes an alias and future activity keeps counting toward the target. Merge works on session IDs and existing data only; it does not guess by time similarity and never happens automatically.",+  "audience.merge.search": "Search target viewer",+  "audience.merge.identityHelp": "Options are distinguished by userKey; similar display names do not mean the same person. Check userKey and the preview stats.",+  "audience.merge.targetMeta": "Selected: {name} · userKey: {key} · score {score} · {sessions} sessions · {messages} messages",+  "audience.merge.preview": "Preview merge",+  "audience.merge.apply": "Confirm merge",+  "audience.merge.cancel": "Cancel",+  "audience.merge.source": "Merge “{name}” (userKey: {key}) into:",+  "audience.merge.sourceColumn": "Source",+  "audience.merge.targetColumn": "Target",+  "audience.merge.mergedColumn": "Merged",+  "audience.merge.name": "Name",+  "audience.merge.sessions": "Sessions",+  "audience.merge.messages": "Counted messages",+  "audience.merge.gifts": "Gifts",+  "audience.merge.firstSeen": "First seen",+  "audience.merge.lastSeen": "Last seen",+  "audience.merge.loading": "Loading…",+  "audience.merge.noTargets": "No other viewer to merge into.",+  "audience.merge.chooseTarget": "Choose a target viewer first.",+  "audience.merge.previewReady": "Preview ready; nothing is written until you confirm.",+  "audience.merge.previewError": "Preview failed.",+  "audience.merge.applyError": "Merge failed.",+  "audience.merge.loadError": "Unable to load merge candidates.",+  "audience.merge.overlap": "{count} shared sessions; per-session caps apply and joins are not double-counted.",+  "audience.merge.calculation": "Merged by session ID: {overlap} shared, {newSessions} new sessions and {newDays} new days from the source. Shared-session messages and gifts are capped per session; this is not time-similarity matching.",+  "audience.merge.alias": "“{source}” will become an alias of “{target}”.",+  "audience.undo.button": "Undo last merge",+  "audience.undo.ready": "Last merge: {source} → {target}",+  "audience.undo.available": "Last merge can be undone ({target})",+  "audience.undo.confirm": "Undo the last merge?\n\n{source} → {target}\n\nOnly this merge is reverted; if there is new activity after the merge, it will be refused.",+  "audience.undo.error": "Undo failed.",   "runtime.title": "Runtime status",   "runtime.checking": "Checking process status…",   "runtime.loading": "Loading server status",diff --git a/lang/zh-TW.json b/lang/zh-TW.jsonindex 96d758e..b8cd23f 100644--- a/lang/zh-TW.json+++ b/lang/zh-TW.json@@ -448,6 +448,40 @@   "audience.recent.col.joins": "進房",   "audience.recent.col.lastSeen": "最後活動",   "audience.meta": "{platform} · {tenure} · 首次 {first} · 最後 {last}",+  "audience.merge.open": "合併資料",+  "audience.merge.title": "合併常客資料",+  "audience.merge.help": "把重複出現的同一個人合併到目標身分;舊名稱會保留為別名,之後的活動仍會累計到目標。合併只依場次 ID 與既有資料,不做時間相似度猜測,也不會自動發生。",+  "audience.merge.search": "搜尋目標觀眾",+  "audience.merge.identityHelp": "選項以 userKey 區分;顯示名稱相近不代表同一人,請用 userKey 與預覽統計確認。",+  "audience.merge.targetMeta": "已選:{name} · userKey:{key} · {score} 分 · {sessions} 場 · {messages} 則",+  "audience.merge.preview": "預覽合併",+  "audience.merge.apply": "確認合併",+  "audience.merge.cancel": "取消",+  "audience.merge.source": "將「{name}」(userKey:{key})合併到目標身分:",+  "audience.merge.sourceColumn": "來源",+  "audience.merge.targetColumn": "目標",+  "audience.merge.mergedColumn": "合併後",+  "audience.merge.name": "名稱",+  "audience.merge.sessions": "出席場次",+  "audience.merge.messages": "有效訊息",+  "audience.merge.gifts": "送禮",+  "audience.merge.firstSeen": "首次出現",+  "audience.merge.lastSeen": "最後出現",+  "audience.merge.loading": "載入中…",+  "audience.merge.noTargets": "沒有其他可合併的觀眾。",+  "audience.merge.chooseTarget": "請先選擇目標觀眾。",+  "audience.merge.previewReady": "預覽完成;確認後才會寫入。",+  "audience.merge.previewError": "預覽失敗。",+  "audience.merge.applyError": "合併失敗。",+  "audience.merge.loadError": "無法載入可合併的觀眾。",+  "audience.merge.overlap": "有 {count} 個共同場次;合併時會依單場上限加總,不會重複計算進房。",+  "audience.merge.calculation": "以場次 ID 聯集合併:共同 {overlap} 場、來源新增 {newSessions} 場/{newDays} 天。共同場次的訊息與送禮相加後受單場上限;不是用時間相似度判斷。",+  "audience.merge.alias": "「{source}」會成為「{target}」的別名。",+  "audience.undo.button": "還原最近合併",+  "audience.undo.ready": "最近合併:{source} → {target}",+  "audience.undo.available": "最近合併可還原({target})",+  "audience.undo.confirm": "還原最近一次合併?\n\n{source} → {target}\n\n只會還原這次合併;若合併後已有新活動,系統會拒絕。",+  "audience.undo.error": "還原失敗。",   "runtime.title": "運行狀態",   "runtime.checking": "正在確認程序狀態…",   "runtime.loading": "正在讀取主服務資料",diff --git a/todo.md b/todo.mdindex 769fd59..b060831 100644--- a/todo.md+++ b/todo.md@@ -29,6 +29,7 @@ - [x] 診斷頁 Socket 外部事件訂閱開關(subscribeExternalLogs、自動重連)。 - [x] 未啟用 YouTube 時不輸出假斷線;handleExit 冪等。 - [x] monitor 歷史 data URI materialize 與 MediaCache LRU/容量上限。+- [x] 常客手動合併、預覽、舊名稱別名與最近一次合併還原。 - [ ] 正式環境以實際 child 程序驗證 IPC 結果與倒數(目前 monitor UI 使用假 SSE/fake `/chat`)。  ## 表情映射後續(2026-11-06)