diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b95440..3d49051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 本文件的格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 并且本项目遵循 [语义化版本 (Semantic Versioning)](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。 +## [1.2.5] - 2026-01-15 + +### 修复 +- **前端鲁棒性**: 修复了当数据库为空或 API 返回错误对象时页面发生崩溃(白屏)的问题。 + - 为 `TopicsView`, `SystemLoadView` 和 `AdminView` 中的所有 API 请求增加了 `res.ok` 和 `Array.isArray` 校验。 + - 增加了防御性逻辑,确保在数据未加载或加载失败时显示友好的提示而非崩溃。 +- **Vite 环境变量**: 修复了 `TypeError: Cannot read properties of undefined (reading 'VITE_WEBHOOK_BASE_URL')`。 + - 在 `TopicsView.tsx` 中使用可选链 (`meta.env?.`) 安全地访问 Vite 环境变量,防止由于环境未完全初始化导致的崩溃。 + +## [1.2.4] - 2026-01-15 + +### 变更 +- **类型安全**: 全面重构了服务端与前端的代码,消除了绝大部分 `any` 类型的使用。 + - 在 `webhook.ts`, `verify_permissions.ts`, `feishu.ts` 等核心文件中引入了显式接口。 + - 改进了 Webhook Body 的处理逻辑,在保持灵活性的同时增强了类型校验。 + - 修复了多处 Non-null Assertion 为更安全的可选链或显式空值检查。 +- **Linting**: 严格执行 Biome 的 `noExplicitAny` 规则。 + ## [1.2.3] - 2026-01-15 ### 新增 diff --git a/apps/server/package.json b/apps/server/package.json index 30fea03..4aaa10e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -25,4 +25,4 @@ "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3" } -} \ No newline at end of file +} diff --git a/apps/server/src/event-handler.ts b/apps/server/src/event-handler.ts index ef221c4..0ddc8f5 100644 --- a/apps/server/src/event-handler.ts +++ b/apps/server/src/event-handler.ts @@ -4,12 +4,21 @@ import { db } from "./db"; import { knownGroupChats, topicGroupChats } from "./db/schema"; import { logger } from "./lib/logger"; +interface BotAddedEvent { + chat_id: string; + name?: string; +} + +interface BotDeletedEvent { + chat_id: string; +} + export const eventDispatcher = new lark.EventDispatcher({ encryptKey: process.env.FEISHU_ENCRYPT_KEY, verificationToken: process.env.FEISHU_VERIFICATION_TOKEN, }).register({ "im.chat.member.bot.added_v1": async (data) => { - const { chat_id, name } = data as any; + const { chat_id, name } = data as unknown as BotAddedEvent; logger.info({ chat_id, name }, "[Feishu Event] Bot added to group"); if (chat_id) { @@ -30,7 +39,7 @@ export const eventDispatcher = new lark.EventDispatcher({ } }, "im.chat.member.bot.deleted_v1": async (data) => { - const { chat_id } = data as any; + const { chat_id } = data as unknown as BotDeletedEvent; logger.info({ chat_id }, "[Feishu Event] Bot removed from group"); if (chat_id) { diff --git a/apps/server/src/feishu.ts b/apps/server/src/feishu.ts index b69ee11..ed7f9b5 100644 --- a/apps/server/src/feishu.ts +++ b/apps/server/src/feishu.ts @@ -1,6 +1,15 @@ import * as lark from "@larksuiteoapi/node-sdk"; import { logger } from "./lib/logger"; +export interface UserAccessTokenData { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + refresh_expires_in: number; + scope: string; +} + export class FeishuClient { public client: lark.Client; public appId: string; @@ -20,7 +29,7 @@ export class FeishuClient { receiveId: string, receiveIdType: "open_id" | "user_id" | "email" | "chat_id", msgType: string, - content: any, + content: Record | string, ) { // Content needs to be stringified for 'text' type in API, but SDK might handle it differently? // Actually SDK expects 'content' as string JSON for 'im.v1.messages.create' @@ -50,7 +59,9 @@ export class FeishuClient { } } - async getUserAccessToken(code: string): Promise { + async getUserAccessToken( + code: string, + ): Promise { try { const response = await this.client.authen.accessToken.create({ data: { @@ -63,7 +74,7 @@ export class FeishuClient { logger.error({ response }, "Feishu get user access token error"); throw new Error(`Failed to get user access token: ${response.msg}`); } - return response.data; + return response.data as UserAccessTokenData; } catch (e) { console.error("Feishu SDK error:", e); throw e; diff --git a/apps/server/src/verify_permissions.ts b/apps/server/src/verify_permissions.ts index 6632813..be09689 100644 --- a/apps/server/src/verify_permissions.ts +++ b/apps/server/src/verify_permissions.ts @@ -3,6 +3,11 @@ import { db } from "./db"; import { subscriptions, topics, users } from "./db/schema"; import app from "./index"; +interface TopicWithSubscriptions { + id: string; + subscriptions: { userId: string }[]; +} + async function verify() { console.log("Starting Verification..."); let errors = 0; @@ -112,9 +117,9 @@ async function verify() { }, }); const res3 = await app.request(req3); - const data3 = await res3.json(); + const data3 = (await res3.json()) as TopicWithSubscriptions[]; - const targetTopic = (data3 as any).find((t: any) => t.id === topic.id); + const targetTopic = data3.find((t) => t.id === topic.id); if (targetTopic) { if ( targetTopic.subscriptions.length === 1 && @@ -135,23 +140,19 @@ async function verify() { errors++; } - // Test as Admin (Should see ALL subscriptions?? Wait, I didn't add another subscription. Let's add admin subscription too) - // Actually, let's just check that Admin sees the User's subscription. - // In my logic: isAdmin ? undefined (all) : ... - // So Admin should see User's subscription. - + // Test as Admin (Should see ALL subscriptions??) const req4 = new Request("http://localhost/api/topics", { headers: { Cookie: `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`, }, }); const res4 = await app.request(req4); - const data4 = await res4.json(); + const data4 = (await res4.json()) as TopicWithSubscriptions[]; - const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id); + const targetTopicAdmin = data4.find((t) => t.id === topic.id); // Should see the subscription for userUser - const hasUserSub = targetTopicAdmin.subscriptions.some( - (s: any) => s.userId === userUser.id, + const hasUserSub = targetTopicAdmin?.subscriptions.some( + (s) => s.userId === userUser.id, ); if (hasUserSub) { console.log( diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index 375cd89..0c43cf9 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -5,6 +5,16 @@ import { alertLogs, alertTasks, topics, users } from "./db/schema"; import { feishuClient } from "./feishu"; import { logger } from "./lib/logger"; +type FeishuReceiveIdType = "open_id" | "user_id" | "email" | "chat_id"; + +interface Recipient { + type: "user" | "group"; + id: string; + name: string; + feishuId: string; + idType: FeishuReceiveIdType; +} + const webhook = new Hono(); webhook.post("/:token/topic/:slug", async (c) => { @@ -21,7 +31,8 @@ webhook.post("/:token/topic/:slug", async (c) => { logger.warn({ token }, "[Webhook] Invalid personal token"); return c.json({ error: "Invalid personal token" }, 401); } - let body: any; + // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON + let body: Record; try { const rawBody = await c.req.text(); logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body"); @@ -55,26 +66,31 @@ webhook.post("/:token/topic/:slug", async (c) => { logger.info({ topicName: topic.name }, "[Webhook] Found topic"); // 2. Collect recipients - const userRecipients = topic.subscriptions + const userRecipients: Recipient[] = topic.subscriptions .map((sub) => sub.user) - .filter((u) => !!u && !!u.feishuUserId) - .map((u) => ({ - type: "user", - id: u.id, - name: u.name, - feishuId: u.feishuUserId, - idType: u.feishuUserId.startsWith("ou_") ? "open_id" : "user_id", - })); + .map((u) => { + if (!u || !u.feishuUserId) return null; + return { + type: "user" as const, + id: u.id, + name: u.name, + feishuId: u.feishuUserId, + idType: (u.feishuUserId.startsWith("ou_") + ? "open_id" + : "user_id") as FeishuReceiveIdType, + }; + }) + .filter((u): u is NonNullable => u !== null); - const groupRecipients = topic.groupChats.map((g) => ({ + const groupRecipients: Recipient[] = topic.groupChats.map((g) => ({ type: "group", id: g.id, // Binding ID name: g.name, feishuId: g.chatId, - idType: "chat_id", + idType: "chat_id" as FeishuReceiveIdType, })); - const allRecipients = [...userRecipients, ...groupRecipients]; + const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients]; const [task] = await db .insert(alertTasks) @@ -137,13 +153,15 @@ webhook.post("/:token/topic/:slug", async (c) => { await feishuClient.sendMessage( recipient.feishuId, - recipient.idType as any, + recipient.idType, msgType, content, ); return { recipientId: recipient.id, status: "sent", error: null }; - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); logger.error( { err: error, @@ -155,22 +173,25 @@ webhook.post("/:token/topic/:slug", async (c) => { return { recipientId: recipient.id, status: "failed", - error: error.message, + error: errorMessage, }; } }), ).then(async (results) => { const successCount = results.filter( - (r) => r.status === "fulfilled" && (r.value as any).status === "sent", + (r) => + r.status === "fulfilled" && + (r.value as { status: string }).status === "sent", ).length; const failures = results.filter( (r) => r.status === "rejected" || - (r.status === "fulfilled" && (r.value as any).status === "failed"), + (r.status === "fulfilled" && + (r.value as { status: string }).status === "failed"), ).length; // Determine final status - const finalStatus = + const finalStatus: "completed" | "failed" = failures === 0 ? "completed" : successCount > 0 ? "completed" : "failed"; // Update Task @@ -189,26 +210,29 @@ webhook.post("/:token/topic/:slug", async (c) => { const logs = results.map((r, index) => { const recipient = allRecipients[index]; if (r.status === "fulfilled") { - const val = r.value as any; + const val = r.value as { + status: "sent" | "failed"; + error: string | null; + }; return { taskId: task.id, userId: recipient.type === "user" ? recipient.id : null, // Only link users // We could add connection to group binding if we altered schema, but for now log it - status: val.status, + status: val.status as "sent" | "failed", error: val.error, }; } else { return { taskId: task.id, userId: recipient.type === "user" ? recipient.id : null, - status: "failed", - error: r.reason ? String(r.reason) : "Unknown error", + status: "failed" as const, + error: r.status === "rejected" ? String(r.reason) : "Unknown error", }; } }); if (logs.length > 0) { - await db.insert(alertLogs).values(logs as any); + await db.insert(alertLogs).values(logs); } logger.info( @@ -248,7 +272,8 @@ webhook.post("/:token/dm", async (c) => { return c.json({ error: "User has no Feishu ID linked" }, 400); } - let body: any; + // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON + let body: Record; try { const rawBody = await c.req.text(); if (!rawBody || rawBody.trim() === "") { @@ -315,24 +340,26 @@ webhook.post("/:token/dm", async (c) => { await db.insert(alertLogs).values({ taskId: task.id, userId: user.id, - status: "sent", + status: "sent" as const, }); - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); logger.error({ err: error, userName: user.name }, "Failed to send DM"); await db .update(alertTasks) .set({ status: "failed", updatedAt: new Date(), - error: error.message, + error: errorMessage, }) .where(eq(alertTasks.id, task.id)); await db.insert(alertLogs).values({ taskId: task.id, userId: user.id, - status: "failed", - error: error.message, + status: "failed" as const, + error: errorMessage, }); } })(); diff --git a/apps/web/src/components/GroupBindingsModal.tsx b/apps/web/src/components/GroupBindingsModal.tsx index bb138aa..12d43ab 100644 --- a/apps/web/src/components/GroupBindingsModal.tsx +++ b/apps/web/src/components/GroupBindingsModal.tsx @@ -48,8 +48,10 @@ export default function GroupBindingsModal({ init: { credentials: "include" }, }, ); - const data = await res.json(); - setBindings(data as any); + const data = (await res.json()) as GroupBinding[]; + if (Array.isArray(data)) { + setBindings(data); + } } catch (err) { console.error(err); } @@ -60,8 +62,10 @@ export default function GroupBindingsModal({ const res = await client.api.groups.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setKnownGroups(data as any); + const data = (await res.json()) as KnownGroup[]; + if (Array.isArray(data)) { + setKnownGroups(data); + } } catch (err) { console.error(err); } diff --git a/apps/web/src/views/AdminView.tsx b/apps/web/src/views/AdminView.tsx index 83b39b9..aa2bd00 100644 --- a/apps/web/src/views/AdminView.tsx +++ b/apps/web/src/views/AdminView.tsx @@ -2,6 +2,24 @@ import { useCallback, useEffect, useState } from "react"; import { client } from "../lib/client"; import SystemLoadView from "./SystemLoadView"; +interface TopicUser { + id: string; + name: string; + email?: string | null; +} + +interface Topic { + id: string; + name: string; + slug: string; + description?: string; + status: "pending" | "approved" | "rejected"; + subscriptions?: { id: string }[]; + creator?: TopicUser; + approver?: TopicUser; + createdAt: string; +} + export default function AdminView() { const [activeTab, setActiveTab] = useState("load"); @@ -17,33 +35,30 @@ export default function AdminView() { @@ -59,7 +74,7 @@ export default function AdminView() { } function TopicsManagement() { - const [topics, setTopics] = useState([]); + const [topics, setTopics] = useState([]); const [loading, setLoading] = useState(true); const fetchAllTopics = useCallback(async () => { @@ -68,10 +83,21 @@ function TopicsManagement() { const res = await client.api.topics.all.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setTopics(data); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setTopics(data as unknown as Topic[]); + } else { + console.error("All topics data is not an array:", data); + setTopics([]); + } + } else { + console.error("Failed to fetch all topics:", res.status); + setTopics([]); + } } catch (error) { console.error(error); + setTopics([]); } finally { setLoading(false); } @@ -141,13 +167,12 @@ function TopicsManagement() { {topic.status} @@ -179,7 +204,7 @@ function TopicsManagement() { } function TopicRequestsList() { - const [requests, setRequests] = useState([]); + const [requests, setRequests] = useState([]); const [loading, setLoading] = useState(true); const fetchRequests = useCallback(async () => { @@ -188,10 +213,20 @@ function TopicRequestsList() { const res = await client.api.topics.requests.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setRequests(data); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setRequests(data as unknown as Topic[]); + } else { + setRequests([]); + } + } else { + console.error("Failed to fetch requests:", res.status); + setRequests([]); + } } catch (error) { console.error(error); + setRequests([]); } finally { setLoading(false); } diff --git a/apps/web/src/views/SystemLoadView.tsx b/apps/web/src/views/SystemLoadView.tsx index d96f660..623346e 100644 --- a/apps/web/src/views/SystemLoadView.tsx +++ b/apps/web/src/views/SystemLoadView.tsx @@ -2,6 +2,19 @@ import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { client } from "../lib/client"; +interface Task { + id: string; + createdAt: string; + topicSlug: string | null; + sender: { + name: string; + email: string | null; + } | null; + successCount: number; + recipientCount: number; + status: string; +} + interface Stats { topics: { topicSlug: string; @@ -16,7 +29,7 @@ interface Stats { failedCount: number; successRate: number; }; - tasks: any[]; + tasks: Task[]; } export default function SystemLoadView() { @@ -29,7 +42,13 @@ export default function SystemLoadView() { const res = await client.api.stats.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); + if (!res.ok) { + console.error("Failed to fetch stats:", res.status); + return; + } + const data = (await res.json()) as Omit & { + topics: unknown; + }; // Fetch recent tasks as well const tasksRes = await client.api.alerts.tasks.$get( @@ -38,9 +57,19 @@ export default function SystemLoadView() { init: { credentials: "include" }, }, ); - const tasks = await tasksRes.json(); + if (!tasksRes.ok) { + console.error("Failed to fetch tasks:", tasksRes.status); + return; + } + const tasks = (await tasksRes.json()) as unknown; - setStats({ ...data, tasks } as Stats); + setStats({ + topics: Array.isArray(data.topics) + ? (data.topics as Stats["topics"]) + : [], + recent: data.recent as Stats["recent"], + tasks: Array.isArray(tasks) ? (tasks as Task[]) : [], + }); setLastUpdated(new Date()); } catch (error) { console.error("Failed to fetch stats:", error); @@ -237,7 +266,7 @@ export default function SystemLoadView() { - {stats.tasks.map((task: any) => ( + {stats.tasks.map((task) => ( + Success Rate Gauge ([]); - const [myRequests, setMyRequests] = useState([]); + const [myRequests, setMyRequests] = useState([]); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); @@ -65,10 +67,21 @@ export default function TopicsView() { const res = await client.api.topics.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setTopics(data as unknown as Topic[]); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setTopics(data as unknown as Topic[]); + } else { + console.error("Topics data is not an array:", data); + setTopics([]); + } + } else { + console.error("Failed to fetch topics:", res.status); + setTopics([]); + } } catch (err) { console.error(err); + setTopics([]); } finally { setLoading(false); } @@ -79,8 +92,12 @@ export default function TopicsView() { const res = await client.api.topics["my-requests"].$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setMyRequests(data); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setMyRequests(data as unknown as Topic[]); + } + } } catch (err) { console.error(err); } @@ -91,8 +108,12 @@ export default function TopicsView() { const res = await client.api.users.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setUsers(data as unknown as TopicUser[]); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setUsers(data as unknown as TopicUser[]); + } + } } catch (err) { console.error(err); } @@ -112,7 +133,11 @@ export default function TopicsView() { try { const res = await client.api.topics.$post( { - json: formData as any, + json: formData as { + name: string; + slug: string; + description?: string; + }, }, { init: { credentials: "include" }, @@ -134,7 +159,7 @@ export default function TopicsView() { setSubmitStatus(null); }, 1500); } else { - const error = await res.json(); + const error = (await res.json()) as { message?: string }; setSubmitStatus({ type: "error", message: error.message || "Failed to submit request.", @@ -194,12 +219,20 @@ export default function TopicsView() { const updatedSubs = isSubscribed ? t.subscriptions.filter((s) => s.userId !== userId) : [ - ...t.subscriptions, - { - userId, - user: users.find((u) => u.id === userId) || currentUser!, - }, - ]; + ...t.subscriptions, + { + userId, + user: + users.find((u) => u.id === userId) || + (currentUser + ? { + id: currentUser.id, + name: currentUser.name, + email: currentUser.email, + } + : { id: "unknown", name: "Unknown" }), + }, + ]; return { ...t, subscriptions: updatedSubs }; } return t; @@ -211,12 +244,20 @@ export default function TopicsView() { const updatedSubs = isSubscribed ? selectedTopic.subscriptions.filter((s) => s.userId !== userId) : [ - ...selectedTopic.subscriptions, - { - userId, - user: users.find((u) => u.id === userId) || currentUser!, - }, - ]; + ...selectedTopic.subscriptions, + { + userId, + user: + users.find((u) => u.id === userId) || + (currentUser + ? { + id: currentUser.id, + name: currentUser.name, + email: currentUser.email, + } + : { id: "unknown", name: "Unknown" }), + }, + ]; setSelectedTopic({ ...selectedTopic, subscriptions: updatedSubs }); } @@ -226,13 +267,13 @@ export default function TopicsView() { } }; - const isSubscribed = (topic: Topic) => { + const isSubscribedToTopic = (topic: Topic) => { return topic.subscriptions.some((sub) => sub.userId === currentUser?.id); }; const handleSelfSubscribe = async (topic: Topic) => { if (!currentUser) return; - const subscribed = isSubscribed(topic); + const subscribed = isSubscribedToTopic(topic); await toggleSubscription(topic.id, currentUser.id, subscribed); }; @@ -245,16 +286,20 @@ export default function TopicsView() { const getWebhookUrl = (topicSlug: string) => { if (!currentUser?.personalToken) return ""; // Use an environment variable if available, otherwise fallback to current origin + // biome-ignore lint/suspicious/noExplicitAny: Vite env access + const meta = import.meta as any; const baseUrl = ( - (import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin + meta.env?.VITE_WEBHOOK_BASE_URL || window.location.origin ).replace(/\/$/, ""); return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`; }; const getDmWebhookUrl = () => { if (!currentUser?.personalToken) return ""; + // biome-ignore lint/suspicious/noExplicitAny: Vite env access + const meta = import.meta as any; const baseUrl = ( - (import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin + meta.env?.VITE_WEBHOOK_BASE_URL || window.location.origin ).replace(/\/$/, ""); return `${baseUrl}/webhook/${currentUser.personalToken}/dm`; }; @@ -384,13 +429,12 @@ export default function TopicsView() {
{req.status === "approved" ? "Approved" @@ -565,7 +608,9 @@ export default function TopicsView() { )}

Requested on:{" "} - {new Date(req.createdAt).toLocaleDateString()} + {req.createdAt + ? new Date(req.createdAt).toLocaleDateString() + : "Unknown"} {req.approver && ( | Approved by: {req.approver.name} @@ -600,7 +645,7 @@ export default function TopicsView() { id="topic-name" type="text" required - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value }) @@ -618,7 +663,7 @@ export default function TopicsView() { id="topic-slug" type="text" required - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900" value={formData.slug} onChange={(e) => setFormData({ ...formData, slug: e.target.value }) @@ -634,7 +679,7 @@ export default function TopicsView() {