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:
co-authored by
Claude Fable 5
parent
d0e3f72d6a
commit
41d9fcac06
+9
-1
@@ -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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (existing) {
|
if (inserted.length > 0) {
|
||||||
await db
|
return { success: true as const, favorited: true };
|
||||||
.delete(favorite)
|
|
||||||
.where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId)));
|
|
||||||
return { success: true as const, favorited: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(favorite).values({ userId, toolId }).onConflictDoNothing();
|
await db
|
||||||
return { success: true as const, favorited: true };
|
.delete(favorite)
|
||||||
|
.where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId)));
|
||||||
|
return { success: true as const, favorited: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 (next.has(toolId)) {
|
if (result.success) {
|
||||||
next.delete(toolId);
|
if (result.favorited) {
|
||||||
|
next.add(toolId);
|
||||||
|
} else {
|
||||||
|
next.delete(toolId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
next.add(toolId);
|
// 失败回滚乐观更新
|
||||||
|
if (next.has(toolId)) {
|
||||||
|
next.delete(toolId);
|
||||||
|
} else {
|
||||||
|
next.add(toolId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
toast.error(t("error"));
|
if (!result.success) toast.error(t("error"));
|
||||||
}
|
})
|
||||||
});
|
.finally(() => {
|
||||||
|
setPendingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(toolId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user