mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-09 18:48:29 +09:00
Initial open source release
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
import { listGroups, listAllTags, tagGroup } from '@/lib/groups';
|
||||
import { classifyGroupHeuristic } from '@/lib/group-classifier';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface Suggestion {
|
||||
chatroom_id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
current_group_ids: number[];
|
||||
suggested_group_id: number | null;
|
||||
suggested_group_name: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const ApplySchema = z.object({
|
||||
picks: z.array(
|
||||
z.object({
|
||||
chatroom_id: z.string().min(1),
|
||||
group_id: z.number().int().positive(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const sessions = await wxSessions(500);
|
||||
const groupSessions = sessions.filter((s) => s.is_group);
|
||||
const groups = listGroups();
|
||||
const tags = listAllTags();
|
||||
const tagged = new Map<string, number[]>();
|
||||
for (const t of tags) {
|
||||
const arr = tagged.get(t.chatroom_id) ?? [];
|
||||
arr.push(t.group_id);
|
||||
tagged.set(t.chatroom_id, arr);
|
||||
}
|
||||
|
||||
const suggestions: Suggestion[] = groupSessions
|
||||
.filter((g) => !tagged.has(g.username))
|
||||
.slice(0, 200)
|
||||
.map((g) => {
|
||||
const guess = classifyGroupHeuristic(g.chat, g.summary, groups);
|
||||
return {
|
||||
chatroom_id: g.username,
|
||||
name: g.chat,
|
||||
summary: g.summary,
|
||||
current_group_ids: tagged.get(g.username) ?? [],
|
||||
suggested_group_id: guess?.group_id ?? null,
|
||||
suggested_group_name: guess?.group_name ?? null,
|
||||
reason: guess?.reason ?? '未匹配到关键词',
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, groups, suggestions });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = ApplySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
|
||||
}
|
||||
for (const p of parsed.data.picks) {
|
||||
tagGroup(p.chatroom_id, p.group_id);
|
||||
}
|
||||
return NextResponse.json({ ok: true, applied: parsed.data.picks.length });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { wxDaemonStatus } from '@/lib/wx';
|
||||
import { cache, CK } from '@/lib/cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
let s = cache.get(CK.daemon()) as Awaited<ReturnType<typeof wxDaemonStatus>> | undefined;
|
||||
if (!s) {
|
||||
s = await wxDaemonStatus();
|
||||
cache.set(CK.daemon(), s, 30);
|
||||
}
|
||||
return NextResponse.json({ ok: true, ...s });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
|
||||
const dbPath = join(dataDir, 'radar.db');
|
||||
const dbSize = existsSync(dbPath) ? statSync(dbPath).size : 0;
|
||||
const counts = {
|
||||
groups: (db().prepare('SELECT COUNT(*) AS n FROM groups').get() as { n: number }).n,
|
||||
messages: (db().prepare('SELECT COUNT(*) AS n FROM messages').get() as { n: number }).n,
|
||||
daily_stats: (db().prepare('SELECT COUNT(*) AS n FROM daily_stats').get() as { n: number }).n,
|
||||
sync_state: (db().prepare('SELECT COUNT(*) AS n FROM sync_state').get() as { n: number }).n,
|
||||
};
|
||||
const topGroups = db().prepare(`
|
||||
SELECT chatroom_id, COUNT(*) AS n FROM messages GROUP BY chatroom_id ORDER BY n DESC LIMIT 5
|
||||
`).all();
|
||||
return NextResponse.json({ dataDir, dbPath, dbSize, counts, topGroups });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { setFavorite, tagGroup, untagGroup, tagsForChatroom } from '@/lib/groups';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const TagSchema = z.object({
|
||||
chatroom_id: z.string().min(1),
|
||||
group_id: z.number().int().positive(),
|
||||
action: z.enum(['add', 'remove']),
|
||||
});
|
||||
|
||||
const FavSchema = z.object({
|
||||
chatroom_id: z.string().min(1),
|
||||
fav: z.boolean(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get('chatroom_id');
|
||||
if (!id) return NextResponse.json({ ok: false, error: 'chatroom_id required' }, { status: 400 });
|
||||
return NextResponse.json({ ok: true, group_ids: tagsForChatroom(id) });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const tag = TagSchema.safeParse(body);
|
||||
if (tag.success) {
|
||||
if (tag.data.action === 'add') tagGroup(tag.data.chatroom_id, tag.data.group_id);
|
||||
else untagGroup(tag.data.chatroom_id, tag.data.group_id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
const fav = FavSchema.safeParse(body);
|
||||
if (fav.success) {
|
||||
setFavorite(fav.data.chatroom_id, fav.data.fav);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: 'invalid payload' }, { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { todayStr } from '@/lib/range';
|
||||
import { db } from '@/lib/db';
|
||||
import { listMessagesForDate, getSyncState, listAllSyncedDates } from '@/lib/messages-store';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface DailyHistoryRow {
|
||||
date: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const chatroomId = decodeURIComponent(id);
|
||||
const url = new URL(req.url);
|
||||
const date = url.searchParams.get('date') ?? todayStr();
|
||||
const limit = Math.min(Number(url.searchParams.get('limit') ?? 1000), 5000);
|
||||
|
||||
// 拉群名(从 wx sessions)
|
||||
let chatName = chatroomId;
|
||||
try {
|
||||
const sessions = await wxSessions(500);
|
||||
const found = sessions.find((s) => s.username === chatroomId);
|
||||
if (found) chatName = found.chat;
|
||||
} catch {}
|
||||
|
||||
// 当日消息
|
||||
const messages = listMessagesForDate(chatroomId, date, limit);
|
||||
|
||||
// 当日聚合统计
|
||||
const total = messages.length;
|
||||
const senderMap = new Map<string, number>();
|
||||
const typeMap = new Map<string, number>();
|
||||
const hours = new Array(24).fill(0) as number[];
|
||||
for (const m of messages) {
|
||||
senderMap.set(m.sender, (senderMap.get(m.sender) ?? 0) + 1);
|
||||
typeMap.set(m.type, (typeMap.get(m.type) ?? 0) + 1);
|
||||
if (m.timestamp) {
|
||||
const h = new Date(m.timestamp * 1000).getHours();
|
||||
if (h >= 0 && h < 24) hours[h]++;
|
||||
}
|
||||
}
|
||||
const stats = {
|
||||
chat: chatName,
|
||||
total,
|
||||
by_hour: hours.map((count, hour) => ({ hour, count })),
|
||||
by_type: Array.from(typeMap.entries())
|
||||
.map(([type, count]) => ({ type, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
top_senders: Array.from(senderMap.entries())
|
||||
.map(([sender, count]) => ({ sender, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20),
|
||||
};
|
||||
|
||||
// 历史日柱图
|
||||
const dailyHistory = db()
|
||||
.prepare(
|
||||
'SELECT date, total FROM daily_stats WHERE chatroom_id = ? ORDER BY date ASC',
|
||||
)
|
||||
.all(chatroomId) as DailyHistoryRow[];
|
||||
|
||||
// 同步状态
|
||||
const syncState = getSyncState(chatroomId);
|
||||
const syncedDates = listAllSyncedDates(chatroomId);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
chatroom_id: chatroomId,
|
||||
date,
|
||||
stats,
|
||||
recent: messages,
|
||||
daily_history: dailyHistory,
|
||||
sync_state: syncState ?? null,
|
||||
synced_dates: syncedDates,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createGroup, deleteGroup, listGroups } from '@/lib/groups';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const CreateSchema = z.object({
|
||||
name: z.string().min(1).max(40),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
emoji: z.string().max(8).optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ ok: true, groups: listGroups() });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = CreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const id = createGroup(parsed.data);
|
||||
return NextResponse.json({ ok: true, id });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const id = Number(url.searchParams.get('id'));
|
||||
if (!id) return NextResponse.json({ ok: false, error: 'id required' }, { status: 400 });
|
||||
deleteGroup(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { countMentions, listMentions, markMentionsSeen } from '@/lib/mentions';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const limit = Math.min(Math.max(Number(url.searchParams.get('limit') ?? 1000), 1), 5000);
|
||||
|
||||
const sessions = await wxSessions(500);
|
||||
const nameByChatroom = new Map<string, string>();
|
||||
for (const s of sessions) nameByChatroom.set(s.username, s.chat);
|
||||
|
||||
const items = listMentions(limit).map((m) => ({
|
||||
...m,
|
||||
chat_name: nameByChatroom.get(m.chatroom_id) ?? m.chatroom_id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ ok: true, total: countMentions(), items });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = (await req.json().catch(() => ({}))) as { chatroom_id?: string };
|
||||
markMentionsSeen(body.chatroom_id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { wxNewMessages, wxSessions } from '@/lib/wx';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 600;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const interval = Math.max(Number(url.searchParams.get('interval') ?? 5000), 2000);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
const send = (obj: unknown) =>
|
||||
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let stopped = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const [msgs, sessions] = await Promise.all([
|
||||
wxNewMessages(50).catch(() => []),
|
||||
wxSessions(500).catch(() => []),
|
||||
]);
|
||||
const names = new Map<string, string>();
|
||||
for (const s of sessions) names.set(s.username, s.chat);
|
||||
const enriched = msgs.map((m) => ({
|
||||
...m,
|
||||
chat_name: names.get(m.username) ?? m.username,
|
||||
}));
|
||||
send({ type: 'tick', count: msgs.length, items: enriched, ts: Date.now() });
|
||||
} catch (e) {
|
||||
send({ type: 'error', error: e instanceof Error ? e.message : 'unknown' });
|
||||
}
|
||||
};
|
||||
|
||||
// Send initial heartbeat so the client knows the stream is open
|
||||
send({ type: 'open', interval });
|
||||
await tick();
|
||||
timer = setInterval(tick, interval);
|
||||
|
||||
req.signal.addEventListener('abort', () => {
|
||||
stopped = true;
|
||||
if (timer) clearInterval(timer);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST() {
|
||||
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
|
||||
const dest = join(dataDir, 'radar-recovered.db');
|
||||
try {
|
||||
db().pragma('wal_checkpoint(TRUNCATE)');
|
||||
db().exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`);
|
||||
const size = existsSync(dest) ? statSync(dest).size : 0;
|
||||
return NextResponse.json({ ok: true, dest, size });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
import { syncFullHistory } from '@/lib/stats-aggregator';
|
||||
import { normalizeDate, normalizeRangeKey, rangeToWindow, type RangeKey } from '@/lib/range';
|
||||
import { readConfig } from '@/lib/config';
|
||||
import { cache, CK } from '@/lib/cache';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 1800; // 30 min
|
||||
|
||||
interface RescanBody {
|
||||
range?: RangeKey;
|
||||
anchorDate?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
full?: boolean; // 一键全量:1 年
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = (await req.json().catch(() => ({}))) as RescanBody;
|
||||
|
||||
let since: string;
|
||||
let until: string;
|
||||
let scope: string;
|
||||
|
||||
if (body.full) {
|
||||
const w = rangeToWindow('year');
|
||||
since = w.since;
|
||||
until = w.until;
|
||||
scope = 'full(365d)';
|
||||
} else if (body.since && body.until) {
|
||||
since = body.since;
|
||||
until = body.until;
|
||||
scope = `custom(${since}~${until})`;
|
||||
} else {
|
||||
const range = normalizeRangeKey(body.range, 'month');
|
||||
const w = rangeToWindow(range, normalizeDate(body.anchorDate));
|
||||
since = w.since;
|
||||
until = w.until;
|
||||
scope = range;
|
||||
}
|
||||
|
||||
const sessions = await wxSessions(500);
|
||||
const targets = sessions
|
||||
.filter((s) => s.is_group)
|
||||
.map((s) => ({ chatroomId: s.username, display: s.chat }));
|
||||
|
||||
const cfg = readConfig();
|
||||
const concurrency = cfg.rescanConcurrency ?? 6;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
const send = (obj: unknown) =>
|
||||
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||
|
||||
send({ type: 'start', scope, since, until, groups: targets.length });
|
||||
|
||||
try {
|
||||
const result = await syncFullHistory({
|
||||
targets,
|
||||
since,
|
||||
until,
|
||||
concurrency,
|
||||
onProgress: (p) => send(p),
|
||||
});
|
||||
cache.del(CK.sessions());
|
||||
send({
|
||||
type: 'finished',
|
||||
ok: result.ok,
|
||||
failed: result.failed,
|
||||
messages: result.messages,
|
||||
});
|
||||
} catch (e) {
|
||||
send({ type: 'error', error: e instanceof Error ? e.message : 'unknown' });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
import type { WxSession } from '@/lib/wx-types';
|
||||
import { cache, CK } from '@/lib/cache';
|
||||
import { listGroups, listAllTags, listFavorites } from '@/lib/groups';
|
||||
import { effectiveGroupIds } from '@/lib/group-classifier';
|
||||
import { db } from '@/lib/db';
|
||||
import { readConfig } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = await loadSessionsSafe(500);
|
||||
|
||||
const groups = listGroups();
|
||||
const tags = listAllTags();
|
||||
const favorites = new Set(listFavorites());
|
||||
|
||||
const tagsByChatroom = new Map<string, number[]>();
|
||||
for (const t of tags) {
|
||||
const arr = tagsByChatroom.get(t.chatroom_id) ?? [];
|
||||
arr.push(t.group_id);
|
||||
tagsByChatroom.set(t.chatroom_id, arr);
|
||||
}
|
||||
|
||||
const groupsList = sessions.filter((s) => s.is_group);
|
||||
|
||||
const enriched = groupsList.map((s) => {
|
||||
const groupIds = effectiveGroupIds(
|
||||
s.chat,
|
||||
s.summary,
|
||||
tagsByChatroom.get(s.username) ?? [],
|
||||
groups,
|
||||
);
|
||||
return {
|
||||
chatroom_id: s.username,
|
||||
name: s.chat,
|
||||
last_msg_type: s.last_msg_type,
|
||||
last_sender: s.last_sender,
|
||||
summary: s.summary,
|
||||
time: s.time,
|
||||
timestamp: s.timestamp,
|
||||
unread: s.unread,
|
||||
is_favorite: favorites.has(s.username),
|
||||
group_ids: groupIds,
|
||||
};
|
||||
});
|
||||
|
||||
const memberCounts = new Map<number, number>();
|
||||
for (const g of enriched) {
|
||||
for (const groupId of g.group_ids) {
|
||||
memberCounts.set(groupId, (memberCounts.get(groupId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const categories = groups.map((g) => ({
|
||||
...g,
|
||||
member_count: memberCounts.get(g.id) ?? 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
total: groupsList.length,
|
||||
groups: enriched,
|
||||
categories,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'unknown error';
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadSessionsSafe(limit: number): Promise<WxSession[]> {
|
||||
if (readConfig().demoMode) return listLocalSessionsFallback(limit);
|
||||
const cached = cache.get(CK.sessions()) as WxSession[] | undefined;
|
||||
try {
|
||||
const sessions = await wxSessions(limit);
|
||||
cache.set(CK.sessions(), sessions, 60);
|
||||
return sessions;
|
||||
} catch (e) {
|
||||
if (cached?.length) return cached;
|
||||
console.warn('wx sessions failed, falling back to local radar.db', e);
|
||||
return listLocalSessionsFallback(limit);
|
||||
}
|
||||
}
|
||||
|
||||
function listLocalSessionsFallback(limit: number): WxSession[] {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`
|
||||
SELECT m.chatroom_id, m.sender, m.content, m.time, m.timestamp, m.type
|
||||
FROM messages m
|
||||
JOIN (
|
||||
SELECT chatroom_id, MAX(timestamp) AS timestamp
|
||||
FROM messages
|
||||
GROUP BY chatroom_id
|
||||
) latest
|
||||
ON latest.chatroom_id = m.chatroom_id
|
||||
AND latest.timestamp = m.timestamp
|
||||
GROUP BY m.chatroom_id
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(limit) as Array<{
|
||||
chatroom_id: string;
|
||||
sender: string;
|
||||
content: string;
|
||||
time: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
}>;
|
||||
|
||||
return rows.map((r) => ({
|
||||
chat: r.chatroom_id,
|
||||
chat_type: 'group',
|
||||
is_group: true,
|
||||
last_msg_type: r.type,
|
||||
last_sender: r.sender,
|
||||
summary: r.content,
|
||||
time: r.time,
|
||||
timestamp: r.timestamp,
|
||||
unread: 0,
|
||||
username: r.chatroom_id,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { DATA_DIR, configStatus, writeConfig } from '@/lib/config';
|
||||
import { seedDemoData } from '@/lib/demo-data';
|
||||
import { wxAvailable, wxDaemonStatus } from '@/lib/wx';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const SetupSchema = z.object({
|
||||
myNicknames: z.array(z.string()).default([]),
|
||||
privacyConfirmed: z.boolean(),
|
||||
demoMode: z.boolean().default(false),
|
||||
defaultSyncDays: z.number().int().min(1).max(365).default(7),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const [wxInstalled, daemon] = await Promise.all([wxAvailable(), wxDaemonStatus()]);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...configStatus(),
|
||||
dataDir: DATA_DIR,
|
||||
checks: {
|
||||
wxInstalled,
|
||||
wxDaemonRunning: daemon.running,
|
||||
wxDaemonPid: daemon.pid ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = SetupSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
|
||||
}
|
||||
const names = parsed.data.myNicknames.map((name) => name.trim()).filter(Boolean);
|
||||
if (!parsed.data.demoMode && names.length === 0) {
|
||||
return NextResponse.json({ ok: false, error: '请至少填写一个自己的微信名或群昵称' }, { status: 400 });
|
||||
}
|
||||
const config = writeConfig({
|
||||
myNicknames: names,
|
||||
privacyConfirmed: parsed.data.privacyConfirmed,
|
||||
demoMode: parsed.data.demoMode,
|
||||
defaultSyncDays: parsed.data.defaultSyncDays,
|
||||
setupCompleted: true,
|
||||
});
|
||||
const demo = parsed.data.demoMode ? seedDemoData() : null;
|
||||
return NextResponse.json({ ok: true, configured: true, config, demo });
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
import type { WxSession } from '@/lib/wx-types';
|
||||
import { listCachedStatsRange } from '@/lib/stats-aggregator';
|
||||
import { listAllTags, listGroups, listFavorites } from '@/lib/groups';
|
||||
import { effectiveGroupIds } from '@/lib/group-classifier';
|
||||
import { rangeToWindow, dateList, normalizeDate, normalizeRangeKey } from '@/lib/range';
|
||||
import { countMentionsBetween } from '@/lib/mentions';
|
||||
import { buildDashboardIntelligence } from '@/lib/dashboard-intelligence';
|
||||
import { cache, CK } from '@/lib/cache';
|
||||
import { db } from '@/lib/db';
|
||||
import { readConfig } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const range = normalizeRangeKey(url.searchParams.get('range'), 'week');
|
||||
const anchorDate = normalizeDate(url.searchParams.get('date'));
|
||||
const w = rangeToWindow(range, anchorDate);
|
||||
|
||||
const sessions = await loadSessionsSafe(500);
|
||||
const groups = sessions.filter((s) => s.is_group);
|
||||
const groupNames = new Map(groups.map((g) => [g.username, g.chat]));
|
||||
const allCount = groups.length;
|
||||
|
||||
const cached = listCachedStatsRange(w.since, w.until);
|
||||
const totalMessages = cached.reduce((sum, r) => sum + r.total, 0);
|
||||
|
||||
const dates = dateList(w.since, w.until);
|
||||
const trendByDate = new Map<string, number>(dates.map((d) => [d, 0]));
|
||||
for (const r of cached) {
|
||||
if (trendByDate.has(r.date)) {
|
||||
trendByDate.set(r.date, (trendByDate.get(r.date) ?? 0) + r.total);
|
||||
}
|
||||
}
|
||||
const trend = dates.map((d) => ({ date: d, count: trendByDate.get(d) ?? 0 }));
|
||||
|
||||
const peak = trend.reduce((max, t) => (t.count > max.count ? t : max), { date: '', count: 0 });
|
||||
const sumTrend = trend.reduce((s, t) => s + t.count, 0);
|
||||
const avg = trend.length > 0 ? sumTrend / trend.length : 0;
|
||||
|
||||
const totalsByGroup = new Map<string, number>();
|
||||
const sendersByGroup = new Map<string, Map<string, number>>();
|
||||
for (const r of cached) {
|
||||
totalsByGroup.set(r.chatroom_id, (totalsByGroup.get(r.chatroom_id) ?? 0) + r.total);
|
||||
const senderMap = sendersByGroup.get(r.chatroom_id) ?? new Map<string, number>();
|
||||
for (const s of r.top_senders) {
|
||||
senderMap.set(s.sender, (senderMap.get(s.sender) ?? 0) + s.count);
|
||||
}
|
||||
sendersByGroup.set(r.chatroom_id, senderMap);
|
||||
}
|
||||
const active = groups.filter((g) => (totalsByGroup.get(g.username) ?? 0) > 0).length;
|
||||
const silent = allCount - active;
|
||||
|
||||
const topActiveGroups = groups
|
||||
.map((g) => ({
|
||||
chatroom_id: g.username,
|
||||
name: g.chat,
|
||||
summary: g.summary,
|
||||
total: totalsByGroup.get(g.username) ?? 0,
|
||||
top_senders: Array.from(sendersByGroup.get(g.username)?.entries() ?? [])
|
||||
.map(([sender, count]) => ({ sender, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 3),
|
||||
}))
|
||||
.filter((g) => g.total > 0)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const tags = listAllTags();
|
||||
const cats = listGroups();
|
||||
const tagsByChatroom = new Map<string, number[]>();
|
||||
for (const t of tags) {
|
||||
const arr = tagsByChatroom.get(t.chatroom_id) ?? [];
|
||||
arr.push(t.group_id);
|
||||
tagsByChatroom.set(t.chatroom_id, arr);
|
||||
}
|
||||
const effectiveTagsByChatroom = new Map<string, number[]>();
|
||||
for (const g of groups) {
|
||||
effectiveTagsByChatroom.set(
|
||||
g.username,
|
||||
effectiveGroupIds(g.chat, g.summary, tagsByChatroom.get(g.username) ?? [], cats),
|
||||
);
|
||||
}
|
||||
const taggedChatroomIds = new Set(
|
||||
Array.from(effectiveTagsByChatroom.entries())
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([chatroomId]) => chatroomId),
|
||||
);
|
||||
const unsortedCount = groups.filter((g) => (effectiveTagsByChatroom.get(g.username) ?? []).length === 0).length;
|
||||
|
||||
const categoryStats = cats.map((c) => {
|
||||
const memberIds = Array.from(effectiveTagsByChatroom.entries())
|
||||
.filter(([, ids]) => ids.includes(c.id))
|
||||
.map(([chatroomId]) => chatroomId);
|
||||
const memberSet = new Set(memberIds);
|
||||
let groupMessageCount = 0;
|
||||
for (const r of cached) {
|
||||
if (memberSet.has(r.chatroom_id)) groupMessageCount += r.total;
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
color: c.color,
|
||||
emoji: c.emoji,
|
||||
group_count: memberIds.length,
|
||||
message_count: groupMessageCount,
|
||||
};
|
||||
});
|
||||
const unsortedMessageCount = cached
|
||||
.filter((r) => !taggedChatroomIds.has(r.chatroom_id))
|
||||
.reduce((s, r) => s + r.total, 0);
|
||||
if (unsortedCount > 0) {
|
||||
categoryStats.push({
|
||||
id: -1,
|
||||
name: '未分类',
|
||||
color: '#94a3b8',
|
||||
emoji: '❓',
|
||||
group_count: unsortedCount,
|
||||
message_count: unsortedMessageCount,
|
||||
});
|
||||
}
|
||||
|
||||
const favorites = listFavorites();
|
||||
const mentionCount = countMentionsBetween(unixStartOfDay(w.since), unixEndOfDay(w.until));
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
range,
|
||||
window: w,
|
||||
cards: {
|
||||
active_groups: active,
|
||||
total_groups: allCount,
|
||||
total_messages: totalMessages,
|
||||
mentions: mentionCount,
|
||||
silent_groups: silent,
|
||||
avg_per_group: allCount ? Math.round(totalMessages / allCount) : 0,
|
||||
},
|
||||
trend: {
|
||||
data: trend,
|
||||
peak,
|
||||
avg,
|
||||
total: sumTrend,
|
||||
},
|
||||
active_groups: topActiveGroups,
|
||||
categories: categoryStats,
|
||||
intelligence: buildDashboardIntelligence(w.until, groupNames),
|
||||
sidebar_counts: {
|
||||
all: allCount,
|
||||
favorites: favorites.length,
|
||||
unsorted: unsortedCount,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'unknown error';
|
||||
console.error('/api/stats failed', e);
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionsSafe(limit: number): Promise<WxSession[]> {
|
||||
if (readConfig().demoMode) return listLocalSessionsFallback(limit);
|
||||
const cached = cache.get(CK.sessions()) as WxSession[] | undefined;
|
||||
try {
|
||||
const sessions = await wxSessions(limit);
|
||||
cache.set(CK.sessions(), sessions, 60);
|
||||
return sessions;
|
||||
} catch (e) {
|
||||
if (cached?.length) return cached;
|
||||
console.warn('wx sessions failed, falling back to local radar.db', e);
|
||||
return listLocalSessionsFallback(limit);
|
||||
}
|
||||
}
|
||||
|
||||
function listLocalSessionsFallback(limit: number): WxSession[] {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`
|
||||
SELECT m.chatroom_id, m.sender, m.content, m.time, m.timestamp, m.type
|
||||
FROM messages m
|
||||
JOIN (
|
||||
SELECT chatroom_id, MAX(timestamp) AS timestamp
|
||||
FROM messages
|
||||
GROUP BY chatroom_id
|
||||
) latest
|
||||
ON latest.chatroom_id = m.chatroom_id
|
||||
AND latest.timestamp = m.timestamp
|
||||
GROUP BY m.chatroom_id
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(limit) as Array<{
|
||||
chatroom_id: string;
|
||||
sender: string;
|
||||
content: string;
|
||||
time: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
}>;
|
||||
|
||||
return rows.map((r) => ({
|
||||
chat: r.chatroom_id,
|
||||
chat_type: 'group',
|
||||
is_group: true,
|
||||
last_msg_type: r.type,
|
||||
last_sender: r.sender,
|
||||
summary: r.content,
|
||||
time: r.time,
|
||||
timestamp: r.timestamp,
|
||||
unread: 0,
|
||||
username: r.chatroom_id,
|
||||
}));
|
||||
}
|
||||
|
||||
function unixStartOfDay(date: string) {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return Math.floor(new Date(year, month - 1, day, 0, 0, 0, 0).getTime() / 1000);
|
||||
}
|
||||
|
||||
function unixEndOfDay(date: string) {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return Math.floor(new Date(year, month - 1, day, 23, 59, 59, 999).getTime() / 1000);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTopicDetail } from '@/lib/topics';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const numId = Number(id);
|
||||
if (!Number.isInteger(numId) || numId <= 0) {
|
||||
return NextResponse.json({ ok: false, error: 'invalid id' }, { status: 400 });
|
||||
}
|
||||
const detail = await getTopicDetail(numId);
|
||||
if (!detail) return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
return NextResponse.json({ ok: true, ...detail });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDailyLinkIntelligence } from '@/lib/link-intelligence';
|
||||
import { todayStr } from '@/lib/range';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const date = url.searchParams.get('date') ?? todayStr();
|
||||
if (!DATE_RE.test(date)) {
|
||||
return NextResponse.json({ ok: false, error: 'invalid date' }, { status: 400 });
|
||||
}
|
||||
const refresh = url.searchParams.get('refresh') === '1' || url.searchParams.get('refresh') === 'true';
|
||||
|
||||
try {
|
||||
const result = await getDailyLinkIntelligence(date, { refresh });
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { listTopics } from '@/lib/topics';
|
||||
import { todayStr } from '@/lib/range';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const date = url.searchParams.get('date') ?? todayStr();
|
||||
const topics = listTopics(date);
|
||||
return NextResponse.json({ ok: true, date, topics });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { mimeFor, resolveWxImage } from '@/lib/wx-image';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const localIdStr = url.searchParams.get('local_id');
|
||||
const chatroomId = url.searchParams.get('chatroom') ?? undefined;
|
||||
const hintMonth = url.searchParams.get('month') ?? undefined;
|
||||
|
||||
const localId = Number(localIdStr);
|
||||
if (!localIdStr || !Number.isInteger(localId) || localId <= 0) {
|
||||
return new Response('invalid local_id', { status: 400 });
|
||||
}
|
||||
|
||||
// 自动推断 month:从本地 messages 表查这条消息的日期
|
||||
let resolvedMonth = hintMonth;
|
||||
if (!resolvedMonth && chatroomId) {
|
||||
const row = db()
|
||||
.prepare('SELECT date FROM messages WHERE chatroom_id = ? AND local_id = ?')
|
||||
.get(chatroomId, localId) as { date: string } | undefined;
|
||||
if (row?.date) resolvedMonth = row.date.slice(0, 7);
|
||||
}
|
||||
|
||||
const found = await resolveWxImage(localId, resolvedMonth);
|
||||
if (!found) {
|
||||
return new Response('image not found in wx cache', { status: 404 });
|
||||
}
|
||||
|
||||
const buf = await readFile(/*turbopackIgnore: true*/ found.path);
|
||||
return new Response(new Uint8Array(buf), {
|
||||
headers: {
|
||||
'Content-Type': mimeFor(found.format),
|
||||
'Cache-Control': 'public, max-age=86400, immutable',
|
||||
'X-Image-Type': found.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user