Skip to content

Commit 30537dd

Browse files
chore(quality): issue #302 剩余 P1 + P2 一锅端
清掉 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 单例
1 parent 6ca90b1 commit 30537dd

5 files changed

Lines changed: 115 additions & 22 deletions

File tree

app/[locale]/admin/events/EventForm.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ export function EventForm({ initial }: Props) {
5858
status: (fd.get("status") as EventStatus) ?? "draft",
5959
};
6060

61+
// endTime > startTime 客户端校验(issue #302 P2-1)。
62+
// 后端有相同校验作为权威,前端这层是防止管理员在 UI 上明显手抖。
63+
// 两个 ISO 字符串字典序与时间序一致,可以直接 string compare。
64+
if (req.startTime && req.endTime && req.endTime <= req.startTime) {
65+
setError("结束时间必须晚于开始时间");
66+
setSubmitting(false);
67+
return;
68+
}
69+
6170
try {
6271
if (initial) {
6372
await updateEvent(initial.id, req);

app/[locale]/events/[id]/InterestButton.tsx

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* 不做 SWR 集成:交互简单(只关心自己的点击),直接 useState + fetch 更直白
1212
*/
1313

14-
import { useState } from "react";
14+
import { useEffect, useRef, useState } from "react";
1515
import { useRouter } from "next/navigation";
1616
import { useAuth } from "@/lib/use-auth";
1717

@@ -37,6 +37,24 @@ export function InterestButton({
3737
const [count, setCount] = useState(initialCount);
3838
const [interested, setInterested] = useState(initialInterested);
3939
const [loading, setLoading] = useState(false);
40+
// 失败提示(issue #302 P1-1):之前 catch 静默吞错,乐观 UI 回滚后用户
41+
// 看到数字"动了一下又回去"以为按钮坏了。短暂展示一行红字 3 秒消失,
42+
// 与 SettingsForm 的 toast 思路一致但内联化(按钮位置紧凑,不强插全局 toast)
43+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
44+
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
45+
46+
// 卸载时清掉 timer,避免对已 unmount 的组件 setState 报警
47+
useEffect(() => {
48+
return () => {
49+
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
50+
};
51+
}, []);
52+
53+
function flashError(msg: string) {
54+
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
55+
setErrorMsg(msg);
56+
errorTimerRef.current = setTimeout(() => setErrorMsg(null), 3000);
57+
}
4058

4159
if (status === "unauthenticated") {
4260
return (
@@ -74,28 +92,40 @@ export function InterestButton({
7492
// 用后端返回的权威值覆盖乐观值,避免竞争
7593
setCount(json.data.count);
7694
setInterested(json.data.interested);
77-
} catch {
78-
// 回滚
95+
} catch (err) {
96+
// 回滚 + 显式 toast,避免静默吞错(issue #302 P1-1)
7997
setInterested(prevInterested);
8098
setCount(prevCount);
99+
flashError(err instanceof Error ? err.message : "操作失败,请重试");
81100
} finally {
82101
setLoading(false);
83102
}
84103
};
85104

86105
return (
87-
<button
88-
type="button"
89-
disabled={loading}
90-
onClick={toggle}
91-
className={`font-mono text-xs uppercase tracking-widest px-4 py-2 border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
92-
interested
93-
? "border-[#CC0000] bg-[#CC0000] text-white hover:bg-transparent hover:text-[#CC0000]"
94-
: "border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)]"
95-
}`}
96-
>
97-
{interested ? "已标记 · " : "感兴趣 · "}
98-
{count}
99-
</button>
106+
<div className="flex items-center gap-3">
107+
<button
108+
type="button"
109+
disabled={loading}
110+
onClick={toggle}
111+
className={`font-mono text-xs uppercase tracking-widest px-4 py-2 border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
112+
interested
113+
? "border-[#CC0000] bg-[#CC0000] text-white hover:bg-transparent hover:text-[#CC0000]"
114+
: "border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)]"
115+
}`}
116+
>
117+
{interested ? "已标记 · " : "感兴趣 · "}
118+
{count}
119+
</button>
120+
{errorMsg && (
121+
<span
122+
role="alert"
123+
aria-live="polite"
124+
className="font-mono text-xs text-red-600 dark:text-red-400"
125+
>
126+
{errorMsg}
127+
</span>
128+
)}
129+
</div>
100130
);
101131
}

sentry.client.config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
* - tracesSampleRate 0.1:10% 的页面 transaction 采样足够看性能趋势
77
* - 关闭 Session Replay:它是另外的独立配额(小),开了容易炸
88
* - 不启用 profiling(需要付费)
9+
*
10+
* beforeSend:过滤敏感请求头,避免 satoken / cookie / authorization 等
11+
* 凭据被原样上报到 Sentry(合规 + 减少误用攻击面,issue #302 P1-2)。
912
*/
1013
import * as Sentry from "@sentry/nextjs";
1114

@@ -19,4 +22,16 @@ Sentry.init({
1922
replaysOnErrorSampleRate: 0,
2023
// 线上开 false 省日志;排障时临时改 true
2124
debug: false,
25+
beforeSend(event) {
26+
if (event.request?.headers) {
27+
const h = event.request.headers as Record<string, string>;
28+
delete h.satoken;
29+
delete h.Satoken;
30+
delete h.cookie;
31+
delete h.Cookie;
32+
delete h.authorization;
33+
delete h.Authorization;
34+
}
35+
return event;
36+
},
2237
});

sentry.edge.config.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
11
/**
22
* Sentry Edge runtime 初始化(middleware 及未来可能出现的 Edge routes)。
3-
* 当前仓库根目录的 middleware.ts 即在此 runtime 执行(IP geo → locale cookie)。
4-
* 启用条件:production 构建 + DSN 已配置,避免 DSN 漏配时 SDK 启动时打告警。
3+
* 当前仓库根目录的 proxy.ts(Next.js 16 改名)即在此 runtime 执行。
4+
*
5+
* 启用条件:production 构建 + DSN 已配置,避免 DSN 漏配时 SDK 启动时打
6+
* 告警。读 SENTRY_DSN 私有 env(issue #302 P2-2),fallback 到旧的
7+
* NEXT_PUBLIC_SENTRY_DSN 兼容现有部署。
8+
*
9+
* beforeSend:过滤敏感请求头(issue #302 P1-2),避免凭据上报。
510
*/
611
import * as Sentry from "@sentry/nextjs";
712

8-
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
13+
const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN;
914

1015
Sentry.init({
1116
dsn,
1217
enabled: process.env.NODE_ENV === "production" && !!dsn,
1318
tracesSampleRate: 0.1,
1419
debug: false,
20+
beforeSend(event) {
21+
if (event.request?.headers) {
22+
const h = event.request.headers as Record<string, string>;
23+
delete h.satoken;
24+
delete h.Satoken;
25+
delete h.cookie;
26+
delete h.Cookie;
27+
delete h.authorization;
28+
delete h.Authorization;
29+
}
30+
return event;
31+
},
1532
});

sentry.server.config.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
11
/**
22
* Sentry Node.js runtime 初始化(Next.js API routes / Server Components / RSC)。
3-
* 与 client 同策略:production 且 DSN 已配置时启用,traces 10%,无 replay/profiling。
4-
* 加 DSN 校验是为了避免漏配 env 时 SDK 初始化打告警日志(Copilot CR)。
3+
*
4+
* 与 client 同策略:production 且 DSN 已配置时启用,traces 10%,
5+
* 无 replay/profiling。加 DSN 校验是为了避免漏配 env 时 SDK 初始化打告
6+
* 警日志(Copilot CR)。
7+
*
8+
* Server / Edge 端读 SENTRY_DSN(私有 env)而非 NEXT_PUBLIC_SENTRY_DSN
9+
* (issue #302 P2-2)。NEXT_PUBLIC_ 前缀的 env 会打进客户端 bundle,
10+
* client config 必须用 public 那份;server / edge 没必要再暴露一份,
11+
* 走私有 env 更干净。两个 env 都没配时回退读 NEXT_PUBLIC_ 兜底,避免
12+
* 旧部署在迁移过程中突然丢 Sentry 上报。
13+
*
14+
* beforeSend:过滤敏感请求头(issue #302 P1-2),与 client config 同款。
515
*/
616
import * as Sentry from "@sentry/nextjs";
717

8-
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
18+
const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN;
919

1020
Sentry.init({
1121
dsn,
1222
enabled: process.env.NODE_ENV === "production" && !!dsn,
1323
tracesSampleRate: 0.1,
1424
debug: false,
25+
beforeSend(event) {
26+
if (event.request?.headers) {
27+
const h = event.request.headers as Record<string, string>;
28+
delete h.satoken;
29+
delete h.Satoken;
30+
delete h.cookie;
31+
delete h.Cookie;
32+
delete h.authorization;
33+
delete h.Authorization;
34+
}
35+
return event;
36+
},
1537
});

0 commit comments

Comments
 (0)