From 30537ddc170fce2eb73308e113020df6386fc572 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 18:02:25 +0000 Subject: [PATCH] =?UTF-8?q?chore(quality):=20issue=20#302=20=E5=89=A9?= =?UTF-8?q?=E4=BD=99=20P1=20+=20P2=20=E4=B8=80=E9=94=85=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清掉 issue #302 还开着的 4 项: - P1-1 InterestButton 失败提示 catch 之前静默吞错,乐观 UI 回滚后用户看到数字"动了一下又回去"以为 按钮坏了。加 inline toast:失败时按钮旁短暂红字(aria-live="polite"), 3 秒消失,timer 卸载时清掉避免 setState on unmounted。 - P1-2 Sentry beforeSend 过滤敏感请求头 所有 Sentry init(client/server/edge)加 beforeSend,剔除 satoken / cookie / authorization header(含大小写变体),避免凭据原样上报。 合规 + 缩小攻击面,不影响错误本身的归类。 - P2-1 EventForm 客户端校验 endTime > startTime 提交前做字典序对比(两个 ISO 字符串字典序与时间序一致),不通过直接 setError + 阻止提交。后端仍是权威,前端这层只防 UI 手抖。 - P2-2 Sentry server / edge 改读 SENTRY_DSN 原来三份 config 都读 NEXT_PUBLIC_SENTRY_DSN,server / edge 用 public env 没必要。改为优先 SENTRY_DSN,fallback NEXT_PUBLIC_SENTRY_DSN 兼 容旧部署,迁移期间不丢上报。 剩下未做(issue 还会留着): - P3-1 SafeImg 组件(重复 eslint-disable,重构属性) - P3-2 events/feed/events-id 改 ISR + client fetch(CPU 边际改善) - P3-3 rate-limit Redis 单例 --- app/[locale]/admin/events/EventForm.tsx | 9 +++ app/[locale]/events/[id]/InterestButton.tsx | 62 +++++++++++++++------ sentry.client.config.ts | 15 +++++ sentry.edge.config.ts | 23 +++++++- sentry.server.config.ts | 28 +++++++++- 5 files changed, 115 insertions(+), 22 deletions(-) diff --git a/app/[locale]/admin/events/EventForm.tsx b/app/[locale]/admin/events/EventForm.tsx index 2e19960..d931fed 100644 --- a/app/[locale]/admin/events/EventForm.tsx +++ b/app/[locale]/admin/events/EventForm.tsx @@ -58,6 +58,15 @@ export function EventForm({ initial }: Props) { status: (fd.get("status") as EventStatus) ?? "draft", }; + // endTime > startTime 客户端校验(issue #302 P2-1)。 + // 后端有相同校验作为权威,前端这层是防止管理员在 UI 上明显手抖。 + // 两个 ISO 字符串字典序与时间序一致,可以直接 string compare。 + if (req.startTime && req.endTime && req.endTime <= req.startTime) { + setError("结束时间必须晚于开始时间"); + setSubmitting(false); + return; + } + try { if (initial) { await updateEvent(initial.id, req); diff --git a/app/[locale]/events/[id]/InterestButton.tsx b/app/[locale]/events/[id]/InterestButton.tsx index dc423f9..87fbab4 100644 --- a/app/[locale]/events/[id]/InterestButton.tsx +++ b/app/[locale]/events/[id]/InterestButton.tsx @@ -11,7 +11,7 @@ * 不做 SWR 集成:交互简单(只关心自己的点击),直接 useState + fetch 更直白 */ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/use-auth"; @@ -37,6 +37,24 @@ export function InterestButton({ const [count, setCount] = useState(initialCount); const [interested, setInterested] = useState(initialInterested); const [loading, setLoading] = useState(false); + // 失败提示(issue #302 P1-1):之前 catch 静默吞错,乐观 UI 回滚后用户 + // 看到数字"动了一下又回去"以为按钮坏了。短暂展示一行红字 3 秒消失, + // 与 SettingsForm 的 toast 思路一致但内联化(按钮位置紧凑,不强插全局 toast) + const [errorMsg, setErrorMsg] = useState(null); + const errorTimerRef = useRef | null>(null); + + // 卸载时清掉 timer,避免对已 unmount 的组件 setState 报警 + useEffect(() => { + return () => { + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + }; + }, []); + + function flashError(msg: string) { + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + setErrorMsg(msg); + errorTimerRef.current = setTimeout(() => setErrorMsg(null), 3000); + } if (status === "unauthenticated") { return ( @@ -74,28 +92,40 @@ export function InterestButton({ // 用后端返回的权威值覆盖乐观值,避免竞争 setCount(json.data.count); setInterested(json.data.interested); - } catch { - // 回滚 + } catch (err) { + // 回滚 + 显式 toast,避免静默吞错(issue #302 P1-1) setInterested(prevInterested); setCount(prevCount); + flashError(err instanceof Error ? err.message : "操作失败,请重试"); } finally { setLoading(false); } }; return ( - +
+ + {errorMsg && ( + + {errorMsg} + + )} +
); } diff --git a/sentry.client.config.ts b/sentry.client.config.ts index af093f2..3e07a76 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -6,6 +6,9 @@ * - tracesSampleRate 0.1:10% 的页面 transaction 采样足够看性能趋势 * - 关闭 Session Replay:它是另外的独立配额(小),开了容易炸 * - 不启用 profiling(需要付费) + * + * beforeSend:过滤敏感请求头,避免 satoken / cookie / authorization 等 + * 凭据被原样上报到 Sentry(合规 + 减少误用攻击面,issue #302 P1-2)。 */ import * as Sentry from "@sentry/nextjs"; @@ -19,4 +22,16 @@ Sentry.init({ replaysOnErrorSampleRate: 0, // 线上开 false 省日志;排障时临时改 true debug: false, + beforeSend(event) { + if (event.request?.headers) { + const h = event.request.headers as Record; + delete h.satoken; + delete h.Satoken; + delete h.cookie; + delete h.Cookie; + delete h.authorization; + delete h.Authorization; + } + return event; + }, }); diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index c2e149e..46f301c 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -1,15 +1,32 @@ /** * Sentry Edge runtime 初始化(middleware 及未来可能出现的 Edge routes)。 - * 当前仓库根目录的 middleware.ts 即在此 runtime 执行(IP geo → locale cookie)。 - * 启用条件:production 构建 + DSN 已配置,避免 DSN 漏配时 SDK 启动时打告警。 + * 当前仓库根目录的 proxy.ts(Next.js 16 改名)即在此 runtime 执行。 + * + * 启用条件:production 构建 + DSN 已配置,避免 DSN 漏配时 SDK 启动时打 + * 告警。读 SENTRY_DSN 私有 env(issue #302 P2-2),fallback 到旧的 + * NEXT_PUBLIC_SENTRY_DSN 兼容现有部署。 + * + * beforeSend:过滤敏感请求头(issue #302 P1-2),避免凭据上报。 */ import * as Sentry from "@sentry/nextjs"; -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; +const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, tracesSampleRate: 0.1, debug: false, + beforeSend(event) { + if (event.request?.headers) { + const h = event.request.headers as Record; + delete h.satoken; + delete h.Satoken; + delete h.cookie; + delete h.Cookie; + delete h.authorization; + delete h.Authorization; + } + return event; + }, }); diff --git a/sentry.server.config.ts b/sentry.server.config.ts index e240328..ae52292 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -1,15 +1,37 @@ /** * Sentry Node.js runtime 初始化(Next.js API routes / Server Components / RSC)。 - * 与 client 同策略:production 且 DSN 已配置时启用,traces 10%,无 replay/profiling。 - * 加 DSN 校验是为了避免漏配 env 时 SDK 初始化打告警日志(Copilot CR)。 + * + * 与 client 同策略:production 且 DSN 已配置时启用,traces 10%, + * 无 replay/profiling。加 DSN 校验是为了避免漏配 env 时 SDK 初始化打告 + * 警日志(Copilot CR)。 + * + * Server / Edge 端读 SENTRY_DSN(私有 env)而非 NEXT_PUBLIC_SENTRY_DSN + * (issue #302 P2-2)。NEXT_PUBLIC_ 前缀的 env 会打进客户端 bundle, + * client config 必须用 public 那份;server / edge 没必要再暴露一份, + * 走私有 env 更干净。两个 env 都没配时回退读 NEXT_PUBLIC_ 兜底,避免 + * 旧部署在迁移过程中突然丢 Sentry 上报。 + * + * beforeSend:过滤敏感请求头(issue #302 P1-2),与 client config 同款。 */ import * as Sentry from "@sentry/nextjs"; -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; +const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, tracesSampleRate: 0.1, debug: false, + beforeSend(event) { + if (event.request?.headers) { + const h = event.request.headers as Record; + delete h.satoken; + delete h.Satoken; + delete h.cookie; + delete h.Cookie; + delete h.authorization; + delete h.Authorization; + } + return event; + }, });