Initial open source release

This commit is contained in:
joeseesun
2026-05-24 13:42:56 +08:00
commit 8abb29e13e
75 changed files with 13298 additions and 0 deletions
+69
View File
@@ -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 });
}
+14
View File
@@ -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 });
}
+23
View File
@@ -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 });
}
+39
View File
@@ -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 });
}
+82
View File
@@ -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,
});
}
+40
View File
@@ -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 });
}
+27
View File
@@ -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 });
}
+59
View File
@@ -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',
},
});
}
+23
View File
@@ -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 },
);
}
}
+89
View File
@@ -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',
},
});
}
+127
View File
@@ -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,
}));
}
+49
View File
@@ -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 });
}
+225
View File
@@ -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);
}
+18
View File
@@ -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 });
}
+27
View File
@@ -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 },
);
}
}
+12
View File
@@ -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 });
}
+41
View File
@@ -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,
},
});
}