fix: 修复代码评审发现的 5 个问题

- P1 存量库回填:新增 0002 迁移,pricing/featured 从种子标签幂等回填,
  升级已有数据库不再出现定价筛选与首页精选无数据
- P1 Newsletter 双向同步:抽取 syncResendContact 助手并显式检查 SDK
  返回值中的 error 字段(Resend API 错误不抛异常);退订接口同步
  Resend Audience 退订状态,重新订阅恢复订阅状态;订阅(active 分支)
  与退订链接均可重复触发同步,作为同步失败后的重试路径
- P1 后台表单:新增/编辑工具支持设置定价(含清空)与首页精选;
  顺带修掉 status 选项用 t("status")==="状态" 判断语言的 hack
- P2 分类缓存失效:分类增删改同时失效首页、工具列表与 ISR 分类落地页
- P2 收藏竞态:服务端改为 insert-first 原子 toggle,客户端加单工具
  pending 锁并以服务端返回状态收敛乐观更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
CoolChange.AI
2026-07-31 21:24:24 -07:00
co-authored by Claude Fable 5
parent d0e3f72d6a
commit 41d9fcac06
14 changed files with 1366 additions and 48 deletions
+9 -1
View File
@@ -319,7 +319,15 @@
"updated": "Tool updated", "updated": "Tool updated",
"created": "Tool created", "created": "Tool created",
"updateFailed": "Update failed", "updateFailed": "Update failed",
"createFailed": "Create failed" "createFailed": "Create failed",
"statusDraft": "Draft",
"statusPublished": "Published",
"pricing": "Pricing",
"pricingNone": "Not set",
"pricingFree": "Free",
"pricingFreemium": "Freemium",
"pricingPaid": "Paid",
"featured": "Featured on homepage"
} }
}, },
"categoriesManagement": { "categoriesManagement": {
+9 -1
View File
@@ -319,7 +319,15 @@
"updated": "工具已更新", "updated": "工具已更新",
"created": "工具已创建", "created": "工具已创建",
"updateFailed": "更新失败", "updateFailed": "更新失败",
"createFailed": "创建失败" "createFailed": "创建失败",
"statusDraft": "草稿",
"statusPublished": "已发布",
"pricing": "定价",
"pricingNone": "未设置",
"pricingFree": "免费",
"pricingFreemium": "免费增值",
"pricingPaid": "付费",
"featured": "首页精选"
} }
}, },
"categoriesManagement": { "categoriesManagement": {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { newsletterSubscription } from "@/lib/db/schema"; import { newsletterSubscription } from "@/lib/db/schema";
import { syncResendContact } from "@/lib/newsletter";
/** /**
* 退订链接处理:GET /api/newsletter/unsubscribe?token=xxx * 退订链接处理:GET /api/newsletter/unsubscribe?token=xxx
@@ -29,6 +30,11 @@ export async function GET(req: NextRequest) {
.where(eq(newsletterSubscription.id, subscription.id)); .where(eq(newsletterSubscription.id, subscription.id));
} }
// 无论本地是否已退订都尝试同步 Resend:
// 上次同步失败时,用户再次打开退订链接即是重试,
// 否则通过 Audience 群发时用户仍会继续收到邮件
await syncResendContact(subscription.email, true);
return new NextResponse( return new NextResponse(
"<html><body style=\"font-family:sans-serif;text-align:center;padding-top:80px\"><h1>Unsubscribed</h1><p>You have been unsubscribed from the Toolso.AI newsletter.</p></body></html>", "<html><body style=\"font-family:sans-serif;text-align:center;padding-top:80px\"><h1>Unsubscribed</h1><p>You have been unsubscribed from the Toolso.AI newsletter.</p></body></html>",
{ headers: { "Content-Type": "text/html; charset=utf-8" } } { headers: { "Content-Type": "text/html; charset=utf-8" } }
@@ -0,0 +1,15 @@
-- 回填存量数据的 pricing / featured 字段(0001 只加列不填值)。
-- 从种子数据的定价标签与"编辑推荐"标签推导,幂等:
-- 已回填过或未使用种子标签体系的库执行后均无副作用。
UPDATE tool SET pricing = 'free'
FROM tool_tag
WHERE tool_tag.tool_id = tool.id AND tool_tag.tag_id = 'tag_free' AND tool.pricing IS NULL;--> statement-breakpoint
UPDATE tool SET pricing = 'freemium'
FROM tool_tag
WHERE tool_tag.tool_id = tool.id AND tool_tag.tag_id = 'tag_freemium' AND tool.pricing IS NULL;--> statement-breakpoint
UPDATE tool SET pricing = 'paid'
FROM tool_tag
WHERE tool_tag.tool_id = tool.id AND tool_tag.tag_id = 'tag_paid' AND tool.pricing IS NULL;--> statement-breakpoint
UPDATE tool SET featured = true
FROM tool_tag
WHERE tool_tag.tool_id = tool.id AND tool_tag.tag_id = 'tag_editors_choice' AND tool.featured = false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1785552096235, "when": 1785552096235,
"tag": "0001_lumpy_proudstar", "tag": "0001_lumpy_proudstar",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785555896005,
"tag": "0002_backfill_pricing_featured",
"breakpoints": true
} }
] ]
} }
@@ -7,6 +7,7 @@ import { db } from "@/lib/db";
import { newsletterSubscription } from "@/lib/db/schema"; import { newsletterSubscription } from "@/lib/db/schema";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { syncResendContact } from "@/lib/newsletter";
const emailSchema = z.string().email().max(255); const emailSchema = z.string().email().max(255);
@@ -39,6 +40,9 @@ export async function subscribeNewsletter(email: string) {
}) })
.where(eq(newsletterSubscription.id, existing.id)); .where(eq(newsletterSubscription.id, existing.id));
} }
// 已订阅用户重复提交同样触发同步——这是此前同步失败后的重试路径,
// 不能提前返回跳过(重新订阅场景也依赖它恢复 Resend 侧状态)
await syncResendContact(normalizedEmail, false);
return { success: true as const }; return { success: true as const };
} }
@@ -55,19 +59,7 @@ export async function subscribeNewsletter(email: string) {
} }
// 可选:同步到 Resend Audience,失败不影响本地订阅结果 // 可选:同步到 Resend Audience,失败不影响本地订阅结果
if (process.env.RESEND_API_KEY && process.env.RESEND_AUDIENCE_ID) { await syncResendContact(normalizedEmail, false);
try {
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.contacts.create({
email: normalizedEmail,
audienceId: process.env.RESEND_AUDIENCE_ID,
unsubscribed: false,
});
} catch (error) {
console.error("Failed to sync contact to Resend:", error);
}
}
return { success: true as const }; return { success: true as const };
} }
+12 -8
View File
@@ -5,6 +5,14 @@ import { category, toolCategory, tool } from "@/lib/db/schema";
import { eq, asc, and, sql } from "drizzle-orm"; import { eq, asc, and, sql } from "drizzle-orm";
import { isAdmin } from "@/lib/auth/admin"; import { isAdmin } from "@/lib/auth/admin";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
// 分类数据出现在首页、工具列表、ISR 分类落地页上,变更需全部失效
function revalidateCategoryPages() {
revalidatePath("/[locale]", "page");
revalidatePath("/[locale]/tools", "page");
revalidatePath("/[locale]/category/[slug]", "page");
revalidatePath("/[locale]/admin/categories", "page");
}
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
// ========== 类型定义 ========== // ========== 类型定义 ==========
@@ -92,8 +100,7 @@ export async function createCategory(input: CreateCategoryInput) {
sortOrder: input.sortOrder ?? 0, sortOrder: input.sortOrder ?? 0,
}); });
revalidatePath("/admin/categories"); revalidateCategoryPages();
revalidatePath("/tools");
return { success: true, id }; return { success: true, id };
} }
@@ -116,8 +123,7 @@ export async function updateCategory(input: UpdateCategoryInput) {
}) })
.where(eq(category.id, id)); .where(eq(category.id, id));
revalidatePath("/admin/categories"); revalidateCategoryPages();
revalidatePath("/tools");
return { success: true }; return { success: true };
} }
@@ -133,8 +139,7 @@ export async function deleteCategory(id: string) {
// 删除分类会自动删除关联(级联删除) // 删除分类会自动删除关联(级联删除)
await db.delete(category).where(eq(category.id, id)); await db.delete(category).where(eq(category.id, id));
revalidatePath("/admin/categories"); revalidateCategoryPages();
revalidatePath("/tools");
return { success: true }; return { success: true };
} }
@@ -156,7 +161,6 @@ export async function updateCategorySortOrder(
.where(eq(category.id, item.id)); .where(eq(category.id, item.id));
} }
revalidatePath("/admin/categories"); revalidateCategoryPages();
revalidatePath("/tools");
return { success: true }; return { success: true };
} }
+11 -10
View File
@@ -20,21 +20,22 @@ export async function toggleFavorite(toolId: string) {
return { success: false as const, error: "UNAUTHORIZED" }; return { success: false as const, error: "UNAUTHORIZED" };
} }
const [existing] = await db // 原子 toggle:先尝试插入,冲突(已收藏)则删除。
.select({ toolId: favorite.toolId }) // 避免"先查再写"在并发点击下的竞态,返回值即数据库最终状态。
.from(favorite) const inserted = await db
.where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId))) .insert(favorite)
.limit(1); .values({ userId, toolId })
.onConflictDoNothing()
.returning({ toolId: favorite.toolId });
if (inserted.length > 0) {
return { success: true as const, favorited: true };
}
if (existing) {
await db await db
.delete(favorite) .delete(favorite)
.where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId))); .where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId)));
return { success: true as const, favorited: false }; return { success: true as const, favorited: false };
}
await db.insert(favorite).values({ userId, toolId }).onConflictDoNothing();
return { success: true as const, favorited: true };
} }
/** /**
+1 -1
View File
@@ -21,7 +21,7 @@ export interface CreateToolInput {
descriptionEn?: string; descriptionEn?: string;
nameZh?: string; nameZh?: string;
descriptionZh?: string; descriptionZh?: string;
pricing?: ToolPricing; pricing?: ToolPricing | null;
featured?: boolean; featured?: boolean;
status?: ToolStatus; status?: ToolStatus;
categoryIds?: string[]; categoryIds?: string[];
@@ -38,6 +38,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
const t = useTranslations("tools.favorites"); const t = useTranslations("tools.favorites");
const isAuthenticated = !!session.data?.user; const isAuthenticated = !!session.data?.user;
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set()); const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (session.isPending) return; if (session.isPending) return;
@@ -56,7 +57,11 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
return; return;
} }
// 乐观更新,失败回滚 // 单个工具同一时刻只允许一个进行中的请求,防止连点竞态
if (pendingIds.has(toolId)) return;
setPendingIds((prev) => new Set(prev).add(toolId));
// 乐观更新;响应到达后以服务端返回的最终状态收敛
setFavoriteIds((prev) => { setFavoriteIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(toolId)) { if (next.has(toolId)) {
@@ -67,22 +72,37 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
return next; return next;
}); });
toggleFavorite(toolId).then((result) => { toggleFavorite(toolId)
if (!result.success) { .then((result) => {
setFavoriteIds((prev) => { setFavoriteIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (result.success) {
if (result.favorited) {
next.add(toolId);
} else {
next.delete(toolId);
}
} else {
// 失败回滚乐观更新
if (next.has(toolId)) { if (next.has(toolId)) {
next.delete(toolId); next.delete(toolId);
} else { } else {
next.add(toolId); next.add(toolId);
} }
}
return next;
});
if (!result.success) toast.error(t("error"));
})
.finally(() => {
setPendingIds((prev) => {
const next = new Set(prev);
next.delete(toolId);
return next; return next;
}); });
toast.error(t("error"));
}
}); });
}, },
[isAuthenticated, locale, router, t] [isAuthenticated, locale, pendingIds, router, t]
); );
const value = useMemo( const value = useMemo(
@@ -25,6 +25,8 @@ interface ToolData {
descriptionEn: string | null; descriptionEn: string | null;
nameZh: string | null; nameZh: string | null;
descriptionZh: string | null; descriptionZh: string | null;
pricing: string | null;
featured: boolean;
status: string; status: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -68,6 +70,8 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) {
nameZh: tool?.nameZh || "", nameZh: tool?.nameZh || "",
descriptionZh: tool?.descriptionZh || "", descriptionZh: tool?.descriptionZh || "",
status: tool?.status || "draft", status: tool?.status || "draft",
pricing: tool?.pricing || "",
featured: tool?.featured || false,
categoryIds: [] as string[], categoryIds: [] as string[],
tagIds: [] as string[], tagIds: [] as string[],
}); });
@@ -109,6 +113,11 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) {
id: tool.id, id: tool.id,
...formData, ...formData,
status: formData.status as "draft" | "published", status: formData.status as "draft" | "published",
pricing: (formData.pricing || null) as
| "free"
| "freemium"
| "paid"
| null,
domain: formData.domain || undefined, domain: formData.domain || undefined,
websiteUrl: formData.websiteUrl || undefined, websiteUrl: formData.websiteUrl || undefined,
coverImageUrl: formData.coverImageUrl || undefined, coverImageUrl: formData.coverImageUrl || undefined,
@@ -120,6 +129,7 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) {
onSave({ onSave({
...tool, ...tool,
...formData, ...formData,
pricing: formData.pricing || null,
domain: formData.domain || null, domain: formData.domain || null,
websiteUrl: formData.websiteUrl || null, websiteUrl: formData.websiteUrl || null,
coverImageUrl: formData.coverImageUrl || null, coverImageUrl: formData.coverImageUrl || null,
@@ -133,6 +143,11 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) {
} else { } else {
const result = await createTool({ const result = await createTool({
...formData, ...formData,
pricing: (formData.pricing || null) as
| "free"
| "freemium"
| "paid"
| null,
domain: formData.domain || undefined, domain: formData.domain || undefined,
websiteUrl: formData.websiteUrl || undefined, websiteUrl: formData.websiteUrl || undefined,
coverImageUrl: formData.coverImageUrl || undefined, coverImageUrl: formData.coverImageUrl || undefined,
@@ -145,6 +160,7 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) {
onSave({ onSave({
id: result.id, id: result.id,
...formData, ...formData,
pricing: formData.pricing || null,
domain: formData.domain || null, domain: formData.domain || null,
websiteUrl: formData.websiteUrl || null, websiteUrl: formData.websiteUrl || null,
coverImageUrl: formData.coverImageUrl || null, coverImageUrl: formData.coverImageUrl || null,
@@ -230,12 +246,45 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) {
} }
className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground" className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground"
> >
<option value="draft">{t("status") === "状态" ? "草稿" : "Draft"}</option> <option value="draft">{t("statusDraft")}</option>
<option value="published">{t("status") === "状态" ? "已发布" : "Published"}</option> <option value="published">{t("statusPublished")}</option>
</select> </select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
{t("pricing")}
</label>
<select
value={formData.pricing}
onChange={(e) =>
setFormData({ ...formData, pricing: e.target.value })
}
className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground"
>
<option value="">{t("pricingNone")}</option>
<option value="free">{t("pricingFree")}</option>
<option value="freemium">{t("pricingFreemium")}</option>
<option value="paid">{t("pricingPaid")}</option>
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm font-medium text-foreground cursor-pointer">
<input
type="checkbox"
checked={formData.featured}
onChange={(e) =>
setFormData({ ...formData, featured: e.target.checked })
}
className="h-4 w-4 rounded border-border"
/>
{t("featured")}
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-muted-foreground mb-1"> <label className="block text-sm font-medium text-muted-foreground mb-1">
@@ -32,6 +32,8 @@ interface ToolData {
descriptionEn: string | null; descriptionEn: string | null;
nameZh: string | null; nameZh: string | null;
descriptionZh: string | null; descriptionZh: string | null;
pricing: string | null;
featured: boolean;
status: string; status: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
+50
View File
@@ -0,0 +1,50 @@
/**
* Resend Audience 联系人同步(订阅/退订双向)。
*
* - 未配置 RESEND_API_KEY / RESEND_AUDIENCE_ID 时视为无需同步,返回 true。
* - Resend SDK 的 API 错误通过返回值的 error 字段表达而非抛异常,
* 必须显式检查;网络异常与 API 错误统一返回 false 并记录日志。
* - 本地表是事实来源,调用方不因同步失败而失败;
* 订阅与退订入口均可重复触发本函数,作为失败后的重试路径。
*/
export async function syncResendContact(
email: string,
unsubscribed: boolean
): Promise<boolean> {
const apiKey = process.env.RESEND_API_KEY;
const audienceId = process.env.RESEND_AUDIENCE_ID;
if (!apiKey || !audienceId) return true;
try {
const { Resend } = await import("resend");
const resend = new Resend(apiKey);
if (!unsubscribed) {
// 仅用于确保联系人存在,"已存在"类报错可忽略,以下方 update 结果为准
await resend.contacts
.create({ email, audienceId, unsubscribed: false })
.catch(() => undefined);
}
const { error } = await resend.contacts.update({
email,
audienceId,
unsubscribed,
});
if (error) {
console.error(
`Resend contact sync failed (${unsubscribed ? "unsubscribe" : "subscribe"}, email=${email}):`,
error
);
return false;
}
return true;
} catch (error) {
console.error(
`Resend contact sync threw (${unsubscribed ? "unsubscribe" : "subscribe"}, email=${email}):`,
error
);
return false;
}
}