feat: 数据库 schema 扩展(工具字段/收藏/Newsletter/联系表单)

- tool 表新增 pricing(free/freemium/paid)、featured、viewCount 字段
- 新增 favorite、newsletter_subscription、contact_message 表
- auth 表拆分至 schema/auth.ts,schema.ts 改为纯汇出(表定义未变)
- seed-tools.ts 改为直接导入项目 schema,pricing/featured 从标签推导
- 删除 seed-tools-data.sql,种子数据以 seed-tools.ts 为唯一来源(部署本项目必有 Node 环境,pnpm seed:tools 始终可用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
CoolChange.AI
2026-07-31 20:35:17 -07:00
co-authored by Claude Fable 5
parent 8976caaaad
commit 7fd9338f80
9 changed files with 1356 additions and 605 deletions
+40
View File
@@ -0,0 +1,40 @@
CREATE TABLE "favorite" (
"user_id" text NOT NULL,
"tool_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "favorite_user_id_tool_id_pk" PRIMARY KEY("user_id","tool_id")
);
--> statement-breakpoint
CREATE TABLE "contact_message" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"company" text,
"message" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "newsletter_subscription" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"user_id" text,
"status" text DEFAULT 'active' NOT NULL,
"unsubscribe_token" text NOT NULL,
"subscribed_at" timestamp DEFAULT now() NOT NULL,
"unsubscribed_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "newsletter_subscription_email_unique" UNIQUE("email"),
CONSTRAINT "newsletter_subscription_unsubscribe_token_unique" UNIQUE("unsubscribe_token")
);
--> statement-breakpoint
ALTER TABLE "tool" ADD COLUMN "pricing" text;--> statement-breakpoint
ALTER TABLE "tool" ADD COLUMN "featured" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "tool" ADD COLUMN "view_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "favorite" ADD CONSTRAINT "favorite_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "favorite" ADD CONSTRAINT "favorite_tool_id_tool_id_fk" FOREIGN KEY ("tool_id") REFERENCES "public"."tool"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "newsletter_subscription" ADD CONSTRAINT "newsletter_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "favorite_tool_id_idx" ON "favorite" USING btree ("tool_id");--> statement-breakpoint
CREATE INDEX "newsletter_subscription_status_idx" ON "newsletter_subscription" USING btree ("status");--> statement-breakpoint
CREATE INDEX "tool_featured_idx" ON "tool" USING btree ("featured");--> statement-breakpoint
CREATE INDEX "tool_pricing_idx" ON "tool" USING btree ("pricing");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1767366953512,
"tag": "0000_many_tomas",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785552096235,
"tag": "0001_lumpy_proudstar",
"breakpoints": true
}
]
}
+5 -67
View File
@@ -1,70 +1,8 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
// user role: 'admin' | 'user'
role: text("role").default("user").notNull(),
// ban status
banned: boolean("banned").default(false).notNull(),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
// ========== 认证模块 ==========
export * from "./schema/auth";
// ========== 工具模块 ==========
export * from "./schema/tool";
// ========== 营销模块 (Newsletter / 联系表单) ==========
export * from "./schema/marketing";
+67
View File
@@ -0,0 +1,67 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
// user role: 'admin' | 'user'
role: text("role").default("user").notNull(),
// ban status
banned: boolean("banned").default(false).notNull(),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+32
View File
@@ -0,0 +1,32 @@
import { pgTable, text, timestamp, index } from "drizzle-orm/pg-core";
import { user } from "./auth";
// ========== Newsletter 订阅表 ==========
export const newsletterSubscription = pgTable("newsletter_subscription", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
// 已登录用户订阅时关联,游客订阅为空
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
// 状态: active | unsubscribed
status: text("status").notNull().default("active"),
unsubscribeToken: text("unsubscribe_token").notNull().unique(),
subscribedAt: timestamp("subscribed_at").defaultNow().notNull(),
unsubscribedAt: timestamp("unsubscribed_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
}, (table) => [
index("newsletter_subscription_status_idx").on(table.status),
]);
// ========== 联系表单留言表 ==========
export const contactMessage = pgTable("contact_message", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
company: text("company"),
message: text("message").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
+27 -1
View File
@@ -1,4 +1,5 @@
import { pgTable, text, timestamp, integer, primaryKey, index } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, integer, boolean, primaryKey, index } from "drizzle-orm/pg-core";
import { user } from "./auth";
// ========== 工具表 ==========
export const tool = pgTable("tool", {
@@ -17,6 +18,15 @@ export const tool = pgTable("tool", {
nameZh: text("name_zh"),
descriptionZh: text("description_zh"),
// 定价: free | freemium | paid
pricing: text("pricing"),
// 首页精选标记
featured: boolean("featured").notNull().default(false),
// 浏览量
viewCount: integer("view_count").notNull().default(0),
// 状态: draft | published
status: text("status").notNull().default("draft"),
@@ -28,6 +38,8 @@ export const tool = pgTable("tool", {
}, (table) => [
index("tool_slug_idx").on(table.slug),
index("tool_status_idx").on(table.status),
index("tool_featured_idx").on(table.featured),
index("tool_pricing_idx").on(table.pricing),
]);
// ========== 分类表 ==========
@@ -89,6 +101,20 @@ export const toolCategory = pgTable("tool_category", {
index("tool_category_category_id_idx").on(table.categoryId),
]);
// ========== 用户收藏表 ==========
export const favorite = pgTable("favorite", {
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
toolId: text("tool_id")
.notNull()
.references(() => tool.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
primaryKey({ columns: [table.userId, table.toolId] }),
index("favorite_tool_id_idx").on(table.toolId),
]);
// ========== 工具-标签关联表 ==========
export const toolTag = pgTable("tool_tag", {
toolId: text("tool_id")