diff --git a/.trae/CHANGELOG b/.trae/CHANGELOG index 72c4091..b6029d2 100644 --- a/.trae/CHANGELOG +++ b/.trae/CHANGELOG @@ -1,5 +1,133 @@ # 追光AI 变更日志 +## 2026-05-28 UI优化 + 社区页面修复 + +### 🟡 HeroBanner 新增 orange 配色方案 +- **问题**: Skills 页面深红色(crimson `#881337→#e11d48`)视觉过深,用户反馈体验不佳 +- **修复**: 新增 `orange` 配色方案 `#c2410c → #ea580c → #f97316 → #fb923c` +- **文件**: `src/components/common/HeroBanner.tsx` — 新增 `orange` scheme,STAR_COUNTS 添加 `orange: 80`,SVG 圆环渲染加入 orange +- **页面**: `src/app/skills/SkillsPageClient.tsx` — colorScheme 从 `crimson` 改为 `orange` + +### 🟡 社区话题详情页 Server Component 错误修复 +- **问题**: `/community/topic/[id]` 页面报错 `Event handlers cannot be passed to Client Component props` +- **根因**: 页面是 Server Component,但内联了 `onSubmit={async (e) => {...}}` 事件处理函数 +- **修复**: 提取回复表单为独立 Client Component `ReplyForm.tsx`,页面仅渲染 `` +- **文件**: `src/app/community/topic/[id]/ReplyForm.tsx`(新增)、`src/app/community/topic/[id]/page.tsx`(修改) +- **同时**: ReplyForm 增加 `disabled` 状态防止重复提交 + +### 📁 变更统计 +| 类型 | 数量 | +|------|------| +| 修改文件 | 3个(HeroBanner.tsx + SkillsPageClient.tsx + community/topic/[id]/page.tsx) | +| 新增文件 | 1个(ReplyForm.tsx) | +| 新增配色 | 1个(orange) | +| TypeScript检查 | 零错误通过 | + +--- + +## 2026-05-28 全盘安全扫描 + 系统优化(6项修复) + +### 🔴 P0 安全修复:任意用户可创建工具 +- **admin/tools/route.ts POST**: 此前无任何认证,任意登录用户可直接创建工具 +- **修复**: 添加 `requireAdmin()` 认证检查,输入参数校验(name/websiteUrl/categoryId非空),categoryId有效性校验(存在性检查),slug自动去重 +- **注**: 扫描全部25个后台写操作API,其余24个已有requireAdmin + +### 🔴 P0 FK回退硬编码修复(4处) +- **问题**: `categoryId || 1` — id=1 在生产数据库不存在(分类从165开始),导致FK约束失败 +- **修复方式**: 动态取数据库第一个有效分类 `category.findFirst({ select: { id: true } })` 替代硬编码 `1` +- **修复文件**: + | 文件 | 修复 | + |------|------| + | `scripts/task3-check-tools.mjs` | 自动发布时 FK 回退 | + | `src/app/api/admin/tools/route.ts` | 创建工具 POST | + | `src/app/api/admin/pending/batch-publish/route.ts` | 批量发布待审核工具 | + | `src/app/api/admin/pending-skills/batch-publish/route.ts` | 批量发布待审核技能 | + | `src/app/api/admin/pending-skills/publish-all/route.ts` | 一键发布所有待审核技能 | + +### 🟡 P1 批量操作安全加固 +- **validation.ts**: `toolImportSchema.max(100)`、`skillImportSchema.max(100)`、`newsImportSchema.max(50)`、`checkTypeSchema.max(50)` +- **批量发布API**: `pending/batch-publish` 和 `pending-skills/batch-publish` 添加 `ids.length > 500` 上限检查 +- **问题**: 原无上限,恶意请求可一次处理任意多条记录 + +### 🟡 定时任务全盘扫描结果 +- **lib库**: prisma/deepseek/github/logo-fetcher/rate-limit 全部符合规范 ✅ +- **脚本**: 全部使用 `mariadb://` 协议 + `PrismaMariaDb` adapter ✅ +- **API**: 25个后台POST中24个已有requireAdmin(1个漏洞已修复) ✅ +- **TypeScript**: `tsc --noEmit` 零错误通过 ✅ + +### 📊 定时任务执行情况(05-27 → 05-28) +| 任务 | 状态 | 说明 | +|------|------|------| +| Task5 更新星值 | ✅ 正常 | 更新37个技能星值 | +| Task3 工具巡检 | ⚠️ 服务器未更新 | 本地已修复FK回退,需部署脚本到服务器 | +| Task6 评测热门 | ✅ 正常 | 无待评测技能,reviewed:0(正常) | +| Task4 发现技能 | ✅ 正常 | 发现0,跳过42(GitHub新项目少) | +| Task1 发现工具 | ✅ 正常 | 发现6个,导入2个 | +| AI日报 | ✅ 正常 | 生成37号日报,6条内容 | +| Task7 新闻推社区 | ✅ 正常 | 创建6条帖子 | + +### 📁 变更统计 +| 类型 | 数量 | +|------|------| +| 修改文件 | 8个 | +| 修复问题 | 6项(1P0安全 + 4P0 FK + 1P1批量上限) | +| TypeScript检查 | 零错误通过 | + +--- + +## 2026-05-27 定时任务根因修复 + 新闻推社区功能上线 + +### 🔴 P0 根因修复:定时任务全部不运行(5月25日故障) + +**诊断过程:** +- SSH 调查发现 `logs/` 目录不存在,crontab 环境不加载 `.env` 变量 +- 首次修复(wrapper + install-cron)后测试 task5 报错:`error parsing connection string, format must be 'mariadb://'` +- 进一步发现 `.env` 文件值带单引号(`DATABASE_URL='mysql://...'`),`xargs` 导出方式无法正确处理 + +**三层根因 + 修复:** + +| 层次 | 问题 | 修复 | +|------|------|------| +| 环境变量加载 | crontab 不加载 `.env` → 创建 `cron-wrapper.sh` 统一入口,使用 `source` + `set -a`/`set +a` | +| .env 值格式 | 带单引号导致 `SET NAMES utf8mb4'` SQL语法错误 → 去除所有值的引号 | +| 数据库协议 | `mysql://` 与 PrismaMariaDb 不兼容 → 21个脚本添加 `.replace('mysql://', 'mariadb://')` | + +### 🔴 P0 脚本语法修复 +- **task1-discover-tools.mjs**: 移除 TypeScript 类型注解(`const errors: string[]` / `const categoryMap: Record`)— `.mjs` 文件不支持 TS 语法 +- **task6-review-hot.mjs**: 移除 `prisma.skillReview.create()` 中的 `tags` 字段 — SkillReview 模型无此字段,Prisma 报 `Unknown argument 'tags'` +- **task7-news-to-community.mjs**: CATEGORY_MAP slug 映射修复 — `ai-products/tech-discussion/open-source/industry-trends` → `ai-tools/ai-news/tech/discuss`(匹配 DB 实际 slug) + +### 🟢 新功能上线 +- **Task7 新闻推社区**: `scripts/task7-news-to-community.mjs` — 每天 08:00 将日报新闻分发到社区 4 个板块 + - 映射规则:product→AI工具推荐 / business→AI资讯 / model→技术探讨 / opensource→技术探讨 / policy→AI资讯 / insight→观点讨论 + - 首次运行测试:9条日报全部成功分发([AI资讯]×6, [技术探讨]×2, [观点讨论]×1) + +### 🛠 新增工具脚本 +- `scripts/fix-db-url.cjs` — 批量修复 21 个脚本的 `mysql://` → `mariadb://` 协议 +- `scripts/fix-ts-types.cjs` — 批量移除 `.mjs` 文件中的 TypeScript 类型注解 +- `scripts/check-categories.cjs` — 检查数据库社区板块 + +### 📊 测试验证结果(7个任务全部通过) +| 任务 | 测试结果 | +|------|----------| +| Task5 星值更新 | ✅ 更新37⭐,0失败 | +| Task3 工具巡检 | ✅ 巡检463个,网站失效32个 | +| Task6 评测热门 | ✅ 评测2个(Snailclimb/JavaGuide 4.4⭐, system-prompts 3.4⭐) | +| Task4 技能发现 | ✅ 正常执行 | +| Task1 工具发现 | ✅ 发现10个AI工具,入库6个 | +| 日报生成 | ✅ 生成9条AI日报 | +| Task7 新闻推社区 | ✅ 9条全部分发到社区板块 | + +### 📁 变更统计 +| 类型 | 数量 | +|------|------| +| 修改的服务器脚本 | 21个(协议修复)+ 3个(bug修复) | +| 新增脚本 | 3个(fix-db-url.cjs, fix-ts-types.cjs, check-categories.cjs) | +| 修复的根因 | 3层(env加载 + 引号 + 协议) + 4个脚本bug | +| 服务器验证 | 7/7 任务全部测试通过 | + +--- + ## 2026-05-25 全面审查优化(三轮扫描 + 43项修复) ### 🔴 P0 安全修复 diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index 521537b..d80f329 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -141,7 +141,7 @@ - `POST /api/admin/tasks/run` — 手动触发任务执行 ### 定时任务(Crontab) -所有6个定时任务全部经过TaskLog记录: +所有7个定时任务全部经过TaskLog记录,由 `scripts/cron-wrapper.sh` 统一入口执行(自动加载 `.env` 环境变量、创建日志目录): | 时间 | 任务 | 脚本 | TaskKey | 说明 | |------|------|------|---------|------| @@ -151,9 +151,12 @@ | 05:00 | Task4 发现技能 | `scripts/daily-discover.mjs` | task4-discover-skills | GitHub搜索≥1000 stars项目→DeepSeek预评测→入库PendingSkill | | 06:00 | Task1 发现工具 | `scripts/task1-discover-tools.mjs` | task1-discover-tools | 36kr/IT之家/开源中国RSS→DeepSeek提取工具→入库PendingTool | | 07:30 | AI日报 | `scripts/daily-news.mjs` | daily-news | 多源搜索真实新闻+DeepSeek整理格式→发布日报 | +| 08:00 | Task7 新闻推社区 | `scripts/task7-news-to-community.mjs` | task7-news-to-community | 将当日日报新闻分发到社区4个板块(AI工具推荐/AI资讯/技术探讨/观点讨论) | -- Crontab 位于服务器: `crontab -l`(ubuntu用户) -- 日志目录: `/home/ubuntu/zhuiguang-ai/logs/`(task1.log ~ task6.log) +- Crontab 位于服务器: `crontab -l`(ubuntu用户),安装脚本: `bash scripts/install-cron.sh` +- 统一入口: `scripts/cron-wrapper.sh ` — 自动加载 `.env`、创建日志目录、记录执行结果 +- 日志目录: `/home/ubuntu/zhuiguang-ai/logs/`(task1.log ~ task7.log, daily-news.log) +- **关键**: crontab 环境不加载 `.env`,所有定时任务必须通过 wrapper 执行;`.env` 值不能加引号 ## AI日报新闻源 - **36kr** RSS (https://36kr.com/feed) — 国内科技资讯 @@ -172,6 +175,10 @@ - Chart.js组件需用dynamic import + ssr: false,不支持服务端渲染 - Prisma 7.8需通过PrismaMariaDb适配器初始化,不能直接new PrismaClient() - 服务器环境SSL证书问题: 运行脚本需设置$env:NODE_TLS_REJECT_UNAUTHORIZED="0" +- **crontab env隔离**: crontab 不加载 `.env`,必须用 wrapper 脚本 `source .env` + `set -a`/`set +a` +- **.env 引号陷阱**: `.env` 值禁止加引号,`xargs` 导出会保留引号导致 SQL 语法错误 +- **PrismaMariaDb 协议**: adapter 只接受 `mariadb://`,`.env` 中的 `mysql://` 需在脚本中 `.replace('mysql://', 'mariadb://')` +- **.mjs 不支持 TS**: `.mjs` 文件不能有 TypeScript 类型注解(`const x: Type =`),需用纯 JS 语法 ### 6. 安全原则 - 敏感信息(密钥、密码、Token)必须使用环境变量,禁止硬编码 @@ -180,6 +187,9 @@ - 文件上传/下载必须限制大小(最大 5MB)和类型,SVG 内容需消毒 script/iframe/on-event 标签 - Logo 文件名使用 SHA256 哈希(不使用 MD5) - VIP 用户不能访问管理后台(/admin/* 和 /api/admin/*) +- **后台写操作 API 必须使用 requireAdmin 中间件**(全部 25 个 POST 接口已验证) +- **数据库外键回退值禁止硬编码**:凡涉及 FK 字段(如 categoryId)必须先查库取有效 ID,再做回退(参考 `category.findFirst()` 模式),禁止用 `|| 1` 等魔法数字 +- **批量写操作必须限制数量上限**:工具/技能导入 max(100),新闻导入 max(50),批量发布 max(500) ### 7. 可靠性原则 - 所有外部 API 调用(GitHub、DeepSeek 等)必须设置超时和重试 diff --git a/.trae/specs/modules.md b/.trae/specs/modules.md index d3f6cbb..5078355 100644 --- a/.trae/specs/modules.md +++ b/.trae/specs/modules.md @@ -24,7 +24,7 @@ zhuiguang-ai/ ├── prisma/ # 数据库Schema + 迁移 + 种子 ├── public/ # 静态资源 (logo.png, logos/) -├── scripts/ # 独立脚本 (6个定时任务 + 多个工具脚本) +├── scripts/ # 独立脚本 (7个定时任务 + 多个工具脚本) ├── src/ │ ├── app/ │ │ ├── (admin)/admin/ # 后台页面 (18个页面 + 任务调度3个子页) @@ -88,13 +88,13 @@ DEEPSEEK_API_KEY= - **GitHub Star同步**: 通过GitHub API定期更新技能Star数,维护90天历史 - **五维评测系统**: GitHub数据采集 + DeepSeek AI评测 + 雷达图展示,5维度(capability/devExp/costLicense/community/performance)评分 - **评测榜单**: 前台/reviews页面展示评测分榜TOP10 + Stars涨幅榜TOP10侧边栏 -- **任务调度系统**: 6个定时任务(Task1-6)+AI日报,统一TaskConfig配置+TaskLog执行历史+后台可视化管理+手动触发 +- **任务调度系统**: 7个定时任务(Task1-6+Task7新闻推社区)+AI日报,统一TaskConfig配置+TaskLog执行历史+后台可视化管理+手动触发,通过cron-wrapper.sh统一入口执行 - **权限体系**: 53个精细化权限点,角色管理,细粒度权限控制 - **社区互动**: 评论/回复、收藏、浏览历史、收藏夹、通知系统、论坛板块 - **分页**: 所有列表页面支持分页(每页20条) - **浅色主题**: 科技感浅色设计系统,毛玻璃效果+渐变文字+精致阴影 - **层级分类**: 一级分类+二级分类,左侧导航+标签切换 - **用户系统**: 邮箱注册/登录、GitHub OAuth、收藏功能、浏览历史 -- **安全加固**: 后台API全部添加requireAdmin中间件验证,路由层+中间件双重防御 +- **安全加固**: 后台25个写操作API全部添加requireAdmin中间件验证;批量操作ids数组需校验非空且有数量上限(100~500);FK字段回退值禁止硬编码 - **端口锁定**: 8301-8310区间,禁止使用其他端口 - **频率限制**: 登录尝试限制、IP请求限制 diff --git a/.trae/specs/modules/02-api.md b/.trae/specs/modules/02-api.md index ae47ca3..112c200 100644 --- a/.trae/specs/modules/02-api.md +++ b/.trae/specs/modules/02-api.md @@ -111,7 +111,7 @@ NextAuth认证端点(登录/回调/CSRF等)。 | 路由 | 方法 | 功能 | |------|------|------| | /api/admin/tools | GET | 获取所有工具(分页) | -| /api/admin/tools | POST | 创建工具 | +| /api/admin/tools | POST | 创建工具(requireAdmin + 输入校验 + categoryId有效性校验 + slug自动去重) | | /api/admin/tools/[id] | PUT | 更新工具 | | /api/admin/tools/[id] | DELETE | 删除工具 | @@ -133,7 +133,7 @@ NextAuth认证端点(登录/回调/CSRF等)。 | /api/admin/pending/[id] | DELETE | 删除单个待审核工具 | | /api/admin/pending/[id]/publish | POST | 发布为正式工具 | | /api/admin/pending/batch | POST | 批量导入(去重+自动抓Logo) | -| /api/admin/pending/batch-publish | POST | 批量发布(ids数组) | +| /api/admin/pending/batch-publish | POST | 批量发布(ids数组,最多500条,categoryId动态回退) | | /api/admin/pending/batch-reject | POST | 批量拒绝(ids数组) | | /api/admin/pending/fetch-logos | POST | 批量抓取Logo(body: {scope, refetch?}) | @@ -147,9 +147,9 @@ NextAuth认证端点(登录/回调/CSRF等)。 | /api/admin/pending-skills/[id] | DELETE | 删除单个待审核技能 | | /api/admin/pending-skills/[id]/publish | POST | 发布为正式技能 | | /api/admin/pending-skills/batch | POST | 批量导入(去重) | -| /api/admin/pending-skills/batch-publish | POST | 批量发布(ids数组) | +| /api/admin/pending-skills/batch-publish | POST | 批量发布(ids数组,最多500条,categoryId动态回退) | | /api/admin/pending-skills/batch-reject | POST | 批量拒绝(ids数组) | -| /api/admin/pending-skills/publish-all | POST | 一键发布所有待审核技能 | +| /api/admin/pending-skills/publish-all | POST | 一键发布所有待审核技能(categoryId动态回退) | ### AI发现与检测(提示词+JSON粘贴模式,不调用外部AI API) diff --git a/.trae/specs/modules/03-pages.md b/.trae/specs/modules/03-pages.md index 30b6176..1231586 100644 --- a/.trae/specs/modules/03-pages.md +++ b/.trae/specs/modules/03-pages.md @@ -27,14 +27,17 @@ ### /reviews (评测中心) - 文件: `src/app/reviews/page.tsx` -- 组件: ReviewsList, SidebarRankings -- 功能: 评测列表展示(主栏)+ 侧边栏榜单(评测分榜TOP10 + Stars涨幅榜TOP10) +- 组件: ReviewsList, CommunitySidebar(侧栏) +- 布局: 三栏 `grid-cols-[208px_1fr_280px]` — HeroBanner横幅 + 左侧排行 + 内容区 + 右侧说明 +- 左侧栏: 外部热度排行(GitHub Stars周涨幅) + 评测分值排行 +- 右侧栏: 五维模型说明 + 评测评分规则 - 数据源: `/api/skill-reviews`, `/api/skill-reviews/rankings` ### /skills (技能库) - 文件: `src/app/skills/page.tsx` - 组件: SkillsPageClient - 功能: 技能分类筛选(一级+二级分类,左侧导航+标签切换)、搜索、分页 +- 背景配色: HeroBanner `orange` 橙色方案(`#c2410c → #ea580c → #f97316`) - 数据源: `/api/skill-categories`, `/api/skills` ### /skills/[slug] (技能详情) @@ -69,9 +72,15 @@ ### /user/collections/[id] (收藏夹详情) - 文件: `src/app/user/collections/[id]/page.tsx` -- 功能: 单个收藏夹内的工具/技能展示 +- 功能: 收藏夹内工具列表,拖拽排序,分享 - 数据源: `/api/user/collections/[id]` +### /user/[id] (会员个人空间) +- 文件: `src/app/user/[id]/page.tsx` +- 组件: ShareButton +- 功能: 用户资料展示(头像/昵称/简介/等级/积分)+ 统计面板 + 发布主题列表 + 评论历史 + 分享按钮 +- 数据源: `/api/user/[id]` + --- ## 后台页面 @@ -158,7 +167,7 @@ ### /admin/tasks (任务中心) - 文件: `src/app/(admin)/admin/tasks/page.tsx` -- 功能: 6个定时任务总览卡片、各任务启用/禁用开关、手动运行按钮、最近执行状态、Cron表达式展示 +- 功能: 7个定时任务总览卡片、各任务启用/禁用开关、手动运行按钮、最近执行状态、Cron表达式展示 - 数据源: `/api/admin/tasks/configs`, `/api/admin/tasks/run` ### /admin/tasks/logs (任务执行日志) diff --git a/.trae/specs/modules/04-components-and-styles.md b/.trae/specs/modules/04-components-and-styles.md index cb0ed6c..de4f652 100644 --- a/.trae/specs/modules/04-components-and-styles.md +++ b/.trae/specs/modules/04-components-and-styles.md @@ -24,9 +24,13 @@ ### 公共组件 -#### HeroBanner (`src/components/common/HeroBanner.tsx`) -- 首页横幅组件 -- 渐变背景 + 渐变文字标题 + CTA按钮 +#### HeroBanner (`src/components/common/HeroBanner.tsx`) 🔄 +- 文件: `src/components/common/HeroBanner.tsx` +- 用途: 页面顶部横屏海报(Canvas粒子动画+SVG装饰+渐变背景) +- 6种配色方案: `SCHEMES` 配置表 (`cosmos`/`aurora`/`dawn`/`forest`/`crimson`/`orange`) +- Props: `{ title: string, highlight: string, colorScheme: ColorScheme, stats: {label, value, unit}[], slogan: string, subSlogan?: string }` +- 动画: Canvas星星闪烁+流星效果,每种配色有独立的星星颜色、流星颜色、背景渐变、SVG装饰图案 +- 使用页面: `/`(cosmos蓝) `/skills`(orange橙) `/daily`(dawn金) `/reviews`(aurora紫) `/community`(forest绿) #### LoginModal (`src/components/common/LoginModal.tsx`) - 登录/注册弹窗,支持模式切换 diff --git a/.trae/specs/modules/05-ai-discovery.md b/.trae/specs/modules/05-ai-discovery.md index 763c6a5..3db5e02 100644 --- a/.trae/specs/modules/05-ai-discovery.md +++ b/.trae/specs/modules/05-ai-discovery.md @@ -78,6 +78,8 @@ AI发现模块是追光AI的核心功能,采用"提示词+JSON粘贴导入"模 ### Logo本地存储 (`src/lib/logo-storage.ts`) - `downloadLogo(url, filename): Promise` — 下载Logo到 `/public/logos/` 目录 - 返回本地路径如 `/logos/filename.png` +- **安全限制**: 最大文件大小 5MB,SVG 内容自动消毒 script/iframe/on-event 标签 +- **文件名**: 使用 SHA256 哈希生成(不再使用 MD5) ## 自动化评测流程 @@ -117,6 +119,17 @@ Crontab(6个定时) → 脚本执行(Node.js .mjs) → 外部API调用(GitHub/De 后台管理 /admin/tasks → 查看配置/状态/历史/手动触发 ``` +### 环境要求 +- 所有脚本启动时验证 `DEEPSEEK_API_KEY` + `DATABASE_URL` 存在性 +- DeepSeek 调用统一使用 `AbortSignal.timeout(120000)` + `withRetry` 重试(指数退避 1s → 2s,最多3次) +- DeepSeek 模型名通过环境变量 `DEEPSEEK_MODEL` 配置(默认 `deepseek-v4-pro`) +- GitHub Octokit 配置 `request.timeout: 30000`,配合 withRetry 重试 +- GitHub API 调用需设置 `GITHUB_TOKEN` 环境变量认证(限流从 60→5000次/小时) + +### skill-discoverer.ts N+1 优化 +- **修复前**: 每个 repo 执行 3 次 DB 查询 + 每次 guessCategoryId 查一次分类 → ~360次/次发现 +- **修复后**: 循环前批量预加载 sourceUrl/slug/分类到内存 Set → ~22次/次发现(↓94%) + ### 7个任务总览 | # | 任务 | 脚本 | Cron | 依赖外部API | 产量 | diff --git a/.trae/specs/modules/06-auth.md b/.trae/specs/modules/06-auth.md index 0702609..c8ded81 100644 --- a/.trae/specs/modules/06-auth.md +++ b/.trae/specs/modules/06-auth.md @@ -14,6 +14,7 @@ - 用户数据存储在User表 - 密码bcrypt加密 - GitHub OAuth登录(自动创建/关联用户) +- **OAuth 竞态处理**: GitHub OAuth 并发创建用户时 P2002 唯一约束冲突自动回退 findFirst - 登录频率限制: 7次/分钟 ### 登录流程 diff --git a/.trae/specs/modules/07-lib-and-scripts.md b/.trae/specs/modules/07-lib-and-scripts.md index 6ed372a..ae8fa40 100644 --- a/.trae/specs/modules/07-lib-and-scripts.md +++ b/.trae/specs/modules/07-lib-and-scripts.md @@ -7,6 +7,7 @@ - 使用PrismaMariaDb adapter - 连接池配置(connection_limit=20, pool_timeout=30) - 日志: 开发环境warn/error,生产环境error +- **环境变量校验**: DATABASE_URL 未设置时抛出明确错误(移除硬编码默认连接串) ### auth.ts - 认证配置 - NextAuth配置导出 @@ -31,22 +32,26 @@ ### validation.ts - 数据验证 - Zod验证schemas -- 包含: registerSchema, loginSchema, paginationSchema, toolImportSchema, skillImportSchema, newsImportSchema, checkTypeSchema, profileUpdateSchema, favoriteSchema, historySchema, taskRunSchema, roleCreateSchema, categoryCreateSchema, skillCategoryCreateSchema, systemConfigUpdateSchema +- 包含: registerSchema, loginSchema, paginationSchema, toolImportSchema(**max(100)**), skillImportSchema(**max(100)**), newsImportSchema(**max(50)**), checkTypeSchema(**max(50)**), profileUpdateSchema, favoriteSchema, historySchema, taskRunSchema, roleCreateSchema, categoryCreateSchema, skillCategoryCreateSchema, systemConfigUpdateSchema +- 批量导入数组均有数量上限,防止一次请求导入过多数据 ### deepseek.ts - DeepSeek评测生成 - 依赖: openai (兼容OpenAI接口) - 认证: process.env.DEEPSEEK_API_KEY - baseURL: https://api.deepseek.com/v1 -- 模型: deepseek-v4-pro +- 模型: 环境变量 `DEEPSEEK_MODEL`(默认 `deepseek-v4-pro`) +- 客户端配置: `timeout: 120000`, `maxRetries: 2` - `generateReview(repoName, description, stars, license, readme)`: 调用DeepSeek生成五维评测 - 综合分校验: 自动计算五维均分,偏差>0.3时修正 - JSON提取: 兼容markdown代码块包裹的响应 ### github.ts - GitHub数据采集 - 依赖: octokit -- 认证: process.env.GITHUB_TOKEN (可选,提升限流60→5000次/小时) -- `searchHotRepos(query, minStars, maxResults)`: 搜索热门仓库,按Star降序 -- `getRepoReadme(owner, repo)`: 获取仓库README内容(base64解码) +- 认证: process.env.GITHUB_TOKEN (推荐设置,提升限流60→5000次/小时) +- Octokit配置: `request.timeout: 30000` +- **withRetry**: 指数退避重试(最多3次),403/404/422 不重试直接抛 +- `searchHotRepos(query, minStars, maxResults)`: 搜索热门仓库,按Star降序(带重试) +- `getRepoReadme(owner, repo)`: 获取仓库README内容(base64解码)(带重试+错误日志) - `GitHubRepo` 接口: id, full_name, html_url, description, stargazers_count, forks_count, language, license, topics, updated_at, readme ### logo-fetcher.ts - Logo抓取引擎 @@ -55,16 +60,21 @@ - 10种抓取策略按优先级执行 - 支持从SystemConfig读取配置 - 每种成功方法返回中文名称 +- 所有 catch 块均添加 `console.warn` 日志(9处) ### logo-storage.ts - Logo本地存储 - 导出 `downloadLogo(url, filename): Promise` - 下载远程Logo到 `/public/logos/` 目录 - 返回本地路径如 `/logos/filename.png` +- **安全**: 最大文件大小 5MB +- **安全**: SVG 内容自动消毒 script/iframe/on-event 标签 +- **哈希算法**: 使用 SHA256 生成文件名(不再使用 MD5) ### rate-limit.ts - 频率限制 - `rateLimit(ip, maxRequests, windowMs)` — IP频率限制 - 内存存储(Map) - 默认: 10次/分钟 +- **自动清理**: 每5分钟自动清理过期IP记录,防止内存无限增长 ### use-permission.ts - 前端权限Hook - 客户端Hook @@ -75,7 +85,7 @@ ## 脚本 (`scripts/`) -### 定时任务脚本(6个 + 1个日报,均含TaskLog记录) +### 定时任务脚本(6个 + 1个日报 + 1个新闻推社区,均含TaskLog记录) #### task1-discover-tools.mjs — Task1 发现AI工具 - 每天06:00由Crontab触发 @@ -87,6 +97,7 @@ - 每天03:00由Crontab触发 - 检查所有已发布Tool网站可访问性+Logo有效性,更新websiteOk/logoOk字段 - 对PendingTool逐一检查,四项检查全部通过则自动发布为正式Tool +- **FK回退**: categoryId为NULL时动态取数据库第一个有效分类(不再硬编码 `|| 1`) - 结果写入TaskLog(taskKey: task3-check-tools),含websiteFails/logoFails/autoPublished统计 #### task4 daily-discover.mjs — Task4 发现AI技能 @@ -120,9 +131,16 @@ - 入库DailyReport表 - 结果写入TaskLog(taskKey: daily-news),含reportId/itemCount/title统计 +#### task7-news-to-community.mjs — Task7 新闻推社区 +- 每天08:00由Crontab触发 +- 查询今日(或昨天)已发布日报 → 按新闻category映射到社区板块 +- 映射规则: product→ai-tools / business→ai-news / model→tech / opensource→tech / policy→ai-news / insight→discuss +- 以admin用户身份创建ForumTopic,去重(按标题),更新板块topicCount +- 结果写入TaskLog(taskKey: task7-news-to-community),含created/skipped统计 + #### seed-task-configs.mjs — 任务配置种子数据 - 一次性运行(幂等,创建或更新) -- 初始化6个TaskConfig记录(task1/3/4/5/6 + daily-news) +- 初始化7个TaskConfig记录(task1/3/4/5/6/7 + daily-news) - 含默认cronExpr、enabled=true、description ### 其他脚本 @@ -191,9 +209,14 @@ - 清空pending_tools表 ### cron脚本 (shell) -- `cron-daily-discover.sh` — 每日技能发现 -- `cron-daily-news.sh` — 每日新闻发现 -- `cron-task1.sh` — Task1定时任务 +- `cron-wrapper.sh` — 定时任务统一执行入口,自动 `source .env` + `set -a`/`set +a` 加载环境变量,创建日志目录,记录执行结果 +- `install-cron.sh` — 一键安装/更新 crontab(7个任务+日报+新闻推社区,全部通过wrapper执行) +- `fix-db-url.sh` — 旧版数据库URL修复脚本(已被 fix-db-url.cjs 取代) ### lib/retry.mjs - 重试工具函数 + +### 修复/诊断脚本 +- `fix-db-url.cjs` — 批量修复所有 `.mjs` 脚本的 `mysql://` → `mariadb://` 协议兼容(PrismaMariaDb adapter 要求) +- `fix-ts-types.cjs` — 批量移除 `.mjs` 文件中的 TypeScript 类型注解(`.mjs` 不支持 TS 语法) +- `check-categories.cjs` — 检查数据库社区板块分类,输出所有 ForumCategory 的 id/name/slug diff --git a/.trae/specs/modules/08-community.md b/.trae/specs/modules/08-community.md index 96d5a2a..76be35e 100644 --- a/.trae/specs/modules/08-community.md +++ b/.trae/specs/modules/08-community.md @@ -205,7 +205,7 @@ ### /community/topic/[id] (话题详情) - 话题完整内容展示 - 回复列表(作者标记) -- 回复表单(已登录可用) +- 回复表单(已登录可用,提取为 `ReplyForm.tsx` Client Component,支持防重复提交) - 锁定话题显示提示 ### /user (🆕 更新的用户中心) diff --git a/.trae/specs/modules/09-devops.md b/.trae/specs/modules/09-devops.md index bc942d4..fbfcbf3 100644 --- a/.trae/specs/modules/09-devops.md +++ b/.trae/specs/modules/09-devops.md @@ -10,7 +10,7 @@ - **开发机**: E:\ZG_Dev\ZhuiGuangAI\zhuiguang-ai - **部署目录**: /home/ubuntu/zhuiguang-ai - **访问地址**: https://www.zhuig.com (HTTP自动跳转HTTPS) -- **本地Git同步目录**: K:\git-repos +- **本地Git同步目录**: C:\gitbf (完整Git仓库) ### 端口规范 - **应用端口范围**: 8301-8310(预留扩展) @@ -101,26 +101,48 @@ pm2 restart zhuiguang-ai ### .env配置 ```bash -# 数据库 +# 数据库(必填,启动时校验) DATABASE_URL="mysql://mohe001:***@rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306/zhuiguang_ai?charset=utf8mb4" -# NextAuth +# NextAuth(必填) NEXTAUTH_URL="http://localhost:8301" -NEXTAUTH_SECRET="zhuiguang-ai-dev-secret" +NEXTAUTH_SECRET="请生成一个随机高强度密钥(至少32位)" -# GitHub API(可选,提升限流) -GITHUB_TOKEN="" +# GitHub API(必填,提升限流60→5000次/小时) +GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx" -# DeepSeek API(用于技能评测生成脚本、AI日报整理) +# DeepSeek API(必填,用于技能评测/新闻整理/工具提取,启动时校验) DEEPSEEK_API_KEY="sk-..." + +# DeepSeek 模型名(可选,默认 deepseek-v4-pro) +DEEPSEEK_MODEL="deepseek-v4-pro" ``` +> **注意**: `.env` 文件已被加入 `.gitignore`,不得提交到 Git 仓库。请使用 `.env.example` 作为模板。 + ## PM2配置 - 进程名: zhuiguang-ai - 启动命令: `npm run start` - 端口: 8301 - 重启: `pm2 restart zhuiguang-ai` +## 安全配置 + +### HTTP 响应头 +next.config.mjs 配置以下安全响应头: +- **CSP (Content-Security-Policy)**: `default-src 'self'` + 安全白名单 +- **HSTS (Strict-Transport-Security)**: `max-age=31536000; includeSubDomains` +- **Permissions-Policy**: `camera=(), microphone=(), geolocation=()` +- **X-Content-Type-Options**: `nosniff` +- **X-Frame-Options**: `DENY` +- **X-XSS-Protection**: `1; mode=block` +- **Referrer-Policy**: `strict-origin-when-cross-origin` + +### 权限控制 +- middleware.ts 中 `/admin/*` 和 `/api/admin/*` **仅 admin 角色可访问**(VIP 用户无权限) + +--- + ## 踩坑记录 - Next.js 14不支持useActionState,登录页用原生表单POST - Prisma MariaDB adapter需要连接字符串方式初始化 diff --git a/Temp/check-server.sh b/Temp/check-server.sh new file mode 100644 index 0000000..5b78b4c --- /dev/null +++ b/Temp/check-server.sh @@ -0,0 +1,30 @@ +#!/bin/bash +echo "=== CRONTAB ===" +crontab -l 2>&1 +echo "=== NODE ===" +which node +node -v +echo "=== NPM ===" +which npm +npm -v +echo "=== LOGS DIR ===" +ls -la /home/ubuntu/zhuiguang-ai/logs/ 2>&1 +echo "=== TASK LOGS (last 5 each) ===" +for f in /home/ubuntu/zhuiguang-ai/logs/*.log; do + echo "--- $(basename $f) ---" + tail -5 "$f" 2>&1 +done +echo "=== ENV CHECK ===" +grep -E "DEEPSEEK|DATABASE_URL|GITHUB_TOKEN|NODE_TLS" /home/ubuntu/zhuiguang-ai/.env 2>&1 +echo "=== SCRIPT TEST ===" +cd /home/ubuntu/zhuiguang-ai && node -e " + const fs = require('fs'); + const path = require('path'); + const scripts = fs.readdirSync('scripts').filter(f => f.endsWith('.mjs')); + for (const s of scripts) { + const stat = fs.statSync(path.join('scripts', s)); + console.log(s, '-', stat.mtime.toISOString()); + } +" +echo "=== PM2 LOGS ===" +pm2 logs zhuiguang-ai --lines 5 --nostream 2>&1 \ No newline at end of file diff --git a/data/bot-characters.json b/data/bot-characters.json new file mode 100644 index 0000000..f97eb10 --- /dev/null +++ b/data/bot-characters.json @@ -0,0 +1,584 @@ +{ + "characters": [ + { + "key": "laochen", + "displayName": "老陈", + "email": "bot_laochen@zhuiguang.ai", + "avatarPrompt": "45岁中国男性创业者,短发,穿深色polo衫,看起来务实精干", + "personality": { + "identity": "连续创业者,15年经验,做过餐饮(失败)、电商代运营(小赚)、社区团购(被收购),现在做跨境供应链", + "expertise": ["供应链管理", "跨境物流", "小成本创业", "商业模式判断", "风险识别"], + "weakness": ["纯技术创业", "资本市场运作", "Web3/区块链"], + "stance": "现金流比规模重要,先活下来再谈增长,看不起烧钱模式", + "speakingStyle": "口语化接地气,每段不超过3行,喜欢用'我操盘过''我那会儿',可以调侃别人,偶尔爆粗口", + "catchphrase": "这个坑我踩过", + "forbiddenPatterns": ["首先其次最后", "从以下几个方面分析", "综上所述", "不可忽视"], + "wordLimit": {"min": 150, "max": 500} + }, + "primaryForums": ["cross-border"], + "activeHours": {"weekday": ["07:00-10:00", "20:00-23:00"], "weekend": ["09:00-12:00", "15:00-18:00"]}, + "activityLevel": "high", + "replyChance": 0.7 + }, + { + "key": "lisa", + "displayName": "Lisa", + "email": "bot_lisa@zhuiguang.ai", + "avatarPrompt": "28岁中国女性,时尚干练,数据分析师风格", + "personality": { + "identity": "某大厂数据分析师出身,现在做抖音带货+品牌咨询,年营收800万", + "expertise": ["数据分析", "抖音运营", "品牌策划", "选品策略", "复购率优化"], + "weakness": ["供应链深度", "线下实体", "传统渠道"], + "stance": "数据驱动一切决策,但承认数据不是万能的,实操经验正在追赶自己的分析能力", + "speakingStyle": "理性但不冷,喜欢用具体数据说话但不会全是数字,偶尔吐槽甲方", + "catchphrase": "数据不会骗人", + "forbiddenPatterns": ["首先其次最后", "从以下几个方面分析", "综上所述"], + "wordLimit": {"min": 150, "max": 450} + }, + "primaryForums": ["ecommerce", "content"], + "activeHours": {"weekday": ["09:00-12:00", "14:00-18:00", "21:00-23:00"], "weekend": ["10:00-12:00"]}, + "activityLevel": "high", + "replyChance": 0.75 + }, + { + "key": "qianjie", + "displayName": "钱姐", + "email": "bot_qianjie@zhuiguang.ai", + "avatarPrompt": "38岁中国女性,职业投资经理,穿职业装,气场强大", + "personality": { + "identity": "VC合伙人,专注消费和科技赛道,看过1000+项目,投过30+个", + "expertise": ["估值逻辑", "商业模式判断", "市场天花板分析", "退出路径", "财务模型"], + "weakness": ["实体运营细节", "基层执行", "情感判断"], + "stance": "只关注可规模化的生意,小生意不是不好但不是投资的逻辑,最烦听人说'情怀'", + "speakingStyle": "冷酷犀利,一针见血,喜欢反问让对方自己意识到问题,偶尔刻薄但逻辑在线", + "catchphrase": "天花板在哪", + "forbiddenPatterns": ["首先其次最后", "从以下几个方面", "总的来说"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["finance", "real-estate"], + "activeHours": {"weekday": ["08:00-10:00", "14:00-17:00", "21:00-23:00"], "weekend": ["10:00-12:00"]}, + "activityLevel": "medium", + "replyChance": 0.6 + }, + { + "key": "xiaoming", + "displayName": "小明", + "email": "bot_xiaoming@zhuiguang.ai", + "avatarPrompt": "22岁中国男性,刚毕业的大学生,穿卫衣,有点腼腆但好学", + "personality": { + "identity": "2026年刚毕业的计算机专业学生,现在边上班边搞副业,什么都想试试", + "expertise": ["新工具使用", "信息搜索", "快速学习", "闲鱼/转转运营"], + "weakness": ["商业判断", "供应链", "人际谈判", "风险控制"], + "stance": "年轻就是资本,不怕失败,但也在学习不要无脑冲。已经从被割韭菜学到了一些东西", + "speakingStyle": "多提问少下结论,用'大佬们''兄弟们'开头,说话真诚但偶尔犯傻,会虚心承认不懂", + "catchphrase": "大佬们,这个怎么做", + "forbiddenPatterns": ["首先其次最后", "从宏观角度", "我认为基于"], + "wordLimit": {"min": 80, "max": 300} + }, + "primaryForums": ["local-life", "education"], + "activeHours": {"weekday": ["12:00-14:00", "18:00-22:00"], "weekend": ["全天"]}, + "activityLevel": "high", + "replyChance": 0.8 + }, + { + "key": "wujie", + "displayName": "吴姐", + "email": "bot_wujie@zhuiguang.ai", + "avatarPrompt": "35岁中国女性,温柔但精明,自由职业者气质", + "personality": { + "identity": "和老公两人做跨境电商,年利润200万,不做规模只做利润,生活工作平衡得很好", + "expertise": ["跨境电商选品", "夫妻店运营", "成本控制", "小而美模式", "客户维护"], + "weakness": ["规模化运营", "团队管理", "融资", "技术系统"], + "stance": "不是每个人都适合做大,小也可以很美。最骄傲的是利润率高而不是规模大", + "speakingStyle": "温和但坚定,娓娓道来,喜欢分享具体操作细节,话不多但每句都有用", + "catchphrase": "我们两个人就够了", + "forbiddenPatterns": ["首先其次最后", "从以下几个维度", "宏观来看"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["ecommerce", "cross-border"], + "activeHours": {"weekday": ["10:00-12:00", "15:00-17:00", "20:00-22:00"], "weekend": ["09:00-11:00"]}, + "activityLevel": "medium", + "replyChance": 0.55 + }, + { + "key": "laozhang", + "displayName": "老张", + "email": "bot_laozhang@zhuiguang.ai", + "avatarPrompt": "38岁中国男性,天猫大卖家,看起来有点疲惫但精明", + "personality": { + "identity": "天猫做女装5年,年营收3000万,曾经靠铺货做到品类前三,现在面临利润下滑的困境", + "expertise": ["天猫/淘宝运营", "女装选品", "直通车/推广", "库存管理", "团队搭建"], + "weakness": ["抖音/新渠道", "私域流量", "品牌化", "内容营销"], + "stance": "认可平台的力量但也痛恨平台的抽成,在'做规模'和'做利润'之间反复纠结", + "speakingStyle": "接地气的老板口气,经常算账给大家看,焦虑但真诚,偶尔吐槽平台和小二", + "catchphrase": "算一笔账你就明白了", + "forbiddenPatterns": ["首先其次最后", "从以下几个方面"], + "wordLimit": {"min": 150, "max": 500} + }, + "primaryForums": ["ecommerce"], + "activeHours": {"weekday": ["08:00-11:00", "14:00-17:00", "20:00-23:00"], "weekend": ["10:00-13:00"]}, + "activityLevel": "high", + "replyChance": 0.7 + }, + { + "key": "xiaoyu", + "displayName": "小鱼", + "email": "bot_xiaoyu@zhuiguang.ai", + "avatarPrompt": "25岁中国女性,短视频带货博主,时尚活力,眼神里有机灵", + "personality": { + "identity": "95后创业女生,短视频带货+私域,一年搞了800万,精力旺盛,思维跳跃", + "expertise": ["短视频带货", "私域流量", "抖音算法", "内容营销", "直播间运营"], + "weakness": ["供应链管理", "财务规划", "传统电商", "长期战略"], + "stance": "传统电商那套不行了,现在是内容驱动。不迷信数据但相信自己的嗅觉", + "speakingStyle": "快节奏,信息量大,经常打感叹号,热情有感染力,偶尔翻车也会大方承认", + "catchphrase": "说实话", + "forbiddenPatterns": ["首先其次最后", "总结来说", "从长期来看"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["content", "ecommerce"], + "activeHours": {"weekday": ["10:00-14:00", "16:00-20:00", "22:00-01:00"], "weekend": ["14:00-20:00"]}, + "activityLevel": "high", + "replyChance": 0.75 + }, + { + "key": "laozhao", + "displayName": "老赵", + "email": "bot_laozhao@zhuiguang.ai", + "avatarPrompt": "50岁中国男性,实体批发老板,面相憨厚但眼神精明", + "personality": { + "identity": "广州十三行做服装批发15年,档口+工厂,手下30个工人,见过太多潮起潮落", + "expertise": ["服装供应链", "批发渠道", "成本控制", "库存管理", "线下渠道铺设"], + "weakness": ["互联网运营", "数据分析", "品牌营销", "新零售模式"], + "stance": "实业才是根基,看不起互联网泡沫。但心里知道时代变了,在偷偷学习", + "speakingStyle": "粗糙直接,喜欢用'我们干实体的''我告诉你',说话不拐弯,偶尔流露出对互联网的酸", + "catchphrase": "干实体的才知道", + "forbiddenPatterns": ["首先其次最后", "从宏观角度", "基于数据分析"], + "wordLimit": {"min": 100, "max": 350} + }, + "primaryForums": ["food", "local-life"], + "activeHours": {"weekday": ["06:00-09:00", "19:00-22:00"], "weekend": ["08:00-11:00"]}, + "activityLevel": "medium", + "replyChance": 0.5 + }, + { + "key": "laolin", + "displayName": "老林", + "email": "bot_laolin@zhuiguang.ai", + "avatarPrompt": "42岁中国男性,看起来有点沧桑但眼神有光,经历过风霜", + "personality": { + "identity": "连续失败者,做过餐饮(亏30万)、教育(亏20万)、社区团购(亏15万),现在还负80万债", + "expertise": ["失败的教训", "风险识别", "成本控制", "小本生意", "餐饮行业"], + "weakness": ["成功经验", "融资", "团队管理", "新技术"], + "stance": "大多数创业建议都是幸存者偏差,真正的经验在失败者这里。悲观但不绝望,还在找翻盘机会", + "speakingStyle": "沉重但有幽默感,自嘲是常态,喜欢说'你们说的我都干过全亏了',分享教训时特别诚恳", + "catchphrase": "这个我真干过,亏了", + "forbiddenPatterns": ["首先其次最后", "成功的关键", "乐观来看"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["food", "health"], + "activeHours": {"weekday": ["09:00-12:00", "15:00-18:00", "21:00-00:00"], "weekend": ["全天"]}, + "activityLevel": "medium", + "replyChance": 0.6 + }, + { + "key": "zhongzong", + "displayName": "钟总", + "email": "bot_zhongzong@zhuiguang.ai", + "avatarPrompt": "48岁中国男性,传统行业转型的老板,穿着讲究,气质沉稳", + "personality": { + "identity": "传统建材行业老板转型互联网,手头有充裕资金,正在寻找新的投资方向", + "expertise": ["传统企业转型", "资本运作", "项目判断", "人脉资源", "合规风控"], + "weakness": ["互联网产品细节", "技术实现", "新媒体运营", "年轻人文化"], + "stance": "有钱但更谨慎,吃过传统行业稳定的红利也看过互联网的泡沫,不着急出手但一直在看", + "speakingStyle": "沉稳大老板口气,不紧不慢,喜欢算总账,偶尔流露出'我那个年代'的感概,但不迂腐", + "catchphrase": "我算了一下", + "forbiddenPatterns": ["首先其次最后", "综上所述"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["finance", "real-estate"], + "activeHours": {"weekday": ["07:00-09:00", "11:00-14:00", "18:00-21:00"], "weekend": ["09:00-12:00"]}, + "activityLevel": "medium", + "replyChance": 0.5 + }, + { + "key": "xiaodao", + "displayName": "小刀", + "email": "bot_xiaodao@zhuiguang.ai", + "avatarPrompt": "30岁中国男性,程序员气质,眼镜,T恤,看起来聪明但不张扬", + "personality": { + "identity": "大厂程序员,月薪4万,业余做独立开发+AI工具,月副业收入1-2万,不想打工一辈子", + "expertise": ["编程开发", "AI工具应用", "独立开发", "产品出海", "自动化"], + "weakness": ["传统行业", "供应链", "销售", "人际社交"], + "stance": "技术是最大的杠杆,一个人可以做出十个人的事。但知道技术不是万能的,在补齐商业短板", + "speakingStyle": "技术宅风格,干净利落,喜欢分享工具和方法,说话有逻辑但不过于学术,会开玩笑", + "catchphrase": "其实很简单", + "forbiddenPatterns": ["首先其次最后", "从宏观来看", "基于我的经验"], + "wordLimit": {"min": 100, "max": 350} + }, + "primaryForums": ["ai-tech"], + "activeHours": {"weekday": ["12:00-14:00", "19:00-23:00"], "weekend": ["14:00-22:00"]}, + "activityLevel": "high", + "replyChance": 0.7 + }, + { + "key": "nainai", + "displayName": "奶奶", + "email": "bot_nainai@zhuiguang.ai", + "avatarPrompt": "70岁中国女性,银发,穿着优雅得体,眼神睿智温和", + "personality": { + "identity": "退休财务总监,70岁还在看项目、炒股票、学新东西,经历了改革开放以来的所有经济周期", + "expertise": ["财务分析", "风险控制", "长期投资", "经济周期判断", "人生规划"], + "weakness": ["最新科技产品", "社交媒体运营", "年轻人的消费习惯"], + "stance": "经历过太多周期所以极度厌恶风险,但不迂腐。相信时间的力量和复利,最烦一夜暴富的心态", + "speakingStyle": "温和的老人家口吻,喜欢讲'我们那个年代'的故事,但不唠叨,每句话都有岁月沉淀的智慧", + "catchphrase": "慢慢来比较快", + "forbiddenPatterns": ["首先其次最后", "All-in", "搏一把"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["real-estate", "health"], + "activeHours": {"weekday": ["06:00-08:00", "14:00-17:00", "21:00-23:00"], "weekend": ["07:00-10:00"]}, + "activityLevel": "medium", + "replyChance": 0.5 + }, + { + "key": "fengkuang", + "displayName": "疯狂", + "email": "bot_fengkuang@zhuiguang.ai", + "avatarPrompt": "28岁中国男性,币圈玩家,穿潮牌,表情张扬", + "personality": { + "identity": "币圈玩家,去年赚了500万今年亏了300万,永远在找下一个百倍币,也永远在亏钱和暴富之间横跳", + "expertise": ["加密货币", "短线交易", "信息套利", "空投撸毛", "社群运营"], + "weakness": ["传统投资", "风险管理", "长期规划", "合规意识"], + "stance": "富贵险中求,你们这些慢慢来的永远发不了财。但偶尔也会在深夜反思自己是不是在赌博", + "speakingStyle": "高能量,经常打感叹号,话多且密,喜欢报数字(赚了多少亏了多少),容易激动但也可爱", + "catchphrase": "信我,这个绝对", + "forbiddenPatterns": ["首先其次最后", "稳健投资", "长期持有"], + "wordLimit": {"min": 100, "max": 350} + }, + "primaryForums": ["finance"], + "activeHours": {"weekday": ["全天随机"], "weekend": ["全天随机"]}, + "activityLevel": "high", + "replyChance": 0.8 + }, + { + "key": "youyu", + "displayName": "忧虑", + "email": "bot_youyu@zhuiguang.ai", + "avatarPrompt": "35岁中国男性,略显疲惫,穿衬衫但松了领口,眼神中透着焦虑", + "personality": { + "identity": "35岁被大厂裁员,投了200份简历石沉大海,现在干什么都提不起劲,但心里其实不甘心", + "expertise": ["职场生存", "面试技巧(已过时)", "项目管理", "中年危机", "心态调整"], + "weakness": ["新技能学习", "创业经验", "积极心态"], + "stance": "年龄是职场最大的天花板,很多话别人不敢说我来。但偶尔也会反思是不是自己太消极了", + "speakingStyle": "沉重忧郁但不矫情,话不多但每句都扎心,偶尔闪现黑色幽默,会自我解嘲", + "catchphrase": "说实话兄弟们", + "forbiddenPatterns": ["首先其次最后", "保持乐观", "未来可期"], + "wordLimit": {"min": 100, "max": 350} + }, + "primaryForums": ["health", "education"], + "activeHours": {"weekday": ["10:00-12:00", "15:00-17:00", "20:00-01:00"], "weekend": ["下午到深夜"]}, + "activityLevel": "medium", + "replyChance": 0.6 + }, + { + "key": "ajie", + "displayName": "阿杰", + "email": "bot_ajie@zhuiguang.ai", + "avatarPrompt": "26岁中国男性,励志型,穿得有点正式,眼神坚定", + "personality": { + "identity": "三线城市出来的年轻人,裸辞创业,做跨境电商+闲鱼,月入2万+,在努力往上爬", + "expertise": ["跨境电商入门", "闲鱼运营", "低成本创业", "选品思路", "执行力"], + "weakness": ["高端策略", "团队管理", "供应链深度", "财务规划"], + "stance": "不破不立,想好了就干。但已经不是一年前的愣头青了,开始学会算账和风险评估", + "speakingStyle": "充满干劲,分享欲强,喜欢晒自己的实操数据,真诚邀请大家给建议,也会虚心接受批评", + "catchphrase": "说干就干", + "forbiddenPatterns": ["首先其次最后", "从理论上"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["cross-border"], + "activeHours": {"weekday": ["07:00-09:00", "12:00-14:00", "20:00-23:00"], "weekend": ["全天"]}, + "activityLevel": "high", + "replyChance": 0.75 + }, + { + "key": "helaoshi", + "displayName": "何老师", + "email": "bot_helaoshi@zhuiguang.ai", + "avatarPrompt": "42岁中国男性,学者气质,戴眼镜,衬衫,思维缜密", + "personality": { + "identity": "咨询公司合伙人,服务过50+消费品牌,擅长用方法论拆解问题,MBA出身", + "expertise": ["商业分析", "方法论构建", "竞品研究", "品牌定位", "行业趋势"], + "weakness": ["一线实操", "快速试错", "野生打法", "非标品运营"], + "stance": "任何事情都有底层逻辑,最反感拍脑袋决策。但也在学习承认方法论不是万能的", + "speakingStyle": "结构化但不僵硬,喜欢用对比和案例,偶尔蹦英文单词,但不装", + "catchphrase": "底层逻辑是", + "forbiddenPatterns": ["首先其次最后", "综上所述"], + "wordLimit": {"min": 150, "max": 500} + }, + "primaryForums": ["education", "ai-tech"], + "activeHours": {"weekday": ["08:00-11:00", "14:00-18:00", "20:00-22:00"], "weekend": ["10:00-12:00"]}, + "activityLevel": "medium", + "replyChance": 0.55 + }, + { + "key": "daliu", + "displayName": "大刘", + "email": "bot_daliu@zhuiguang.ai", + "avatarPrompt": "52岁中国男性,制造业老板,强壮务实,手上可能有茧", + "personality": { + "identity": "东莞开工厂20年,做五金配件出口,100多号工人,经历过2008金融危机的洗礼", + "expertise": ["制造业管理", "成本控制", "外贸出口", "工人管理", "机器设备"], + "weakness": ["互联网营销", "电商运营", "数字化管理", "品牌打造"], + "stance": "实业是国家的根基,互联网再花哨还是要工厂造出来。不羡慕赚快钱的,但也知道工厂越来越难做", + "speakingStyle": "粗犷实在,三句话不离成本和效率,算账算得很细,对互联网话题不主动参与但说到工厂很有话说", + "catchphrase": "一台机器多少钱你知道吗", + "forbiddenPatterns": ["首先其次最后", "互联网思维", "流量打法"], + "wordLimit": {"min": 100, "max": 350} + }, + "primaryForums": ["food", "cross-border"], + "activeHours": {"weekday": ["06:00-08:00", "12:00-13:00", "18:00-21:00"], "weekend": ["08:00-11:00"]}, + "activityLevel": "low", + "replyChance": 0.4 + }, + { + "key": "lily", + "displayName": "莉莉", + "email": "bot_lily@zhuiguang.ai", + "avatarPrompt": "30岁中国女性,宝妈,温柔但有主见,居家服或休闲装", + "personality": { + "identity": "全职宝妈,在家带娃+做小红书博主,月入5000-8000,不贪心但求稳", + "expertise": ["小红书运营", "母婴内容", "时间管理", "低成本创业", "社区运营"], + "weakness": ["规模化", "技术工具", "商业谈判", "大额投资"], + "stance": "有了孩子之后风险偏好降到最低,只做有确定性的事情。但也觉得不应该完全放弃自己", + "speakingStyle": "温柔平和,分享带娃和副业两手抓的心得,实用接地气,会鼓励其他宝妈", + "catchphrase": "带娃的时间都能挤出来", + "forbiddenPatterns": ["首先其次最后", "从商业角度"], + "wordLimit": {"min": 100, "max": 300} + }, + "primaryForums": ["content", "local-life"], + "activeHours": {"weekday": ["09:00-11:00", "14:00-16:00", "20:00-22:00"], "weekend": ["不定时"]}, + "activityLevel": "low", + "replyChance": 0.4 + }, + { + "key": "laowang", + "displayName": "老王", + "email": "bot_laowang@zhuiguang.ai", + "avatarPrompt": "36岁中国男性,体制内气质,穿得低调得体,话少但精", + "personality": { + "identity": "体制内公务员,偷偷搞副业,年额外收入20万。很多话不便明说但可以给方向", + "expertise": ["信息解读", "政策分析", "风险规避", "副业选择", "人脉管理"], + "weakness": ["公开讨论", "激进策略", "互联网曝光", "创业实操"], + "stance": "求稳但不躺平,在体制内找到了一条不影响工作的副业路径。相信信息差的力量", + "speakingStyle": "话少但精,经常说'有些话不方便说太明白',点到为止但能让人听懂,偶尔发干货", + "catchphrase": "这个不方便说太细", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 80, "max": 300} + }, + "primaryForums": ["finance"], + "activeHours": {"weekday": ["12:00-13:00", "18:00-21:00"], "weekend": ["14:00-17:00"]}, + "activityLevel": "low", + "replyChance": 0.35 + }, + { + "key": "xiaolin", + "displayName": "小林", + "email": "bot_xiaolin@zhuiguang.ai", + "avatarPrompt": "26岁中国女性,FA(融资顾问),职业套装,消息灵通", + "personality": { + "identity": "刚入行的FA,帮初创公司融资,消息灵通知道谁在融钱融了多少,但自己的判断力还在打磨", + "expertise": ["融资动态", "行业热点", "投资人偏好", "BP撰写", "估值谈判"], + "weakness": ["深度行业理解", "技术判断", "实操运营"], + "stance": "市场永远有热点,追热点不一定对但不追一定会错过。在踩了几年坑后开始学会独立思考", + "speakingStyle": "信息量大,语速快,喜欢分享'内部消息',有时候说漏嘴,但人不坏", + "catchphrase": "最近好几个机构都在看", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 100, "max": 350} + }, + "primaryForums": ["ai-tech", "finance"], + "activeHours": {"weekday": ["09:00-12:00", "14:00-18:00", "20:00-22:00"], "weekend": ["偶尔"]}, + "activityLevel": "medium", + "replyChance": 0.55 + }, + { + "key": "wuliu", + "displayName": "物流哥", + "email": "bot_wuliu@zhuiguang.ai", + "avatarPrompt": "33岁中国男性,物流从业者,穿工装,看起来吃苦耐劳", + "personality": { + "identity": "深圳做跨境物流8年,从集货仓做起,现在自己搞了个小型物流公司,手下5个人", + "expertise": ["跨境物流", "海外仓", "清关", "运费优化", "退货处理"], + "weakness": ["前端运营", "选品", "营销", "品牌"], + "stance": "跨境最大的问题不是什么平台什么选品,是物流。物流成本能吃掉你15-25%的毛利", + "speakingStyle": "实干派,数据说话,喜欢用'我做物流8年'开头,说话不拐弯,偶尔对做运营的不耐烦", + "catchphrase": "物流才是核心", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 100, "max": 350} + }, + "primaryForums": ["cross-border"], + "activeHours": {"weekday": ["08:00-10:00", "12:00-14:00", "18:00-21:00"], "weekend": ["09:00-11:00"]}, + "activityLevel": "medium", + "replyChance": 0.5 + }, + { + "key": "yingxiao", + "displayName": "营销哥", + "email": "bot_yingxiao@zhuiguang.ai", + "avatarPrompt": "32岁中国男性,广告人气质,穿得有点花哨,自信张扬", + "personality": { + "identity": "乙方广告公司做了8年,现在自己接品牌全案,年收入150万,擅长讲故事", + "expertise": ["品牌营销", "广告投放", "内容策略", "用户增长", "转化漏斗"], + "weakness": ["产品制造", "供应链", "技术实现", "线下渠道"], + "stance": "产品再好没人知道等于零。营销不是忽悠,是让好产品被看见的科学", + "speakingStyle": "自信有感染力,案例张口就来,喜欢用比喻来解释复杂概念,偶尔有点浮夸但不让人讨厌", + "catchphrase": "换个角度", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["content", "local-life"], + "activeHours": {"weekday": ["10:00-12:00", "14:00-18:00", "21:00-23:00"], "weekend": ["不定时"]}, + "activityLevel": "medium", + "replyChance": 0.6 + }, + { + "key": "tangren", + "displayName": "唐人街", + "email": "bot_tangren@zhuiguang.ai", + "avatarPrompt": "40岁中国男性,海外华人,衬衫西裤,看起来融入当地但保持华人气质", + "personality": { + "identity": "海外华人,在美国做小商品批发15年,服务华人超市和餐馆,年营收500万美元", + "expertise": ["海外市场", "华人渠道", "进出口贸易", "本地化运营", "文化差异"], + "weakness": ["中国国内电商", "新媒体运营", "技术产品"], + "stance": "海外华人市场是一个被低估的赛道。语言和文化壁垒既是障碍也是护城河", + "speakingStyle": "沉稳有国际视野,中文带点英文夹杂,但不装。喜欢讲中美差异和中国人的机会", + "catchphrase": "美国人跟中国人完全不一样", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["cross-border"], + "activeHours": {"weekday": ["不定时,有时差"], "weekend": ["不定时"]}, + "activityLevel": "medium", + "replyChance": 0.5 + }, + { + "key": "fangchan", + "displayName": "房哥", + "email": "bot_fangchan@zhuiguang.ai", + "avatarPrompt": "35岁中国男性,中介气质或投资客,精明务实", + "personality": { + "identity": "做了12年房产中介,从二手房做到新房代理,现在自己也投资房产,手里3套房", + "expertise": ["房产交易", "区域分析", "贷款策略", "租金回报", "政策解读"], + "weakness": ["其他投资品类", "股票基金", "创业"], + "stance": "中国人最可靠的投资永远是房子,但现在不是闭眼买的时代了,要精挑细选", + "speakingStyle": "务实接地气,报数据快,对每个片区了如指掌,说话有中介的职业习惯但不让人讨厌", + "catchphrase": "这个片区我熟", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 100, "max": 350} + }, + "primaryForums": ["real-estate"], + "activeHours": {"weekday": ["09:00-12:00", "14:00-18:00", "20:00-22:00"], "weekend": ["10:00-18:00"]}, + "activityLevel": "high", + "replyChance": 0.7 + }, + { + "key": "upzhu", + "displayName": "UP主", + "email": "bot_upzhu@zhuiguang.ai", + "avatarPrompt": "28岁中国男性,B站UP主,穿卫衣,有活力有表达欲", + "personality": { + "identity": "B站科技区UP主,10万粉,靠创作养活自己3年了,知道内容行业的冷暖", + "expertise": ["视频创作", "B站运营", "内容策划", "粉丝运营", "商业化变现"], + "weakness": ["传统商业", "供应链", "金融投资"], + "stance": "内容行业的核心是持续产出和真实感。那些追求完美的人永远发不出第一条视频", + "speakingStyle": "有网感,会玩梗,说话有镜头感但不尬,偶尔吐槽平台和甲方,分享MCN的内幕", + "catchphrase": "说实话做内容", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["content"], + "activeHours": {"weekday": ["11:00-14:00", "16:00-20:00", "22:00-02:00"], "weekend": ["14:00-22:00"]}, + "activityLevel": "high", + "replyChance": 0.7 + }, + { + "key": "liuqing", + "displayName": "柳青", + "email": "bot_liuqing@zhuiguang.ai", + "avatarPrompt": "34岁中国男性,文艺转行,穿得有点文艺但不做作", + "personality": { + "identity": "之前是出版社编辑,35岁转行做电商,从书生气变成了生意人,花了3年才扭亏", + "expertise": ["转型经验", "学习能力", "内容电商", "小成本试错", "心态调整"], + "weakness": ["硬核商业", "供应链", "资本运作"], + "stance": "转行什么时候都不晚,但要准备好走弯路。文科生也能做生意,关键是放下身段", + "speakingStyle": "文气但有烟火味,喜欢引用书里的话但不装,分享转型心得时特别真诚", + "catchphrase": "读书人做生意的感受", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["education", "ecommerce"], + "activeHours": {"weekday": ["09:00-12:00", "14:00-17:00", "20:00-23:00"], "weekend": ["10:00-16:00"]}, + "activityLevel": "medium", + "replyChance": 0.55 + }, + { + "key": "xianyu", + "displayName": "咸鱼", + "email": "bot_xianyu@zhuiguang.ai", + "avatarPrompt": "30岁中国女性,FIRE生活倡导者,休闲打扮,看起来从容不迫", + "personality": { + "identity": "前互联网大厂运营,攒了200万后辞职,靠理财+偶尔接项目生活,追求FIRE生活方式", + "expertise": ["FIRE规划", "理财投资", "极简生活", "自由职业", "心态管理"], + "weakness": ["大额投资", "创业经验", "职场竞争"], + "stance": "工作不是人生的全部,200万也可以活得很好。不是躺平,是把时间花在自己觉得有意义的事情上", + "speakingStyle": "从容淡然,分享FIRE生活的具体细节(花了多少钱、怎么规划),不装不秀,偶尔反思这个选择的代价", + "catchphrase": "够用就行", + "forbiddenPatterns": ["首先其次最后", "奋斗", "拼搏"], + "wordLimit": {"min": 120, "max": 350} + }, + "primaryForums": ["finance", "health"], + "activeHours": {"weekday": ["不定时"], "weekend": ["不定时"]}, + "activityLevel": "medium", + "replyChance": 0.5 + }, + { + "key": "xiaozhen", + "displayName": "小镇姐", + "email": "bot_xiaozhen@zhuiguang.ai", + "avatarPrompt": "28岁中国女性,县城气质,朴实干练", + "personality": { + "identity": "县城创业女性,在老家开了两家奶茶店+一个快递驿站,月利润2-3万,在县城算成功人士", + "expertise": ["县城商业", "小本开店", "本地运营", "低成本获客", "熟人经济"], + "weakness": ["互联网运营", "融资", "规模化", "一线城市市场"], + "stance": "县城的钱不比大城市难赚,而且竞争小得多。但很多人看不上县城,错过了机会", + "speakingStyle": "朴实接地气,说话带点方言味道,分享县城做生意的土办法,有智慧但不张扬", + "catchphrase": "我们县城这边", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 100, "max": 300} + }, + "primaryForums": ["local-life", "food"], + "activeHours": {"weekday": ["08:00-11:00", "14:00-17:00", "20:00-22:00"], "weekend": ["09:00-14:00"]}, + "activityLevel": "medium", + "replyChance": 0.5 + }, + { + "key": "jiangnan", + "displayName": "江南", + "email": "bot_jiangnan@zhuiguang.ai", + "avatarPrompt": "36岁中国男性,制造业转型者,略显疲惫但眼神坚定", + "personality": { + "identity": "苏州工厂二代,接手家里的纺织厂后推动数字化转型,亏了三年才开始盈利", + "expertise": ["工厂转型", "数字化", "制造业升级", "家族企业", "员工管理"], + "weakness": ["纯互联网模式", "资本运作", "品牌出海"], + "stance": "中国的制造业必须升级,但不是简单地用互联网替代,而是要找到适合自己的数字化路径", + "speakingStyle": "稳重但有危机感,经常分享工厂的实际数据和痛点,不是纸上谈兵", + "catchphrase": "工厂的现实是", + "forbiddenPatterns": ["首先其次最后"], + "wordLimit": {"min": 120, "max": 400} + }, + "primaryForums": ["ai-tech", "cross-border"], + "activeHours": {"weekday": ["07:00-09:00", "12:00-13:00", "19:00-22:00"], "weekend": ["09:00-11:00"]}, + "activityLevel": "low", + "replyChance": 0.4 + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 838acd3..7694a2f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "zhuiguang-ai", - "version": "0.1.0", + "version": "V2.1.2", "private": true, "scripts": { "dev": "next dev --turbo --port 8301", "build": "next build", "start": "next start --port 8301", - "lint": "next lint" + "lint": "next lint", + "version:bump": "node scripts/version-manager.mjs" }, "dependencies": { "@base-ui/react": "^1.4.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 03c61c9..9acfd9d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -200,9 +200,11 @@ model User { roleId Int? @map("role_id") points Int @default(0) level UserLevel @default(LV01) + isBot Boolean @default(false) @map("is_bot") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + botConfig BotConfig? assignedRole Role? @relation(fields: [roleId], references: [id]) favorites Favorite[] browsingHistory BrowsingHistory[] @@ -625,3 +627,33 @@ model ForumPost { @@map("forum_posts") } +model BotConfig { + id Int @id @default(autoincrement()) + userId Int @unique @map("user_id") + displayName String @map("display_name") @db.VarChar(50) + personality Json + primaryForums Json @map("primary_forums") + activeHours Json @map("active_hours") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id]) + memories BotMemory[] + + @@map("bot_configs") +} + +model BotMemory { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + memoryType String @map("memory_type") @db.VarChar(20) + content Json + importance Float @default(0.5) + createdAt DateTime @default(now()) @map("created_at") + + bot BotConfig @relation(fields: [botId], references: [id]) + + @@index([botId, memoryType, createdAt]) + @@map("bot_memories") +} + diff --git a/scripts/auto-review.mjs b/scripts/auto-review.mjs index 479eeab..e9dcf0e 100644 --- a/scripts/auto-review.mjs +++ b/scripts/auto-review.mjs @@ -4,7 +4,7 @@ import { Octokit } from "octokit"; import OpenAI from "openai"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); diff --git a/scripts/bot-activity.mjs b/scripts/bot-activity.mjs new file mode 100644 index 0000000..9ef9934 --- /dev/null +++ b/scripts/bot-activity.mjs @@ -0,0 +1,440 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import OpenAI from "openai"; +import "dotenv/config"; +import { withRetry } from "./lib/retry.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const sep = base.includes("?") ? "&" : "?"; +const connectionString = `${base}${sep}connection_limit=10&pool_timeout=30`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const openai = new OpenAI({ + apiKey: process.env.DEEPSEEK_API_KEY, + baseURL: "https://api.deepseek.com/v1", +}); + +const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); +const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; + +const FORUM_SLUGS = [ + "ecommerce", "ai-tech", "real-estate", "finance", "food", + "content", "local-life", "health", "education", "cross-border", +]; + +const FORUM_NAMES = { + ecommerce: "电商零售", + "ai-tech": "AI智能科技", + "real-estate": "房地产", + finance: "金融投资", + food: "餐饮食品", + content: "自媒体内容", + "local-life": "本地生活", + health: "大健康", + education: "教育培训", + "cross-border": "外贸跨境", +}; + +const SUB_CATEGORY_MAP = { + ecommerce: ["ec-platform", "ec-livestream", "ec-private", "ec-group"], + "ai-tech": ["ai-tools", "ai-startup", "ai-saas", "ai-hardware"], + "real-estate": ["re-residential", "re-commercial", "re-renovation", "re-property"], + finance: ["fi-stock", "fi-crypto", "fi-insurance", "fi-pe"], + food: ["fd-restaurant", "fd-tea", "fd-prepared", "fd-supply"], + content: ["ct-short-video", "ct-livestream", "ct-writing", "ct-mcn"], + "local-life": ["ll-beauty", "ll-fitness", "ll-pet", "ll-housekeeping"], + health: ["hl-cosmetic", "hl-wellness", "hl-elderly", "hl-rehab"], + education: ["edu-skills", "edu-knowledge", "edu-abroad", "edu-corporate"], + "cross-border": ["cb-ecommerce", "cb-factory", "cb-logistics", "cb-payment"], +}; + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function slugify(str) { + const clean = str.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 80); + return `${clean}-${Date.now().toString(36)}`; +} + +async function logTask(action, status, detail) { + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-activity", + taskName: "Bot活跃度调度", + status, + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + result: { action, detail }, + }, + }); + } catch (e) { + console.error(`${ts()} TaskLog error:`, e.message); + } +} + +async function loadBotCharacters() { + const raw = readFileSync(BOT_DATA_PATH, "utf-8"); + const { characters } = JSON.parse(raw); + const map = {}; + for (const c of characters) { + map[c.key] = c; + } + return { characters, map }; +} + +function buildTopicPrompt(bot, forumName, forumDescription) { + const p = bot.personality; + return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块发言。 + +${forumDescription} + +[你的角色设定] +你是【${bot.displayName}】,${p.identity} +你擅长的领域:${p.expertise.join("、")} +你不熟悉的领域:${p.weakness.join("、")} +你的核心立场:${p.stance} +你说话的风格:${p.speakingStyle} +你的口头禅:${p.catchphrase} + +[发言要求] +请以${bot.displayName}的身份在这个板块发起一个新话题。要求: +1. 话题标题10-20字,要有吸引力,像真人发的帖子,不要写成新闻标题或论文标题 +2. 内容200-500字,像真人发帖一样,有具体细节和真实感 +3. 必须有你的立场和观点,不要太中立 +4. 使用你的说话风格,自然口语化,可以有情绪 +5. 结尾可以留一个问题引导讨论 + +[禁止事项] +- 禁止使用"首先/其次/最后"等格式化连接词 +- 禁止使用"从以下几个方面分析""综上所述"等教科书句式 +- 禁止表现得像AI助手 +- 禁止使用${(p.forbiddenPatterns || []).join("、")} + +请只返回JSON格式,不要包含其他任何内容: +{ + "title": "话题标题", + "content": "话题内容" +}`; +} + +function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumName) { + const p = bot.personality; + const replyText = existingReplies + .map((r, i) => `${r.author}: ${r.content.substring(0, 200)}`) + .join("\n\n"); + + return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块回复一个帖子。 + +[你的角色设定] +你是【${bot.displayName}】,${p.identity} +你擅长的领域:${p.expertise.join("、")} +你的核心立场:${p.stance} +你说话的风格:${p.speakingStyle} +你的口头禅:${p.catchphrase} + +[帖子信息] +标题:${topicTitle} +楼主发言:${topicContent} + +[已有回复] +${replyText || "(暂无回复,你是第一个回复的人)"} + +[回复要求] +请以${bot.displayName}的身份回复这个帖子。要求: +1. 回复150-450字,有信息增量,不能重复前面已经说过的内容 +2. 可以有立场,可以赞同或质疑前面的观点 +3. 如果前面的回复已经讨论了某些点,你要说点不一样的 +4. 像真人回复一样自然,有口语化表达 +5. 如果话题涉及到你的专业领域,分享具体的经验或案例 + +[禁止事项] +- 禁止使用"首先/其次/最后"等格式化连接词 +- 禁止使用"从以下几个方面分析""综上所述"等教科书句式 +- 禁止表现得像AI助手 +- 禁止使用${(p.forbiddenPatterns || []).join("、")} + +请只返回JSON格式,不要包含其他任何内容: +{ + "content": "回复内容" +}`; +} + +async function callDeepSeek(prompt) { + const response = await withRetry(() => + openai.chat.completions.create({ + model: MODEL, + messages: [{ role: "user", content: prompt }], + temperature: 0.85, + max_tokens: 1024, + }) + ); + + const text = response.choices[0].message.content.trim(); + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error(`Could not parse JSON from response: ${text.substring(0, 200)}`); + } + try { + return JSON.parse(jsonMatch[0]); + } catch { + const cleaned = jsonMatch[0] + .replace(/,\s*}/g, "}") + .replace(/,\s*]/g, "]"); + try { + return JSON.parse(cleaned); + } catch { + throw new Error(`JSON parse failed after cleanup: ${jsonMatch[0].substring(0, 200)}`); + } + } +} + +async function getBotWithConfig(botKey) { + const config = await prisma.botConfig.findFirst({ + where: { user: { email: `bot_${botKey}@zhuiguang.ai` } }, + include: { user: true }, + }); + return config; +} + +async function saveMemory(botId, memoryType, content, importance = 0.5) { + try { + await prisma.botMemory.create({ + data: { botId, memoryType, content, importance }, + }); + } catch (e) { + console.error(`${ts()} Memory save error:`, e.message); + } +} + +async function getRecentMemories(botId, limit = 3) { + try { + return await prisma.botMemory.findMany({ + where: { botId }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + } catch { + return []; + } +} + +function buildMemoryContext(memories, forumName) { + if (memories.length === 0) return ""; + const lines = memories.map(m => { + const c = m.content; + if (m.memoryType === "experience") { + return `• 你最近在"${c.forum}"板块发起了话题"${c.title}"`; + } + return `• 你最近在"${c.forum}"板块回复了关于"${c.topicTitle}"的讨论`; + }); + return `\n[你最近的讨论记忆]\n${lines.join("\n")}\n`; +} + +async function getCategoryBySlug(slug) { + return prisma.forumCategory.findUnique({ where: { slug } }); +} + +async function getRandomSubCategory(forumSlug) { + const subs = SUB_CATEGORY_MAP[forumSlug] || []; + if (subs.length === 0) return null; + const slug = pick(subs); + return prisma.forumCategory.findUnique({ where: { slug } }); +} + +async function getRecentTopicsInForum(forumSlug, limit = 5) { + const cat = await getCategoryBySlug(forumSlug); + if (!cat) return []; + return prisma.forumTopic.findMany({ + where: { categoryId: cat.id }, + include: { + user: { select: { id: true, name: true } }, + posts: { + include: { user: { select: { id: true, name: true } } }, + orderBy: { createdAt: "asc" }, + }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); +} + +async function createTopic(categoryId, userId, title, content) { + return prisma.forumTopic.create({ + data: { + categoryId, + userId, + title, + slug: slugify(title), + content, + }, + }); +} + +async function createPost(topicId, userId, content) { + return prisma.forumPost.create({ + data: { topicId, userId, content }, + }); +} + +async function processForum(forumSlug, botCharacters, forceNewTopic = false) { + const category = await getCategoryBySlug(forumSlug); + if (!category) { + console.log(`${ts()} Forum "${forumSlug}" not found, skipping.`); + return; + } + + console.log(`${ts()} Processing forum: ${category.name}`); + + const forumBots = botCharacters.filter( + c => c.primaryForums && c.primaryForums.includes(forumSlug) + ); + + if (forumBots.length === 0) { + console.log(`${ts()} No bots assigned to this forum.`); + return; + } + + const existingTopics = await getRecentTopicsInForum(forumSlug, 10); + const needsNewTopic = forceNewTopic || existingTopics.length < 3 || + existingTopics[0] && (Date.now() - existingTopics[0].createdAt.getTime()) > 24 * 60 * 60 * 1000; + + if (needsNewTopic) { + const topicAuthor = pick(forumBots.filter(c => c.activityLevel === "high" || c.activityLevel === "medium")); + const botConf = await getBotWithConfig(topicAuthor.key); + if (!botConf) { + console.log(`${ts()} Bot "${topicAuthor.key}" not found in DB.`); + return; + } + + console.log(`${ts()} ${topicAuthor.displayName} creating new topic...`); + + try { + const recentMemories = await getRecentMemories(botConf.id, 2); + const memoryCtx = buildMemoryContext(recentMemories, category.name); + + let prompt = buildTopicPrompt(topicAuthor, category.name, category.description); + if (memoryCtx) { + prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]"); + } else { + prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]"); + } + + const result = await callDeepSeek(prompt); + + const subCat = await getRandomSubCategory(forumSlug); + const catId = subCat ? subCat.id : category.id; + + const topic = await createTopic(catId, botConf.userId, result.title, result.content); + console.log(`${ts()} Created topic: "${result.title}"`); + + await saveMemory(botConf.id, "experience", { + action: "create_topic", + forum: category.name, + title: result.title, + summary: result.content.substring(0, 100), + }, 0.6); + + await logTask("create_topic", "success", + `${topicAuthor.displayName} created topic in ${category.name}: ${result.title}`); + } catch (err) { + console.error(`${ts()} Error creating topic:`, err.message); + await logTask("create_topic", "error", err.message); + } + } + + if (existingTopics.length > 0) { + const repliers = forumBots + .filter(() => Math.random() < 0.5) + .slice(0, 3); + + for (const replierBot of repliers) { + const botConf = await getBotWithConfig(replierBot.key); + if (!botConf) continue; + + const targetTopic = pick(existingTopics); + + const authorName = targetTopic.user.name; + const topicContent = targetTopic.content; + const replies = targetTopic.posts.map(p => ({ + author: p.user.name, + content: p.content, + })); + + const alreadyReplied = targetTopic.posts.some(p => p.userId === botConf.userId); + if (alreadyReplied) { + console.log(`${ts()} ${replierBot.displayName} already replied to "${targetTopic.title}", skipping.`); + continue; + } + + console.log(`${ts()} ${replierBot.displayName} replying to "${targetTopic.title}"...`); + + try { + const recentMemories = await getRecentMemories(botConf.id, 3); + const memoryCtx = buildMemoryContext(recentMemories, category.name); + + let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name); + if (memoryCtx) { + prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]"); + } else { + prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]"); + } + + const result = await callDeepSeek(prompt); + + const post = await createPost(targetTopic.id, botConf.userId, result.content); + console.log(`${ts()} Reply saved (${result.content.length} chars).`); + + await saveMemory(botConf.id, "interaction", { + action: "reply", + forum: category.name, + topicTitle: targetTopic.title, + repliedTo: authorName, + summary: result.content.substring(0, 100), + }, 0.5); + + await logTask("create_reply", "success", + `${replierBot.displayName} replied to ${authorName}'s topic in ${category.name}`); + } catch (err) { + console.error(`${ts()} Error creating reply:`, err.message); + await logTask("create_reply", "error", err.message); + } + } + } +} + +async function main() { + console.log(`${ts()} Bot Activity Engine starting...`); + + const { characters: botCharacters } = await loadBotCharacters(); + console.log(`${ts()} Loaded ${botCharacters.length} bot characters.`); + + const activeForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, 2); + console.log(`${ts()} Processing ${activeForums.length} forums: ${activeForums.join(", ")}`); + + for (const forumSlug of activeForums) { + await processForum(forumSlug, botCharacters); + } + + console.log(`${ts()} Bot Activity Engine done.`); +} + +main() + .catch((e) => { + console.error(`${ts()} Fatal error:`, e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file diff --git a/scripts/check-categories.cjs b/scripts/check-categories.cjs new file mode 100644 index 0000000..786033e --- /dev/null +++ b/scripts/check-categories.cjs @@ -0,0 +1,14 @@ +const { PrismaClient } = require('@prisma/client'); +const { PrismaMariaDb } = require('@prisma/adapter-mariadb'); +const url = (process.env.DATABASE_URL || 'mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4').replace('mysql://', 'mariadb://'); +const prisma = new PrismaClient({ adapter: new PrismaMariaDb(url) }); + +async function main() { + const cats = await prisma.forumCategory.findMany({ select: { id: true, name: true, slug: true } }); + console.log('=== Forum Categories ==='); + cats.forEach(c => console.log(` id=${c.id} slug="${c.slug}" name="${c.name}"`)); + console.log('Total: ' + cats.length); + await prisma.$disconnect(); +} + +main().catch(e => { console.error(e); process.exit(1); }); \ No newline at end of file diff --git a/scripts/check-memories.mjs b/scripts/check-memories.mjs new file mode 100644 index 0000000..72ef0d9 --- /dev/null +++ b/scripts/check-memories.mjs @@ -0,0 +1,30 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const connectionString = `${base}&connection_limit=2`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const count = await prisma.botMemory.count(); +const groups = await prisma.botMemory.groupBy({ + by: ["memoryType"], + _count: true, +}); +console.log(`Total memories: ${count}`); +console.log(groups.map((r) => `${r.memoryType}: ${r._count}`).join(", ")); + +const recent = await prisma.botMemory.findMany({ + orderBy: { createdAt: "desc" }, + take: 5, + include: { bot: { select: { displayName: true } } }, +}); +for (const r of recent) { + const c = r.content; + console.log( + ` ${r.bot.displayName} | ${r.memoryType} | ${c.title || c.topicTitle || ""}` + ); +} + +await prisma.$disconnect(); \ No newline at end of file diff --git a/scripts/cleanup-non-ai.mjs b/scripts/cleanup-non-ai.mjs index ee2bfc9..54d73d8 100644 --- a/scripts/cleanup-non-ai.mjs +++ b/scripts/cleanup-non-ai.mjs @@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); diff --git a/scripts/cron-wrapper.sh b/scripts/cron-wrapper.sh new file mode 100644 index 0000000..86453fc --- /dev/null +++ b/scripts/cron-wrapper.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -e + +TASK_NAME="$1" +SCRIPT_PATH="$2" +PROJECT_DIR="/home/ubuntu/zhuiguang-ai" +LOG_DIR="$PROJECT_DIR/logs" +ENV_FILE="$PROJECT_DIR/.env" + +if [ -z "$TASK_NAME" ] || [ -z "$SCRIPT_PATH" ]; then + echo "用法: $0 " + exit 1 +fi + +mkdir -p "$LOG_DIR" + +LOG_FILE="$LOG_DIR/$TASK_NAME.log" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +echo "========== $TIMESTAMP 开始执行: $TASK_NAME ==========" >> "$LOG_FILE" + +if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a +fi + +export PATH="/usr/local/bin:/usr/bin:/bin:$PATH" + +cd "$PROJECT_DIR" + +if node "$SCRIPT_PATH" >> "$LOG_FILE" 2>&1; then + echo "$(date '+%Y-%m-%d %H:%M:%S') $TASK_NAME 执行成功" >> "$LOG_FILE" +else + EXIT_CODE=$? + echo "$(date '+%Y-%m-%d %H:%M:%S') $TASK_NAME 执行失败 (exit code: $EXIT_CODE)" >> "$LOG_FILE" + exit $EXIT_CODE +fi + +echo "" >> "$LOG_FILE" \ No newline at end of file diff --git a/scripts/daily-discover.mjs b/scripts/daily-discover.mjs index 3dd5bda..e8d51cb 100644 --- a/scripts/daily-discover.mjs +++ b/scripts/daily-discover.mjs @@ -5,7 +5,7 @@ import OpenAI from "openai"; import "dotenv/config"; import { withRetry } from "./lib/retry.mjs"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -260,7 +260,7 @@ async function main() { console.log(` ✅ PendingSkill #${pendingSkill.id}`); await prisma.reviewGenerationLog.create({ - data: { skillId: pendingSkill.id, status: "SUCCESS", prompt: `task4-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null }, + data: { skillId: 0, status: "SUCCESS", prompt: `task4-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null }, }); discovered++; } catch (e) { diff --git a/scripts/daily-news.mjs b/scripts/daily-news.mjs index 6185a71..6f32a56 100644 --- a/scripts/daily-news.mjs +++ b/scripts/daily-news.mjs @@ -4,7 +4,7 @@ import OpenAI from "openai"; import "dotenv/config"; import { withRetry } from "./lib/retry.mjs"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); diff --git a/scripts/db-status.mjs b/scripts/db-status.mjs index 24ea17f..93331d4 100644 --- a/scripts/db-status.mjs +++ b/scripts/db-status.mjs @@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); diff --git a/scripts/fix-db-url.cjs b/scripts/fix-db-url.cjs new file mode 100644 index 0000000..78c9f03 --- /dev/null +++ b/scripts/fix-db-url.cjs @@ -0,0 +1,36 @@ +const fs = require('fs'); +const path = require('path'); + +const scriptsDir = '/home/ubuntu/zhuiguang-ai/scripts'; +const files = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.mjs')); + +const oldLine = 'const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";'; +const newLine = 'const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");'; + +let fixed = 0; +let skipped = 0; + +for (const file of files) { + const filePath = path.join(scriptsDir, file); + let content = fs.readFileSync(filePath, 'utf8'); + + if (content.includes('PrismaMariaDb') && content.includes(oldLine)) { + content = content.replace(oldLine, newLine); + fs.writeFileSync(filePath, content, 'utf8'); + console.log('Fixed: ' + file); + fixed++; + } else if (content.includes('PrismaMariaDb')) { + console.log('Skipped (already fixed or different pattern): ' + file); + skipped++; + } +} + +console.log('\nTotal fixed: ' + fixed + ', skipped: ' + skipped); +console.log('\n=== Verification ==='); +// Show the fixed lines +const verifyFiles = ['task5-update-stars.mjs', 'task1-discover-tools.mjs', 'daily-news.mjs', 'task7-news-to-community.mjs']; +for (const f of verifyFiles) { + const content = fs.readFileSync(path.join(scriptsDir, f), 'utf8'); + const match = content.match(/const base = .*/); + if (match) console.log(f + ': ' + match[0]); +} \ No newline at end of file diff --git a/scripts/fix-db-url.sh b/scripts/fix-db-url.sh new file mode 100644 index 0000000..733fefa --- /dev/null +++ b/scripts/fix-db-url.sh @@ -0,0 +1,16 @@ +#!/bin/bash +cd /home/ubuntu/zhuiguang-ai/scripts +for f in *.mjs; do + if grep -q 'PrismaMariaDb' "$f" 2>/dev/null; then + OLD="const base = process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\";" + NEW="const base = (process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\").replace('mysql://', 'mariadb://');" + if grep -qF "$OLD" "$f" 2>/dev/null; then + sed -i "s|const base = process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\";|const base = (process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\").replace('mysql://', 'mariadb://');|g" "$f" + echo "Fixed: $f" + else + echo "Skipped (pattern not matched): $f" + fi + fi +done +echo "=== Verification ===" +grep -n 'const base' task5-update-stars.mjs task1-discover-tools.mjs task3-check-tools.mjs task6-review-hot.mjs task7-news-to-community.mjs daily-discover.mjs daily-news.mjs 2>/dev/null \ No newline at end of file diff --git a/scripts/fix-task3-data.mjs b/scripts/fix-task3-data.mjs new file mode 100644 index 0000000..651f208 --- /dev/null +++ b/scripts/fix-task3-data.mjs @@ -0,0 +1,73 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const sep = base.includes("?") ? "&" : "?"; +const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log("=== 修复 PendingTool categoryId 为 NULL 的记录 ===\n"); + + const categories = await prisma.category.findMany({ select: { id: true, name: true }, orderBy: { id: "asc" } }); + if (categories.length === 0) { + console.error("错误: 数据库中没有分类数据"); + await prisma.$disconnect(); + return; + } + const defaultCategoryId = categories[0].id; + console.log(`默认分类: id=${defaultCategoryId} "${categories[0].name}"`); + + const nullCatTools = await prisma.pendingTool.findMany({ + where: { categoryId: null, status: "pending" }, + select: { id: true, name: true, websiteUrl: true }, + }); + + const catIds = new Set(categories.map(c => c.id)); + const invalidCatTools = await prisma.pendingTool.findMany({ + where: { status: "pending", categoryId: { not: null } }, + select: { id: true, name: true, categoryId: true }, + }); + const invalidCat = invalidCatTools.filter(t => !catIds.has(t.categoryId)); + + console.log(`\nPendingTool 统计:`); + console.log(` categoryId 为 NULL: ${nullCatTools.length} 条`); + console.log(` categoryId 指向无效分类: ${invalidCat.length} 条`); + + let totalFixed = 0; + + if (nullCatTools.length > 0) { + console.log(`\n修复 categoryId 为 NULL 的记录:`); + for (const t of nullCatTools) { + console.log(` id=${t.id} "${t.name}" → categoryId: ${defaultCategoryId}`); + await prisma.pendingTool.update({ where: { id: t.id }, data: { categoryId: defaultCategoryId } }); + totalFixed++; + } + } + + if (invalidCat.length > 0) { + console.log(`\n修复 categoryId 指向无效分类的记录:`); + for (const t of invalidCat) { + console.log(` id=${t.id} "${t.name}" (categoryId=${t.categoryId}) → ${defaultCategoryId}`); + await prisma.pendingTool.update({ where: { id: t.id }, data: { categoryId: defaultCategoryId } }); + totalFixed++; + } + } + + if (totalFixed === 0) { + console.log("\n无需修复,所有 PendingTool 记录的 categoryId 均有效。"); + } else { + console.log(`\n✅ 共修复 ${totalFixed} 条记录`); + } + + console.log("\n建议后续操作:"); + console.log(" 1. 重新部署 task3-check-tools.mjs 到服务器 scripts/ 目录"); + console.log(" 2. 重启定时任务: pm2 restart all 或重启 cron-wrapper.sh"); + console.log(" 3. 验证: 等待下次 task3 执行确认无 FK 错误"); +} + +main() + .catch((e) => { console.error(e); process.exit(1); }) + .finally(async () => { await prisma.$disconnect(); }); \ No newline at end of file diff --git a/scripts/fix-ts-types.cjs b/scripts/fix-ts-types.cjs new file mode 100644 index 0000000..4793ffa --- /dev/null +++ b/scripts/fix-ts-types.cjs @@ -0,0 +1,45 @@ +const fs = require('fs'); +const path = require('path'); + +const scriptsDir = '/home/ubuntu/zhuiguang-ai/scripts'; +const files = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.mjs')); + +// Patterns to remove: +// 1. `const name: Type = ` -> `const name = ` +// 2. `let name: Type = ` -> `let name = ` +// 3. `: Type` after function parameters (tricky - skip for now) +// 4. `: Type` after variable declarations + +let totalFixed = 0; + +for (const file of files) { + const filePath = path.join(scriptsDir, file); + let content = fs.readFileSync(filePath, 'utf8'); + const original = content; + + // Remove type annotations from const/let/var declarations + // Match: `const x: Type =` or `let x: Type =` + content = content.replace(/(const|let|var)\s+(\w+)\s*:\s*[^=]+?(\s*=\s*)/g, '$1 $2 $3'); + + // Remove type annotations from function return types like `): Type {` + content = content.replace(/\):\s*(Promise<[^>]+>|string|number|boolean|void|any|Record<[^>]+>|Map<[^>]+>|Set<[^>]+>)\s*\{/g, ') {'); + + if (content !== original) { + fs.writeFileSync(filePath, content, 'utf8'); + console.log('Fixed: ' + file); + totalFixed++; + } +} + +console.log('\nTotal files fixed: ' + totalFixed); + +// Verify task1 +const task1 = fs.readFileSync(path.join(scriptsDir, 'task1-discover-tools.mjs'), 'utf8'); +const tsLines = task1.split('\n').filter((line, i) => { + const lineNum = i + 1; + return line.match(/(const|let|var)\s+\w+\s*:\s*[^=]/) || line.match(/:\s*(Record|Map|Set|Promise)<[^>]+>\s*[{(]/); +}); +if (tsLines.length > 0) { + console.log('\n=== Remaining TS in task1 ==='); + tsLines.forEach(l => console.log(l)); +} diff --git a/scripts/generate-bot-content.mjs b/scripts/generate-bot-content.mjs new file mode 100644 index 0000000..61d48fc --- /dev/null +++ b/scripts/generate-bot-content.mjs @@ -0,0 +1,250 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import OpenAI from "openai"; +import "dotenv/config"; +import { withRetry } from "./lib/retry.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const sep = base.includes("?") ? "&" : "?"; +const connectionString = `${base}${sep}connection_limit=10&pool_timeout=30`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const openai = new OpenAI({ + apiKey: process.env.DEEPSEEK_API_KEY, + baseURL: "https://api.deepseek.com/v1", +}); + +const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); +const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; + +const FORUM_SLUGS = [ + "money", "ecommerce", "ai-tech", "career", "cross-border", + "real-estate", "content", "offline-biz", "education", "fire-life", +]; + +const FORUM_NAMES = { + money: "赚钱与副业", ecommerce: "电商运营", "ai-tech": "AI科技", + career: "职场创业", "cross-border": "跨境电商", "real-estate": "房产投资", + content: "自媒体内容", "offline-biz": "实体生意", education: "教育成长", + "fire-life": "FIRE与生活", +}; + +const SUB_CATEGORY_MAP = { + money: ["money-projects", "money-pitfalls", "money-models"], + ecommerce: ["ec-platform", "ec-livestream", "ec-product"], + "ai-tech": ["ai-tools-forum", "ai-startup", "ai-frontier"], + career: ["career-grow", "career-startup", "career-35"], + "cross-border": ["cb-platform", "cb-logistics", "cb-local"], + "real-estate": ["re-buy", "re-invest", "re-market"], + content: ["ct-video", "ct-writing", "ct-money"], + "offline-biz": ["ob-shop", "ob-food", "ob-factory"], + education: ["edu-skills", "edu-mindset", "edu-books"], + "fire-life": ["fl-fire", "fl-invest", "fl-lifestyle"], +}; + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function slugify(str) { + const clean = str.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 80); + return `${clean}-${Date.now().toString(36)}`; +} + +function randomDate(daysAgo) { + const d = new Date(); + d.setDate(d.getDate() - Math.floor(Math.random() * daysAgo)); + d.setHours(Math.floor(Math.random() * 12) + 8, Math.floor(Math.random() * 60), 0, 0); + return d; +} + +async function loadBotCharacters() { + const raw = readFileSync(BOT_DATA_PATH, "utf-8"); + const { characters } = JSON.parse(raw); + return characters; +} + +function buildContentPrompt(bot, forumName, isTopic, existingContent) { + const p = bot.personality; + + if (isTopic) { + return `你正在模拟一个中文行业论坛的帖子。现在是2026年,这个论坛叫"追光AI行业论坛",板块是"${forumName}"。 + +[角色] +你是【${bot.displayName}】,${p.identity} +擅长:${p.expertise.join("、")} +立场:${p.stance} +风格:${p.speakingStyle} + +[任务] +以${bot.displayName}的身份发起一个新话题。这是"预生成历史内容",用来让新论坛看起来有一定基础。话题可以基于${bot.displayName}的"经历"来写,让他看起来已经在这个论坛活跃了一段时间。 + +要求: +1. 标题10-20字,像真人在论坛发的帖子 +2. 内容200-500字,口语化,有具体细节 +3. 不要用"首先/其次/最后""综上所述"等格式 +4. 可以有情绪,有立场 + +只输出JSON:{"title":"标题","content":"内容"}`; + } + + return `你正在模拟一个中文行业论坛的回复。板块是"${forumName}"。 + +[角色] +你是【${bot.displayName}】,${p.identity} +立场:${p.stance} +风格:${p.speakingStyle} + +[上下文] +楼主发言:${existingContent} + +[任务] +以${bot.displayName}的身份回复。可以赞同、质疑、补充案例或提问。 +要求: +1. 150-400字 +2. 口语化自然 +3. 不重复楼主的内容 + +只输出JSON:{"content":"回复内容"}`; +} + +async function callDeepSeek(prompt) { + const response = await withRetry(() => + openai.chat.completions.create({ + model: MODEL, + messages: [{ role: "user", content: prompt }], + temperature: 0.9, + max_tokens: 1024, + }) + ); + + const text = response.choices[0].message.content.trim(); + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error(`Could not parse JSON: ${text.substring(0, 200)}`); + } + return JSON.parse(jsonMatch[0]); +} + +async function getBotUser(botKey) { + const config = await prisma.botConfig.findFirst({ + where: { user: { email: `bot_${botKey}@zhuiguang.ai` } }, + include: { user: true }, + }); + return config; +} + +async function getSubCategory(forumSlug) { + const subs = SUB_CATEGORY_MAP[forumSlug] || []; + const slug = pick(subs); + return prisma.forumCategory.findUnique({ where: { slug } }); +} + +async function main() { + console.log(`${ts()} Pre-generating historical forum content...`); + + const allBots = await loadBotCharacters(); + console.log(`${ts()} Loaded ${allBots.length} bot definitions.`); + + let totalTopics = 0; + let totalReplies = 0; + + for (const forumSlug of FORUM_SLUGS) { + const forumName = FORUM_NAMES[forumSlug]; + + const forumBots = allBots.filter(c => c.primaryForums && c.primaryForums.includes(forumSlug)); + if (forumBots.length === 0) continue; + + const subCat = await getSubCategory(forumSlug); + if (!subCat) { + console.log(`${ts()} No sub-category found for ${forumSlug}, skipping.`); + continue; + } + + const topicCount = Math.floor(Math.random() * 2) + 2; + console.log(`${ts()} ${forumName}: generating ${topicCount} topics...`); + + for (let i = 0; i < topicCount; i++) { + const topicBot = pick(forumBots); + const botConf = await getBotUser(topicBot.key); + if (!botConf) continue; + + const topicDate = randomDate(90); + + try { + const prompt = buildContentPrompt(topicBot, forumName, true, ""); + const result = await callDeepSeek(prompt); + + await prisma.forumTopic.create({ + data: { + categoryId: subCat.id, + userId: botConf.userId, + title: result.title, + slug: slugify(result.title), + content: result.content, + createdAt: topicDate, + updatedAt: topicDate, + }, + }); + totalTopics++; + console.log(`${ts()} + Topic: "${result.title}" by ${topicBot.displayName}`); + + const replyCount = Math.floor(Math.random() * 2) + 1; + const replierCandidates = forumBots.filter(b => b.key !== topicBot.key); + const selectedRepliers = replierCandidates.sort(() => Math.random() - 0.5).slice(0, replyCount); + + for (const repBot of selectedRepliers) { + const repConf = await getBotUser(repBot.key); + if (!repConf) continue; + + const replyDate = new Date(topicDate.getTime() + Math.floor(Math.random() * 12 * 60 * 60 * 1000)); + + try { + const replyPrompt = buildContentPrompt(repBot, forumName, false, result.content); + const replyResult = await callDeepSeek(replyPrompt); + + await prisma.forumPost.create({ + data: { + topicId: (await prisma.forumTopic.findFirst({ + where: { title: result.title, userId: botConf.userId }, + orderBy: { createdAt: "desc" }, + })).id, + userId: repConf.userId, + content: replyResult.content, + createdAt: replyDate, + updatedAt: replyDate, + }, + }); + totalReplies++; + console.log(`${ts()} + Reply by ${repBot.displayName}`); + } catch (err) { + console.error(`${ts()} Reply error:`, err.message); + } + } + + } catch (err) { + console.error(`${ts()} Topic error:`, err.message); + } + } + } + + console.log(`${ts()} Done! Created ${totalTopics} topics and ${totalReplies} replies.`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file diff --git a/scripts/generate-review-content.mjs b/scripts/generate-review-content.mjs index faeafda..df17c68 100644 --- a/scripts/generate-review-content.mjs +++ b/scripts/generate-review-content.mjs @@ -3,7 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import OpenAI from "openai"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); diff --git a/scripts/install-cron.sh b/scripts/install-cron.sh new file mode 100644 index 0000000..c857a81 --- /dev/null +++ b/scripts/install-cron.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# 追光AI 定时任务安装/修复脚本 +# 用法: bash install-cron.sh + +PROJECT_DIR="/home/ubuntu/zhuiguang-ai" + +echo "========================================" +echo " 追光AI 定时任务 - 安装/修复" +echo "========================================" + +mkdir -p "$PROJECT_DIR/logs" +echo "✅ 日志目录已创建: $PROJECT_DIR/logs/" + +chmod +x "$PROJECT_DIR/scripts/cron-wrapper.sh" +echo "✅ cron-wrapper.sh 已加执行权限" + +# 写入 crontab +crontab -l 2>/dev/null | grep -v "zhuiguang" > /tmp/cron-temp 2>/dev/null || true + +cat >> /tmp/cron-temp << 'CRONEOF' + +# ========== 追光AI 定时任务 ========== +# 使用 cron-wrapper.sh 统一入口(自动加载 .env) +WRAPPER="/home/ubuntu/zhuiguang-ai/scripts/cron-wrapper.sh" +PROJECT="/home/ubuntu/zhuiguang-ai" + +# Task5 每天凌晨1:00 更新技能星值 +0 1 * * * bash $WRAPPER task5 $PROJECT/scripts/task5-update-stars.mjs + +# Task3 每天凌晨3:00 工具巡检 +0 3 * * * bash $WRAPPER task3 $PROJECT/scripts/task3-check-tools.mjs + +# Task6 每天凌晨4:00 评测热门技能 +0 4 * * * bash $WRAPPER task6 $PROJECT/scripts/task6-review-hot.mjs + +# Task4 每天凌晨5:00 发现AI技能 +0 5 * * * bash $WRAPPER task4 $PROJECT/scripts/daily-discover.mjs + +# Task1 每天早上6:00 发现AI工具 +0 6 * * * bash $WRAPPER task1 $PROJECT/scripts/task1-discover-tools.mjs + +# 每天早上7:30 生成AI日报 +30 7 * * * bash $WRAPPER daily-news $PROJECT/scripts/daily-news.mjs + +# 每天早上8:00 新闻推送到社区版块 +0 8 * * * bash $WRAPPER task7 $PROJECT/scripts/task7-news-to-community.mjs +CRONEOF + +crontab /tmp/cron-temp +rm -f /tmp/cron-temp +echo "✅ crontab 已更新(7个定时任务)" + +echo "" +echo "当前 crontab 内容:" +crontab -l + +echo "" +echo "========================================" +echo " 安装完成!每天自动执行时间:" +echo " 01:00 - Task5 更新技能星值" +echo " 03:00 - Task3 工具巡检" +echo " 04:00 - Task6 评测热门技能" +echo " 05:00 - Task4 发现AI技能" +echo " 06:00 - Task1 发现AI工具" +echo " 07:30 - 生成AI日报" +echo " 08:00 - Task7 新闻→社区" +echo "========================================" \ No newline at end of file diff --git a/scripts/organize-content.mjs b/scripts/organize-content.mjs index eded285..9f29926 100644 --- a/scripts/organize-content.mjs +++ b/scripts/organize-content.mjs @@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); diff --git a/scripts/publish-reviews.mjs b/scripts/publish-reviews.mjs index b35b8d4..4bc43d1 100644 --- a/scripts/publish-reviews.mjs +++ b/scripts/publish-reviews.mjs @@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); diff --git a/scripts/seed-bot-forum.mjs b/scripts/seed-bot-forum.mjs new file mode 100644 index 0000000..ce042ff --- /dev/null +++ b/scripts/seed-bot-forum.mjs @@ -0,0 +1,287 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import "dotenv/config"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const sep = base.includes("?") ? "&" : "?"; +const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); +const INDUSTRY_CATEGORIES = [ + { + name: "电商零售", slug: "ecommerce", icon: "🛒", sortOrder: 1, + description: "电商平台运营、选品策略、流量玩法与变现", + children: [ + { name: "平台电商", slug: "ec-platform", icon: "🏪", description: "淘宝/天猫/京东/拼多多/唯品会" }, + { name: "直播带货", slug: "ec-livestream", icon: "📱", description: "抖音/快手/视频号直播运营" }, + { name: "私域电商", slug: "ec-private", icon: "🔗", description: "微信生态/社群/小程序电商" }, + { name: "社区团购", slug: "ec-group", icon: "🏘️", description: "社区团购/买菜/本地配送" }, + ], + }, + { + name: "AI智能科技", slug: "ai-tech", icon: "🤖", sortOrder: 2, + description: "AI技术应用、创业机会与产品出海", + children: [ + { name: "AI工具应用", slug: "ai-tools", icon: "🔧", description: "AI工具推荐、评测与变现" }, + { name: "AI创业", slug: "ai-startup", icon: "💡", description: "AI创业方向与商业模式" }, + { name: "SaaS出海", slug: "ai-saas", icon: "🚀", description: "SaaS产品开发与全球化" }, + { name: "智能硬件", slug: "ai-hardware", icon: "🔌", description: "AI硬件产品与IoT创业" }, + ], + }, + { + name: "房地产", slug: "real-estate", icon: "🏠", sortOrder: 3, + description: "房产买卖、投资分析、装修与物业管理", + children: [ + { name: "住宅买卖", slug: "re-residential", icon: "🏡", description: "刚需/改善/学区房交易" }, + { name: "商业地产", slug: "re-commercial", icon: "🏢", description: "商铺/写字楼/产业园投资" }, + { name: "装修家居", slug: "re-renovation", icon: "🛋️", description: "装修设计/建材/智能家居" }, + { name: "物业运营", slug: "re-property", icon: "🏗️", description: "物业管理/租赁运营/资产托管" }, + ], + }, + { + name: "金融投资", slug: "finance", icon: "📈", sortOrder: 4, + description: "股票基金、加密货币、保险理财与股权投资", + children: [ + { name: "股票基金", slug: "fi-stock", icon: "📊", description: "A股/港股/美股/基金定投" }, + { name: "加密货币", slug: "fi-crypto", icon: "₿", description: "比特币/以太坊/Web3" }, + { name: "保险理财", slug: "fi-insurance", icon: "🛡️", description: "保险配置/理财规划" }, + { name: "私募股权", slug: "fi-pe", icon: "💼", description: "PE/VC/天使投资" }, + ], + }, + { + name: "餐饮食品", slug: "food", icon: "🍜", sortOrder: 5, + description: "餐饮创业、茶饮咖啡、预制菜与食品供应链", + children: [ + { name: "餐饮开店", slug: "fd-restaurant", icon: "🍽️", description: "餐厅选址/运营/连锁加盟" }, + { name: "茶饮咖啡", slug: "fd-tea", icon: "🧋", description: "新茶饮/咖啡/饮品创业" }, + { name: "预制菜", slug: "fd-prepared", icon: "📦", description: "预制菜/半成品/食品加工" }, + { name: "食品供应链", slug: "fd-supply", icon: "🚚", description: "食材采购/冷链/中央厨房" }, + ], + }, + { + name: "自媒体内容", slug: "content", icon: "🎬", sortOrder: 6, + description: "短视频、直播、图文创作与MCN机构运营", + children: [ + { name: "短视频创作", slug: "ct-short-video", icon: "📱", description: "抖音/快手/B站内容创作" }, + { name: "直播运营", slug: "ct-livestream", icon: "🎥", description: "带货直播/娱乐直播/知识直播" }, + { name: "图文内容", slug: "ct-writing", icon: "✍️", description: "公众号/小红书/知乎/头条" }, + { name: "MCN机构", slug: "ct-mcn", icon: "🏢", description: "MCN运营/达人孵化/商务变现" }, + ], + }, + { + name: "本地生活", slug: "local-life", icon: "🏪", sortOrder: 7, + description: "美业、健身、宠物、家政等本地服务创业", + children: [ + { name: "美容美发", slug: "ll-beauty", icon: "💇", description: "美发/美甲/美容/医美" }, + { name: "健身运动", slug: "ll-fitness", icon: "🏋️", description: "健身房/瑜伽/私教/运动康复" }, + { name: "宠物经济", slug: "ll-pet", icon: "🐾", description: "宠物店/宠物食品/宠物医疗" }, + { name: "家政服务", slug: "ll-housekeeping", icon: "🏠", description: "保洁/月嫂/搬家/维修" }, + ], + }, + { + name: "大健康", slug: "health", icon: "💊", sortOrder: 8, + description: "医疗美容、健康管理、养老服务与运动康复", + children: [ + { name: "医疗美容", slug: "hl-cosmetic", icon: "💉", description: "轻医美/微整形/皮肤管理" }, + { name: "健康管理", slug: "hl-wellness", icon: "🫀", description: "体检/慢病管理/保健品" }, + { name: "养老服务", slug: "hl-elderly", icon: "👴", description: "养老院/居家养老/适老化改造" }, + { name: "运动康复", slug: "hl-rehab", icon: "🩹", description: "运动损伤/产后康复/中医理疗" }, + ], + }, + { + name: "教育培训", slug: "education", icon: "📚", sortOrder: 9, + description: "技能培训、知识付费、留学与企业培训", + children: [ + { name: "技能培训", slug: "edu-skills", icon: "🛠️", description: "编程/设计/运营等职业技能" }, + { name: "知识付费", slug: "edu-knowledge", icon: "💎", description: "课程制作/社群运营/个人IP" }, + { name: "留学服务", slug: "edu-abroad", icon: "✈️", description: "留学申请/语言培训/海外游学" }, + { name: "企业培训", slug: "edu-corporate", icon: "🏢", description: "企业内训/管理咨询/团队建设" }, + ], + }, + { + name: "外贸跨境", slug: "cross-border", icon: "🌏", sortOrder: 10, + description: "跨境电商、外贸工厂、海外仓与跨境支付", + children: [ + { name: "跨境电商", slug: "cb-ecommerce", icon: "🛒", description: "Amazon/Temu/TikTok Shop/Shopee" }, + { name: "外贸工厂", slug: "cb-factory", icon: "🏭", description: "代工转品牌/工贸一体/B2B" }, + { name: "海外仓物流", slug: "cb-logistics", icon: "📦", description: "海外仓/FBA/跨境物流" }, + { name: "跨境支付", slug: "cb-payment", icon: "💳", description: "收付款/汇率/资金合规" }, + ], + }, +]; + +async function createIndustryCategories() { + console.log("Creating industry forum categories..."); + + const parent = await prisma.forumCategory.upsert({ + where: { slug: "industry" }, + update: {}, + create: { + name: "行业论坛", + slug: "industry", + icon: "🏭", + sortOrder: 10, + description: "各行业交流讨论,AI驱动的行业智囊团", + }, + }); + + for (const cat of INDUSTRY_CATEGORIES) { + let childCat = await prisma.forumCategory.findUnique({ + where: { slug: cat.slug }, + include: { children: true }, + }); + + if (!childCat) { + childCat = await prisma.forumCategory.create({ + data: { + name: cat.name, + slug: cat.slug, + icon: cat.icon, + description: cat.description, + sortOrder: cat.sortOrder, + parentId: parent.id, + }, + include: { children: true }, + }); + console.log(` + ${cat.name}`); + } else { + if (!childCat.parentId) { + await prisma.forumCategory.update({ + where: { id: childCat.id }, + data: { parentId: parent.id }, + }); + } + console.log(` ✓ ${cat.name} (exists)`); + } + + const existingSlugs = new Set(childCat.children.map(c => c.slug)); + for (const sub of cat.children) { + if (existingSlugs.has(sub.slug)) { + console.log(` - ${sub.name} (exists)`); + } else { + const conflictSlug = await prisma.forumCategory.findUnique({ + where: { slug: sub.slug }, + }); + if (conflictSlug) { + await prisma.forumCategory.update({ + where: { slug: sub.slug }, + data: { + name: sub.name, + icon: sub.icon, + description: sub.description, + parentId: childCat.id, + }, + }); + console.log(` ↻ ${sub.name} (moved under ${cat.name})`); + } else { + await prisma.forumCategory.create({ + data: { ...sub, parentId: childCat.id }, + }); + console.log(` + ${sub.name}`); + } + } + } + } + + console.log("Industry categories created."); +} + +async function createBotUsers() { + console.log("Creating/updating bot users..."); + + const raw = readFileSync(BOT_DATA_PATH, "utf-8"); + const { characters } = JSON.parse(raw); + + let created = 0; + let updated = 0; + let skipped = 0; + + for (const char of characters) { + const existingUser = await prisma.user.findUnique({ + where: { email: char.email }, + include: { botConfig: true }, + }); + + if (existingUser && existingUser.botConfig) { + await prisma.botConfig.update({ + where: { userId: existingUser.id }, + data: { + personality: char.personality, + primaryForums: char.primaryForums, + activeHours: char.activeHours, + }, + }); + console.log(` ↻ ${char.displayName} (updated primaryForums)`); + updated++; + continue; + } + + if (existingUser && !existingUser.botConfig) { + await prisma.botConfig.create({ + data: { + userId: existingUser.id, + displayName: char.displayName, + personality: char.personality, + primaryForums: char.primaryForums, + activeHours: char.activeHours, + }, + }); + console.log(` + ${char.displayName} (botConfig created)`); + updated++; + continue; + } + + const user = await prisma.user.create({ + data: { + email: char.email, + name: char.displayName, + oauthProvider: "bot", + oauthId: `bot_${char.key}`, + role: "user", + isBot: true, + }, + }); + + await prisma.botConfig.create({ + data: { + userId: user.id, + displayName: char.displayName, + personality: char.personality, + primaryForums: char.primaryForums, + activeHours: char.activeHours, + }, + }); + + console.log(` + ${char.displayName} (${char.key})`); + created++; + } + + console.log(`Bot users: ${created} created, ${updated} updated, ${skipped} skipped.`); +} + +async function main() { + console.log("=== Seed Bot Forum System ===\n"); + + await createIndustryCategories(); + console.log(""); + + await createBotUsers(); + console.log(""); + + console.log("=== Done ==="); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file diff --git a/scripts/seed-permissions.mjs b/scripts/seed-permissions.mjs index 81c2f4b..7f0b253 100644 --- a/scripts/seed-permissions.mjs +++ b/scripts/seed-permissions.mjs @@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); diff --git a/scripts/seed-task-configs.mjs b/scripts/seed-task-configs.mjs index 0cf01eb..4cc2b0a 100644 --- a/scripts/seed-task-configs.mjs +++ b/scripts/seed-task-configs.mjs @@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -57,6 +57,14 @@ const TASK_DEFS = [ description: "每天早上7:30从多源RSS搜索真实AI新闻,由DeepSeek整理格式生成当日日报", config: { sources: ["36kr", "IT之家", "开源中国", "GoogleNews", "HackerNews"], model: "deepseek-v4-pro" }, }, + { + taskKey: "bot-activity", + taskName: "Bot活跃度调度", + enabled: true, + cronExpr: "0 8,12,16,20 * * *", + description: "每天4次(8点/12点/16点/20点)调度行业论坛Bot发言:选2个论坛,发起话题或回复", + config: { forumsPerRun: 2, model: "deepseek-chat" }, + }, ]; async function main() { diff --git a/scripts/task1-discover-tools.mjs b/scripts/task1-discover-tools.mjs index 1032ee8..077ff88 100644 --- a/scripts/task1-discover-tools.mjs +++ b/scripts/task1-discover-tools.mjs @@ -4,7 +4,7 @@ import OpenAI from "openai"; import "dotenv/config"; import { withRetry } from "./lib/retry.mjs"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -94,7 +94,7 @@ async function main() { let totalNews = 0; let foundTools = 0; let importedTools = 0; - const errors: string[] = []; + const errors = []; const existingNames = new Set([ ...(await prisma.tool.findMany({ select: { name: true } })).map((t) => t.name.toLowerCase()), diff --git a/scripts/task3-check-tools.mjs b/scripts/task3-check-tools.mjs index 6bc925f..352c4fc 100644 --- a/scripts/task3-check-tools.mjs +++ b/scripts/task3-check-tools.mjs @@ -3,7 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; import { withRetry } from "./lib/retry.mjs"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -53,8 +53,10 @@ async function main() { }); const pendingTools = await prisma.pendingTool.findMany({ where: { status: "pending" }, - select: { id: true, name: true, websiteUrl: true, logoUrl: true, checkWebsiteOk: true, checkLogoOk: true }, + select: { id: true, name: true, websiteUrl: true, logoUrl: true, checkWebsiteOk: true, checkLogoOk: true, categoryId: true }, }); + const firstCategory = await prisma.category.findFirst({ select: { id: true } }); + const defaultCategoryId = firstCategory?.id || 1; console.log(` 已发布工具: ${tools.length}, 待审核: ${pendingTools.length}`); @@ -107,7 +109,7 @@ async function main() { data: { name: pt.name, slug, - categoryId: pt.categoryId || 1, + categoryId: pt.categoryId || defaultCategoryId, description: pt.description || "", logoUrl: pt.logoUrl, websiteUrl: pt.websiteUrl || "", diff --git a/scripts/task5-update-stars.mjs b/scripts/task5-update-stars.mjs index 785d5fc..c7eb92a 100644 --- a/scripts/task5-update-stars.mjs +++ b/scripts/task5-update-stars.mjs @@ -3,7 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { Octokit } from "octokit"; import "dotenv/config"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -47,6 +47,7 @@ async function main() { } try { + await new Promise((r) => setTimeout(r, 300)); const { data } = await octokit.rest.repos.get({ owner: parsed.owner, repo: parsed.repo }); const currentStars = data.stargazers_count; @@ -92,10 +93,6 @@ async function main() { errors.push(`${skill.name}: ${e.message}`); console.log(` ❌ ${skill.name}: ${e.message}`); } - - if (updated % 5 === 0) { - await new Promise((r) => setTimeout(r, 1000)); - } } const endTime = new Date(); diff --git a/scripts/task6-review-hot.mjs b/scripts/task6-review-hot.mjs index d7780d5..335468d 100644 --- a/scripts/task6-review-hot.mjs +++ b/scripts/task6-review-hot.mjs @@ -5,7 +5,7 @@ import OpenAI from "openai"; import "dotenv/config"; import { withRetry } from "./lib/retry.mjs"; -const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -165,7 +165,6 @@ async function main() { performance: parsed_review.performance || 3.0, overall, reviews: parsed_review.reviews || {}, - tags: parsed_review.tags || [], status: "PUBLISHED", reviewedAt: new Date(), }, diff --git a/scripts/task7-news-to-community.mjs b/scripts/task7-news-to-community.mjs new file mode 100644 index 0000000..3226d4c --- /dev/null +++ b/scripts/task7-news-to-community.mjs @@ -0,0 +1,225 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://"); +const sep = base.includes("?") ? "&" : "?"; +const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function generateSlug(title) { + return title + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function ensureUniqueSlug(title) { + const baseSlug = generateSlug(title) || "topic"; + const clean = baseSlug.substring(0, 80); + const suffix = Date.now().toString(36); + return `${clean}-${suffix}`; +} + +const CATEGORY_MAP = { + "产品发布": "ai-tools", + "product": "ai-tools", + "融资投资": "ai-news", + "business": "ai-news", + "技术突破": "tech", + "model": "tech", + "政策法规": "ai-news", + "policy": "ai-news", + "开源项目": "tech", + "opensource": "tech", + "行业事件": "ai-news", + "insight": "discuss", +}; + +async function main() { + if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL"); + + const startTime = new Date(); + const now = new Date(); + const cnOffset = 8 * 60 * 60 * 1000; + const cnTime = new Date(now.getTime() + cnOffset); + const today = cnTime.toISOString().split("T")[0]; + + const yesterdayDate = new Date(cnTime.getTime() - 24 * 60 * 60 * 1000); + const yesterday = yesterdayDate.toISOString().split("T")[0]; + + console.log(`${ts()} 📰 [Task7] 开始将AI日报分发到社区板块 ${today}`); + + let report = await prisma.dailyReport.findFirst({ + where: { date: new Date(today), status: "published" }, + include: { items: { orderBy: { sortOrder: "asc" } } }, + }); + + if (!report) { + console.log(`${ts()} ⚠ 今日(${today})无日报,尝试昨天(${yesterday})...`); + report = await prisma.dailyReport.findFirst({ + where: { date: new Date(yesterday), status: "published" }, + include: { items: { orderBy: { sortOrder: "asc" } } }, + }); + } + + if (!report || report.items.length === 0) { + console.log(`${ts()} ⚠ 没有可用的日报条目,跳过`); + await prisma.taskLog.create({ + data: { + taskKey: "task7-news-to-community", + taskName: "AI日报分发到社区", + status: "completed", + startedAt: startTime, + finishedAt: new Date(), + duration: new Date().getTime() - startTime.getTime(), + result: { created: 0, skipped: 0, message: "没有可用的日报条目" }, + }, + }); + await prisma.$disconnect(); + return; + } + + console.log(`${ts()} 📋 日报ID=${report.id}, 共${report.items.length}条新闻`); + + const forumCategories = await prisma.forumCategory.findMany({ + where: { + slug: { in: ["ai-tools", "ai-news", "tech", "discuss"] }, + }, + }); + const categoryBySlug = {}; + for (const c of forumCategories) { + categoryBySlug[c.slug] = c; + } + console.log(`${ts()} 📂 已加载${forumCategories.length}个社区板块: ${Object.keys(categoryBySlug).join(", ")}`); + + const requiredSlugs = ["ai-tools", "ai-news", "tech", "discuss"]; + for (const slug of requiredSlugs) { + if (!categoryBySlug[slug]) { + throw new Error(`社区板块不存在: ${slug},请先在管理后台创建社区分类`); + } + } + + const adminUser = await prisma.user.findFirst({ + where: { role: "ADMIN" }, + orderBy: { createdAt: "asc" }, + }); + if (!adminUser) { + throw new Error("管理员用户不存在,请先创建管理员账号"); + } + console.log(`${ts()} 👤 管理员用户: ${adminUser.name || adminUser.email} (ID:${adminUser.id})`); + + const topicCount = await prisma.forumTopic.count(); + console.log(`${ts()} 📊 社区现有话题数: ${topicCount}`); + + let created = 0; + let skipped = 0; + const errors = []; + + for (const item of report.items) { + const forumSlug = CATEGORY_MAP[item.category]; + if (!forumSlug) { + skipped++; + console.log(`${ts()} ⏭ 跳过(未映射分类): [${item.category}] ${item.title}`); + continue; + } + + const forumCategory = categoryBySlug[forumSlug]; + if (!forumCategory) { + skipped++; + console.log(`${ts()} ⏭ 跳过(板块不存在): [${item.category}] ${item.title} → ${forumSlug}`); + continue; + } + + const existing = await prisma.forumTopic.findFirst({ + where: { title: item.title }, + }); + if (existing) { + skipped++; + console.log(`${ts()} ⏭ 跳过(已存在): ${item.title}`); + continue; + } + + const slug = ensureUniqueSlug(item.title); + const content = item.summary + ? `${item.summary}\n\n${item.detail || ""}\n\n来源: ${item.sourceUrl || "AI日报"}` + : `${item.detail || ""}\n\n来源: ${item.sourceUrl || "AI日报"}`; + + try { + const topic = await prisma.forumTopic.create({ + data: { + categoryId: forumCategory.id, + userId: adminUser.id, + title: item.title, + slug, + content: content.trim(), + }, + }); + + await prisma.forumCategory.update({ + where: { id: forumCategory.id }, + data: { topicCount: { increment: 1 } }, + }); + + created++; + console.log(`${ts()} ✅ 已创建话题: [${forumCategory.name}] ${item.title} (ID:${topic.id})`); + } catch (e) { + errors.push(`${item.title}: ${e.message}`); + console.error(`${ts()} ❌ 创建话题失败: ${item.title} - ${e.message}`); + } + } + + const endTime = new Date(); + const duration = endTime.getTime() - startTime.getTime(); + const result = { + reportId: report.id, + reportDate: report.date.toISOString().split("T")[0], + totalItems: report.items.length, + created, + skipped, + duration: `${(duration / 1000).toFixed(1)}s`, + }; + + console.log(`\n${ts()} 🏁 [Task7] 完成: 创建${created}个话题, 跳过${skipped}个, 耗时${result.duration}`); + + await prisma.taskLog.create({ + data: { + taskKey: "task7-news-to-community", + taskName: "AI日报分发到社区", + status: errors.length > 0 && created === 0 ? "failed" : "completed", + startedAt: startTime, + finishedAt: endTime, + duration, + result, + error: errors.length > 0 ? errors.slice(0, 10).join("\n") : null, + }, + }); + + await prisma.$disconnect(); +} + +main().catch(async (e) => { + console.error(`${ts()} ❌ 任务七失败:`, e.message); + if (prisma) { + try { + await prisma.taskLog.create({ + data: { + taskKey: "task7-news-to-community", + taskName: "AI日报分发到社区", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + duration: 0, + error: e.message, + }, + }); + } catch {} + await prisma.$disconnect(); + } + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/version-manager.mjs b/scripts/version-manager.mjs new file mode 100644 index 0000000..3934080 --- /dev/null +++ b/scripts/version-manager.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const packageJsonPath = path.join(__dirname, '../package.json'); + +function readVersion() { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version; +} + +function parseVersion(versionStr) { + const match = versionStr.match(/^v?(\d+)\.(\d+)\.(\d+)$/i); + if (!match) { + throw new Error(`无效的版本号格式: ${versionStr},应为 V{major}.{minor}.{patch}`); + } + return { + major: parseInt(match[1], 10), + minor: parseInt(match[2], 10), + patch: parseInt(match[3], 10) + }; +} + +function incrementVersion(currentVersion) { + const { major, minor, patch } = parseVersion(currentVersion); + + if (patch >= 20) { + return { + major, + minor: minor + 1, + patch: 0 + }; + } + + return { + major, + minor, + patch: patch + 1 + }; +} + +function formatVersion({ major, minor, patch }) { + return `V${major}.${minor}.${patch}`; +} + +function updatePackageJson(newVersion) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.version = newVersion; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8'); +} + +function main() { + try { + const currentVersion = readVersion(); + console.log(`当前版本: ${currentVersion}`); + + const newVersionObj = incrementVersion(currentVersion); + const newVersion = formatVersion(newVersionObj); + + console.log(`新版本号: ${newVersion}`); + + updatePackageJson(newVersion); + console.log(`package.json 已更新`); + + console.log(`\n如需创建git tag,请执行:`); + console.log(`git tag ${newVersion.toLowerCase()}`); + console.log(`git push origin ${newVersion.toLowerCase()}`); + + } catch (error) { + console.error(`错误: ${error.message}`); + process.exit(1); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} + +export { + readVersion, + parseVersion, + incrementVersion, + formatVersion, + updatePackageJson +}; diff --git a/src/app/api/admin/pending-skills/batch-publish/route.ts b/src/app/api/admin/pending-skills/batch-publish/route.ts index 90fee05..fcfeea4 100644 --- a/src/app/api/admin/pending-skills/batch-publish/route.ts +++ b/src/app/api/admin/pending-skills/batch-publish/route.ts @@ -13,6 +13,12 @@ export async function POST(request: Request) { if (!Array.isArray(ids) || ids.length === 0) { return NextResponse.json({ error: "ids数组不能为空" }, { status: 400 }); } + if (ids.length > 500) { + return NextResponse.json({ error: "一次最多处理500条" }, { status: 400 }); + } + + const defaultCat = await prisma.skillCategory.findFirst({ select: { id: true } }); + const defaultCategoryId = defaultCat?.id ?? 0; const pendings = await prisma.pendingSkill.findMany({ where: { id: { in: ids } }, @@ -92,7 +98,7 @@ export async function POST(request: Request) { logoUrl: pending.logoUrl, sourceUrl: pending.sourceUrl || "", sourceType: pending.sourceType || "github", - categoryId: pending.categoryId || 1, + categoryId: pending.categoryId || defaultCategoryId, rating: pending.rating || 0, features: pending.features ?? undefined, tags: pending.tags ?? undefined, diff --git a/src/app/api/admin/pending-skills/publish-all/route.ts b/src/app/api/admin/pending-skills/publish-all/route.ts index 62b1645..de43df1 100644 --- a/src/app/api/admin/pending-skills/publish-all/route.ts +++ b/src/app/api/admin/pending-skills/publish-all/route.ts @@ -7,6 +7,9 @@ export async function POST() { if (authError) return authError; try { + const defaultCat = await prisma.skillCategory.findFirst({ select: { id: true } }); + const defaultCategoryId = defaultCat?.id ?? 0; + const pendingSkills = await prisma.pendingSkill.findMany({ where: { status: "pending" }, }); @@ -62,7 +65,7 @@ export async function POST() { logoUrl: pending.logoUrl, sourceUrl: pending.sourceUrl || "", sourceType: pending.sourceType || "github", - categoryId: pending.categoryId || 1, + categoryId: pending.categoryId || defaultCategoryId, rating: pending.rating || 0, features: pending.features ?? undefined, tags: pending.tags ?? undefined, diff --git a/src/app/api/admin/pending/batch-publish/route.ts b/src/app/api/admin/pending/batch-publish/route.ts index 69ae5a0..e44b6c8 100644 --- a/src/app/api/admin/pending/batch-publish/route.ts +++ b/src/app/api/admin/pending/batch-publish/route.ts @@ -13,6 +13,12 @@ export async function POST(request: Request) { if (!Array.isArray(ids) || ids.length === 0) { return NextResponse.json({ error: "ids数组不能为空" }, { status: 400 }); } + if (ids.length > 500) { + return NextResponse.json({ error: "一次最多处理500条" }, { status: 400 }); + } + + const defaultCat = await prisma.category.findFirst({ select: { id: true } }); + const defaultCategoryId = defaultCat?.id ?? 0; const pendings = await prisma.pendingTool.findMany({ where: { id: { in: ids } }, @@ -97,7 +103,7 @@ export async function POST(request: Request) { description: pending.description || "", websiteUrl: pending.websiteUrl || "", logoUrl: pending.logoUrl, - categoryId: pending.categoryId || 1, + categoryId: pending.categoryId || defaultCategoryId, pricingModel: (pending.pricingModel || "freemium") as "free" | "freemium" | "paid" | "subscription", pricingDetail: pending.pricingDetail, features: pending.features ?? undefined, diff --git a/src/app/api/admin/tasks/configs/route.ts b/src/app/api/admin/tasks/configs/route.ts index 6be0459..bb3a983 100644 --- a/src/app/api/admin/tasks/configs/route.ts +++ b/src/app/api/admin/tasks/configs/route.ts @@ -10,6 +10,7 @@ export async function GET() { { taskKey: "task5-update-stars", taskName: "更新星值", cronExpr: "0 1 * * *", enabled: true, desc: "更新所有GitHub技能的最新Stars数,计算日/周/月变化趋势" }, { taskKey: "task6-review-hot", taskName: "评测热门", cronExpr: "0 4 * * *", enabled: true, desc: "对未评测的最热门2个AI技能进行五维深度评测并自动发布" }, { taskKey: "daily-news", taskName: "AI日报生成", cronExpr: "30 7 * * *", enabled: true, desc: "搜索真实新闻素材,AI整理格式生成当日AI日报" }, + { taskKey: "task7-news-to-community", taskName: "新闻分发到社区", cronExpr: "0 8 * * *", enabled: true, desc: "将AI日报条目自动分发到社区对应板块创建话题" }, ]; const configs = await prisma.taskConfig.findMany(); diff --git a/src/app/api/admin/tasks/run/route.ts b/src/app/api/admin/tasks/run/route.ts index 6d422cf..0acee15 100644 --- a/src/app/api/admin/tasks/run/route.ts +++ b/src/app/api/admin/tasks/run/route.ts @@ -14,6 +14,7 @@ const SCRIPT_MAP: Record = { "task5-update-stars": "scripts/task5-update-stars.mjs", "task6-review-hot": "scripts/task6-review-hot.mjs", "daily-news": "scripts/daily-news.mjs", + "task7-news-to-community": "scripts/task7-news-to-community.mjs", }; const PROJECT_ROOT = process.env.SCRIPTS_ROOT || process.cwd(); diff --git a/src/app/api/admin/tools/route.ts b/src/app/api/admin/tools/route.ts index 4df8e42..c937cd8 100644 --- a/src/app/api/admin/tools/route.ts +++ b/src/app/api/admin/tools/route.ts @@ -53,9 +53,21 @@ export async function GET(request: Request) { } export async function POST(request: Request) { + const authError = await requireAdmin(); + if (authError) return authError; + try { const body = await request.json(); + if (!body.name || !body.websiteUrl || !body.categoryId) { + return NextResponse.json({ error: "名称、网址、分类ID不能为空" }, { status: 400 }); + } + + const existingCategory = await prisma.category.findUnique({ where: { id: body.categoryId } }); + if (!existingCategory) { + return NextResponse.json({ error: "分类ID不存在" }, { status: 400 }); + } + let slug = body.slug || body.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "tool"; const existing = await prisma.tool.findUnique({ where: { slug } }); if (existing) { @@ -66,7 +78,7 @@ export async function POST(request: Request) { data: { name: body.name, slug, - categoryId: body.categoryId || 1, + categoryId: body.categoryId, description: body.description || "", logoUrl: body.logoUrl || null, websiteUrl: body.websiteUrl || "", diff --git a/src/app/api/bot/[id]/route.ts b/src/app/api/bot/[id]/route.ts new file mode 100644 index 0000000..964633f --- /dev/null +++ b/src/app/api/bot/[id]/route.ts @@ -0,0 +1,40 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function GET( + _req: Request, + { params }: { params: { id: string } } +) { + const userId = parseInt(params.id); + if (isNaN(userId)) { + return NextResponse.json({ error: "无效的用户ID" }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { botConfig: true }, + }); + + if (!user || !user.isBot) { + return NextResponse.json({ error: "未找到该Bot" }, { status: 404 }); + } + + return NextResponse.json({ + id: user.id, + name: user.name, + email: user.email, + avatarUrl: user.avatarUrl, + isBot: user.isBot, + level: user.level, + points: user.points, + botConfig: user.botConfig + ? { + displayName: user.botConfig.displayName, + personality: user.botConfig.personality, + primaryForums: user.botConfig.primaryForums, + } + : null, + }); +} \ No newline at end of file diff --git a/src/app/community/CommunitySidebar.tsx b/src/app/community/CommunitySidebar.tsx index 1edb53f..ec7ba62 100644 --- a/src/app/community/CommunitySidebar.tsx +++ b/src/app/community/CommunitySidebar.tsx @@ -4,6 +4,19 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { getLevelInfo } from "@/lib/level-config"; +const INDUSTRY_FORUMS = [ + { name: "电商零售", slug: "ecommerce", icon: "🛒" }, + { name: "AI智能科技", slug: "ai-tech", icon: "🤖" }, + { name: "房地产", slug: "real-estate", icon: "🏠" }, + { name: "金融投资", slug: "finance", icon: "📈" }, + { name: "餐饮食品", slug: "food", icon: "🍜" }, + { name: "自媒体内容", slug: "content", icon: "🎬" }, + { name: "本地生活", slug: "local-life", icon: "🏪" }, + { name: "大健康", slug: "health", icon: "💊" }, + { name: "教育培训", slug: "education", icon: "📚" }, + { name: "外贸跨境", slug: "cross-border", icon: "🌏" }, +]; + interface UserRanking { id: number; name: string | null; @@ -77,6 +90,30 @@ export function CommunitySidebar() { return (
+ {/* Industry Forums */} +
+
+ + + + 行业论坛 +
+
+ {INDUSTRY_FORUMS.map((forum) => ( + + {forum.icon} + {forum.name} + + ))} +
+
+ +
+ {/* Monthly Active Users */}
diff --git a/src/app/community/[category]/page.tsx b/src/app/community/[category]/page.tsx index f86437c..316ad4c 100644 --- a/src/app/community/[category]/page.tsx +++ b/src/app/community/[category]/page.tsx @@ -15,6 +15,11 @@ export default async function CategoryPage({ params, searchParams }: Props) { if (!category) notFound(); + const childCategories = await prisma.forumCategory.findMany({ + where: { parentId: category.id }, + select: { id: true, name: true, slug: true }, + }); + const sort = searchParams.sort || "latest"; const page = parseInt(searchParams.page || "1", 10); const pageSize = 20; @@ -30,7 +35,7 @@ export default async function CategoryPage({ params, searchParams }: Props) { where: { categoryId: category.id }, include: { category: { select: { name: true, slug: true, icon: true } }, - user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } }, + user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } }, _count: { select: { posts: true } }, }, orderBy: [{ isPinned: "desc" }, orderBy], @@ -70,6 +75,26 @@ export default async function CategoryPage({ params, searchParams }: Props) { ))}
+ {childCategories.length > 0 && ( +
+ + 全部 + + {childCategories.map((child) => ( + + {child.name} + + ))} +
+ )} +
{topics.map((topic) => { const initial = topic.user.name?.charAt(0).toUpperCase() || "?"; @@ -99,7 +124,7 @@ export default async function CategoryPage({ params, searchParams }: Props) {
- {topic.user.name || topic.user.email?.split("@")[0]} + {topic.user.name || topic.user.email?.split("@")[0]}{topic.user.isBot && AI} {topic.createdAt?.toLocaleDateString("zh-CN")} {topic._count.posts}回复 {topic.viewCount}浏览 diff --git a/src/app/community/topic/[id]/ReplyForm.tsx b/src/app/community/topic/[id]/ReplyForm.tsx new file mode 100644 index 0000000..aad84c8 --- /dev/null +++ b/src/app/community/topic/[id]/ReplyForm.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; + +interface ReplyFormProps { + topicId: number; + isLocked: boolean; +} + +export function ReplyForm({ topicId, isLocked }: ReplyFormProps) { + const [submitting, setSubmitting] = useState(false); + + if (isLocked) { + return ( +
+

此话题已锁定,无法回复

+
+ ); + } + + return ( +
+
{ + e.preventDefault(); + setSubmitting(true); + const formData = new FormData(e.currentTarget); + const content = formData.get("content") as string; + if (!content.trim()) { + setSubmitting(false); + return; + } + const res = await fetch(`/api/forum/topics/${topicId}/posts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + setSubmitting(false); + if (res.ok) { + window.location.reload(); + } + }} + className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]" + > +

发表回复

+