From 41d9fcac06e40cf40966f588eac8b258f5deb513 Mon Sep 17 00:00:00 2001 From: "CoolChange.AI" Date: Fri, 31 Jul 2026 21:24:24 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E5=8F=91=E7=8E=B0=E7=9A=84=205=20=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- messages/en.json | 10 +- messages/zh.json | 10 +- src/app/api/newsletter/unsubscribe/route.ts | 6 + .../0002_backfill_pricing_featured.sql | 15 + src/drizzle/meta/0002_snapshot.json | 1156 +++++++++++++++++ src/drizzle/meta/_journal.json | 7 + .../marketing/actions/newsletter-actions.ts | 18 +- .../tools/actions/category-actions.ts | 20 +- .../tools/actions/favorite-actions.ts | 25 +- src/features/tools/actions/tool-actions.ts | 2 +- .../tools/components/favorite-button.tsx | 40 +- .../tools/components/tool-form-modal.tsx | 53 +- src/features/tools/components/tools-table.tsx | 2 + src/lib/newsletter.ts | 50 + 14 files changed, 1366 insertions(+), 48 deletions(-) create mode 100644 src/drizzle/0002_backfill_pricing_featured.sql create mode 100644 src/drizzle/meta/0002_snapshot.json create mode 100644 src/lib/newsletter.ts diff --git a/messages/en.json b/messages/en.json index 4c98be9..5ee1097 100644 --- a/messages/en.json +++ b/messages/en.json @@ -319,7 +319,15 @@ "updated": "Tool updated", "created": "Tool created", "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": { diff --git a/messages/zh.json b/messages/zh.json index 26da7b2..aab786b 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -319,7 +319,15 @@ "updated": "工具已更新", "created": "工具已创建", "updateFailed": "更新失败", - "createFailed": "创建失败" + "createFailed": "创建失败", + "statusDraft": "草稿", + "statusPublished": "已发布", + "pricing": "定价", + "pricingNone": "未设置", + "pricingFree": "免费", + "pricingFreemium": "免费增值", + "pricingPaid": "付费", + "featured": "首页精选" } }, "categoriesManagement": { diff --git a/src/app/api/newsletter/unsubscribe/route.ts b/src/app/api/newsletter/unsubscribe/route.ts index 2c8a2b1..efb984e 100644 --- a/src/app/api/newsletter/unsubscribe/route.ts +++ b/src/app/api/newsletter/unsubscribe/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { newsletterSubscription } from "@/lib/db/schema"; +import { syncResendContact } from "@/lib/newsletter"; /** * 退订链接处理:GET /api/newsletter/unsubscribe?token=xxx @@ -29,6 +30,11 @@ export async function GET(req: NextRequest) { .where(eq(newsletterSubscription.id, subscription.id)); } + // 无论本地是否已退订都尝试同步 Resend: + // 上次同步失败时,用户再次打开退订链接即是重试, + // 否则通过 Audience 群发时用户仍会继续收到邮件 + await syncResendContact(subscription.email, true); + return new NextResponse( "

Unsubscribed

You have been unsubscribed from the Toolso.AI newsletter.

", { headers: { "Content-Type": "text/html; charset=utf-8" } } diff --git a/src/drizzle/0002_backfill_pricing_featured.sql b/src/drizzle/0002_backfill_pricing_featured.sql new file mode 100644 index 0000000..8095e80 --- /dev/null +++ b/src/drizzle/0002_backfill_pricing_featured.sql @@ -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; diff --git a/src/drizzle/meta/0002_snapshot.json b/src/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..ecf4927 --- /dev/null +++ b/src/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1156 @@ +{ + "id": "f6aa2e5e-9086-4b6c-b73b-391e82018882", + "prevId": "070779c8-a8ca-4934-a8f6-ab5edb368663", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category": { + "name": "category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name_en": { + "name": "name_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_zh": { + "name": "name_zh", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_zh": { + "name": "description_zh", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "category_slug_idx": { + "name": "category_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "category_sort_order_idx": { + "name": "category_sort_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category_slug_unique": { + "name": "category_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorite": { + "name": "favorite", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "favorite_tool_id_idx": { + "name": "favorite_tool_id_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "favorite_user_id_user_id_fk": { + "name": "favorite_user_id_user_id_fk", + "tableFrom": "favorite", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "favorite_tool_id_tool_id_fk": { + "name": "favorite_tool_id_tool_id_fk", + "tableFrom": "favorite", + "columnsFrom": [ + "tool_id" + ], + "tableTo": "tool", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "favorite_user_id_tool_id_pk": { + "name": "favorite_user_id_tool_id_pk", + "columns": [ + "user_id", + "tool_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_en": { + "name": "name_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_zh": { + "name": "name_zh", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tag_slug_idx": { + "name": "tag_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_slug_unique": { + "name": "tag_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image_url": { + "name": "cover_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_en": { + "name": "name_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_zh": { + "name": "name_zh", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_zh": { + "name": "description_zh", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing": { + "name": "pricing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_slug_idx": { + "name": "tool_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tool_status_idx": { + "name": "tool_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tool_featured_idx": { + "name": "tool_featured_idx", + "columns": [ + { + "expression": "featured", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tool_pricing_idx": { + "name": "tool_pricing_idx", + "columns": [ + { + "expression": "pricing", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tool_slug_unique": { + "name": "tool_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_category": { + "name": "tool_category", + "schema": "", + "columns": { + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_category_tool_id_idx": { + "name": "tool_category_tool_id_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tool_category_category_id_idx": { + "name": "tool_category_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "tool_category_tool_id_tool_id_fk": { + "name": "tool_category_tool_id_tool_id_fk", + "tableFrom": "tool_category", + "columnsFrom": [ + "tool_id" + ], + "tableTo": "tool", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tool_category_category_id_category_id_fk": { + "name": "tool_category_category_id_category_id_fk", + "tableFrom": "tool_category", + "columnsFrom": [ + "category_id" + ], + "tableTo": "category", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "tool_category_tool_id_category_id_pk": { + "name": "tool_category_tool_id_category_id_pk", + "columns": [ + "tool_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_tag": { + "name": "tool_tag", + "schema": "", + "columns": { + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_tag_tool_id_idx": { + "name": "tool_tag_tool_id_idx", + "columns": [ + { + "expression": "tool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tool_tag_tag_id_idx": { + "name": "tool_tag_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "tool_tag_tool_id_tool_id_fk": { + "name": "tool_tag_tool_id_tool_id_fk", + "tableFrom": "tool_tag", + "columnsFrom": [ + "tool_id" + ], + "tableTo": "tool", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tool_tag_tag_id_tag_id_fk": { + "name": "tool_tag_tag_id_tag_id_fk", + "tableFrom": "tool_tag", + "columnsFrom": [ + "tag_id" + ], + "tableTo": "tag", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "tool_tag_tool_id_tag_id_pk": { + "name": "tool_tag_tool_id_tag_id_pk", + "columns": [ + "tool_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_message": { + "name": "contact_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.newsletter_subscription": { + "name": "newsletter_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "unsubscribe_token": { + "name": "unsubscribe_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscribed_at": { + "name": "subscribed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "newsletter_subscription_status_idx": { + "name": "newsletter_subscription_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "newsletter_subscription_user_id_user_id_fk": { + "name": "newsletter_subscription_user_id_user_id_fk", + "tableFrom": "newsletter_subscription", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "newsletter_subscription_email_unique": { + "name": "newsletter_subscription_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + }, + "newsletter_subscription_unsubscribe_token_unique": { + "name": "newsletter_subscription_unsubscribe_token_unique", + "columns": [ + "unsubscribe_token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/drizzle/meta/_journal.json b/src/drizzle/meta/_journal.json index 2d88a97..e6f9c86 100644 --- a/src/drizzle/meta/_journal.json +++ b/src/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1785552096235, "tag": "0001_lumpy_proudstar", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785555896005, + "tag": "0002_backfill_pricing_featured", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/features/marketing/actions/newsletter-actions.ts b/src/features/marketing/actions/newsletter-actions.ts index 6f32df3..22914f4 100644 --- a/src/features/marketing/actions/newsletter-actions.ts +++ b/src/features/marketing/actions/newsletter-actions.ts @@ -7,6 +7,7 @@ import { db } from "@/lib/db"; import { newsletterSubscription } from "@/lib/db/schema"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { syncResendContact } from "@/lib/newsletter"; const emailSchema = z.string().email().max(255); @@ -39,6 +40,9 @@ export async function subscribeNewsletter(email: string) { }) .where(eq(newsletterSubscription.id, existing.id)); } + // 已订阅用户重复提交同样触发同步——这是此前同步失败后的重试路径, + // 不能提前返回跳过(重新订阅场景也依赖它恢复 Resend 侧状态) + await syncResendContact(normalizedEmail, false); return { success: true as const }; } @@ -55,19 +59,7 @@ export async function subscribeNewsletter(email: string) { } // 可选:同步到 Resend Audience,失败不影响本地订阅结果 - if (process.env.RESEND_API_KEY && process.env.RESEND_AUDIENCE_ID) { - 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); - } - } + await syncResendContact(normalizedEmail, false); return { success: true as const }; } diff --git a/src/features/tools/actions/category-actions.ts b/src/features/tools/actions/category-actions.ts index 561c0ec..346c5ae 100644 --- a/src/features/tools/actions/category-actions.ts +++ b/src/features/tools/actions/category-actions.ts @@ -5,6 +5,14 @@ import { category, toolCategory, tool } from "@/lib/db/schema"; import { eq, asc, and, sql } from "drizzle-orm"; import { isAdmin } from "@/lib/auth/admin"; 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"; // ========== 类型定义 ========== @@ -92,8 +100,7 @@ export async function createCategory(input: CreateCategoryInput) { sortOrder: input.sortOrder ?? 0, }); - revalidatePath("/admin/categories"); - revalidatePath("/tools"); + revalidateCategoryPages(); return { success: true, id }; } @@ -116,8 +123,7 @@ export async function updateCategory(input: UpdateCategoryInput) { }) .where(eq(category.id, id)); - revalidatePath("/admin/categories"); - revalidatePath("/tools"); + revalidateCategoryPages(); return { success: true }; } @@ -133,8 +139,7 @@ export async function deleteCategory(id: string) { // 删除分类会自动删除关联(级联删除) await db.delete(category).where(eq(category.id, id)); - revalidatePath("/admin/categories"); - revalidatePath("/tools"); + revalidateCategoryPages(); return { success: true }; } @@ -156,7 +161,6 @@ export async function updateCategorySortOrder( .where(eq(category.id, item.id)); } - revalidatePath("/admin/categories"); - revalidatePath("/tools"); + revalidateCategoryPages(); return { success: true }; } diff --git a/src/features/tools/actions/favorite-actions.ts b/src/features/tools/actions/favorite-actions.ts index 39d2b05..76397bf 100644 --- a/src/features/tools/actions/favorite-actions.ts +++ b/src/features/tools/actions/favorite-actions.ts @@ -20,21 +20,22 @@ export async function toggleFavorite(toolId: string) { return { success: false as const, error: "UNAUTHORIZED" }; } - const [existing] = await db - .select({ toolId: favorite.toolId }) - .from(favorite) - .where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId))) - .limit(1); + // 原子 toggle:先尝试插入,冲突(已收藏)则删除。 + // 避免"先查再写"在并发点击下的竞态,返回值即数据库最终状态。 + const inserted = await db + .insert(favorite) + .values({ userId, toolId }) + .onConflictDoNothing() + .returning({ toolId: favorite.toolId }); - if (existing) { - await db - .delete(favorite) - .where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId))); - return { success: true as const, favorited: false }; + if (inserted.length > 0) { + return { success: true as const, favorited: true }; } - await db.insert(favorite).values({ userId, toolId }).onConflictDoNothing(); - return { success: true as const, favorited: true }; + await db + .delete(favorite) + .where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId))); + return { success: true as const, favorited: false }; } /** diff --git a/src/features/tools/actions/tool-actions.ts b/src/features/tools/actions/tool-actions.ts index e6d0d3a..ce579dc 100644 --- a/src/features/tools/actions/tool-actions.ts +++ b/src/features/tools/actions/tool-actions.ts @@ -21,7 +21,7 @@ export interface CreateToolInput { descriptionEn?: string; nameZh?: string; descriptionZh?: string; - pricing?: ToolPricing; + pricing?: ToolPricing | null; featured?: boolean; status?: ToolStatus; categoryIds?: string[]; diff --git a/src/features/tools/components/favorite-button.tsx b/src/features/tools/components/favorite-button.tsx index 9edc855..7b21c49 100644 --- a/src/features/tools/components/favorite-button.tsx +++ b/src/features/tools/components/favorite-button.tsx @@ -38,6 +38,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) { const t = useTranslations("tools.favorites"); const isAuthenticated = !!session.data?.user; const [favoriteIds, setFavoriteIds] = useState>(new Set()); + const [pendingIds, setPendingIds] = useState>(new Set()); useEffect(() => { if (session.isPending) return; @@ -56,7 +57,11 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) { return; } - // 乐观更新,失败回滚 + // 单个工具同一时刻只允许一个进行中的请求,防止连点竞态 + if (pendingIds.has(toolId)) return; + setPendingIds((prev) => new Set(prev).add(toolId)); + + // 乐观更新;响应到达后以服务端返回的最终状态收敛 setFavoriteIds((prev) => { const next = new Set(prev); if (next.has(toolId)) { @@ -67,22 +72,37 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) { return next; }); - toggleFavorite(toolId).then((result) => { - if (!result.success) { + toggleFavorite(toolId) + .then((result) => { setFavoriteIds((prev) => { const next = new Set(prev); - if (next.has(toolId)) { - next.delete(toolId); + if (result.success) { + if (result.favorited) { + next.add(toolId); + } else { + next.delete(toolId); + } } else { - next.add(toolId); + // 失败回滚乐观更新 + if (next.has(toolId)) { + next.delete(toolId); + } else { + next.add(toolId); + } } 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( diff --git a/src/features/tools/components/tool-form-modal.tsx b/src/features/tools/components/tool-form-modal.tsx index 8941dbd..d1bf2e3 100644 --- a/src/features/tools/components/tool-form-modal.tsx +++ b/src/features/tools/components/tool-form-modal.tsx @@ -25,6 +25,8 @@ interface ToolData { descriptionEn: string | null; nameZh: string | null; descriptionZh: string | null; + pricing: string | null; + featured: boolean; status: string; createdAt: Date; updatedAt: Date; @@ -68,6 +70,8 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) { nameZh: tool?.nameZh || "", descriptionZh: tool?.descriptionZh || "", status: tool?.status || "draft", + pricing: tool?.pricing || "", + featured: tool?.featured || false, categoryIds: [] as string[], tagIds: [] as string[], }); @@ -109,6 +113,11 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) { id: tool.id, ...formData, status: formData.status as "draft" | "published", + pricing: (formData.pricing || null) as + | "free" + | "freemium" + | "paid" + | null, domain: formData.domain || undefined, websiteUrl: formData.websiteUrl || undefined, coverImageUrl: formData.coverImageUrl || undefined, @@ -120,6 +129,7 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) { onSave({ ...tool, ...formData, + pricing: formData.pricing || null, domain: formData.domain || null, websiteUrl: formData.websiteUrl || null, coverImageUrl: formData.coverImageUrl || null, @@ -133,6 +143,11 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) { } else { const result = await createTool({ ...formData, + pricing: (formData.pricing || null) as + | "free" + | "freemium" + | "paid" + | null, domain: formData.domain || undefined, websiteUrl: formData.websiteUrl || undefined, coverImageUrl: formData.coverImageUrl || undefined, @@ -145,6 +160,7 @@ export function ToolFormModal({ tool, onClose, onSave }: ToolFormModalProps) { onSave({ id: result.id, ...formData, + pricing: formData.pricing || null, domain: formData.domain || null, websiteUrl: formData.websiteUrl || 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" > - - + + +
+
+ + +
+
+ +
+
+