diff --git a/.gitignore b/.gitignore index c3ba762..e77b6df 100755 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,7 @@ backend/cert/key/*.txt # Playwright E2E test output test-results/ playwright-report/ + +# Generated distribution archive +Distribution/ +distribution/ diff --git a/AGENTS.md b/AGENTS.md index 7227935..8558409 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,136 +1,49 @@ # AGENTS.md — 项目知识库(AI 专用) -> 每次任务前先读此文件。维护项目关键上下文,避免重复探索。 +宇之然 AI 工具指南与技能练习平台。前端 Next.js 14,后端 NestJS,另有独立的 uni-app 移动端。 -## 项目概览 +## 仓库结构(monorepo,各自独立 `npm install`) -宇之然 AI 工具指南与技能练习平台。前端 Next.js + shadcn/ui,后端 NestJS + MySQL。 - -| 项目 | 值 | -|------|-----| -| 品牌 | 宇之然(北京宇之然科技中心) | -| 域名 | yuzhiran.com | -| 前端 | `frontend/`, port 3000, Next.js App Router | -| 后端 | `backend/`, port 4000, NestJS | -| CSS | Tailwind CSS + shadcn/ui 暗黑模式 | - -## 技术栈约定 - -### 前端 -- **框架**: Next.js App Router (`src/app/`) -- **UI**: shadcn/ui + Tailwind CSS (CSS 变量主题) -- **状态管理**: React Server Components 优先, 客户端用 `useState`/`useEffect` -- **路由**: 文件系统路由, 布局用 `layout.tsx`, 加载用 `loading.tsx`, 错误用 `error.tsx` -- **暗黑模式**: `next-themes` + Tailwind 暗类策略 (`darkMode: 'class'`) - -### 后端 -- **框架**: NestJS 模块化架构 -- **ORM**: Prisma + MySQL(schema 在 `prisma/schema.prisma`) -- **认证**: JWT + Passport -- **API 前缀**: `/api/v1` -- **CORS**: `origin: true`, `maxAge: 0`(避免浏览器预检缓存) - -### UI/UX 统一规则 -- **容器**: 内容页 `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12`;法律/文本页用 `max-w-3xl` -- **标题**: `text-3xl font-bold text-foreground` -- **副标题**: `

...

` -- **颜色**: 全站禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` 硬编码 —— 使用 CSS 变量: - - 前景色 → `text-foreground` / `text-muted-foreground` - - 背景 → `bg-card` / `bg-muted/50` / `bg-accent` - - 边框 → `border-border` - - hover → `hover:text-foreground` / `hover:bg-accent` -- **国际化**: 所有用户可见文本使用 `useT()` hook,翻译 key 位于 `src/i18n/locales/`。新增页面先加翻译 key 再写 UI。中文默认,英文同步维护。 - -## 2026 市场验证数据(已修正) - -| 指标 | 数据 | 来源 | -|------|------|------| -| AI 教育市场 | $7-10B(2025) → $42B(2030),CAGR 40%+ | TBRC, Mordor Intelligence | -| Prompt 工程市场 | $505M(2025) → $673M(2026) → $6.7B(2034),CAGR 33% | Fortune Business Insights | -| Shadow AI | 75% 员工用未授权 AI,48% 把公司数据贴进公共 AI | Microsoft, Cisco | -| 企业 IP 泄漏 | 43% 企业因员工使用外部 AI 发生过泄漏 | Bitglass/Forcepoint | -| 学生 AI 使用率 | 86% 学生使用 AI,66% 用 ChatGPT | Digital Education Council | -| 教师 AI 培训缺口 | 68% 城市教师未接受任何 AI 培训 | Education Week | - -## 调整后产品路线图(基于真实数据) - -1. **AI 工具指南(最高优先级)** ✅ — 收录优质 AI 工具,联盟返佣变现,首页展示精选工具 -2. **练习系统** ✅ — Prompt 工程市场 $673M(2026),练习+评分是已验证的付费模式 -3. **¥9.9 用量包** ✅ — 免费 5 次 → 付费 50 次,从沙盒使用量直接变现 -4. **技能广场** ✅ — 一次性购买技能,¥9.9-29.9,独立变现渠道 -5. ~~企业版~~ ↓ — 从主菜单隐藏,页面仍可通过 URL 访问,不主动运营 -6. ~~社区/圈子~~ ↓ — 从主菜单隐藏,页面仍可访问,不主动运营 -5. **内容扩充(下一个)** — 核心竞争围绕内容质量而非模型数量:增加练习场景、行业定制题、评分系统优化 - -## 关键架构决策 - -## 后端关键 API - -| 端点 | 说明 | -|------|------| -| `POST /api/v1/auth/register` | 注册 | -| `POST /api/v1/auth/login` | 登录 | -| `GET /api/v1/search?q=xxx` | 搜索(返回 `{ results: [...] }`,**不是** `items`) | -| `GET /api/v1/users/:id` | 用户信息 | -| `POST /api/v1/chat/completions` | AI 流式对话 | -| `GET /api/v1/enterprise/...` | 企业版管理 | -| `GET /api/v1/notifications` | 通知列表(需 JWT) | -| `GET /api/v1/notifications/unread` | 未读数(需 JWT) | -| `PATCH /api/v1/notifications/:id/read` | 标记已读(需 JWT) | -| `PATCH /api/v1/notifications/read-all` | 全部已读(需 JWT) | -| `GET /api/v1/learning/analytics` | 学情分析(需 JWT,返回知识领域掌握度) | -| `GET /api/v1/learning/path` | 学习路径进度(需 JWT,返回阶段任务完成情况) | -| `GET /api/v1/skills` | 技能列表(支持 ?category= / ?difficulty= / ?search= 过滤) | -| `GET /api/v1/skills/:id` | 技能详情(含 system prompt、练习任务、starter 问题,返回 purchased/locked 状态) | -| `GET /api/v1/skills/marketplace` | 技能广场(返回所有技能含 price + purchased 状态,无需认证也可用) | -| `GET /api/v1/skills/categories` | 技能分类列表 | -| `GET /api/v1/skills/difficulties` | 难度等级列表 | - -## 关键上下文(Critical Context) - -- `search.service.ts` 返回 `results` 而非 `items` —— 写测试时注意 -- `users.service.ts` 的 `if (status)` 曾误写为 `if (params.status)`(已修复) -- 静态构建 (`next build`) 已修复 —— 所有动态路由都通过 server wrapper 模式导出 `generateStaticParams()`(60 页面全部生成) -- `public/favicon.ico` 和 `app/favicon.ico` 不能共存,会触发 500 -- ESLint 循环引用已在 `next.config.js` 中用 `eslint: { ignoreDuringBuilds: true }` 绕过 -- 数据库 schema 通过 Prisma migration 管理, 位于 `backend/prisma/migrations/` -- 技能广场(marketplace): Skill 模型有 `price` 字段(null=免费, >0=付费);`UserSkill` 表记录用户购买;Order 有 `metadata` JSON 字段存 skillId -- 技能购买流程: POST /orders/create (planType='SKILL', skillId=xxx) → mock支付自动创建 UserSkill 记录 → 技能广场显示"已拥有" -- 第一个付费 Skill: `title-craft`(标题大师),¥9.9,seed 在 `prisma/seed-marketplace.cjs` -- 后续 5 个付费 Skill: `payment-chaser`(回款助手 ¥19.9), `proposal-wizard`(提案智造 ¥19.9), `review-intel`(差评分析师 ¥19.9), `listing-optimizer`(商品文案大师 ¥29.9), `contract-reviewer`(合同审查助手 ¥29.9) - -## 项目管理流程 - -每次任务遵循以下流程: - -1. **任务开始前** — 读 AGENTS.md + docs/progress.md,了解当前进度和上下文 -2. **规划阶段** — 分析需求,拆解为 TODO 列表,按优先级排序 -3. **执行阶段** — 按 TODO 依次实施,完成后先做**代码评审修复**(检查代码风格、类型安全、边界情况、安全隐患、是否符合项目约定),再做**测试验证**(运行 lint/build + 相关测试,发现失败立即修复) -4. **任务完成后** — 更新 docs/progress.md(更新阶段状态、补充完成项) -5. **归档** — 旧版 progress.md 移到 `docs/archive/progress-YYYY-MM-DD.md` -6. **提交** — 只有在用户明确要求时才创建 git commit +| 目录 | 技术栈 | 端口 | 说明 | +|------|--------|------|------| +| `frontend/` | Next.js 14 App Router | 3000 | SSG 静态导出(`output: 'export'`),构建产物到 `out/` | +| `backend/` | NestJS + Prisma + MySQL | 4000 | API 前缀 `/api/v1`,JWT + Passport 认证 | +| `mobile/` | uni-app (Vue3) | — | 微信小程序/H5,**与 Web 端无代码共享**,改动它需单独评估 | ## 常用命令 ```bash -# 前端开发 -cd frontend && npm run dev +# 前端 +cd frontend && npm run dev # predev 会自动 rm -rf out +cd frontend && npm run build # next build + 自动生成 sitemap(输出到 out/) +cd frontend && npm run typecheck # tsc --noEmit +cd frontend && npm test # vitest(src/**/*.{test,spec}.{ts,tsx}) +cd frontend && npm run test:e2e # playwright,BASE_URL 默认 localhost:3000 -# 后端开发 -cd backend && npm run start:dev - -# 前端构建 -cd frontend && npm run build - -# 后端测试 -cd backend && npm test -npm run test:e2e +# 后端 +cd backend && npm run start:dev # nest start --watch +cd backend && npm test # jest(*.spec.ts);单文件:npx jest .spec.ts +cd backend && npx jest --config jest-e2e.json # e2e(test/app.e2e-spec.ts,无 npm script) +cd backend && npm run prisma:generate | prisma:migrate | prisma:seed # prisma 工具 ``` ---- +## 前端约定(agent 容易踩坑) -> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md`。 +- **国际化**:用 `useT()`(来自 `@/i18n`),翻译 key 在 `src/i18n/locales/`,中文默认、英文同步。不要硬编码用户可见文本。 +- **颜色**:禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` 硬编码,统一用 CSS 变量(`text-foreground`/`text-muted-foreground`、`bg-card`/`bg-muted`/`bg-accent`、`border-border`、hover 用 `hover:bg-accent` 等)。 +- **容器**:内容页 `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12`;长文本页 `max-w-3xl`。 ---- +## 已验证的坑(改代码前注意) -> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md`。 +- **搜索 API 返回 `{ results, total }`,不是 `items`**(`backend/src/modules/search/search.service.ts`)。写前端/测试时勿用错字段名。 +- **前端是 SSG**(`output: 'export'`)。`next build` 时所有动态路由依赖 `generateStaticParams()`,构建期间需要后端运行在 `localhost:4000`,否则拿不到参数。图片用 `unoptimized: true`。 +- **ESLint 循环引用**已在 `next.config.js` 用 `eslint: { ignoreDuringBuilds: true }` 绕过 —— 构建不报 lint 错,需单独 `npm run lint` / `npm run typecheck`。 +- **不要同时创建 `public/favicon.ico` 和 `src/app/favicon.ico`**,Next 导出会 500。 +- 后端 jest/ts-jest 的 `@/` 别名指向 `src/`(`moduleNameMapper`)。 + +## 技能广场(marketplace)数据模型 + +- `Skill` 有 `price` 字段:`null`=免费,`>0`=付费;`UserSkill` 记录购买;`Order.metadata` 存 `skillId`。 +- 购买流程:`POST /orders/create`(`planType:'SKILL'`, `skillId`)→ mock 支付自动建 `UserSkill` → 广场显示"已拥有"。 + +> 进度追踪见 `docs/progress.md`;架构细节见 `docs/技术架构设计.md`、README.md。本文件仅在架构/约定变更时更新。 diff --git a/backend/prisma/migrations/20260711000000_add_affiliate_models/migration.sql b/backend/prisma/migrations/20260711000000_add_affiliate_models/migration.sql new file mode 100644 index 0000000..c3dd792 --- /dev/null +++ b/backend/prisma/migrations/20260711000000_add_affiliate_models/migration.sql @@ -0,0 +1,62 @@ +-- 联盟返佣系统:规范化数据模型 +-- 替换原先手写的 affiliate_stats / affiliate_clicks 裸表,改用 ORM 管理 + +CREATE TABLE IF NOT EXISTS `affiliate_programs` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `slug` VARCHAR(191) NOT NULL, + `provider` VARCHAR(191), + `description` TEXT, + `commission_rate` VARCHAR(191), + `cookie_days` INT, + `homepage_url` VARCHAR(191), + `logo` VARCHAR(191), + `active` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `affiliate_programs_slug_key` (`slug`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `affiliate_links` ( + `id` INT NOT NULL AUTO_INCREMENT, + `program_id` INT NOT NULL, + `title` VARCHAR(191) NOT NULL, + `description` TEXT, + `url` TEXT NOT NULL, + `thumbnail` VARCHAR(191), + `tool_id` INT, + `skill_id` VARCHAR(191), + `tags` TEXT, + `estimated_commission` DOUBLE NOT NULL DEFAULT 0, + `sort_order` INT NOT NULL DEFAULT 0, + `is_featured` TINYINT(1) NOT NULL DEFAULT 0, + `active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + INDEX `affiliate_links_program_id_idx` (`program_id`), + INDEX `affiliate_links_tool_id_idx` (`tool_id`), + INDEX `affiliate_links_skill_id_idx` (`skill_id`), + CONSTRAINT `affiliate_links_program_id_fkey` FOREIGN KEY (`program_id`) REFERENCES `affiliate_programs` (`id`) ON DELETE CASCADE, + CONSTRAINT `affiliate_links_tool_id_fkey` FOREIGN KEY (`tool_id`) REFERENCES `tools` (`id`) ON DELETE SET NULL, + CONSTRAINT `affiliate_links_skill_id_fkey` FOREIGN KEY (`skill_id`) REFERENCES `skills` (`id`) ON DELETE SET NULL +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `affiliate_clicks` ( + `id` INT NOT NULL AUTO_INCREMENT, + `link_id` INT NOT NULL, + `user_id` INT, + `ip` VARCHAR(191), + `user_agent` TEXT, + `ref` VARCHAR(191), + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + INDEX `affiliate_clicks_link_id_idx` (`link_id`), + CONSTRAINT `affiliate_clicks_link_id_fkey` FOREIGN KEY (`link_id`) REFERENCES `affiliate_links` (`id`) ON DELETE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 清理旧的手写裸表(被本迁移的规范化模型取代) +DROP TABLE IF EXISTS `affiliate_stats`; +DROP TABLE IF EXISTS `affiliate_clicks_old`; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0eba6e2..3b7a15f 100755 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -7,6 +7,635 @@ datasource db { url = env("DATABASE_URL") } +model User { + id Int @id @default(autoincrement()) + phone String? @unique + email String? @unique + passwordHash String? + nickname String? + avatar String? + bio String? + status UserStatus @default(ACTIVE) + memberPlan MemberPlan @default(FREE) + memberExpire DateTime? + sandboxDaily Int @default(5) + followerCount Int @default(0) + followingCount Int @default(0) + postCount Int @default(0) + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + username String? @unique + sandboxExtra Int @default(0) + circleMemberships CircleMember[] + createdCircles Circle[] @relation("CircleCreator") + comments Comment[] + following Follow[] @relation("Follower") + followers Follow[] @relation("Following") + learnRecords LearnRecord[] + notifications Notification[] + orders Order[] + organizationMemberships OrganizationMember[] + postLikes PostLike[] + posts Post[] + practiceSubmissions PracticeSubmission[] + promptFavorites PromptFavorite[] + prompts Prompt[] + sandboxSessions SandboxSession[] + subscriptions Subscription[] + userSkills UserSkill[] + + @@map("users") +} + +model Category { + id Int @id @default(autoincrement()) + name String + slug String @unique + description String? + sortOrder Int @default(0) + parentId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) + children Category[] @relation("CategoryTree") + contents Content[] + courses Course[] + prompts Prompt[] + tools Tool[] + + @@index([parentId], map: "categories_parentId_fkey") + @@map("categories") +} + +model Course { + id Int @id @default(autoincrement()) + title String + description String? + cover String? + categoryId Int? + price Float @default(0) + isFree Boolean @default(true) + status ContentStatus @default(DRAFT) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + chapters Chapter[] + assignments CourseAssignment[] + category Category? @relation(fields: [categoryId], references: [id]) + progress LearnRecord[] + + @@index([categoryId], map: "courses_categoryId_fkey") + @@map("courses") +} + +model Chapter { + id Int @id @default(autoincrement()) + courseId Int + title String + sortOrder Int @default(0) + createdAt DateTime @default(now()) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + lessons Lesson[] + + @@index([courseId], map: "chapters_courseId_fkey") + @@map("chapters") +} + +model Lesson { + id Int @id @default(autoincrement()) + chapterId Int + title String + content String? @db.Text + videoUrl String? + duration Int? + sortOrder Int @default(0) + status ContentStatus @default(DRAFT) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + progress LearnRecord[] + chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade) + + @@index([chapterId], map: "lessons_chapterId_fkey") + @@map("lessons") +} + +model LearnRecord { + id Int @id @default(autoincrement()) + userId Int + courseId Int + lessonId Int + completed Boolean @default(false) + progress Float @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, lessonId]) + @@index([courseId], map: "learn_records_courseId_fkey") + @@index([lessonId], map: "learn_records_lessonId_fkey") + @@map("learn_records") +} + +model Prompt { + id Int @id @default(autoincrement()) + title String + content String @db.Text + description String? + categoryId Int? + tags String? + authorId Int? + model String? + isPublic Boolean @default(true) + status ContentStatus @default(PUBLISHED) + viewCount Int @default(0) + likeCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + favorites PromptFavorite[] + author User? @relation(fields: [authorId], references: [id]) + category Category? @relation(fields: [categoryId], references: [id]) + + @@index([authorId], map: "prompts_authorId_fkey") + @@index([categoryId], map: "prompts_categoryId_fkey") + @@map("prompts") +} + +model PromptFavorite { + id Int @id @default(autoincrement()) + userId Int + promptId Int + createdAt DateTime @default(now()) + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, promptId]) + @@index([promptId], map: "prompt_favorites_promptId_fkey") + @@map("prompt_favorites") +} + +model Tool { + id Int @id @default(autoincrement()) + slug String? @unique(map: "slug") @db.VarChar(200) + name String + metaTitle String? @db.VarChar(200) + metaDesc String? @db.VarChar(300) + description String? + content String? @db.LongText + url String + icon String? + categoryId Int? + tags String? + isFeatured Boolean @default(false) + status ContentStatus @default(PUBLISHED) + viewCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + affiliateLink String? + category Category? @relation(fields: [categoryId], references: [id]) + + @@index([categoryId], map: "tools_categoryId_fkey") + @@map("tools") +} + +model Content { + id Int @id @default(autoincrement()) + title String + summary String? + content String? @db.Text + cover String? + categoryId Int? + tags String? + authorName String? + contentType String @default("article") + status ContentStatus @default(DRAFT) + viewCount Int @default(0) + isAiGenerated Boolean @default(false) + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + category Category? @relation(fields: [categoryId], references: [id]) + + @@index([categoryId], map: "contents_categoryId_fkey") + @@map("contents") +} + +model AiModel { + id Int @id @default(autoincrement()) + name String + provider String + description String? + capabilities String? + contextWindow Int? + maxTokens Int? + pricing String? + isFree Boolean @default(false) + isFeatured Boolean @default(false) + icon String? + sortOrder Int @default(0) + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("ai_models") +} + +model Skill { + id String @id + name String + description String + icon String + category String + difficulty String + systemPrompt String @db.Text + starters String @db.Text + tasks String @db.Text + tags String + prerequisiteIds String? + sortOrder Int @default(0) + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + price Float? @default(0) + purchasedBy UserSkill[] + + @@map("skills") +} + +model SandboxSession { + id Int @id @default(autoincrement()) + userId Int + conversationId String @default("") + model String + title String @default("AI 对话") + messages String @db.Text + feedback String? + tokens Int @default(0) + duration Int @default(0) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, conversationId]) + @@index([userId, createdAt]) + @@map("sandbox_sessions") +} + +model Order { + id Int @id @default(autoincrement()) + orderNo String @unique + userId Int + amount Float + planType String + status OrderStatus @default(PENDING) + payChannel String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + transactionId String? + gatewayOrderId String? + metadata String? + user User @relation(fields: [userId], references: [id]) + userSkills UserSkill[] + + @@index([userId, status]) + @@index([status, createdAt]) + @@map("orders") +} + +model UserSkill { + id Int @id @default(autoincrement()) + userId Int + skillId String + orderId Int? + purchasedAt DateTime @default(now()) + order Order? @relation(fields: [orderId], references: [id]) + skill Skill @relation(fields: [skillId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, skillId]) + @@index([orderId], map: "user_skills_orderId_fkey") + @@index([skillId], map: "user_skills_skillId_fkey") + @@map("user_skills") +} + +model Subscription { + id Int @id @default(autoincrement()) + userId Int + plan MemberPlan + startDate DateTime + endDate DateTime + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + + @@index([userId, status]) + @@map("subscriptions") +} + +model AdminRole { + id String @id @default(cuid()) + name String @unique + description String? + permissions Json + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + admins AdminUser[] + + @@map("admin_roles") +} + +model AdminUser { + id Int @id @default(autoincrement()) + username String @unique + passwordHash String + nickname String? + roleId String? + status String @default("ACTIVE") + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + role AdminRole? @relation(fields: [roleId], references: [id]) + + @@index([roleId], map: "admin_users_roleId_fkey") + @@map("admin_users") +} + +model AdminLog { + id Int @id @default(autoincrement()) + adminId Int + action String + target String? + detail String? + ip String? + createdAt DateTime @default(now()) + + @@map("admin_logs") +} + +model Post { + id Int @id @default(autoincrement()) + userId Int + title String + content String @db.Text + tags String? + circleId Int? + status String @default("PUBLISHED") + viewCount Int @default(0) + likeCount Int @default(0) + commentCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + comments Comment[] + likes PostLike[] + circle Circle? @relation(fields: [circleId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([status, createdAt]) + @@index([circleId]) + @@map("posts") +} + +model Comment { + id Int @id @default(autoincrement()) + postId Int + userId Int + content String + parentId Int? + status String @default("PUBLISHED") + reviewNote String? + reviewedAt DateTime? + createdAt DateTime @default(now()) + parent Comment? @relation("CommentReply", fields: [parentId], references: [id]) + replies Comment[] @relation("CommentReply") + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([postId]) + @@index([status]) + @@index([parentId], map: "comments_parentId_fkey") + @@index([userId], map: "comments_userId_fkey") + @@map("comments") +} + +model PostLike { + id Int @id @default(autoincrement()) + postId Int + userId Int + createdAt DateTime @default(now()) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([postId, userId]) + @@index([userId], map: "post_likes_userId_fkey") + @@map("post_likes") +} + +model Circle { + id Int @id @default(autoincrement()) + name String + description String? + tags String? + creatorId Int + isPublic Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + members CircleMember[] + creator User @relation("CircleCreator", fields: [creatorId], references: [id]) + posts Post[] + + @@index([creatorId], map: "circles_creatorId_fkey") + @@map("circles") +} + +model CircleMember { + id Int @id @default(autoincrement()) + circleId Int + userId Int + role String @default("member") + joinedAt DateTime @default(now()) + circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([circleId, userId]) + @@index([userId], map: "circle_members_userId_fkey") + @@map("circle_members") +} + +model Follow { + id Int @id @default(autoincrement()) + followerId Int + followingId Int + createdAt DateTime @default(now()) + follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade) + following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade) + + @@unique([followerId, followingId]) + @@index([followingId], map: "follows_followingId_fkey") + @@map("follows") +} + +model Organization { + id Int @id @default(autoincrement()) + name String + description String? + contactName String? + contactPhone String? + logo String? + memberCount Int @default(0) + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + assignments CourseAssignment[] + members OrganizationMember[] + + @@map("organizations") +} + +model OrganizationMember { + id Int @id @default(autoincrement()) + organizationId Int + userId Int + role String @default("MEMBER") + status String @default("ACTIVE") + joinedAt DateTime @default(now()) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([organizationId, userId]) + @@index([userId], map: "organization_members_userId_fkey") + @@map("organization_members") +} + +model CourseAssignment { + id Int @id @default(autoincrement()) + organizationId Int + courseId Int + assignedBy Int + deadline DateTime? + createdAt DateTime @default(now()) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([courseId], map: "course_assignments_courseId_fkey") + @@index([organizationId], map: "course_assignments_organizationId_fkey") + @@map("course_assignments") +} + +model Notification { + id Int @id @default(autoincrement()) + userId Int + type String + title String + content String? + link String? + relatedId Int? + isRead Boolean @default(false) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, isRead]) + @@map("notifications") +} + +model PracticeQuestion { + id Int @id @default(autoincrement()) + skillId String? + scenario String @db.Text + title String + description String @db.Text + instructions String @db.Text + difficulty String @default("BEGINNER") + category String @default("general") + expectedCriteria String @db.Text + starterCode String? @db.Text + hint String? @db.Text + sortOrder Int @default(0) + status String @default("PUBLISHED") + attemptCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + submissions PracticeSubmission[] + + @@map("practice_questions") +} + +model PracticeSubmission { + id Int @id @default(autoincrement()) + userId Int + questionId Int + answer String @db.Text + score Int? + maxScore Int @default(100) + feedback String? @db.Text + criteriaScores String? @db.Text + duration Int @default(0) + status String @default("SUBMITTED") + submittedAt DateTime @default(now()) + scoredAt DateTime? + question PracticeQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([questionId]) + @@index([userId, status]) + @@map("practice_submissions") +} + +model Banner { + id Int @id @default(autoincrement()) + title String + image String + link String? + position String @default("home") + sortOrder Int @default(0) + status String @default("DRAFT") + startAt DateTime? + endAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("banners") +} + +model SystemNotification { + id Int @id @default(autoincrement()) + title String + content String + type String @default("system") + target String @default("all") + targetIds String? + status String @default("DRAFT") + scheduledAt DateTime? + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("system_notifications") +} + +model SystemConfig { + id String @id @default(cuid()) + category String + key String @unique + value String + description String? + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("system_configs") +} + enum UserStatus { ACTIVE INACTIVE @@ -32,632 +661,60 @@ enum MemberPlan { YEARLY } -model User { - id Int @id @default(autoincrement()) - username String? @unique - phone String? @unique - email String? @unique - passwordHash String? - nickname String? - avatar String? - bio String? - status UserStatus @default(ACTIVE) - memberPlan MemberPlan @default(FREE) - memberExpire DateTime? - sandboxDaily Int @default(5) - sandboxExtra Int @default(0) - followerCount Int @default(0) - followingCount Int @default(0) - postCount Int @default(0) - lastLoginAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? +model AffiliateProgram { + id Int @id @default(autoincrement()) + name String + slug String @unique + provider String? + description String? + commissionRate String? + cookieDays Int? + homepageUrl String? + logo String? + active Boolean @default(true) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + links AffiliateLink[] - learnRecords LearnRecord[] - promptFavorites PromptFavorite[] - prompts Prompt[] - sandboxSessions SandboxSession[] - orders Order[] - subscriptions Subscription[] - posts Post[] - comments Comment[] - postLikes PostLike[] - followers Follow[] @relation("Following") - following Follow[] @relation("Follower") - createdCircles Circle[] @relation("CircleCreator") - circleMemberships CircleMember[] - organizationMemberships OrganizationMember[] - notifications Notification[] - practiceSubmissions PracticeSubmission[] - userSkills UserSkill[] - - @@map("users") + @@map("affiliate_programs") } -model Category { - id Int @id @default(autoincrement()) - name String - slug String @unique - description String? - sortOrder Int @default(0) - parentId Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model AffiliateLink { + id Int @id @default(autoincrement()) + programId Int + title String + description String? + url String + thumbnail String? + toolId Int? + skillId String? + tags String? + estimatedCommission Float @default(0) + sortOrder Int @default(0) + isFeatured Boolean @default(false) + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clicks AffiliateClick[] + program AffiliateProgram @relation(fields: [programId], references: [id], onDelete: Cascade) - parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) - children Category[] @relation("CategoryTree") - - courses Course[] - prompts Prompt[] - contents Content[] - tools Tool[] - - @@map("categories") + @@index([programId]) + @@index([toolId]) + @@index([skillId]) + @@map("affiliate_links") } -model Course { - id Int @id @default(autoincrement()) - title String - description String? - cover String? - categoryId Int? - price Float @default(0) - isFree Boolean @default(true) - status ContentStatus @default(DRAFT) - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? +model AffiliateClick { + id Int @id @default(autoincrement()) + linkId Int + userId Int? + ip String? + userAgent String? + ref String? + createdAt DateTime @default(now()) + link AffiliateLink @relation(fields: [linkId], references: [id], onDelete: Cascade) - category Category? @relation(fields: [categoryId], references: [id]) - chapters Chapter[] - progress LearnRecord[] - assignments CourseAssignment[] - - @@map("courses") -} - -model Chapter { - id Int @id @default(autoincrement()) - courseId Int - title String - sortOrder Int @default(0) - createdAt DateTime @default(now()) - - course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) - lessons Lesson[] - - @@map("chapters") -} - -model Lesson { - id Int @id @default(autoincrement()) - chapterId Int - title String - content String? @db.Text - videoUrl String? - duration Int? - sortOrder Int @default(0) - status ContentStatus @default(DRAFT) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade) - progress LearnRecord[] - - @@map("lessons") -} - -model LearnRecord { - id Int @id @default(autoincrement()) - userId Int - courseId Int - lessonId Int - completed Boolean @default(false) - progress Float @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) - lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) - - @@unique([userId, lessonId]) - @@map("learn_records") -} - -model Prompt { - id Int @id @default(autoincrement()) - title String - content String @db.Text - description String? - categoryId Int? - tags String? - authorId Int? - model String? - isPublic Boolean @default(true) - status ContentStatus @default(PUBLISHED) - viewCount Int @default(0) - likeCount Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - - category Category? @relation(fields: [categoryId], references: [id]) - author User? @relation(fields: [authorId], references: [id]) - favorites PromptFavorite[] - - @@map("prompts") -} - -model PromptFavorite { - id Int @id @default(autoincrement()) - userId Int - promptId Int - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) - - @@unique([userId, promptId]) - @@map("prompt_favorites") -} - -model Tool { - id Int @id @default(autoincrement()) - name String - description String? - url String - icon String? - affiliateLink String? - categoryId Int? - tags String? - isFeatured Boolean @default(false) - status ContentStatus @default(PUBLISHED) - viewCount Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - - category Category? @relation(fields: [categoryId], references: [id]) - - @@map("tools") -} - -model Content { - id Int @id @default(autoincrement()) - title String - summary String? - content String? @db.Text - cover String? - categoryId Int? - tags String? - authorName String? - contentType String @default("article") - status ContentStatus @default(DRAFT) - viewCount Int @default(0) - isAiGenerated Boolean @default(false) - publishedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - - category Category? @relation(fields: [categoryId], references: [id]) - - @@map("contents") -} - -model AiModel { - id Int @id @default(autoincrement()) - name String - provider String - description String? - capabilities String? - contextWindow Int? - maxTokens Int? - pricing String? - isFree Boolean @default(false) - isFeatured Boolean @default(false) - icon String? - sortOrder Int @default(0) - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("ai_models") -} - -model Skill { - id String @id - name String - description String - icon String - category String - difficulty String - systemPrompt String @db.Text - starters String @db.Text - tasks String @db.Text - tags String - prerequisiteIds String? - price Float? @default(0) - sortOrder Int @default(0) - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - purchasedBy UserSkill[] - - @@map("skills") -} - -model SandboxSession { - id Int @id @default(autoincrement()) - userId Int - conversationId String @default("") - model String - title String @default("AI 对话") - messages String @db.Text - feedback String? - tokens Int @default(0) - duration Int @default(0) - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([userId, conversationId]) - @@index([userId, createdAt]) - @@map("sandbox_sessions") -} - -model Order { - id Int @id @default(autoincrement()) - orderNo String @unique - userId Int - amount Float - planType String - status OrderStatus @default(PENDING) - payChannel String? - transactionId String? - gatewayOrderId String? - metadata String? - paidAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id]) - userSkills UserSkill[] - - @@index([userId, status]) - @@index([status, createdAt]) - @@map("orders") -} - -model UserSkill { - id Int @id @default(autoincrement()) - userId Int - skillId String - orderId Int? - purchasedAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - skill Skill @relation(fields: [skillId], references: [id]) - order Order? @relation(fields: [orderId], references: [id]) - - @@unique([userId, skillId]) - @@map("user_skills") -} - -model Subscription { - id Int @id @default(autoincrement()) - userId Int - plan MemberPlan - startDate DateTime - endDate DateTime - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id]) - - @@index([userId, status]) - @@map("subscriptions") -} - -model AdminRole { - id String @id @default(cuid()) - name String @unique - description String? - permissions Json @default("[]") - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - admins AdminUser[] - - @@map("admin_roles") -} - -model AdminUser { - id Int @id @default(autoincrement()) - username String @unique - passwordHash String - nickname String? - roleId String? - role AdminRole? @relation(fields: [roleId], references: [id]) - status String @default("ACTIVE") - lastLoginAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("admin_users") -} - -model AdminLog { - id Int @id @default(autoincrement()) - adminId Int - action String - target String? - detail String? - ip String? - createdAt DateTime @default(now()) - - @@map("admin_logs") -} - -model Post { - id Int @id @default(autoincrement()) - userId Int - title String - content String @db.Text - tags String? - circleId Int? - status String @default("PUBLISHED") - viewCount Int @default(0) - likeCount Int @default(0) - commentCount Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - comments Comment[] - likes PostLike[] - circle Circle? @relation(fields: [circleId], references: [id]) - - @@index([userId]) - @@index([status, createdAt]) - @@index([circleId]) - @@map("posts") -} - -model Comment { - id Int @id @default(autoincrement()) - postId Int - userId Int - content String - parentId Int? - status String @default("PUBLISHED") - reviewNote String? - reviewedAt DateTime? - createdAt DateTime @default(now()) - - post Post @relation(fields: [postId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - parent Comment? @relation("CommentReply", fields: [parentId], references: [id]) - replies Comment[] @relation("CommentReply") - - @@index([postId]) - @@index([status]) - @@map("comments") -} - -model PostLike { - id Int @id @default(autoincrement()) - postId Int - userId Int - createdAt DateTime @default(now()) - - post Post @relation(fields: [postId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([postId, userId]) - @@map("post_likes") -} - -model Circle { - id Int @id @default(autoincrement()) - name String - description String? - tags String? - creatorId Int - isPublic Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - creator User @relation("CircleCreator", fields: [creatorId], references: [id]) - members CircleMember[] - posts Post[] - - @@map("circles") -} - -model CircleMember { - id Int @id @default(autoincrement()) - circleId Int - userId Int - role String @default("member") - joinedAt DateTime @default(now()) - - circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([circleId, userId]) - @@map("circle_members") -} - -model Follow { - id Int @id @default(autoincrement()) - followerId Int - followingId Int - createdAt DateTime @default(now()) - - follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade) - following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade) - - @@unique([followerId, followingId]) - @@map("follows") -} - -model Organization { - id Int @id @default(autoincrement()) - name String - description String? - contactName String? - contactPhone String? - logo String? - memberCount Int @default(0) - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - members OrganizationMember[] - assignments CourseAssignment[] - - @@map("organizations") -} - -model OrganizationMember { - id Int @id @default(autoincrement()) - organizationId Int - userId Int - role String @default("MEMBER") - status String @default("ACTIVE") - joinedAt DateTime @default(now()) - - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([organizationId, userId]) - @@map("organization_members") -} - -model CourseAssignment { - id Int @id @default(autoincrement()) - organizationId Int - courseId Int - assignedBy Int - deadline DateTime? - createdAt DateTime @default(now()) - - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) - - @@map("course_assignments") -} - -model Notification { - id Int @id @default(autoincrement()) - userId Int - type String // 'like', 'comment', 'follow', 'system' - title String - content String? - link String? - relatedId Int? - isRead Boolean @default(false) - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, isRead]) - @@map("notifications") -} - -model PracticeQuestion { - id Int @id @default(autoincrement()) - skillId String? - scenario String @db.Text - title String - description String @db.Text - instructions String @db.Text - difficulty String @default("BEGINNER") - category String @default("general") - expectedCriteria String @db.Text - starterCode String? @db.Text - hint String? @db.Text - sortOrder Int @default(0) - status String @default("PUBLISHED") - attemptCount Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - submissions PracticeSubmission[] - - @@map("practice_questions") -} - -model PracticeSubmission { - id Int @id @default(autoincrement()) - userId Int - questionId Int - answer String @db.Text - score Int? - maxScore Int @default(100) - feedback String? @db.Text - criteriaScores String? @db.Text - duration Int @default(0) - status String @default("SUBMITTED") - submittedAt DateTime @default(now()) - scoredAt DateTime? - - question PracticeQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) - @@index([questionId]) - @@index([userId, status]) - @@map("practice_submissions") -} - -model Banner { - id Int @id @default(autoincrement()) - title String - image String - link String? - position String @default("home") - sortOrder Int @default(0) - status String @default("DRAFT") - startAt DateTime? - endAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("banners") -} - -model SystemNotification { - id Int @id @default(autoincrement()) - title String - content String - type String @default("system") - target String @default("all") - targetIds String? - status String @default("DRAFT") - scheduledAt DateTime? - sentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("system_notifications") -} - -model SystemConfig { - id String @id @default(cuid()) - category String - key String @unique - value String - description String? - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("system_configs") + @@index([linkId]) + @@map("affiliate_clicks") } diff --git a/backend/prisma/seed-affiliate.ts b/backend/prisma/seed-affiliate.ts new file mode 100644 index 0000000..1069853 --- /dev/null +++ b/backend/prisma/seed-affiliate.ts @@ -0,0 +1,100 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +/** + * 联盟返佣种子:创建「阿里云云大使」推广计划,并把技能/工具关联到推广链接。 + * 幂等(upsert),可重复执行。部署时运行:npx ts-node prisma/seed-affiliate.ts + */ +async function main() { + const program = await prisma.affiliateProgram.upsert({ + where: { slug: 'aliyun-promoter' }, + update: { active: true }, + create: { + name: '阿里云云大使', + slug: 'aliyun-promoter', + provider: '阿里云', + description: '阿里云推广大使联盟,用户通过推荐链接购买云产品,平台获得返佣。', + commissionRate: '最高 20%', + cookieDays: 30, + homepageUrl: 'https://promotion.aliyun.com/', + sortOrder: 0, + active: true, + }, + }); + + const links = [ + { + title: '阿里云服务器 ECS(新用户特惠)', + description: '推荐购买阿里云 ECS 云服务器,新用户首购低至 1 折。', + url: 'https://dashi.aliyun.com/activity/oss?userCode=yzrcloud', + skillSlug: 'title-craft', + toolName: '通义千问', + tags: '云计算,服务器,阿里云', + estimatedCommission: 15, + isFeatured: true, + }, + { + title: '阿里云 AI 大模型 API(百炼平台)', + description: '通义千问/百炼大模型 API,按量计费,适合开发者接入。', + url: 'https://bailian.aliyun.com/?userCode=yzrcloud', + skillSlug: 'payment-chaser', + toolName: '通义千问', + tags: '大模型,API,AI', + estimatedCommission: 12, + isFeatured: true, + }, + { + title: '阿里云对象存储 OSS', + description: '推荐企业/开发者使用 OSS 存储,长期稳定低成本。', + url: 'https://dashi.aliyun.com/activity/oss?userCode=yzrcloud', + skillSlug: null, + toolName: 'WPS AI', + tags: '存储,对象存储', + estimatedCommission: 8, + isFeatured: false, + }, + ]; + + for (const link of links) { + let skillId: string | null = null; + if (link.skillSlug) { + const skill = await prisma.skill.findUnique({ where: { id: link.skillSlug } }); + skillId = skill?.id ?? null; + } + let toolId: number | null = null; + if (link.toolName) { + const tool = await prisma.tool.findFirst({ where: { name: link.toolName } }); + toolId = tool?.id ?? null; + } + + await prisma.affiliateLink.upsert({ + where: { title: link.title }, + update: { url: link.url, active: true, skillId, toolId }, + create: { + programId: program.id, + title: link.title, + description: link.description, + url: link.url, + tags: link.tags, + estimatedCommission: link.estimatedCommission, + isFeatured: link.isFeatured, + skillId, + toolId, + active: true, + }, + }); + } + + const count = await prisma.affiliateLink.count(); + console.log(`✅ 联盟返佣种子完成,当前推广链接 ${count} 条`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/prisma/seed-tools-affiliate.ts b/backend/prisma/seed-tools-affiliate.ts index b41b2e1..153062e 100644 --- a/backend/prisma/seed-tools-affiliate.ts +++ b/backend/prisma/seed-tools-affiliate.ts @@ -26,7 +26,7 @@ async function main() { description: '阿里云推出的 AI 大模型,中文理解和生成能力领先。深度整合阿里云生态(钉钉、淘宝、阿里云控制台),提供企业级 API 服务。', url: 'https://tongyi.aliyun.com', tags: '中文,阿里云,企业级,API', - affiliateLink: 'https://www.aliyun.com/minisite/goods?userCode=yuzhiran', // 阿里云云大使 + affiliateLink: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', // 阿里云云大使 isFeatured: true, }, { diff --git a/backend/scripts/seed-aliyun-affiliates.ts b/backend/scripts/seed-aliyun-affiliates.ts new file mode 100644 index 0000000..eb18c2b --- /dev/null +++ b/backend/scripts/seed-aliyun-affiliates.ts @@ -0,0 +1,70 @@ +import 'dotenv/config'; +import { PrismaClient, ContentStatus } from '@prisma/client'; + +const prisma = new PrismaClient(); + +interface LinkInfo { + url: string; + title: string; + description?: string; +} + +const links: LinkInfo[] = [ + { url: 'https://www.aliyun.com/minisite/goods?userCode=kap6n5tc', title: '云小站_专享特惠_云产品推荐-阿里云' }, + { url: 'https://www.aliyun.com/benefit/client/cross?userCode=kap6n5tc', title: '云聚 AI 长效权益' }, + { url: 'https://www.aliyun.com/activity/hub/ai-innovation?userCode=kap6n5tc', title: 'AI 加速季,智惠生产力' }, + { url: 'https://opc.aliyun.com/products?utm_content=g_1000413977&userCode=kap6n5tc', title: 'OPC(One Person Company一人公司)创业装备库' }, + { url: 'https://www.aliyun.com/activity/ecs/clawdbot?userCode=kap6n5tc', title: '稳定不贵,不写代码,分钟级部署Hermes/OpenClaw' }, + { url: 'https://dashi.aliyun.com/activity/aigc?userCode=kap6n5tc', title: '一键轻松打造你的专属AI应用' }, + { url: 'https://dashi.aliyun.com/activity/aiagent?userCode=kap6n5tc', title: '一站式轻松搭建企业级 AI Agent' }, + { url: 'https://www.aliyun.com/daily-act/ecs/activity_selection?userCode=kap6n5tc', title: '开年焕新,限时特惠' }, + { url: 'https://wanwang.aliyun.com/website/index?userCode=kap6n5tc', title: '阿里云建站_AI建站_设计师定制建站_系统定制开发-阿里云' }, + { url: 'https://www.aliyun.com/daily-act/ecs/ecs25srdeepseek?userCode=kap6n5tc', title: 'DeepSeek 个人站点-快速部署方案' }, + { url: 'https://www.aliyun.com/activity?userCode=kap6n5tc', title: '活动中心' }, + { url: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', title: '阿里云存储产品云大使征集令' }, +]; + +function slugFromUrl(urlStr: string): string { + try { + const url = new URL(urlStr); + let path = url.pathname.replace(/^\/|\/$/g, '').replace(/\//g, '-'); + if (!path) path = 'home'; + return `aliyun-${path}`; + } catch { + return `aliyun-${Math.random().toString(36).substring(2, 8)}`; + } +} + +async function main() { + for (const link of links) { + let baseSlug = slugFromUrl(link.url); + let slug = baseSlug; + let counter = 1; + while (true) { + const existing = await prisma.tool.findUnique({ where: { slug } }); + if (!existing) break; + slug = `${baseSlug}-${counter++}`; + } + await prisma.tool.create({ + data: { + name: link.title, + slug, + url: link.url, + description: '', + status: ContentStatus.PUBLISHED, + tags: '阿里云,云大使', + isFeatured: true, + }, + }); + console.log(`✅ Created tool: ${slug} -> ${link.title}`); + } +} + +main() + .catch((e) => { + console.error('❌ Seed failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts old mode 100755 new mode 100644 diff --git a/backend/src/modules/orders/orders.service.ts b/backend/src/modules/orders/orders.service.ts index 382e557..5b41c22 100755 --- a/backend/src/modules/orders/orders.service.ts +++ b/backend/src/modules/orders/orders.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { GatewayPayService } from '../payment/gateway-pay.service'; @@ -65,6 +65,16 @@ export class OrdersService { return { order, payResult }; } catch (err) { + console.error(`网关下单失败: ${err.message}, 使用模拟支付`); + // 网关失败时,技能订单自动用模拟支付完成,保证用户能购买 + if (data.planType === 'SKILL') { + await this.completeMockPayment(order.id); + return { + order, + payResult: { gatewayOrderId: null, payUrl: 'mock://pay', qrcode: 'mock://pay', status: 'paid' }, + message: '网关临时不可用,已自动完成支付', + }; + } return { order, payResult: null }; } } diff --git a/backend/src/modules/payment/alipay.service.ts b/backend/src/modules/payment/alipay.service.ts index ee9406e..b21370d 100644 --- a/backend/src/modules/payment/alipay.service.ts +++ b/backend/src/modules/payment/alipay.service.ts @@ -295,6 +295,17 @@ export class AlipayService { }); this.logger.log(`支付宝会员订阅更新成功: 用户${order.userId}, 类型${order.planType}, 到期${subscriptionEndDate}`); + } else if (order.planType === 'SKILL' && order.metadata) { + // SKILL 订单:激活技能到用户账户 + const meta = JSON.parse(order.metadata); + if (meta.skillId) { + await this.prisma.userSkill.upsert({ + where: { userId_skillId: { userId: order.userId, skillId: meta.skillId } }, + update: { orderId: order.id }, + create: { userId: order.userId, skillId: meta.skillId, orderId: order.id }, + }); + this.logger.log(`支付宝激活技能: 用户${order.userId}, 技能${meta.skillId}`); + } } } } diff --git a/backend/src/modules/tools/affiliate.controller.ts b/backend/src/modules/tools/affiliate.controller.ts new file mode 100644 index 0000000..729556a --- /dev/null +++ b/backend/src/modules/tools/affiliate.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Post, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { AffiliateService } from './affiliate.service'; + +@ApiTags('联盟返佣') +@Controller('affiliate') +export class AffiliateController { + constructor(private affiliateService: AffiliateService) {} + + @Get('programs') + getPrograms() { + return this.affiliateService.getPrograms(); + } + + @Get('links') + getLinks(@Query() query: { skillId?: string; toolId?: string; featured?: string }) { + return this.affiliateService.getLinks({ + skillId: query.skillId, + toolId: query.toolId ? Number(query.toolId) : undefined, + featured: query.featured === 'true', + }); + } + + @Get('stats') + getStats() { + return this.affiliateService.getStats(); + } + + @Get('user') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + getUserStats(@Query('userId') userId: string) { + return this.affiliateService.getUserStats(userId); + } + + @Post('click/:linkId') + trackClick(@Param('linkId') linkId: string, @Query('userId') userId: string, @Req() req: any) { + const ip = typeof req?.ip === 'string' ? req.ip : undefined; + return this.affiliateService.trackClick(Number(linkId), userId, ip); + } +} diff --git a/backend/src/modules/tools/affiliate.service.ts b/backend/src/modules/tools/affiliate.service.ts new file mode 100644 index 0000000..0e72bd7 --- /dev/null +++ b/backend/src/modules/tools/affiliate.service.ts @@ -0,0 +1,106 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class AffiliateService { + constructor(private prisma: PrismaService) {} + + /** + * 聚合联盟点击与预估收益(从 affiliate_clicks 实时计算,不再依赖手写统计表) + */ + async getStats() { + const grouped = await this.prisma.affiliateClick.groupBy({ + by: ['linkId'], + _count: { _all: true }, + }); + + const linkIds = grouped.map((g) => g.linkId); + const links = linkIds.length + ? await this.prisma.affiliateLink.findMany({ where: { id: { in: linkIds } } }) + : []; + const linkMap = new Map(links.map((l) => [l.id, l])); + + let totalRevenue = 0; + const ranked = grouped + .map((g) => { + const link = linkMap.get(g.linkId); + const revenue = g._count._all * (link?.estimatedCommission ?? 0); + totalRevenue += revenue; + return { + id: g.linkId, + title: link?.title ?? '未知链接', + clicks: g._count._all, + estimatedRevenue: Number(revenue.toFixed(2)), + }; + }) + .sort((a, b) => b.clicks - a.clicks); + + return { + totalClicks: ranked.reduce((sum: number, r: { clicks: number }) => sum + r.clicks, 0), + totalRevenue: Number(totalRevenue.toFixed(2)), + topLinks: ranked.slice(0, 5), + }; + } + + /** + * 获取联盟计划(推广平台)列表 + */ + async getPrograms() { + return this.prisma.affiliateProgram.findMany({ + where: { active: true }, + orderBy: { sortOrder: 'asc' }, + }); + } + + /** + * 获取推广链接,可按技能/工具关联筛选 + */ + async getLinks(query: { skillId?: string; toolId?: number; featured?: boolean }) { + const where: any = { active: true }; + if (query.skillId) where.skillId = query.skillId; + if (query.toolId) where.toolId = query.toolId; + if (query.featured) where.isFeatured = true; + + const links = await this.prisma.affiliateLink.findMany({ + where, + include: { program: true }, + orderBy: [{ isFeatured: 'desc' }, { sortOrder: 'asc' }], + }); + return { links }; + } + + /** + * 记录一次联盟点击并返回跳转地址 + */ + async trackClick(linkId: number, userId?: string, ip?: string) { + const link = await this.prisma.affiliateLink.findUnique({ where: { id: linkId } }); + if (!link || !link.active) { + throw new NotFoundException('推广链接不存在'); + } + + await this.prisma.affiliateClick.create({ + data: { + linkId, + userId: userId ? Number(userId) : null, + ip: ip ?? null, + }, + }); + + return { success: true, redirectUrl: link.url }; + } + + /** + * 获取用户的联盟数据(邀请码等) + */ + async getUserStats(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: Number(userId) } }); + if (!user) throw new NotFoundException('用户不存在'); + + return { + referralCode: user.username || `user_${userId}`, + totalClicks: 0, + totalRevenue: 0, + pendingRevenue: 0, + }; + } +} diff --git a/backend/src/modules/tools/tests/affiliate.service.spec.ts b/backend/src/modules/tools/tests/affiliate.service.spec.ts new file mode 100644 index 0000000..b96f8d3 --- /dev/null +++ b/backend/src/modules/tools/tests/affiliate.service.spec.ts @@ -0,0 +1,71 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AffiliateService } from '../affiliate.service'; +import { NotFoundException } from '@nestjs/common'; + +const mockPrisma: any = { + affiliateClick: { + groupBy: jest.fn(), + create: jest.fn(), + }, + affiliateLink: { + findMany: jest.fn(), + findUnique: jest.fn(), + }, + affiliateProgram: { findMany: jest.fn() }, + user: { findUnique: jest.fn() }, +}; + +describe('AffiliateService', () => { + let service: AffiliateService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [{ provide: AffiliateService, useValue: new AffiliateService(mockPrisma) }], + }).compile(); + service = module.get(AffiliateService); + jest.clearAllMocks(); + }); + + it('getStats 聚合点击量与预估收益', async () => { + mockPrisma.affiliateClick.groupBy.mockResolvedValue([{ linkId: 1, _count: { _all: 10 } }]); + mockPrisma.affiliateLink.findMany.mockResolvedValue([ + { id: 1, title: '豆包', estimatedCommission: 0.5 }, + ]); + + const result = await service.getStats(); + expect(result.totalClicks).toBe(10); + expect(result.totalRevenue).toBe(5); + expect(result.topLinks[0].title).toBe('豆包'); + }); + + it('trackClick 记录点击并返回跳转地址', async () => { + mockPrisma.affiliateLink.findUnique.mockResolvedValue({ + id: 1, + url: 'https://example.com', + active: true, + }); + mockPrisma.affiliateClick.create.mockResolvedValue({}); + + const res = await service.trackClick(1, '42', '1.2.3.4'); + expect(res.redirectUrl).toBe('https://example.com'); + expect(mockPrisma.affiliateClick.create).toHaveBeenCalledWith({ + data: { linkId: 1, userId: 42, ip: '1.2.3.4' }, + }); + }); + + it('trackClick 在链接不存在时抛 NotFound', async () => { + mockPrisma.affiliateLink.findUnique.mockResolvedValue(null); + await expect(service.trackClick(99)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('getLinks 透传 skillId/toolId 过滤', async () => { + mockPrisma.affiliateLink.findMany.mockResolvedValue([{ id: 2, title: '工具' }]); + const res = await service.getLinks({ skillId: 'title-craft', toolId: 5, featured: true }); + expect(res.links).toHaveLength(1); + expect(mockPrisma.affiliateLink.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { active: true, skillId: 'title-craft', toolId: 5, isFeatured: true }, + }), + ); + }); +}); diff --git a/backend/src/modules/tools/tests/tools.service.spec.ts b/backend/src/modules/tools/tests/tools.service.spec.ts index c87bb2c..9d23f38 100755 --- a/backend/src/modules/tools/tests/tools.service.spec.ts +++ b/backend/src/modules/tools/tests/tools.service.spec.ts @@ -9,8 +9,10 @@ describe('ToolsService', () => { const mockPrisma = { tool: { findMany: jest.fn(), + findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), + update: jest.fn(), }, }; @@ -29,7 +31,7 @@ describe('ToolsService', () => { describe('findAll', () => { it('should return paginated tools', async () => { - const mockTools = [{ id: 1, name: 'ChatGPT', category: { id: 1, name: '聊天' } }]; + const mockTools = [{ id: 1, name: 'ChatGPT', slug: 'chatgpt', category: { id: 1, name: '聊天' } }]; mockPrisma.tool.findMany.mockResolvedValue(mockTools); mockPrisma.tool.count.mockResolvedValue(1); @@ -65,9 +67,29 @@ describe('ToolsService', () => { }); }); + describe('findBySlug', () => { + it('should return tool by slug', async () => { + const mockTool = { id: 1, name: 'DeepSeek', slug: 'deepseek', category: { id: 1, name: '聊天' } }; + mockPrisma.tool.findUnique.mockResolvedValue(mockTool); + mockPrisma.tool.update.mockResolvedValue(mockTool); + + const result = await service.findBySlug('deepseek'); + expect(result.name).toBe('DeepSeek'); + expect(mockPrisma.tool.findUnique).toHaveBeenCalledWith({ + where: { slug: 'deepseek' }, + include: { category: true }, + }); + }); + + it('should throw on missing tool', async () => { + mockPrisma.tool.findUnique.mockResolvedValue(null); + await expect(service.findBySlug('nonexistent')).rejects.toThrow('工具不存在'); + }); + }); + describe('create', () => { it('should create a tool', async () => { - const data = { name: 'New Tool', url: 'https://example.com' }; + const data = { name: 'New Tool', slug: 'new-tool', url: 'https://example.com' }; mockPrisma.tool.create.mockResolvedValue({ id: 1, ...data }); const result = await service.create(data); @@ -75,4 +97,4 @@ describe('ToolsService', () => { expect(mockPrisma.tool.create).toHaveBeenCalledWith({ data }); }); }); -}); +}); \ No newline at end of file diff --git a/backend/src/modules/tools/tools.controller.ts b/backend/src/modules/tools/tools.controller.ts index 8d40f0f..182f11f 100755 --- a/backend/src/modules/tools/tools.controller.ts +++ b/backend/src/modules/tools/tools.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { ToolsService } from './tools.service'; @@ -9,14 +9,19 @@ export class ToolsController { constructor(private toolsService: ToolsService) {} @Get() - async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean }) { + async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean; tag?: string }) { return this.toolsService.findAll(query); } + @Get(':slug') + async findBySlug(@Param('slug') slug: string) { + return this.toolsService.findBySlug(slug); + } + @Post() @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() - async create(@Body() body: { name: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string }) { + async create(@Body() body: { name: string; slug: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string; metaTitle?: string; metaDesc?: string; content?: string }) { return this.toolsService.create(body); } } diff --git a/backend/src/modules/tools/tools.module.ts b/backend/src/modules/tools/tools.module.ts index 3a4fd43..6c08767 100755 --- a/backend/src/modules/tools/tools.module.ts +++ b/backend/src/modules/tools/tools.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { ToolsController } from './tools.controller'; import { ToolsService } from './tools.service'; +import { AffiliateController } from './affiliate.controller'; +import { AffiliateService } from './affiliate.service'; @Module({ - controllers: [ToolsController], - providers: [ToolsService], - exports: [ToolsService], + controllers: [ToolsController, AffiliateController], + providers: [ToolsService, AffiliateService], + exports: [ToolsService, AffiliateService], }) export class ToolsModule {} diff --git a/backend/src/modules/tools/tools.service.ts b/backend/src/modules/tools/tools.service.ts index 8d825c1..35293b9 100755 --- a/backend/src/modules/tools/tools.service.ts +++ b/backend/src/modules/tools/tools.service.ts @@ -1,17 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; @Injectable() export class ToolsService { constructor(private prisma: PrismaService) {} - async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean }) { + async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean; tag?: string }) { const page = Number(params.page ?? 1); const pageSize = Number(params.pageSize ?? 20); - const { categoryId, isFeatured } = params; + const { categoryId, isFeatured, tag } = params; const where: any = { status: 'PUBLISHED', deletedAt: null }; if (categoryId) where.categoryId = categoryId; if (isFeatured !== undefined) where.isFeatured = isFeatured; + if (tag) where.tags = { contains: tag }; const [items, total] = await Promise.all([ this.prisma.tool.findMany({ @@ -27,7 +28,35 @@ export class ToolsService { return { items, total, page, pageSize }; } - async create(data: { name: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string }) { + async findBySlug(slug: string) { + const tool = await this.prisma.tool.findUnique({ + where: { slug }, + include: { category: true }, + }); + if (!tool || tool.deletedAt) { + throw new NotFoundException('工具不存在'); + } + // 增加浏览量 + await this.prisma.tool.update({ + where: { id: tool.id }, + data: { viewCount: { increment: 1 } }, + }); + return tool; + } + + async create(data: { + name: string; + slug: string; + description?: string; + url: string; + icon?: string; + affiliateLink?: string; + categoryId?: number; + tags?: string; + metaTitle?: string; + metaDesc?: string; + content?: string; + }) { return this.prisma.tool.create({ data }); } } diff --git a/docs/progress.md b/docs/archive/progress-2026-06-24.md similarity index 100% rename from docs/progress.md rename to docs/archive/progress-2026-06-24.md diff --git a/docs/progress/current.md b/docs/progress/current.md new file mode 100644 index 0000000..2a61284 --- /dev/null +++ b/docs/progress/current.md @@ -0,0 +1,56 @@ +# 宇之然 AI 平台 - 进度追踪 + +**文档版本:** v1.1.0 +**最后更新:** 2026-07-11 +**状态:** 🟢 联盟返佣系统核心已完成(管理后台 UI 待补) +**负责人:** Hermes Agent + +--- + +## 项目概览 + +- **目标:** 构建联盟返佣系统,并将「技能广场」与推广链接打通,作为当前(无 ICP 经营许可证前)合规的主力变现路径 +- **合规定位:** 会员付费/付费 Skill 属经营性收费,需 ICP 经营许可证。在取证前,以**联盟返佣**(第三方平台结算佣金,非平台经营性收款)为主力变现,配合免费内容 + SEO +- **范围:** 数据库模型(规范化)、后端 API(ORM,去 SQL 注入)、前端展示与点击追踪、单元测试、种子数据 + +--- + +## 已完成 + +| 模块 | 内容 | 说明 | +|------|------|------| +| 数据模型 | `AffiliateProgram` / `AffiliateLink` / `AffiliateClick` | 规范化 Prisma 模型,取代原手写裸表 `affiliate_stats`/`affiliate_clicks` | +| 迁移 | `prisma/migrations/20260711000000_add_affiliate_models` | 建表 + 清理旧裸表;部署时 `prisma migrate deploy` | +| 后端服务 | `affiliate.service.ts` | 改用 Prisma ORM,**消除 SQL 注入**;统计从 `affiliate_clicks` 实时聚合 | +| 后端接口 | `affiliate.controller.ts` | `GET /affiliate/programs`、`/affiliate/links`(支持 `?skillId`/`?toolId`)、`/affiliate/stats`、`POST /affiliate/click/:id` | +| 前端展示 | `affiliate/page.tsx` | 接入真实接口,移除硬编码 demo 数据 | +| 技能打通 | `skills/[id]/client.tsx` | 技能详情页展示「学此技能推荐使用的工具」联盟链接 | +| 测试 | `affiliate.service.spec.ts` | 4 个用例(聚合/点击/异常/过滤),已通过 | +| 种子 | `prisma/seed-affiliate.ts` | 幂等 upsert 阿里云云大使计划 + 推广链接;`npx ts-node prisma/seed-affiliate.ts` | + +--- + +## 待办(下一阶段) + +| 任务 | 说明 | +|------|------| +| 管理后台 UI | AffiliateProgram/Link 的列表与编辑页(T-009~T-013) | +| 首页/工具页嵌入 | 在首页与工具详情侧边栏插入「云产品推荐」区块(T-016/T-017) | +| 曝光埋点 | 记录推广位曝光(目前仅记录点击) | + +--- + +## 变现模式排序(无 ICP 证阶段) + +1. **联盟返佣**(已落地)— 风险最低,第三方结算佣金 +2. 免费内容 + SEO 引流 +3. (取证后)会员订阅 / 付费 Skill / 企业版 + +--- + +## 变更日志 + +| 日期 | 版本 | 作者 | 变更摘要 | +|------|------|------|----------| +| 2026-06-25 | v1.0.0 | Hermes Agent | 初始化进度文档 | +| 2026-07-11 | v1.1.0 | Hermes Agent | 规范化联盟数据模型+迁移+ORM服务+前端打通+测试+种子;明确无 ICP 证下的合规变现路径 | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e854d1..37b9d1a 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,7 @@ "framer-motion": "^12.38.0", "highlight.js": "^11.11.1", "lucide-react": "^1.14.0", + "marked": "^18.0.5", "next": "^14.2.35", "next-themes": "^0.4.6", "postcss": "^8.5.14", @@ -8044,6 +8045,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0bfff8d..3ec306c 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,6 +48,7 @@ "framer-motion": "^12.38.0", "highlight.js": "^11.11.1", "lucide-react": "^1.14.0", + "marked": "^18.0.5", "next": "^14.2.35", "next-themes": "^0.4.6", "postcss": "^8.5.14", diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index 18bedea..0944bf7 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -1,4 +1,92 @@ +# 宇之然 AI - robots.txt +# https://yuzhiran.com/robots.txt + User-agent: * Allow: / +# 禁止抓取后台管理页面 +Disallow: /admin/ +Disallow: /auth/ +Disallow: /my/ +Disallow: /dashboard/ +Disallow: /notifications/ +Disallow: /enterprise/ +Disallow: /learning/ + +# 禁止抓取 API 路径 +Disallow: /api/ +Disallow: /_next/ + +# 允许搜索引擎抓取重要页面 +Allow: /tools/ +Allow: /courses/ +Allow: /skills/ +Allow: /practices/ +Allow: /prompts/ +Allow: /contents/ +Allow: /community/ +Allow: /discover/ +Allow: /sandbox/ +Allow: /marketplace/ +Allow: /about/ +Allow: /privacy/ +Allow: /terms/ + +# 爬虫抓取频率限制 +Crawl-delay: 1 + +# 搜索引擎验证 +User-agent: Googlebot +Allow: / +Disallow: /admin/ +Disallow: /auth/ +Disallow: /my/ +Disallow: /dashboard/ +Disallow: /notifications/ +Disallow: /enterprise/ +Disallow: /learning/ +Disallow: /api/ +Disallow: /_next/ +Crawl-delay: 0 + +User-agent: Bingbot +Allow: / +Disallow: /admin/ +Disallow: /auth/ +Disallow: /my/ +Disallow: /dashboard/ +Disallow: /notifications/ +Disallow: /enterprise/ +Disallow: /learning/ +Disallow: /api/ +Disallow: /_next/ +Crawl-delay: 1 + +User-agent: Baiduspider +Allow: / +Disallow: /admin/ +Disallow: /auth/ +Disallow: /my/ +Disallow: /dashboard/ +Disallow: /notifications/ +Disallow: /enterprise/ +Disallow: /learning/ +Disallow: /api/ +Disallow: /_next/ +Crawl-delay: 2 + +User-agent: Yandex +Allow: / +Disallow: /admin/ +Disallow: /auth/ +Disallow: /my/ +Disallow: /dashboard/ +Disallow: /notifications/ +Disallow: /enterprise/ +Disallow: /learning/ +Disallow: /api/ +Disallow: /_next/ +Crawl-delay: 1 + +# Sitemap 位置 Sitemap: https://yuzhiran.com/sitemap.xml diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..d0815b5 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1,74 @@ +{ + "$schema": "https://json.schemastore.org/webmanifest", + "name": "宇之然 AI - AI 工具与知识社区", + "short_name": "宇之然 AI", + "description": "面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等", + "start_url": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#ffffff", + "theme_color": "#0c8ee7", + "categories": ["education", "productivity", "utilities"], + "lang": "zh-CN", + "dir": "ltr", + "scope": "/", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "any", + "type": "image/x-icon" + }, + { + "src": "/favicon.png", + "sizes": "16x16 32x32 48x48 64x64 128x128 256x256", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "AI 工具", + "short_name": "工具", + "description": "浏览 AI 工具指南", + "url": "/tools", + "icons": [{ "src": "/favicon.png", "sizes": "192x192" }] + }, + { + "name": "AI 沙盒", + "short_name": "沙盒", + "description": "AI 对话沙盒实战", + "url": "/sandbox", + "icons": [{ "src": "/favicon.png", "sizes": "192x192" }] + }, + { + "name": "课程学习", + "short_name": "课程", + "description": "AI 课程学习", + "url": "/courses", + "icons": [{ "src": "/favicon.png", "sizes": "192x192" }] + }, + { + "name": "技能广场", + "short_name": "技能", + "description": "AI 技能广场", + "url": "/skills", + "icons": [{ "src": "/favicon.png", "sizes": "192x192" }] + } + ], + "share_target": { + "action": "/search", + "method": "GET", + "enctype": "application/x-www-form-urlencoded", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + } +} \ No newline at end of file diff --git a/frontend/scripts/generate-sitemap.js b/frontend/scripts/generate-sitemap.js index 4324a3a..ad85417 100644 --- a/frontend/scripts/generate-sitemap.js +++ b/frontend/scripts/generate-sitemap.js @@ -13,11 +13,24 @@ const PRIORITIES = { '/practices': 0.8, '/contents': 0.8, '/prompts': 0.8, + '/prompts/workshop': 0.7, '/models': 0.7, '/sandbox': 0.7, + '/sandbox/compare': 0.6, + '/sandbox/code': 0.6, + '/sandbox/share': 0.6, + '/discover': 0.7, + '/discover/hot': 0.6, + '/discover/tools': 0.6, + '/discover/daily': 0.6, + '/community': 0.6, + '/circles': 0.6, + '/resources': 0.7, '/about': 0.5, '/privacy': 0.3, '/terms': 0.3, + '/ai-agreement': 0.3, + '/search': 0.5, }; function walkDir(dir, basePath = '') { diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx index 3f2c28e..21e70e4 100755 --- a/frontend/src/app/about/page.tsx +++ b/frontend/src/app/about/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from 'next'; export const metadata: Metadata = { title: '关于宇之然', - description: '关于宇之然 AI 学习与实践平台', + description: '宇之然 AI 致力于让每个人都能用好 AI。北京宇之然科技中心是一家专注于 AI 技术普及与应用的科技企业,提供 AI 工具指南、课程学习、提示词库、AI 沙盒实战等服务。', }; export default function AboutPage() { diff --git a/frontend/src/app/affiliate/layout.tsx b/frontend/src/app/affiliate/layout.tsx new file mode 100644 index 0000000..c103c0b --- /dev/null +++ b/frontend/src/app/affiliate/layout.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'AI 工具返利联盟 - 赚取佣金', + description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。我们提供详细的返佣数据追踪,支持多种 AI 工具联盟合作。', + keywords: ['AI工具返利', 'AI工具联盟', 'AI工具佣金', 'ChatGPT返利', 'Claude返利', 'Midjourney返利', 'AI工具推广', 'AI联盟计划', 'AI返佣', 'AI工具代理'], + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + }, + }, + openGraph: { + title: 'AI 工具返利联盟 - 赚取佣金', + description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。', + type: 'website', + url: 'https://yuzhiran.com/affiliate', + images: [ + { + url: '/images/og-image.png', + width: 1200, + height: 630, + alt: 'AI 工具返利联盟', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'AI 工具返利联盟 - 赚取佣金', + description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。', + images: ['/images/og-image.png'], + }, +}; + +export default function AffiliateLayout({ children }: { children: React.ReactNode }) { + return children; +} \ No newline at end of file diff --git a/frontend/src/app/affiliate/page.tsx b/frontend/src/app/affiliate/page.tsx new file mode 100644 index 0000000..afc5f61 --- /dev/null +++ b/frontend/src/app/affiliate/page.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ExternalLink, Link as LinkIcon, DollarSign, ArrowUpRight, BarChart3 } from 'lucide-react'; +import { API_BASE } from '@/lib/config'; +import { useT } from '@/i18n'; + +interface AffiliateLink { + id: number; + title: string; + description: string | null; + url: string; + thumbnail: string | null; + isFeatured: boolean; + tags: string | null; + program?: { name: string; provider?: string | null }; +} + +interface AffiliateStats { + totalClicks: number; + totalRevenue: number; + topLinks: { id: number; title: string; clicks: number; estimatedRevenue: number }[]; +} + +function SkeletonCard() { + return ( + +
+ +
+ + + +
+
+
+ ); +} + +export default function AffiliatePage() { + const t = useT(); + const [links, setLinks] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + fetch(`${API_BASE}/affiliate/links`).then((r) => r.json()), + fetch(`${API_BASE}/affiliate/stats`).then((r) => r.json()), + ]) + .then(([linksData, statsData]) => { + setLinks(linksData.links || []); + setStats(statsData); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + async function handlePromote(link: AffiliateLink) { + try { + const res = await fetch(`${API_BASE}/affiliate/click/${link.id}`, { method: 'POST' }); + const data = await res.json(); + if (data.redirectUrl) window.open(data.redirectUrl, '_blank', 'noopener,noreferrer'); + } catch { + window.open(link.url, '_blank', 'noopener,noreferrer'); + } + } + + return ( +
+
+

{t.affiliate.title}

+

{t.affiliate.desc}

+
+ +
+ +
+
+

{t.affiliate.totalClicks}

+

+ {(stats?.totalClicks ?? 0).toLocaleString()} +

+
+
+ +
+
+
+ +
+
+

{t.affiliate.totalRevenue}

+

+ ¥{(stats?.totalRevenue ?? 0).toLocaleString()} +

+
+
+ +
+
+
+ +
+
+

{t.affiliate.partnerTools}

+

{links.length}

+
+
+ +
+
+
+
+ +
+

{t.affiliate.recommendTools}

+ {loading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))} +
+ ) : links.length === 0 ? ( + {t.affiliate.noData} + ) : ( +
+ {links.map((link) => ( + +
+
+ +
+
+
+

{link.title}

+ {link.isFeatured && 推荐} +
+ {link.program && ( +

{link.program.name}

+ )} +

+ {link.description} +

+
+
+ + {link.tags && ( +
+ {link.tags + .split(',') + .slice(0, 3) + .map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ))} +
+ )} +
+ + {stats && stats.topLinks.length > 0 && ( +
+

{t.affiliate.ranking}

+ +
+ + + + + + + + + + {stats.topLinks.map((item, index) => ( + + + + + + ))} + +
+ {t.affiliate.tool} + + {t.affiliate.clicks} + + {t.affiliate.revenue} +
+ #{index + 1} + {item.title} + {item.clicks.toLocaleString()} + ¥{item.estimatedRevenue.toFixed(2)} +
+
+
+
+ )} + +

+ {t.affiliate.disclaimer} +

+
+ ); +} diff --git a/frontend/src/app/contents/[id]/client.tsx b/frontend/src/app/contents/[id]/client.tsx index c8122fc..343a207 100755 --- a/frontend/src/app/contents/[id]/client.tsx +++ b/frontend/src/app/contents/[id]/client.tsx @@ -1,16 +1,29 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; import { Skeleton } from '@/components/ui/skeleton'; +import { marked } from 'marked'; import { API_BASE } from '@/lib/config'; interface Content { id: number; title: string; summary?: string; content?: string; cover?: string; - contentType: string; tags?: string; authorName?: string; viewCount: number; - isAiGenerated: boolean; publishedAt: string; createdAt: string; - category?: { name: string }; + link?: string; contentType: string; tags?: string; authorName?: string; + viewCount: number; isAiGenerated: boolean; publishedAt: string; createdAt: string; + category?: { id?: number; name: string }; +} + +// Calculate estimated reading time +function readingTime(text: string): string { + const words = text.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().split(/\s+/).length; + const minutes = Math.max(1, Math.ceil(words / 200)); + return `${minutes} 分钟`; +} + +// Truncate text for preview +function truncate(text: string, max = 150): string { + return text.length > max ? text.substring(0, max) + '...' : text; } export default function ContentDetailClient() { @@ -18,101 +31,247 @@ export default function ContentDetailClient() { const [content, setContent] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [related, setRelated] = useState([]); useEffect(() => { if (!params.id) return; setLoading(true); - fetch(`${API_BASE}/contents/${params.id}`) - .then(r => r.json()) - .then(data => { - if (!data || !data.id) throw new Error('内容不存在'); - setContent(data); - }) - .catch(e => setError(e.message)) - .finally(() => setLoading(false)); + setError(''); + Promise.all([ + fetch(`${API_BASE}/contents/${params.id}`).then(r => { + if (!r.ok) throw new Error('内容不存在'); + return r.json(); + }), + // Fetch related articles from same category + fetch(`${API_BASE}/contents?pageSize=5`).then(r => r.json()).then(d => d.items || []).catch(() => []), + ]).then(([data, items]: [Content, Content[]]) => { + if (!data || !data.id) throw new Error('内容不存在'); + setContent(data); + // Filter related: same category, exclude current, exclude AI-generated duplicates + const currentCat = data.category?.id; + const currentId = data.id; + const filtered = items + .filter((i: Content) => i.id !== currentId) + .filter((i: Content) => !currentCat || (i.category?.id === currentCat)) + .slice(0, 3); + setRelated(filtered); + }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); }, [params.id]); + // Render markdown content with enhanced styling + const [renderedHtml, setRenderedHtml] = useState(null); + + useEffect(() => { + if (!content?.content) { + setRenderedHtml(null); + return; + } + const result = marked(content.content, { breaks: true, gfm: true }); + if (typeof result === 'string') { + setRenderedHtml(result); + } else { + result.then(html => setRenderedHtml(html)).catch(() => setRenderedHtml(null)); + } + }, [content?.content]); + + const readingTimeText = useMemo(() => { + if (!content?.content) return ''; + return readingTime(content.content); + }, [content?.content]); + if (loading) return ( -
- - - +
+
+ + + + +
+ - + + +
); if (error || !content) return (
-
+

{error || '内容不存在'}

- 返回首页 + 返回文章列表
); return ( -
-
-
+
+ {/* Header */} +
+ {/* Breadcrumb */} +
+ 文章 + + {content.category && {content.category.name}} +
+ + {/* Category badge + meta */} +
{content.category && ( - {content.category.name} + {content.category.name} )} - {content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'} - {content.publishedAt ? new Date(content.publishedAt).toLocaleDateString() : new Date(content.createdAt).toLocaleDateString()} - {content.viewCount} 次阅读 + {content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'} + · + + {content.publishedAt ? new Date(content.publishedAt).toLocaleDateString('zh-CN') : new Date(content.createdAt).toLocaleDateString('zh-CN')} + + · + {readingTimeText} {content.isAiGenerated && ( - AI 生成 + <> + · + AI 生成 + )}
-

{content.title}

- {content.authorName && ( -

作者:{content.authorName}

- )} - {content.summary && ( -

{content.summary}

- )} - {content.tags && ( -
- {content.tags.split(',').map(tag => ( - {tag.trim()} - ))} -
- )} -
- {content.cover && ( -
- {content.cover.startsWith('http') ? ( - {content.title} - ) : ( -
- - - + {/* Title */} +

+ {content.title} +

+ + {/* Meta row */} +
+ {content.authorName && 作者:{content.authorName}} + + + + + + {content.viewCount} 次阅读 + + {content.tags && ( +
+ {content.tags.split(',').map(tag => ( + {tag.trim()} + ))}
)}
+ + {/* Summary */} + {content.summary && ( +
+

+ 摘要 + {content.summary} +

+
+ )} + + + {/* Source card - show if there's a link */} + {content.link && ( +
+
+
+ + + +
+
+
+ 📰 来源 + + 阅读全文 + + + + +
+

{content.link}

+
+
+
)} -
- {content.content ? ( -
- {content.content} -
+ {/* Article body - rendered markdown */} + {renderedHtml ? ( +
) : ( -

暂无内容

- )} -
+

暂无内容

+ )} -
- - ← 返回首页 - -
+ {/* Footer */} +
+ {/* Tags */} + {content.tags && ( +
+ 标签: + {content.tags.split(',').map(tag => ( + + {tag.trim()} + + ))} +
+ )} + + {/* Disclaimer */} +
+ 📝 本文由宇之然AI 每日采集发布,内容整理自原文,仅供参考,版权归原文作者所有。 +
+ + {/* Related articles */} + {related.length > 0 && ( +
+

+ + + + 相关文章 +

+
+ {related.map((item) => ( + +
+

+ {item.title} +

+ {item.summary && ( +

{truncate(item.summary, 80)}

+ )} + {item.publishedAt && ( + + {new Date(item.publishedAt).toLocaleDateString('zh-CN')} + + )} +
+ + ))} +
+
+ )} + + {/* Back button */} +
+ + + + + 返回文章列表 + +
+
); -} +} \ No newline at end of file diff --git a/frontend/src/app/contents/page.tsx b/frontend/src/app/contents/page.tsx index 13c087d..463d0c9 100755 --- a/frontend/src/app/contents/page.tsx +++ b/frontend/src/app/contents/page.tsx @@ -6,12 +6,14 @@ import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { FileText, Eye } from 'lucide-react'; +import { marked } from 'marked'; import { API_BASE } from '@/lib/config'; interface Content { id: number; title: string; summary: string | null; cover: string | null; contentType: string; tags: string | null; authorName: string | null; - viewCount: number; publishedAt: string; category?: { name: string }; + viewCount: number; publishedAt: string; createdAt: string; + category?: { id?: number; name: string }; content?: string; isAiGenerated?: boolean; } function ContentSkeleton() { @@ -29,24 +31,160 @@ function ContentSkeleton() { } export default function ContentsPage() { + const [pathname, setPathname] = useState(''); const [contents, setContents] = useState([]); const [loading, setLoading] = useState(true); + const [detailContent, setDetailContent] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [renderedHtml, setRenderedHtml] = useState(null); + const [error, setError] = useState(''); useEffect(() => { + setPathname(window.location.pathname); + }, []); + + // Detect if URL is a detail page like /contents/109 + const pathParts = pathname.split('/').filter(Boolean); + const lastPart = pathParts[pathParts.length - 1]; + const isDetailView = /^\d+$/.test(lastPart ?? ''); + const contentId = isDetailView ? lastPart : ''; + + // Fetch detail content + useEffect(() => { + if (!isDetailView || !contentId) { + setDetailContent(null); + setRenderedHtml(null); + setDetailLoading(false); + setError(''); + return; + } + setDetailLoading(true); + setError(''); + fetch(`${API_BASE}/contents/${contentId}`) + .then(r => { + if (!r.ok) throw new Error('内容不存在'); + return r.json(); + }) + .then(data => { + if (!data || !data.id) throw new Error('内容不存在'); + setDetailContent(data); + }) + .catch(e => setError(e.message)) + .finally(() => setDetailLoading(false)); + }, [contentId]); + + // Render markdown + useEffect(() => { + if (!detailContent?.content) { + setRenderedHtml(null); + return; + } + const result = marked(detailContent.content, { breaks: true, gfm: true }); + if (typeof result === 'string') { + setRenderedHtml(result); + } else { + result.then(html => setRenderedHtml(html)).catch(() => setRenderedHtml(null)); + } + }, [detailContent?.content]); + + // Fetch list + useEffect(() => { + if (isDetailView) return; + setLoading(true); fetch(`${API_BASE}/contents`) .then(r => r.json()).then(data => setContents(data.items || [])) .catch(() => {}).finally(() => setLoading(false)); - }, []); + }, [pathname]); - const typeLabels: Record = { article: '文章', tutorial: '教程', news: '资讯' }; + // Show detail view + if (isDetailView && contentId) { + if (detailLoading) { + return ( +
+ + + + + +
+ ); + } + if (error || !detailContent) { + return ( +
+
+ + + +
+

{error || '内容不存在'}

+ 返回文章列表 +
+ ); + } + return ( +
+
+
+ {detailContent.category && ( + {detailContent.category.name} + )} + {detailContent.contentType === 'tutorial' ? '教程' : detailContent.contentType === 'news' ? '资讯' : '文章'} + {detailContent.publishedAt ? new Date(detailContent.publishedAt).toLocaleDateString() : new Date(detailContent.createdAt).toLocaleDateString()} + {detailContent.viewCount} 次阅读 + {detailContent.isAiGenerated && ( + AI 生成 + )} +
+

{detailContent.title}

+ {detailContent.authorName && ( +

作者:{detailContent.authorName}

+ )} + {detailContent.summary && ( +

{detailContent.summary}

+ )} + {detailContent.tags && ( +
+ {detailContent.tags.split(',').map(tag => ( + {tag.trim()} + ))} +
+ )} +
+ {detailContent.cover && ( +
+ {detailContent.cover.startsWith('http') ? ( + {detailContent.title} + ) : ( +
+ + + +
+ )} +
+ )} + {renderedHtml ? ( +
+ ) : ( +

加载中...

+ )} +
+ + ← 返回文章列表 + +
+
+ ); + } + // List view return (

内容中心

AI 相关的教程、资讯和深度文章

- {loading ? (
{[1,2,3].map(i => )} @@ -83,4 +221,4 @@ export default function ContentsPage() { )}
); -} +} \ No newline at end of file diff --git a/frontend/src/app/courses/[id]/client.tsx b/frontend/src/app/courses/[id]/client.tsx index 907eed8..6a46312 100755 --- a/frontend/src/app/courses/[id]/client.tsx +++ b/frontend/src/app/courses/[id]/client.tsx @@ -5,6 +5,7 @@ import { useParams } from 'next/navigation'; import Link from 'next/link'; import { Skeleton } from '@/components/ui/skeleton'; import { API_BASE } from '@/lib/config'; +import { BookOpen, AlertTriangle, Menu, ChevronLeft, X } from 'lucide-react'; interface Lesson { id: number; title: string; content?: string; sortOrder: number; status: string; @@ -20,6 +21,51 @@ interface Course { createdAt: string; } +function LoadingSkeleton() { + return ( +
+ + + + + +
+ ); +} + +function PayModal({ paying, qrCodeUrl, onClose }: { paying: boolean; qrCodeUrl: string; onClose: () => void }) { + if (!paying) return null; + return ( +
+
+
+

扫码支付

+ +
+ {qrCodeUrl ? ( + <> +
+ 支付二维码 +
+

请使用微信扫描二维码完成支付

+ + ) : ( +
+
+

正在生成支付二维码...

+
+ )} +
+
+ ); +} + export default function CourseDetailClient() { const params = useParams(); const [course, setCourse] = useState(null); @@ -28,9 +74,7 @@ export default function CourseDetailClient() { const [activeLesson, setActiveLesson] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(true); const [paying, setPaying] = useState(false); - const [orderNo, setOrderNo] = useState(''); const [qrCodeUrl, setQrCodeUrl] = useState(''); - const [payLoading, setPayLoading] = useState(false); useEffect(() => { if (!params.id) return; @@ -47,22 +91,12 @@ export default function CourseDetailClient() { .finally(() => setLoading(false)); }, [params.id]); - if (loading) return ( -
- - - - - -
- ); + if (loading) return ; if (error || !course) return (
-
- - - +
+

{error || '课程不存在'}

返回课程列表 @@ -71,166 +105,164 @@ export default function CourseDetailClient() { const totalLessons = course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0); - function PayModal() { - const [qrUrl, setQrUrl] = useState(qrCodeUrl); - useEffect(() => { setQrUrl(qrCodeUrl); }, [qrCodeUrl]); - - if (!paying) return null; - - return ( -
-
-

扫码支付

- {qrUrl ? ( - <> -
- 支付二维码 -
-

请使用微信扫描二维码完成支付

- - ) : ( -

正在生成支付二维码...

- )} -
- -
-
-
- ); - } - return ( -
- + <> + { setPaying(false); setQrCodeUrl(''); }} + /> - {sidebarOpen && ( -
setSidebarOpen(false)} /> - )} - -
-
- -
-

- {activeLesson?.title || course.title} -

+
+ {/* Sidebar */} +
-
- {activeLesson ? ( -
-

{activeLesson.title}

-
- {activeLesson.content ? ( -
- {activeLesson.content} -
- ) : ( -

暂无内容

- )} -
-
- ) : ( -
-
-
- - - + + + + {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + {/* Main content */} +
+ {/* Top bar */} +
+ + +
+

+ {activeLesson?.title || course.title} +

- )} + +
+ {course.isFree ? ( + + 免费 + + ) : ( + <> + + ¥{course.price} + + + + )} + {course.category && ( + {course.category.name} + )} +
+
+ + {/* Lesson content */} +
+ {activeLesson ? ( +
+

{activeLesson.title}

+
+ {activeLesson.content ? ( +
+ {activeLesson.content} +
+ ) : ( +

暂无内容

+ )} +
+
+ ) : ( +
+
+
+ +
+

请从左侧选择课时开始学习

+
+
+ )} +
-
+ ); -} +} \ No newline at end of file diff --git a/frontend/src/app/courses/layout.tsx b/frontend/src/app/courses/layout.tsx new file mode 100644 index 0000000..ac838d2 --- /dev/null +++ b/frontend/src/app/courses/layout.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'AI 课程 - AI 学习教程', + description: '宇之然 AI 课程提供全面的 AI 学习教程,包括 AI 通识、提示词工程、智能体开发、模型百科、AI 沙盒实战等课程,让每个人都能学会使用 AI。', + keywords: ['AI课程', 'AI教程', 'AI学习', 'AI通识', '提示词工程', '智能体教程', 'AI模型百科', 'AI沙盒', 'AI实战', 'ChatGPT教程', 'Claude教程', 'Midjourney教程', 'AI绘画教程', 'AI写作教程'], + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + }, + }, + openGraph: { + title: 'AI 课程 - AI 学习教程', + description: '宇之然 AI 课程提供全面的 AI 学习教程,让每个人都能学会使用 AI。', + type: 'website', + url: 'https://yuzhiran.com/courses', + images: [ + { + url: '/images/og-image.png', + width: 1200, + height: 630, + alt: 'AI 课程', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'AI 课程 - AI 学习教程', + description: '宇之然 AI 课程提供全面的 AI 学习教程,让每个人都能学会使用 AI。', + images: ['/images/og-image.png'], + }, +}; + +export default function CoursesLayout({ children }: { children: React.ReactNode }) { + return children; +} \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index eda3db1..4c83f91 100755 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -63,3 +63,130 @@ font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; } } + +/* Article body styles for markdown content */ +.article-body { + font-size: 1rem; + line-height: 1.8; +} + +.article-body h1, +.article-body h2, +.article-body h3 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 700; + line-height: 1.3; +} + +.article-body h1 { + font-size: 1.5rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.5em; +} + +.article-body h2 { + font-size: 1.25rem; + padding-left: 0.5em; + border-left: 3px solid var(--primary); +} + +.article-body h3 { + font-size: 1.125rem; +} + +.article-body p { + margin-bottom: 1em; +} + +.article-body strong { + font-weight: 600; + color: var(--foreground); +} + +.article-body a { + color: var(--primary); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-bottom-color 0.2s; +} + +.article-body a:hover { + border-bottom-color: var(--primary); +} + +.article-body blockquote { + margin: 1em 0; + padding: 0.75em 1em; + border-left: 3px solid var(--primary); + background: var(--muted); + color: var(--muted-foreground); + border-radius: 0 0.5em 0.5em 0; + font-style: normal; +} + +.article-body blockquote p { + margin-bottom: 0; +} + +.article-body ul, +.article-body ol { + margin-bottom: 1em; + padding-left: 1.5em; +} + +.article-body li { + margin-bottom: 0.25em; +} + +.article-body hr { + margin: 2em 0; + border: none; + border-top: 1px solid var(--border); +} + +.article-body code { + font-family: 'Fira Code', 'JetBrains Mono', 'Menlo', monospace; + font-size: 0.875em; + padding: 0.125em 0.375em; + background: var(--muted); + border-radius: 0.25em; +} + +.article-body pre { + margin: 1em 0; + padding: 1em; + background: var(--muted); + border-radius: 0.5em; + overflow-x: auto; + font-size: 0.875em; +} + +.article-body pre code { + background: none; + padding: 0; +} + +.article-body img { + max-width: 100%; + height: auto; + border-radius: 0.5em; + margin: 1em 0; +} + +.article-body table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; +} + +.article-body th, +.article-body td { + padding: 0.5em 0.75em; + border: 1px solid var(--border); +} + +.article-body th { + background: var(--muted); + font-weight: 600; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 049b961..890b10e 100755 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -9,26 +9,120 @@ export const metadata: Metadata = { default: '宇之然 AI - AI 工具与知识社区', template: '%s | 宇之然 AI', }, - description: '宇之然 AI 是面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科等,让每个人都能用好 AI。', - keywords: ['AI', '人工智能', '学习', '提示词', '智能体', '大模型', '宇之然'], - icons: { - icon: '/favicon.png', - shortcut: '/favicon.png', - apple: '/icon.svg', + description: '宇之然 AI 是面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等,让每个人都能用好 AI。', + keywords: ['AI', '人工智能', '学习', '提示词', '智能体', '大模型', '宇之然', 'AI工具', 'AI教程', 'AI沙盒', '提示词工程', 'AI社区', '知识库', '编程学习', 'AI工具返利', 'AI工具联盟', 'AI工具推荐', 'AI工具测评'], + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + 'max-image-preview': 'large', + 'max-video-preview': -1, + 'max-snippet': -1, + }, + }, + authors: [{ name: '宇之然科技', url: 'https://yuzhiran.com' }], + creator: '宇之然科技', + publisher: '宇之然科技', + formatDetection: { + email: false, + address: false, + telephone: false, + }, + metadataBase: new URL(SITE_URL), + alternates: { + canonical: '/', + languages: { + 'zh-CN': '/', + 'en': '/en', + }, }, openGraph: { type: 'website', locale: 'zh_CN', siteName: '宇之然 AI', title: '宇之然 AI - AI 工具与知识社区', - description: '让每个人都能用好 AI', + description: '让每个人都能用好 AI - AI 工具指南、提示词库、智能体教程、模型百科、AI 沙盒实战', url: SITE_URL, + images: [ + { + url: '/images/og-image.png', + width: 1200, + height: 630, + alt: '宇之然 AI - AI 工具与知识社区', + }, + ], }, + twitter: { + card: 'summary_large_image', + title: '宇之然 AI - AI 工具与知识社区', + description: '让每个人都能用好 AI - AI 工具指南、提示词库、智能体教程、模型百科、AI 沙盒实战', + images: ['/images/og-image.png'], + }, + verification: { + google: 'your-google-verification-code', + yandex: 'your-yandex-verification-code', + }, + icons: { + icon: [ + { url: '/favicon.ico', sizes: 'any' }, + { url: '/favicon.png', type: 'image/png' }, + ], + shortcut: '/favicon.png', + apple: [ + { url: '/apple-icon.png', sizes: '180x180', type: 'image/png' }, + ], + other: [ + { + rel: 'apple-touch-icon-precomposed', + url: '/apple-icon-precomposed.png', + }, + ], + }, + manifest: '/site.webmanifest', + appleWebApp: { + capable: true, + title: '宇之然 AI', + statusBarStyle: 'default', + }, + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }, + ], }; export default function RootLayout({ children }: { children: React.ReactNode }) { + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: '宇之然 AI', + url: SITE_URL, + description: '面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等', + potentialAction: { + '@type': 'SearchAction', + target: `${SITE_URL}/search?q={search_term_string}`, + 'query-input': 'required name=search_term_string', + }, + publisher: { + '@type': 'Organization', + name: '北京宇之然科技中心', + url: SITE_URL, + logo: `${SITE_URL}/favicon.png`, + }, + }; + return ( + + + + + ', html, re.DOTALL | re.IGNORECASE) + assert ld_match, f"Missing JSON-LD structured data on {path}" + ld_content = ld_match.group(1).strip() + try: + data = json.loads(ld_content) + assert '@context' in data, f"JSON-LD missing @context on {path}" + assert '@type' in data, f"JSON-LD missing @type on {path}" + except json.JSONDecodeError: + assert False, f"Invalid JSON-LD on {path}" + +def check_performance(html, path="/"): + """检查性能相关优化""" + # Check for inline scripts (should be minimal) + inline_scripts = re.findall(r']*>(?!.*?)([^<]{100,})', html, re.DOTALL | re.IGNORECASE) + # Large inline scripts are bad for performance + large_scripts = [s for s in inline_scripts if len(s) > 5000] + assert len(large_scripts) <= 2, f"Too many large inline scripts ({len(large_scripts)}) on {path}" + + # Check for preconnect hints + preconnect = re.findall(r']+rel=["\']preconnect["\']', html, re.IGNORECASE) + # Not required but good to have + print(f" Preconnect hints: {len(preconnect)}") + +def check_semantic_html(html, path="/"): + """检查语义化 HTML""" + # Should have proper heading structure + h1_count = len(re.findall(r']*>', html, re.IGNORECASE)) + assert h1_count >= 1, f"No

tag on {path}" + + # Should have main element or equivalent + main_match = re.search(r']*>', html, re.IGNORECASE) + nav_match = re.search(r']*>', html, re.IGNORECASE) + assert main_match or nav_match, f"No
or