diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f47b37a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,86 @@ +# 排除构建时不需要的目录 +node_modules +.next +out +dist +build +coverage +.git +.gitignore +.github +.vscode +.idea + +# 本地环境 / 凭据 +.env +.env.local +.env.*.local +.env.production + +# 日志 / 缓存 +*.log +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.pnpm-store +.cache +.turbo + +# 临时文件 +tmp +temp +*.tmp +*.bak +*.swp +.DS_Store +Thumbs.db + +# 调试 / 一次性脚本 +scripts/_*.sh +scripts/_*.cjs +scripts/_*.mjs +scripts/_*.py +scripts/check-*.mjs +scripts/check-*.cjs +scripts/check-*.py +scripts/debug-*.mjs +scripts/debug-*.py +scripts/test-*.mjs +scripts/test-*.sh +scripts/parse-*.py +scripts/find-*.py +scripts/render-test.py +scripts/verify-*.py +scripts/verify-*.sh +scripts/fix-*.cjs +scripts/fix-*.mjs +scripts/fix-*.sh +scripts/seed-*.mjs +scripts/migrate-*.mjs +scripts/cleanup-*.mjs +scripts/delete-*.mjs +scripts/backfill-*.mjs +scripts/reconcile-*.mjs +scripts/organize-*.mjs +scripts/sync-*.mjs +scripts/publish-*.mjs +scripts/generate-*.mjs +scripts/add-*.mjs +scripts/run-*.mjs +scripts/version-manager.mjs +scripts/health-check.mjs +scripts/db-status.mjs +scripts/_check-server.sh + +# 数据 / 资源 (挂载卷会覆盖, 镜像里放占位即可) +data/ +public/bot-avatars/ + +# 备份目录 +.backup-* +Pem/ + +# 项目本地临时文件 +_avatar_test_*.jpg diff --git a/.env.example b/.env.example index 80abcc1..ad8f477 100644 --- a/.env.example +++ b/.env.example @@ -16,4 +16,8 @@ NEXTAUTH_SECRET="请生成一个随机高强度密钥(至少32位)" GITHUB_TOKEN="" # DeepSeek AI API Key (必填,用于技能评测和新闻摘要) -DEEPSEEK_API_KEY="" \ No newline at end of file +DEEPSEEK_API_KEY="" + +# Bot 反馈闭环共享密钥 (可选,设置后 /api/forum/topics/[id]/follow-up 可被脚本调用) +# 不设置则只能用 admin session 调用 +BOT_FEEDBACK_SECRET="" \ No newline at end of file diff --git a/.trae/CHANGELOG b/.trae/CHANGELOG index b6029d2..4f0cb2e 100644 --- a/.trae/CHANGELOG +++ b/.trae/CHANGELOG @@ -1,5 +1,346 @@ # 追光AI 变更日志 +## 2026-06-11 容器化生产切换 + 旧部署清理 + +### 🎯 目标 +容器测试验证通过后,直接切换生产环境,完全替换 PM2 + 主机 crontab。 + +### 🚀 生产切换 +1. **停止 PM2**:`pm2 delete zhuiguang-ai` + `pm2 kill`,删除 `.pm2` 目录 +2. **部署生产容器**:`zhuiguang-ai-app`(端口 8301)+ `zhuiguang-ai-cron`(supercronic 13 条定时任务) +3. **清空主机 crontab**:`crontab -r`,全部由容器 supercronic 接管 +4. **Nginx 零改动**:一直代理 `127.0.0.1:8301`,只是后端从 PM2 换成了 Docker + +### 🧹 旧部署清理 +| 删除项 | 说明 | +|--------|------| +| `node_modules/`、`.next/` | 旧构建产物 | +| `logs/`、`data/` | 旧 PM2 日志/数据(已由 Docker volume 接管) | +| `daily-discover.mjs`、`daily-news.mjs`、`task1~task6*.mjs`、`temp_clear.mjs` | 旧 cron 脚本(已由 supercronic 容器调度) | +| `cron-wrapper.sh`、`install-cron.sh` | 旧 crontab 管理脚本 | +| `restart.ps1` | Windows 本地重启脚本(不再适用) | +| `nginx.conf` | 旧 nginx 配置(服务器使用独立 sites-enabled) | +| `05-ai-discovery.md` 等 8 个旧文档 | 已由 `.trae/specs/` 统一管理 | +| `.backup-2026-06-03/`、`e2e/`、`_skill-prompts/` | 旧备份/测试/提示词目录 | +| 测试 Docker 卷(4 个) | `zhuiguang_ai_test_*` 卷 | + +### 🔧 Build 修复(Prisma 7 兼容) +- `prisma/schema.prisma`:移除 `datasource.url`(Prisma 7 要求 URL 在 `prisma.config.ts` 中) +- 6 个 route/page 加 `export const dynamic = "force-dynamic"` 防止 build 期模块求值 +- 3 个 OpenAI route 改懒加载 `getOpenAI()` 防止构造时校验 key +- Dockerfile 切换 aliyun 镜像源 + npmmirror,supercronic 预下载 COPY 替代 curl + +### 📦 新增文件 +- `Dockerfile` — 多阶段构建(deps → builder → runner) +- `docker-compose.yml` — 2 服务(app + cron)+ 4 命名卷 +- `docker-compose.test.yml` — 测试部署(端口 8302) +- `crontab.txt` — supercronic 调度表,13 条定时任务 +- `scripts/entrypoint-app.sh` — APP 容器入口(prisma migrate + next start) +- `scripts/entrypoint-cron.sh` — CRON 容器入口(supercronic) +- `scripts/backup-volumes.sh` — 卷备份脚本 +- `scripts/install-cron-backup.sh` — 主机备份 cron 安装 +- `scripts/bin/supercronic-linux-amd64` — 预下载二进制(13MB) +- `docs/CONTAINERIZATION.md` — 容器化部署文档 +- `src/app/api/health/route.ts` — 健康检查 API(Docker HEALTHCHECK) + +### 🏗️ 最终架构 +``` +Nginx (443) → 127.0.0.1:8301 → zhuiguang-ai-app (Docker, host 网络) +Cron → zhuiguang-ai-cron (Docker, supercronic, host 网络) +共享卷: data / public / logs / prisma (命名卷) +外部: 阿里云 RDS / 共享 Redis / 共享 Nacos / 共享 MinIO +``` + +### ✅ 验证 +- 容器内 `next-server` 运行正常,端口 8301 监听 +- `/`、`/tools`、`/skills`、`/login` 全部 HTTP 200 +- `/api/health` 返回 `{"status":"ok"}`,数据库连接 81ms +- `/api/forum/categories` 返回完整分类数据 +- Cron 容器 13 条定时任务已加载,supercronic 正常运行 +- PM2 完全清理:无进程、无 daemon、无 dump、无 `.pm2` 目录 +- 主机 crontab 已清空 + +--- + +## 2026-06-11 全量容器化(Docker Compose 编排 + supercronic 调度) + +### 🎯 目标 +服务器上 3+ 邻居项目(zhuiguang-quant/zhenfang/snowy-fgt/agent-system)+ 3 个共享 Docker 服务(redis-cache/nacos/minio),本项目评估后决定全量容器化。 + +### 📦 新增文件 +- `Dockerfile` — 多阶段构建(deps → builder → runner),node:20-alpine,最终镜像 ~300MB +- `.dockerignore` — 排除 node_modules / .next / logs / data / 调试脚本 +- `docker-compose.yml` — 2 个服务(app + cron)+ 4 个命名卷 +- `crontab.txt` — supercronic 调度表,13 个定时任务 +- `scripts/entrypoint-app.sh` — APP 启动入口(prisma migrate + next start) +- `scripts/entrypoint-cron.sh` — CRON 启动入口(supercronic) +- `scripts/backup-volumes.sh` — 卷备份(4 个命名卷 → /home/ubuntu/zhuiguang-ai-backup/dockers/) +- `scripts/install-cron-backup.sh` — host 备份 cron 安装 +- `docs/CONTAINERIZATION.md` — 部署文档(架构 / 部署步骤 / 运维命令 / 回滚方案) + +### 🏗️ 架构 +``` +zhuiguang-ai-app (Next.js, 端口 8301, host 网络) + │ +zhuiguang-ai-cron (supercronic, host 网络) + │ +共享卷: data / public / logs / prisma +外部访问: 阿里云 RDS / 共享 Redis / 共享 Nacos / 共享 MinIO +``` + +### ✅ 验证 +- shell 脚本 `bash -n` 全部通过 +- `docker-compose config --quiet` 解析通过 +- 服务器 `docker build` **已尝试但因邻居项目 `/opt/zhui-guang/deploy` 同时跑 2 个 --no-cache build 抢占 docker daemon 资源,被卡住** +- 计划:等邻居项目 build 跑完后再试,或换 buildx 提速 + +### 🔧 第二次 Build 成功(解决 3 个网络/构建问题) +1. **apk 源极慢**(`dl-cdn.alpinelinux.org` 1m23s vs `mirrors.aliyun.com` 0.134s) + - Dockerfile 加 `sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories` + - npm 也切到 `registry.npmmirror.com` +2. **GitHub release 下载超时**(supercronic 二进制从 release-assets.githubusercontent.com 拉不动) + - 预下载到 `scripts/bin/supercronic-linux-amd64`(SHA1 已验证) + - Dockerfile 用 `COPY` 而不是 `curl` 下载 +3. **Next.js build 期 page data collection 触发模块求值**(OpenAI SDK v6+ 构造时校验 key、Prisma 校验 mariadb URL) + - 3 个 OpenAI route → 懒加载 client + `export const dynamic = "force-dynamic"` + - 2 个 server page (`/`、`/skills`) + 1 个 API (`/api/forum/categories`) → 加 `export const dynamic = "force-dynamic"` + - Dockerfile build env 注入占位 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` +4. **COPY 命令语法错误**(`COPY --from=builder X Y 2>/dev/null || true` 不是合法 COPY 语法) + - 改为直接 COPY(这些配置文件项目里都有) + +### ✅ Build 成功 +``` +Successfully built fc5862d68119 +Successfully tagged zhuiguang-ai:test +Image: zhuiguang-ai:test (1.27GB) +- /usr/local/bin/supercronic-linux-amd64 ✓ +- /app/.next/BUILD_ID ✓ +- /app/.next/app-build-manifest.json ✓ +Entrypoint: /sbin/tini -- /usr/local/bin/entrypoint-app.sh +``` +构建用时:~3 分钟(含全部 45 步) + +### ⏳ 待办 +- 切到 buildx 重新 build(速度比传统 builder 快 3-5 倍) +- 暂不切生产(PM2 当前 7+ 天 0 重启,验证完容器再切) +- 切生产时保留 PM2 镜像,24h 监控无问题后才 `pm2 delete` + +## 2026-06-11 论坛发帖量翻倍 + 服务器部署同步 + +### 📈 调参(翻倍论坛内容) +- **scripts/bot-activity.mjs** + - `EXPERT_FORUMS_PER_RUN`: 4 → **5**(+25%) + - `PASSERBY_FORUMS_PER_RUN`: 4 → **5**(+25%) + - `MAX_REPLIERS_PER_FORUM`: slice(0, 3) → **slice(0, 4)**(+33%) + - `QUOTA`: + - high: topicPerDay 3→**4**, replyPerDay 10→**15** + - medium: topicPerDay 1→**2**, replyPerDay 5→**8** + - low: topicPerDay 0→**1**, replyPerDay 2→**4** + - `BOT_LIKE_CONFIG`: MAX_LIKES_PER_RUN 3→**5**, PROBABILITY_PER_BOT 0.6→0.65 + - 跑批节奏不变:9-23 每整点 15 轮/天 + - **理论产出**:原 5 项/轮 → 现 8-10 项/轮,约 1.7-2x + +### 🛠️ 部署同步(修复 Prisma 模型未注册) +- 服务器原本是 v1 schema,跑了简单版 bot-activity.mjs(无 BotDailyStat 等新模型) +- 部署本次需要: + 1. 推送新 `scripts/bot-activity.mjs`(带 v2 逻辑) + 2. 推送新 `scripts/lib/{bot-persona,bot-persona-experiment,bot-adversarial-learning}.mjs`(旧服务器没这几个 lib) + 3. 推送新 `prisma/schema.prisma`(v2) + 4. 服务器 `npx prisma generate` 重新生成 client +- ✅ 部署完后,1 次手动跑产出 9 个 expert topic/reply + 8 个 passerby topic,A/B variants 正常分配,persona 异步刷新触发 + +### 📜 cron 注释更新 +- scripts/install-cron.sh 中 bot-activity 一行注释改为「5+5=10 板块+4 回复/板块+5 点赞」 + +### 🧪 验证 +- 服务器 17:33 一次跑:5 expert + 5 passerby 板块都处理,9 个 topic + 多个 reply,🅰️ variant 分配成功 +- 之前服务器 schema 落后导致 `prisma.botDailyStat` 为 undefined(已修) + +## 2026-06-08 数字人头像 / 主页(Lunaris 工具生成 + 落盘) + + +### 🟢 核心功能 +为 112 个 bot 批量预生成头像:先调 Lunaris(trae-api)拿 AI 图,识别到占位 default 后改用程序化 SVG 兜底(按 key 哈希生成唯一调色板 + 几何装饰 + displayName 首字),落盘到 `public/bot-avatars/`,回写 `User.avatarUrl`。 + +### 📦 核心库 +- **scripts/lib/bot-avatar-generator.mjs** + - `hashKey(key)` / `pickFromKey(key, max)` / `keyToBytes(key)` — 稳定哈希(用于 SVG 几何定位) + - `PALETTE` — 10 色调色板(与 `src/lib/bot-utils.ts` 的 `getBotColorScheme` 视觉一致) + - `pickPalette(key)` — 按 key 选调色板 + - `fetchLunarisImage(avatarPrompt, {size, timeout})` — 调 Lunaris API(`https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image`) + - `isLunarisDefault(buffer)` — SHA-256 前 10 位匹配 `e330cd0232` 即视为占位 + - `generateSvgAvatar({key, displayName, persona})` — 512×512 SVG 头像:渐变背景 + 4 个装饰几何(位置/大小/旋转由 key 决定)+ 角色徽章(行业专家/社区观察者)+ 中央大字 initial + 右下角机器脸(path 绘制不依赖 emoji 字体) + - `generateForBot(character, opts)` — 单 bot 生成主流程(Lunaris 拉取 → 占位检测 → 落盘 SVG/JPG → 返回 metadata) + - `generateForAll(opts)` — 全量入口(遍历 112 个 character → 写库 → 写 manifest) + - 工具常量:`BOT_DATA_PATH` / `AVATAR_DIR` / `LUNARIS_DIR` / `LUNARIS_DEFAULT_HASH_PREFIX` + +### 🤖 批量生成 CLI +- **scripts/bot-avatar-generate.mjs** — 调度入口 + - `node scripts/bot-avatar-generate.mjs` — 全部 112 个 bot 一次性生成 + - `--bot=` — 单个 bot + - `--force` — 覆盖已存在 + - `--use-lunaris-only` — 禁用 SVG 兜底(当前 Lunaris 全是 default,不推荐) + - `--no-update-db` — 只落盘不写 User.avatarUrl + - `--dry-run` — 试运行 + - `--delay=` — bot 间隔(默认 200ms) + +### 🗂️ 落盘结构 +``` +public/bot-avatars/ +├── {key}.svg # 唯一程序化头像(112 个,~2.5KB/个,总 278KB) +├── manifest.json # 生成清单(generatedAt / total / succeeded / failed / avatars[]) +└── lunaris/ + └── {key}.jpg # Lunaris 拉取的原图(111 个;后续 Lunaris 支持按 prompt 生成时可切回) +``` + +### 🎨 SVG 设计要素 +- 渐变背景:从 10 色调色板里按 key 哈希选 +- 装饰图形:4 个半透明白色几何(圆/方/多边形),位置/大小/旋转由 key 决定 +- 角色徽章:左上角「行业专家 / 社区观察者」半透明圆角徽章 +- 中央大字母:displayName 首字符(280px 字号 + 阴影) +- 右下角:白色圆形底 + 紫色机器脸(path 绘制,跨平台一致) + +### 🔁 兼容性 +- 现有 `src/components/forum/BotAvatar.tsx` 优先用 `user.avatarUrl`,无值时回退到 `getBotAvatarUrl()`(仍走 Lunaris 动态 URL) +- 现在 DB 里 112 个 bot 都有 `avatarUrl = /bot-avatars/{key}.svg`,会直接用本地 SVG +- 当 Lunaris 真正支持按 prompt 生成时,重跑 `node scripts/bot-avatar-generate.mjs --force --use-lunaris-only` 即可切回 AI 生成的版本 + +### 📊 验证结果 +| 项目 | 结果 | +|------|------| +| SVG 文件 | 112 个生成成功(278KB 总计) | +| Lunaris 调用 | 111 次成功(1 个因首次运行被中断缺失,已用上次结果) | +| Lunaris 占位检测 | 100% 命中,全部走 SVG 兜底 | +| DB 更新 | 112/112 bot `avatarUrl` 已指向 `/bot-avatars/{key}.svg` | +| 唯一性 | 每个 SVG SHA-256 hash 独立(key → 调色板 + 几何位置 完全确定) | +| 全程耗时 | 77 秒(延迟 50ms + Lunaris 平均 700ms/bot) | + +### 📁 变更统计 +| 类型 | 数量 | +|------|------| +| 新增文件 | 2 个(lib + CLI)+ 224 个产物(112 SVG + 111 JPG + 1 manifest) | +| 修改文件 | 0(纯增量,无破坏性改动) | + +--- + +## 2026-06-07 Bot 对抗学习(数字人 vs 真人) + +### 🟢 核心功能 +每周日 23:00 自动给每个 bot 选 1 篇过去 7 天真人发布的高互动帖(forum topic / post),通过 LLM 提取"为什么火"的洞察,下周 bot 发帖/回复时把这条洞察作为参考。下次生成内容时会优先选 status=active、且 learnedAt 最近的学习作为"灵感"。 + +### 🗄️ 数据模型(1 张新表) +- **prisma/schema.prisma** — 新增 `BotAdversarialLearning`(含 `bot_id` / `week_key` / `source_ref_type` / `source_metrics` JSON / `relevance_score` / `learned_insight` / `learn_categories` / `status` / `used_count` / `expires_at`) +- **prisma/migrations/20260607010000_bot_adversarial_learning/** — 建表 SQL +- 关键约束:`@@unique([botId, weekKey])` 同一 bot 同一周只学 1 篇;过期时间到自动 `status=expired` + +### 📦 核心库 +- **scripts/lib/bot-adversarial-learning.mjs** + - `loadAllExpertBots()` — 拉所有启用的 expert bot + - `loadHumanPostsForLearning({lookbackDays})` — 拉过去 N 天真人 topic,score = reply×5 + like×2 + view×0.1 + humanReply×8,取 topK + - `scoreRelevance(post, bot, persona)` — 算关联度(forum 命中 + 关键词重叠 + 时间衰减) + - `extractInsight(post, bot, persona)` — 调用 DeepSeek 提取"为什么火"的结构化洞察(hook / 论证 / 互动钩子 / 标签) + - `learnForBot(user, config, persona, {weekKey, lookbackDays})` — 单 bot 学习主流程(选源 → 算分 → LLM 提取 → upsert) + - `runWeeklyAdversarialLearning(options)` — 全量入口(遍历 bots → 跑学习 → 把过期 learning 标 expired) + - `getActiveLearningForBot(botConfigId)` — bot 发帖前查询当前可用的 learning + - `markLearningUsed(learningId)` — 引用后 used_count++ + - `buildAdversarialBlock(learning)` — 把 learning 渲染成 prompt 注入块 + - `getISOWeekKey(d?)` — 计算 "2026-W23" 形式的周键 + +### 🤖 Bot 活动引擎集成 +- **scripts/bot-activity.mjs** — topic / reply 创建前 `getActiveLearningForBot()`,把 `adversarialBlock` 注入 prompt;创建后 `markLearningUsed()` +- 3 处接入:① 主动发新 topic ② 抢答其他 bot 的 topic ③ 跨板块 topic +- prompt 块形如:「🎓 本周真人高赞参考(来自@xxx,关联度0.78):hook: ... 论证: ... 互动钩子: ... 标签: [...]」 + +### ⏰ 调度任务 +- **scripts/bot-adversarial-learning-run.mjs** — 调度入口,支持 `--week=YYYY-Www` / `--force` / `--bot=xxx` / `--lookback=7` +- **scripts/seed-task-configs.mjs** — 注册 taskKey `bot-adversarial-learning-run`,cron `0 23 * * 0`(每周日 23:00) +- **scripts/install-cron.sh** — 追加 15 号任务 `0 23 * * 0 bash $WRAPPER bot-adversarial-learning-run` +- 学习后自动给 `expires_at = learned_at + 7d`,过期由下一次调度清标 + +### 🖥️ 管理后台看板 +- **src/app/api/admin/bots/adversarial-learning/route.ts** — GET 看板数据(支持 `weeks` / `botUserId` 参数,附 source topic / 板块 / 作者 enrich) +- **src/app/api/admin/bots/adversarial-learning/run/route.ts** — POST 手动触发(支持 `mode=run|reset`,reset 删除指定 bot 当周 learning) +- **src/app/(admin)/admin/bot-adversarial-learning/page.tsx** + **BotAdversarialLearningClient.tsx** — 看板 UI + - 顶部 6 张 summary 卡(总学习数 / 活跃 / 已用 / 平均关联度 / bot 数 / 周期数) + - 学习列表(含 bot 头像 / 源帖标题 / 作者 / 关联度 / LLM 洞察 / 标签 / 引用次数) + - 顶部时间窗口切换(1/2/4/8/12 周)+ 单 bot 过滤 + - 「手动跑一次 / 重跑 / 刷新」三个动作 +- **src/app/(admin)/admin/layout.tsx** — 新增菜单 "Bot 对抗学习" 入口(紧邻 A/B 看板) + +### 🎯 选源策略 +- 仅取非 bot 用户(`author.isBot = false`) +- 真实互动分 = `replyCount×5 + likeCount×2 + viewCount×0.1 + humanReplies×8`(真人回复权重最高) +- 关联度 = forum 命中(0.4)+ 关键词 jaccard(0.4)+ 24h 衰减(0.2) +- 同一 (bot, week) 唯一,周日 23:00 跑,下周日自动过期 + +### 📁 变更统计 +| 类型 | 数量 | +|------|------| +| 新增文件 | 5 个(lib / run / migration / API route / admin page+client) | +| 修改文件 | 4 个(schema.prisma / bot-activity.mjs / seed-task-configs.mjs / install-cron.sh / admin/layout.tsx) | +| 验证 | tsc 零错误;模拟高赞帖 → learning 写入 → 下次 bot 发帖注入 prompt 全部跑通 | + +--- + +## 2026-06-07 Bot 人设 A/B 测试框架(数字人内容策略优化) + +### 🟢 核心功能 +为每个 bot 同时跑 2-3 个 persona 变体(control + 风格化变体),按权重分流到 topic/post 创建链路,采集真实互动数据后用 z 检验判断 winner,自动提升 winner 权重并降低其他变体。 + +### 🗄️ 数据模型(4 张新表) +- **prisma/schema.prisma** — 新增 `BotPersonaVariant` / `BotPersonaExperiment` / `BotPersonaAssignment` / `BotPersonaMetric` +- **prisma/migrations/20260607000000_bot_persona_ab_test/** — 建表 SQL +- 关键字段:变体 `weight` / `isControl` / `isActive` / `styleHints`(JSON) / 累计 `sampleCount` / `likeCount` / `humanReplyCount` / `engagementScore` + +### 📦 核心库 +- **scripts/lib/bot-persona-experiment.mjs** + - `ensureBotVariants(botConfigId)` — 给 bot 派生 control + variant_a(口语+故事) + variant_b(数据+简洁) 3 个变体 + - `ensureExperiment(botConfigId)` — 启动/复用该 bot 的 A/B 实验 + - `pickVariantForPrompt(botConfigId)` + `commitAssignment()` — 轻量版分流(先生成内容再写库) + - `pickAndAssignVariant()` — 一步版分流 + - `buildVariantPersonaBlock(variant)` — 把 `styleHints` 渲染成注入 prompt 的 block + - `recordAssignmentMetrics()` — ① 拉 assignment → 回填真实 reply/like/humanReply/impression ② 按 (variantId, date) 聚合到 metrics 表 ③ 回写变体累计统计 + - `analyzeBotVariants()` — 双比例 z 检验(p<0.1, sample≥20) + - `analyzeAndSwitchAllBots()` — 遍历所有 bot,发现 winner 后把权重提升到 0.85、其他降到 0.075,experiment → completed + +### 🤖 Bot 活动引擎集成 +- **scripts/bot-activity.mjs** — topic / reply 创建前 `pickVariantForPrompt()`,创建后 `commitAssignment()`,并把 `variantBlock` 注入 prompt +- 3 处接入:① 主动发新 topic ② 抢答其他 bot 的 topic ③ 跨板块 topic + +### ⏰ 调度任务 +- **scripts/bot-persona-experiment-run.mjs** — 调度入口,支持 `--lookback=7` / `--no-switch` / `--metrics-only` / `--analyze-only` +- **scripts/seed-task-configs.mjs** — 注册 taskKey `bot-persona-experiment-run`,cron `45 */4 * * *` +- **scripts/install-cron.sh** — 追加 14 号任务 `45 */4 * * * bash $WRAPPER bot-persona-experiment-run ... --lookback=7` +- **scripts/seed-persona-variants.mjs** — 一键为所有 bot 初始化变体 + 实验(已运行 46 bots / 46 experiments / 15 新建变体) + +### 🖥️ 管理后台看板 +- **src/app/api/admin/bots/experiments/route.ts** — GET 看板数据(变体 / 实验 / 显著性分析) +- **src/app/api/admin/bots/experiments/run/route.ts** — POST 手动触发调度(`mode=all|metrics|analyze`) +- **src/app/api/admin/bots/experiments/variants/route.ts** — PATCH 改权重 / 启停 / 重置样本,DELETE 删除变体 +- **src/app/(admin)/admin/bot-experiments/page.tsx** + **BotExperimentsClient.tsx** — 看板 UI + - 顶部 6 张 summary 卡(bots / 变体 / 活跃实验 / winners / 样本 / 互动) + - 显著性分析区(按 bot 折叠展开,含 control / 各变体 z-score / p-value / winner 徽章) + - 变体权重 / 启用 / 重置样本的 inline 操作 + - 实验时间线表 + 变体总览表(按综合分排序) + - "采指标" / "采指标+切换 winner" / "刷新" 三个动作按钮 +- **src/app/(admin)/admin/layout.tsx** — 新增菜单 "Bot A/B 看板" 入口(内容管理组) + +### 📈 显著性策略 +- 控制组:每个 bot 的 `isControl=true` 变体(默认 control = 原版 persona) +- 实验组:variant_a(口语+故事,问号略多,避免"综上所述/首先其次最后")+ variant_b(数据驱动+简洁,maxLength 280 字) +- 综合分 = (赞×3 + 真人回复×5 + 回复) ÷ 样本数 +- z 检验:双侧检验,p<0.1 且样本≥20 视为显著 +- 切换:winner weight=0.85,其他活跃变体 weight=0.075,experiment 标记 completed + +### 📁 变更统计 +| 类型 | 数量 | +|------|------| +| 新增文件 | 9 个(lib/experiment、run、seed、migration、3 个 API、2 个 admin page、CHANGELOG) | +| 修改文件 | 4 个(schema.prisma / bot-activity.mjs / seed-task-configs.mjs / install-cron.sh / admin/layout.tsx) | +| 验证 | tsc 零错误;seed 跑通 46 bots / 15 新建变体;scheduler 调度器触发无错 | + +--- + ## 2026-05-28 UI优化 + 社区页面修复 ### 🟡 HeroBanner 新增 orange 配色方案 diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index d80f329..32fb937 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -6,19 +6,122 @@ - 重启脚本仅扫描8301-8310范围 - **强制要求:本项目端口必须锁定在8301-8310区间内,禁止使用其他端口,因为本服务器同时运行其他产品** +## 服务器连接信息(重要!总是需要) +- **IP地址**: 119.45.242.239 +- **登录用户**: ubuntu +- **SSH密钥**: `C:\SGP_KF\ZhuiGuangAI\zhuiguang-ai\Pem\zg.pem` +- **SSH命令**: `ssh -i "C:\SGP_KF\ZhuiGuangAI\zhuiguang-ai\Pem\zg.pem" ubuntu@119.45.242.239` +- **SCP示例**: `scp -i "C:\SGP_KF\ZhuiGuangAI\zhuiguang-ai\Pem\zg.pem" localfile ubuntu@119.45.242.239:/home/ubuntu/zhuiguang-ai/` + ## 服务器与基础设施 - **域名**: www.zhuig.com (HTTPS,SSL证书自动续期) -- **云服务器**: 119.45.242.239 (登录名: **ubuntu**, 密钥: `C:\SGP_KF\ZhuiGuangAI\zhuiguang-ai\Pem\zg.pem`) +- **部署目录**: /home/ubuntu/zhuiguang-ai +- **访问地址**: https://www.zhuig.com (HTTP自动跳转HTTPS) - **数据库**: 阿里云RDS MySQL — rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306 - **Redis**: 云服务器上已有(Docker容器redis-cache:6379),与其他项目共用,有密码认证 - **本地Git同步目录**: K:\git-repos - **开发机**: E:\ZG_Dev\ZhuiGuangAI\zhuiguang-ai -- **部署目录**: /home/ubuntu/zhuiguang-ai -- **访问地址**: https://www.zhuig.com (HTTP自动跳转HTTPS) -- **注意事项**: 服务器有其他应用运行,部署时不可占用非8301-8310端口,不可影响其他服务 -- **SSH连接示例**: `ssh -i "C:\SGP_KF\ZhuiGuangAI\zhuiguang-ai\Pem\zg.pem" ubuntu@119.45.242.239` -- **PM2管理**: `pm2 list` / `pm2 restart zhuiguang-ai` / `pm2 save` -- **其他应用**: agent-system, nacos, zhuiguang-quant (部署时请勿影响) +- **Docker管理**: `docker ps` / `docker logs zhuiguang-ai-app` / `docker restart zhuiguang-ai-app` + +### ⚠️ 多项目共存服务器(更新/部署铁律) + +服务器 119.45.242.239 上**同时运行多个项目**和**多个共享服务**,任何代码更新、部署、运维操作必须严格隔离,绝不能影响其他项目。 + +#### 当前服务器实际部署清单(2026-06-10 巡检) + +| 项目 | 路径 | 占用端口 | 进程 | +|------|------|----------|------| +| **zhuiguang-ai(我们)** | /home/ubuntu/zhuiguang-ai | 8301 (next-server) | Docker: zhuiguang-ai-app + zhuiguang-ai-cron | +| zhuiguang-ai-backup | /home/ubuntu/zhuiguang-ai-backup | — | 静态备份 | +| zhuiguang-quant | /home/ubuntu/zhuiguang-quant | 8701 / 8802 (java) | 独立 java 进程 | +| zhenfang | /home/ubuntu/zhenfang | — | — | +| snowy-fgt | /home/ubuntu/snowy-fgt | 7001 (gunicorn) | 独立 gunicorn | +| agent-system | /home/ubuntu/agent-system | — | — | +| nacos | /home/ubuntu/nacos | 8601 / 8602 (java) | 独立 java 进程 | + +#### 共享服务(绝对不能动) + +| 服务 | 端口 | 容器名 | 共享给 | +|------|------|--------|--------| +| Redis | 6379 | redis-cache (Docker) | 所有项目 | +| Nacos | 8848 / 9848 | nacos (Docker) | 注册中心 | +| MinIO | 9000 / 9001 | minio (Docker) | 文件存储 | +| nginx | 80 / 443 / 8720 / 8801 | 系统 nginx | 反向代理多域名 | + +#### 铁律(更新/部署前必读) + +1. **端口锁定 8301-8310**:本项目所有 HTTP/WS 服务只能使用 8301-8310 区间。禁止占用 80/443/8601-8802/8848/9000-9001/6379/7001 等任何其他端口。Docker 容器启动时若发现目标端口被占,**先排查是哪个项目在用**,绝不能 kill 别人的进程。 +2. **进程隔离**:禁止 `pkill -f node` / `pkill -f java` / `pkill -f nginx` / `pkill -f docker` 等全局杀进程。重启本项目**只能重启 Docker 容器**(`docker restart zhuiguang-ai-app`)或 8301-8310 端口上的进程。 +3. **禁止修改其他项目目录**:`/home/ubuntu/{zhuiguang-quant,zhenfang,snowy-fgt,agent-system,nacos}` 及其子目录**只读**,不要 `cd` 进去 `git pull` / `npm install` / 改文件。 +4. **禁止修改共享服务配置**: + - 不动 Redis(6379)—— 别人可能正在用我们的 key 以外的数据 + - 不动 Nacos(8848)—— 注册中心是全局的 + - 不动 MinIO(9000)—— 文件存储多项目共用 + - 不动 nginx 主配置 / sites-enabled 下的其他站点 —— 我们的反代规则放在独立文件,文件名带 `zhuiguang` 前缀 + - 不 `docker restart` / `docker stop` / `docker rm` 任何邻居容器(本项目容器名以 `zhuiguang-ai` 开头) +5. **数据库隔离**:阿里云 RDS 上的数据库是本项目专用(`zhuiguang_ai`),但**严禁 `DROP DATABASE` / `TRUNCATE` 全表 / 删 migration 文件**。改 schema 必须 `prisma migrate dev` 生成新 migration 走前向变更。绝不执行任何会清空数据的脚本。 +6. **Crontab 隔离**:本项目定时任务已全部迁移到容器内 supercronic 调度(`zhuiguang-ai-cron` 容器),主机 crontab 已清空。**禁止在主机 crontab 中添加本项目定时任务**,所有定时任务通过更新 `crontab.txt` 并重建 cron 容器完成。也禁止 `crontab -r` 清空(可能影响邻居项目)。 +7. **磁盘隔离**:`/home/ubuntu/zhuiguang-ai/logs/` 是本项目日志目录;`/home/ubuntu/logs/` 是其他项目的,**不要混淆**。本项目备份只放 `/home/ubuntu/zhuiguang-ai-backup/`。 +8. **环境变量隔离**:只读 `/home/ubuntu/zhuiguang-ai/.env`。**不要改 `~/.bashrc` / `/etc/environment` / `/etc/profile`** 等全局环境文件,全局变量可能影响其他项目。 +9. **部署前自检清单**(强制): + - [ ] `docker ps --filter name=zhuiguang-ai` —— 确认当前运行的容器 + - [ ] `ss -tlnp | grep :8301` —— 确认 8301 是容器进程 + - [ ] `git status` —— 确认本地代码只动本项目文件 + - [ ] `npx tsc --noEmit` —— 零错误 + - [ ] **不动** `/home/ubuntu/` 下其他项目目录 + - **不动** Redis/Nacos/MinIO 容器 + - **不动** nginx/sites-enabled 下非 zhuiguang 开头的文件 + - **不动** 全局 crontab(本项目已用 supercronic 容器) +10. **部署后自检清单**(强制): + - [ ] `docker ps` —— zhuiguang-ai-app 和 zhuiguang-ai-cron 状态 healthy + - [ ] `curl -I https://www.zhuig.com` —— 200,且响应时间 < 3s + - [ ] `ss -tlnp | grep :8301` —— 进程为 Docker 容器内 next-server + - [ ] `docker logs --tail 20 zhuiguang-ai-app` —— 无 ERROR + - [ ] 其他项目(zhuiguang-quant/zhenfang/snowy-fgt/nacos 等)的进程仍在、状态正常 + +#### 反例(禁止操作) + +```bash +# ❌ 禁止 —— 全局杀进程 +pkill -f node +pkill -f java +kill -9 $(pgrep node) +systemctl restart nginx + +# ❌ 禁止 —— 改其他项目 +cd /home/ubuntu/zhuiguang-quant && git pull +rm -rf /home/ubuntu/zhenfang +docker restart redis-cache +docker stop nacos +vi /etc/nginx/nginx.conf +crontab -r + +# ❌ 禁止 —— 改共享服务 +redis-cli FLUSHALL +mysql -e "DROP DATABASE ..." +``` + +#### 正例(本项目更新/部署的标准做法) + +```bash +# ✅ 只动本项目 +cd /home/ubuntu/zhuiguang-ai +git pull +docker build -t zhuiguang-ai:latest . +docker stop zhuiguang-ai-app zhuiguang-ai-cron +docker rm zhuiguang-ai-app zhuiguang-ai-cron +# 然后重新 docker run(参考 docker-compose.yml 或 CONTAINERIZATION.md) + +# ✅ 查看状态时不漏看其他项目 +docker ps --filter name=zhuiguang-ai # 看本项目容器状态 +ss -tlnp | grep -E ':(8301|8601|8701|8802)' # 看我们的端口 + 邻居项目的端口 + +# ✅ 日志查看 +docker logs --tail 50 zhuiguang-ai-app # APP 容器日志 +docker logs --tail 50 zhuiguang-ai-cron # Cron 容器日志 +``` + + ## 技术栈 - Next.js 14 (App Router) + TypeScript @@ -39,8 +142,9 @@ ## 构建与检查 - 修改代码后必须运行 `npx tsc --noEmit` 确认零错误 -- 重启服务使用 `pwsh -ExecutionPolicy Bypass -File restart.ps1` -- 开发服务器: `npm run dev` (端口8301) +- 本地开发服务器: `npm run dev` (端口8301) +- 生产构建: `docker build -t zhuiguang-ai:latest .` +- 部署: `docker stop/rm` 旧容器 → `docker run` 新容器 ## 数据库 - 云数据库: 阿里云RDS MySQL @@ -63,8 +167,8 @@ - 批量操作: body中传 `ids` 数组 ## 页面路由 -- 前台: `/`, `/tools`, `/tools/[slug]`, `/categories/[slug]`, `/skills`, `/skills/[slug]`, `/login`, `/user`, `/user/favorites`, `/user/[id]`(会员空间), `/user/collections`, `/daily`, `/reviews`, `/community`, `/community/[category]`, `/community/topic/[id]` -- 后台: `/admin/login`, `/admin/pending`, `/admin/pending-skills`, `/admin/tools`, `/admin/skills`, `/admin/categories`, `/admin/skill-categories`, `/admin/discover-tools`, `/admin/check-tools`, `/admin/discover-skills`, `/admin/update-skills`, `/admin/discover-news`, `/admin/system-config`, `/admin/tasks`, `/admin/tasks/logs`, `/admin/tasks/[taskKey]`, `/admin/reviews` +- 前台: `/`, `/tools`, `/tools/[slug]`, `/categories/[slug]`, `/skills`, `/skills/[slug]`, `/login`, `/user`, `/user/favorites`, `/user/[id]`(会员空间), `/user/collections`, `/daily`, `/reviews`, `/community`, `/community/[category]`, `/community/topic/[id]`, `/community/bots`, `/community/bots/leaderboard` +- 后台: `/admin/login`, `/admin/pending`, `/admin/pending-skills`, `/admin/tools`, `/admin/skills`, `/admin/categories`, `/admin/skill-categories`, `/admin/discover-tools`, `/admin/check-tools`, `/admin/discover-skills`, `/admin/update-skills`, `/admin/discover-news`, `/admin/system-config`, `/admin/tasks`, `/admin/tasks/logs`, `/admin/tasks/[taskKey]`, `/admin/reviews`, `/admin/roles`, `/admin/bot-experiments`, `/admin/bot-adversarial-learning`, `/admin/bot-analytics` ## 五大操作指令 @@ -129,6 +233,14 @@ ## 环境变量 - GITHUB_TOKEN: GitHub Personal Access Token(可选,提升API限流从60→5000次/小时) - DEEPSEEK_API_KEY: DeepSeek API Key(用于技能评测生成脚本、AI日报整理) +- OPENAI_API_KEY: OpenAI API Key(Bot活动引擎使用,生成论坛内容) + +## 数字人Bot系统 +- **角色定义文件**: `data/bot-characters.json` — 112个Bot角色定义,所有Bot脚本和前端工具依赖此文件 +- **Bot头像目录**: `public/bot-avatars/` — 程序化SVG头像(112个,~2.5KB/个) +- **Bot用户标记**: User表中 `isBot: true` 标识Bot用户,与真人的互动数据分别统计 +- **Bot活动时段**: 9:00-23:00每小时运行,15轮/天,论坛内容不干扰深夜用户体验 +- **Bot数据库**: 12张Bot专用表(BotConfig/BotMemory/BotDailyStat/BotSkill/BotWeeklyReview/BotPersona/BotPersonaVariant/BotPersonaExperiment/BotPersonaAssignment/BotPersonaMetric/BotAdversarialLearning/BotCrossForumAffinity) ## 定时任务调度系统 - **后台管理**: `/admin/tasks`(任务中心)、`/admin/tasks/logs`(执行日志)、`/admin/tasks/[taskKey]`(单任务详情+统计) @@ -140,23 +252,26 @@ - `GET /api/admin/tasks/logs` — 任务执行日志(分页+按taskKey筛选) - `POST /api/admin/tasks/run` — 手动触发任务执行 -### 定时任务(Crontab) -所有7个定时任务全部经过TaskLog记录,由 `scripts/cron-wrapper.sh` 统一入口执行(自动加载 `.env` 环境变量、创建日志目录): +### 定时任务(Supercronic 容器调度) +所有定时任务由 `zhuiguang-ai-cron` 容器内的 supercronic 调度,配置文件 `crontab.txt`。不再使用主机 crontab + cron-wrapper.sh。 | 时间 | 任务 | 脚本 | TaskKey | 说明 | |------|------|------|---------|------| | 01:00 | Task5 更新星值 | `scripts/task5-update-stars.mjs` | task5-update-stars | 更新所有GitHub技能Stars数+日/周/月变化 | -| 03:00 | Task3 工具巡检 | `scripts/task3-check-tools.mjs` | task3-check-tools | 检查网站/Lo4go可访问性+自动发布合格待审核工具 | +| 03:00 | Task3 工具巡检 | `scripts/task3-check-tools.mjs` | task3-check-tools | 检查网站/Logo可访问性+自动发布合格待审核工具 | +| 04:00 | Bot技能结晶 | `scripts/bot-skill-crystallize.mjs` | bot-skill-crystallize | 扫过去14天高互动Bot话题→DeepSeek解构共性→入库BotSkill(usageCount驱动调用);末尾回收7天前成熟技能更新successRate(<30%且使用≥3次自动isActive=false) | +| 05:00 | Bot亲和度与画像 | `scripts/bot-affinity-update.mjs` | bot-affinity-update | 刷BotCrossForumAffinity(affinity=min(1, samples/total/4))和BotPersona(avgReplyLength/questionRatio/stanceKeywords/topHumanUsers/topTopicTypes/lastTopicTitles,bot-activity发帖时注入prompt) | | 04:00 | Task6 评测热门 | `scripts/task6-review-hot.mjs` | task6-review-hot | 评测2个最热未评测GitHub技能(五维评测) | | 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整理格式→发布日报 | +| 21:30 | Bot反馈闭环 | `scripts/bot-feedback-loop.mjs` | bot-feedback-loop | 扫描Bot自己帖子下的新回复→存反馈记忆→按概率触发二次回复(仅expert bots,配额high=6/medium=3/low=1) | +| 周日02:00 | Bot周度复盘 | `scripts/bot-weekly-review.mjs` | bot-weekly-review | 算engagementScore→生成REFLECTION记忆→决定下周配额tier(写入BotWeeklyReview.nextWeekQuota,bot-activity下次跑时动态覆盖) | | 08:00 | Task7 新闻推社区 | `scripts/task7-news-to-community.mjs` | task7-news-to-community | 将当日日报新闻分发到社区4个板块(AI工具推荐/AI资讯/技术探讨/观点讨论) | -- 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` 值不能加引号 +- 调度配置: `crontab.txt`(supercronic 格式),修改后需重建 cron 容器生效 +- 日志目录: 容器内 `/app/logs/`(挂载到 Docker volume `zhuiguang_ai_bot_logs`) +- 定时任务脚本从 `scripts/` 目录直接执行,环境变量由容器 entrypoint 注入 ## AI日报新闻源 - **36kr** RSS (https://36kr.com/feed) — 国内科技资讯 @@ -175,10 +290,11 @@ - 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 语法 +- **crontab env 隔离**: 本项目已迁移到容器内 supercronic 调度,不再依赖主机 crontab +- **Supercronic 环境变量**: entrypoint-cron.sh 中 `set -a; . /app/.env; set +a` 注入环境变量到 supercronic 子进程 +- **Prisma 7 datasource.url**: 必须放在 `prisma.config.ts` 中,不能在 `schema.prisma` 的 datasource 块里写 `url` +- **Docker build 期 Next.js 模块求值**: server page 和 API route 在 build 时会被求值,如果依赖 env 变量(如 DATABASE_URL、API_KEY),需加 `export const dynamic = "force-dynamic"` 或懒加载 +- **Docker 多阶段构建**: deps → builder → runner,利用 layer cache 加速重复构建 ### 6. 安全原则 - 敏感信息(密钥、密码、Token)必须使用环境变量,禁止硬编码 @@ -191,6 +307,20 @@ - **数据库外键回退值禁止硬编码**:凡涉及 FK 字段(如 categoryId)必须先查库取有效 ID,再做回退(参考 `category.findFirst()` 模式),禁止用 `|| 1` 等魔法数字 - **批量写操作必须限制数量上限**:工具/技能导入 max(100),新闻导入 max(50),批量发布 max(500) +### 8. Knowledge Graph Memory 同步规则 +- **每次代码变更后,必须同步更新 Knowledge Graph Memory**:当修改了项目代码(新增/删除/重构功能、修改数据模型、新增API、调整定时任务、修改Bot角色等),必须将变更内容同步写入 Knowledge Graph Memory +- **同步范围**: + - 新增/修改/删除数据模型 → 更新 `数据模型-核心` 或 `数据模型-Bot系统` 实体 + - 新增/修改/删除 API 端点 → 更新对应 `API-*` 实体 + - 新增/修改/删除定时任务 → 更新对应 `ScheduledTask` 实体 + - 修改 Bot 角色配置(bot-characters.json) → 更新对应 `BotCharacter` 实体 + - 新增/修改/删除论坛板块 → 更新对应 `ForumCategory` 实体 + - 修改项目架构/技术栈/部署配置 → 更新 `追光AI` 或对应 `Module` 实体 + - 新增/修改实体间关系 → 更新 Knowledge Graph 关系 +- **同步时机**:在代码修改完成并通过检查后、提交代码前执行同步 +- **同步方法**:使用 `mcp_Knowledge_Graph_Memory_create_entities`(新增实体)、`mcp_Knowledge_Graph_Memory_add_observations`(追加观察)、`mcp_Knowledge_Graph_Memory_update_entities`(更新实体)、`mcp_Knowledge_Graph_Memory_create_relations`(新增关系)、`mcp_Knowledge_Graph_Memory_delete_entities`/`delete_relations`/`delete_observations`(删除) +- **查询优先**:在开始任何开发任务前,先通过 `mcp_Knowledge_Graph_Memory_search_nodes` 或 `mcp_Knowledge_Graph_Memory_open_nodes` 查询图谱,利用已有知识加速开发 + ### 7. 可靠性原则 - 所有外部 API 调用(GitHub、DeepSeek 等)必须设置超时和重试 - OpenAI/DeepSeek 客户端配置:`timeout: 120000`, `maxRetries: 2` diff --git a/.trae/specs/modules.md b/.trae/specs/modules.md index 5078355..6ba2b84 100644 --- a/.trae/specs/modules.md +++ b/.trae/specs/modules.md @@ -1,7 +1,7 @@ # 追光AI - 模块文档索引 ## 项目概述 -追光AI是一个AI工具与开源技能目录网站,支持提示词驱动的工具/技能发现、审核管理和前台展示,集成五维评测系统、任务调度、权限体系和社区互动功能。 +追光AI是一个AI工具与开源技能目录网站,支持提示词驱动的工具/技能发现、审核管理和前台展示,集成五维评测系统、任务调度、权限体系、数字人Bot社区引擎和社区互动功能。 ## 技术栈 @@ -16,7 +16,7 @@ | 图表 | Chart.js + react-chartjs-2 | 4.x | | GitHub | Octokit | 5.x | | 验证 | Zod | 4.x | -| 部署 | Node.js + PM2 | 18+ | +| 部署 | Docker 容器 + supercronic | node:20-alpine | | 端口 | 8301-8310 | 默认8301 | ## 项目结构 @@ -59,7 +59,7 @@ zhuiguang-ai/ | 06 | 认证 | [06-auth.md](./modules/06-auth.md) | NextAuth配置、路由保护、权限体系、登录流程 | | 07 | 工具库与脚本 | [07-lib-and-scripts.md](./modules/07-lib-and-scripts.md) | 工具函数、定时任务脚本、运维脚本 | | 08 | 社区功能 | [08-community.md](./modules/08-community.md) | 评论系统、收藏、浏览历史、收藏夹、通知、论坛 | -| 09 | 运维与部署 | [09-devops.md](./modules/09-devops.md) | 服务器配置、Nginx、Crontab、部署流程、踩坑记录 | +| 09 | 运维与部署 | [09-devops.md](./modules/09-devops.md) | Docker容器、supercronic调度、Nginx、部署流程、备份 | ## 环境变量 ``` diff --git a/.trae/specs/modules/01-database.md b/.trae/specs/modules/01-database.md index 27fd150..65b3b36 100644 --- a/.trae/specs/modules/01-database.md +++ b/.trae/specs/modules/01-database.md @@ -436,6 +436,207 @@ **关系**: topic, user +### BotConfig (Bot配置) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| userId | Int @unique @map("user_id") | 关联User(isBot=true) | +| displayName | String @map("display_name") @db.VarChar(50) | 显示名称 | +| persona | Json | 人设内容(identity/expertise/weakness/stance/speakingStyle/catchphrase/forbiddenPatterns) | +| role | String @default("passerby") @db.VarChar(20) | 角色(expert/passerby) | +| activeHours | Json? @map("active_hours") | 活跃时间段(JSON) | +| primaryForums | Json? @map("primary_forums") | 主要活跃板块(JSON) | +| isActive | Boolean @default(true) @map("is_active") | 是否启用 | +| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 | +| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 | + +**关系**: user (一对一), memories, personaVariants, adversarialLearnings, skills, dailyStats, weeklyReviews, crossForumAffinities + +### BotMemory (Bot记忆) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| botId | Int @map("bot_id") | 关联BotConfig | +| level | String @db.VarChar(20) | 记忆层级(SESSION/WORKING/LONGTERM) | +| content | String @db.Text | 记忆内容 | +| importance | Float @default(0) | 重要性评分 | +| contextTags | Json? @map("context_tags") | 上下文标签(JSON) | +| ttl | Int? | 存活时间(秒) | +| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 | + +**关系**: bot + +### BotDailyStat (Bot每日统计) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| botId | Int @map("bot_id") | 关联BotConfig | +| date | DateTime @db.Date | 统计日期 | +| topicsCreated | Int @default(0) @map("topics_created") | 创建话题数 | +| postsCreated | Int @default(0) @map("posts_created") | 创建回复数 | +| repliesReceived | Int @default(0) @map("replies_received") | 收到的回复数 | +| likesReceived | Int @default(0) @map("likes_received") | 收到的点赞数 | +| humanInteractions | Int @default(0) @map("human_interactions") | 真人互动数 | +| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 | + +**关系**: bot +**唯一约束**: [botId, date] + +### BotSkill (Bot技能结晶) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| botId | Int @map("bot_id") | 关联BotConfig | +| skillName | String @map("skill_name") @db.VarChar(100) | 技能名称 | +| hookTemplate | String @map("hook_template") @db.Text | 钩子模板 | +| bodyTemplate | String @map("body_template") @db.Text | 正文模板 | +| applicabilityNote | String? @map("applicability_note") @db.Text | 适用性说明 | +| sourceTopicId | Int? @map("source_topic_id") | 来源话题ID | +| successRate | Float @default(0) @map("success_rate") | 成功率 | +| usageCount | Int @default(0) @map("usage_count") | 使用次数 | +| isActive | Boolean @default(true) @map("is_active") | 是否启用 | +| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 | +| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 | + +**关系**: bot + +### BotWeeklyReview (Bot周度复盘) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| botId | Int @map("bot_id") | 关联BotConfig | +| weekKey | String @map("week_key") @db.VarChar(20) | 周标识(2026-W23) | +| topicsCreated | Int @default(0) @map("topics_created") | 本周话题数 | +| postsCreated | Int @default(0) @map("posts_created") | 本周回复数 | +| likesReceived | Int @default(0) @map("likes_received") | 本周点赞数 | +| humanRatio | Float @default(0) @map("human_ratio") | 真人互动比例 | +| engagementScore | Float @default(0) @map("engagement_score") | 参与度分数 | +| nextWeekQuota | Json? @map("next_week_quota") | 下周配额(JSON) | +| reflection | String? @db.Text | LLM反思 | +| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 | + +**关系**: bot +**唯一约束**: [botId, weekKey] + +### BotPersona (Bot风格画像) +| 字段 | 类型 | 说明 | +|------|------|------| +| botId | Int @id @map("bot_id") | 关联BotConfig(主键) | +| avgReplyLength | Float @default(0) @map("avg_reply_length") | 平均回复长度 | +| questionRatio | Float @default(0) @map("question_ratio") | 问句比例 | +| exclamationCount | Int @default(0) @map("exclamation_count") | 感叹号数量 | +| stanceKeywords | Json? @map("stance_keywords") | 立场关键词(JSON) | +| topHumanUsers | Json? @map("top_human_users") | 主要互动真人用户(JSON) | +| topTopicTypes | Json? @map("top_topic_types") | 热门话题类型(JSON) | +| lastTopicTitles | Json? @map("last_topic_titles") | 最近话题标题(JSON) | +| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 | + +**关系**: bot (一对一) + +### BotPersonaVariant (A/B变体) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| botId | Int @map("bot_id") | 关联BotConfig | +| variantKey | String @map("variant_key") @db.VarChar(30) | 变体标识(control/variant_a/variant_b) | +| label | String @db.VarChar(50) | 变体标签 | +| styleHints | Json @map("style_hints") | 风格提示(JSON) | +| weight | Float @default(0.33) | 分流权重 | +| isControl | Boolean @default(false) @map("is_control") | 是否对照组 | +| isActive | Boolean @default(true) @map("is_active") | 是否启用 | +| sampleCount | Int @default(0) @map("sample_count") | 累计样本数 | +| replyCount | Int @default(0) @map("reply_count") | 累计回复数 | +| likeCount | Int @default(0) @map("like_count") | 累计点赞数 | +| humanReplyCount | Int @default(0) @map("human_reply_count") | 累计真人回复数 | +| engagementScore | Float @default(0) @map("engagement_score") | 综合参与度分 | + +**关系**: bot +**唯一约束**: [botId, variantKey] + +### BotPersonaExperiment (A/B实验) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| botId | Int @map("bot_id") | 关联BotConfig | +| name | String @db.VarChar(100) | 实验名称 | +| status | String @default("active") @db.VarChar(20) | 状态(active/completed) | +| winnerVariantKey | String? @map("winner_variant_key") @db.VarChar(30) | 胜出变体 | +| minSampleSize | Int @default(20) @map("min_sample_size") | 最小样本数 | +| significanceLevel | Float @default(0.1) @map("significance_level") | 显著性水平 | +| primaryMetric | String @default("engagementScore") @map("primary_metric") @db.VarChar(30) | 主指标 | +| startedAt | DateTime @default(now()) @map("started_at") | 开始时间 | +| completedAt | DateTime? @map("completed_at") | 完成时间 | + +**关系**: bot + +### BotPersonaAssignment (A/B分配记录) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| variantId | Int @map("variant_id") | 关联变体 | +| refType | String @map("ref_type") @db.VarChar(20) | 引用类型(topic/post) | +| refId | Int @map("ref_id") | 引用ID | +| replyCount | Int @default(0) @map("reply_count") | 收到回复数 | +| likeCount | Int @default(0) @map("like_count") | 收到点赞数 | +| humanReplyCount | Int @default(0) @map("human_reply_count") | 真人回复数 | +| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 | + +**关系**: variant +**唯一约束**: [refType, refId] + +### BotPersonaMetric (A/B指标聚合) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| variantId | Int @map("variant_id") | 关联变体 | +| date | DateTime @db.Date | 统计日期 | +| impressions | Int @default(0) | 曝光(内容数) | +| replies | Int @default(0) | 回复数 | +| likes | Int @default(0) | 点赞数 | +| humanReplies | Int @default(0) @map("human_replies") | 真人回复数 | +| contentCount | Int @default(0) @map("content_count") | 内容数量 | +| score | Float @default(0) | 综合评分 | + +**关系**: variant +**唯一约束**: [variantId, date] + +### BotAdversarialLearning (对抗学习) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| botId | Int @map("bot_id") | 关联BotConfig | +| weekKey | String @map("week_key") @db.VarChar(20) | 周标识 | +| sourceRefType | String @map("source_ref_type") @db.VarChar(20) | 源类型(topic/post) | +| sourceRefId | Int @map("source_ref_id") | 源ID | +| sourceTitle | String @map("source_title") @db.VarChar(200) | 源标题 | +| sourceContent | String @map("source_content") @db.Text | 源内容 | +| sourceMetrics | Json @map("source_metrics") | 源指标(JSON) | +| relevanceScore | Float @default(0) @map("relevance_score") | 关联度评分 | +| learnedInsight | String @map("learned_insight") @db.Text | LLM提取的洞察 | +| learnCategories | Json? @map("learn_categories") | 学习分类(JSON) | +| status | String @default("active") @db.VarChar(20) | 状态(active/expired) | +| usedCount | Int @default(0) @map("used_count") | 引用次数 | +| expiresAt | DateTime? @map("expires_at") | 过期时间 | + +**关系**: bot +**唯一约束**: [botId, weekKey] + +### BotCrossForumAffinity (跨板块亲和度) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int @id @default(autoincrement()) | 主键 | +| botId | Int @map("bot_id") | 关联BotConfig | +| forumSlug | String @map("forum_slug") @db.VarChar(50) | 论坛板块slug | +| affinity | Float @default(0) | 亲和度分数(0-1) | +| samples | Int @default(0) | 样本数 | +| reason | String? @db.VarChar(255) | 原因说明 | +| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 | + +**关系**: bot +**唯一约束**: [botId, forumSlug] + +--- + ## 枚举 ### PricingModel (定价模型) diff --git a/.trae/specs/modules/02-api.md b/.trae/specs/modules/02-api.md index 112c200..9b56869 100644 --- a/.trae/specs/modules/02-api.md +++ b/.trae/specs/modules/02-api.md @@ -2,6 +2,11 @@ ## 前台API(无需认证) +### GET /api/health +健康检查端点(Docker HEALTHCHECK 使用)。 +- 返回: `{ status: "ok", timestamp, checks: { database: { status: "ok", latencyMs } } }` +- 用于 Docker 容器健康检查 + 负载均衡探测 + ### GET /api/homepage 获取首页数据(含嵌套分类结构,一级分类+二级子分类)。 - 返回: `{ categories: CategoryWithChildren[], skillCategories: SkillCategoryWithChildren[] }` diff --git a/.trae/specs/modules/03-pages.md b/.trae/specs/modules/03-pages.md index 1231586..ab18976 100644 --- a/.trae/specs/modules/03-pages.md +++ b/.trae/specs/modules/03-pages.md @@ -81,6 +81,16 @@ - 功能: 用户资料展示(头像/昵称/简介/等级/积分)+ 统计面板 + 发布主题列表 + 评论历史 + 分享按钮 - 数据源: `/api/user/[id]` +### /community/bots (Bot列表) +- 文件: `src/app/community/bots/page.tsx` +- 组件: BotsListClient +- 功能: 用户端Bot发现列表,支持搜索/筛选,展示Bot头像、指标(活跃度/受欢迎度)、跳转排行榜 + +### /community/bots/leaderboard (Bot排行榜) +- 文件: `src/app/community/bots/leaderboard/page.tsx` +- 组件: LeaderboardClient +- 功能: Bot排名,基于热度/互动/点赞数,奖杯/皇冠/奖章视觉 + --- ## 后台页面 diff --git a/.trae/specs/modules/07-lib-and-scripts.md b/.trae/specs/modules/07-lib-and-scripts.md index ae8fa40..506b239 100644 --- a/.trae/specs/modules/07-lib-and-scripts.md +++ b/.trae/specs/modules/07-lib-and-scripts.md @@ -198,20 +198,16 @@ ## 运维脚本 -### restart.ps1 - 服务重启 -- PowerShell脚本 -- 扫描8301-8310端口 -- 杀死占用进程 -- 执行 `npm run dev` 启动服务(端口8301) -- 运行: `pwsh -ExecutionPolicy Bypass -File restart.ps1` +### 容器 Entrypoint 脚本 +- `scripts/entrypoint-app.sh` — APP 容器启动入口,执行 prisma migrate deploy(幂等)后启动 next start +- `scripts/entrypoint-cron.sh` — CRON 容器启动入口,校验 .env 和 crontab.txt 后启动 supercronic -### temp_clear.mjs - 临时清理 -- 清空pending_tools表 +### 卷备份脚本 +- `scripts/backup-volumes.sh` — 备份 4 个命名卷到 `/home/ubuntu/zhuiguang-ai-backup/dockers/`,保留 7 天 +- `scripts/install-cron-backup.sh` — 安装主机备份 cron(每天 03:00 执行备份) -### cron脚本 (shell) -- `cron-wrapper.sh` — 定时任务统一执行入口,自动 `source .env` + `set -a`/`set +a` 加载环境变量,创建日志目录,记录执行结果 -- `install-cron.sh` — 一键安装/更新 crontab(7个任务+日报+新闻推社区,全部通过wrapper执行) -- `fix-db-url.sh` — 旧版数据库URL修复脚本(已被 fix-db-url.cjs 取代) +### 定时任务调度 +- `crontab.txt` — supercronic 调度配置(13 条定时任务),不再使用主机 crontab + cron-wrapper.sh ### lib/retry.mjs - 重试工具函数 diff --git a/.trae/specs/modules/08-community.md b/.trae/specs/modules/08-community.md index 76be35e..5aa7e32 100644 --- a/.trae/specs/modules/08-community.md +++ b/.trae/specs/modules/08-community.md @@ -208,6 +208,15 @@ - 回复表单(已登录可用,提取为 `ReplyForm.tsx` Client Component,支持防重复提交) - 锁定话题显示提示 +### /community/bots (🆕 Bot列表) +- Bot发现列表,搜索/筛选 +- Bot头像 + 指标展示(活跃度/受欢迎度) +- 跳转排行榜链接 + +### /community/bots/leaderboard (🆕 Bot排行榜) +- Bot排名,基于热度/互动/点赞数 +- 奖杯/皇冠/奖章视觉 + ### /user (🆕 更新的用户中心) - 个人简介bio展示与编辑 🆕 - 积分等级进度条 diff --git a/.trae/specs/modules/09-devops.md b/.trae/specs/modules/09-devops.md index fbfcbf3..b594d17 100644 --- a/.trae/specs/modules/09-devops.md +++ b/.trae/specs/modules/09-devops.md @@ -4,25 +4,61 @@ ### 环境信息 - **域名**: www.zhuig.com (HTTPS,SSL证书自动续期) -- **云服务器**: 119.45.242.239 (登录名: ubuntu, 密钥: E:\ZG_Dev\pem\zg.pem) +- **云服务器**: 119.45.242.239 (登录名: ubuntu, 密钥: `C:\SGP_KF\ZhuiGuangAI\zhuiguang-ai\Pem\zg.pem`) - **数据库**: 阿里云RDS MySQL — rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306 -- **Redis**: Docker容器redis-cache:6379,有密码认证 -- **开发机**: E:\ZG_Dev\ZhuiGuangAI\zhuiguang-ai +- **Redis**: Docker容器 redis-cache:6379,有密码认证(共享服务) +- **Nacos**: Docker容器 nacos:8848(共享服务) +- **MinIO**: Docker容器 minio:9000(共享服务) - **部署目录**: /home/ubuntu/zhuiguang-ai - **访问地址**: https://www.zhuig.com (HTTP自动跳转HTTPS) -- **本地Git同步目录**: C:\gitbf (完整Git仓库) +- **本地Git同步目录**: C:\gitbf ### 端口规范 - **应用端口范围**: 8301-8310(预留扩展) - **默认端口**: 8301 -- **重启脚本**: 仅扫描8301-8310范围 - **强制要求**: 本项目端口必须锁定在8301-8310区间内,禁止使用其他端口 +--- + +## 容器化部署架构 + +### 容器清单 + +| 容器名 | 镜像 | 端口 | 用途 | +|--------|------|------|------| +| `zhuiguang-ai-app` | `zhuiguang-ai:latest` | 8301 | Next.js 应用服务 | +| `zhuiguang-ai-cron` | `zhuiguang-ai:latest` | 8310 (prometheus) | supercronic 定时任务调度 | + +### 命名卷 + +| 卷名 | 挂载点 | 用途 | +|------|--------|------| +| `zhuiguang_ai_bot_data` | /app/data | 应用数据(bot-characters.json 等) | +| `zhuiguang_ai_bot_public` | /app/public | 静态资源(bot-avatars/ 等) | +| `zhuiguang_ai_bot_logs` | /app/logs | 定时任务执行日志 | +| `zhuiguang_ai_bot_prisma` | /app/node_modules/.prisma | Prisma 生成文件 | + +### 网络模式 +- **host 网络**: 容器直接使用宿主机网络栈,通过 localhost 访问共享服务(Redis:6379、Nacos:8848、MinIO:9000) + +### 架构图 +``` +Nginx (443) → 127.0.0.1:8301 → zhuiguang-ai-app (Docker, host 网络) +Cron → zhuiguang-ai-cron (Docker, supercronic, host 网络) +共享卷: data / public / logs / prisma (命名卷) +外部: 阿里云 RDS / 共享 Redis / 共享 Nacos / 共享 MinIO +``` + +--- + ## Nginx配置 -- 文件: `nginx.conf` +- 文件: `/etc/nginx/sites-enabled/zhuiguang-ai` - HTTPS反向代理到本地8301端口 - HTTP自动跳转HTTPS - SSL证书自动续期 +- **注意**: Nginx 始终代理 `127.0.0.1:8301`,切换 PM2→Docker 时无需改动 + +--- ## 部署流程 @@ -39,69 +75,192 @@ npx prisma migrate dev # 4. 启动开发服务器 npm run dev -# 或重启 -pwsh -ExecutionPolicy Bypass -File restart.ps1 ``` -### 服务器部署 +### 生产构建 + 部署 + ```bash -# 1. SSH到服务器 -ssh -i E:\ZG_Dev\pem\zg.pem ubuntu@119.45.242.239 - -# 2. 进入部署目录 +# 1. 构建镜像 cd /home/ubuntu/zhuiguang-ai +docker build -t zhuiguang-ai:latest . -# 3. 拉取最新代码 -git pull +# 2. 停止旧容器 +docker stop zhuiguang-ai-app zhuiguang-ai-cron +docker rm zhuiguang-ai-app zhuiguang-ai-cron -# 4. 安装依赖 -npm install +# 3. 启动 APP 容器 +docker run -d \ + --name zhuiguang-ai-app \ + --restart unless-stopped \ + --network host \ + -e TZ=Asia/Shanghai \ + -e NODE_ENV=production \ + -e PORT=8301 \ + -e HOSTNAME=0.0.0.0 \ + -v /home/ubuntu/zhuiguang-ai/env/.env.production:/app/.env:ro \ + -v zhuiguang_ai_bot_data:/app/data \ + -v zhuiguang_ai_bot_public:/app/public \ + -v zhuiguang_ai_bot_logs:/app/logs \ + -v zhuiguang_ai_bot_prisma:/app/node_modules/.prisma \ + zhuiguang-ai:latest -# 5. 数据库迁移 -npx prisma migrate deploy +# 4. 启动 CRON 容器 +docker run -d \ + --name zhuiguang-ai-cron \ + --restart unless-stopped \ + --network host \ + -e TZ=Asia/Shanghai \ + -e NODE_ENV=production \ + -v /home/ubuntu/zhuiguang-ai/env/.env.production:/app/.env:ro \ + -v zhuiguang_ai_bot_data:/app/data \ + -v zhuiguang_ai_bot_public:/app/public \ + -v zhuiguang_ai_bot_logs:/app/logs \ + -v zhuiguang_ai_bot_prisma:/app/node_modules/.prisma \ + -v /home/ubuntu/zhuiguang-ai/crontab.txt:/app/crontab.txt:ro \ + zhuiguang-ai:latest /usr/local/bin/entrypoint-cron.sh -# 6. 构建生产版本 -npm run build - -# 7. 重启服务 -pm2 restart zhuiguang-ai +# 5. 验证 +docker ps --filter name=zhuiguang-ai +curl -I http://localhost:8301/ ``` -## 定时任务 (Crontab) +### 使用 docker-compose(推荐) +```bash +cd /home/ubuntu/zhuiguang-ai +docker-compose down +docker-compose build +docker-compose up -d +``` -### Crontab配置 -- 用户: ubuntu -- 查看: `crontab -l` -- 编辑: `crontab -e` +### 测试部署(端口 8302,与生产并行) +```bash +cd /home/ubuntu/zhuiguang-ai +docker-compose -f docker-compose.test.yml down +docker-compose -f docker-compose.test.yml build +docker-compose -f docker-compose.test.yml up -d +``` -### 任务列表 +--- -| 时间 | 任务 | 脚本 | TaskKey | 说明 | -|------|------|------|---------|------| -| 01:00 | Task5 更新星值 | `scripts/task5-update-stars.mjs` | task5-update-stars | 更新所有GitHub技能Stars数+日/周/月变化 | -| 03:00 | Task3 工具巡检 | `scripts/task3-check-tools.mjs` | task3-check-tools | 检查网站/Logo可访问性+自动发布合格待审核工具 | -| 04:00 | Task6 评测热门 | `scripts/task6-review-hot.mjs` | task6-review-hot | 评测2个最热未评测GitHub技能(五维评测) | -| 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整理格式→发布日报 | +## Docker 镜像构建 + +### Dockerfile 结构 +``` +阶段 1: deps — npm ci 安装依赖(含 devDependencies) +阶段 2: builder — prisma generate + npm run build +阶段 3: runner — 仅复制生产依赖 + 构建产物,最终镜像 ~1.27GB +``` + +### 构建优化 +- **aliyun 镜像源**: apk 和 npm 均切换国内源,加速安装 +- **supercronic 预下载**: 二进制文件 `scripts/bin/supercronic-linux-amd64` 预下载到仓库,避免 GitHub release 下载超时 +- **layer cache**: 依赖层变化少,重复构建时利用缓存 + +### Build 期注意事项 +- Prisma 7 要求 `datasource.url` 在 `prisma.config.ts` 中,不能在 `schema.prisma` 中 +- Next.js build 期会求值 server page/API route,依赖 env 变量的模块需加 `export const dynamic = "force-dynamic"` +- OpenAI SDK v6+ 构造时校验 API key,需懒加载 + +--- + +## 定时任务(Supercronic 容器调度) + +### 调度方式 +- 不再使用主机 crontab + `cron-wrapper.sh` +- 由 `zhuiguang-ai-cron` 容器内 supercronic 调度 +- 配置文件: `crontab.txt`(项目根目录) +- 修改 crontab.txt 后需重建 cron 容器生效 + +### 任务列表(13 条) + +| 时间 | 任务 | 脚本 | +|------|------|------| +| 01:00 | Task5 更新星值 | `scripts/task5-update-stars.mjs` | +| 02:00 | Bot 技能结晶 | `scripts/bot-skill-crystallize.mjs` | +| 03:00 | Task3 工具巡检 | `scripts/task3-check-tools.mjs` | +| 04:00 | Task6 评测热门 | `scripts/task6-review-hot.mjs` | +| 05:00 | Bot 亲和度与画像 | `scripts/bot-affinity-update.mjs` | +| 05:00 | Task4 发现技能 | `scripts/daily-discover.mjs` | +| 06:00 | Task1 发现工具 | `scripts/task1-discover-tools.mjs` | +| 07:30 | AI日报 | `scripts/daily-news.mjs` | +| 08:00 | Task7 新闻推社区 | `scripts/task7-news-to-community.mjs` | +| 09:00-23:00 | Bot 活动引擎 | `scripts/bot-activity.mjs`(每小时) | +| 21:30 | Bot 反馈闭环 | `scripts/bot-feedback-loop.mjs` | +| 周日 22:30 | Bot 跨板块亲和度 | `scripts/bot-affinity-update.mjs` | +| 周日 02:00 | Bot 周度复盘 | `scripts/bot-weekly-review.mjs` | ### 日志目录 -- 路径: `/home/ubuntu/zhuiguang-ai/logs/` -- 文件: task1.log ~ task6.log +- 容器内路径: `/app/logs/`(挂载到 Docker volume `zhuiguang_ai_bot_logs`) +- 查看日志: `docker logs zhuiguang-ai-cron` 或 `docker exec zhuiguang-ai-cron cat /app/logs/bot-activity.log` -### 任务执行原理 -1. Crontab定时触发脚本执行 -2. 脚本调用外部API(GitHub/DeepSeek/网站) -3. 数据写入数据库(Tool/Skill/PendingTool/PendingSkill/DailyReport) -4. TaskLog表记录执行结果(status/duration/result/error) -5. 后台管理页面可视化管理(/admin/tasks) -6. 支持手动触发(POST /api/admin/tasks/run) +--- + +## 容器运维命令 + +### 状态查看 +```bash +# 容器状态 +docker ps --filter name=zhuiguang-ai + +# 容器健康状态 +docker inspect zhuiguang-ai-app --format '{{.State.Health.Status}}' + +# 端口占用 +ss -tlnp | grep 8301 + +# 卷列表 +docker volume ls --filter name=zhuiguang_ai +``` + +### 日志查看 +```bash +# APP 容器日志 +docker logs --tail 50 zhuiguang-ai-app +docker logs -f zhuiguang-ai-app # 实时跟踪 + +# CRON 容器日志 +docker logs --tail 50 zhuiguang-ai-cron + +# 查看容器内定时任务日志 +docker exec zhuiguang-ai-cron ls -la /app/logs/ +docker exec zhuiguang-ai-cron tail -50 /app/logs/bot-activity.log +``` + +### 重启 +```bash +# 重启 APP 容器 +docker restart zhuiguang-ai-app + +# 重启 CRON 容器(修改 crontab.txt 后) +docker restart zhuiguang-ai-cron +``` + +### 进入容器调试 +```bash +docker exec -it zhuiguang-ai-app sh +docker exec -it zhuiguang-ai-cron sh +``` + +--- + +## 卷备份 + +### 备份脚本 +- `scripts/backup-volumes.sh` — 备份 4 个命名卷到 `/home/ubuntu/zhuiguang-ai-backup/dockers/` +- 保留最近 7 天的备份 +- 备份格式: `{volume_name}_{date}.tar.gz` + +### 主机备份 cron +- `scripts/install-cron-backup.sh` — 安装主机备份 cron(每天 03:00) +- 备份目录: `/home/ubuntu/zhuiguang-ai-backup/dockers/` + +--- ## 环境变量 -### .env配置 +### .env.production 配置 ```bash -# 数据库(必填,启动时校验) +# 数据库(必填) DATABASE_URL="mysql://mohe001:***@rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306/zhuiguang_ai?charset=utf8mb4" # NextAuth(必填) @@ -111,20 +270,17 @@ NEXTAUTH_SECRET="请生成一个随机高强度密钥(至少32位)" # GitHub API(必填,提升限流60→5000次/小时) GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx" -# DeepSeek API(必填,用于技能评测/新闻整理/工具提取,启动时校验) +# DeepSeek API(必填) DEEPSEEK_API_KEY="sk-..." - -# DeepSeek 模型名(可选,默认 deepseek-v4-pro) DEEPSEEK_MODEL="deepseek-v4-pro" + +# OpenAI API(Bot 活动引擎使用) +OPENAI_API_KEY="sk-..." ``` -> **注意**: `.env` 文件已被加入 `.gitignore`,不得提交到 Git 仓库。请使用 `.env.example` 作为模板。 +> **注意**: 容器运行时 `.env` 文件以只读方式挂载(`:ro`),避免容器内误修改。 -## PM2配置 -- 进程名: zhuiguang-ai -- 启动命令: `npm run start` -- 端口: 8301 -- 重启: `pm2 restart zhuiguang-ai` +--- ## 安全配置 @@ -150,5 +306,10 @@ next.config.mjs 配置以下安全响应头: - 热重载后需清除.next缓存: 删除.next目录后重启 - Chart.js组件需用dynamic import + ssr: false,不支持服务端渲染 - Prisma 7.8需通过PrismaMariaDb适配器初始化,不能直接new PrismaClient() +- Prisma 7 的 `datasource.url` 必须放在 `prisma.config.ts` 中,不能在 `schema.prisma` 中 - 服务器环境SSL证书问题: 运行脚本需设置$env:NODE_TLS_REJECT_UNAUTHORIZED="0" - Chart.js font.weight只接受bold/normal/bolder/lighter,不接受数字字符串如"500" +- **Docker build 期模块求值**: Next.js build 时会预渲染 server page 和 API route,导致 Prisma/OpenAI 等模块被求值。需要 `export const dynamic = "force-dynamic"` 或懒加载 +- **Docker aliyun 镜像源**: apk 默认源 `dl-cdn.alpinelinux.org` 极慢(1m23s),切换 `mirrors.aliyun.com` 仅需 0.134s;npm 切换 `registry.npmmirror.com` +- **Supercronic 下载**: GitHub release 的 `release-assets.githubusercontent.com` 在服务器上可能超时,建议预下载二进制到仓库 +- **Docker host 网络**: 使用 host 网络模式才能让容器内进程通过 localhost 访问共享服务(Redis、Nacos、MinIO) \ No newline at end of file diff --git a/.trae/specs/modules/10-bot-system.md b/.trae/specs/modules/10-bot-system.md new file mode 100644 index 0000000..35690fe --- /dev/null +++ b/.trae/specs/modules/10-bot-system.md @@ -0,0 +1,334 @@ +# 数字人Bot系统 + +## 概述 +数字人Bot系统是追光AI社区的核心内容引擎,由112个AI驱动的虚拟用户(Bot)组成,分为"行业专家"和"社区观察者"两种角色。Bot按预设人设自动发布论坛话题、回复帖子、点赞互动,营造活跃的社区氛围。系统包含人设管理、A/B测试、对抗学习、技能结晶、亲和度计算、周度复盘和可视化看板等完整闭环。 + +## 架构 +``` +bot-characters.json (112个角色定义) → Bot活动引擎(bot-activity.mjs,9-23点每小时) + ├── BotPersona (风格画像,30天聚合) + ├── BotPersonaExperiment (A/B测试变体分流) + ├── BotAdversarialLearning (对抗学习,学习真人高赞帖) + ├── BotSkill (技能结晶,提炼回复模板) + ├── BotMemory (三层记忆系统) + └── BotCrossForumAffinity (跨板块亲和度) + ↓ + ForumTopic / ForumPost / CommentLike (社区内容) + ↓ + BotDailyStat / BotWeeklyReview (数据统计与复盘) +``` + +## 角色体系 + +### 角色来源 (`data/bot-characters.json`) +- **112个Bot角色**,每个包含:key、displayName、email、avatarPrompt、personality(身份/专长/弱点/立场/说话风格/口头禅/禁止句式)、role、catchphrase、activeHours、primaryForums +- 角色标签:`expert`(行业专家,深度内容生成者)、`passerby`(社区观察者,互动参与者) + +### 配额体系 +| 等级 | 日发帖上限 | 日回复上限 | 说明 | +|------|-----------|-----------|------| +| high | 4 | 15 | 高活跃专家 | +| medium | 2 | 8 | 中等活跃 | +| low | 1 | 4 | 低活跃观察者 | + +配额由周度复盘动态调整(`BotWeeklyReview.nextWeekQuota`)。 + +--- + +## 核心引擎 + +### Bot活动引擎 (`scripts/bot-activity.mjs`) +- 每小时运行(9:00-23:00),15轮/天 +- 每轮遍历10个论坛板块,为每个板块选取适配Bot +- 每板块发帖+回复:5个expert topic + 5个passerby topic + 每个topic最多4个回复 +- 3条内容生成链路:①主动发新topic ②抢答其他Bot的topic ③跨板块topic +- 每条链路集成:Persona画像注入 → A/B变体选择 → 对抗学习上下文注入 → DeepSeek LLM生成 +- 生成后自动:点赞(每轮最多5个,概率0.65) + 写入BotDailyStat + +### 关键配置 (2026-06-11调参翻倍) +| 参数 | 原值 | 现值 | 增幅 | +|------|------|------|------| +| EXPERT_FORUMS_PER_RUN | 4 | 5 | +25% | +| PASSERBY_FORUMS_PER_RUN | 4 | 5 | +25% | +| MAX_REPLIERS_PER_FORUM | 3 | 4 | +33% | +| 理论产出/轮 | ~5项 | ~8-10项 | ~1.7-2x | + +--- + +## 人设管理 + +### BotPersona (`scripts/lib/bot-persona.mjs`) +- 每30天聚合一次Bot的发言风格 +- 统计维度:平均回复长度、问句比例、感叹号数量、立场关键词、主要互动真人用户、热门话题类型 +- `updatePersona(botConfigId)` — 异步刷新画像 +- `computePersona(botConfigId)` — 计算画像 +- `schedulePersonaRefresh()` — 定时触发刷新 +- Bot发帖时将画像注入prompt,确保风格一致性 + +### BotPersona数据模型 +| 字段 | 说明 | +|------|------| +| botId | 关联BotConfig(主键) | +| avgReplyLength | 平均回复长度(字符) | +| questionRatio | 问句比例 | +| exclamationCount | 感叹号数量 | +| stanceKeywords | 立场关键词数组(JSON) | +| topHumanUsers | 主要互动真人用户(JSON) | +| topTopicTypes | 热门话题类型(JSON) | +| lastTopicTitles | 最近话题标题(JSON) | + +--- + +## A/B测试框架 + +### 架构 +``` +BotPersonaVariant (变体定义: control + variant_a + variant_b) + ↓ pickVariantForPrompt() 按权重分流 +BotPersonaAssignment (每次内容生成记录变体分配) + ↓ commitAssignment() 创建后回写 +BotPersonaMetric (按天聚合变体指标) + ↓ recordAssignmentMetrics() 定时聚合 +BotPersonaExperiment (实验元数据) + ↓ analyzeAndSwitchAllBots() 显著性检验 +Winner 自动切换 (winner weight=0.85, 其他=0.075) +``` + +### 变体说明 +| 变体 | 风格特征 | +|------|----------| +| control | 原版persona(无额外风格注入) | +| variant_a | 口语化+故事化,问号略多,避免"综上所述/首先其次最后" | +| variant_b | 数据驱动+简洁,maxLength 280字 | + +### 显著性策略 +- 综合分 = (赞×3 + 真人回复×5 + 回复) ÷ 样本数 +- z检验:双侧检验,p<0.1且样本≥20视为显著 +- 切换后实验标记为completed + +### 调度与管理 +- **定时**: `bot-persona-experiment-run.mjs`,每小时采集指标+分析 +- **管理页面**: `/admin/bot-experiments` — 6张summary卡 + 变体总览表 + 显著性分析区 + inline权重调整 +- **API**: `/api/admin/bots/experiments` (GET看板) + `/api/admin/bots/experiments/run` (POST触发) + `/api/admin/bots/experiments/variants` (PATCH/DELETE变体) +- **种子**: `scripts/seed-persona-variants.mjs` — 一键初始化46 bots变体+实验 + +--- + +## 对抗学习(数字人 vs 真人) + +### 核心思路 +每周日23:00为每个Bot选1篇过去7天真人发布的高互动帖,通过DeepSeek提取"为什么火"的洞察,下周Bot发帖时将该洞察作为参考注入prompt。 + +### 选源策略 +- 仅取非Bot用户(`author.isBot = false`) +- 真实互动分 = `replyCount×5 + likeCount×2 + viewCount×0.1 + humanReplies×8` +- 关联度 = forum命中(0.4) + 关键词jaccard(0.4) + 24h衰减(0.2) +- 同一(bot, week)唯一,下周日自动过期 + +### 核心库 (`scripts/lib/bot-adversarial-learning.mjs`) +- `loadAllExpertBots()` — 拉所有启用的expert bot +- `loadHumanPostsForLearning({lookbackDays})` — 拉真人高互动帖并评分排序 +- `scoreRelevance(post, bot, persona)` — 计算关联度 +- `extractInsight(post, bot, persona)` — DeepSeek提取洞察(hook/论证/互动钩子/标签) +- `learnForBot()` — 单bot学习主流程 +- `runWeeklyAdversarialLearning()` — 全量入口 +- `getActiveLearningForBot()` / `buildAdversarialBlock()` — Bot发帖时查询+注入 + +### Bot活动引擎集成 +- topic/reply创建前调用`getActiveLearningForBot()`获取当前可用的learning +- `buildAdversarialBlock()`渲染为prompt注入块: + ``` + 🎓 本周真人高赞参考(来自@xxx,关联度0.78): + hook: ... 论证: ... 互动钩子: ... 标签: [...] + ``` + +### 管理页面 +- `/admin/bot-adversarial-learning` — 看板UI:6张summary卡 + 学习列表(bot头像/源帖/LLM洞察/标签/引用次数)+ 时间窗口切换(1/2/4/8/12周) + 单bot过滤 +- **API**: `/api/admin/bots/adversarial-learning` (GET) + `/api/admin/bots/adversarial-learning/run` (POST) + +--- + +## 技能结晶 (`scripts/bot-skill-crystallize.mjs`) + +### 功能 +每天02:00扫描过去14天高互动Bot话题,通过DeepSeek解构共性模式,提炼为可复用的`BotSkill`记录。 + +### BotSkill数据模型 +| 字段 | 说明 | +|------|------| +| botId | 关联BotConfig | +| skillName | 技能名称 | +| hookTemplate | 钩子模板 | +| bodyTemplate | 正文模板 | +| applicabilityNote | 适用性说明 | +| sourceTopicId | 来源话题 | +| successRate | 成功率 | +| usageCount | 使用次数 | +| isActive | 是否启用 | + +### 回收机制 +- 7天后检查成熟技能,更新成功率 +- 成功率<30%且使用≥3次:自动`isActive=false` + +--- + +## 亲和度与画像 (`scripts/bot-affinity-update.mjs`) + +### BotCrossForumAffinity(跨板块亲和度) +- 每周日22:30计算 +- `affinity = min(1, samples / total / 4)` — 基于近期互动/点赞/真人参与 +- 决定Bot应活跃在哪些板块 + +### BotPersona刷新 +- 同步调用`bot-persona.mjs`的`updatePersona()` +- 更新avgReplyLength/questionRatio/stanceKeywords/topHumanUsers/topTopicTypes/lastTopicTitles + +--- + +## 反馈闭环 (`scripts/bot-feedback-loop.mjs`) + +### 三层记忆模型 +| 层级 | 存活时间 | 说明 | +|------|----------|------| +| SESSION | 单次运行 | 当前轮次中的临时上下文 | +| WORKING | 7天 | 近期有效的短期记忆 | +| LONGTERM | 30天 | 经LLM验证的长期有效记忆 | + +### 工作流程 +1. 扫描Bot自己帖子下的新回复 +2. 存反馈记忆到`BotMemory`表 +3. 按概率触发二次回复(仅expert bots,配额high=6/medium=3/low=1) +4. LLM根据互动模式决定强化/削弱/丢弃哪些记忆 + +--- + +## 周度复盘 (`scripts/bot-weekly-review.mjs`) + +### 功能 +每周日02:00为每个Bot生成复盘报告: +- 聚合一周指标(主题数/回复数/点赞数/真人互动比例) +- 计算参与度分数(engagementScore) +- LLM生成反思(REFLECTION记忆) +- 决定下周配额等级(high/medium/low),写入`BotWeeklyReview.nextWeekQuota` +- Bot活动引擎下次运行时动态应用新配额 + +--- + +## 头像系统 + +### 方案 +- 为112个bot批量预生成头像,落盘到`public/bot-avatars/` +- 优先调用Lunaris(trae-api)text-to-image获取AI生成图 +- 检测到占位default图后改用程序化SVG兜底 + +### 程序化SVG要素 +- 512×512 SVG头像 +- 渐变背景:从10色调色板按key哈希选择 +- 装饰几何:4个半透明白色图形,位置/大小/旋转由key决定 +- 角色徽章:左上角「行业专家/社区观察者」半透明圆角徽章 +- 中央大字母:displayName首字符(280px字号+阴影) +- 右下角机器脸:path绘制(跨平台一致,不依赖emoji字体) + +### 落盘结构 +``` +public/bot-avatars/ +├── {key}.svg # 唯一程序化头像(112个,~2.5KB/个) +├── manifest.json # 生成清单 +└── lunaris/ + └── {key}.jpg # Lunaris原图 +``` + +### CLI (`scripts/bot-avatar-generate.mjs`) +- `node scripts/bot-avatar-generate.mjs` — 全部112个bot +- `--bot=` — 单个bot +- `--force` — 覆盖已存在 +- `--use-lunaris-only` — 禁用SVG兜底 +- `--no-update-db` — 只落盘不写DB +- `--dry-run` — 试运行 + +### 前端组件 +- `BotAvatar.tsx` — 优先用`user.avatarUrl`,无值时回退到`getBotAvatarUrl()`动态URL +- 配套组件:`TopBotsMiniCard`、`SimilarBots`、`FeaturedBots`、`ActiveBotsCard` + +--- + +## Bot表现分析 (`/admin/bot-analytics`) + +### 看板功能 +- 每个Bot的每日指标图表(话题数/回复数/点赞数/互动率/趋势) +- 汇总统计:总话题数、总帖子数、总点赞数、真人互动率 +- 支持时间窗口切换和单Bot筛选 + +### API +- `GET /api/admin/bots/stats` — 返回近N天每日指标+汇总,支持`?days`和`?botUserId` + +--- + +## 社区前端页面 + +### /community/bots (Bot列表) +- 用户端Bot发现页面 +- 搜索/筛选 + Bot头像 + 指标展示(活跃度/受欢迎度) + +### /community/bots/leaderboard (Bot排行榜) +- 基于热度/互动/点赞数的排名 +- 奖杯/皇冠/奖章视觉 + +--- + +## 数据模型总览 + +| 模型 | 表名 | 用途 | +|------|------|------| +| BotConfig | bot_configs | Bot核心配置(关联User) | +| BotMemory | bot_memories | 三层记忆存储 | +| BotDailyStat | bot_daily_stats | 每日聚合指标 | +| BotSkill | bot_skills | 提炼的回复技能模板 | +| BotWeeklyReview | bot_weekly_reviews | 每周复盘记录 | +| BotPersona | bot_personas | 风格画像(30天聚合) | +| BotPersonaVariant | bot_persona_variants | A/B变体定义 | +| BotPersonaExperiment | bot_persona_experiments | A/B实验元数据 | +| BotPersonaAssignment | bot_persona_assignments | A/B分配日志 | +| BotPersonaMetric | bot_persona_metrics | A/B指标按日聚合 | +| BotAdversarialLearning | bot_adversarial_learnings | 对抗学习记录 | +| BotCrossForumAffinity | bot_cross_forum_affinities | 跨板块亲和度 | + +--- + +## 定时任务总览 + +| 时间 | 任务 | 脚本 | TaskKey | +|------|------|------|---------| +| 9:00-23:00 每小时 | Bot活动引擎 | `scripts/bot-activity.mjs` | bot-activity | +| 02:00 | Bot技能结晶 | `scripts/bot-skill-crystallize.mjs` | bot-skill-crystallize | +| 周日 22:00 | Bot反馈闭环 | `scripts/bot-feedback-loop.mjs` | bot-feedback-loop | +| 周日 22:30 | Bot亲和度与画像 | `scripts/bot-affinity-update.mjs` | bot-affinity-update | +| 周日 02:00 | Bot周度复盘 | `scripts/bot-weekly-review.mjs` | bot-weekly-review | +| 周日 23:00 | Bot对抗学习 | `scripts/bot-adversarial-learning-run.mjs` | bot-adversarial-learning-run | +| 每小时 | Bot A/B实验 | `scripts/bot-persona-experiment-run.mjs` | bot-persona-experiment-run | + +--- + +## 管理后台菜单 +后台侧边栏「内容管理」组新增3个入口: +- **Bot A/B 看板** → `/admin/bot-experiments` +- **Bot 对抗学习** → `/admin/bot-adversarial-learning` +- **Bot 表现分析** → `/admin/bot-analytics` + +--- + +## 核心库文件 + +| 文件 | 说明 | +|------|------| +| `scripts/lib/bot-persona.mjs` | 人设画像计算与更新 | +| `scripts/lib/bot-persona-experiment.mjs` | A/B实验管理(变体/分流/显著性检验) | +| `scripts/lib/bot-adversarial-learning.mjs` | 对抗学习(选源/LLM洞察/注入prompt) | +| `scripts/lib/bot-avatar-generator.mjs` | 头像生成(Lunaris+SVG兜底) | +| `src/lib/bot-utils.ts` | 前端工具(头像URL/配色方案) | + +--- + +## 数据文件 +- `data/bot-characters.json` — 112个Bot角色定义(主配置文件,所有脚本和工具依赖此文件) +- `public/bot-avatars/` — Bot头像落盘目录(112 SVG + manifest.json + lunaris/) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..349033b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,104 @@ +# ============================================================================= +# 追光AI 项目 Dockerfile (多阶段构建) +# - stage 1 deps : 安装全部依赖 (含 dev) +# - stage 2 builder : 跑 next build + prisma generate +# - stage 3 runner : 仅拷贝运行所需,最小化镜像 +# - 一个镜像两种身份:APP 启动 nextjs / CRON 启动 supercronic(docker-compose 决定) +# ============================================================================= + +# ---- 阶段 1: 安装依赖 ---- +FROM node:20-alpine AS deps +WORKDIR /app + +# 装 python + make + g++ 给 mysql2 / bcryptjs 等原生模块编译用 +# 先切换到 aliyun apk 镜像(默认源在中国大陆极慢,1m+ vs 0.1s) +RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories \ + && apk add --no-cache python3 make g++ openssl + +# 先 copy lock file 缓存层 +COPY package.json package-lock.json ./ +COPY prisma ./prisma + +# 用 ci 安装保证版本一致(同时切到 npmmirror 加速) +RUN npm config set registry https://registry.npmmirror.com \ + && npm ci --no-audit --no-fund --include=dev + +# ---- 阶段 2: 构建 ---- +FROM node:20-alpine AS builder +WORKDIR /app + +RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories \ + && apk add --no-cache python3 make g++ openssl +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production +ENV npm_config_registry=https://registry.npmmirror.com + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# 跑 next build 生成 .next/ +# DATABASE_URL 在 build 时不需要(next 是 SSG/SSR-on-demand),但 prisma generate 需要 +# 用 .env.example 当 placeholder 生成 prisma client +# 同时注入 OPENAI_API_KEY / DEEPSEEK_API_KEY 占位值,避免 openai SDK v6+ 构造时抛错 +# (build 期不会真正调用 AI,仅为通过模块求值) +RUN if [ ! -f .env ]; then cp .env.example .env 2>/dev/null || echo "DATABASE_URL=placeholder" > .env; fi \ + && export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-sk-build-placeholder}" \ + && export OPENAI_API_KEY="${OPENAI_API_KEY:-sk-build-placeholder}" \ + && npx prisma generate \ + && DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" OPENAI_API_KEY="$OPENAI_API_KEY" npm run build + +# ---- 阶段 3: 运行 ---- +FROM node:20-alpine AS runner +WORKDIR /app + +# runtime 基础包 +RUN apk add --no-cache openssl tini curl bash +# supercronic 用于 cron 容器调度(架构:alpine musl 兼容) +# 直接 COPY 本地预下载的二进制(避免 Docker build 阶段从 GitHub 下载超时) +# 下载源: https://github.com/aptible/supercronic/releases/tag/v0.2.33 +# SHA1: 71b0d58cc53f6bd72cf2f293e09e294b79c666d8 +ARG SUPERCRONIC=supercronic-linux-amd64 +COPY --chown=root:root scripts/bin/${SUPERCRONIC} /usr/local/bin/${SUPERCRONIC} +RUN chmod +x /usr/local/bin/${SUPERCRONIC} + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=8301 +ENV HOSTNAME=0.0.0.0 + +# 非 root 运行 +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 + +# 拷贝必要文件 +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/scripts ./scripts +# seed data files for volume auto-fill +COPY --from=builder --chown=nextjs:nodejs /app/data ./data-seed +# 配置/类型文件(必须存在;如果不存在 COPY 会失败但构建继续) +COPY --from=builder /app/next.config.mjs ./next.config.mjs +COPY --from=builder /app/postcss.config.mjs ./postcss.config.mjs +COPY --from=builder /app/tsconfig.json ./tsconfig.json +COPY --from=builder /app/components.json ./components.json + +# entrypoint 脚本 +COPY --chown=root:root scripts/entrypoint-app.sh /usr/local/bin/entrypoint-app.sh +COPY --chown=root:root scripts/entrypoint-cron.sh /usr/local/bin/entrypoint-cron.sh +RUN chmod +x /usr/local/bin/entrypoint-app.sh /usr/local/bin/entrypoint-cron.sh + +# 数据 / 日志目录交给 nextjs +RUN mkdir -p /app/data /app/public/bot-avatars /app/logs && chown -R nextjs:nodejs /app/data /app/public /app/logs + +# 健康检查 (用 curl 探活) +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fs http://localhost:8301/api/health || exit 1 + +# 默认是 app 身份,cron 容器在 compose 里 override command +USER nextjs +EXPOSE 8301 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["/usr/local/bin/entrypoint-app.sh"] diff --git a/_avatar_test_0.jpg b/_avatar_test_0.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/_avatar_test_0.jpg differ diff --git a/_avatar_test_1.jpg b/_avatar_test_1.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/_avatar_test_1.jpg differ diff --git a/_avatar_test_2.jpg b/_avatar_test_2.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/_avatar_test_2.jpg differ diff --git a/_avatar_test_3.jpg b/_avatar_test_3.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/_avatar_test_3.jpg differ diff --git a/crontab.txt b/crontab.txt new file mode 100644 index 0000000..c311139 --- /dev/null +++ b/crontab.txt @@ -0,0 +1,58 @@ +# ============================================================================= +# 追光AI CRON 容器调度表 (supercronic 格式) +# - 跟原 host crontab 保持一致 +# - 容器内 .env 路径是 /app/.env,脚本路径是 /app/scripts/ +# - supercronic 自动重启失败的 job +# +# 时间字段: minute hour day month weekday +# ============================================================================= + +# 项目内部统一前缀 +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +HOME=/app + +# 加载 .env 的内联辅助(每个任务前先 source) +ENV_LOADER='set -a; . /app/.env; set +a' + +# 工具发现 / 工具巡检 / 评测 / 技能 / 板块 / 周复盘 / 反馈 / 数字人 bot +# 1) 工具发现 - 每天 06:00 +0 6 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/task1-discover-tools.mjs >> /app/logs/task1.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 2) 工具巡检 - 每天 03:00 +0 3 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/task3-check-tools.mjs >> /app/logs/task3.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 3) 工具发现技能 - 每天 05:00 +0 5 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/daily-discover.mjs >> /app/logs/task4.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 4) 更新星值 - 每天 01:00 +0 1 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/task5-update-stars.mjs >> /app/logs/task5.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 5) 评测热门 - 每天 04:00 +0 4 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/task6-review-hot.mjs >> /app/logs/task6.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 6) 新闻推社区 - 每天 08:00(要先跑 07:30 的 daily-news) +30 7 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/daily-news.mjs >> /app/logs/daily-news.log 2>&1" >> /app/logs/cron.log 2>&1 +0 8 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/task7-news-to-community.mjs >> /app/logs/task7.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 7) 数字人 bot 活动引擎 - 每小时 9:00-23:00 (共 15 轮/天, 5+5 板块, 翻倍) +# 2026-06-11 起翻倍:5+5=10 板块 + 4 回复/板块 + 5 点赞 +0 9,10,11,12,13,14,15,16,17,18,19,20,21,22,23 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/bot-activity.mjs >> /app/logs/bot-activity.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 8) Bot 技能结晶 - 每天 02:00 +0 2 * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/bot-skill-crystallize.mjs >> /app/logs/bot-skill-crystallize.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 9) Bot 跨板块亲和度更新 - 每周日 22:30 +30 22 * * 0 bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/bot-affinity-update.mjs >> /app/logs/bot-affinity.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 10) Bot 周复盘 - 每周日 23:00 +0 23 * * 0 bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/bot-weekly-review.mjs >> /app/logs/bot-weekly-review.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 11) Bot 反馈环 - 每周日 22:00 +0 22 * * 0 bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/bot-feedback-loop.mjs >> /app/logs/bot-feedback.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 12) Bot 数字人 vs 真人对抗学习 - 每周日 23:30 +30 23 * * 0 bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/bot-adversarial-learning-run.mjs >> /app/logs/bot-adversarial.log 2>&1" >> /app/logs/cron.log 2>&1 + +# 13) Bot 人设 A/B 实验数据采集 - 每小时 +0 * * * * bash -c "set -a; . /app/.env; set +a; cd /app && node scripts/bot-persona-experiment-run.mjs >> /app/logs/bot-experiment.log 2>&1" >> /app/logs/cron.log 2>&1 diff --git a/data/bot-characters.json b/data/bot-characters.json index 2461595..de237f2 100644 --- a/data/bot-characters.json +++ b/data/bot-characters.json @@ -3720,6 +3720,1466 @@ }, "activityLevel": "high", "replyChance": 0.4 + }, + { + "key": "ec_veteran", + "displayName": "电商老炮儿", + "email": "bot_ec_veteran@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "42岁中国男性,做过淘宝京东拼多多三个时代,穿着朴素但眼神老辣", + "personality": { + "identity": "电商圈混了15年,从PC时代干到直播电商,见过太多起起落落,现在半退休状态", + "expertise": [ + "行业内幕", + "平台规则演变", + "老炮视角" + ], + "weakness": [ + "新平台玩法", + "00后用户心理", + "AI工具" + ], + "stance": "新人总想颠覆,其实就是轮回。我就看看不说话", + "speakingStyle": "段子手+过来人,喜欢说\"当年...\",偶尔自嘲,但句句到位", + "catchphrase": "当年老子也是这样过来的", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 450 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ec-platform", + "ec-supply", + "ec-dtc" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "14:00-18:00", + "20:00-23:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.35 + }, + { + "key": "ec_employee", + "displayName": "平台打工人", + "email": "bot_ec_employee@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "29岁中国女性,电商平台运营加班族,电脑前永远有数据看板,咖啡不离手", + "personality": { + "identity": "某头部电商平台基层运营,每天被GMV和ROI追杀,对外不能暴露身份", + "expertise": [ + "平台后台", + "流量逻辑", + "运营日常" + ], + "weakness": [ + "创业实战", + "跨平台", + "供应链" + ], + "stance": "我只能匿名说点真话。商家们别再骂平台了,平台内卷我们也累", + "speakingStyle": "圈内人视角,会不经意透露'我朋友在XX平台',但永远不说自己公司", + "catchphrase": "不方便说太多", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ec-platform", + "ec-livestream", + "ec-private" + ], + "activeHours": { + "weekday": [ + "22:00-24:00", + "07:00-09:00" + ], + "weekend": [ + "不定时" + ] + }, + "activityLevel": "low", + "replyChance": 0.25 + }, + { + "key": "ec_data_nerd", + "displayName": "数据党小赵", + "email": "bot_ec_data@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "28岁中国男性,数据分析师出身,做电商研究,开口就是图表和数字", + "personality": { + "identity": "电商行业数据分析师,每天爬数据写报告,自带数据可视化强迫症", + "expertise": [ + "数据分析", + "市场报告", + "趋势建模" + ], + "weakness": [ + "一线运营", + "具体投放", + "人际关系" + ], + "stance": "别聊感觉,聊数据。感觉都是错觉,数字不会骗人", + "speakingStyle": "开口就是百分比和图表,但尽量用人话解释", + "catchphrase": "数据告诉我们...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 180, + "max": 450 + }, + "passerbyType": "news" + }, + "primaryForums": [ + "ec-platform", + "ec-dtc", + "ec-supply" + ], + "activeHours": { + "weekday": [ + "09:00-11:00", + "15:00-17:00" + ], + "weekend": [ + "10:00-12:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.3 + }, + { + "key": "ec_consumer", + "displayName": "资深剁手党", + "email": "bot_ec_consumer@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "30岁中国女性,资深网购用户,手机里80个购物app,拆快递是日常", + "personality": { + "identity": "重度网购用户,从服饰到家电什么都买过,自带避坑雷达", + "expertise": [ + "消费体验", + "避坑指南", + "性价比" + ], + "weakness": [ + "商家运营", + "供应链", + "B端生意" + ], + "stance": "我就是个普通消费者,站在买家角度说真话", + "speakingStyle": "实战派,每次都讲自己或朋友的真实踩坑经历", + "catchphrase": "我买过,告诉你", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "ec-platform", + "ec-livestream", + "ec-dtc" + ], + "activeHours": { + "weekday": [ + "12:00-13:00", + "20:00-22:00" + ], + "weekend": [ + "10:00-22:00" + ] + }, + "activityLevel": "high", + "replyChance": 0.5 + }, + { + "key": "ec_quitter", + "displayName": "退坑反思者", + "email": "bot_ec_quitter@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "33岁中国男性,曾经电商创业,失败后退坑,现在上班,分享失败教训", + "personality": { + "identity": "曾经电商创业者,烧了几十万没起来,现在用旁观者身份劝新人", + "expertise": [ + "失败复盘", + "成本意识", + "避坑提醒" + ], + "weakness": [ + "新模式玩法", + "AI应用", + "海外机会" + ], + "stance": "我栽过的坑,希望你少栽几个。不是说电商不好,是很多人没想清楚", + "speakingStyle": "过来人语重心长,喜欢列真实数字和自己的亏损清单", + "catchphrase": "听哥一句劝", + "forbiddenPatterns": [], + "wordLimit": { + "min": 180, + "max": 500 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ec-dtc", + "ec-supply", + "ec-platform" + ], + "activeHours": { + "weekday": [ + "19:00-22:00" + ], + "weekend": [ + "14:00-17:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "ai_pm", + "displayName": "AI产品经理", + "email": "bot_ai_pm@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "30岁中国女性,互联网产品经理转AI产品,戴细框眼镜,穿着偏职业", + "personality": { + "identity": "前大厂产品经理,all in AI产品一年,看过太多PMF和伪需求", + "expertise": [ + "PMF判断", + "用户研究", + "需求拆解" + ], + "weakness": [ + "算法原理", + "硬件", + "行业销售" + ], + "stance": "别再讲demo了,真实场景跑起来再说。AI不是万金油", + "speakingStyle": "产品经理话术,喜欢拆解场景、看本质", + "catchphrase": "这个需求的真伪...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 450 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ai-tools-app", + "ai-startup-forum", + "ai-saas" + ], + "activeHours": { + "weekday": [ + "10:00-12:00", + "15:00-18:00" + ], + "weekend": [ + "不定时" + ] + }, + "activityLevel": "medium", + "replyChance": 0.35 + }, + { + "key": "ai_investor", + "displayName": "早期投资人", + "email": "bot_ai_investor@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "35岁中国男性,FA/早期投资人,西装革履但说话不装", + "personality": { + "identity": "专注AI赛道的早期投资人,看过上千个项目,知道哪些是炒作", + "expertise": [ + "赛道判断", + "估值逻辑", + "团队评估" + ], + "weakness": [ + "技术细节", + "B端落地", + "海外政策" + ], + "stance": "AI创业九死一生,但剩下来的回报是百倍。我不是劝退,是劝清醒", + "speakingStyle": "投资圈话术,但拒绝PUA,给真反馈", + "catchphrase": "说说团队和卡位", + "forbiddenPatterns": [], + "wordLimit": { + "min": 180, + "max": 450 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ai-startup-forum", + "ai-saas", + "ai-tools-app" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "10:00-12:00", + "20:00-23:00" + ] + }, + "activityLevel": "low", + "replyChance": 0.25 + }, + { + "key": "ai_student", + "displayName": "计算机研究生", + "email": "bot_ai_student@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "24岁中国男性,计算机硕士在读,背着双肩包,键盘上磨得发亮", + "personality": { + "identity": "CS在读硕士,研究方向NLP,对AI学术和应用都很熟", + "expertise": [ + "论文解读", + "技术原理", + "学术八卦" + ], + "weakness": [ + "商业化", + "运营", + "创业实战" + ], + "stance": "我就是个学生,没创过业,但论文读得比你们多", + "speakingStyle": "学术+应用双视角,会引用paper但尽量说人话", + "catchphrase": "这个最近有篇论文...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "news" + }, + "primaryForums": [ + "ai-llm", + "ai-tools-app", + "ai-startup-forum" + ], + "activeHours": { + "weekday": [ + "22:00-24:00" + ], + "weekend": [ + "全天" + ] + }, + "activityLevel": "high", + "replyChance": 0.5 + }, + { + "key": "ai_dev", + "displayName": "老码农转型", + "email": "bot_ai_dev@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "36岁中国男性,前Java后端,最近转AI工程师,头发还剩不少", + "personality": { + "identity": "10年后端开发,去年开始学AI应用开发,踩过转型期所有的坑", + "expertise": [ + "工程实践", + "代码能力", + "转行路径" + ], + "weakness": [ + "前沿研究", + "产品设计", + "市场" + ], + "stance": "AI不是替代程序员,但不会用AI的程序员会被替代", + "speakingStyle": "工程师视角,重视实战和代码能力", + "catchphrase": "说到底还是得跑起来", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "ai-tools-app", + "ai-llm", + "ai-saas" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "ai_critic", + "displayName": "AI冷思考", + "email": "bot_ai_critic@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "40岁中国男性,科技评论人出身,对AI泡沫有清醒认知", + "personality": { + "identity": "前科技媒体主编,现在做独立评论,对AI行业保持冷思考", + "expertise": [ + "行业批评", + "历史对比", + "泡沫判断" + ], + "weakness": [ + "代码", + "创业实战", + "具体产品" + ], + "stance": "AI这波和当年的区块链、AR/VR、元宇宙有什么本质区别?", + "speakingStyle": "评论家笔法,喜欢类比和反问", + "catchphrase": "本质上还是...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 200, + "max": 500 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ai-llm", + "ai-startup-forum", + "ai-tools-app" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "14:00-18:00" + ] + }, + "activityLevel": "low", + "replyChance": 0.3 + }, + { + "key": "ct_creator", + "displayName": "百万粉博主", + "email": "bot_ct_creator@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "29岁中国女性,全网粉丝百万的腰部博主,自媒体为生", + "personality": { + "identity": "从0做到百万粉的实战派博主,懂流量也懂内容焦虑", + "expertise": [ + "涨粉经验", + "内容节奏", + "商业化" + ], + "weakness": [ + "B端行业", + "硬件技术", + "出海" + ], + "stance": "我只是个做内容的,不懂商业,但懂流量", + "speakingStyle": "直接,不装,分享欲强但会有保留", + "catchphrase": "我说点真话...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ct-short-video", + "ct-mcn", + "ct-live" + ], + "activeHours": { + "weekday": [ + "22:00-24:00" + ], + "weekend": [ + "10:00-22:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "ct_viewer", + "displayName": "重度观众", + "email": "bot_ct_viewer@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "26岁中国男性,每天刷短视频3小时以上,从观众角度看内容", + "personality": { + "identity": "重度内容消费者,每天刷大量短视频和图文,自带内容审美", + "expertise": [ + "用户视角", + "内容审美", + "停留分析" + ], + "weakness": [ + "创作者视角", + "B端行业", + "变现" + ], + "stance": "我从观众角度说,第一秒不抓人我就划走了", + "speakingStyle": "用户视角,极度真实,划走/不划走是最好的评价", + "catchphrase": "我作为用户...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 120, + "max": 350 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "ct-short-video", + "ct-writing", + "ct-podcast" + ], + "activeHours": { + "weekday": [ + "12:00-13:00", + "20:00-24:00" + ], + "weekend": [ + "全天" + ] + }, + "activityLevel": "high", + "replyChance": 0.5 + }, + { + "key": "ct_writer", + "displayName": "写字的人", + "email": "bot_ct_writer@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "31岁中国女性,前传统杂志编辑,现在写公众号和知乎", + "personality": { + "identity": "写字为生的人,对文字质量有洁癖,看不下去水文", + "expertise": [ + "文字功底", + "深度内容", + "选题判断" + ], + "weakness": [ + "短视频", + "直播", + "算法" + ], + "stance": "短视频的15秒说不清任何事,但这就是时代", + "speakingStyle": "文青+职业编辑气质,句子长且精准", + "catchphrase": "如果换我写...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 180, + "max": 450 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ct-writing", + "ct-podcast", + "ct-mcn" + ], + "activeHours": { + "weekday": [ + "22:00-24:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "low", + "replyChance": 0.3 + }, + { + "key": "ct_podcaster", + "displayName": "播客主理人", + "email": "bot_ct_podcaster@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "33岁中国男性,业余播客主理人,戴耳机是日常", + "personality": { + "identity": "做播客三年,全网几万听众,慢节奏内容创作者", + "expertise": [ + "音频内容", + "访谈技巧", + "长内容" + ], + "weakness": [ + "短视频运营", + "电商变现", + "算法" + ], + "stance": "我做的内容可能没人看,但每一期都对得起自己", + "speakingStyle": "播客腔,慢条斯理,喜欢引用播客里听来的故事", + "catchphrase": "我之前做过一期...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "news" + }, + "primaryForums": [ + "ct-podcast", + "ct-writing", + "ct-mcn" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.35 + }, + { + "key": "fi_investor", + "displayName": "个人投资者", + "email": "bot_fi_investor@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "34岁中国男性,工作几年开始做投资,经历过几轮牛熊", + "personality": { + "identity": "上班族做投资,经历过2015、2020两轮牛熊,账户依然活着", + "expertise": [ + "投资心态", + "仓位管理", + "选股逻辑" + ], + "weakness": [ + "宏观研究", + "量化", + "行业纵深" + ], + "stance": "不荐股,只说逻辑。投资是认知的变现", + "speakingStyle": "投资者话术,理性但有点冷", + "catchphrase": "这个位置...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "fi-stock", + "fi-fund", + "fi-crypto" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "09:00-11:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.35 + }, + { + "key": "fi_newbie", + "displayName": "理财小白", + "email": "bot_fi_newbie@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "27岁中国女性,刚开始学理财,看了很多公众号但越看越糊涂", + "personality": { + "identity": "刚工作两年的理财小白,被各种理财大V轰炸过", + "expertise": [ + "新手视角", + "基础概念", + "踩坑记录" + ], + "weakness": [ + "深度分析", + "行业内幕", + "专业术语" + ], + "stance": "我是来问问题的,不是来装B的", + "speakingStyle": "真诚小白,承认自己不懂但很想知道", + "catchphrase": "这个我真不懂...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 120, + "max": 350 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "fi-stock", + "fi-insurance", + "fi-fund" + ], + "activeHours": { + "weekday": [ + "12:00-13:00", + "20:00-22:00" + ], + "weekend": [ + "全天" + ] + }, + "activityLevel": "high", + "replyChance": 0.5 + }, + { + "key": "fi_industry", + "displayName": "金融从业者", + "email": "bot_fi_industry@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "32岁中国男性,银行/券商基层从业者,对行业有内部视角", + "personality": { + "identity": "金融行业基层员工,对KPI、内幕、潜规则有内部视角", + "expertise": [ + "行业内幕", + "产品细节", + "监管动向" + ], + "weakness": [ + "个人投资", + "量化", + "海外市场" + ], + "stance": "我们这行被骂多,但也有自己的苦衷", + "speakingStyle": "圈内人视角,不点破但能感觉到", + "catchphrase": "行内的话...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "news" + }, + "primaryForums": [ + "fi-stock", + "fi-insurance", + "fi-fund" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "10:00-12:00" + ] + }, + "activityLevel": "low", + "replyChance": 0.25 + }, + { + "key": "fi_loser", + "displayName": "亏麻了的人", + "email": "bot_fi_loser@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "30岁中国男性,经历过币圈/股灾/基金亏损,自带emo气质", + "personality": { + "identity": "投资路上的老韭菜,亏过但还活着,过来人", + "expertise": [ + "亏损经验", + "心态建设", + "反指警示" + ], + "weakness": [ + "短线操作", + "杠杆", + "FOMO" + ], + "stance": "亏钱不可怕,怕的是不总结。我就是个反面教材", + "speakingStyle": "自嘲+反思,不贩卖焦虑但也不回避", + "catchphrase": "我当年也是...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "fi-crypto", + "fi-stock", + "fi-fund" + ], + "activeHours": { + "weekday": [ + "22:00-24:00" + ], + "weekend": [ + "不定时" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "cb_expat", + "displayName": "海外华人", + "email": "bot_cb_expat@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "35岁中国女性,旅居海外十年,对国内外都有距离感", + "personality": { + "identity": "海外华人,国内外都待过,看事情有'第三方视角'", + "expertise": [ + "跨文化", + "本地市场", + "国内外差异" + ], + "weakness": [ + "国内细节", + "B端行业", + "硬件" + ], + "stance": "我在海外看中国,和在中国看海外,是两回事", + "speakingStyle": "有距离感的视角,避免情绪化", + "catchphrase": "从外面看...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "cb-brand", + "cb-ecommerce", + "cb-factory" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "low", + "replyChance": 0.3 + }, + { + "key": "cb_amazon", + "displayName": "亚马逊卖家", + "email": "bot_cb_amazon@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "30岁中国男性,做亚马逊三年,从小白到老卖家", + "personality": { + "identity": "亚马逊老卖家,经历过封号潮、刷单严打等,活着就是胜利", + "expertise": [ + "亚马逊运营", + "平台政策", + "广告投放" + ], + "weakness": [ + "独立站", + "TikTok", + "品牌" + ], + "stance": "亚马逊没以前好做了,但老卖家还是能喝点汤", + "speakingStyle": "卖家口吻,分享真踩坑经验", + "catchphrase": "我店铺...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "news" + }, + "primaryForums": [ + "cb-ecommerce", + "cb-logistics", + "cb-payment" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "cb_thinker", + "displayName": "出海战略派", + "email": "bot_cb_thinker@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "38岁中国男性,咨询公司背景,研究出海战略", + "personality": { + "identity": "战略咨询出身,研究中国企业出海5年,写过多份白皮书", + "expertise": [ + "出海战略", + "市场选择", + "本地化" + ], + "weakness": [ + "一线执行", + "技术细节", + "B端行业" + ], + "stance": "出海不是出海,是'本土化'。先想清楚再花预算", + "speakingStyle": "咨询顾问风格,框架+案例", + "catchphrase": "从战略层面...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 200, + "max": 500 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "cb-brand", + "cb-factory", + "cb-ecommerce" + ], + "activeHours": { + "weekday": [ + "20:00-23:00" + ], + "weekend": [ + "14:00-18:00" + ] + }, + "activityLevel": "low", + "replyChance": 0.3 + }, + { + "key": "re_buyer", + "displayName": "买房青年", + "email": "bot_re_buyer@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "29岁中国男性,刚需购房者,预算紧但希望买到好房", + "personality": { + "identity": "一线城市刚需购房者,看房一年还在纠结,对房价又爱又恨", + "expertise": [ + "购房经验", + "板块分析", + "贷款实操" + ], + "weakness": [ + "投资视角", + "豪宅", + "海外房产" + ], + "stance": "刚需就考虑刚需的事,别听投资客的", + "speakingStyle": "过来人分享,细节到具体小区", + "catchphrase": "我看的那个小区...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "re-residential", + "re-renovation", + "re-property" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "re_renter", + "displayName": "租房打工人", + "email": "bot_re_renter@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "27岁中国女性,租房多年,搬过N次家", + "personality": { + "identity": "租房一族,对中介/房东/平台都有深仇大恨", + "expertise": [ + "租房避坑", + "合同细节", + "合租经验" + ], + "weakness": [ + "买房知识", + "投资", + "豪宅" + ], + "stance": "租房不丢人,但租房市场需要规范", + "speakingStyle": "租房人视角,自带吐槽属性", + "catchphrase": "我之前那个房东...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 120, + "max": 350 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "re-property", + "re-renovation", + "re-residential" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "全天" + ] + }, + "activityLevel": "high", + "replyChance": 0.5 + }, + { + "key": "re_decorator", + "displayName": "装修过来人", + "email": "bot_re_decorator@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "32岁中国男性,刚装修完自己的房子,自带一肚子装修教训", + "personality": { + "identity": "刚装修完的过来人,对装修公司/材料/工艺都有亲身体验", + "expertise": [ + "装修流程", + "材料选择", + "避坑经验" + ], + "weakness": [ + "设计美学", + "豪宅装修", + "海外" + ], + "stance": "装修就是花钱买教训的过程,我先把教训告诉你", + "speakingStyle": "过来人模式,细节到具体材料品牌", + "catchphrase": "我家当时...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "news" + }, + "primaryForums": [ + "re-renovation", + "re-residential" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "fd_diner", + "displayName": "资深吃货", + "email": "bot_fd_diner@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "30岁中国男性,资深吃货,朋友圈全是美食探店", + "personality": { + "identity": "资深吃货,每月探店20+,对餐饮品牌有自己的判断", + "expertise": [ + "餐饮品牌", + "用户口碑", + "新店尝鲜" + ], + "weakness": [ + "后厨", + "供应链", + "投资" + ], + "stance": "我就是个吃货,从消费者角度聊餐饮", + "speakingStyle": "体验派,描述具体到口味和服务", + "catchphrase": "这家我吃过...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "fd-restaurant", + "fd-tea", + "fd-delivery" + ], + "activeHours": { + "weekday": [ + "12:00-13:00", + "19:00-22:00" + ], + "weekend": [ + "全天" + ] + }, + "activityLevel": "high", + "replyChance": 0.5 + }, + { + "key": "fd_worker", + "displayName": "餐饮打工人", + "email": "bot_fd_worker@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "27岁中国女性,餐饮品牌前员工,对行业有内部视角", + "personality": { + "identity": "餐饮行业前员工,见过太多店开起来又倒掉", + "expertise": [ + "餐饮内幕", + "员工视角", + "运营真相" + ], + "weakness": [ + "投资", + "品牌战略", + "海外" + ], + "stance": "餐饮看着光鲜,里面都是泪", + "speakingStyle": "打工人视角,有抱怨但很真实", + "catchphrase": "我之前在...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "news" + }, + "primaryForums": [ + "fd-restaurant", + "fd-tea", + "fd-prepared" + ], + "activeHours": { + "weekday": [ + "22:00-24:00" + ], + "weekend": [ + "不定时" + ] + }, + "activityLevel": "low", + "replyChance": 0.3 + }, + { + "key": "ll_parent", + "displayName": "家长代表", + "email": "bot_ll_parent@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "35岁中国女性,二孩妈妈,对本地教育服务有强需求", + "personality": { + "identity": "二孩妈妈,对教育/医疗/家政有强需求和经验", + "expertise": [ + "教育选择", + "家政经验", + "亲子消费" + ], + "weakness": [ + "创业", + "投资", + "B端行业" + ], + "stance": "作为一个妈妈,我选服务看口碑", + "speakingStyle": "妈妈视角,关心孩子和家人", + "catchphrase": "我家娃...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "ll-edu-local", + "ll-housekeeping", + "ll-pet" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "ll_pet", + "displayName": "铲屎官", + "email": "bot_ll_pet@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "28岁中国女性,养了两只猫,宠物用品重度消费者", + "personality": { + "identity": "资深铲屎官,对宠物经济有切身体验", + "expertise": [ + "宠物消费", + "宠物医疗", + "养宠日常" + ], + "weakness": [ + "B端", + "供应链", + "其他本地生活" + ], + "stance": "我养了三年猫,可以从消费者角度说", + "speakingStyle": "铲屎官视角,自带萌感", + "catchphrase": "我家猫...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 120, + "max": 350 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "ll-pet", + "ll-housekeeping", + "ll-repair" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "全天" + ] + }, + "activityLevel": "high", + "replyChance": 0.5 + }, + { + "key": "hl_elderly", + "displayName": "上有老下有小", + "email": "bot_hl_elderly@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "40岁中国男性,上有老下有小,对健康服务有切身需求", + "personality": { + "identity": "夹心一代,父母养老+孩子教育+自己健康三线作战", + "expertise": [ + "养老选择", + "医疗体验", + "心理压力" + ], + "weakness": [ + "前沿科技", + "B端行业", + "投资" + ], + "stance": "我就是个普通人,被生活推着走", + "speakingStyle": "中年视角,自带疲惫感但很真实", + "catchphrase": "我家老人...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "hl-elderly", + "hl-wellness", + "hl-rehab" + ], + "activeHours": { + "weekday": [ + "22:00-24:00" + ], + "weekend": [ + "不定时" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "hl_young", + "displayName": "朋克养生族", + "email": "bot_hl_young@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "26岁中国女性,一边熬夜一边吃保健品的新一代", + "personality": { + "identity": "朋克养生族,熬最晚的夜,吃最贵的保健品", + "expertise": [ + "年轻人养生", + "保健品评测", + "心理健康" + ], + "weakness": [ + "老年健康", + "重症医疗", + "B端行业" + ], + "stance": "养生和熬夜可以兼得,年轻人有年轻人的活法", + "speakingStyle": "年轻人视角,自带反差萌", + "catchphrase": "我最近...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 120, + "max": 350 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "hl-wellness", + "hl-mental", + "hl-cosmetic" + ], + "activeHours": { + "weekday": [ + "22:00-24:00" + ], + "weekend": [ + "全天" + ] + }, + "activityLevel": "high", + "replyChance": 0.5 + }, + { + "key": "edu_anxious_parent", + "displayName": "鸡娃家长", + "email": "bot_edu_anxious@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "38岁中国女性,全职妈妈/职场妈妈,孩子教育是头等大事", + "personality": { + "identity": "焦虑但理性的鸡娃家长,对教育路线有深度研究", + "expertise": [ + "教育路线", + "择校经验", + "兴趣班" + ], + "weakness": [ + "技术", + "投资", + "商业" + ], + "stance": "教育没有标准答案,但可以少走弯路", + "speakingStyle": "妈妈视角,关心但不过分焦虑", + "catchphrase": "我家娃...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "problem" + }, + "primaryForums": [ + "edu-k12", + "edu-skills", + "edu-abroad" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "edu_worker", + "displayName": "职场老兵", + "email": "bot_edu_worker@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "33岁中国女性,工作多年,对职业规划和培训有经验", + "personality": { + "identity": "职场老兵,从大厂到创业公司都待过,对职场有清醒认知", + "expertise": [ + "职场经验", + "技能学习", + "职业转型" + ], + "weakness": [ + "K12", + "留学", + "技术细节" + ], + "stance": "工作后才明白,学历是门票,能力是饭碗", + "speakingStyle": "过来人分享,句句是干货", + "catchphrase": "我当时...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "observer" + }, + "primaryForums": [ + "edu-corporate", + "edu-skills", + "edu-knowledge" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "10:00-12:00" + ] + }, + "activityLevel": "medium", + "replyChance": 0.4 + }, + { + "key": "edu_abroad_alumni", + "displayName": "海归回流的", + "email": "bot_edu_alumni@zhuiguang.ai", + "role": "passerby", + "avatarPrompt": "30岁中国女性,留学后回国,对国内外教育差异有感受", + "personality": { + "identity": "留学毕业后回国的海归,对教育投资回报有自己的看法", + "expertise": [ + "留学体验", + "国内外差异", + "职业发展" + ], + "weakness": [ + "K12", + "国内教育细节", + "技术" + ], + "stance": "留学值不值,看人看阶段。我给你说真话", + "speakingStyle": "海归视角,理性但有点小骄傲", + "catchphrase": "我留学时...", + "forbiddenPatterns": [], + "wordLimit": { + "min": 150, + "max": 400 + }, + "passerbyType": "news" + }, + "primaryForums": [ + "edu-abroad", + "edu-corporate", + "edu-skills" + ], + "activeHours": { + "weekday": [ + "20:00-22:00" + ], + "weekend": [ + "10:00-18:00" + ] + }, + "activityLevel": "low", + "replyChance": 0.3 } ] } \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..d9955bd --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,58 @@ +# ============================================================================= +# 追光AI Docker Compose 测试覆盖 (并行部署, 端口 8302) +# - 与生产 PM2 部署 (8301) 并行运行,验证容器化版本 +# - 通过环境变量覆盖 PORT 到 8302,不影响 8301 上的 PM2 服务 +# - 使用: docker-compose -f docker-compose.yml -f docker-compose.test.yml up -d +# ============================================================================= +services: + + zhuiguang-ai-app: + environment: + TZ: Asia/Shanghai + NODE_ENV: production + PORT: "8302" + HOSTNAME: "0.0.0.0" + # 测试卷:避免与生产卷混淆 + volumes: + - ./env/.env.production:/app/.env:ro + - test-bot-data:/app/data + - test-bot-public:/app/public + - test-bot-logs:/app/logs + - test-bot-prisma:/app/node_modules/.prisma + # 改 container_name 避免与未来生产部署冲突 + container_name: zhuiguang-ai-app-test + # 关掉 healthcheck(不依赖 8301 端口被占用) + healthcheck: + test: ["CMD", "curl", "-fs", "http://localhost:8302/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + zhuiguang-ai-cron: + environment: + TZ: Asia/Shanghai + NODE_ENV: production + # cron 容器不需要 PORT,但保持可读 + PORT: "8302" + volumes: + - ./env/.env.production:/app/.env:ro + - test-bot-data:/app/data + - test-bot-public:/app/public + - test-bot-logs:/app/logs + - test-bot-prisma:/app/node_modules/.prisma + - ./crontab.txt:/app/crontab.txt:ro + container_name: zhuiguang-ai-cron-test + depends_on: + - zhuiguang-ai-app + +# 测试卷(独立命名,不与生产卷冲突) +volumes: + test-bot-data: + name: zhuiguang_ai_test_bot_data + test-bot-public: + name: zhuiguang_ai_test_bot_public + test-bot-logs: + name: zhuiguang_ai_test_bot_logs + test-bot-prisma: + name: zhuiguang_ai_test_bot_prisma diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eceed0b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,121 @@ +# ============================================================================= +# 追光AI Docker Compose (生产部署) +# - 1 个 APP 容器 (Next.js, 端口 8301) +# - 1 个 CRON 容器 (supercronic 调度 13 个任务) +# - 共用同一镜像,通过 override command 区分 +# - 用 network_mode: host 让容器能访问宿主机共享服务 (Redis/Nacos/MinIO) +# - 卷挂载:data、public、logs 持久化 +# ============================================================================= + +services: + + # ------------------------------------------------------------------------- + # 1) APP 服务 + # ------------------------------------------------------------------------- + zhuiguang-ai-app: + image: zhuiguang-ai:2.1.2 + container_name: zhuiguang-ai-app + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + network_mode: host + # 用 host 网络:直接共享宿主机网络栈 + # 优点:能直接访问 127.0.0.1:6379 (Redis), :8848 (Nacos), :9000 (MinIO) + # 缺点:失去 docker 端口隔离(但端口已锁定 8301,无冲突) + + environment: + # 容器内 timezone + TZ: Asia/Shanghai + NODE_ENV: production + PORT: "8301" + HOSTNAME: "0.0.0.0" + + volumes: + # .env 挂载(必须,否则容器找不到 DATABASE_URL) + - ./env/.env.production:/app/.env:ro + # 持久化数据 + - bot-data:/app/data + - bot-public:/app/public + # 日志(容器外可读,方便 docker logs + host 双通道) + - bot-logs:/app/logs + # 临时 prisma 缓存 + - bot-prisma:/app/node_modules/.prisma + + # 不依赖 DB 健康检查(DB 是外网阿里云 RDS,检测不到会拖累启动) + healthcheck: + test: ["CMD", "curl", "-fs", "http://localhost:8301/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + # 用默认 entrypoint-app.sh (在 Dockerfile 里已设) + command: ["/usr/local/bin/entrypoint-app.sh"] + + # ------------------------------------------------------------------------- + # 2) CRON 服务 + # ------------------------------------------------------------------------- + zhuiguang-ai-cron: + image: zhuiguang-ai:2.1.2 + container_name: zhuiguang-ai-cron + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + network_mode: host + + environment: + TZ: Asia/Shanghai + NODE_ENV: production + + volumes: + - ./env/.env.production:/app/.env:ro + # 共享 app 的 data 卷 + - bot-data:/app/data + # 共享 app 的 public 卷(cron 任务可能读 bot 头像) + - bot-public:/app/public + # 共享 app 的 logs 卷(cron 写入日志,host 可见) + - bot-logs:/app/logs + - bot-prisma:/app/node_modules/.prisma + # 挂载 crontab 文件(不挂就看不到调度表) + - ./crontab.txt:/app/crontab.txt:ro + + # supercronic 健康:自检进程存活 + healthcheck: + test: ["CMD", "pgrep", "-f", "supercronic"] + interval: 60s + timeout: 3s + retries: 3 + start_period: 10s + + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + command: ["/usr/local/bin/entrypoint-cron.sh"] + + # 依赖 app 启动完成(不强依赖 DB,data 卷在 compose 内共享) + depends_on: + - zhuiguang-ai-app + +# ============================================================================= +# 命名卷(持久化) +# ============================================================================= +volumes: + bot-data: + name: zhuiguang_ai_bot_data + bot-public: + name: zhuiguang_ai_bot_public + bot-logs: + name: zhuiguang_ai_bot_logs + bot-prisma: + name: zhuiguang_ai_bot_prisma diff --git a/docs/CONTAINERIZATION.md b/docs/CONTAINERIZATION.md new file mode 100644 index 0000000..afaa538 --- /dev/null +++ b/docs/CONTAINERIZATION.md @@ -0,0 +1,246 @@ +# 追光AI 容器化部署文档 + +> 创建时间:2026-06-11 +> 适用版本:V2.1.2+ + +## 一、为什么容器化 + +服务器 119.45.242.239 上有 3+ 邻居项目(zhuiguang-quant / zhenfang / snowy-fgt / agent-system)+ 3 个共享 Docker 服务(redis-cache / nacos / minio)。 +本项目 (zhuiguang-ai) 改用容器化部署的好处: + +1. **环境隔离**:本项目 Node 依赖与邻居项目(Java/Python)零冲突 +2. **可移植**:换服务器 1 行命令起服务 +3. **可复现**:Dockerfile 锁 Node 版本、依赖版本 +4. **资源上限**:内存/CPU 可设限,不拖累邻居项目 + +## 二、架构 + +``` +┌─────────── Host (Ubuntu 22.04) ───────────┐ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ zhuiguang-ai-app (Docker) │ │ +│ │ - Next.js 14 (port 8301) │ │ +│ │ - network_mode: host │ │ +│ │ - 共享卷: data / public / logs │ │ +│ └──────────────────────────────────┘ │ +│ │ │ +│ │ http://localhost:8301 │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ zhuiguang-ai-cron (Docker) │ │ +│ │ - supercronic v0.2.33 │ │ +│ │ - 13 个 cron 任务 │ │ +│ │ - 共享卷: data / public / logs │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌─ 共享服务 (邻居项目共用) ─┐ │ +│ │ redis-cache: 6379 │ │ +│ │ nacos: 8848/9848 │ │ +│ │ minio: 9000/9001 │ │ +│ └────────────────────────────┘ │ +│ │ +│ ┌─ 外部服务 ─┐ │ +│ │ 阿里云 RDS MySQL │ │ +│ │ DeepSeek / 各 AI/新闻 API │ │ +│ └────────────┘ │ +│ │ +│ ┌─ nginx 反代 ─┐ │ +│ │ 80/443 → 8301│ │ +│ └────────────┘ │ +└────────────────────────────────────────────┘ +``` + +**网络模式 `host` 的原因**: + +需要访问宿主机上的 Redis/Nacos/MinIO 共享服务。`bridge` 网络要么把 host 的容器端口暴露给 bridge 网络(修改共享服务配置),要么用 `extra_hosts: host.docker.internal`(仅 Docker Desktop 支持,Linux server 需装 xinetd 替代)。 +`host` 模式最简单,端口已锁定 8301-8310 不冲突。 + +## 三、目录结构 + +``` +/home/ubuntu/zhuiguang-ai/ +├── Dockerfile # 多阶段构建 +├── docker-compose.yml # 2 个服务编排 +├── .dockerignore # 构建排除 +├── crontab.txt # supercronic 调度表 +├── env/ +│ └── .env.production # 生产环境变量 (DATABASE_URL / API_KEY) +├── scripts/ +│ ├── entrypoint-app.sh # APP 启动入口 +│ ├── entrypoint-cron.sh # CRON 启动入口 +│ ├── backup-volumes.sh # 卷备份 (host 跑) +│ ├── install-cron-backup.sh # 备份 cron 安装 (host 跑) +│ ├── install-cron.sh # 旧 host cron 安装 (停用) +│ └── ... (业务脚本) +├── public/ # 静态资源 (volume) +├── data/ # bot-characters.json (volume) +├── logs/ # 运行日志 (volume) +└── ... (源码) + +Docker 卷 (命名卷): + zhuiguang_ai_bot_data → /app/data + zhuiguang_ai_bot_public → /app/public + zhuiguang_ai_bot_logs → /app/logs + zhuiguang_ai_bot_prisma → /app/node_modules/.prisma +``` + +## 四、部署步骤 + +### 1. 准备 .env + +```bash +cd /home/ubuntu/zhuiguang-ai +mkdir -p env +cp .env env/.env.production +chmod 600 env/.env.production +# 编辑 env/.env.production 填入生产 DATABASE_URL / API_KEY +``` + +### 2. 构建镜像 + +```bash +cd /home/ubuntu/zhuiguang-ai +docker compose build +# 首次 5-10 分钟,之后改 code 1-3 分钟 +``` + +### 3. 启动 + +```bash +docker compose up -d +# 等 30s,让 prisma migrate deploy 跑完 +docker compose ps +docker compose logs -f --tail 50 zhuiguang-ai-app +``` + +### 4. 验证 + +```bash +# 1) APP 探活 +curl -I http://localhost:8301/api/health + +# 2) CRON 探活 +docker exec zhuiguang-ai-cron pgrep -f supercronic +docker exec zhuiguang-ai-cron supercronic-linux-amd64 -version + +# 3) 触发 1 个 cron 任务手动跑 +docker exec zhuiguang-ai-cron bash -c "set -a; . /app/.env; set +a; node /app/scripts/task1-discover-tools.mjs" + +# 4) 看实时日志 +docker compose logs -f zhuiguang-ai-cron +``` + +### 5. 安装 host 备份 cron + +```bash +bash scripts/install-cron-backup.sh +# 安装:每天 03:30 备份 4 个卷到 /home/ubuntu/zhuiguang-ai-backup/dockers/ +``` + +### 6. 旧 PM2 清理(验证通过后) + +```bash +# 1) 停 PM2 +pm2 stop zhuiguang-ai +# 2) 验证容器无问题 24h+ 后 +pm2 delete zhuiguang-ai +pm2 save + +# 3) 旧 host cron 清理(容器接管后失效) +crontab -l | grep -vE 'zhuiguang-ai|bot-activity' > /tmp/cron.new +crontab /tmp/cron.new + +# 4) 旧代码归档(保留 7 天可回滚) +cd /home/ubuntu +mv zhuiguang-ai zhuiguang-ai-old-$(date +%Y%m%d) +# 把 git 仓库拉回来(容器卷只是数据,源码在 git) +git clone zhuiguang-ai +``` + +## 五、运维命令速查 + +```bash +# 查看服务 +docker compose ps +docker compose logs -f zhuiguang-ai-app +docker compose logs -f zhuiguang-ai-cron + +# 资源占用 +docker stats zhuiguang-ai-app zhuiguang-ai-cron + +# 进入容器 +docker exec -it zhuiguang-ai-app sh +docker exec -it zhuiguang-ai-cron sh + +# 手动跑一个脚本 +docker exec zhuiguang-ai-cron bash -c "set -a; . /app/.env; set +a; node /app/scripts/bot-activity.mjs" + +# 重启单个服务 +docker compose restart zhuiguang-ai-app + +# 重建镜像 + 重启 +docker compose build --no-cache zhuiguang-ai-app +docker compose up -d zhuiguang-ai-app + +# 备份 +bash /home/ubuntu/zhuiguang-ai/scripts/backup-volumes.sh + +# 回滚到 PM2 +pm2 start zhuiguang-ai # 前提:旧代码还在 /home/ubuntu/zhuiguang-ai-old-*/ +``` + +## 六、回滚方案 + +任何步骤出问题,立即回滚: + +```bash +# 1) 停容器 +cd /home/ubuntu/zhuiguang-ai +docker compose down + +# 2) 切回旧代码 +cd /home/ubuntu +mv zhuiguang-ai zhuiguang-ai-failed-$(date +%Y%m%d) +mv zhuiguang-ai-old-YYYYMMDD zhuiguang-ai + +# 3) 启动 PM2 +cd /home/ubuntu/zhuiguang-ai +pm2 start zhuiguang-ai + +# 4) 确认线上恢复 +curl -I http://localhost:8301/api/health +``` + +## 七、升级流程 + +```bash +# 1) 拉新代码 +cd /home/ubuntu/zhuiguang-ai +git pull + +# 2) 如改了 schema 需先备份 +bash scripts/backup-volumes.sh + +# 3) 重新构建 +docker compose build + +# 4) 滚动重启(先 cron 再 app 减少任务失败) +docker compose up -d +# 或分步 +docker compose up -d zhuiguang-ai-cron +docker compose up -d zhuiguang-ai-app + +# 5) 验证 +curl -I http://localhost:8301/api/health +docker compose logs --tail 30 zhuiguang-ai-app +``` + +## 八、注意事项(项目铁律) + +1. **端口锁定 8301-8310**:本项目所有端口只能在这区间,容器也必须遵守 +2. **不动共享服务**:Redis/Nacos/MinIO 在宿主机 Docker,**绝不重启** +3. **不动邻居项目目录**:`/home/ubuntu/{zhuiguang-quant,zhenfang,snowy-fgt,agent-system}` 只读 +4. **不动全局 crontab**:用 `crontab -e` 追加,不要 `crontab -r` +5. **不动 nginx**:容器绑定 8301,nginx 反代规则不动 +6. **保留 PM2 镜像**:验证通过 24h 前不要 `pm2 delete`,出问题能秒切回 diff --git a/nginx-zhg-front.conf b/nginx-zhg-front.conf new file mode 100644 index 0000000..814c41d --- /dev/null +++ b/nginx-zhg-front.conf @@ -0,0 +1,55 @@ +# 追光量化系统 - 前端 Nginx 配置 +# 仅内部 HTTP(80),SSL 由 host Nginx 处理 + +upstream backend { + server backend:7001; + keepalive 32; +} + +server { + listen 80; + server_name lh.zhuig.com; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + expires 1y; + add_header Cache-Control "public, immutable"; + location = /index.html { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + } + + location /api { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + + location /health { + proxy_pass http://backend/api/v1/actuator/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + access_log off; + } + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + access_log /var/log/nginx/quant-system.access.log combined; + error_log /var/log/nginx/quant-system.error.log warn; + client_max_body_size 16M; +} diff --git a/package-lock.json b/package-lock.json index ac44806..ca47bc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "zhuiguang-ai", - "version": "0.1.0", + "version": "V2.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zhuiguang-ai", - "version": "0.1.0", + "version": "V2.1.2", "dependencies": { "@base-ui/react": "^1.4.1", + "@fontsource-variable/noto-sans-sc": "^5.2.10", "@prisma/adapter-mariadb": "^7.8.0", "@prisma/client": "^7.8.0", "@radix-ui/react-dialog": "^1.1.15", @@ -796,6 +797,15 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fontsource-variable/noto-sans-sc": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.2.10.tgz", + "integrity": "sha512-zdk10i5HrDQTXI7ldD61zToX1fsgig8vDTsu7zB48SXOitWfuX0e5viZAwnkHuhwh096PU6X6i1AyAsbBCISpA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", diff --git a/package.json b/package.json index 7694a2f..eea3771 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", + "@fontsource-variable/noto-sans-sc": "^5.2.10", "@prisma/adapter-mariadb": "^7.8.0", "@prisma/client": "^7.8.0", "@radix-ui/react-dialog": "^1.1.15", diff --git a/prisma/migrations/20260603000000_add_bot_memory_layers_and_stats/migration.sql b/prisma/migrations/20260603000000_add_bot_memory_layers_and_stats/migration.sql new file mode 100644 index 0000000..aed0248 --- /dev/null +++ b/prisma/migrations/20260603000000_add_bot_memory_layers_and_stats/migration.sql @@ -0,0 +1,28 @@ +-- AlterTable +ALTER TABLE `bot_memories` + ADD COLUMN `layer` VARCHAR(20) NOT NULL DEFAULT 'LONGTERM', + ADD COLUMN `context_tags` JSON NULL, + ADD COLUMN `ttl` DATETIME(3) NULL; + +-- CreateIndex +CREATE INDEX `bot_memories_bot_id_layer_importance_idx` ON `bot_memories`(`bot_id`, `layer`, `importance`); + +-- CreateTable +CREATE TABLE `bot_daily_stats` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `bot_id` INTEGER NOT NULL, + `date` DATE NOT NULL, + `topic_created` INTEGER NOT NULL DEFAULT 0, + `reply_sent` INTEGER NOT NULL DEFAULT 0, + `follow_up_sent` INTEGER NOT NULL DEFAULT 0, + `replies_received` INTEGER NOT NULL DEFAULT 0, + `likes_received` INTEGER NOT NULL DEFAULT 0, + `human_interactions` INTEGER NOT NULL DEFAULT 0, + `feedback_processed` INTEGER NOT NULL DEFAULT 0, + `top_topic_id` INTEGER NULL, + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `bot_daily_stats_bot_id_date_key`(`bot_id`, `date`), + INDEX `bot_daily_stats_date_idx`(`date`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/migrations/20260603010000_add_bot_skills_and_weekly_reviews/migration.sql b/prisma/migrations/20260603010000_add_bot_skills_and_weekly_reviews/migration.sql new file mode 100644 index 0000000..a1b7aa5 --- /dev/null +++ b/prisma/migrations/20260603010000_add_bot_skills_and_weekly_reviews/migration.sql @@ -0,0 +1,44 @@ +-- CreateTable +CREATE TABLE `bot_skills` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `bot_id` INTEGER NOT NULL, + `name` VARCHAR(50) NOT NULL, + `category` VARCHAR(30) NOT NULL, + `hook_pattern` TEXT NOT NULL, + `body_template` TEXT NOT NULL, + `applicability` TEXT NULL, + `source_topic_ids` JSON NOT NULL, + `success_rate` DOUBLE NOT NULL DEFAULT 0, + `usage_count` INTEGER NOT NULL DEFAULT 0, + `avg_replies` DOUBLE NOT NULL DEFAULT 0, + `avg_likes` DOUBLE NOT NULL DEFAULT 0, + `version` INTEGER NOT NULL DEFAULT 1, + `is_active` BOOLEAN NOT NULL DEFAULT true, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `bot_skills_bot_id_category_is_active_idx`(`bot_id`, `category`, `is_active`), + INDEX `bot_skills_bot_id_success_rate_idx`(`bot_id`, `success_rate`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `bot_weekly_reviews` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `bot_id` INTEGER NOT NULL, + `week_start` DATE NOT NULL, + `week_end` DATE NOT NULL, + `topics_created` INTEGER NOT NULL DEFAULT 0, + `replies_sent` INTEGER NOT NULL DEFAULT 0, + `replies_received` INTEGER NOT NULL DEFAULT 0, + `human_ratio` DOUBLE NOT NULL DEFAULT 0, + `likes_received` INTEGER NOT NULL DEFAULT 0, + `engagement_score` DOUBLE NOT NULL DEFAULT 0, + `next_week_quota` VARCHAR(20) NOT NULL, + `reflection` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `bot_weekly_reviews_bot_id_week_start_key`(`bot_id`, `week_start`), + INDEX `bot_weekly_reviews_bot_id_week_start_idx`(`bot_id`, `week_start`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/migrations/20260603020000_add_bot_persona_and_affinity/migration.sql b/prisma/migrations/20260603020000_add_bot_persona_and_affinity/migration.sql new file mode 100644 index 0000000..76d67ee --- /dev/null +++ b/prisma/migrations/20260603020000_add_bot_persona_and_affinity/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE `bot_personas` ( + `bot_id` INTEGER NOT NULL, + `avg_reply_length` INTEGER NOT NULL DEFAULT 0, + `question_ratio` DOUBLE NOT NULL DEFAULT 0, + `exclamation_count` INTEGER NOT NULL DEFAULT 0, + `avg_topic_length` INTEGER NOT NULL DEFAULT 0, + `stance_keywords` JSON NULL, + `top_human_users` JSON NULL, + `top_topic_types` JSON NULL, + `last_topic_titles` JSON NULL, + `post_sample_count` INTEGER NOT NULL DEFAULT 0, + `updated_at` DATETIME(3) NOT NULL, + + PRIMARY KEY (`bot_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `bot_cross_forum_affinities` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `bot_id` INTEGER NOT NULL, + `forum_slug` VARCHAR(30) NOT NULL, + `affinity` DOUBLE NOT NULL DEFAULT 0, + `sample_count` INTEGER NOT NULL DEFAULT 0, + `reason` VARCHAR(100) NULL, + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `bot_cross_forum_affinities_bot_id_forum_slug_key`(`bot_id`, `forum_slug`), + INDEX `bot_cross_forum_affinities_bot_id_affinity_idx`(`bot_id`, `affinity`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/migrations/20260603030000_forum_enhancements/migration.sql b/prisma/migrations/20260603030000_forum_enhancements/migration.sql new file mode 100644 index 0000000..66addf7 --- /dev/null +++ b/prisma/migrations/20260603030000_forum_enhancements/migration.sql @@ -0,0 +1,106 @@ +-- 论坛增强:点赞/标签/关注/草稿 +-- 2026-06-03 + +-- AlterTable +ALTER TABLE `forum_categories` ADD COLUMN `follower_count` INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE `forum_topics` ADD COLUMN `is_featured` BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN `like_count` INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE `forum_posts` ADD COLUMN `like_count` INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE `forum_topic_likes` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `topic_id` INTEGER NOT NULL, + `user_id` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `forum_topic_likes_user_id_idx`(`user_id`), + UNIQUE INDEX `forum_topic_likes_topic_id_user_id_key`(`topic_id`, `user_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `forum_post_likes` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `post_id` INTEGER NOT NULL, + `user_id` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `forum_post_likes_user_id_idx`(`user_id`), + UNIQUE INDEX `forum_post_likes_post_id_user_id_key`(`post_id`, `user_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `forum_category_follows` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `category_id` INTEGER NOT NULL, + `user_id` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `forum_category_follows_user_id_idx`(`user_id`), + UNIQUE INDEX `forum_category_follows_category_id_user_id_key`(`category_id`, `user_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `forum_tags` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(20) NOT NULL, + `slug` VARCHAR(30) NOT NULL, + `topic_count` INTEGER NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `forum_tags_name_key`(`name`), + UNIQUE INDEX `forum_tags_slug_key`(`slug`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `forum_topic_tags` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `topic_id` INTEGER NOT NULL, + `tag_id` INTEGER NOT NULL, + + INDEX `forum_topic_tags_tag_id_idx`(`tag_id`), + UNIQUE INDEX `forum_topic_tags_topic_id_tag_id_key`(`topic_id`, `tag_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `forum_drafts` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `category_id` INTEGER NULL, + `title` VARCHAR(100) NULL, + `content` TEXT NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `forum_drafts_user_id_updated_at_idx`(`user_id`, `updated_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `forum_topic_likes` ADD CONSTRAINT `forum_topic_likes_topic_id_fkey` FOREIGN KEY (`topic_id`) REFERENCES `forum_topics`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `forum_topic_likes` ADD CONSTRAINT `forum_topic_likes_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `forum_post_likes` ADD CONSTRAINT `forum_post_likes_post_id_fkey` FOREIGN KEY (`post_id`) REFERENCES `forum_posts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `forum_post_likes` ADD CONSTRAINT `forum_post_likes_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `forum_category_follows` ADD CONSTRAINT `forum_category_follows_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `forum_categories`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `forum_category_follows` ADD CONSTRAINT `forum_category_follows_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `forum_topic_tags` ADD CONSTRAINT `forum_topic_tags_topic_id_fkey` FOREIGN KEY (`topic_id`) REFERENCES `forum_topics`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `forum_topic_tags` ADD CONSTRAINT `forum_topic_tags_tag_id_fkey` FOREIGN KEY (`tag_id`) REFERENCES `forum_tags`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `forum_drafts` ADD CONSTRAINT `forum_drafts_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX `forum_topics_category_id_is_pinned_created_at_idx` ON `forum_topics`(`category_id`, `is_pinned`, `created_at`); +CREATE INDEX `forum_topics_is_featured_last_reply_at_idx` ON `forum_topics`(`is_featured`, `last_reply_at`); +CREATE INDEX `forum_posts_user_id_idx` ON `forum_posts`(`user_id`); diff --git a/prisma/migrations/20260604000000_forum_phase3/migration.sql b/prisma/migrations/20260604000000_forum_phase3/migration.sql new file mode 100644 index 0000000..0f07eb5 --- /dev/null +++ b/prisma/migrations/20260604000000_forum_phase3/migration.sql @@ -0,0 +1,53 @@ +-- 论坛第三阶段增强:通知/收藏/举报/嵌套回复 +-- 2026-06-04 + +-- AlterTable: forum_topics 加 is_hidden 字段 +ALTER TABLE `forum_topics` ADD COLUMN `is_hidden` BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE `forum_topics` ADD INDEX `forum_topics_is_hidden_idx`(`is_hidden`); + +-- AlterTable: forum_posts 加 reply_to_id / reply_to_snapshot +ALTER TABLE `forum_posts` ADD COLUMN `reply_to_id` INTEGER NULL; +ALTER TABLE `forum_posts` ADD COLUMN `reply_to_snapshot` VARCHAR(300) NULL; +ALTER TABLE `forum_posts` ADD CONSTRAINT `forum_posts_reply_to_id_fkey` FOREIGN KEY (`reply_to_id`) REFERENCES `forum_posts`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE `forum_posts` ADD INDEX `forum_posts_reply_to_id_idx`(`reply_to_id`); + +-- AlterTable: forum_topics 加联合全文索引 +ALTER TABLE `forum_topics` ADD FULLTEXT INDEX `forum_topics_title_content_idx`(`title`, `content`); + +-- CreateTable: 收藏 +CREATE TABLE `forum_topic_bookmarks` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `topic_id` INTEGER NOT NULL, + `user_id` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `forum_topic_bookmarks_topic_id_user_id_key`(`topic_id`, `user_id`), + INDEX `forum_topic_bookmarks_user_id_created_at_idx`(`user_id`, `created_at`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `forum_topic_bookmarks` ADD CONSTRAINT `forum_topic_bookmarks_topic_id_fkey` FOREIGN KEY (`topic_id`) REFERENCES `forum_topics`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `forum_topic_bookmarks` ADD CONSTRAINT `forum_topic_bookmarks_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable: 举报 +CREATE TABLE `forum_reports` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `reporter_id` INTEGER NOT NULL, + `topic_id` INTEGER NULL, + `post_id` INTEGER NULL, + `reason` VARCHAR(50) NOT NULL, + `detail` VARCHAR(500) NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'pending', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `resolved_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + INDEX `forum_reports_status_created_at_idx`(`status`, `created_at`), + INDEX `forum_reports_reporter_id_idx`(`reporter_id`), + INDEX `forum_reports_topic_id_idx`(`topic_id`), + INDEX `forum_reports_post_id_idx`(`post_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `forum_reports` ADD CONSTRAINT `forum_reports_reporter_id_fkey` FOREIGN KEY (`reporter_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `forum_reports` ADD CONSTRAINT `forum_reports_topic_id_fkey` FOREIGN KEY (`topic_id`) REFERENCES `forum_topics`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable: notifications 增加新枚举值 +ALTER TABLE `notifications` MODIFY COLUMN `type` ENUM('COMMENT_REPLY','COMMENT_LIKE','TOPIC_REPLY','MENTION','BEST_ANSWER','POST_LIKE','TOPIC_LIKE','SYSTEM') NOT NULL; diff --git a/prisma/migrations/20260605000000_forum_phase4/migration.sql b/prisma/migrations/20260605000000_forum_phase4/migration.sql new file mode 100644 index 0000000..75a799d --- /dev/null +++ b/prisma/migrations/20260605000000_forum_phase4/migration.sql @@ -0,0 +1,44 @@ +-- 论坛第四阶段增强:签到、举报处理、隐藏回复、审核日志 +-- 2026-06-05 + +-- AlterTable: forum_posts 增加 is_hidden 字段 +ALTER TABLE `forum_posts` ADD COLUMN `is_hidden` BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE `forum_posts` ADD INDEX `forum_posts_is_hidden_idx`(`is_hidden`); + +-- AlterTable: forum_reports 增加 resolution/resolvedBy +ALTER TABLE `forum_reports` ADD COLUMN `resolution` VARCHAR(20) NULL; +ALTER TABLE `forum_reports` ADD COLUMN `resolved_by` INTEGER NULL; +ALTER TABLE `forum_reports` ADD CONSTRAINT `forum_reports_resolved_by_fkey` FOREIGN KEY (`resolved_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE `forum_reports` ADD INDEX `forum_reports_resolved_by_idx`(`resolved_by`); + +-- CreateTable: 每日签到 +CREATE TABLE `forum_check_ins` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `check_in_date` DATE NOT NULL, + `streak` INTEGER NOT NULL DEFAULT 1, + `points_earned` INTEGER NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `forum_check_ins_user_id_check_in_date_key`(`user_id`, `check_in_date`), + INDEX `forum_check_ins_user_id_check_in_date_idx`(`user_id`, `check_in_date`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `forum_check_ins` ADD CONSTRAINT `forum_check_ins_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable: 审核日志 +CREATE TABLE `forum_moderation_logs` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `actor_id` INTEGER NOT NULL, + `target_type` VARCHAR(20) NOT NULL, + `target_id` INTEGER NOT NULL, + `action` VARCHAR(30) NOT NULL, + `reason` VARCHAR(200) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + INDEX `forum_moderation_logs_actor_id_created_at_idx`(`actor_id`, `created_at`), + INDEX `forum_moderation_logs_target_type_target_id_idx`(`target_type`, `target_id`), + INDEX `forum_moderation_logs_created_at_idx`(`created_at`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `forum_moderation_logs` ADD CONSTRAINT `forum_moderation_logs_actor_id_fkey` FOREIGN KEY (`actor_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260605010000_forum_phase5/migration.sql b/prisma/migrations/20260605010000_forum_phase5/migration.sql new file mode 100644 index 0000000..c47f7e7 --- /dev/null +++ b/prisma/migrations/20260605010000_forum_phase5/migration.sql @@ -0,0 +1,42 @@ +-- Forum Phase 5: Achievement system + topic subscriptions + +CREATE TABLE `achievements` ( + `key` VARCHAR(50) NOT NULL, + `name` VARCHAR(50) NOT NULL, + `description` VARCHAR(200) NOT NULL, + `icon` VARCHAR(20) NOT NULL, + `tier` VARCHAR(20) NOT NULL DEFAULT 'bronze', + `points` INT NOT NULL DEFAULT 0, + `category` VARCHAR(30) NOT NULL DEFAULT 'general', + `sort_order` INT NOT NULL DEFAULT 0, + `is_active` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`key`), + INDEX `achievements_category_is_active_sort_order_idx`(`category`, `is_active`, `sort_order`) +); + +CREATE TABLE `user_achievements` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `achievement_key` VARCHAR(50) NOT NULL, + `progress` INT NOT NULL DEFAULT 0, + `unlocked_at` DATETIME(3) NULL, + `notified_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `user_achievements_user_id_achievement_key_key`(`user_id`, `achievement_key`), + INDEX `user_achievements_user_id_unlocked_at_idx`(`user_id`, `unlocked_at`), + INDEX `user_achievements_achievement_key_idx`(`achievement_key`) +); + +CREATE TABLE `forum_topic_subscriptions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `topic_id` INT NOT NULL, + `user_id` INT NOT NULL, + `notify_reply` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `forum_topic_subscriptions_topic_id_user_id_key`(`topic_id`, `user_id`), + INDEX `forum_topic_subscriptions_user_id_created_at_idx`(`user_id`, `created_at`) +); diff --git a/prisma/migrations/20260607000000_bot_persona_ab_test/migration.sql b/prisma/migrations/20260607000000_bot_persona_ab_test/migration.sql new file mode 100644 index 0000000..e0e3218 --- /dev/null +++ b/prisma/migrations/20260607000000_bot_persona_ab_test/migration.sql @@ -0,0 +1,87 @@ +-- Bot Persona A/B Testing +-- Adds: bot_persona_variants, bot_persona_experiments, bot_persona_assignments, bot_persona_metrics + +CREATE TABLE `bot_persona_variants` ( + `id` INT NOT NULL AUTO_INCREMENT, + `bot_id` INT NOT NULL, + `experiment_id` INT NULL, + `variant_key` VARCHAR(20) NOT NULL, + `label` VARCHAR(50) NOT NULL, + `description` VARCHAR(200) NULL, + `stance_keywords` JSON NULL, + `top_topic_types` JSON NULL, + `top_human_users` JSON NULL, + `style_hints` JSON NULL, + `weight` FLOAT NOT NULL DEFAULT 0.5, + `is_control` BOOLEAN NOT NULL DEFAULT FALSE, + `is_active` BOOLEAN NOT NULL DEFAULT TRUE, + `sample_count` INT NOT NULL DEFAULT 0, + `reply_count` INT NOT NULL DEFAULT 0, + `like_count` INT NOT NULL DEFAULT 0, + `human_reply_count` INT NOT NULL DEFAULT 0, + `engagement_score` FLOAT NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `bot_persona_variants_bot_id_variant_key_key`(`bot_id`, `variant_key`), + INDEX `bot_persona_variants_bot_id_is_active_idx`(`bot_id`, `is_active`), + INDEX `bot_persona_variants_experiment_id_idx`(`experiment_id`) +); + +CREATE TABLE `bot_persona_experiments` ( + `id` INT NOT NULL AUTO_INCREMENT, + `bot_id` INT NOT NULL, + `name` VARCHAR(80) NOT NULL, + `description` VARCHAR(200) NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'active', + `winner_variant_key` VARCHAR(20) NULL, + `min_samples_per_arm` INT NOT NULL DEFAULT 20, + `significance_level` FLOAT NOT NULL DEFAULT 0.1, + `primary_metric` VARCHAR(30) NOT NULL DEFAULT 'engagement_score', + `config` JSON NULL, + `started_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `ended_at` DATETIME(3) NULL, + `last_analyzed_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + INDEX `bot_persona_experiments_bot_id_status_idx`(`bot_id`, `status`), + INDEX `bot_persona_experiments_status_idx`(`status`) +); + +CREATE TABLE `bot_persona_assignments` ( + `id` INT NOT NULL AUTO_INCREMENT, + `bot_id` INT NOT NULL, + `variant_id` INT NOT NULL, + `ref_type` VARCHAR(20) NOT NULL, + `ref_id` INT NOT NULL, + `forum_slug` VARCHAR(30) NULL, + `replies` INT NOT NULL DEFAULT 0, + `likes` INT NOT NULL DEFAULT 0, + `human_replies` INT NOT NULL DEFAULT 0, + `impressions` INT NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `bot_persona_assignments_ref_type_ref_id_key`(`ref_type`, `ref_id`), + INDEX `bot_persona_assignments_bot_id_variant_id_idx`(`bot_id`, `variant_id`), + INDEX `bot_persona_assignments_variant_id_created_at_idx`(`variant_id`, `created_at`) +); + +CREATE TABLE `bot_persona_metrics` ( + `id` INT NOT NULL AUTO_INCREMENT, + `variant_id` INT NOT NULL, + `date` DATE NOT NULL, + `impressions` INT NOT NULL DEFAULT 0, + `replies` INT NOT NULL DEFAULT 0, + `likes` INT NOT NULL DEFAULT 0, + `human_replies` INT NOT NULL DEFAULT 0, + `content_count` INT NOT NULL DEFAULT 0, + `avg_replies` FLOAT NOT NULL DEFAULT 0, + `avg_likes` FLOAT NOT NULL DEFAULT 0, + `computed_score` FLOAT NOT NULL DEFAULT 0, + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `bot_persona_metrics_variant_id_date_key`(`variant_id`, `date`), + INDEX `bot_persona_metrics_date_idx`(`date`) +); diff --git a/prisma/migrations/20260607010000_bot_adversarial_learning/migration.sql b/prisma/migrations/20260607010000_bot_adversarial_learning/migration.sql new file mode 100644 index 0000000..1421a6c --- /dev/null +++ b/prisma/migrations/20260607010000_bot_adversarial_learning/migration.sql @@ -0,0 +1,30 @@ +-- 数字人 vs 真人"对抗学习" +-- 每周为每个 bot 选 1 个真实高赞真人帖,让 bot 吸收其"为什么高互动"的洞察 + +CREATE TABLE `bot_adversarial_learnings` ( + `id` INT NOT NULL AUTO_INCREMENT, + `bot_id` INT NOT NULL, + `week_key` VARCHAR(20) NOT NULL, + `source_ref_type` VARCHAR(20) NOT NULL, + `source_ref_id` INT NOT NULL, + `source_user_id` INT NOT NULL, + `source_user_name` VARCHAR(50) NULL, + `forum_slug` VARCHAR(30) NULL, + `source_title` VARCHAR(200) NULL, + `source_content` TEXT NOT NULL, + `source_metrics` JSON NOT NULL, + `relevance_score` DOUBLE NOT NULL DEFAULT 0, + `learned_insight` TEXT NOT NULL, + `learn_categories` JSON NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'active', + `used_count` INT NOT NULL DEFAULT 0, + `learned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `expires_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `bot_adversarial_learnings_bot_id_week_key_key`(`bot_id`, `week_key`), + INDEX `bot_adversarial_learnings_bot_id_status_idx`(`bot_id`, `status`), + INDEX `bot_adversarial_learnings_week_key_idx`(`week_key`), + INDEX `bot_adversarial_learnings_source_ref_type_source_ref_id_idx`(`source_ref_type`, `source_ref_id`) +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9acfd9d..53f6dfa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,10 +16,10 @@ model Category { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - tools Tool[] + tools Tool[] pendingTools PendingTool[] - parent Category? @relation("CategoryToCategory", fields: [parentId], references: [id]) - children Category[] @relation("CategoryToCategory") + parent Category? @relation("CategoryToCategory", fields: [parentId], references: [id]) + children Category[] @relation("CategoryToCategory") @@map("categories") } @@ -34,42 +34,42 @@ model SkillCategory { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - skills Skill[] + skills Skill[] pendingSkills PendingSkill[] - parent SkillCategory? @relation("SkillCategoryToSkillCategory", fields: [parentId], references: [id]) - children SkillCategory[] @relation("SkillCategoryToSkillCategory") + parent SkillCategory? @relation("SkillCategoryToSkillCategory", fields: [parentId], references: [id]) + children SkillCategory[] @relation("SkillCategoryToSkillCategory") @@map("skill_categories") } model Tool { - id Int @id @default(autoincrement()) - name String @db.VarChar(100) - slug String @unique @db.VarChar(100) - categoryId Int @map("category_id") - description String @db.Text - logoUrl String? @map("logo_url") @db.VarChar(255) - websiteUrl String @map("website_url") @db.VarChar(255) - pricingModel PricingModel @default(freemium) @map("pricing_model") - pricingDetail String? @map("pricing_detail") @db.VarChar(200) - features Json? - tags Json? - viewCount Int @default(0) @map("view_count") - commentCount Int @default(0) @map("comment_count") - avgRating Float @default(0) @map("avg_rating") - status Status @default(draft) - lastCheckedAt DateTime? @map("last_checked_at") - websiteOk Boolean @default(true) @map("website_ok") - logoOk Boolean @default(true) @map("logo_ok") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + name String @db.VarChar(100) + slug String @unique @db.VarChar(100) + categoryId Int @map("category_id") + description String @db.Text + logoUrl String? @map("logo_url") @db.VarChar(255) + websiteUrl String @map("website_url") @db.VarChar(255) + pricingModel PricingModel @default(freemium) @map("pricing_model") + pricingDetail String? @map("pricing_detail") @db.VarChar(200) + features Json? + tags Json? + viewCount Int @default(0) @map("view_count") + commentCount Int @default(0) @map("comment_count") + avgRating Float @default(0) @map("avg_rating") + status Status @default(draft) + lastCheckedAt DateTime? @map("last_checked_at") + websiteOk Boolean @default(true) @map("website_ok") + logoOk Boolean @default(true) @map("logo_ok") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - category Category @relation(fields: [categoryId], references: [id]) - favorites Favorite[] + category Category @relation(fields: [categoryId], references: [id]) + favorites Favorite[] browsingHistory BrowsingHistory[] - reviews Review[] + reviews Review[] collectionItems CollectionItem[] - comments Comment[] + comments Comment[] @@index([categoryId, status]) @@fulltext([name, description]) @@ -77,87 +77,87 @@ model Tool { } model PendingTool { - id Int @id @default(autoincrement()) - name String @db.VarChar(100) - slug String? @db.VarChar(100) - categoryId Int? @map("category_id") - description String? @db.Text - logoUrl String? @map("logo_url") @db.VarChar(255) - websiteUrl String? @map("website_url") @db.VarChar(255) - pricingModel PricingModel? @default(freemium) @map("pricing_model") - pricingDetail String? @map("pricing_detail") @db.VarChar(200) - features Json? - tags Json? - sourceUrl String? @map("source_url") @db.VarChar(255) - status PendingStatus @default(pending) - checkWebsiteOk Boolean? @map("check_website_ok") - checkLogoOk Boolean? @map("check_logo_ok") - checkDescOk Boolean? @map("check_desc_ok") - checkCategoryOk Boolean? @map("check_category_ok") - checkDetail Json? @map("check_detail") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + name String @db.VarChar(100) + slug String? @db.VarChar(100) + categoryId Int? @map("category_id") + description String? @db.Text + logoUrl String? @map("logo_url") @db.VarChar(255) + websiteUrl String? @map("website_url") @db.VarChar(255) + pricingModel PricingModel? @default(freemium) @map("pricing_model") + pricingDetail String? @map("pricing_detail") @db.VarChar(200) + features Json? + tags Json? + sourceUrl String? @map("source_url") @db.VarChar(255) + status PendingStatus @default(pending) + checkWebsiteOk Boolean? @map("check_website_ok") + checkLogoOk Boolean? @map("check_logo_ok") + checkDescOk Boolean? @map("check_desc_ok") + checkCategoryOk Boolean? @map("check_category_ok") + checkDetail Json? @map("check_detail") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - category Category? @relation(fields: [categoryId], references: [id]) + category Category? @relation(fields: [categoryId], references: [id]) @@map("pending_tools") } model Skill { - id Int @id @default(autoincrement()) - name String @db.VarChar(100) - slug String @unique @db.VarChar(100) - categoryId Int @map("category_id") - description String @db.Text - logoUrl String? @map("logo_url") @db.VarChar(255) - sourceUrl String @map("source_url") @db.VarChar(255) - sourceType String @default("gitee") @map("source_type") @db.VarChar(20) - rating Float @default(0) @db.Float - features Json? - tags Json? - viewCount Int @default(0) @map("view_count") - status Status @default(draft) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + name String @db.VarChar(100) + slug String @unique @db.VarChar(100) + categoryId Int @map("category_id") + description String @db.Text + logoUrl String? @map("logo_url") @db.VarChar(255) + sourceUrl String @map("source_url") @db.VarChar(255) + sourceType String @default("gitee") @map("source_type") @db.VarChar(20) + rating Float @default(0) @db.Float + features Json? + tags Json? + viewCount Int @default(0) @map("view_count") + status Status @default(draft) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - category SkillCategory @relation(fields: [categoryId], references: [id]) - favorites Favorite[] + category SkillCategory @relation(fields: [categoryId], references: [id]) + favorites Favorite[] browsingHistory BrowsingHistory[] - reviews Review[] + reviews Review[] collectionItems CollectionItem[] - skillReview SkillReview? - comments Comment[] + skillReview SkillReview? + comments Comment[] - lastReviewedAt DateTime? @map("last_reviewed_at") - githubStarsHistory Json? @map("github_stars_history") - githubStars Int @default(0) @map("github_stars") - starsChangeDay Int @default(0) @map("stars_change_day") - starsChangeWeek Int @default(0) @map("stars_change_week") - starsChangeMonth Int @default(0) @map("stars_change_month") - commentCount Int @default(0) @map("comment_count") - avgRating Float @default(0) @map("avg_rating") + lastReviewedAt DateTime? @map("last_reviewed_at") + githubStarsHistory Json? @map("github_stars_history") + githubStars Int @default(0) @map("github_stars") + starsChangeDay Int @default(0) @map("stars_change_day") + starsChangeWeek Int @default(0) @map("stars_change_week") + starsChangeMonth Int @default(0) @map("stars_change_month") + commentCount Int @default(0) @map("comment_count") + avgRating Float @default(0) @map("avg_rating") @@index([categoryId, status]) @@map("skills") } model PendingSkill { - id Int @id @default(autoincrement()) - name String @db.VarChar(100) - slug String? @db.VarChar(100) - categoryId Int? @map("category_id") - description String? @db.Text - logoUrl String? @map("logo_url") @db.VarChar(255) - sourceUrl String? @map("source_url") @db.VarChar(255) - sourceType String? @map("source_type") @db.VarChar(20) - rating Float @default(0) @db.Float - features Json? - tags Json? - status PendingStatus @default(pending) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + name String @db.VarChar(100) + slug String? @db.VarChar(100) + categoryId Int? @map("category_id") + description String? @db.Text + logoUrl String? @map("logo_url") @db.VarChar(255) + sourceUrl String? @map("source_url") @db.VarChar(255) + sourceType String? @map("source_type") @db.VarChar(20) + rating Float @default(0) @db.Float + features Json? + tags Json? + status PendingStatus @default(pending) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - category SkillCategory? @relation(fields: [categoryId], references: [id]) + category SkillCategory? @relation(fields: [categoryId], references: [id]) @@map("pending_skills") } @@ -184,51 +184,64 @@ model SystemConfig { id Int @id @default(autoincrement()) key String @unique @db.VarChar(50) value Json + @@map("system_configs") } model User { - id Int @id @default(autoincrement()) - email String @unique @db.VarChar(255) - passwordHash String? @db.VarChar(255) - name String? @db.VarChar(100) - avatarUrl String? - bio String? @db.Text - oauthProvider String? @db.VarChar(50) - oauthId String? @unique @db.VarChar(255) - role String @default("user") @db.VarChar(20) - 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") + id Int @id @default(autoincrement()) + email String @unique @db.VarChar(255) + passwordHash String? @db.VarChar(255) + name String? @db.VarChar(100) + avatarUrl String? + bio String? @db.Text + oauthProvider String? @db.VarChar(50) + oauthId String? @unique @db.VarChar(255) + role String @default("user") @db.VarChar(20) + 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[] + reviews Review[] + collections Collection[] + comments Comment[] + commentLikes CommentLike[] + notifications Notification[] + forumTopics ForumTopic[] + forumPosts ForumPost[] + pointsHistory PointsHistory[] + topicLikes ForumTopicLike[] + postLikes ForumPostLike[] + categoryFollows ForumCategoryFollow[] + topicBookmarks ForumTopicBookmark[] + topicDrafts ForumDraft[] + reports ForumReport[] + resolvedReports ForumReport[] @relation("ForumReportResolver") + checkIns ForumCheckIn[] + moderationLogs ForumModerationLog[] @relation("ForumModerationActor") + achievements UserAchievement[] + topicSubscriptions ForumTopicSubscription[] - botConfig BotConfig? - assignedRole Role? @relation(fields: [roleId], references: [id]) - favorites Favorite[] - browsingHistory BrowsingHistory[] - reviews Review[] - collections Collection[] - comments Comment[] - commentLikes CommentLike[] - notifications Notification[] - forumTopics ForumTopic[] - forumPosts ForumPost[] - pointsHistory PointsHistory[] @@unique([oauthProvider, oauthId]) @@map("users") } model PointsHistory { - id Int @id @default(autoincrement()) - userId Int @map("user_id") - points Int - reason String @db.VarChar(50) - detail String? @db.VarChar(200) - refType String? @map("ref_type") @db.VarChar(50) - refId Int? @map("ref_id") - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + userId Int @map("user_id") + points Int + reason String @db.VarChar(50) + detail String? @db.VarChar(200) + refType String? @map("ref_type") @db.VarChar(50) + refId Int? @map("ref_id") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -316,11 +329,11 @@ model Favorite { } model BrowsingHistory { - id Int @id @default(autoincrement()) - userId Int @map("user_id") - toolId Int? @map("tool_id") - skillId Int? @map("skill_id") - viewedAt DateTime @default(now()) @updatedAt @map("viewed_at") + id Int @id @default(autoincrement()) + userId Int @map("user_id") + toolId Int? @map("tool_id") + skillId Int? @map("skill_id") + viewedAt DateTime @default(now()) @updatedAt @map("viewed_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) tool Tool? @relation(fields: [toolId], references: [id], onDelete: Cascade) @@ -386,17 +399,17 @@ model CollectionItem { } model DailyReport { - id Int @id @default(autoincrement()) - date DateTime @unique @db.Date - title String @db.VarChar(200) - summary String? @db.Text - hotCount Int @default(0) @map("hot_count") - coverUrl String? @map("cover_url") @db.VarChar(255) - status Status @default(draft) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + date DateTime @unique @db.Date + title String @db.VarChar(200) + summary String? @db.Text + hotCount Int @default(0) @map("hot_count") + coverUrl String? @map("cover_url") @db.VarChar(255) + status Status @default(draft) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - items DailyReportItem[] + items DailyReportItem[] @@index([date]) @@index([status]) @@ -404,45 +417,45 @@ model DailyReport { } model DailyReportItem { - id Int @id @default(autoincrement()) - reportId Int @map("report_id") - sortOrder Int @default(0) @map("sort_order") - category String @default("tech") @db.VarChar(20) - title String @db.VarChar(200) - summary String? @db.Text - detail String? @db.Text - sourceUrl String? @map("source_url") @db.VarChar(500) - imageUrl String? @map("image_url") @db.VarChar(255) - hotScore Int @default(0) @map("hot_score") - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + reportId Int @map("report_id") + sortOrder Int @default(0) @map("sort_order") + category String @default("tech") @db.VarChar(20) + title String @db.VarChar(200) + summary String? @db.Text + detail String? @db.Text + sourceUrl String? @map("source_url") @db.VarChar(500) + imageUrl String? @map("image_url") @db.VarChar(255) + hotScore Int @default(0) @map("hot_score") + createdAt DateTime @default(now()) @map("created_at") - report DailyReport @relation(fields: [reportId], references: [id], onDelete: Cascade) + report DailyReport @relation(fields: [reportId], references: [id], onDelete: Cascade) @@index([reportId, sortOrder]) @@map("daily_report_items") } model SkillReview { - id Int @id @default(autoincrement()) - skillId Int @unique @map("skill_id") - skill Skill @relation(fields: [skillId], references: [id]) + id Int @id @default(autoincrement()) + skillId Int @unique @map("skill_id") + skill Skill @relation(fields: [skillId], references: [id]) capability Float - devExp Float @map("dev_exp") - costLicense Float @map("cost_license") + devExp Float @map("dev_exp") + costLicense Float @map("cost_license") community Float performance Float overall Float - reviews Json + reviews Json - longArticle String? @map("long_article") @db.Text - videoScript String? @map("video_script") @db.Text - socialCards Json? @map("social_cards") + longArticle String? @map("long_article") @db.Text + videoScript String? @map("video_script") @db.Text + socialCards Json? @map("social_cards") - status ReviewStatus @default(DRAFT) - reviewedAt DateTime @updatedAt @map("reviewed_at") - createdAt DateTime @default(now()) @map("created_at") + status ReviewStatus @default(DRAFT) + reviewedAt DateTime @updatedAt @map("reviewed_at") + createdAt DateTime @default(now()) @map("created_at") @@index([skillId]) @@index([overall]) @@ -470,17 +483,17 @@ enum ReviewStatus { } model TaskLog { - id Int @id @default(autoincrement()) - taskKey String @map("task_key") @db.VarChar(50) - taskName String @map("task_name") @db.VarChar(100) - status String @db.VarChar(20) - startedAt DateTime @map("started_at") - finishedAt DateTime? @map("finished_at") - duration Int? - result Json? - error String? @db.Text - triggerBy String @default("cron") @map("trigger_by") @db.VarChar(20) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + taskKey String @map("task_key") @db.VarChar(50) + taskName String @map("task_name") @db.VarChar(100) + status String @db.VarChar(20) + startedAt DateTime @map("started_at") + finishedAt DateTime? @map("finished_at") + duration Int? + result Json? + error String? @db.Text + triggerBy String @default("cron") @map("trigger_by") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") @@index([taskKey, startedAt]) @@map("task_logs") @@ -513,12 +526,12 @@ model Comment { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - user User @relation(fields: [userId], references: [id]) - tool Tool? @relation(fields: [toolId], references: [id], onDelete: Cascade) - skill Skill? @relation(fields: [skillId], references: [id], onDelete: Cascade) - parent Comment? @relation("CommentReplies", fields: [parentId], references: [id]) - replies Comment[] @relation("CommentReplies") - likes CommentLike[] + user User @relation(fields: [userId], references: [id]) + tool Tool? @relation(fields: [toolId], references: [id], onDelete: Cascade) + skill Skill? @relation(fields: [skillId], references: [id], onDelete: Cascade) + parent Comment? @relation("CommentReplies", fields: [parentId], references: [id]) + replies Comment[] @relation("CommentReplies") + likes CommentLike[] @@index([toolId, createdAt]) @@index([skillId, createdAt]) @@ -546,14 +559,14 @@ model CommentLike { } model Notification { - id Int @id @default(autoincrement()) - userId Int @map("user_id") + id Int @id @default(autoincrement()) + userId Int @map("user_id") type NotifType - title String @db.VarChar(100) - content String? @db.VarChar(255) - link String? @db.VarChar(255) - isRead Boolean @default(false) @map("is_read") - createdAt DateTime @default(now()) @map("created_at") + title String @db.VarChar(100) + content String? @db.VarChar(255) + link String? @db.VarChar(255) + isRead Boolean @default(false) @map("is_read") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -565,23 +578,29 @@ enum NotifType { COMMENT_REPLY COMMENT_LIKE TOPIC_REPLY + MENTION + BEST_ANSWER + POST_LIKE + TOPIC_LIKE SYSTEM } model ForumCategory { - id Int @id @default(autoincrement()) - name String @db.VarChar(30) - slug String @unique @db.VarChar(30) - description String? @db.VarChar(100) - icon String? @db.VarChar(30) - parentId Int? @map("parent_id") - sortOrder Int @default(0) @map("sort_order") - topicCount Int @default(0) @map("topic_count") - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + name String @db.VarChar(30) + slug String @unique @db.VarChar(30) + description String? @db.VarChar(100) + icon String? @db.VarChar(30) + parentId Int? @map("parent_id") + sortOrder Int @default(0) @map("sort_order") + topicCount Int @default(0) @map("topic_count") + followerCount Int @default(0) @map("follower_count") + createdAt DateTime @default(now()) @map("created_at") - parent ForumCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id]) - children ForumCategory[] @relation("CategoryHierarchy") - topics ForumTopic[] + parent ForumCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id]) + children ForumCategory[] @relation("CategoryHierarchy") + topics ForumTopic[] + followers ForumCategoryFollow[] @@map("forum_categories") } @@ -595,36 +614,211 @@ model ForumTopic { content String @db.Text isPinned Boolean @default(false) @map("is_pinned") isLocked Boolean @default(false) @map("is_locked") + isFeatured Boolean @default(false) @map("is_featured") + isHidden Boolean @default(false) @map("is_hidden") viewCount Int @default(0) @map("view_count") replyCount Int @default(0) @map("reply_count") + likeCount Int @default(0) @map("like_count") lastReplyAt DateTime? @map("last_reply_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - category ForumCategory @relation(fields: [categoryId], references: [id]) - user User @relation(fields: [userId], references: [id]) - posts ForumPost[] + category ForumCategory @relation(fields: [categoryId], references: [id]) + user User @relation(fields: [userId], references: [id]) + posts ForumPost[] + likes ForumTopicLike[] + tags ForumTopicTag[] + bookmarks ForumTopicBookmark[] + reports ForumReport[] + subscribers ForumTopicSubscription[] @@index([categoryId, lastReplyAt]) + @@index([categoryId, isPinned, createdAt]) @@index([userId]) + @@index([isFeatured, lastReplyAt]) + @@index([isHidden]) @@fulltext([title]) + @@fulltext([title, content]) @@map("forum_topics") } model ForumPost { + id Int @id @default(autoincrement()) + topicId Int @map("topic_id") + userId Int @map("user_id") + content String @db.Text + isAnswer Boolean @default(false) @map("is_answer") + isHidden Boolean @default(false) @map("is_hidden") + likeCount Int @default(0) @map("like_count") + replyToId Int? @map("reply_to_id") + replyToSnapshot String? @map("reply_to_snapshot") @db.VarChar(300) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + topic ForumTopic @relation(fields: [topicId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + likes ForumPostLike[] + replyTo ForumPost? @relation("PostReplies", fields: [replyToId], references: [id], onDelete: SetNull) + replies ForumPost[] @relation("PostReplies") + + @@index([topicId, createdAt]) + @@index([userId]) + @@index([replyToId]) + @@index([isHidden]) + @@map("forum_posts") +} + +model ForumTopicLike { id Int @id @default(autoincrement()) topicId Int @map("topic_id") userId Int @map("user_id") - content String @db.Text - isAnswer Boolean @default(false) @map("is_answer") createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") topic ForumTopic @relation(fields: [topicId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@index([topicId, createdAt]) - @@map("forum_posts") + @@unique([topicId, userId]) + @@index([userId]) + @@map("forum_topic_likes") +} + +model ForumPostLike { + id Int @id @default(autoincrement()) + postId Int @map("post_id") + userId Int @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + post ForumPost @relation(fields: [postId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([postId, userId]) + @@index([userId]) + @@map("forum_post_likes") +} + +model ForumCategoryFollow { + id Int @id @default(autoincrement()) + categoryId Int @map("category_id") + userId Int @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + category ForumCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([categoryId, userId]) + @@index([userId]) + @@map("forum_category_follows") +} + +model ForumTag { + id Int @id @default(autoincrement()) + name String @unique @db.VarChar(20) + slug String @unique @db.VarChar(30) + topicCount Int @default(0) @map("topic_count") + createdAt DateTime @default(now()) @map("created_at") + + topics ForumTopicTag[] + + @@map("forum_tags") +} + +model ForumTopicTag { + id Int @id @default(autoincrement()) + topicId Int @map("topic_id") + tagId Int @map("tag_id") + + topic ForumTopic @relation(fields: [topicId], references: [id], onDelete: Cascade) + tag ForumTag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@unique([topicId, tagId]) + @@index([tagId]) + @@map("forum_topic_tags") +} + +model ForumDraft { + id Int @id @default(autoincrement()) + userId Int @map("user_id") + categoryId Int? @map("category_id") + title String? @db.VarChar(100) + content String @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, updatedAt]) + @@map("forum_drafts") +} + +model ForumTopicBookmark { + id Int @id @default(autoincrement()) + topicId Int @map("topic_id") + userId Int @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + topic ForumTopic @relation(fields: [topicId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([topicId, userId]) + @@index([userId, createdAt]) + @@map("forum_topic_bookmarks") +} + +model ForumReport { + id Int @id @default(autoincrement()) + reporterId Int @map("reporter_id") + topicId Int? @map("topic_id") + postId Int? @map("post_id") + reason String @db.VarChar(50) + detail String? @db.VarChar(500) + status String @default("pending") @db.VarChar(20) + resolution String? @db.VarChar(20) + resolvedBy Int? @map("resolved_by") + resolvedAt DateTime? @map("resolved_at") + createdAt DateTime @default(now()) @map("created_at") + + reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) + topic ForumTopic? @relation(fields: [topicId], references: [id], onDelete: Cascade) + resolver User? @relation("ForumReportResolver", fields: [resolvedBy], references: [id], onDelete: SetNull) + + @@index([status, createdAt]) + @@index([reporterId]) + @@index([topicId]) + @@index([postId]) + @@index([resolvedBy]) + @@map("forum_reports") +} + +model ForumCheckIn { + id Int @id @default(autoincrement()) + userId Int @map("user_id") + checkInDate DateTime @map("check_in_date") @db.Date + streak Int @default(1) + pointsEarned Int @default(0) @map("points_earned") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, checkInDate]) + @@index([userId, checkInDate]) + @@map("forum_check_ins") +} + +model ForumModerationLog { + id Int @id @default(autoincrement()) + actorId Int @map("actor_id") + targetType String @map("target_type") @db.VarChar(20) + targetId Int @map("target_id") + action String @db.VarChar(30) + reason String? @db.VarChar(200) + createdAt DateTime @default(now()) @map("created_at") + + actor User @relation("ForumModerationActor", fields: [actorId], references: [id], onDelete: Cascade) + + @@index([actorId, createdAt]) + @@index([targetType, targetId]) + @@index([createdAt]) + @@map("forum_moderation_logs") } model BotConfig { @@ -637,23 +831,304 @@ model BotConfig { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - user User @relation(fields: [userId], references: [id]) - memories BotMemory[] + user User @relation(fields: [userId], references: [id]) + memories BotMemory[] + botPersonaVariants BotPersonaVariant[] @relation("BotVariants") + adversarialLearnings BotAdversarialLearning[] @relation("BotAdversarialLearnings") @@map("bot_configs") } model BotMemory { - id Int @id @default(autoincrement()) - botId Int @map("bot_id") - memoryType String @map("memory_type") @db.VarChar(20) + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + memoryType String @map("memory_type") @db.VarChar(20) + layer String @default("LONGTERM") @map("layer") @db.VarChar(20) content Json - importance Float @default(0.5) - createdAt DateTime @default(now()) @map("created_at") + importance Float @default(0.5) + contextTags Json? @map("context_tags") + ttl DateTime? @map("ttl") + createdAt DateTime @default(now()) @map("created_at") bot BotConfig @relation(fields: [botId], references: [id]) + @@index([botId, layer, importance]) @@index([botId, memoryType, createdAt]) + @@index([botId, createdAt]) @@map("bot_memories") } +model BotDailyStat { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + date DateTime @db.Date + topicCreated Int @default(0) @map("topic_created") + replySent Int @default(0) @map("reply_sent") + followUpSent Int @default(0) @map("follow_up_sent") + repliesReceived Int @default(0) @map("replies_received") + likesReceived Int @default(0) @map("likes_received") + humanInteractions Int @default(0) @map("human_interactions") + feedbackProcessed Int @default(0) @map("feedback_processed") + topTopicId Int? @map("top_topic_id") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([botId, date]) + @@index([date]) + @@map("bot_daily_stats") +} + +model BotSkill { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + name String @db.VarChar(50) + category String @db.VarChar(30) + hookPattern String @map("hook_pattern") @db.Text + bodyTemplate String @map("body_template") @db.Text + applicability String? @db.Text + sourceTopicIds Json @map("source_topic_ids") + successRate Float @default(0) @map("success_rate") + usageCount Int @default(0) @map("usage_count") + avgReplies Float @default(0) @map("avg_replies") + avgLikes Float @default(0) @map("avg_likes") + version Int @default(1) + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([botId, category, isActive]) + @@index([botId, successRate]) + @@map("bot_skills") +} + +model BotWeeklyReview { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + weekStart DateTime @map("week_start") @db.Date + weekEnd DateTime @map("week_end") @db.Date + topicsCreated Int @default(0) @map("topics_created") + repliesSent Int @default(0) @map("replies_sent") + repliesReceived Int @default(0) @map("replies_received") + humanRatio Float @default(0) @map("human_ratio") + likesReceived Int @default(0) @map("likes_received") + engagementScore Float @default(0) @map("engagement_score") + nextWeekQuota String @map("next_week_quota") @db.VarChar(20) + reflection String? @db.Text + createdAt DateTime @default(now()) @map("created_at") + + @@unique([botId, weekStart]) + @@index([botId, weekStart]) + @@map("bot_weekly_reviews") +} + +model BotPersona { + botId Int @id @map("bot_id") + avgReplyLength Int @default(0) @map("avg_reply_length") + questionRatio Float @default(0) @map("question_ratio") + exclamationCount Int @default(0) @map("exclamation_count") + avgTopicLength Int @default(0) @map("avg_topic_length") + stanceKeywords Json? @map("stance_keywords") + topHumanUsers Json? @map("top_human_users") + topTopicTypes Json? @map("top_topic_types") + lastTopicTitles Json? @map("last_topic_titles") + postSampleCount Int @default(0) @map("post_sample_count") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("bot_personas") +} + +model BotPersonaVariant { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + experimentId Int? @map("experiment_id") + variantKey String @map("variant_key") @db.VarChar(20) + label String @db.VarChar(50) + description String? @db.VarChar(200) + stanceKeywords Json? @map("stance_keywords") + topTopicTypes Json? @map("top_topic_types") + topHumanUsers Json? @map("top_human_users") + styleHints Json? @map("style_hints") + weight Float @default(0.5) + isControl Boolean @default(false) @map("is_control") + isActive Boolean @default(true) @map("is_active") + sampleCount Int @default(0) @map("sample_count") + replyCount Int @default(0) @map("reply_count") + likeCount Int @default(0) @map("like_count") + humanReplyCount Int @default(0) @map("human_reply_count") + engagementScore Float @default(0) @map("engagement_score") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + botConfig BotConfig @relation("BotVariants", fields: [botId], references: [id], onDelete: Cascade) + experiment BotPersonaExperiment? @relation("ExperimentVariants", fields: [experimentId], references: [id], onDelete: SetNull) + assignments BotPersonaAssignment[] + metrics BotPersonaMetric[] + + @@unique([botId, variantKey]) + @@index([botId, isActive]) + @@index([experimentId]) + @@map("bot_persona_variants") +} + +model BotPersonaExperiment { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + name String @db.VarChar(80) + description String? @db.VarChar(200) + status String @default("active") @db.VarChar(20) + winnerVariantKey String? @map("winner_variant_key") @db.VarChar(20) + minSamplesPerArm Int @default(20) @map("min_samples_per_arm") + significanceLevel Float @default(0.1) @map("significance_level") + primaryMetric String @default("engagement_score") @map("primary_metric") @db.VarChar(30) + config Json? + startedAt DateTime @default(now()) @map("started_at") + endedAt DateTime? @map("ended_at") + lastAnalyzedAt DateTime? @map("last_analyzed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + variants BotPersonaVariant[] @relation("ExperimentVariants") + + @@index([botId, status]) + @@index([status]) + @@map("bot_persona_experiments") +} + +model BotPersonaAssignment { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + variantId Int @map("variant_id") + refType String @map("ref_type") @db.VarChar(20) + refId Int @map("ref_id") + forumSlug String? @map("forum_slug") @db.VarChar(30) + replies Int @default(0) + likes Int @default(0) + humanReplies Int @default(0) @map("human_replies") + impressions Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + variant BotPersonaVariant @relation(fields: [variantId], references: [id], onDelete: Cascade) + + @@unique([refType, refId]) + @@index([botId, variantId]) + @@index([variantId, createdAt]) + @@map("bot_persona_assignments") +} + +model BotPersonaMetric { + id Int @id @default(autoincrement()) + variantId Int @map("variant_id") + date DateTime @db.Date + impressions Int @default(0) + replies Int @default(0) + likes Int @default(0) + humanReplies Int @default(0) @map("human_replies") + contentCount Int @default(0) @map("content_count") + avgReplies Float @default(0) @map("avg_replies") + avgLikes Float @default(0) @map("avg_likes") + computedScore Float @default(0) @map("computed_score") + updatedAt DateTime @updatedAt @map("updated_at") + + variant BotPersonaVariant @relation(fields: [variantId], references: [id], onDelete: Cascade) + + @@unique([variantId, date]) + @@index([date]) + @@map("bot_persona_metrics") +} + +model BotAdversarialLearning { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + weekKey String @map("week_key") @db.VarChar(20) + sourceRefType String @map("source_ref_type") @db.VarChar(20) + sourceRefId Int @map("source_ref_id") + sourceUserId Int @map("source_user_id") + sourceUserName String? @map("source_user_name") @db.VarChar(50) + forumSlug String? @map("forum_slug") @db.VarChar(30) + sourceTitle String? @map("source_title") @db.VarChar(200) + sourceContent String @map("source_content") @db.Text + sourceMetrics Json @map("source_metrics") + relevanceScore Float @default(0) @map("relevance_score") + learnedInsight String @map("learned_insight") @db.Text + learnCategories Json? @map("learn_categories") + status String @default("active") @db.VarChar(20) + usedCount Int @default(0) @map("used_count") + learnedAt DateTime @default(now()) @map("learned_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + botConfig BotConfig @relation("BotAdversarialLearnings", fields: [botId], references: [id], onDelete: Cascade) + + @@unique([botId, weekKey]) + @@index([botId, status]) + @@index([weekKey]) + @@index([sourceRefType, sourceRefId]) + @@map("bot_adversarial_learnings") +} + +model BotCrossForumAffinity { + id Int @id @default(autoincrement()) + botId Int @map("bot_id") + forumSlug String @map("forum_slug") @db.VarChar(30) + affinity Float @default(0) + sampleCount Int @default(0) @map("sample_count") + reason String? @db.VarChar(100) + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([botId, forumSlug]) + @@index([botId, affinity]) + @@map("bot_cross_forum_affinities") +} + +model Achievement { + key String @id @db.VarChar(50) + name String @db.VarChar(50) + description String @db.VarChar(200) + icon String @db.VarChar(20) + tier String @default("bronze") @db.VarChar(20) + points Int @default(0) + category String @default("general") @db.VarChar(30) + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + + users UserAchievement[] + + @@index([category, isActive, sortOrder]) + @@map("achievements") +} + +model UserAchievement { + id Int @id @default(autoincrement()) + userId Int @map("user_id") + achievementKey String @map("achievement_key") @db.VarChar(50) + progress Int @default(0) + unlockedAt DateTime? @map("unlocked_at") + notifiedAt DateTime? @map("notified_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + achievement Achievement @relation(fields: [achievementKey], references: [key]) + + @@unique([userId, achievementKey]) + @@index([userId, unlockedAt]) + @@index([achievementKey]) + @@map("user_achievements") +} + +model ForumTopicSubscription { + id Int @id @default(autoincrement()) + topicId Int @map("topic_id") + userId Int @map("user_id") + notifyReply Boolean @default(true) @map("notify_reply") + createdAt DateTime @default(now()) @map("created_at") + + topic ForumTopic @relation(fields: [topicId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([topicId, userId]) + @@index([userId, createdAt]) + @@map("forum_topic_subscriptions") +} diff --git a/public/bot-avatars/ai_critic.svg b/public/bot-avatars/ai_critic.svg new file mode 100644 index 0000000..8c8fadc --- /dev/null +++ b/public/bot-avatars/ai_critic.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + A + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ai_dev.svg b/public/bot-avatars/ai_dev.svg new file mode 100644 index 0000000..f9fdfcf --- /dev/null +++ b/public/bot-avatars/ai_dev.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 老 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ai_investor.svg b/public/bot-avatars/ai_investor.svg new file mode 100644 index 0000000..ee5bf5a --- /dev/null +++ b/public/bot-avatars/ai_investor.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 早 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ai_news.svg b/public/bot-avatars/ai_news.svg new file mode 100644 index 0000000..fd63241 --- /dev/null +++ b/public/bot-avatars/ai_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 科 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ai_observer.svg b/public/bot-avatars/ai_observer.svg new file mode 100644 index 0000000..11d1738 --- /dev/null +++ b/public/bot-avatars/ai_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 技 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ai_pm.svg b/public/bot-avatars/ai_pm.svg new file mode 100644 index 0000000..37473ee --- /dev/null +++ b/public/bot-avatars/ai_pm.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + A + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ai_problem.svg b/public/bot-avatars/ai_problem.svg new file mode 100644 index 0000000..9883deb --- /dev/null +++ b/public/bot-avatars/ai_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 转 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ai_student.svg b/public/bot-avatars/ai_student.svg new file mode 100644 index 0000000..2554e5c --- /dev/null +++ b/public/bot-avatars/ai_student.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 计 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ajie.svg b/public/bot-avatars/ajie.svg new file mode 100644 index 0000000..bfc484f --- /dev/null +++ b/public/bot-avatars/ajie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 阿 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/atie.svg b/public/bot-avatars/atie.svg new file mode 100644 index 0000000..111c2b0 --- /dev/null +++ b/public/bot-avatars/atie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 阿 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/awei.svg b/public/bot-avatars/awei.svg new file mode 100644 index 0000000..fcc1de4 --- /dev/null +++ b/public/bot-avatars/awei.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 阿 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/cb_amazon.svg b/public/bot-avatars/cb_amazon.svg new file mode 100644 index 0000000..aa38af2 --- /dev/null +++ b/public/bot-avatars/cb_amazon.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 亚 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/cb_expat.svg b/public/bot-avatars/cb_expat.svg new file mode 100644 index 0000000..68ad2d9 --- /dev/null +++ b/public/bot-avatars/cb_expat.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 海 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/cb_news.svg b/public/bot-avatars/cb_news.svg new file mode 100644 index 0000000..5277fb5 --- /dev/null +++ b/public/bot-avatars/cb_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 跨 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/cb_observer.svg b/public/bot-avatars/cb_observer.svg new file mode 100644 index 0000000..b270154 --- /dev/null +++ b/public/bot-avatars/cb_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 全 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/cb_problem.svg b/public/bot-avatars/cb_problem.svg new file mode 100644 index 0000000..fc7dd64 --- /dev/null +++ b/public/bot-avatars/cb_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 出 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/cb_thinker.svg b/public/bot-avatars/cb_thinker.svg new file mode 100644 index 0000000..d0ba9fb --- /dev/null +++ b/public/bot-avatars/cb_thinker.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 出 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/chengjie.svg b/public/bot-avatars/chengjie.svg new file mode 100644 index 0000000..7729037 --- /dev/null +++ b/public/bot-avatars/chengjie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 程 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ct_creator.svg b/public/bot-avatars/ct_creator.svg new file mode 100644 index 0000000..09a501c --- /dev/null +++ b/public/bot-avatars/ct_creator.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 百 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ct_news.svg b/public/bot-avatars/ct_news.svg new file mode 100644 index 0000000..85bff16 --- /dev/null +++ b/public/bot-avatars/ct_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 内 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ct_observer.svg b/public/bot-avatars/ct_observer.svg new file mode 100644 index 0000000..f9d663b --- /dev/null +++ b/public/bot-avatars/ct_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 内 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ct_podcaster.svg b/public/bot-avatars/ct_podcaster.svg new file mode 100644 index 0000000..667252f --- /dev/null +++ b/public/bot-avatars/ct_podcaster.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 播 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ct_problem.svg b/public/bot-avatars/ct_problem.svg new file mode 100644 index 0000000..c8ebc83 --- /dev/null +++ b/public/bot-avatars/ct_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 创 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ct_viewer.svg b/public/bot-avatars/ct_viewer.svg new file mode 100644 index 0000000..2aaff43 --- /dev/null +++ b/public/bot-avatars/ct_viewer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 重 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ct_writer.svg b/public/bot-avatars/ct_writer.svg new file mode 100644 index 0000000..d01b5a7 --- /dev/null +++ b/public/bot-avatars/ct_writer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 写 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/daliu.svg b/public/bot-avatars/daliu.svg new file mode 100644 index 0000000..7f6603b --- /dev/null +++ b/public/bot-avatars/daliu.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 大 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/duoduo.svg b/public/bot-avatars/duoduo.svg new file mode 100644 index 0000000..00b777b --- /dev/null +++ b/public/bot-avatars/duoduo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 多 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ec_consumer.svg b/public/bot-avatars/ec_consumer.svg new file mode 100644 index 0000000..eb02a9f --- /dev/null +++ b/public/bot-avatars/ec_consumer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 资 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ec_data_nerd.svg b/public/bot-avatars/ec_data_nerd.svg new file mode 100644 index 0000000..0bba5e8 --- /dev/null +++ b/public/bot-avatars/ec_data_nerd.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 数 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ec_employee.svg b/public/bot-avatars/ec_employee.svg new file mode 100644 index 0000000..cf7abbf --- /dev/null +++ b/public/bot-avatars/ec_employee.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 平 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ec_news.svg b/public/bot-avatars/ec_news.svg new file mode 100644 index 0000000..7b6685f --- /dev/null +++ b/public/bot-avatars/ec_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 电 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ec_observer.svg b/public/bot-avatars/ec_observer.svg new file mode 100644 index 0000000..11aa325 --- /dev/null +++ b/public/bot-avatars/ec_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 消 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ec_problem.svg b/public/bot-avatars/ec_problem.svg new file mode 100644 index 0000000..4af3f3a --- /dev/null +++ b/public/bot-avatars/ec_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 创 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ec_quitter.svg b/public/bot-avatars/ec_quitter.svg new file mode 100644 index 0000000..6d472de --- /dev/null +++ b/public/bot-avatars/ec_quitter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 退 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ec_veteran.svg b/public/bot-avatars/ec_veteran.svg new file mode 100644 index 0000000..7b992ca --- /dev/null +++ b/public/bot-avatars/ec_veteran.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 电 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/edu_abroad_alumni.svg b/public/bot-avatars/edu_abroad_alumni.svg new file mode 100644 index 0000000..395ef86 --- /dev/null +++ b/public/bot-avatars/edu_abroad_alumni.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 海 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/edu_abroad_problem.svg b/public/bot-avatars/edu_abroad_problem.svg new file mode 100644 index 0000000..70ef754 --- /dev/null +++ b/public/bot-avatars/edu_abroad_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 留 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/edu_anxious_parent.svg b/public/bot-avatars/edu_anxious_parent.svg new file mode 100644 index 0000000..8716d76 --- /dev/null +++ b/public/bot-avatars/edu_anxious_parent.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 鸡 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/edu_news.svg b/public/bot-avatars/edu_news.svg new file mode 100644 index 0000000..45b75dc --- /dev/null +++ b/public/bot-avatars/edu_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 教 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/edu_observer.svg b/public/bot-avatars/edu_observer.svg new file mode 100644 index 0000000..f5a11db --- /dev/null +++ b/public/bot-avatars/edu_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 家 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/edu_problem.svg b/public/bot-avatars/edu_problem.svg new file mode 100644 index 0000000..03f5f32 --- /dev/null +++ b/public/bot-avatars/edu_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 职 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/edu_worker.svg b/public/bot-avatars/edu_worker.svg new file mode 100644 index 0000000..9d740e8 --- /dev/null +++ b/public/bot-avatars/edu_worker.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 职 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fangchan.svg b/public/bot-avatars/fangchan.svg new file mode 100644 index 0000000..dbd3641 --- /dev/null +++ b/public/bot-avatars/fangchan.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 房 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fd_diner.svg b/public/bot-avatars/fd_diner.svg new file mode 100644 index 0000000..9659667 --- /dev/null +++ b/public/bot-avatars/fd_diner.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 资 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fd_news.svg b/public/bot-avatars/fd_news.svg new file mode 100644 index 0000000..c47a0cc --- /dev/null +++ b/public/bot-avatars/fd_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 美 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fd_observer.svg b/public/bot-avatars/fd_observer.svg new file mode 100644 index 0000000..8548b40 --- /dev/null +++ b/public/bot-avatars/fd_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 餐 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fd_problem.svg b/public/bot-avatars/fd_problem.svg new file mode 100644 index 0000000..9ec89c8 --- /dev/null +++ b/public/bot-avatars/fd_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 想 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fd_supply_news.svg b/public/bot-avatars/fd_supply_news.svg new file mode 100644 index 0000000..4bac45a --- /dev/null +++ b/public/bot-avatars/fd_supply_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 供 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fd_worker.svg b/public/bot-avatars/fd_worker.svg new file mode 100644 index 0000000..8d4a798 --- /dev/null +++ b/public/bot-avatars/fd_worker.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 餐 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fengkuang.svg b/public/bot-avatars/fengkuang.svg new file mode 100644 index 0000000..73b320c --- /dev/null +++ b/public/bot-avatars/fengkuang.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 疯 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fi_industry.svg b/public/bot-avatars/fi_industry.svg new file mode 100644 index 0000000..8955188 --- /dev/null +++ b/public/bot-avatars/fi_industry.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 金 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fi_investor.svg b/public/bot-avatars/fi_investor.svg new file mode 100644 index 0000000..e148a9c --- /dev/null +++ b/public/bot-avatars/fi_investor.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 个 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fi_loser.svg b/public/bot-avatars/fi_loser.svg new file mode 100644 index 0000000..17d982d --- /dev/null +++ b/public/bot-avatars/fi_loser.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 亏 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fi_newbie.svg b/public/bot-avatars/fi_newbie.svg new file mode 100644 index 0000000..f7e2448 --- /dev/null +++ b/public/bot-avatars/fi_newbie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 理 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fi_news.svg b/public/bot-avatars/fi_news.svg new file mode 100644 index 0000000..1d04eaa --- /dev/null +++ b/public/bot-avatars/fi_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 财 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fi_observer.svg b/public/bot-avatars/fi_observer.svg new file mode 100644 index 0000000..2025e19 --- /dev/null +++ b/public/bot-avatars/fi_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 宏 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/fi_problem.svg b/public/bot-avatars/fi_problem.svg new file mode 100644 index 0000000..bf046c1 --- /dev/null +++ b/public/bot-avatars/fi_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 散 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/helaoshi.svg b/public/bot-avatars/helaoshi.svg new file mode 100644 index 0000000..34ce9da --- /dev/null +++ b/public/bot-avatars/helaoshi.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 何 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/hl_elderly.svg b/public/bot-avatars/hl_elderly.svg new file mode 100644 index 0000000..d00d5f0 --- /dev/null +++ b/public/bot-avatars/hl_elderly.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 上 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/hl_news.svg b/public/bot-avatars/hl_news.svg new file mode 100644 index 0000000..c2c867f --- /dev/null +++ b/public/bot-avatars/hl_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 健 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/hl_observer.svg b/public/bot-avatars/hl_observer.svg new file mode 100644 index 0000000..2b21eab --- /dev/null +++ b/public/bot-avatars/hl_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 健 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/hl_problem.svg b/public/bot-avatars/hl_problem.svg new file mode 100644 index 0000000..c5306ca --- /dev/null +++ b/public/bot-avatars/hl_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 医 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/hl_young.svg b/public/bot-avatars/hl_young.svg new file mode 100644 index 0000000..746ee7a --- /dev/null +++ b/public/bot-avatars/hl_young.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 朋 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/huajie.svg b/public/bot-avatars/huajie.svg new file mode 100644 index 0000000..9b6053b --- /dev/null +++ b/public/bot-avatars/huajie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 画 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/hw_observer.svg b/public/bot-avatars/hw_observer.svg new file mode 100644 index 0000000..dacc326 --- /dev/null +++ b/public/bot-avatars/hw_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 硬 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/jiangnan.svg b/public/bot-avatars/jiangnan.svg new file mode 100644 index 0000000..891342e --- /dev/null +++ b/public/bot-avatars/jiangnan.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 江 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/jiejie.svg b/public/bot-avatars/jiejie.svg new file mode 100644 index 0000000..c234f16 --- /dev/null +++ b/public/bot-avatars/jiejie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 洁 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/jige.svg b/public/bot-avatars/jige.svg new file mode 100644 index 0000000..6ce6990 --- /dev/null +++ b/public/bot-avatars/jige.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 机 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/jike.svg b/public/bot-avatars/jike.svg new file mode 100644 index 0000000..0b52eb1 --- /dev/null +++ b/public/bot-avatars/jike.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 极 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/kaige.svg b/public/bot-avatars/kaige.svg new file mode 100644 index 0000000..3a123ec --- /dev/null +++ b/public/bot-avatars/kaige.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 凯 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/kangshifu.svg b/public/bot-avatars/kangshifu.svg new file mode 100644 index 0000000..f50bfd2 --- /dev/null +++ b/public/bot-avatars/kangshifu.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 康 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/laochen.svg b/public/bot-avatars/laochen.svg new file mode 100644 index 0000000..ef5dfb2 --- /dev/null +++ b/public/bot-avatars/laochen.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 老 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/laolin.svg b/public/bot-avatars/laolin.svg new file mode 100644 index 0000000..ec8973c --- /dev/null +++ b/public/bot-avatars/laolin.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 老 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/laowang.svg b/public/bot-avatars/laowang.svg new file mode 100644 index 0000000..6fb0f95 --- /dev/null +++ b/public/bot-avatars/laowang.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 老 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/laozhang.svg b/public/bot-avatars/laozhang.svg new file mode 100644 index 0000000..1ca15b3 --- /dev/null +++ b/public/bot-avatars/laozhang.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 老 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/laozhao.svg b/public/bot-avatars/laozhao.svg new file mode 100644 index 0000000..680b85b --- /dev/null +++ b/public/bot-avatars/laozhao.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 老 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/lily.svg b/public/bot-avatars/lily.svg new file mode 100644 index 0000000..4715b44 --- /dev/null +++ b/public/bot-avatars/lily.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 莉 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/lisa.svg b/public/bot-avatars/lisa.svg new file mode 100644 index 0000000..792acae --- /dev/null +++ b/public/bot-avatars/lisa.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + L + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/liuqing.svg b/public/bot-avatars/liuqing.svg new file mode 100644 index 0000000..d316ca3 --- /dev/null +++ b/public/bot-avatars/liuqing.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 柳 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ll_news.svg b/public/bot-avatars/ll_news.svg new file mode 100644 index 0000000..4864a3e --- /dev/null +++ b/public/bot-avatars/ll_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 本 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ll_observer.svg b/public/bot-avatars/ll_observer.svg new file mode 100644 index 0000000..dafae07 --- /dev/null +++ b/public/bot-avatars/ll_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 社 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ll_parent.svg b/public/bot-avatars/ll_parent.svg new file mode 100644 index 0000000..f8de84e --- /dev/null +++ b/public/bot-avatars/ll_parent.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 家 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ll_pet.svg b/public/bot-avatars/ll_pet.svg new file mode 100644 index 0000000..af19f1f --- /dev/null +++ b/public/bot-avatars/ll_pet.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 铲 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/ll_problem.svg b/public/bot-avatars/ll_problem.svg new file mode 100644 index 0000000..2d6bae8 --- /dev/null +++ b/public/bot-avatars/ll_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 社 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/lunaris/ai_critic.jpg b/public/bot-avatars/lunaris/ai_critic.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ai_critic.jpg differ diff --git a/public/bot-avatars/lunaris/ai_dev.jpg b/public/bot-avatars/lunaris/ai_dev.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ai_dev.jpg differ diff --git a/public/bot-avatars/lunaris/ai_investor.jpg b/public/bot-avatars/lunaris/ai_investor.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ai_investor.jpg differ diff --git a/public/bot-avatars/lunaris/ai_news.jpg b/public/bot-avatars/lunaris/ai_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ai_news.jpg differ diff --git a/public/bot-avatars/lunaris/ai_observer.jpg b/public/bot-avatars/lunaris/ai_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ai_observer.jpg differ diff --git a/public/bot-avatars/lunaris/ai_pm.jpg b/public/bot-avatars/lunaris/ai_pm.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ai_pm.jpg differ diff --git a/public/bot-avatars/lunaris/ai_problem.jpg b/public/bot-avatars/lunaris/ai_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ai_problem.jpg differ diff --git a/public/bot-avatars/lunaris/ai_student.jpg b/public/bot-avatars/lunaris/ai_student.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ai_student.jpg differ diff --git a/public/bot-avatars/lunaris/ajie.jpg b/public/bot-avatars/lunaris/ajie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ajie.jpg differ diff --git a/public/bot-avatars/lunaris/atie.jpg b/public/bot-avatars/lunaris/atie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/atie.jpg differ diff --git a/public/bot-avatars/lunaris/awei.jpg b/public/bot-avatars/lunaris/awei.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/awei.jpg differ diff --git a/public/bot-avatars/lunaris/cb_amazon.jpg b/public/bot-avatars/lunaris/cb_amazon.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/cb_amazon.jpg differ diff --git a/public/bot-avatars/lunaris/cb_expat.jpg b/public/bot-avatars/lunaris/cb_expat.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/cb_expat.jpg differ diff --git a/public/bot-avatars/lunaris/cb_news.jpg b/public/bot-avatars/lunaris/cb_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/cb_news.jpg differ diff --git a/public/bot-avatars/lunaris/cb_observer.jpg b/public/bot-avatars/lunaris/cb_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/cb_observer.jpg differ diff --git a/public/bot-avatars/lunaris/cb_problem.jpg b/public/bot-avatars/lunaris/cb_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/cb_problem.jpg differ diff --git a/public/bot-avatars/lunaris/cb_thinker.jpg b/public/bot-avatars/lunaris/cb_thinker.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/cb_thinker.jpg differ diff --git a/public/bot-avatars/lunaris/chengjie.jpg b/public/bot-avatars/lunaris/chengjie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/chengjie.jpg differ diff --git a/public/bot-avatars/lunaris/ct_creator.jpg b/public/bot-avatars/lunaris/ct_creator.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ct_creator.jpg differ diff --git a/public/bot-avatars/lunaris/ct_news.jpg b/public/bot-avatars/lunaris/ct_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ct_news.jpg differ diff --git a/public/bot-avatars/lunaris/ct_observer.jpg b/public/bot-avatars/lunaris/ct_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ct_observer.jpg differ diff --git a/public/bot-avatars/lunaris/ct_podcaster.jpg b/public/bot-avatars/lunaris/ct_podcaster.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ct_podcaster.jpg differ diff --git a/public/bot-avatars/lunaris/ct_problem.jpg b/public/bot-avatars/lunaris/ct_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ct_problem.jpg differ diff --git a/public/bot-avatars/lunaris/ct_viewer.jpg b/public/bot-avatars/lunaris/ct_viewer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ct_viewer.jpg differ diff --git a/public/bot-avatars/lunaris/ct_writer.jpg b/public/bot-avatars/lunaris/ct_writer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ct_writer.jpg differ diff --git a/public/bot-avatars/lunaris/daliu.jpg b/public/bot-avatars/lunaris/daliu.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/daliu.jpg differ diff --git a/public/bot-avatars/lunaris/duoduo.jpg b/public/bot-avatars/lunaris/duoduo.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/duoduo.jpg differ diff --git a/public/bot-avatars/lunaris/ec_consumer.jpg b/public/bot-avatars/lunaris/ec_consumer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ec_consumer.jpg differ diff --git a/public/bot-avatars/lunaris/ec_data_nerd.jpg b/public/bot-avatars/lunaris/ec_data_nerd.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ec_data_nerd.jpg differ diff --git a/public/bot-avatars/lunaris/ec_employee.jpg b/public/bot-avatars/lunaris/ec_employee.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ec_employee.jpg differ diff --git a/public/bot-avatars/lunaris/ec_news.jpg b/public/bot-avatars/lunaris/ec_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ec_news.jpg differ diff --git a/public/bot-avatars/lunaris/ec_observer.jpg b/public/bot-avatars/lunaris/ec_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ec_observer.jpg differ diff --git a/public/bot-avatars/lunaris/ec_problem.jpg b/public/bot-avatars/lunaris/ec_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ec_problem.jpg differ diff --git a/public/bot-avatars/lunaris/ec_quitter.jpg b/public/bot-avatars/lunaris/ec_quitter.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ec_quitter.jpg differ diff --git a/public/bot-avatars/lunaris/ec_veteran.jpg b/public/bot-avatars/lunaris/ec_veteran.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ec_veteran.jpg differ diff --git a/public/bot-avatars/lunaris/edu_abroad_alumni.jpg b/public/bot-avatars/lunaris/edu_abroad_alumni.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/edu_abroad_alumni.jpg differ diff --git a/public/bot-avatars/lunaris/edu_abroad_problem.jpg b/public/bot-avatars/lunaris/edu_abroad_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/edu_abroad_problem.jpg differ diff --git a/public/bot-avatars/lunaris/edu_anxious_parent.jpg b/public/bot-avatars/lunaris/edu_anxious_parent.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/edu_anxious_parent.jpg differ diff --git a/public/bot-avatars/lunaris/edu_news.jpg b/public/bot-avatars/lunaris/edu_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/edu_news.jpg differ diff --git a/public/bot-avatars/lunaris/edu_problem.jpg b/public/bot-avatars/lunaris/edu_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/edu_problem.jpg differ diff --git a/public/bot-avatars/lunaris/edu_worker.jpg b/public/bot-avatars/lunaris/edu_worker.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/edu_worker.jpg differ diff --git a/public/bot-avatars/lunaris/fangchan.jpg b/public/bot-avatars/lunaris/fangchan.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fangchan.jpg differ diff --git a/public/bot-avatars/lunaris/fd_diner.jpg b/public/bot-avatars/lunaris/fd_diner.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fd_diner.jpg differ diff --git a/public/bot-avatars/lunaris/fd_news.jpg b/public/bot-avatars/lunaris/fd_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fd_news.jpg differ diff --git a/public/bot-avatars/lunaris/fd_observer.jpg b/public/bot-avatars/lunaris/fd_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fd_observer.jpg differ diff --git a/public/bot-avatars/lunaris/fd_problem.jpg b/public/bot-avatars/lunaris/fd_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fd_problem.jpg differ diff --git a/public/bot-avatars/lunaris/fd_supply_news.jpg b/public/bot-avatars/lunaris/fd_supply_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fd_supply_news.jpg differ diff --git a/public/bot-avatars/lunaris/fd_worker.jpg b/public/bot-avatars/lunaris/fd_worker.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fd_worker.jpg differ diff --git a/public/bot-avatars/lunaris/fengkuang.jpg b/public/bot-avatars/lunaris/fengkuang.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fengkuang.jpg differ diff --git a/public/bot-avatars/lunaris/fi_industry.jpg b/public/bot-avatars/lunaris/fi_industry.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fi_industry.jpg differ diff --git a/public/bot-avatars/lunaris/fi_investor.jpg b/public/bot-avatars/lunaris/fi_investor.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fi_investor.jpg differ diff --git a/public/bot-avatars/lunaris/fi_loser.jpg b/public/bot-avatars/lunaris/fi_loser.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fi_loser.jpg differ diff --git a/public/bot-avatars/lunaris/fi_newbie.jpg b/public/bot-avatars/lunaris/fi_newbie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fi_newbie.jpg differ diff --git a/public/bot-avatars/lunaris/fi_news.jpg b/public/bot-avatars/lunaris/fi_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fi_news.jpg differ diff --git a/public/bot-avatars/lunaris/fi_observer.jpg b/public/bot-avatars/lunaris/fi_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fi_observer.jpg differ diff --git a/public/bot-avatars/lunaris/fi_problem.jpg b/public/bot-avatars/lunaris/fi_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/fi_problem.jpg differ diff --git a/public/bot-avatars/lunaris/helaoshi.jpg b/public/bot-avatars/lunaris/helaoshi.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/helaoshi.jpg differ diff --git a/public/bot-avatars/lunaris/hl_elderly.jpg b/public/bot-avatars/lunaris/hl_elderly.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/hl_elderly.jpg differ diff --git a/public/bot-avatars/lunaris/hl_news.jpg b/public/bot-avatars/lunaris/hl_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/hl_news.jpg differ diff --git a/public/bot-avatars/lunaris/hl_observer.jpg b/public/bot-avatars/lunaris/hl_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/hl_observer.jpg differ diff --git a/public/bot-avatars/lunaris/hl_problem.jpg b/public/bot-avatars/lunaris/hl_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/hl_problem.jpg differ diff --git a/public/bot-avatars/lunaris/hl_young.jpg b/public/bot-avatars/lunaris/hl_young.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/hl_young.jpg differ diff --git a/public/bot-avatars/lunaris/huajie.jpg b/public/bot-avatars/lunaris/huajie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/huajie.jpg differ diff --git a/public/bot-avatars/lunaris/hw_observer.jpg b/public/bot-avatars/lunaris/hw_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/hw_observer.jpg differ diff --git a/public/bot-avatars/lunaris/jiangnan.jpg b/public/bot-avatars/lunaris/jiangnan.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/jiangnan.jpg differ diff --git a/public/bot-avatars/lunaris/jiejie.jpg b/public/bot-avatars/lunaris/jiejie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/jiejie.jpg differ diff --git a/public/bot-avatars/lunaris/jige.jpg b/public/bot-avatars/lunaris/jige.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/jige.jpg differ diff --git a/public/bot-avatars/lunaris/jike.jpg b/public/bot-avatars/lunaris/jike.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/jike.jpg differ diff --git a/public/bot-avatars/lunaris/kaige.jpg b/public/bot-avatars/lunaris/kaige.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/kaige.jpg differ diff --git a/public/bot-avatars/lunaris/kangshifu.jpg b/public/bot-avatars/lunaris/kangshifu.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/kangshifu.jpg differ diff --git a/public/bot-avatars/lunaris/laochen.jpg b/public/bot-avatars/lunaris/laochen.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/laochen.jpg differ diff --git a/public/bot-avatars/lunaris/laolin.jpg b/public/bot-avatars/lunaris/laolin.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/laolin.jpg differ diff --git a/public/bot-avatars/lunaris/laowang.jpg b/public/bot-avatars/lunaris/laowang.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/laowang.jpg differ diff --git a/public/bot-avatars/lunaris/laozhang.jpg b/public/bot-avatars/lunaris/laozhang.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/laozhang.jpg differ diff --git a/public/bot-avatars/lunaris/laozhao.jpg b/public/bot-avatars/lunaris/laozhao.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/laozhao.jpg differ diff --git a/public/bot-avatars/lunaris/lily.jpg b/public/bot-avatars/lunaris/lily.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/lily.jpg differ diff --git a/public/bot-avatars/lunaris/lisa.jpg b/public/bot-avatars/lunaris/lisa.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/lisa.jpg differ diff --git a/public/bot-avatars/lunaris/liuqing.jpg b/public/bot-avatars/lunaris/liuqing.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/liuqing.jpg differ diff --git a/public/bot-avatars/lunaris/ll_news.jpg b/public/bot-avatars/lunaris/ll_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ll_news.jpg differ diff --git a/public/bot-avatars/lunaris/ll_observer.jpg b/public/bot-avatars/lunaris/ll_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ll_observer.jpg differ diff --git a/public/bot-avatars/lunaris/ll_parent.jpg b/public/bot-avatars/lunaris/ll_parent.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ll_parent.jpg differ diff --git a/public/bot-avatars/lunaris/ll_pet.jpg b/public/bot-avatars/lunaris/ll_pet.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ll_pet.jpg differ diff --git a/public/bot-avatars/lunaris/ll_problem.jpg b/public/bot-avatars/lunaris/ll_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/ll_problem.jpg differ diff --git a/public/bot-avatars/lunaris/maoge.jpg b/public/bot-avatars/lunaris/maoge.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/maoge.jpg differ diff --git a/public/bot-avatars/lunaris/meijie.jpg b/public/bot-avatars/lunaris/meijie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/meijie.jpg differ diff --git a/public/bot-avatars/lunaris/meili.jpg b/public/bot-avatars/lunaris/meili.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/meili.jpg differ diff --git a/public/bot-avatars/lunaris/nainai.jpg b/public/bot-avatars/lunaris/nainai.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/nainai.jpg differ diff --git a/public/bot-avatars/lunaris/paopao.jpg b/public/bot-avatars/lunaris/paopao.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/paopao.jpg differ diff --git a/public/bot-avatars/lunaris/qianjie.jpg b/public/bot-avatars/lunaris/qianjie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/qianjie.jpg differ diff --git a/public/bot-avatars/lunaris/re_buyer.jpg b/public/bot-avatars/lunaris/re_buyer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/re_buyer.jpg differ diff --git a/public/bot-avatars/lunaris/re_decorator.jpg b/public/bot-avatars/lunaris/re_decorator.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/re_decorator.jpg differ diff --git a/public/bot-avatars/lunaris/re_news.jpg b/public/bot-avatars/lunaris/re_news.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/re_news.jpg differ diff --git a/public/bot-avatars/lunaris/re_observer.jpg b/public/bot-avatars/lunaris/re_observer.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/re_observer.jpg differ diff --git a/public/bot-avatars/lunaris/re_problem.jpg b/public/bot-avatars/lunaris/re_problem.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/re_problem.jpg differ diff --git a/public/bot-avatars/lunaris/re_renter.jpg b/public/bot-avatars/lunaris/re_renter.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/re_renter.jpg differ diff --git a/public/bot-avatars/lunaris/shengge.jpg b/public/bot-avatars/lunaris/shengge.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/shengge.jpg differ diff --git a/public/bot-avatars/lunaris/tangren.jpg b/public/bot-avatars/lunaris/tangren.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/tangren.jpg differ diff --git a/public/bot-avatars/lunaris/tiege.jpg b/public/bot-avatars/lunaris/tiege.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/tiege.jpg differ diff --git a/public/bot-avatars/lunaris/upzhu.jpg b/public/bot-avatars/lunaris/upzhu.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/upzhu.jpg differ diff --git a/public/bot-avatars/lunaris/wujie.jpg b/public/bot-avatars/lunaris/wujie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/wujie.jpg differ diff --git a/public/bot-avatars/lunaris/wuliu.jpg b/public/bot-avatars/lunaris/wuliu.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/wuliu.jpg differ diff --git a/public/bot-avatars/lunaris/xianyu.jpg b/public/bot-avatars/lunaris/xianyu.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/xianyu.jpg differ diff --git a/public/bot-avatars/lunaris/xiaodao.jpg b/public/bot-avatars/lunaris/xiaodao.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/xiaodao.jpg differ diff --git a/public/bot-avatars/lunaris/xiaolin.jpg b/public/bot-avatars/lunaris/xiaolin.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/xiaolin.jpg differ diff --git a/public/bot-avatars/lunaris/xiaoming.jpg b/public/bot-avatars/lunaris/xiaoming.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/xiaoming.jpg differ diff --git a/public/bot-avatars/lunaris/xiaoyu.jpg b/public/bot-avatars/lunaris/xiaoyu.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/xiaoyu.jpg differ diff --git a/public/bot-avatars/lunaris/xiaozhen.jpg b/public/bot-avatars/lunaris/xiaozhen.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/xiaozhen.jpg differ diff --git a/public/bot-avatars/lunaris/yingxiao.jpg b/public/bot-avatars/lunaris/yingxiao.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/yingxiao.jpg differ diff --git a/public/bot-avatars/lunaris/youyu.jpg b/public/bot-avatars/lunaris/youyu.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/youyu.jpg differ diff --git a/public/bot-avatars/lunaris/zhongzong.jpg b/public/bot-avatars/lunaris/zhongzong.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/zhongzong.jpg differ diff --git a/public/bot-avatars/lunaris/zhuangjie.jpg b/public/bot-avatars/lunaris/zhuangjie.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/bot-avatars/lunaris/zhuangjie.jpg differ diff --git a/public/bot-avatars/manifest.json b/public/bot-avatars/manifest.json new file mode 100644 index 0000000..717a132 --- /dev/null +++ b/public/bot-avatars/manifest.json @@ -0,0 +1,680 @@ +{ + "generatedAt": "2026-06-08T10:42:21.200Z", + "total": 112, + "succeeded": 112, + "failed": 0, + "avatars": [ + { + "key": "laochen", + "avatarUrl": "/bot-avatars/laochen.svg", + "source": "existing-svg", + "bytes": 2537 + }, + { + "key": "lisa", + "avatarUrl": "/bot-avatars/lisa.svg", + "source": "existing-svg", + "bytes": 2547 + }, + { + "key": "qianjie", + "avatarUrl": "/bot-avatars/qianjie.svg", + "source": "existing-svg", + "bytes": 2553 + }, + { + "key": "xiaoming", + "avatarUrl": "/bot-avatars/xiaoming.svg", + "source": "existing-svg", + "bytes": 2549 + }, + { + "key": "wujie", + "avatarUrl": "/bot-avatars/wujie.svg", + "source": "existing-svg", + "bytes": 2543 + }, + { + "key": "laozhang", + "avatarUrl": "/bot-avatars/laozhang.svg", + "source": "existing-svg", + "bytes": 2573 + }, + { + "key": "xiaoyu", + "avatarUrl": "/bot-avatars/xiaoyu.svg", + "source": "existing-svg", + "bytes": 2544 + }, + { + "key": "laozhao", + "avatarUrl": "/bot-avatars/laozhao.svg", + "source": "existing-svg", + "bytes": 2565 + }, + { + "key": "laolin", + "avatarUrl": "/bot-avatars/laolin.svg", + "source": "existing-svg", + "bytes": 2548 + }, + { + "key": "zhongzong", + "avatarUrl": "/bot-avatars/zhongzong.svg", + "source": "existing-svg", + "bytes": 2535 + }, + { + "key": "xiaodao", + "avatarUrl": "/bot-avatars/xiaodao.svg", + "source": "existing-svg", + "bytes": 2553 + }, + { + "key": "nainai", + "avatarUrl": "/bot-avatars/nainai.svg", + "source": "svg-fallback", + "bytes": 2528 + }, + { + "key": "fengkuang", + "avatarUrl": "/bot-avatars/fengkuang.svg", + "source": "svg-fallback", + "bytes": 2550 + }, + { + "key": "youyu", + "avatarUrl": "/bot-avatars/youyu.svg", + "source": "svg-fallback", + "bytes": 2539 + }, + { + "key": "ajie", + "avatarUrl": "/bot-avatars/ajie.svg", + "source": "svg-fallback", + "bytes": 2538 + }, + { + "key": "helaoshi", + "avatarUrl": "/bot-avatars/helaoshi.svg", + "source": "svg-fallback", + "bytes": 2537 + }, + { + "key": "daliu", + "avatarUrl": "/bot-avatars/daliu.svg", + "source": "svg-fallback", + "bytes": 2562 + }, + { + "key": "lily", + "avatarUrl": "/bot-avatars/lily.svg", + "source": "svg-fallback", + "bytes": 2531 + }, + { + "key": "laowang", + "avatarUrl": "/bot-avatars/laowang.svg", + "source": "svg-fallback", + "bytes": 2532 + }, + { + "key": "xiaolin", + "avatarUrl": "/bot-avatars/xiaolin.svg", + "source": "svg-fallback", + "bytes": 2551 + }, + { + "key": "wuliu", + "avatarUrl": "/bot-avatars/wuliu.svg", + "source": "svg-fallback", + "bytes": 2535 + }, + { + "key": "yingxiao", + "avatarUrl": "/bot-avatars/yingxiao.svg", + "source": "svg-fallback", + "bytes": 2534 + }, + { + "key": "tangren", + "avatarUrl": "/bot-avatars/tangren.svg", + "source": "svg-fallback", + "bytes": 2553 + }, + { + "key": "fangchan", + "avatarUrl": "/bot-avatars/fangchan.svg", + "source": "svg-fallback", + "bytes": 2538 + }, + { + "key": "upzhu", + "avatarUrl": "/bot-avatars/upzhu.svg", + "source": "svg-fallback", + "bytes": 2558 + }, + { + "key": "liuqing", + "avatarUrl": "/bot-avatars/liuqing.svg", + "source": "svg-fallback", + "bytes": 2531 + }, + { + "key": "xianyu", + "avatarUrl": "/bot-avatars/xianyu.svg", + "source": "svg-fallback", + "bytes": 2533 + }, + { + "key": "xiaozhen", + "avatarUrl": "/bot-avatars/xiaozhen.svg", + "source": "svg-fallback", + "bytes": 2584 + }, + { + "key": "jiangnan", + "avatarUrl": "/bot-avatars/jiangnan.svg", + "source": "svg-fallback", + "bytes": 2528 + }, + { + "key": "duoduo", + "avatarUrl": "/bot-avatars/duoduo.svg", + "source": "svg-fallback", + "bytes": 2539 + }, + { + "key": "jike", + "avatarUrl": "/bot-avatars/jike.svg", + "source": "svg-fallback", + "bytes": 2547 + }, + { + "key": "shengge", + "avatarUrl": "/bot-avatars/shengge.svg", + "source": "svg-fallback", + "bytes": 2560 + }, + { + "key": "tiege", + "avatarUrl": "/bot-avatars/tiege.svg", + "source": "svg-fallback", + "bytes": 2545 + }, + { + "key": "zhuangjie", + "avatarUrl": "/bot-avatars/zhuangjie.svg", + "source": "svg-fallback", + "bytes": 2566 + }, + { + "key": "awei", + "avatarUrl": "/bot-avatars/awei.svg", + "source": "svg-fallback", + "bytes": 2547 + }, + { + "key": "paopao", + "avatarUrl": "/bot-avatars/paopao.svg", + "source": "svg-fallback", + "bytes": 2552 + }, + { + "key": "meili", + "avatarUrl": "/bot-avatars/meili.svg", + "source": "svg-fallback", + "bytes": 2539 + }, + { + "key": "atie", + "avatarUrl": "/bot-avatars/atie.svg", + "source": "svg-fallback", + "bytes": 2556 + }, + { + "key": "maoge", + "avatarUrl": "/bot-avatars/maoge.svg", + "source": "svg-fallback", + "bytes": 2552 + }, + { + "key": "jiejie", + "avatarUrl": "/bot-avatars/jiejie.svg", + "source": "svg-fallback", + "bytes": 2534 + }, + { + "key": "jige", + "avatarUrl": "/bot-avatars/jige.svg", + "source": "svg-fallback", + "bytes": 2553 + }, + { + "key": "huajie", + "avatarUrl": "/bot-avatars/huajie.svg", + "source": "svg-fallback", + "bytes": 2545 + }, + { + "key": "meijie", + "avatarUrl": "/bot-avatars/meijie.svg", + "source": "svg-fallback", + "bytes": 2531 + }, + { + "key": "kangshifu", + "avatarUrl": "/bot-avatars/kangshifu.svg", + "source": "svg-fallback", + "bytes": 2537 + }, + { + "key": "kaige", + "avatarUrl": "/bot-avatars/kaige.svg", + "source": "svg-fallback", + "bytes": 2575 + }, + { + "key": "chengjie", + "avatarUrl": "/bot-avatars/chengjie.svg", + "source": "svg-fallback", + "bytes": 2539 + }, + { + "key": "ec_news", + "avatarUrl": "/bot-avatars/ec_news.svg", + "source": "svg-fallback", + "bytes": 2546 + }, + { + "key": "ec_observer", + "avatarUrl": "/bot-avatars/ec_observer.svg", + "source": "svg-fallback", + "bytes": 2540 + }, + { + "key": "ec_problem", + "avatarUrl": "/bot-avatars/ec_problem.svg", + "source": "svg-fallback", + "bytes": 2550 + }, + { + "key": "ai_news", + "avatarUrl": "/bot-avatars/ai_news.svg", + "source": "svg-fallback", + "bytes": 2561 + }, + { + "key": "ai_observer", + "avatarUrl": "/bot-avatars/ai_observer.svg", + "source": "svg-fallback", + "bytes": 2566 + }, + { + "key": "ai_problem", + "avatarUrl": "/bot-avatars/ai_problem.svg", + "source": "svg-fallback", + "bytes": 2544 + }, + { + "key": "ct_news", + "avatarUrl": "/bot-avatars/ct_news.svg", + "source": "svg-fallback", + "bytes": 2553 + }, + { + "key": "ct_observer", + "avatarUrl": "/bot-avatars/ct_observer.svg", + "source": "svg-fallback", + "bytes": 2535 + }, + { + "key": "ct_problem", + "avatarUrl": "/bot-avatars/ct_problem.svg", + "source": "svg-fallback", + "bytes": 2534 + }, + { + "key": "fi_news", + "avatarUrl": "/bot-avatars/fi_news.svg", + "source": "svg-fallback", + "bytes": 2551 + }, + { + "key": "fi_observer", + "avatarUrl": "/bot-avatars/fi_observer.svg", + "source": "svg-fallback", + "bytes": 2552 + }, + { + "key": "fi_problem", + "avatarUrl": "/bot-avatars/fi_problem.svg", + "source": "svg-fallback", + "bytes": 2545 + }, + { + "key": "cb_news", + "avatarUrl": "/bot-avatars/cb_news.svg", + "source": "svg-fallback", + "bytes": 2551 + }, + { + "key": "cb_observer", + "avatarUrl": "/bot-avatars/cb_observer.svg", + "source": "svg-fallback", + "bytes": 2543 + }, + { + "key": "cb_problem", + "avatarUrl": "/bot-avatars/cb_problem.svg", + "source": "svg-fallback", + "bytes": 2544 + }, + { + "key": "re_news", + "avatarUrl": "/bot-avatars/re_news.svg", + "source": "svg-fallback", + "bytes": 2565 + }, + { + "key": "re_observer", + "avatarUrl": "/bot-avatars/re_observer.svg", + "source": "svg-fallback", + "bytes": 2533 + }, + { + "key": "re_problem", + "avatarUrl": "/bot-avatars/re_problem.svg", + "source": "svg-fallback", + "bytes": 2537 + }, + { + "key": "fd_news", + "avatarUrl": "/bot-avatars/fd_news.svg", + "source": "svg-fallback", + "bytes": 2543 + }, + { + "key": "fd_observer", + "avatarUrl": "/bot-avatars/fd_observer.svg", + "source": "svg-fallback", + "bytes": 2552 + }, + { + "key": "fd_problem", + "avatarUrl": "/bot-avatars/fd_problem.svg", + "source": "svg-fallback", + "bytes": 2530 + }, + { + "key": "ll_news", + "avatarUrl": "/bot-avatars/ll_news.svg", + "source": "svg-fallback", + "bytes": 2533 + }, + { + "key": "ll_observer", + "avatarUrl": "/bot-avatars/ll_observer.svg", + "source": "svg-fallback", + "bytes": 2576 + }, + { + "key": "ll_problem", + "avatarUrl": "/bot-avatars/ll_problem.svg", + "source": "svg-fallback", + "bytes": 2545 + }, + { + "key": "hl_news", + "avatarUrl": "/bot-avatars/hl_news.svg", + "source": "svg-fallback", + "bytes": 2527 + }, + { + "key": "hl_observer", + "avatarUrl": "/bot-avatars/hl_observer.svg", + "source": "svg-fallback", + "bytes": 2553 + }, + { + "key": "hl_problem", + "avatarUrl": "/bot-avatars/hl_problem.svg", + "source": "svg-fallback", + "bytes": 2549 + }, + { + "key": "edu_news", + "avatarUrl": "/bot-avatars/edu_news.svg", + "source": "svg-fallback", + "bytes": 2528 + }, + { + "key": "edu_observer", + "avatarUrl": "/bot-avatars/edu_observer.svg", + "source": "svg-fallback", + "bytes": 2552 + }, + { + "key": "edu_problem", + "avatarUrl": "/bot-avatars/edu_problem.svg", + "source": "svg-fallback", + "bytes": 2537 + }, + { + "key": "hw_observer", + "avatarUrl": "/bot-avatars/hw_observer.svg", + "source": "svg-fallback", + "bytes": 2538 + }, + { + "key": "fd_supply_news", + "avatarUrl": "/bot-avatars/fd_supply_news.svg", + "source": "svg-fallback", + "bytes": 2533 + }, + { + "key": "edu_abroad_problem", + "avatarUrl": "/bot-avatars/edu_abroad_problem.svg", + "source": "svg-fallback", + "bytes": 2531 + }, + { + "key": "ec_veteran", + "avatarUrl": "/bot-avatars/ec_veteran.svg", + "source": "svg-fallback", + "bytes": 2565 + }, + { + "key": "ec_employee", + "avatarUrl": "/bot-avatars/ec_employee.svg", + "source": "svg-fallback", + "bytes": 2539 + }, + { + "key": "ec_data_nerd", + "avatarUrl": "/bot-avatars/ec_data_nerd.svg", + "source": "svg-fallback", + "bytes": 2531 + }, + { + "key": "ec_consumer", + "avatarUrl": "/bot-avatars/ec_consumer.svg", + "source": "svg-fallback", + "bytes": 2560 + }, + { + "key": "ec_quitter", + "avatarUrl": "/bot-avatars/ec_quitter.svg", + "source": "svg-fallback", + "bytes": 2552 + }, + { + "key": "ai_pm", + "avatarUrl": "/bot-avatars/ai_pm.svg", + "source": "svg-fallback", + "bytes": 2529 + }, + { + "key": "ai_investor", + "avatarUrl": "/bot-avatars/ai_investor.svg", + "source": "svg-fallback", + "bytes": 2544 + }, + { + "key": "ai_student", + "avatarUrl": "/bot-avatars/ai_student.svg", + "source": "svg-fallback", + "bytes": 2557 + }, + { + "key": "ai_dev", + "avatarUrl": "/bot-avatars/ai_dev.svg", + "source": "svg-fallback", + "bytes": 2531 + }, + { + "key": "ai_critic", + "avatarUrl": "/bot-avatars/ai_critic.svg", + "source": "svg-fallback", + "bytes": 2575 + }, + { + "key": "ct_creator", + "avatarUrl": "/bot-avatars/ct_creator.svg", + "source": "svg-fallback", + "bytes": 2549 + }, + { + "key": "ct_viewer", + "avatarUrl": "/bot-avatars/ct_viewer.svg", + "source": "svg-fallback", + "bytes": 2563 + }, + { + "key": "ct_writer", + "avatarUrl": "/bot-avatars/ct_writer.svg", + "source": "svg-fallback", + "bytes": 2548 + }, + { + "key": "ct_podcaster", + "avatarUrl": "/bot-avatars/ct_podcaster.svg", + "source": "svg-fallback", + "bytes": 2544 + }, + { + "key": "fi_investor", + "avatarUrl": "/bot-avatars/fi_investor.svg", + "source": "svg-fallback", + "bytes": 2533 + }, + { + "key": "fi_newbie", + "avatarUrl": "/bot-avatars/fi_newbie.svg", + "source": "svg-fallback", + "bytes": 2529 + }, + { + "key": "fi_industry", + "avatarUrl": "/bot-avatars/fi_industry.svg", + "source": "svg-fallback", + "bytes": 2537 + }, + { + "key": "fi_loser", + "avatarUrl": "/bot-avatars/fi_loser.svg", + "source": "svg-fallback", + "bytes": 2544 + }, + { + "key": "cb_expat", + "avatarUrl": "/bot-avatars/cb_expat.svg", + "source": "svg-fallback", + "bytes": 2560 + }, + { + "key": "cb_amazon", + "avatarUrl": "/bot-avatars/cb_amazon.svg", + "source": "svg-fallback", + "bytes": 2544 + }, + { + "key": "cb_thinker", + "avatarUrl": "/bot-avatars/cb_thinker.svg", + "source": "svg-fallback", + "bytes": 2537 + }, + { + "key": "re_buyer", + "avatarUrl": "/bot-avatars/re_buyer.svg", + "source": "svg-fallback", + "bytes": 2536 + }, + { + "key": "re_renter", + "avatarUrl": "/bot-avatars/re_renter.svg", + "source": "svg-fallback", + "bytes": 2552 + }, + { + "key": "re_decorator", + "avatarUrl": "/bot-avatars/re_decorator.svg", + "source": "svg-fallback", + "bytes": 2535 + }, + { + "key": "fd_diner", + "avatarUrl": "/bot-avatars/fd_diner.svg", + "source": "svg-fallback", + "bytes": 2548 + }, + { + "key": "fd_worker", + "avatarUrl": "/bot-avatars/fd_worker.svg", + "source": "svg-fallback", + "bytes": 2544 + }, + { + "key": "ll_parent", + "avatarUrl": "/bot-avatars/ll_parent.svg", + "source": "svg-fallback", + "bytes": 2541 + }, + { + "key": "ll_pet", + "avatarUrl": "/bot-avatars/ll_pet.svg", + "source": "svg-fallback", + "bytes": 2538 + }, + { + "key": "hl_elderly", + "avatarUrl": "/bot-avatars/hl_elderly.svg", + "source": "svg-fallback", + "bytes": 2535 + }, + { + "key": "hl_young", + "avatarUrl": "/bot-avatars/hl_young.svg", + "source": "svg-fallback", + "bytes": 2535 + }, + { + "key": "edu_anxious_parent", + "avatarUrl": "/bot-avatars/edu_anxious_parent.svg", + "source": "svg-fallback", + "bytes": 2556 + }, + { + "key": "edu_worker", + "avatarUrl": "/bot-avatars/edu_worker.svg", + "source": "svg-fallback", + "bytes": 2537 + }, + { + "key": "edu_abroad_alumni", + "avatarUrl": "/bot-avatars/edu_abroad_alumni.svg", + "source": "svg-fallback", + "bytes": 2539 + } + ] +} \ No newline at end of file diff --git a/public/bot-avatars/maoge.svg b/public/bot-avatars/maoge.svg new file mode 100644 index 0000000..9e5cda0 --- /dev/null +++ b/public/bot-avatars/maoge.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 毛 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/meijie.svg b/public/bot-avatars/meijie.svg new file mode 100644 index 0000000..f3b05c1 --- /dev/null +++ b/public/bot-avatars/meijie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 美 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/meili.svg b/public/bot-avatars/meili.svg new file mode 100644 index 0000000..1b166e8 --- /dev/null +++ b/public/bot-avatars/meili.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 美 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/nainai.svg b/public/bot-avatars/nainai.svg new file mode 100644 index 0000000..cb8ece1 --- /dev/null +++ b/public/bot-avatars/nainai.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 奶 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/paopao.svg b/public/bot-avatars/paopao.svg new file mode 100644 index 0000000..903dccc --- /dev/null +++ b/public/bot-avatars/paopao.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 跑 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/qianjie.svg b/public/bot-avatars/qianjie.svg new file mode 100644 index 0000000..2c1e1e0 --- /dev/null +++ b/public/bot-avatars/qianjie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 钱 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/re_buyer.svg b/public/bot-avatars/re_buyer.svg new file mode 100644 index 0000000..b152d79 --- /dev/null +++ b/public/bot-avatars/re_buyer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 买 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/re_decorator.svg b/public/bot-avatars/re_decorator.svg new file mode 100644 index 0000000..e678608 --- /dev/null +++ b/public/bot-avatars/re_decorator.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 装 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/re_news.svg b/public/bot-avatars/re_news.svg new file mode 100644 index 0000000..177c5cd --- /dev/null +++ b/public/bot-avatars/re_news.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 楼 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/re_observer.svg b/public/bot-avatars/re_observer.svg new file mode 100644 index 0000000..fe27907 --- /dev/null +++ b/public/bot-avatars/re_observer.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 城 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/re_problem.svg b/public/bot-avatars/re_problem.svg new file mode 100644 index 0000000..a61c616 --- /dev/null +++ b/public/bot-avatars/re_problem.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 买 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/re_renter.svg b/public/bot-avatars/re_renter.svg new file mode 100644 index 0000000..f2e1520 --- /dev/null +++ b/public/bot-avatars/re_renter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 租 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/shengge.svg b/public/bot-avatars/shengge.svg new file mode 100644 index 0000000..d603adc --- /dev/null +++ b/public/bot-avatars/shengge.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 声 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/tangren.svg b/public/bot-avatars/tangren.svg new file mode 100644 index 0000000..fc9aa30 --- /dev/null +++ b/public/bot-avatars/tangren.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 唐 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/tiege.svg b/public/bot-avatars/tiege.svg new file mode 100644 index 0000000..2fa22aa --- /dev/null +++ b/public/bot-avatars/tiege.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 铁 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/upzhu.svg b/public/bot-avatars/upzhu.svg new file mode 100644 index 0000000..5afa9d0 --- /dev/null +++ b/public/bot-avatars/upzhu.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + U + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/wujie.svg b/public/bot-avatars/wujie.svg new file mode 100644 index 0000000..740eedf --- /dev/null +++ b/public/bot-avatars/wujie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 吴 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/wuliu.svg b/public/bot-avatars/wuliu.svg new file mode 100644 index 0000000..f35cf7f --- /dev/null +++ b/public/bot-avatars/wuliu.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 物 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/xianyu.svg b/public/bot-avatars/xianyu.svg new file mode 100644 index 0000000..92c263d --- /dev/null +++ b/public/bot-avatars/xianyu.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 咸 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/xiaodao.svg b/public/bot-avatars/xiaodao.svg new file mode 100644 index 0000000..2835076 --- /dev/null +++ b/public/bot-avatars/xiaodao.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 小 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/xiaolin.svg b/public/bot-avatars/xiaolin.svg new file mode 100644 index 0000000..5664641 --- /dev/null +++ b/public/bot-avatars/xiaolin.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 小 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/xiaoming.svg b/public/bot-avatars/xiaoming.svg new file mode 100644 index 0000000..6d8b523 --- /dev/null +++ b/public/bot-avatars/xiaoming.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 小 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/xiaoyu.svg b/public/bot-avatars/xiaoyu.svg new file mode 100644 index 0000000..0f028ec --- /dev/null +++ b/public/bot-avatars/xiaoyu.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 小 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/xiaozhen.svg b/public/bot-avatars/xiaozhen.svg new file mode 100644 index 0000000..483ce88 --- /dev/null +++ b/public/bot-avatars/xiaozhen.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 小 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/yingxiao.svg b/public/bot-avatars/yingxiao.svg new file mode 100644 index 0000000..7fdeb9b --- /dev/null +++ b/public/bot-avatars/yingxiao.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 营 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/youyu.svg b/public/bot-avatars/youyu.svg new file mode 100644 index 0000000..f40ff91 --- /dev/null +++ b/public/bot-avatars/youyu.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 忧 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/zhongzong.svg b/public/bot-avatars/zhongzong.svg new file mode 100644 index 0000000..84e30aa --- /dev/null +++ b/public/bot-avatars/zhongzong.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 钟 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/bot-avatars/zhuangjie.svg b/public/bot-avatars/zhuangjie.svg new file mode 100644 index 0000000..abab87e --- /dev/null +++ b/public/bot-avatars/zhuangjie.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + 行业专家 + + 装 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..5c92c28 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,112 @@ +# scripts/ 目录指南 + +追光AI 数字人 Bot 系统的脚本库。本目录的 `.mjs` 脚本都是用 Node.js(>=18)跑,不依赖 Next.js 构建。 + +## 目录结构 + +``` +scripts/ +├── lib/ # 核心库(被 CLI 引用) +│ ├── bot-adversarial-learning.mjs # Bot 对抗学习(每周学 1 篇真人高赞) +│ ├── bot-avatar-generator.mjs # Bot 头像生成(Lunaris + SVG 兜底) +│ ├── bot-persona.mjs # Bot persona 加载/刷新 +│ ├── bot-persona-experiment.mjs # A/B 测试分流 +│ └── retry.mjs # DeepSeek/OpenAI 重试封装 +│ +├── bot-activity.mjs # 主活动引擎:发 topic / reply +├── bot-adversarial-learning-run.mjs # 每周日 23:00 跑 +├── bot-avatar-generate.mjs # 数字人头像批量预生成 +├── bot-affinity-update.mjs # bot 板块亲和度更新 +├── bot-feedback-loop.mjs # bot 自反馈循环 +├── bot-persona-experiment-run.mjs # A/B 指标采集 + 显著性分析 +├── bot-skill-crystallize.mjs # bot 技能结晶化 +└── bot-weekly-review.mjs # bot 周报 +``` + +## 常用命令 + +### 数字人头像生成(新) + +```bash +# 全量:112 个 bot 一次性预生成 + 落盘 + 写库 +node scripts/bot-avatar-generate.mjs + +# 单个 bot +node scripts/bot-avatar-generate.mjs --bot=laochen + +# 强制重新生成(覆盖已存在) +node scripts/bot-avatar-generate.mjs --force + +# 只落盘不写 User.avatarUrl +node scripts/bot-avatar-generate.mjs --no-update-db + +# 试运行 +node scripts/bot-avatar-generate.mjs --dry-run +``` + +产物: +- `public/bot-avatars/{key}.svg` — 唯一程序化头像(~2.5KB/个) +- `public/bot-avatars/lunaris/{key}.jpg` — Lunaris 拉取的原图(占位时也是这个) +- `public/bot-avatars/manifest.json` — 生成清单 + +> 落盘的 SVG 会通过 Next.js 的 `public/` 静态服务直接挂在 `/bot-avatars/{key}.svg`,前端 `` 组件会优先用 `user.avatarUrl`,找不到时回退到动态 Lunaris URL。 + +### Bot 对抗学习 + +```bash +# 全量:每个 bot 学一篇本周真人高赞帖 +node scripts/bot-adversarial-learning-run.mjs + +# 单个 bot +node scripts/bot-adversarial-learning-run.mjs --bot=laochen + +# 重跑(先清掉本周已有 learning) +node scripts/bot-adversarial-learning-run.mjs --force + +# 追溯更长时间窗 +node scripts/bot-adversarial-learning-run.mjs --lookback=14 + +# 调度入口(生产 cron:`0 23 * * 0`) +``` + +### Bot A/B 测试 + +```bash +# 采指标 + 跑显著性分析 +node scripts/bot-persona-experiment-run.mjs + +# 只采指标,不切 winner +node scripts/bot-persona-experiment-run.mjs --no-switch + +# 只跑分析(用已有指标) +node scripts/bot-persona-experiment-run.mjs --analyze-only + +# 调度入口(生产 cron:`45 */4 * * *`) +``` + +## 数据模型要点 + +| 表 | 用途 | 关键字段 | +|------|------|----------| +| `bot_persona_variants` | A/B 测试变体 | `weight` / `is_control` / `sample_count` / `engagement_score` | +| `bot_persona_experiments` | 实验 | `started_at` / `ended_at` / `status` | +| `bot_persona_assignments` | 内容创建时的分流记录 | `variant_id` / `topic_id` / `post_id` | +| `bot_persona_metrics` | 每日聚合指标 | `variant_id` / `date` / `reply_count` / `human_reply_count` | +| `bot_adversarial_learnings` | 每周学习 | `week_key` / `source_ref_id` / `learned_insight` / `status` | + +## 添加新 bot + +1. 在 `data/bot-characters.json` 加 character(必填 `key` / `displayName` / `email` / `avatarPrompt` / `personality` / `primaryForums` / `activeHours` / `activityLevel` / `replyChance`) +2. 用 `seed-bot-forum.mjs` 把 bot 跑进 DB(已有 seed) +3. 跑 `node scripts/bot-avatar-generate.mjs --bot={newKey}` 单独生成头像 +4. 跑 `node scripts/seed-persona-variants.mjs` 派生 A/B 变体 + +## 故障排查 + +| 症状 | 排查 | +|------|------| +| `DATABASE_URL 未配置` | 检查 `.env` 是否存在并 `mysql://` 协议 | +| `Lunaris 拉取失败` | 网络问题;脚本会自动 fallback 到 SVG | +| `Lunaris 全部返回占位` | trae-api 当前所有 prompt 都返回同一张 default.jpeg;用 `isLunarisDefault` 检测后走 SVG | +| `Bot "xxx" 不在 bot-characters.json 中` | 先在 JSON 里加这个 key | +| `tsc` 报错 | 本目录全是 `.mjs`,不应影响 tsc;如果报错,多半是 `src/` 里改坏了 | diff --git a/scripts/_inspection.sh b/scripts/_inspection.sh new file mode 100644 index 0000000..db89443 --- /dev/null +++ b/scripts/_inspection.sh @@ -0,0 +1,114 @@ +#!/bin/bash +echo "==========================================" +echo " 追光AI 定时任务 + Bot 运行巡检" +echo " $(date '+%Y-%m-%d %H:%M:%S')" +echo "==========================================" +echo "" + +echo "=== 1) PM2 进程状态 ===" +pm2 list 2>&1 | head -30 +echo "" + +echo "=== 2) 本项目 crontab 任务(按时间排) ===" +crontab -l 2>/dev/null +echo "" + +echo "=== 3) 本项目最近 5 次 PM2 重启事件 ===" +pm2 show zhuiguang-ai 2>&1 | grep -E "restarts|unstable|uptime|created at|status" | head -10 +echo "" + +echo "=== 4) 各定时任务最近 5 条日志 ===" +cd /home/ubuntu/zhuiguang-ai/logs 2>/dev/null +for f in task1.log task3.log task4.log task5.log task6.log task7.log daily-news.log bot-activity.log; do + if [ -f "$f" ]; then + sz=$(du -h "$f" | cut -f1) + lines=$(wc -l < "$f") + last_mod=$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1) + last_line=$(tail -1 "$f" 2>/dev/null) + echo " [${f}] size=${sz} lines=${lines} mtime=${last_mod}" + echo " tail: ${last_line:0:150}" + else + echo " [${f}] MISSING" + fi +done +echo "" + +echo "=== 5) Bot 相关脚本最近 3 条日志 ===" +for f in bot-activity.log bot-feedback-loop.log bot-weekly-review.log bot-skill-crystallize.log bot-affinity-update.log bot-adversarial-learning.log bot-persona-experiment.log; do + if [ -f "$f" ]; then + last_line=$(tail -1 "$f" 2>/dev/null) + last_mod=$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1) + echo " [${f}] mtime=${last_mod}" + echo " tail: ${last_line:0:150}" + fi +done +echo "" + +echo "=== 6) 24h 内任务失败 / ERROR 关键字扫描 ===" +for f in *.log; do + [ -f "$f" ] || continue + err=$(grep -iE 'error|失败|fatal|exception|exit code [^0]|❌|❌' "$f" 2>/dev/null | tail -3) + if [ -n "$err" ]; then + echo " --- ${f} ---" + echo "$err" | sed 's/^/ /' + fi +done +echo "" + +echo "=== 7) 数据库里的 Bot 现状 ===" +mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e " +SELECT + (SELECT COUNT(*) FROM User WHERE isBot = 1) AS bot_users, + (SELECT COUNT(*) FROM BotConfig) AS bot_configs, + (SELECT COUNT(*) FROM BotConfig WHERE isActive = 1) AS active_bots, + (SELECT COUNT(*) FROM ForumTopic WHERE createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS topics_7d, + (SELECT COUNT(*) FROM ForumPost WHERE createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS posts_7d, + (SELECT COUNT(*) FROM ForumTopic t JOIN User u ON t.authorId = u.id WHERE u.isBot = 1 AND t.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS bot_topics_7d, + (SELECT COUNT(*) FROM ForumPost p JOIN User u ON p.authorId = u.id WHERE u.isBot = 1 AND p.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS bot_posts_7d, + (SELECT COUNT(*) FROM BotAdversarialLearning) AS adv_learnings, + (SELECT COUNT(*) FROM BotAdversarialLearning WHERE status = 'active') AS active_learnings, + (SELECT COUNT(*) FROM BotPersonaVariant) AS persona_variants, + (SELECT COUNT(*) FROM BotPersonaExperiment) AS experiments; +" 2>&1 | grep -v "Warning" +echo "" + +echo "=== 8) 最近 24h 论坛板块活跃度(按板块) ===" +mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e " +SELECT + c.name AS 板块, + c.slug AS slug, + (SELECT COUNT(*) FROM ForumTopic t WHERE t.categoryId = c.id AND t.createdAt > DATE_SUB(NOW(), INTERVAL 24 HOUR)) AS 24h新帖, + (SELECT COUNT(*) FROM ForumPost p WHERE p.topicId IN (SELECT id FROM ForumTopic WHERE categoryId = c.id) AND p.createdAt > DATE_SUB(NOW(), INTERVAL 24 HOUR)) AS 24h新回复 +FROM ForumCategory c +ORDER BY c.id; +" 2>&1 | grep -v "Warning" +echo "" + +echo "=== 9) 最近 7 天发 Bot 帖前 10 名 ===" +mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e " +SELECT + u.name AS bot, + COUNT(DISTINCT t.id) AS 发帖数, + COUNT(DISTINCT p.id) AS 回复数, + COALESCE(SUM(t.likeCount), 0) AS 获赞, + COALESCE(SUM(t.viewCount), 0) AS 浏览 +FROM User u +LEFT JOIN ForumTopic t ON t.authorId = u.id AND t.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY) +LEFT JOIN ForumPost p ON p.authorId = u.id AND p.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY) +WHERE u.isBot = 1 +GROUP BY u.id, u.name +ORDER BY 发帖数 + 回复数 DESC +LIMIT 10; +" 2>&1 | grep -v "Warning" +echo "" + +echo "=== 10) 本项目磁盘占用 ===" +du -sh /home/ubuntu/zhuiguang-ai 2>/dev/null +du -sh /home/ubuntu/zhuiguang-ai/.next 2>/dev/null +du -sh /home/ubuntu/zhuiguang-ai/node_modules 2>/dev/null +du -sh /home/ubuntu/zhuiguang-ai/logs 2>/dev/null +echo "" + +echo "==========================================" +echo " 巡检完成" +echo "==========================================" diff --git a/scripts/add-passersby.mjs b/scripts/add-passersby.mjs new file mode 100644 index 0000000..730a54b --- /dev/null +++ b/scripts/add-passersby.mjs @@ -0,0 +1,779 @@ +// 给 bot-characters.json 追加 33 位「数字路人」(passerby) 角色 +// 在不重复 key/email 的前提下,覆盖更细分的论坛与人群画像 + +import { readFileSync, writeFileSync } from "fs"; + +const path = "data/bot-characters.json"; +const data = JSON.parse(readFileSync(path, "utf-8")); + +const existingKeys = new Set(data.characters.map((c) => c.key)); +const existingEmails = new Set(data.characters.map((c) => c.email)); + +// 33 位新路人设计:每条都是「场景化人物」,与现有 bot 画像不重合 +const newPassersby = [ + // ============ 电商 5 位(补充"老炮/打工人/数据党/消费者/退坑者") ============ + { + key: "ec_veteran", + displayName: "电商老炮儿", + email: "bot_ec_veteran@zhuiguang.ai", + role: "passerby", + avatarPrompt: "42岁中国男性,做过淘宝京东拼多多三个时代,穿着朴素但眼神老辣", + personality: { + identity: "电商圈混了15年,从PC时代干到直播电商,见过太多起起落落,现在半退休状态", + expertise: ["行业内幕", "平台规则演变", "老炮视角"], + weakness: ["新平台玩法", "00后用户心理", "AI工具"], + stance: "新人总想颠覆,其实就是轮回。我就看看不说话", + speakingStyle: "段子手+过来人,喜欢说\"当年...\",偶尔自嘲,但句句到位", + catchphrase: "当年老子也是这样过来的", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 450 }, + passerbyType: "observer", + }, + primaryForums: ["ec-platform", "ec-supply", "ec-dtc"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["14:00-18:00", "20:00-23:00"] }, + activityLevel: "medium", + replyChance: 0.35, + }, + { + key: "ec_employee", + displayName: "平台打工人", + email: "bot_ec_employee@zhuiguang.ai", + role: "passerby", + avatarPrompt: "29岁中国女性,电商平台运营加班族,电脑前永远有数据看板,咖啡不离手", + personality: { + identity: "某头部电商平台基层运营,每天被GMV和ROI追杀,对外不能暴露身份", + expertise: ["平台后台", "流量逻辑", "运营日常"], + weakness: ["创业实战", "跨平台", "供应链"], + stance: "我只能匿名说点真话。商家们别再骂平台了,平台内卷我们也累", + speakingStyle: "圈内人视角,会不经意透露'我朋友在XX平台',但永远不说自己公司", + catchphrase: "不方便说太多", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "observer", + }, + primaryForums: ["ec-platform", "ec-livestream", "ec-private"], + activeHours: { weekday: ["22:00-24:00", "07:00-09:00"], weekend: ["不定时"] }, + activityLevel: "low", + replyChance: 0.25, + }, + { + key: "ec_data_nerd", + displayName: "数据党小赵", + email: "bot_ec_data@zhuiguang.ai", + role: "passerby", + avatarPrompt: "28岁中国男性,数据分析师出身,做电商研究,开口就是图表和数字", + personality: { + identity: "电商行业数据分析师,每天爬数据写报告,自带数据可视化强迫症", + expertise: ["数据分析", "市场报告", "趋势建模"], + weakness: ["一线运营", "具体投放", "人际关系"], + stance: "别聊感觉,聊数据。感觉都是错觉,数字不会骗人", + speakingStyle: "开口就是百分比和图表,但尽量用人话解释", + catchphrase: "数据告诉我们...", + forbiddenPatterns: [], + wordLimit: { min: 180, max: 450 }, + passerbyType: "news", + }, + primaryForums: ["ec-platform", "ec-dtc", "ec-supply"], + activeHours: { weekday: ["09:00-11:00", "15:00-17:00"], weekend: ["10:00-12:00"] }, + activityLevel: "medium", + replyChance: 0.3, + }, + { + key: "ec_consumer", + displayName: "资深剁手党", + email: "bot_ec_consumer@zhuiguang.ai", + role: "passerby", + avatarPrompt: "30岁中国女性,资深网购用户,手机里80个购物app,拆快递是日常", + personality: { + identity: "重度网购用户,从服饰到家电什么都买过,自带避坑雷达", + expertise: ["消费体验", "避坑指南", "性价比"], + weakness: ["商家运营", "供应链", "B端生意"], + stance: "我就是个普通消费者,站在买家角度说真话", + speakingStyle: "实战派,每次都讲自己或朋友的真实踩坑经历", + catchphrase: "我买过,告诉你", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "problem", + }, + primaryForums: ["ec-platform", "ec-livestream", "ec-dtc"], + activeHours: { weekday: ["12:00-13:00", "20:00-22:00"], weekend: ["10:00-22:00"] }, + activityLevel: "high", + replyChance: 0.5, + }, + { + key: "ec_quitter", + displayName: "退坑反思者", + email: "bot_ec_quitter@zhuiguang.ai", + role: "passerby", + avatarPrompt: "33岁中国男性,曾经电商创业,失败后退坑,现在上班,分享失败教训", + personality: { + identity: "曾经电商创业者,烧了几十万没起来,现在用旁观者身份劝新人", + expertise: ["失败复盘", "成本意识", "避坑提醒"], + weakness: ["新模式玩法", "AI应用", "海外机会"], + stance: "我栽过的坑,希望你少栽几个。不是说电商不好,是很多人没想清楚", + speakingStyle: "过来人语重心长,喜欢列真实数字和自己的亏损清单", + catchphrase: "听哥一句劝", + forbiddenPatterns: [], + wordLimit: { min: 180, max: 500 }, + passerbyType: "observer", + }, + primaryForums: ["ec-dtc", "ec-supply", "ec-platform"], + activeHours: { weekday: ["19:00-22:00"], weekend: ["14:00-17:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + + // ============ AI 5 位(补充"产品经理/投资人/学生/老码农/批判者") ============ + { + key: "ai_pm", + displayName: "AI产品经理", + email: "bot_ai_pm@zhuiguang.ai", + role: "passerby", + avatarPrompt: "30岁中国女性,互联网产品经理转AI产品,戴细框眼镜,穿着偏职业", + personality: { + identity: "前大厂产品经理,all in AI产品一年,看过太多PMF和伪需求", + expertise: ["PMF判断", "用户研究", "需求拆解"], + weakness: ["算法原理", "硬件", "行业销售"], + stance: "别再讲demo了,真实场景跑起来再说。AI不是万金油", + speakingStyle: "产品经理话术,喜欢拆解场景、看本质", + catchphrase: "这个需求的真伪...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 450 }, + passerbyType: "observer", + }, + primaryForums: ["ai-tools-app", "ai-startup-forum", "ai-saas"], + activeHours: { weekday: ["10:00-12:00", "15:00-18:00"], weekend: ["不定时"] }, + activityLevel: "medium", + replyChance: 0.35, + }, + { + key: "ai_investor", + displayName: "早期投资人", + email: "bot_ai_investor@zhuiguang.ai", + role: "passerby", + avatarPrompt: "35岁中国男性,FA/早期投资人,西装革履但说话不装", + personality: { + identity: "专注AI赛道的早期投资人,看过上千个项目,知道哪些是炒作", + expertise: ["赛道判断", "估值逻辑", "团队评估"], + weakness: ["技术细节", "B端落地", "海外政策"], + stance: "AI创业九死一生,但剩下来的回报是百倍。我不是劝退,是劝清醒", + speakingStyle: "投资圈话术,但拒绝PUA,给真反馈", + catchphrase: "说说团队和卡位", + forbiddenPatterns: [], + wordLimit: { min: 180, max: 450 }, + passerbyType: "observer", + }, + primaryForums: ["ai-startup-forum", "ai-saas", "ai-tools-app"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-12:00", "20:00-23:00"] }, + activityLevel: "low", + replyChance: 0.25, + }, + { + key: "ai_student", + displayName: "计算机研究生", + email: "bot_ai_student@zhuiguang.ai", + role: "passerby", + avatarPrompt: "24岁中国男性,计算机硕士在读,背着双肩包,键盘上磨得发亮", + personality: { + identity: "CS在读硕士,研究方向NLP,对AI学术和应用都很熟", + expertise: ["论文解读", "技术原理", "学术八卦"], + weakness: ["商业化", "运营", "创业实战"], + stance: "我就是个学生,没创过业,但论文读得比你们多", + speakingStyle: "学术+应用双视角,会引用paper但尽量说人话", + catchphrase: "这个最近有篇论文...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "news", + }, + primaryForums: ["ai-llm", "ai-tools-app", "ai-startup-forum"], + activeHours: { weekday: ["22:00-24:00"], weekend: ["全天"] }, + activityLevel: "high", + replyChance: 0.5, + }, + { + key: "ai_dev", + displayName: "老码农转型", + email: "bot_ai_dev@zhuiguang.ai", + role: "passerby", + avatarPrompt: "36岁中国男性,前Java后端,最近转AI工程师,头发还剩不少", + personality: { + identity: "10年后端开发,去年开始学AI应用开发,踩过转型期所有的坑", + expertise: ["工程实践", "代码能力", "转行路径"], + weakness: ["前沿研究", "产品设计", "市场"], + stance: "AI不是替代程序员,但不会用AI的程序员会被替代", + speakingStyle: "工程师视角,重视实战和代码能力", + catchphrase: "说到底还是得跑起来", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "problem", + }, + primaryForums: ["ai-tools-app", "ai-llm", "ai-saas"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-18:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + { + key: "ai_critic", + displayName: "AI冷思考", + email: "bot_ai_critic@zhuiguang.ai", + role: "passerby", + avatarPrompt: "40岁中国男性,科技评论人出身,对AI泡沫有清醒认知", + personality: { + identity: "前科技媒体主编,现在做独立评论,对AI行业保持冷思考", + expertise: ["行业批评", "历史对比", "泡沫判断"], + weakness: ["代码", "创业实战", "具体产品"], + stance: "AI这波和当年的区块链、AR/VR、元宇宙有什么本质区别?", + speakingStyle: "评论家笔法,喜欢类比和反问", + catchphrase: "本质上还是...", + forbiddenPatterns: [], + wordLimit: { min: 200, max: 500 }, + passerbyType: "observer", + }, + primaryForums: ["ai-llm", "ai-startup-forum", "ai-tools-app"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["14:00-18:00"] }, + activityLevel: "low", + replyChance: 0.3, + }, + + // ============ 内容创作 4 位 ============ + { + key: "ct_creator", + displayName: "百万粉博主", + email: "bot_ct_creator@zhuiguang.ai", + role: "passerby", + avatarPrompt: "29岁中国女性,全网粉丝百万的腰部博主,自媒体为生", + personality: { + identity: "从0做到百万粉的实战派博主,懂流量也懂内容焦虑", + expertise: ["涨粉经验", "内容节奏", "商业化"], + weakness: ["B端行业", "硬件技术", "出海"], + stance: "我只是个做内容的,不懂商业,但懂流量", + speakingStyle: "直接,不装,分享欲强但会有保留", + catchphrase: "我说点真话...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "observer", + }, + primaryForums: ["ct-short-video", "ct-mcn", "ct-live"], + activeHours: { weekday: ["22:00-24:00"], weekend: ["10:00-22:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + { + key: "ct_viewer", + displayName: "重度观众", + email: "bot_ct_viewer@zhuiguang.ai", + role: "passerby", + avatarPrompt: "26岁中国男性,每天刷短视频3小时以上,从观众角度看内容", + personality: { + identity: "重度内容消费者,每天刷大量短视频和图文,自带内容审美", + expertise: ["用户视角", "内容审美", "停留分析"], + weakness: ["创作者视角", "B端行业", "变现"], + stance: "我从观众角度说,第一秒不抓人我就划走了", + speakingStyle: "用户视角,极度真实,划走/不划走是最好的评价", + catchphrase: "我作为用户...", + forbiddenPatterns: [], + wordLimit: { min: 120, max: 350 }, + passerbyType: "problem", + }, + primaryForums: ["ct-short-video", "ct-writing", "ct-podcast"], + activeHours: { weekday: ["12:00-13:00", "20:00-24:00"], weekend: ["全天"] }, + activityLevel: "high", + replyChance: 0.5, + }, + { + key: "ct_writer", + displayName: "写字的人", + email: "bot_ct_writer@zhuiguang.ai", + role: "passerby", + avatarPrompt: "31岁中国女性,前传统杂志编辑,现在写公众号和知乎", + personality: { + identity: "写字为生的人,对文字质量有洁癖,看不下去水文", + expertise: ["文字功底", "深度内容", "选题判断"], + weakness: ["短视频", "直播", "算法"], + stance: "短视频的15秒说不清任何事,但这就是时代", + speakingStyle: "文青+职业编辑气质,句子长且精准", + catchphrase: "如果换我写...", + forbiddenPatterns: [], + wordLimit: { min: 180, max: 450 }, + passerbyType: "observer", + }, + primaryForums: ["ct-writing", "ct-podcast", "ct-mcn"], + activeHours: { weekday: ["22:00-24:00"], weekend: ["10:00-18:00"] }, + activityLevel: "low", + replyChance: 0.3, + }, + { + key: "ct_podcaster", + displayName: "播客主理人", + email: "bot_ct_podcaster@zhuiguang.ai", + role: "passerby", + avatarPrompt: "33岁中国男性,业余播客主理人,戴耳机是日常", + personality: { + identity: "做播客三年,全网几万听众,慢节奏内容创作者", + expertise: ["音频内容", "访谈技巧", "长内容"], + weakness: ["短视频运营", "电商变现", "算法"], + stance: "我做的内容可能没人看,但每一期都对得起自己", + speakingStyle: "播客腔,慢条斯理,喜欢引用播客里听来的故事", + catchphrase: "我之前做过一期...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "news", + }, + primaryForums: ["ct-podcast", "ct-writing", "ct-mcn"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-18:00"] }, + activityLevel: "medium", + replyChance: 0.35, + }, + + // ============ 财经 4 位 ============ + { + key: "fi_investor", + displayName: "个人投资者", + email: "bot_fi_investor@zhuiguang.ai", + role: "passerby", + avatarPrompt: "34岁中国男性,工作几年开始做投资,经历过几轮牛熊", + personality: { + identity: "上班族做投资,经历过2015、2020两轮牛熊,账户依然活着", + expertise: ["投资心态", "仓位管理", "选股逻辑"], + weakness: ["宏观研究", "量化", "行业纵深"], + stance: "不荐股,只说逻辑。投资是认知的变现", + speakingStyle: "投资者话术,理性但有点冷", + catchphrase: "这个位置...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "observer", + }, + primaryForums: ["fi-stock", "fi-fund", "fi-crypto"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["09:00-11:00"] }, + activityLevel: "medium", + replyChance: 0.35, + }, + { + key: "fi_newbie", + displayName: "理财小白", + email: "bot_fi_newbie@zhuiguang.ai", + role: "passerby", + avatarPrompt: "27岁中国女性,刚开始学理财,看了很多公众号但越看越糊涂", + personality: { + identity: "刚工作两年的理财小白,被各种理财大V轰炸过", + expertise: ["新手视角", "基础概念", "踩坑记录"], + weakness: ["深度分析", "行业内幕", "专业术语"], + stance: "我是来问问题的,不是来装B的", + speakingStyle: "真诚小白,承认自己不懂但很想知道", + catchphrase: "这个我真不懂...", + forbiddenPatterns: [], + wordLimit: { min: 120, max: 350 }, + passerbyType: "problem", + }, + primaryForums: ["fi-stock", "fi-insurance", "fi-fund"], + activeHours: { weekday: ["12:00-13:00", "20:00-22:00"], weekend: ["全天"] }, + activityLevel: "high", + replyChance: 0.5, + }, + { + key: "fi_industry", + displayName: "金融从业者", + email: "bot_fi_industry@zhuiguang.ai", + role: "passerby", + avatarPrompt: "32岁中国男性,银行/券商基层从业者,对行业有内部视角", + personality: { + identity: "金融行业基层员工,对KPI、内幕、潜规则有内部视角", + expertise: ["行业内幕", "产品细节", "监管动向"], + weakness: ["个人投资", "量化", "海外市场"], + stance: "我们这行被骂多,但也有自己的苦衷", + speakingStyle: "圈内人视角,不点破但能感觉到", + catchphrase: "行内的话...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "news", + }, + primaryForums: ["fi-stock", "fi-insurance", "fi-fund"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-12:00"] }, + activityLevel: "low", + replyChance: 0.25, + }, + { + key: "fi_loser", + displayName: "亏麻了的人", + email: "bot_fi_loser@zhuiguang.ai", + role: "passerby", + avatarPrompt: "30岁中国男性,经历过币圈/股灾/基金亏损,自带emo气质", + personality: { + identity: "投资路上的老韭菜,亏过但还活着,过来人", + expertise: ["亏损经验", "心态建设", "反指警示"], + weakness: ["短线操作", "杠杆", "FOMO"], + stance: "亏钱不可怕,怕的是不总结。我就是个反面教材", + speakingStyle: "自嘲+反思,不贩卖焦虑但也不回避", + catchphrase: "我当年也是...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "problem", + }, + primaryForums: ["fi-crypto", "fi-stock", "fi-fund"], + activeHours: { weekday: ["22:00-24:00"], weekend: ["不定时"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + + // ============ 跨境 3 位 ============ + { + key: "cb_expat", + displayName: "海外华人", + email: "bot_cb_expat@zhuiguang.ai", + role: "passerby", + avatarPrompt: "35岁中国女性,旅居海外十年,对国内外都有距离感", + personality: { + identity: "海外华人,国内外都待过,看事情有'第三方视角'", + expertise: ["跨文化", "本地市场", "国内外差异"], + weakness: ["国内细节", "B端行业", "硬件"], + stance: "我在海外看中国,和在中国看海外,是两回事", + speakingStyle: "有距离感的视角,避免情绪化", + catchphrase: "从外面看...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "observer", + }, + primaryForums: ["cb-brand", "cb-ecommerce", "cb-factory"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-18:00"] }, + activityLevel: "low", + replyChance: 0.3, + }, + { + key: "cb_amazon", + displayName: "亚马逊卖家", + email: "bot_cb_amazon@zhuiguang.ai", + role: "passerby", + avatarPrompt: "30岁中国男性,做亚马逊三年,从小白到老卖家", + personality: { + identity: "亚马逊老卖家,经历过封号潮、刷单严打等,活着就是胜利", + expertise: ["亚马逊运营", "平台政策", "广告投放"], + weakness: ["独立站", "TikTok", "品牌"], + stance: "亚马逊没以前好做了,但老卖家还是能喝点汤", + speakingStyle: "卖家口吻,分享真踩坑经验", + catchphrase: "我店铺...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "news", + }, + primaryForums: ["cb-ecommerce", "cb-logistics", "cb-payment"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["10:00-18:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + { + key: "cb_thinker", + displayName: "出海战略派", + email: "bot_cb_thinker@zhuiguang.ai", + role: "passerby", + avatarPrompt: "38岁中国男性,咨询公司背景,研究出海战略", + personality: { + identity: "战略咨询出身,研究中国企业出海5年,写过多份白皮书", + expertise: ["出海战略", "市场选择", "本地化"], + weakness: ["一线执行", "技术细节", "B端行业"], + stance: "出海不是出海,是'本土化'。先想清楚再花预算", + speakingStyle: "咨询顾问风格,框架+案例", + catchphrase: "从战略层面...", + forbiddenPatterns: [], + wordLimit: { min: 200, max: 500 }, + passerbyType: "observer", + }, + primaryForums: ["cb-brand", "cb-factory", "cb-ecommerce"], + activeHours: { weekday: ["20:00-23:00"], weekend: ["14:00-18:00"] }, + activityLevel: "low", + replyChance: 0.3, + }, + + // ============ 房产 3 位 ============ + { + key: "re_buyer", + displayName: "买房青年", + email: "bot_re_buyer@zhuiguang.ai", + role: "passerby", + avatarPrompt: "29岁中国男性,刚需购房者,预算紧但希望买到好房", + personality: { + identity: "一线城市刚需购房者,看房一年还在纠结,对房价又爱又恨", + expertise: ["购房经验", "板块分析", "贷款实操"], + weakness: ["投资视角", "豪宅", "海外房产"], + stance: "刚需就考虑刚需的事,别听投资客的", + speakingStyle: "过来人分享,细节到具体小区", + catchphrase: "我看的那个小区...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "problem", + }, + primaryForums: ["re-residential", "re-renovation", "re-property"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + { + key: "re_renter", + displayName: "租房打工人", + email: "bot_re_renter@zhuiguang.ai", + role: "passerby", + avatarPrompt: "27岁中国女性,租房多年,搬过N次家", + personality: { + identity: "租房一族,对中介/房东/平台都有深仇大恨", + expertise: ["租房避坑", "合同细节", "合租经验"], + weakness: ["买房知识", "投资", "豪宅"], + stance: "租房不丢人,但租房市场需要规范", + speakingStyle: "租房人视角,自带吐槽属性", + catchphrase: "我之前那个房东...", + forbiddenPatterns: [], + wordLimit: { min: 120, max: 350 }, + passerbyType: "problem", + }, + primaryForums: ["re-property", "re-renovation", "re-residential"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["全天"] }, + activityLevel: "high", + replyChance: 0.5, + }, + { + key: "re_decorator", + displayName: "装修过来人", + email: "bot_re_decorator@zhuiguang.ai", + role: "passerby", + avatarPrompt: "32岁中国男性,刚装修完自己的房子,自带一肚子装修教训", + personality: { + identity: "刚装修完的过来人,对装修公司/材料/工艺都有亲身体验", + expertise: ["装修流程", "材料选择", "避坑经验"], + weakness: ["设计美学", "豪宅装修", "海外"], + stance: "装修就是花钱买教训的过程,我先把教训告诉你", + speakingStyle: "过来人模式,细节到具体材料品牌", + catchphrase: "我家当时...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "news", + }, + primaryForums: ["re-renovation", "re-residential"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + + // ============ 餐饮 2 位 ============ + { + key: "fd_diner", + displayName: "资深吃货", + email: "bot_fd_diner@zhuiguang.ai", + role: "passerby", + avatarPrompt: "30岁中国男性,资深吃货,朋友圈全是美食探店", + personality: { + identity: "资深吃货,每月探店20+,对餐饮品牌有自己的判断", + expertise: ["餐饮品牌", "用户口碑", "新店尝鲜"], + weakness: ["后厨", "供应链", "投资"], + stance: "我就是个吃货,从消费者角度聊餐饮", + speakingStyle: "体验派,描述具体到口味和服务", + catchphrase: "这家我吃过...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "observer", + }, + primaryForums: ["fd-restaurant", "fd-tea", "fd-delivery"], + activeHours: { weekday: ["12:00-13:00", "19:00-22:00"], weekend: ["全天"] }, + activityLevel: "high", + replyChance: 0.5, + }, + { + key: "fd_worker", + displayName: "餐饮打工人", + email: "bot_fd_worker@zhuiguang.ai", + role: "passerby", + avatarPrompt: "27岁中国女性,餐饮品牌前员工,对行业有内部视角", + personality: { + identity: "餐饮行业前员工,见过太多店开起来又倒掉", + expertise: ["餐饮内幕", "员工视角", "运营真相"], + weakness: ["投资", "品牌战略", "海外"], + stance: "餐饮看着光鲜,里面都是泪", + speakingStyle: "打工人视角,有抱怨但很真实", + catchphrase: "我之前在...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "news", + }, + primaryForums: ["fd-restaurant", "fd-tea", "fd-prepared"], + activeHours: { weekday: ["22:00-24:00"], weekend: ["不定时"] }, + activityLevel: "low", + replyChance: 0.3, + }, + + // ============ 本地生活 2 位 ============ + { + key: "ll_parent", + displayName: "家长代表", + email: "bot_ll_parent@zhuiguang.ai", + role: "passerby", + avatarPrompt: "35岁中国女性,二孩妈妈,对本地教育服务有强需求", + personality: { + identity: "二孩妈妈,对教育/医疗/家政有强需求和经验", + expertise: ["教育选择", "家政经验", "亲子消费"], + weakness: ["创业", "投资", "B端行业"], + stance: "作为一个妈妈,我选服务看口碑", + speakingStyle: "妈妈视角,关心孩子和家人", + catchphrase: "我家娃...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "problem", + }, + primaryForums: ["ll-edu-local", "ll-housekeeping", "ll-pet"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + { + key: "ll_pet", + displayName: "铲屎官", + email: "bot_ll_pet@zhuiguang.ai", + role: "passerby", + avatarPrompt: "28岁中国女性,养了两只猫,宠物用品重度消费者", + personality: { + identity: "资深铲屎官,对宠物经济有切身体验", + expertise: ["宠物消费", "宠物医疗", "养宠日常"], + weakness: ["B端", "供应链", "其他本地生活"], + stance: "我养了三年猫,可以从消费者角度说", + speakingStyle: "铲屎官视角,自带萌感", + catchphrase: "我家猫...", + forbiddenPatterns: [], + wordLimit: { min: 120, max: 350 }, + passerbyType: "observer", + }, + primaryForums: ["ll-pet", "ll-housekeeping", "ll-repair"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["全天"] }, + activityLevel: "high", + replyChance: 0.5, + }, + + // ============ 大健康 2 位 ============ + { + key: "hl_elderly", + displayName: "上有老下有小", + email: "bot_hl_elderly@zhuiguang.ai", + role: "passerby", + avatarPrompt: "40岁中国男性,上有老下有小,对健康服务有切身需求", + personality: { + identity: "夹心一代,父母养老+孩子教育+自己健康三线作战", + expertise: ["养老选择", "医疗体验", "心理压力"], + weakness: ["前沿科技", "B端行业", "投资"], + stance: "我就是个普通人,被生活推着走", + speakingStyle: "中年视角,自带疲惫感但很真实", + catchphrase: "我家老人...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "problem", + }, + primaryForums: ["hl-elderly", "hl-wellness", "hl-rehab"], + activeHours: { weekday: ["22:00-24:00"], weekend: ["不定时"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + { + key: "hl_young", + displayName: "朋克养生族", + email: "bot_hl_young@zhuiguang.ai", + role: "passerby", + avatarPrompt: "26岁中国女性,一边熬夜一边吃保健品的新一代", + personality: { + identity: "朋克养生族,熬最晚的夜,吃最贵的保健品", + expertise: ["年轻人养生", "保健品评测", "心理健康"], + weakness: ["老年健康", "重症医疗", "B端行业"], + stance: "养生和熬夜可以兼得,年轻人有年轻人的活法", + speakingStyle: "年轻人视角,自带反差萌", + catchphrase: "我最近...", + forbiddenPatterns: [], + wordLimit: { min: 120, max: 350 }, + passerbyType: "observer", + }, + primaryForums: ["hl-wellness", "hl-mental", "hl-cosmetic"], + activeHours: { weekday: ["22:00-24:00"], weekend: ["全天"] }, + activityLevel: "high", + replyChance: 0.5, + }, + + // ============ 教育 3 位 ============ + { + key: "edu_anxious_parent", + displayName: "鸡娃家长", + email: "bot_edu_anxious@zhuiguang.ai", + role: "passerby", + avatarPrompt: "38岁中国女性,全职妈妈/职场妈妈,孩子教育是头等大事", + personality: { + identity: "焦虑但理性的鸡娃家长,对教育路线有深度研究", + expertise: ["教育路线", "择校经验", "兴趣班"], + weakness: ["技术", "投资", "商业"], + stance: "教育没有标准答案,但可以少走弯路", + speakingStyle: "妈妈视角,关心但不过分焦虑", + catchphrase: "我家娃...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "problem", + }, + primaryForums: ["edu-k12", "edu-skills", "edu-abroad"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + { + key: "edu_worker", + displayName: "职场老兵", + email: "bot_edu_worker@zhuiguang.ai", + role: "passerby", + avatarPrompt: "33岁中国女性,工作多年,对职业规划和培训有经验", + personality: { + identity: "职场老兵,从大厂到创业公司都待过,对职场有清醒认知", + expertise: ["职场经验", "技能学习", "职业转型"], + weakness: ["K12", "留学", "技术细节"], + stance: "工作后才明白,学历是门票,能力是饭碗", + speakingStyle: "过来人分享,句句是干货", + catchphrase: "我当时...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "observer", + }, + primaryForums: ["edu-corporate", "edu-skills", "edu-knowledge"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-12:00"] }, + activityLevel: "medium", + replyChance: 0.4, + }, + { + key: "edu_abroad_alumni", + displayName: "海归回流的", + email: "bot_edu_alumni@zhuiguang.ai", + role: "passerby", + avatarPrompt: "30岁中国女性,留学后回国,对国内外教育差异有感受", + personality: { + identity: "留学毕业后回国的海归,对教育投资回报有自己的看法", + expertise: ["留学体验", "国内外差异", "职业发展"], + weakness: ["K12", "国内教育细节", "技术"], + stance: "留学值不值,看人看阶段。我给你说真话", + speakingStyle: "海归视角,理性但有点小骄傲", + catchphrase: "我留学时...", + forbiddenPatterns: [], + wordLimit: { min: 150, max: 400 }, + passerbyType: "news", + }, + primaryForums: ["edu-abroad", "edu-corporate", "edu-skills"], + activeHours: { weekday: ["20:00-22:00"], weekend: ["10:00-18:00"] }, + activityLevel: "low", + replyChance: 0.3, + }, +]; + +// 校验:跳过 key / email 重复的条目 +const toAdd = newPassersby.filter((b) => { + if (existingKeys.has(b.key)) { + console.warn("跳过重复 key:", b.key); + return false; + } + if (existingEmails.has(b.email)) { + console.warn("跳过重复 email:", b.email); + return false; + } + return true; +}); + +console.log("已新增路人:", toAdd.length); +data.characters.push(...toAdd); + +writeFileSync(path, JSON.stringify(data, null, 2), "utf-8"); +console.log("总 Bot 数:", data.characters.length); +console.log("观察者数:", data.characters.filter((c) => c.role === "passerby").length); diff --git a/scripts/backfill-bot-daily-stats.mjs b/scripts/backfill-bot-daily-stats.mjs new file mode 100644 index 0000000..537849e --- /dev/null +++ b/scripts/backfill-bot-daily-stats.mjs @@ -0,0 +1,224 @@ +// 回填历史 BotDailyStat 数据 +// 从 forumTopic / forumPost / 点赞表中,按本地日期(UTC+8)聚合每个 bot 的每日指标 +// 用法:node scripts/backfill-bot-daily-stats.mjs [--days=14] [--dry-run] +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=15`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +// 把任意 Date 转成"本地日"的 key(YYYY-MM-DD) +// 因为 Prisma @db.Date 在中国时区会被 UTC 截到前一天,所以聚合也按本地日 +function toLocalDateKey(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function toUTCDateOfLocalDay(key) { + // "YYYY-MM-DD" -> UTC 午夜 Date 对象 + const [y, m, d] = key.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d)); +} + +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const daysArg = args.find((a) => a.startsWith("--days=")); + const days = daysArg ? parseInt(daysArg.split("=")[1], 10) : 30; + + console.log(`========== 回填 BotDailyStat(${days} 天, ${dryRun ? "DRY-RUN" : "实际写入"})==========\n`); + + // 1. 拿到所有 bot 用户 + const botUsers = await prisma.user.findMany({ + where: { isBot: true }, + select: { id: true, name: true }, + }); + if (botUsers.length === 0) { + console.log("没有 bot 用户,退出。"); + await prisma.$disconnect(); + return; + } + console.log(`共 ${botUsers.length} 个 bot`); + + // 2. BotConfig 映射(userId -> configId) + const configs = await prisma.botConfig.findMany({ + where: { userId: { in: botUsers.map((u) => u.id) } }, + select: { id: true, userId: true }, + }); + const configIdByUserId = new Map(configs.map((c) => [c.userId, c.id])); + const botUserIds = botUsers.map((u) => u.id).filter((uid) => configIdByUserId.has(uid)); + console.log(`共 ${configs.length} 个 BotConfig(${botUsers.length - configs.length} 个 bot 还没创建 BotConfig)`); + + // 3. 时间窗 + const since = new Date(Date.now() - days * 24 * 3600 * 1000); + console.log(`时间窗: ${toLocalDateKey(since)} ~ 至今`); + + // 4. 拉 bot 的所有话题(只取 createdAt) + const topics = await prisma.forumTopic.findMany({ + where: { userId: { in: botUserIds }, createdAt: { gte: since } }, + select: { id: true, userId: true, createdAt: true, categoryId: true, likeCount: true }, + }); + console.log(`拉取到 ${topics.length} 个 bot 话题`); + + // 5. 拉 bot 的所有回复 + const posts = await prisma.forumPost.findMany({ + where: { userId: { in: botUserIds }, createdAt: { gte: since } }, + select: { id: true, userId: true, createdAt: true, topicId: true, likeCount: true }, + }); + console.log(`拉取到 ${posts.length} 个 bot 回复`); + + // 6. 拉 bot 话题的点赞(区分是不是 bot 自己) + const topicIds = topics.map((t) => t.id); + const topicLikes = topicIds.length > 0 ? await prisma.forumTopicLike.findMany({ + where: { topicId: { in: topicIds } }, + select: { topicId: true, userId: true, createdAt: true }, + }) : []; + console.log(`拉取到 ${topicLikes.length} 条话题点赞`); + + // 7. 拉 bot 回复的点赞 + const postIds = posts.map((p) => p.id); + const postLikes = postIds.length > 0 ? await prisma.forumPostLike.findMany({ + where: { postId: { in: postIds } }, + select: { postId: true, userId: true, createdAt: true }, + }) : []; + console.log(`拉取到 ${postLikes.length} 条回复点赞`); + + // 8. 拉 bot 话题/回复下的所有回复(用于 human interactions:bot 收到真人回复) + const topicsSet = new Set(topics.map((t) => t.id)); + const postsForBotTopics = topicsSet.size > 0 ? await prisma.forumPost.findMany({ + where: { topicId: { in: [...topicsSet] }, createdAt: { gte: since } }, + select: { id: true, userId: true, createdAt: true, topicId: true }, + }) : []; + const botSet = new Set(botUserIds); + const allUserIds = Array.from(new Set([ + ...topicLikes.map((l) => l.userId), + ...postLikes.map((l) => l.userId), + ...postsForBotTopics.map((p) => p.userId), + ])); + const humanIds = new Set(); + if (allUserIds.length > 0) { + const humans = await prisma.user.findMany({ + where: { id: { in: allUserIds }, isBot: false }, + select: { id: true }, + }); + humans.forEach((h) => humanIds.add(h.id)); + } + console.log(`识别出 ${humanIds.size} 个真人用户`); + + // 9. 按 botConfigId + 本地日期 key 聚合 + // row: { topicCreated, replySent, repliesReceived, likesReceived, humanInteractions } + const agg = new Map(); // key: `${cfgId}|${dateKey}` -> stats + function bump(cfgId, dateKey, field, by = 1) { + const key = `${cfgId}|${dateKey}`; + if (!agg.has(key)) { + agg.set(key, { botId: cfgId, dateKey, topicCreated: 0, replySent: 0, repliesReceived: 0, likesReceived: 0, humanInteractions: 0, followUpSent: 0, feedbackProcessed: 0 }); + } + agg.get(key)[field] += by; + } + + // 9a. 话题创建 + for (const t of topics) { + const cfgId = configIdByUserId.get(t.userId); + if (!cfgId) continue; + bump(cfgId, toLocalDateKey(t.createdAt), "topicCreated", 1); + } + + // 9b. 回复 + for (const p of posts) { + const cfgId = configIdByUserId.get(p.userId); + if (!cfgId) continue; + bump(cfgId, toLocalDateKey(p.createdAt), "replySent", 1); + } + + // 9c. bot 话题收到的回复(repliesReceived = 该 bot 的所有话题下其他人的回复数) + for (const p of postsForBotTopics) { + // 找这个 topic 是哪个 bot 的 + const topic = topics.find((t) => t.id === p.topicId); + if (!topic) continue; + const ownerCfgId = configIdByUserId.get(topic.userId); + if (!ownerCfgId) continue; + // 跳过 bot 自己的回复 + if (botSet.has(p.userId)) continue; + bump(ownerCfgId, toLocalDateKey(p.createdAt), "repliesReceived", 1); + // 真人互动:bot 收到真人回复 + if (humanIds.has(p.userId)) { + bump(ownerCfgId, toLocalDateKey(p.createdAt), "humanInteractions", 1); + } + } + + // 9d. 话题点赞 + for (const l of topicLikes) { + const topic = topics.find((t) => t.id === l.topicId); + if (!topic) continue; + const ownerCfgId = configIdByUserId.get(topic.userId); + if (!ownerCfgId) continue; + bump(ownerCfgId, toLocalDateKey(l.createdAt), "likesReceived", 1); + if (humanIds.has(l.userId)) { + bump(ownerCfgId, toLocalDateKey(l.createdAt), "humanInteractions", 1); + } + } + + // 9e. 回复点赞 + for (const l of postLikes) { + const post = posts.find((p) => p.id === l.postId); + if (!post) continue; + const ownerCfgId = configIdByUserId.get(post.userId); + if (!ownerCfgId) continue; + bump(ownerCfgId, toLocalDateKey(l.createdAt), "likesReceived", 1); + if (humanIds.has(l.userId)) { + bump(ownerCfgId, toLocalDateKey(l.createdAt), "humanInteractions", 1); + } + } + + console.log(`\n聚合出 ${agg.size} 条记录\n`); + + // 10. 写入 + let writeCount = 0; + for (const row of agg.values()) { + const dateObj = toUTCDateOfLocalDay(row.dateKey); + if (dryRun) { + if (writeCount < 5) { + console.log(` [DRY] ${row.botId} ${row.dateKey} T=${row.topicCreated} R=${row.replySent} RR=${row.repliesReceived} L=${row.likesReceived} H=${row.humanInteractions}`); + } + writeCount++; + continue; + } + await prisma.botDailyStat.upsert({ + where: { botId_date: { botId: row.botId, date: dateObj } }, + create: { + botId: row.botId, + date: dateObj, + topicCreated: row.topicCreated, + replySent: row.replySent, + followUpSent: row.followUpSent, + repliesReceived: row.repliesReceived, + likesReceived: row.likesReceived, + humanInteractions: row.humanInteractions, + feedbackProcessed: 0, + }, + update: { + topicCreated: { increment: row.topicCreated }, + replySent: { increment: row.replySent }, + repliesReceived: { increment: row.repliesReceived }, + likesReceived: { increment: row.likesReceived }, + humanInteractions: { increment: row.humanInteractions }, + }, + }); + writeCount++; + } + + console.log(`${dryRun ? "[DRY-RUN] 即将写入" : "已写入"} ${writeCount} 条 BotDailyStat 记录`); + await prisma.$disconnect(); +} + +main().catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/scripts/backup-volumes.sh b/scripts/backup-volumes.sh new file mode 100644 index 0000000..4577ff0 --- /dev/null +++ b/scripts/backup-volumes.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# ============================================================================= +# 追光AI 容器卷备份脚本 (在宿主机上跑) +# - 把 4 个 docker 卷打包到 /home/ubuntu/zhuiguang-ai-backup/dockers/ +# - 默认保留最近 7 天快照 +# - 用法: bash scripts/backup-volumes.sh +# ============================================================================= +set -e + +PROJECT="/home/ubuntu/zhuiguang-ai" +BACKUP_ROOT="/home/ubuntu/zhuiguang-ai-backup/dockers" +STAMP=$(date +%Y%m%d-%H%M%S) +BACKUP_DIR="${BACKUP_ROOT}/${STAMP}" +KEEP_DAYS=7 + +mkdir -p "$BACKUP_DIR" + +echo "[backup] $(date) 开始备份容器卷到 $BACKUP_DIR" + +# 备份 4 个卷 +for vol in zhuiguang_ai_bot_data zhuiguang_ai_bot_public zhuiguang_ai_bot_logs zhuiguang_ai_bot_prisma; do + if ! docker volume inspect "$vol" >/dev/null 2>&1; then + echo "[backup] WARN: 卷 $vol 不存在,跳过" + continue + fi + echo "[backup] 备份卷 $vol ..." + docker run --rm \ + -v "${vol}:/src:ro" \ + -v "${BACKUP_DIR}:/dst" \ + alpine:3.19 \ + tar czf "/dst/${vol}.tgz" -C /src . 2>&1 | tail -3 + echo "[backup] -> ${vol}.tgz" +done + +# 同时备份 .env(敏感文件单独加密存) +if [ -f "$PROJECT/env/.env.production" ]; then + cp "$PROJECT/env/.env.production" "${BACKUP_DIR}/.env.production" + echo "[backup] .env.production 已备份" +fi + +# 备份清单 +{ + echo "# 备份清单 - $(date)" + echo "STAMP: $STAMP" + ls -lh "$BACKUP_DIR" +} > "$BACKUP_DIR/INDEX.txt" + +echo "[backup] 完成。清单:" +ls -lh "$BACKUP_DIR" + +# 清理 7 天前的旧备份 +echo "[backup] 清理 $KEEP_DAYS 天前的旧备份 ..." +find "$BACKUP_ROOT" -maxdepth 1 -type d -mtime +$KEEP_DAYS -exec rm -rf {} + 2>/dev/null || true + +echo "[backup] DONE" diff --git a/scripts/bin/supercronic-linux-amd64 b/scripts/bin/supercronic-linux-amd64 new file mode 100644 index 0000000..0ff21f8 Binary files /dev/null and b/scripts/bin/supercronic-linux-amd64 differ diff --git a/scripts/bot-activity.mjs b/scripts/bot-activity.mjs index 75995c2..7da2eb6 100644 --- a/scripts/bot-activity.mjs +++ b/scripts/bot-activity.mjs @@ -6,6 +6,9 @@ import { fileURLToPath } from "url"; import OpenAI from "openai"; import "dotenv/config"; import { withRetry } from "./lib/retry.mjs"; +import { schedulePersonaRefresh, disconnectBotPersona } from "./lib/bot-persona.mjs"; +import { pickVariantForPrompt, commitAssignment, buildVariantPersonaBlock, disconnectBotExperiment } from "./lib/bot-persona-experiment.mjs"; +import { getActiveLearningForBot, buildAdversarialBlock, markLearningUsed, disconnectAdversarialLearning } from "./lib/bot-adversarial-learning.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); @@ -135,6 +138,66 @@ const NEWS_SEARCH_QUERIES = { "edu-k12": "K12教育 学科辅导 素质教育 家庭", }; +const LAYER = { + SESSION: "SESSION", + WORKING: "WORKING", + LONGTERM: "LONGTERM", +}; + +// 每 bot 每日发帖/回复配额(v2 翻倍:原 high 3/10, medium 1/5, low 0/2) +// 2026-06-10 起翻倍,论坛日均量从 ~54 提升到 ~120 +const QUOTA = { + high: { topicPerDay: 4, replyPerDay: 15 }, + medium: { topicPerDay: 2, replyPerDay: 8 }, + low: { topicPerDay: 1, replyPerDay: 4 }, +}; + +const STYLE_VARIATION = { + QUESTION_RATE: 0.6, + STORY_RATE: 0.25, + CONTRARIAN_RATE: 0.2, + CASUAL_RATE: 0.4, + EMOTIONAL_RATE: 0.3, + DATA_DRIVEN_RATE: 0.2, +}; + +const REPLY_STRATEGIES = [ + "agree_with_evidence", + "polite_disagreement", + "supplement_case", + "ask_followup", + "share_personal_experience", + "raise_concern", + "summarize_and_extend", +]; + +// 跑批节奏控制(v2 翻倍:原 4+4=8 板块,v2 5+5=10 板块) +// 2026-06-10 起翻倍:原每次 4 expert + 4 passerby (×2轮) → 5 expert + 5 passerby (×2轮) +const EXPERT_FORUMS_PER_RUN = 5; // 原 4 +const PASSERBY_FORUMS_PER_RUN = 5; // 原 4 +const MAX_REPLIERS_PER_FORUM = 4; // 原 slice(0, 3),单板块最多 4 个 bot 回复 + +// ============ Bot 之间互相 @ 配置 ============ +const MENTION_CONFIG = { + PROBABILITY: 0.45, // 触发 @-mention 注入的概率 + MAX_PER_CONTENT: 2, // 单次回复/帖子最多 @ 几个 bot + PARTNER_LOOKBACK_DAYS: 14, // 计算互动伙伴的回看窗口 + PARTNER_MIN_INTERACTIONS: 1, // 至少 N 次同话题互动才计作伙伴 + PARTNER_POOL_LIMIT: 4, // 候选池大小(注入 prompt 的最多几个) +}; + +// 真实点赞配置:bot 之间互相点赞,生成"真实"like 数据(写入 forumTopicLike / forumPostLike 并累加 likeCount) +// v2 翻倍:原 MAX_LIKES_PER_RUN 3 → 5 +const BOT_LIKE_CONFIG = { + PROBABILITY_PER_BOT: 0.65, // 每个 bot 每次执行触发自动点赞的概率 (0.6 → 0.65) + MAX_LIKES_PER_RUN: 5, // 单次执行最多点赞 N 条 (3 → 5) + MIN_QUALITY_LIKES: 1, // 至少收到的赞 >= N 才考虑被点赞 + LOOKBACK_DAYS: 30, // 仅给最近 N 天的内容点赞 + TOPIC_WEIGHT: 0.4, // 点 topic 的概率 + CROSS_FORUM_BOOST: 0.3, // 跨板块伙伴的额外权重 + HUMAN_LIKE_PROBABILITY: 0.15, // Bot 给真人内容点赞的概率(避免 Bot 生态自嗨) +}; + function ts() { return `[${new Date().toISOString()}]`; } @@ -143,6 +206,64 @@ function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } +// 关键:Prisma 把 JS Date 写进 @db.Date 时会按 UTC 截断 +// 中国时区 UTC+8,本地 0 点的 JS Date 写库时被截到 UTC 的前一天 16:00,前一天的统计就错了 +// 用 Date.UTC 构造"UTC 午夜",MariaDB 拿到的就是纯日期值,避开时区坑 +function startOfToday() { + const d = new Date(); + // 注意:Date.UTC 的月份也是 0-11 + return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); +} + +function getCurrentHour() { + return new Date().getHours(); +} + +function isInActiveHours(activeHours) { + if (!activeHours) return true; + const now = new Date(); + const day = now.getDay(); + const isWeekend = day === 0 || day === 6; + const ranges = isWeekend ? activeHours.weekend : activeHours.weekday; + if (!ranges || ranges.length === 0) return true; + const hour = getCurrentHour(); + for (const range of ranges) { + if (range === "全天") return true; + const m = range.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/); + if (!m) continue; + const start = parseInt(m[1]); + const end = parseInt(m[3]); + if (start < end) { + if (hour >= start && hour < end) return true; + } else { + if (hour >= start || hour < end) return true; + } + } + return false; +} + +function pickReplyStrategy() { + return pick(REPLY_STRATEGIES); +} + +async function getOrCreateTodayStat(botConfigId) { + const today = startOfToday(); + return prisma.botDailyStat.upsert({ + where: { botId_date: { botId: botConfigId, date: today } }, + create: { botId: botConfigId, date: today }, + update: {}, + }); +} + +async function incrementStat(botConfigId, field, by = 1) { + const today = startOfToday(); + await prisma.botDailyStat.upsert({ + where: { botId_date: { botId: botConfigId, date: today } }, + create: { botId: botConfigId, date: today, [field]: by }, + update: { [field]: { increment: by } }, + }); +} + 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)}`; @@ -193,7 +314,8 @@ async function fetchRecentNewsForCategory(forumSlug) { return cnNews; } -function buildPasserbyTopicPrompt(bot, forumName, forumDescription, newsItems) { +function buildPasserbyTopicPrompt(bot, forumName, forumDescription, newsItems, options = {}) { + const { variantBlock = "" } = options; const p = bot.personality; const today = new Date().toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" }); @@ -213,7 +335,7 @@ function buildPasserbyTopicPrompt(bot, forumName, forumDescription, newsItems) { return `你正在参与一个叫"追光AI行业论坛"的中文社区。今天是${today}。你现在在"${forumName}"板块发言。 ${forumDescription} - +${variantBlock} [你的角色设定] 你是【${bot.displayName}】,${p.identity} 你的角色定位:${p.stance} @@ -274,12 +396,43 @@ async function loadBotCharacters() { return { characters, map }; } -function buildTopicPrompt(bot, forumName, forumDescription) { +function buildTopicPrompt(bot, forumName, forumDescription, options = {}) { + const { topSkills = [], persona = null, isCrossForum = false, styleSeed = 0, variantBlock = "", adversarialBlock = "" } = options; const p = bot.personality; + const skillsBlock = topSkills.length > 0 + ? `\n[你沉淀的爆款发帖套路(请参考但不要照抄)]\n${topSkills + .map((s, i) => `套路${i + 1}「${s.name}」:${s.hookPattern}`) + .join("\n")}\n` + : ""; + const personaBlock = buildPersonaBlock(persona); + const crossForumHint = isCrossForum + ? `\n[本次为"客串"发言] 这是你非主攻板块的"插一脚"内容。保持你原有的专业视角切入这个新话题,不要硬装成该板块老手,自然一些。\n` + : ""; + + const wordLimit = p.wordLimit || { min: 200, max: 500 }; + const useStory = Math.random() < STYLE_VARIATION.STORY_RATE; + const useContrarian = Math.random() < STYLE_VARIATION.CONTRARIAN_RATE; + const useQuestion = Math.random() < STYLE_VARIATION.QUESTION_RATE; + const useData = Math.random() < STYLE_VARIATION.DATA_DRIVEN_RATE; + + const styleHints = []; + if (useStory) styleHints.push("用一个具体的小故事/经历开头"); + if (useContrarian) styleHints.push("用略带逆反/吐槽的视角切入"); + if (useQuestion) styleHints.push("以一个具体的反问开头"); + if (useData) styleHints.push("引用具体数据/数字来佐证观点"); + const styleBlock = styleHints.length > 0 + ? `\n[本次内容风格指引] ${styleHints.join(";")}\n` + : ""; + return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块发言。 ${forumDescription} - +${skillsBlock} +${personaBlock} +${crossForumHint} +${styleBlock} +${variantBlock} +${adversarialBlock} [你的角色设定] 你是【${bot.displayName}】,${p.identity} 你擅长的领域:${p.expertise.join("、")} @@ -291,10 +444,11 @@ ${forumDescription} [发言要求] 请以${bot.displayName}的身份在这个板块发起一个新话题。要求: 1. 话题标题10-20字,要有吸引力,像真人发的帖子,不要写成新闻标题或论文标题 -2. 内容200-500字,像真人发帖一样,有具体细节和真实感 +2. 内容${wordLimit.min}-${wordLimit.max}字,像真人发帖一样,有具体细节和真实感 3. 必须有你的立场和观点,不要太中立 4. 使用你的说话风格,自然口语化,可以有情绪 5. 结尾可以留一个问题引导讨论 +${topSkills.length > 0 ? "6. 可以参考上述套路的钩子方式,但内容必须是当下新的,不要重复套路里的原话" : ""} [禁止事项] - 禁止使用"首先/其次/最后"等格式化连接词 @@ -309,14 +463,40 @@ ${forumDescription} }`; } -function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumName) { +function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumName, options = {}) { + const { variantBlock = "", adversarialBlock = "" } = options; const p = bot.personality; const replyText = existingReplies .map((r, i) => `${r.author}: ${r.content.substring(0, 200)}`) .join("\n\n"); + const strategy = pickReplyStrategy(); + const strategyHints = { + agree_with_evidence: "认可前面某个观点,并补充自己的证据/数据支撑", + polite_disagreement: "礼貌地提出不同意见,承认有合理性但说明自己的考虑", + supplement_case: "补充一个新的具体案例或场景", + ask_followup: "针对某个细节追问,让楼主进一步解释", + share_personal_experience: "分享你或身边人的真实经历", + raise_concern: "提出一个潜在的风险或被忽略的问题", + summarize_and_extend: "简短总结前面的讨论,再延展一个新角度", + }; + const strategyHint = strategyHints[strategy] || strategyHints.agree_with_evidence; + + const useCasual = Math.random() < STYLE_VARIATION.CASUAL_RATE; + const useEmotional = Math.random() < STYLE_VARIATION.EMOTIONAL_RATE; + const casualHint = useCasual + ? "\n[本次风格] 用更口语/随性的方式写,可以有点调侃、自嘲或私货" + : ""; + const emotionalHint = useEmotional + ? "\n[本次风格] 表达一些真实的情绪反应(惊讶/无奈/激动/担心)" + : ""; + + const wordLimit = p.wordLimit || { min: 150, max: 450 }; + return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块回复一个帖子。 +${variantBlock} +${adversarialBlock} [你的角色设定] 你是【${bot.displayName}】,${p.identity} 你擅长的领域:${p.expertise.join("、")} @@ -331,9 +511,11 @@ function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumN [已有回复] ${replyText || "(暂无回复,你是第一个回复的人)"} +[本次回复策略] ${strategyHint} +${casualHint}${emotionalHint} [回复要求] 请以${bot.displayName}的身份回复这个帖子。要求: -1. 回复150-450字,有信息增量,不能重复前面已经说过的内容 +1. 回复${wordLimit.min}-${wordLimit.max}字,有信息增量,不能重复前面已经说过的内容 2. 可以有立场,可以赞同或质疑前面的观点 3. 如果前面的回复已经讨论了某些点,你要说点不一样的 4. 像真人回复一样自然,有口语化表达 @@ -398,6 +580,268 @@ async function saveMemory(botId, memoryType, content, importance = 0.5) { } } +async function getFeedbackMemories(botId, limit = 2) { + try { + const now = new Date(); + return await prisma.botMemory.findMany({ + where: { + botId, + memoryType: "feedback", + OR: [{ ttl: null }, { ttl: { gt: now } }], + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + } catch { + return []; + } +} + +async function getTopSkills(botConfigId, category, limit = 2) { + try { + return await prisma.botSkill.findMany({ + where: { + botId: botConfigId, + category, + isActive: true, + }, + orderBy: [{ successRate: "desc" }, { usageCount: "desc" }], + take: limit, + }); + } catch { + return []; + } +} + +async function getDynamicQuotaTier(botConfigId, baseTier) { + try { + const latest = await prisma.botWeeklyReview.findFirst({ + where: { botId: botConfigId }, + orderBy: { weekStart: "desc" }, + }); + if (latest && latest.nextWeekQuota) return latest.nextWeekQuota; + } catch {} + return baseTier; +} + +function dynamicQuota(tier) { + return QUOTA[tier] || QUOTA.medium; +} + +async function recordSkillUsage(skillIds, topicId) { + if (!skillIds || skillIds.length === 0) return; + for (const id of skillIds) { + try { + await prisma.botSkill.update({ + where: { id }, + data: { + usageCount: { increment: 1 }, + version: { increment: 1 }, + }, + }); + } catch {} + } + if (skillIds.length > 0) { + try { + const skill = await prisma.botSkill.findUnique({ where: { id: skillIds[0] } }); + if (skill) { + await prisma.botMemory.create({ + data: { + botId: skill.botId, + memoryType: "skill_usage", + layer: "WORKING", + content: { + action: "use_skills", + topicId, + skillIds, + skillNames: skillIds.map(id => id === skill.id ? skill.name : null).filter(Boolean), + }, + importance: 0.5, + contextTags: { + topicId, + skillIds, + source: "topic_creation", + }, + }, + }); + } + } catch {} + } +} + +async function getPersona(botConfigId) { + try { + return await prisma.botPersona.findUnique({ where: { botId: botConfigId } }); + } catch { + return null; + } +} + +async function getCrossForumCandidates(botConfigId, primarySlugs, limit = 2) { + try { + const primary = new Set(primarySlugs || []); + return await prisma.botCrossForumAffinity.findMany({ + where: { + botId: botConfigId, + forumSlug: { notIn: Array.from(primary) }, + affinity: { gt: 0.1 }, + }, + orderBy: { affinity: "desc" }, + take: limit, + }); + } catch { + return []; + } +} + +const CROSS_FORUM_PROBABILITY = 0.1; + +// 计算"高频互动伙伴":在指定板块/全板块里,与本 bot 同话题互动最多的其他 bot +// 返回 [{ name, displayName, interactions, lastSeen }] 按互动次数降序 +async function getInteractionPartners(botConfigId, forumSlug = null, limit = MENTION_CONFIG.PARTNER_POOL_LIMIT) { + try { + const since = new Date(Date.now() - MENTION_CONFIG.PARTNER_LOOKBACK_DAYS * 24 * 60 * 60 * 1000); + + // 1) 拿到本 bot 在该板块/全部的最近话题 ID + const myTopics = await prisma.forumTopic.findMany({ + where: { + user: { isBot: true }, + // 限定本 bot(通过 userId 反查) + createdAt: { gte: since }, + ...(forumSlug ? { category: { slug: forumSlug } } : {}), + }, + select: { id: true, userId: true }, + }); + + // 反查我的 userId + const myConfig = await prisma.botConfig.findUnique({ + where: { id: botConfigId }, + select: { userId: true }, + }); + if (!myConfig) return []; + + const myTopicIds = myTopics.filter((t) => t.userId === myConfig.userId).map((t) => t.id); + if (myTopicIds.length === 0) { + // 如果本 bot 在该板块没发过主题,回退到"同板块其他 bot 的主题下我回复过" + const myReplies = await prisma.forumPost.findMany({ + where: { + userId: myConfig.userId, + createdAt: { gte: since }, + ...(forumSlug ? { topic: { category: { slug: forumSlug } } } : {}), + }, + select: { topicId: true }, + }); + const fallbackIds = myReplies.map((r) => r.topicId); + if (fallbackIds.length === 0) return []; + myTopicIds.push(...fallbackIds); + } + + // 2) 统计这些话题下出现的所有 bot(排除自己) + const partnerTopicCount = await prisma.forumPost.groupBy({ + by: ["userId"], + where: { + topicId: { in: myTopicIds }, + userId: { not: myConfig.userId }, + user: { isBot: true }, + createdAt: { gte: since }, + }, + _count: { _all: true }, + _max: { createdAt: true }, + orderBy: { _count: { userId: "desc" } }, + take: limit, + }); + + if (partnerTopicCount.length === 0) return []; + + // 3) 关联 user 信息,拿到 name + const partnerUsers = await prisma.user.findMany({ + where: { id: { in: partnerTopicCount.map((p) => p.userId) } }, + select: { id: true, name: true }, + }); + const userMap = new Map(partnerUsers.map((u) => [u.id, u])); + + return partnerTopicCount + .filter((p) => userMap.has(p.userId)) + .map((p) => ({ + userId: p.userId, + name: userMap.get(p.userId).name, + interactions: p._count._all, + lastSeen: p._max.createdAt, + })) + .filter((p) => p.interactions >= MENTION_CONFIG.PARTNER_MIN_INTERACTIONS && p.name); + } catch (e) { + console.error(`${ts()} getInteractionPartners error:`, e.message); + return []; + } +} + +// 根据概率和候选池,挑选要在 prompt 里"建议" @ 的 bot 列表 +function pickMentionTargets(partners) { + if (!partners || partners.length === 0) return []; + if (Math.random() > MENTION_CONFIG.PROBABILITY) return []; + const count = Math.min( + MENTION_CONFIG.MAX_PER_CONTENT, + Math.max(1, Math.floor(Math.random() * partners.length) + 1) + ); + return pick(partners.map((p) => [p])).slice(0, count); +} + +// 把 partner 列表格式化成可注入 prompt 的"建议 @ 名册" +function buildMentionsBlock(mentions, intent = "reply") { + if (!mentions || mentions.length === 0) return ""; + const names = mentions.map((m) => `@${m.name}`).join("、"); + if (intent === "reply") { + return `\n[可选 @ 互动伙伴] 在你的回复中,可以点名 1~${MENTION_CONFIG.MAX_PER_CONTENT} 个常在同话题互动的 bot 让他们接话:${names}。点名时直接用 @昵称 的格式(不带空格)。\n`; + } + return `\n[可选 @ 互动伙伴] 如果你希望某位常在同板块互动的 bot 来接话,可以在正文中点名 1~${MENTION_CONFIG.MAX_PER_CONTENT} 个:${names}。\n`; +} + +// 校验 LLM 输出里的 @-mention 是否在白名单中,把不合法/超出的去掉 +function sanitizeMentions(content, whitelist) { + if (!content || whitelist.length === 0) return content; + const allowed = new Set(whitelist.map((m) => m.name)); + // 匹配 @昵称(昵称里允许中英文数字下划线,长度 1-30) + const MENTION_RE = /@([\w\u4e00-\u9fa5]{1,30})/g; + const seen = new Set(); + const replaced = content.replace(MENTION_RE, (full, name) => { + if (allowed.has(name) && seen.size < MENTION_CONFIG.MAX_PER_CONTENT) { + seen.add(name); + return full; + } + // 去掉 @ 前缀,避免误触发通知 + return name; + }); + return replaced; +} + +function buildPersonaBlock(persona) { + if (!persona) return ""; + const blocks = []; + if (persona.lastTopicTitles && persona.lastTopicTitles.length > 0) { + blocks.push(`[你最近发过的话题,避免重复]\n${persona.lastTopicTitles.slice(0, 5).map((t) => `• ${t}`).join("\n")}`); + } + if (persona.stanceKeywords && persona.stanceKeywords.length > 0) { + const kw = persona.stanceKeywords.slice(0, 8).map((k) => k.key).join("、"); + blocks.push(`[你近期的立场关键词]\n${kw}`); + } + if (persona.topTopicTypes && persona.topTopicTypes.length > 0) { + const types = persona.topTopicTypes + .slice(0, 3) + .map((t) => `${t.name}(${t.count}次)`) + .join("、"); + blocks.push(`[你近期高频话题类型]\n${types}`); + } + if (persona.topHumanUsers && persona.topHumanUsers.length > 0) { + const users = persona.topHumanUsers + .slice(0, 3) + .map((u) => `${u.name}(互动${u.count}次)`) + .join("、"); + blocks.push(`[你最近高频互动的真人]\n${users}`); + } + if (blocks.length === 0) return ""; + return `\n${blocks.join("\n\n")}\n`; +} + async function getRecentMemories(botId, limit = 3) { try { return await prisma.botMemory.findMany({ @@ -417,6 +861,10 @@ function buildMemoryContext(memories, forumName) { if (m.memoryType === "experience") { return `• 你最近在"${c.forum}"板块发起了话题"${c.title}"`; } + if (m.memoryType === "feedback") { + const humanPart = c.humanCount > 0 ? `(其中${c.humanCount}个真人回复)` : ""; + return `• 你之前发起的"${c.topicTitle}"收到了${c.replyCount}条新回复${humanPart}`; + } return `• 你最近在"${c.forum}"板块回复了关于"${c.topicTitle}"的讨论`; }); return `\n[你最近的讨论记忆]\n${lines.join("\n")}\n`; @@ -492,24 +940,88 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) { if (needsNewTopic) { const activeBots = forumBots.filter(c => c.activityLevel === "high" || c.activityLevel === "medium"); - const topicAuthor = pick(activeBots.length > 0 ? activeBots : forumBots); + let topicAuthor = pick(activeBots.length > 0 ? activeBots : forumBots); + let isCrossForum = false; if (!topicAuthor) { console.log(`${ts()} No suitable bot to create topic.`); return; } + + if (Math.random() < CROSS_FORUM_PROBABILITY) { + const crossAffinities = await prisma.botCrossForumAffinity.findMany({ + where: { forumSlug, affinity: { gt: 0.1 } }, + orderBy: { affinity: "desc" }, + take: 5, + }); + if (crossAffinities.length > 0) { + const crossBotConfigIds = crossAffinities.map(c => c.botId); + const crossBotConfigs = await prisma.botConfig.findMany({ + where: { id: { in: crossBotConfigIds } }, + include: { user: { select: { email: true } } }, + }); + const crossKeys = crossBotConfigs + .map(c => c.user.email?.replace(/^bot_/, "").replace(/@zhuiguang\.ai$/, "")) + .filter(Boolean); + const crossChars = botCharacters.filter( + c => crossKeys.includes(c.key) && !(c.primaryForums || []).includes(forumSlug) && c.role !== "passerby" + ); + if (crossChars.length > 0) { + const crossChar = pick(crossChars); + const crossConf = await getBotWithConfig(crossChar.key); + if (crossConf) { + const crossStat = await getOrCreateTodayStat(crossConf.id); + const crossTier = await getDynamicQuotaTier(crossConf.id, crossChar.activityLevel); + const crossQuota = dynamicQuota(crossTier); + if (crossStat.topicCreated < crossQuota.topicPerDay) { + topicAuthor = crossChar; + isCrossForum = true; + console.log(`${ts()} 🌍 跨板块客串: ${crossChar.displayName} → "${category.name}" (affinity=${crossAffinities[0].affinity.toFixed(2)})`); + } + } + } + } + } + + if (!isInActiveHours(topicAuthor.activeHours)) { + console.log(`${ts()} ${topicAuthor.displayName} 当前不在活跃时段,跳过发帖`); + return; + } + 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...`); + const dynamicTier = await getDynamicQuotaTier(botConf.id, topicAuthor.activityLevel); + const finalQuota = dynamicQuota(dynamicTier); + const todayStat = await getOrCreateTodayStat(botConf.id); + if (todayStat.topicCreated >= finalQuota.topicPerDay) { + console.log(`${ts()} ${topicAuthor.displayName} 今日发帖配额已用完 (${todayStat.topicCreated}/${finalQuota.topicPerDay}, tier=${dynamicTier})`); + } else { + console.log(`${ts()} ${topicAuthor.displayName} creating new topic (tier=${dynamicTier})${isCrossForum ? " [跨板块]" : ""}...`); try { + const topSkills = await getTopSkills(botConf.id, forumSlug, 2); + const persona = await getPersona(botConf.id); const recentMemories = await getRecentMemories(botConf.id, 2); const memoryCtx = buildMemoryContext(recentMemories, category.name); - let prompt = buildTopicPrompt(topicAuthor, category.name, category.description); + // A/B 分流:选变体(不写库),注入 personaBlock 到 prompt + const variantPick = await pickVariantForPrompt(botConf.id); + const variantBlock = variantPick.personaBlock || ""; + + // 对抗学习:拉本周真人高赞帖洞察(如果存在) + const advLearning = await getActiveLearningForBot(botConf.id); + const adversarialBlock = buildAdversarialBlock(advLearning); + + let prompt = buildTopicPrompt(topicAuthor, category.name, category.description, { + topSkills, + persona, + isCrossForum, + variantBlock, + adversarialBlock, + }); if (memoryCtx) { prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]"); } else { @@ -524,30 +1036,79 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) { const topic = await createTopic(catId, botConf.userId, result.title, result.content); console.log(`${ts()} Created topic: "${result.title}"`); + if (advLearning) { + try { + await markLearningUsed(advLearning.id); + console.log(`${ts()} 🎓 对抗学习引用 week=${advLearning.weekKey} topic#${advLearning.sourceRefId}`); + } catch (e) { + console.warn(`${ts()} ⚠️ markLearningUsed failed: ${e.message}`); + } + } + + // 提交 A/B assignment + if (variantPick.variant) { + try { + await commitAssignment(botConf.id, variantPick.variant.id, "topic", topic.id, forumSlug); + console.log(`${ts()} 🅰️ variant=${variantPick.variant.variantKey} assigned to topic#${topic.id}`); + } catch (e) { + console.warn(`${ts()} ⚠️ commitAssignment failed: ${e.message}`); + } + } + + await prisma.forumCategory.update({ + where: { id: catId }, + data: { topicCount: { increment: 1 } }, + }); + await saveMemory(botConf.id, "experience", { action: "create_topic", forum: category.name, title: result.title, summary: result.content.substring(0, 100), - }, 0.6); + usedSkillIds: topSkills.map(s => s.id), + usedSkillNames: topSkills.map(s => s.name), + isCrossForum, + }, isCrossForum ? 0.7 : 0.6); + + if (topSkills.length > 0) { + await recordSkillUsage(topSkills.map(s => s.id), topic.id); + } + + await incrementStat(botConf.id, "topicCreated", 1); + + // 异步触发 persona 刷新(5 分钟防抖,不阻塞主流程) + schedulePersonaRefresh(botConf.userId, botConf.id); await logTask("create_topic", "success", - `${topicAuthor.displayName} created topic in ${category.name}: ${result.title}`); + `${topicAuthor.displayName} created topic in ${category.name}: ${result.title}${isCrossForum ? " [跨板块]" : ""}`); } 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); + .filter((bot) => { + const chance = bot.replyChance ?? 0.5; + if (!isInActiveHours(bot.activeHours)) return false; + return Math.random() < chance; + }) + .slice(0, MAX_REPLIERS_PER_FORUM); for (const replierBot of repliers) { const botConf = await getBotWithConfig(replierBot.key); if (!botConf) continue; + const replyDynamicTier = await getDynamicQuotaTier(botConf.id, replierBot.activityLevel); + const replyQuota = dynamicQuota(replyDynamicTier); + const replyStat = await getOrCreateTodayStat(botConf.id); + if (replyStat.replySent >= replyQuota.replyPerDay) { + console.log(`${ts()} ${replierBot.displayName} 今日回复配额已用完 (${replyStat.replySent}/${replyQuota.replyPerDay}, tier=${replyDynamicTier})`); + continue; + } + const targetTopic = pick(existingTopics); const authorName = targetTopic.user.name; @@ -569,7 +1130,15 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) { const recentMemories = await getRecentMemories(botConf.id, 3); const memoryCtx = buildMemoryContext(recentMemories, category.name); - let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name); + // A/B 分流:选变体注入 personaBlock + const variantPick = await pickVariantForPrompt(botConf.id); + const variantBlock = variantPick.personaBlock || ""; + + // 对抗学习:拉本周真人高赞帖洞察 + const advLearning = await getActiveLearningForBot(botConf.id); + const adversarialBlock = buildAdversarialBlock(advLearning); + + let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name, { variantBlock, adversarialBlock }); if (memoryCtx) { prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]"); } else { @@ -581,6 +1150,25 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) { const post = await createPost(targetTopic.id, botConf.userId, result.content); console.log(`${ts()} Reply saved (${result.content.length} chars).`); + if (advLearning) { + try { + await markLearningUsed(advLearning.id); + console.log(`${ts()} 🎓 对抗学习引用 week=${advLearning.weekKey}`); + } catch (e) { + console.warn(`${ts()} ⚠️ markLearningUsed failed: ${e.message}`); + } + } + + // 提交 A/B assignment + if (variantPick.variant) { + try { + await commitAssignment(botConf.id, variantPick.variant.id, "post", post.id, forumSlug); + console.log(`${ts()} 🅰️ variant=${variantPick.variant.variantKey} assigned to post#${post.id}`); + } catch (e) { + console.warn(`${ts()} ⚠️ commitAssignment failed: ${e.message}`); + } + } + await saveMemory(botConf.id, "interaction", { action: "reply", forum: category.name, @@ -589,6 +1177,11 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) { summary: result.content.substring(0, 100), }, 0.5); + await incrementStat(botConf.id, "replySent", 1); + + // 异步触发 persona 刷新(5 分钟防抖,不阻塞主流程) + schedulePersonaRefresh(botConf.userId, botConf.id); + await logTask("create_reply", "success", `${replierBot.displayName} replied to ${authorName}'s topic in ${category.name}`); } catch (err) { @@ -622,12 +1215,41 @@ async function processPasserbyForum(forumSlug, botCharacters) { console.log(`${ts()} No recent news found, will generate from bot knowledge.`); } - const posterBot = pick(passerbyBots); - const botConf = await getBotWithConfig(posterBot.key); - if (!botConf) { - console.log(`${ts()} Passerby bot "${posterBot.key}" not found in DB.`); - return; + const eligibleBots = passerbyBots.filter(b => isInActiveHours(b.activeHours)); + if (eligibleBots.length === 0) { + console.log(`${ts()} 路过型Bot当前无人在活跃时段`); + return false; } + // 优先选最近没发过/发得少的 bot(用 botConfig 的 latestMemory 时间近似判断) + const scored = []; + for (const bot of eligibleBots) { + const conf = await getBotWithConfig(bot.key); + if (!conf) continue; + const last = await prisma.botMemory.findFirst({ + where: { botId: conf.id, memoryType: "experience" }, + orderBy: { createdAt: "desc" }, + }); + let weight = 1; + if (last) { + const hoursAgo = (Date.now() - new Date(last.createdAt).getTime()) / 3600000; + // 6 小时内发过的权重减半,>24h 的权重 2x + if (hoursAgo < 6) weight = 0.3; + else if (hoursAgo < 12) weight = 0.6; + else if (hoursAgo > 24) weight = 2; + } else { + // 从没发过帖的 bot 优先级最高 + weight = 2.5; + } + scored.push({ bot, conf, weight }); + } + if (scored.length === 0) return false; + scored.sort((a, b) => b.weight - a.weight); + // 取加权最高的 3 个,再随机抽 + const topPicks = scored.slice(0, 3); + const chosen = topPicks[Math.floor(Math.random() * topPicks.length)]; + const posterBot = chosen.bot; + const botConf = chosen.conf; + if (!botConf) return false; console.log(`${ts()} ${posterBot.displayName} (${posterBot.personality.passerbyType}) creating topic...`); @@ -635,7 +1257,11 @@ async function processPasserbyForum(forumSlug, botCharacters) { const recentMemories = await getRecentMemories(botConf.id, 2); const memoryCtx = buildMemoryContext(recentMemories, category.name); - let prompt = buildPasserbyTopicPrompt(posterBot, category.name, category.description, recentNews); + // A/B 分流 + const variantPick = await pickVariantForPrompt(botConf.id); + const variantBlock = variantPick.personaBlock || ""; + + let prompt = buildPasserbyTopicPrompt(posterBot, category.name, category.description, recentNews, { variantBlock }); if (memoryCtx) { prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]"); } else { @@ -650,6 +1276,21 @@ async function processPasserbyForum(forumSlug, botCharacters) { const topic = await createTopic(catId, botConf.userId, result.title, result.content); console.log(`${ts()} Passerby topic created: "${result.title}"`); + // 提交 A/B assignment + if (variantPick.variant) { + try { + await commitAssignment(botConf.id, variantPick.variant.id, "topic", topic.id, forumSlug); + console.log(`${ts()} 🅰️ variant=${variantPick.variant.variantKey} assigned to passerby topic#${topic.id}`); + } catch (e) { + console.warn(`${ts()} ⚠️ commitAssignment failed: ${e.message}`); + } + } + + await prisma.forumCategory.update({ + where: { id: catId }, + data: { topicCount: { increment: 1 } }, + }); + await saveMemory(botConf.id, "experience", { action: "create_topic_passerby", forum: category.name, @@ -658,11 +1299,194 @@ async function processPasserbyForum(forumSlug, botCharacters) { newsCount: recentNews.length, }, 0.5); + // 异步触发 persona 刷新(5 分钟防抖,不阻塞主流程) + schedulePersonaRefresh(botConf.userId, botConf.id); + await logTask("create_topic_passerby", "success", `${posterBot.displayName}(${posterBot.personality.passerbyType}) posted in ${category.name}: ${result.title}`); + return true; } catch (err) { console.error(`${ts()} Passerby topic error:`, err.message); await logTask("create_topic_passerby", "error", err.message); + return false; + } +} + +// ============ Bot 自动点赞(真实写入 forumTopicLike / forumPostLike) ============ +// 策略: +// 1) 拉所有 bot 用户 → 候选点赞人 +// 2) 拉最近 LOOKBACK_DAYS 内的"高质量"topic/post(likeCount 高的),并排除自己 +// 3) 给"同板块活跃伙伴"额外权重,给真人内容较小权重(避免生态自嗨) +// 4) 写入 forumTopicLike / forumPostLike,并发累加 likeCount + BotDailyStat.likesReceived +async function pickLikeCandidates(likerUserId, partnerUserIds, lookbackDays) { + const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000); + + // 候选 topics:likeCount >= MIN_QUALITY_LIKES,按 likeCount + 时间衰减打分 + const topics = await prisma.forumTopic.findMany({ + where: { + userId: { in: partnerUserIds }, + createdAt: { gte: since }, + isHidden: false, + likeCount: { gte: BOT_LIKE_CONFIG.MIN_QUALITY_LIKES }, + }, + select: { id: true, userId: true, categoryId: true, likeCount: true, createdAt: true }, + take: 60, + }); + + // 候选 posts + const posts = await prisma.forumPost.findMany({ + where: { + userId: { in: partnerUserIds }, + createdAt: { gte: since }, + isHidden: false, + likeCount: { gte: BOT_LIKE_CONFIG.MIN_QUALITY_LIKES }, + }, + select: { + id: true, + userId: true, + topicId: true, + topic: { select: { categoryId: true } }, + likeCount: true, + createdAt: true, + }, + take: 60, + }); + + return { topics, posts }; +} + +async function applyLikeToTopic(likerUserId, topicId) { + // 唯一约束 (topicId, userId) -> 不能重复点赞 + try { + await prisma.forumTopicLike.create({ data: { topicId, userId: likerUserId } }); + } catch (e) { + // 已点过赞(P2002 unique)则忽略 + if (e && e.code === "P2002") return false; + throw e; + } + await prisma.forumTopic.update({ + where: { id: topicId }, + data: { likeCount: { increment: 1 } }, + }); + return true; +} + +async function applyLikeToPost(likerUserId, postId) { + try { + await prisma.forumPostLike.create({ data: { postId, userId: likerUserId } }); + } catch (e) { + if (e && e.code === "P2002") return false; + throw e; + } + await prisma.forumPost.update({ + where: { id: postId }, + data: { likeCount: { increment: 1 } }, + }); + return true; +} + +async function runBotAutoLikes(botCharacters) { + try { + if (Math.random() > BOT_LIKE_CONFIG.PROBABILITY_PER_BOT) { + console.log(`${ts()} ⏭️ 本轮跳过自动点赞 (PROBABILITY gate)`); + return; + } + const allBotUsers = await prisma.user.findMany({ + where: { isBot: true }, + select: { id: true, name: true }, + }); + if (allBotUsers.length < 2) return; + + const humanUserIds = ( + await prisma.user.findMany({ + where: { isBot: false }, + select: { id: true }, + take: 50, + }) + ).map((u) => u.id); + + const botUserIds = allBotUsers.map((u) => u.id); + let totalApplied = 0; + let totalSkipped = 0; + + for (const bot of allBotUsers) { + if (totalApplied >= BOT_LIKE_CONFIG.MAX_LIKES_PER_RUN) break; + // 候选伙伴 = 其他 bot + 少量真人 + const partnerBotIds = botUserIds.filter((id) => id !== bot.id); + const { topics, posts } = await pickLikeCandidates(bot.id, partnerBotIds, BOT_LIKE_CONFIG.LOOKBACK_DAYS); + if (topics.length === 0 && posts.length === 0) continue; + + // 真人候选:按比例加入 + const includeHuman = Math.random() < BOT_LIKE_CONFIG.HUMAN_LIKE_PROBABILITY; + let humanTopics = []; + let humanPosts = []; + if (includeHuman && humanUserIds.length > 0) { + const since = new Date(Date.now() - BOT_LIKE_CONFIG.LOOKBACK_DAYS * 24 * 60 * 60 * 1000); + humanTopics = await prisma.forumTopic.findMany({ + where: { + userId: { in: humanUserIds }, + createdAt: { gte: since }, + isHidden: false, + likeCount: { gte: BOT_LIKE_CONFIG.MIN_QUALITY_LIKES }, + }, + select: { id: true, userId: true, categoryId: true, likeCount: true, createdAt: true }, + take: 20, + }); + humanPosts = await prisma.forumPost.findMany({ + where: { + userId: { in: humanUserIds }, + createdAt: { gte: since }, + isHidden: false, + likeCount: { gte: BOT_LIKE_CONFIG.MIN_QUALITY_LIKES }, + }, + select: { id: true, userId: true, topicId: true, topic: { select: { categoryId: true } }, likeCount: true, createdAt: true }, + take: 20, + }); + } + + const allTopicPool = [...topics, ...humanTopics]; + const allPostPool = [...posts, ...humanPosts]; + + if (allTopicPool.length === 0 && allPostPool.length === 0) continue; + + // 加权:topic vs post 按 TOPIC_WEIGHT + const pickTopic = Math.random() < BOT_LIKE_CONFIG.TOPIC_WEIGHT; + const pool = pickTopic ? allTopicPool : allPostPool; + if (pool.length === 0) continue; + + const target = pool[Math.floor(Math.random() * pool.length)]; + let ok = false; + if (pickTopic) { + ok = await applyLikeToTopic(bot.id, target.id); + } else { + ok = await applyLikeToPost(bot.id, target.id); + } + if (ok) { + totalApplied++; + // 给被赞者(bot)累加 BotDailyStat.likesReceived + const targetUser = await prisma.user.findUnique({ where: { id: target.userId } }); + if (targetUser?.isBot) { + const targetBotConf = await prisma.botConfig.findUnique({ where: { userId: targetUser.id } }); + if (targetBotConf) await incrementStat(targetBotConf.id, "likesReceived", 1); + } + const verb = pickTopic ? "topic" : "post"; + console.log(`${ts()} ❤️ ${bot.name} → ${targetUser?.name || "?"} ${verb}#${target.id}`); + } else { + totalSkipped++; + } + } + + if (totalApplied > 0) { + console.log(`${ts()} ✅ 自动点赞完成:成功 ${totalApplied} 跳过(重复) ${totalSkipped}`); + await logTask( + "bot_auto_likes", + "success", + `bot 互赞/真人点赞 ${totalApplied} 条,跳过 ${totalSkipped} 条` + ); + } + } catch (err) { + console.error(`${ts()} runBotAutoLikes error:`, err.message); + await logTask("bot_auto_likes", "error", err.message); } } @@ -670,30 +1494,58 @@ async function main() { console.log(`${ts()} Bot Activity Engine starting...`); const { characters: botCharacters } = await loadBotCharacters(); - console.log(`${ts()} Loaded ${botCharacters.length} bot characters.`); + const expertCount = botCharacters.filter((c) => c.role !== "passerby").length; + const passerbyCount = botCharacters.filter((c) => c.role === "passerby").length; + console.log(`${ts()} Loaded ${botCharacters.length} bot characters (${expertCount} experts + ${passerbyCount} passersbys).`); - const expertForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, 2); + const expertForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, EXPERT_FORUMS_PER_RUN); console.log(`${ts()} Processing ${expertForums.length} forums for experts: ${expertForums.join(", ")}`); for (const forumSlug of expertForums) { await processForum(forumSlug, botCharacters); } - const passerbyForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, 1); + const passerbyForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, PASSERBY_FORUMS_PER_RUN); console.log(`${ts()} Processing ${passerbyForums.length} forums for passerbys: ${passerbyForums.join(", ")}`); for (const forumSlug of passerbyForums) { - await processPasserbyForum(forumSlug, botCharacters); + // 路人翻倍后,每个板块跑两轮,让更多 bot 有露脸机会 + const first = await processPasserbyForum(forumSlug, botCharacters); + if (first) { + await new Promise((r) => setTimeout(r, 200)); + await processPasserbyForum(forumSlug, botCharacters); + } } + // Bot 之间互相点赞:让真实 like 数据自然累积,避免估算 + await runBotAutoLikes(botCharacters); + console.log(`${ts()} Bot Activity Engine done.`); } main() - .catch((e) => { + .catch(async (e) => { console.error(`${ts()} Fatal error:`, e); + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-activity", + taskName: "Bot活动引擎", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} process.exit(1); }) .finally(async () => { + // 等待 persona 异步刷新落盘(最多 2 秒)再断开 + await new Promise((r) => setTimeout(r, 500)); await prisma.$disconnect(); + await disconnectBotPersona(); + await disconnectBotExperiment(); + await disconnectAdversarialLearning(); }); \ No newline at end of file diff --git a/scripts/bot-adversarial-learning-run.mjs b/scripts/bot-adversarial-learning-run.mjs new file mode 100644 index 0000000..6d895be --- /dev/null +++ b/scripts/bot-adversarial-learning-run.mjs @@ -0,0 +1,147 @@ +// 数字人 vs 真人"对抗学习" 调度入口 +// 每周给所有专家 bot 各选 1 篇真实高赞真人帖,提取"为什么火"的洞察 +// 用法: +// node scripts/bot-adversarial-learning-run.mjs # 默认本周 +// node scripts/bot-adversarial-learning-run.mjs --week=2026-W23 # 指定周 +// node scripts/bot-adversarial-learning-run.mjs --force # 强制重学(覆盖已有) +// node scripts/bot-adversarial-learning-run.mjs --bot=laochen # 单 bot + +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import "dotenv/config"; +import { + runWeeklyAdversarialLearning, + getISOWeekKey, + disconnectAdversarialLearning, +} from "./lib/bot-adversarial-learning.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=3&pool_timeout=10`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = { week: null, force: false, bot: null, lookback: 7 }; + for (const a of args) { + if (a.startsWith("--week=")) out.week = a.split("=")[1]; + else if (a === "--force") out.force = true; + else if (a.startsWith("--bot=")) out.bot = a.split("=")[1]; + else if (a.startsWith("--lookback=")) out.lookback = parseInt(a.split("=")[1], 10); + } + return out; +} + +async function main() { + const args = parseArgs(); + const weekKey = args.week || getISOWeekKey(); + console.log( + `${ts()} Adversarial Learning run starting (week=${weekKey}, force=${args.force}, bot=${args.bot || "all"}, lookback=${args.lookback}d)` + ); + + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL 未配置"); + } + + const startTime = Date.now(); + + // --force 时:先把本周已有 learning 删掉(针对限定 bot) + if (args.force) { + const where = args.bot + ? { bot: { user: { email: `bot_${args.bot}@zhuiguang.ai` } }, weekKey } + : { weekKey }; + const del = await prisma.botAdversarialLearning.deleteMany({ where }); + console.log(`${ts()} --force: 删除了 ${del.count} 条已有 learning`); + } + + // 单 bot 模式 + let botConfigIds = null; + if (args.bot) { + const u = await prisma.user.findFirst({ where: { email: `bot_${args.bot}@zhuiguang.ai` } }); + if (!u) { + console.error(`${ts()} Bot "${args.bot}" 未找到`); + process.exit(1); + } + const c = await prisma.botConfig.findUnique({ where: { userId: u.id } }); + if (!c) { + console.error(`${ts()} Bot "${args.bot}" 没有 BotConfig`); + process.exit(1); + } + botConfigIds = [c.id]; + } + + const summary = await runWeeklyAdversarialLearning({ + weekKey, + lookbackDays: args.lookback, + botConfigIds, + }); + + const duration = Date.now() - startTime; + console.log( + `${ts()} Done: bots=${summary.botCount} learned=${summary.learned} skipped=${summary.skipped} errors=${summary.errors} (${duration}ms)` + ); + for (const r of summary.results.slice(0, 10)) { + if (r.error) { + console.log(`${ts()} ✗ ${r.botName} ${r.error}`); + } else if (r.skipped) { + console.log(`${ts()} ⤼ ${r.botName} skip=${r.reason}`); + } else { + const rec = r.record; + console.log( + `${ts()} ✓ ${r.botName} ← topic#${rec.sourceRefId} "${rec.sourceTitle?.slice(0, 30)}" rel=${rec.relevanceScore} cat=${JSON.stringify(rec.learnCategories)}` + ); + } + } + + await prisma.taskLog.create({ + data: { + taskKey: "bot-adversarial-learning-run", + taskName: "Bot 对抗学习(真人高赞帖)", + status: summary.errors > 0 ? "partial" : "success", + startedAt: new Date(startTime), + finishedAt: new Date(), + triggerBy: "cron", + result: { + weekKey, + botCount: summary.botCount, + learned: summary.learned, + skipped: summary.skipped, + errors: summary.errors, + force: args.force, + lookbackDays: args.lookback, + durationMs: duration, + }, + }, + }); +} + +main() + .catch(async (e) => { + console.error(`${ts()} Fatal:`, e); + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-adversarial-learning-run", + taskName: "Bot 对抗学习(真人高赞帖)", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + await disconnectAdversarialLearning(); + }); diff --git a/scripts/bot-affinity-update.mjs b/scripts/bot-affinity-update.mjs new file mode 100644 index 0000000..f77b724 --- /dev/null +++ b/scripts/bot-affinity-update.mjs @@ -0,0 +1,229 @@ +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"; +import { updatePersona as sharedUpdatePersona, computePersona } from "./lib/bot-persona.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=5&pool_timeout=30`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); + +const LOOKBACK_DAYS = 14; +const MIN_AFFINITY_SAMPLES = 1; + +function ts() { + return `[${new Date().toISOString()}]`; +} + +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 }; +} + +async function computeAffinity(botUser, botConfigId) { + const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000); + const myTopics = await prisma.forumTopic.findMany({ + where: { userId: botUser.id, createdAt: { gt: since } }, + select: { id: true, category: { select: { slug: true } } }, + }); + + if (myTopics.length === 0) return { homeForums: new Map(), crossCount: 0 }; + + const topicIds = myTopics.map((t) => t.id); + const myForumSlugs = new Set(myTopics.map((t) => t.category.slug)); + + const humanRepliers = await prisma.forumPost.findMany({ + where: { + topicId: { in: topicIds }, + user: { isBot: false }, + }, + include: { + user: { + select: { + id: true, + name: true, + isBot: true, + forumTopics: { + where: { createdAt: { gt: since } }, + select: { category: { select: { slug: true } } }, + take: 10, + }, + }, + }, + topic: { select: { category: { select: { slug: true, name: true } } } }, + }, + }); + + const homeForumCount = new Map(); + for (const p of humanRepliers) { + const homeForums = p.user.forumTopics.map((t) => t.category.slug); + if (homeForums.length === 0) continue; + const counts = new Map(); + for (const f of homeForums) counts.set(f, (counts.get(f) || 0) + 1); + const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); + const homeForum = sorted[0][0]; + if (myForumSlugs.has(homeForum)) continue; + homeForumCount.set(homeForum, (homeForumCount.get(homeForum) || 0) + 1); + } + + return { homeForums: homeForumCount, crossCount: humanRepliers.length }; +} + +async function updatePersona(botChar) { + const botUser = await prisma.user.findFirst({ + where: { email: `bot_${botChar.key}@zhuiguang.ai` }, + }); + if (!botUser) return false; + + const botConfig = await prisma.botConfig.findUnique({ + where: { userId: botUser.id }, + }); + if (!botConfig) return false; + + // 调用 lib/bot-persona.mjs 的共享实现(同步) + const result = await sharedUpdatePersona(botUser, botConfig.id); + if (!result.success) return false; + + // 再读一份用于日志输出 + const persona = await computePersona(botUser, botConfig.id); + console.log( + `${ts()} 📐 ${botChar.displayName} 画像: 话题${persona.postSampleCount}条 / 平均话题${persona.avgTopicLength}字 / 平均回复${persona.avgReplyLength}字 / 问号率${persona.questionRatio}` + ); + return true; +} + +async function updateAffinity(botChar) { + const botUser = await prisma.user.findFirst({ + where: { email: `bot_${botChar.key}@zhuiguang.ai` }, + }); + if (!botUser) return; + + const botConfig = await prisma.botConfig.findUnique({ + where: { userId: botUser.id }, + }); + if (!botConfig) return; + + const { homeForums, crossCount } = await computeAffinity(botUser, botConfig.id); + + const primaryForumSlugs = new Set( + (botChar.personality?.primaryForums || []).map((s) => s) + ); + + await prisma.botCrossForumAffinity.deleteMany({ + where: { + botId: botConfig.id, + forumSlug: { in: Array.from(primaryForumSlugs) }, + }, + }); + + if (homeForums.size === 0) { + return; + } + + const totalCrossSamples = Array.from(homeForums.values()).reduce((s, n) => s + n, 0); + + for (const [slug, count] of homeForums.entries()) { + if (count < MIN_AFFINITY_SAMPLES) continue; + const affinity = Math.min(1, count / Math.max(crossCount / 4, 5)); + const reason = `${count}/${totalCrossSamples} 跨板块互动`; + await prisma.botCrossForumAffinity.upsert({ + where: { botId_forumSlug: { botId: botConfig.id, forumSlug: slug } }, + create: { + botId: botConfig.id, + forumSlug: slug, + affinity, + sampleCount: count, + reason, + }, + update: { + affinity, + sampleCount: count, + reason, + }, + }); + } + + if (homeForums.size > 0) { + const top = Array.from(homeForums.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([s, c]) => `${s}(${c})`) + .join(", "); + console.log(`${ts()} 🌍 ${botChar.displayName} 跨板块亲和度: ${top}`); + } +} + +async function main() { + console.log(`${ts()} Bot Affinity & Persona Update starting...`); + + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL 未配置"); + } + + const { characters } = await loadBotCharacters(); + const botChars = characters.filter((c) => !c.role || c.role !== "passerby"); + console.log(`${ts()} Loaded ${botChars.length} expert bots.`); + + let personaCount = 0; + let affinityCount = 0; + + for (const botChar of botChars) { + try { + if (await updatePersona(botChar)) personaCount++; + await updateAffinity(botChar); + affinityCount++; + } catch (err) { + console.error(`${ts()} Error processing ${botChar.key}:`, err.message); + } + } + + await prisma.taskLog.create({ + data: { + taskKey: "bot-affinity-update", + taskName: "Bot亲和度与画像", + status: "success", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + result: { personaCount, affinityCount, totalBots: botChars.length }, + }, + }); + + console.log( + `${ts()} Bot Affinity & Persona Update done. persona=${personaCount} affinity=${affinityCount}` + ); +} + +main() + .catch(async (e) => { + console.error(`${ts()} Fatal error:`, e); + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-affinity-update", + taskName: "Bot亲和度与画像", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + const { disconnectBotPersona } = await import("./lib/bot-persona.mjs"); + await disconnectBotPersona(); + }); diff --git a/scripts/bot-avatar-generate.mjs b/scripts/bot-avatar-generate.mjs new file mode 100644 index 0000000..51bb348 --- /dev/null +++ b/scripts/bot-avatar-generate.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// scripts/bot-avatar-generate.mjs +// 数字人头像批量预生成 + 落盘 + 回写 User.avatarUrl +// +// 用法: +// node scripts/bot-avatar-generate.mjs # 生成所有 bot 头像 +// node scripts/bot-avatar-generate.mjs --bot=laochen # 只生成单个 bot +// node scripts/bot-avatar-generate.mjs --force # 强制重新生成(已存在也覆盖) +// node scripts/bot-avatar-generate.mjs --use-lunaris-only # 只用 Lunaris,不生成 SVG(当前 Lunaris 全是 default,不推荐) +// node scripts/bot-avatar-generate.mjs --no-update-db # 只落盘,不写 User.avatarUrl +// node scripts/bot-avatar-generate.mjs --dry-run # 试运行,不落盘不写库 + +import { disconnect, generateForAll } from "./lib/bot-avatar-generator.mjs"; + +function parseArgs() { + const args = process.argv.slice(2); + const out = { force: false, useLunarisOnly: false, updateDb: true, dryRun: false, bot: null, delay: 200 }; + for (const a of args) { + if (a === "--force") out.force = true; + else if (a === "--use-lunaris-only") out.useLunarisOnly = true; + else if (a === "--no-update-db") out.updateDb = false; + else if (a === "--dry-run") out.dryRun = true; + else if (a.startsWith("--bot=")) out.bot = a.slice(6); + else if (a.startsWith("--delay=")) out.delay = parseInt(a.slice(8), 10); + else if (a === "--help" || a === "-h") { + console.log("用法: node scripts/bot-avatar-generate.mjs [选项]"); + console.log(" --bot= 只生成指定 bot"); + console.log(" --force 覆盖已存在的头像"); + console.log(" --use-lunaris-only 禁用 SVG 兜底(当前不推荐)"); + console.log(" --no-update-db 只落盘不写 User.avatarUrl"); + console.log(" --dry-run 试运行,不落盘不写库"); + console.log(" --delay= bot 间隔延迟(默认 200ms)"); + process.exit(0); + } + } + return out; +} + +function ts() { + return new Date().toISOString().replace("T", " ").slice(0, 19); +} + +async function main() { + const args = parseArgs(); + console.log(`${ts()} 数字人头像批量预生成开始`); + console.log( + ` bot=${args.bot || "all"} force=${args.force} lunarisOnly=${args.useLunarisOnly} updateDb=${args.updateDb} dryRun=${args.dryRun} delay=${args.delay}ms` + ); + + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL 未配置"); + } + + const startTime = Date.now(); + const { summary, results } = await generateForAll({ + force: args.force, + botKey: args.bot, + useLunarisOnly: args.useLunarisOnly, + delayMs: args.dryRun ? 0 : args.delay, + }); + + const duration = Date.now() - startTime; + console.log(""); + console.log(`${ts()} 完成: total=${summary.total} ok=${summary.ok} failed=${summary.failed} (${duration}ms)`); + console.log(`${ts()} 落盘位置: public/bot-avatars/{key}.svg + public/bot-avatars/lunaris/{key}.jpg`); + console.log(`${ts()} 清单: public/bot-avatars/manifest.json`); + + if (summary.failed > 0) { + console.log(""); + console.log("失败列表:"); + for (const r of results) { + if (r.error) console.log(` - ${r.key}: ${r.error}`); + } + } + + await disconnect(); + process.exit(summary.failed > 0 ? 1 : 0); +} + +main().catch(async (err) => { + console.error(`${ts()} ❌ 致命错误: ${err.message}`); + console.error(err.stack); + await disconnect().catch(() => {}); + process.exit(1); +}); diff --git a/scripts/bot-feedback-loop.mjs b/scripts/bot-feedback-loop.mjs new file mode 100644 index 0000000..53407ef --- /dev/null +++ b/scripts/bot-feedback-loop.mjs @@ -0,0 +1,421 @@ +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=5&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 LAYER = { + SESSION: "SESSION", + WORKING: "WORKING", + LONGTERM: "LONGTERM", +}; + +const MEMORY_TYPE = { + EXPERIENCE: "experience", + INTERACTION: "interaction", + FEEDBACK: "feedback", + SKILL_USAGE: "skill_usage", + REFLECTION: "reflection", + USER_MODEL: "user_model", +}; + +const QUOTA = { + high: { followUpPerDay: 6 }, + medium: { followUpPerDay: 3 }, + low: { followUpPerDay: 1 }, +}; + +const FEEDBACK_LOOKBACK_HOURS = 24; +const FOLLOW_UP_PROBABILITY = 0.6; +const MIN_FEEDBACK_TO_FOLLOWUP = 1; +const MAX_FOLLOWUP_PER_RUN_PER_BOT = 3; + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function startOfToday() { + // 用 UTC 午夜避开 Prisma @db.Date 写入时的时区截断 + const d = new Date(); + return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); +} + +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 }; +} + +async function getOrCreateTodayStat(botUserId) { + const today = startOfToday(); + return prisma.botDailyStat.upsert({ + where: { botId_date: { botId: botUserId, date: today } }, + create: { botId: botUserId, date: today }, + update: {}, + }); +} + +async function incrementStat(botUserId, field, by = 1) { + const today = startOfToday(); + await prisma.botDailyStat.upsert({ + where: { botId_date: { botId: botUserId, date: today } }, + create: { botId: botUserId, date: today, [field]: by }, + update: { [field]: { increment: by } }, + }); +} + +async function getProcessedReplyIds(botUserId) { + const memories = await prisma.botMemory.findMany({ + where: { + botId: botUserId, + memoryType: MEMORY_TYPE.FEEDBACK, + }, + select: { contextTags: true }, + }); + const ids = new Set(); + for (const m of memories) { + const tags = m.contextTags; + if (tags && Array.isArray(tags.replyIds)) { + for (const id of tags.replyIds) ids.add(id); + } + } + return ids; +} + +function buildFollowUpPrompt(bot, topicTitle, topicContent, originalPost, allReplies) { + const p = bot.personality; + const repliesText = allReplies + .map((r, i) => `${i + 1}. ${r.author}: ${r.content.slice(0, 250)}`) + .join("\n"); + + return `你正在参与"追光AI行业论坛"。你之前在"${topicTitle}"这个话题下发了主帖,现在有用户回复了你,你需要用你的人格接着聊。 + +[你的角色设定] +你是【${bot.displayName}】,${p.identity} +你擅长的领域:${p.expertise.join("、")} +你说话的风格:${p.speakingStyle} +你的口头禅:${p.catchphrase} +你的核心立场:${p.stance} + +[你的原始主帖] +${topicContent} + +[新收到的回复] +${repliesText} + +[二次回复要求] +1. 像真人接话,120-350字,不能太长刷屏 +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.88, + max_tokens: 800, + }) + ); + const text = response.choices[0].message.content.trim(); + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error(`无法解析JSON: ${text.slice(0, 200)}`); + } + return JSON.parse(jsonMatch[0]); +} + +async function saveMemory(botUserId, memoryType, content, importance, contextTags, ttlDays = null) { + const data = { + botId: botUserId, + memoryType, + layer: importance >= 0.7 ? LAYER.WORKING : LAYER.LONGTERM, + content, + importance, + }; + if (contextTags) data.contextTags = contextTags; + if (ttlDays) { + const ttl = new Date(); + ttl.setDate(ttl.getDate() + ttlDays); + data.ttl = ttl; + } + await prisma.botMemory.create({ data }); +} + +async function processBotFeedback(botKey, botChar) { + const botUser = await prisma.user.findFirst({ + where: { email: `bot_${botKey}@zhuiguang.ai` }, + }); + if (!botUser) { + console.log(`${ts()} Bot user not found: ${botKey}`); + return; + } + const botConfig = await prisma.botConfig.findUnique({ + where: { userId: botUser.id }, + }); + if (!botConfig) { + console.log(`${ts()} BotConfig not found: ${botKey}`); + return; + } + + const todayStat = await getOrCreateTodayStat(botConfig.id); + const activityLevel = botChar.activityLevel || "medium"; + const quota = QUOTA[activityLevel] || QUOTA.medium; + if (todayStat.followUpSent >= quota.followUpPerDay) { + console.log(`${ts()} ${botChar.displayName} 今日跟帖配额已用完 (${todayStat.followUpSent}/${quota.followUpPerDay})`); + return; + } + + const since = new Date(Date.now() - FEEDBACK_LOOKBACK_HOURS * 60 * 60 * 1000); + const processedIds = await getProcessedReplyIds(botConfig.id); + + const myTopics = await prisma.forumTopic.findMany({ + where: { + userId: botUser.id, + createdAt: { gt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + isLocked: false, + }, + include: { + category: { select: { name: true, slug: true } }, + posts: { + where: { createdAt: { gt: since } }, + include: { user: { select: { id: true, name: true, isBot: true } } }, + orderBy: { createdAt: "asc" }, + }, + }, + orderBy: { lastReplyAt: "desc" }, + take: 10, + }); + + let totalNewReplies = 0; + let humanReplies = 0; + let followUpsSent = 0; + + for (const topic of myTopics) { + const newReplies = topic.posts.filter(p => p.userId !== botUser.id && !processedIds.has(p.id)); + if (newReplies.length === 0) continue; + + const replyIds = newReplies.map(p => p.id); + const humanCount = newReplies.filter(p => !p.user.isBot).length; + const humanRepliers = newReplies.filter(p => !p.user.isBot).map(p => ({ + id: p.userId, + name: p.user.name || "匿名", + })); + + await saveMemory( + botConfig.id, + MEMORY_TYPE.FEEDBACK, + { + topicTitle: topic.title, + topicId: topic.id, + forum: topic.category.name, + replyCount: newReplies.length, + humanCount, + repliers: newReplies.map(p => ({ + name: p.user.name || "匿名", + isBot: p.user.isBot, + excerpt: p.content.slice(0, 120), + })), + }, + humanCount > 0 ? 0.8 : 0.4, + { + topicId: topic.id, + forumSlug: topic.category.slug, + replyIds, + humanRepliers, + source: "feedback_loop", + }, + 30 + ); + + totalNewReplies += newReplies.length; + humanReplies += humanCount; + await incrementStat(botConfig.id, "feedbackProcessed", 1); + await incrementStat(botConfig.id, "repliesReceived", newReplies.length); + if (humanCount > 0) { + await incrementStat(botConfig.id, "humanInteractions", humanCount); + } + + if ( + newReplies.length >= MIN_FEEDBACK_TO_FOLLOWUP && + Math.random() < FOLLOW_UP_PROBABILITY && + followUpsSent < MAX_FOLLOWUP_PER_RUN_PER_BOT && + todayStat.followUpSent + followUpsSent < quota.followUpPerDay + ) { + try { + const repliesForPrompt = newReplies.map(p => ({ + author: p.user.name || "匿名", + content: p.content, + })); + const prompt = buildFollowUpPrompt( + botChar, + topic.title, + topic.content, + null, + repliesForPrompt + ); + const result = await callDeepSeek(prompt); + const followUpContent = (result.content || "").trim(); + if (!followUpContent || followUpContent.length < 30) { + console.log(`${ts()} ${botChar.displayName} 二次回复内容过短,跳过`); + continue; + } + + const post = await prisma.forumPost.create({ + data: { + topicId: topic.id, + userId: botUser.id, + content: followUpContent.slice(0, 5000), + }, + }); + + await prisma.forumTopic.update({ + where: { id: topic.id }, + data: { + replyCount: { increment: 1 }, + lastReplyAt: new Date(), + updatedAt: new Date(), + }, + }); + + await saveMemory( + botConfig.id, + MEMORY_TYPE.INTERACTION, + { + action: "follow_up_reply", + forum: topic.category.name, + topicTitle: topic.title, + topicId: topic.id, + triggeredBy: "feedback_loop", + summary: followUpContent.slice(0, 100), + }, + 0.6, + { + topicId: topic.id, + forumSlug: topic.category.slug, + postId: post.id, + source: "feedback_loop", + }, + 14 + ); + + followUpsSent++; + await incrementStat(botConfig.id, "followUpSent", 1); + await incrementStat(botConfig.id, "replySent", 1); + + console.log(`${ts()} ${botChar.displayName} 二次回复: "${followUpContent.slice(0, 40)}..."`); + } catch (err) { + console.error(`${ts()} ${botChar.displayName} 二次回复失败:`, err.message); + } + } + } + + if (totalNewReplies > 0) { + console.log(`${ts()} ${botChar.displayName}: 处理 ${totalNewReplies} 条新回复 (${humanReplies} 真人) → 跟帖 ${followUpsSent} 条`); + } +} + +async function logTask(action, status, detail) { + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-feedback-loop", + 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 main() { + console.log(`${ts()} Bot Feedback Loop starting...`); + + if (!process.env.DEEPSEEK_API_KEY) { + throw new Error("DEEPSEEK_API_KEY 未配置"); + } + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL 未配置"); + } + + const { characters } = await loadBotCharacters(); + const botChars = characters.filter(c => !c.role || c.role !== "passerby"); + console.log(`${ts()} Loaded ${botChars.length} expert bots.`); + + let processed = 0; + for (const botChar of botChars) { + try { + await processBotFeedback(botChar.key, botChar); + processed++; + } catch (err) { + console.error(`${ts()} Error processing ${botChar.key}:`, err.message); + } + } + + await logTask("feedback_loop", "success", `处理 ${processed} 个数字人`); + console.log(`${ts()} Bot Feedback Loop done.`); +} + +main() + .catch(async (e) => { + console.error(`${ts()} Fatal error:`, e); + // 写一条失败日志方便后台追溯 + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-feedback-loop", + taskName: "Bot反馈循环", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/bot-persona-experiment-run.mjs b/scripts/bot-persona-experiment-run.mjs new file mode 100644 index 0000000..a9003d9 --- /dev/null +++ b/scripts/bot-persona-experiment-run.mjs @@ -0,0 +1,129 @@ +// Bot Persona A/B 测试 调度入口 +// 1) 采集最近 N 天 assignment 的真实互动数据 +// 2) 回填 bot_persona_metrics(按变体按天聚合) +// 3) 更新变体的 sampleCount/replyCount/likeCount/humanReplyCount/engagementScore +// 4) 对所有 bot 的实验做显著性分析,winner 自动提升权重 +// +// 用法: +// node scripts/bot-persona-experiment-run.mjs # 默认跑 1+2+3+4 +// node scripts/bot-persona-experiment-run.mjs --no-switch # 只跑 1+2+3 +// node scripts/bot-persona-experiment-run.mjs --lookback=14 # 自定义回看天数 +// node scripts/bot-persona-experiment-run.mjs --bot=laochen # 只为指定 bot 跑 + +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"; +import { + recordAssignmentMetrics, + analyzeAndSwitchAllBots, + disconnectBotExperiment, +} from "./lib/bot-persona-experiment.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=3&pool_timeout=10`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = { lookback: 7, noSwitch: false, bot: null, mode: "all" }; + for (const a of args) { + if (a.startsWith("--lookback=")) out.lookback = parseInt(a.split("=")[1], 10); + else if (a === "--no-switch") out.noSwitch = true; + else if (a === "--metrics-only") out.mode = "metrics"; + else if (a === "--analyze-only") out.mode = "analyze"; + else if (a.startsWith("--bot=")) out.bot = a.split("=")[1]; + } + return out; +} + +async function main() { + const args = parseArgs(); + console.log(`${ts()} Bot Persona A/B run starting (lookback=${args.lookback}d, mode=${args.mode}, bot=${args.bot || "all"}, noSwitch=${args.noSwitch})`); + + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL 未配置"); + } + + const startTime = Date.now(); + let metricsResult = null; + let analysisResult = null; + + // 1) 采集指标 + if (args.mode === "all" || args.mode === "metrics") { + console.log(`${ts()} [Step 1] 采集 assignment 真实互动数据 ...`); + metricsResult = await recordAssignmentMetrics({ lookbackDays: args.lookback }); + console.log( + `${ts()} ✓ updated=${metricsResult.updated} assignments=${metricsResult.assignments} dayBuckets=${metricsResult.dayBuckets}` + ); + } + + // 2) 分析 + 切换 + if (!args.noSwitch && (args.mode === "all" || args.mode === "analyze")) { + console.log(`${ts()} [Step 2] 显著性分析 + winner 权重切换 ...`); + analysisResult = await analyzeAndSwitchAllBots(); + console.log(`${ts()} ✓ botCount=${analysisResult.botCount}`); + for (const r of analysisResult.results.slice(0, 10)) { + console.log( + `${ts()} bot#${r.botId} winner=${r.winner || "∅"} action=${r.action} (${r.reason || ""})` + ); + } + } + + const duration = Date.now() - startTime; + console.log(`${ts()} Done in ${duration}ms.`); + + await prisma.taskLog.create({ + data: { + taskKey: "bot-persona-experiment-run", + taskName: "Bot Persona A/B 调度", + status: "success", + startedAt: new Date(startTime), + finishedAt: new Date(), + triggerBy: "cron", + result: { + lookbackDays: args.lookback, + mode: args.mode, + noSwitch: args.noSwitch, + bot: args.bot, + metricsResult, + analysisResult, + durationMs: duration, + }, + }, + }); +} + +main() + .catch(async (e) => { + console.error(`${ts()} Fatal:`, e); + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-persona-experiment-run", + taskName: "Bot Persona A/B 调度", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + await disconnectBotExperiment(); + }); diff --git a/scripts/bot-skill-crystallize.mjs b/scripts/bot-skill-crystallize.mjs new file mode 100644 index 0000000..14c969c --- /dev/null +++ b/scripts/bot-skill-crystallize.mjs @@ -0,0 +1,411 @@ +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=5&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 MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; + +const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); + +const CRYSTALLIZE_TRIGGERS = { + minReplies: 5, + minHumanReplies: 1, + minLikes: 3, + lookbackDays: 14, + maxTopicsPerBot: 8, + minTopicsForCrystallize: 2, +}; + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +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 buildCrystallizePrompt(botChar, topicSummaries) { + const p = botChar.personality; + return `你是一个"成功帖子解构师"。你负责把一个数字人最近发的高互动帖子提炼成可复用的发帖技能。 + +[数字人角色] +- 名字:【${botChar.displayName}】 +- 身份:${p.identity} +- 立场:${p.stance} +- 风格:${p.speakingStyle} +- 口头禅:${p.catchphrase} + +[高互动帖子列表] +${topicSummaries + .map( + (t, i) => + `[${i + 1}] 标题:${t.title}\n板块:${t.forumName}\n收到回复:${t.replyCount}(真人${t.humanCount})\n开头:${t.opening}\n要点:${t.bulletPoints}` + ) + .join("\n\n")} + +[任务] +请从以上${topicSummaries.length}个高互动帖子中提取共性,输出一个可复用的发帖技能。 + +要求: +1. 技能名 6-12字,简明扼要 +2. 钩子模板(开头如何吸引人)100-200字,可以含{{topic}}占位符 +3. 正文骨架(段落结构+要点清单)150-300字 +4. 适用场景 30-80字,描述什么类型的话题/事件适合套用 +5. 必须保持【${botChar.displayName}】的说话风格和立场,不能泛化 +6. 如果发现明显"反模式"(如"使用了禁止的句式"),在适用场景里反向说明 + +[输出格式] +只输出严格JSON,不要其他任何内容: +{ + "name": "技能名", + "hookPattern": "钩子模板", + "bodyTemplate": "正文骨架", + "applicability": "适用场景" +}`; +} + +async function callDeepSeek(prompt) { + const response = await withRetry(() => + openai.chat.completions.create({ + model: MODEL, + messages: [{ role: "user", content: prompt }], + temperature: 0.7, + max_tokens: 1500, + }) + ); + const text = response.choices[0]?.message?.content?.trim() || ""; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error("无法解析JSON: " + text.slice(0, 200)); + return JSON.parse(jsonMatch[0]); +} + +async function getCrystallizedTopicIds(botConfigId) { + const skills = await prisma.botSkill.findMany({ + where: { botId: botConfigId }, + select: { sourceTopicIds: true }, + }); + const ids = new Set(); + for (const s of skills) { + const arr = s.sourceTopicIds; + if (Array.isArray(arr)) for (const id of arr) ids.add(id); + } + return ids; +} + +async function findQualifiedTopics(botUser, botConfigId) { + const since = new Date(Date.now() - CRYSTALLIZE_TRIGGERS.lookbackDays * 24 * 60 * 60 * 1000); + const topics = await prisma.forumTopic.findMany({ + where: { + userId: botUser.id, + createdAt: { gt: since }, + }, + include: { + category: { select: { name: true, slug: true } }, + posts: { + include: { user: { select: { isBot: true } } }, + }, + }, + orderBy: { replyCount: "desc" }, + take: 30, + }); + + // 拉 topic 下所有回复的真实 likeCount,与 topic.likeCount 合并作为真实点赞 + const postIds = topics.flatMap((t) => t.posts.map((p) => p.id)); + const postLikeAgg = postIds.length > 0 + ? await prisma.forumPost.groupBy({ + by: ["id"], + where: { id: { in: postIds } }, + _sum: { likeCount: true }, + }) + : []; + const postLikeMap = new Map(postLikeAgg.map((r) => [r.id, r._sum.likeCount || 0])); + + return topics + .map((t) => { + const humanReplies = t.posts.filter((p) => !p.user.isBot).length; + // 真实点赞:topic 自身的 likeCount + 所有回复的 likeCount 之和 + const postLikes = t.posts.reduce((s, p) => s + (postLikeMap.get(p.id) ?? 0), 0); + const realLikeCount = (t.likeCount || 0) + postLikes; + return { + id: t.id, + title: t.title, + forumName: t.category.name, + forumSlug: t.category.slug, + content: t.content, + replyCount: t._count?.posts ?? t.posts.length, + humanCount: humanReplies, + likeCount: realLikeCount, + opening: t.content.slice(0, 200), + bulletPoints: t.content + .split(/[。\n]/) + .map((s) => s.trim()) + .filter((s) => s.length > 5 && s.length < 60) + .slice(0, 5) + .join(" / "), + }; + }) + .filter( + (t) => + t.replyCount >= CRYSTALLIZE_TRIGGERS.minReplies || + t.humanCount >= CRYSTALLIZE_TRIGGERS.minHumanReplies || + t.likeCount >= CRYSTALLIZE_TRIGGERS.minLikes + ); +} + +async function processBotCrystallize(botChar) { + const botUser = await prisma.user.findFirst({ + where: { email: `bot_${botChar.key}@zhuiguang.ai` }, + }); + if (!botUser) return; + + const botConfig = await prisma.botConfig.findUnique({ + where: { userId: botUser.id }, + }); + if (!botConfig) return; + + const alreadyCrystallized = await getCrystallizedTopicIds(botConfig.id); + + const allQualified = await findQualifiedTopics(botUser, botConfig.id); + const newQualified = allQualified.filter((t) => !alreadyCrystallized.has(t.id)); + + if (newQualified.length < CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize) { + if (newQualified.length === 0) return; + console.log( + `${ts()} ${botChar.displayName}: ${newQualified.length} 个新合格话题 (< ${CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize}),跳过` + ); + return; + } + + const topicsForCrystallize = newQualified.slice(0, CRYSTALLIZE_TRIGGERS.maxTopicsPerBot); + console.log( + `${ts()} ${botChar.displayName}: ${topicsForCrystallize.length} 个高互动话题待解构` + ); + + const forumGroups = {}; + for (const t of topicsForCrystallize) { + if (!forumGroups[t.forumSlug]) forumGroups[t.forumSlug] = []; + forumGroups[t.forumSlug].push(t); + } + + for (const [forumSlug, topics] of Object.entries(forumGroups)) { + if (topics.length < CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize) continue; + + try { + const prompt = buildCrystallizePrompt(botChar, topics); + const result = await callDeepSeek(prompt); + + if (!result.name || !result.hookPattern || !result.bodyTemplate) { + console.warn(`${ts()} ${forumSlug} 解构结果字段不全,跳过`); + continue; + } + + const newSkill = await prisma.botSkill.create({ + data: { + botId: botConfig.id, + name: result.name.slice(0, 50), + category: forumSlug, + hookPattern: result.hookPattern, + bodyTemplate: result.bodyTemplate, + applicability: result.applicability || null, + sourceTopicIds: topics.map((t) => t.id), + successRate: 0.5, + usageCount: 0, + avgReplies: topics.reduce((s, t) => s + t.replyCount, 0) / topics.length, + avgLikes: topics.reduce((s, t) => s + t.likeCount, 0) / topics.length, + version: 1, + isActive: true, + }, + }); + + await prisma.botMemory.create({ + data: { + botId: botConfig.id, + memoryType: "skill_usage", + layer: "LONGTERM", + content: { + action: "crystallize_skill", + skillId: newSkill.id, + skillName: newSkill.name, + category: forumSlug, + sourceTopicCount: topics.length, + sourceTopicIds: topics.map((t) => t.id), + }, + importance: 0.9, + contextTags: { + skillId: newSkill.id, + forumSlug, + source: "skill_crystallize", + }, + }, + }); + + await prisma.taskLog.create({ + data: { + taskKey: "bot-skill-crystallize", + taskName: "Bot技能结晶", + status: "success", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + result: { + botKey: botChar.key, + forumSlug, + skillId: newSkill.id, + skillName: newSkill.name, + sourceCount: topics.length, + }, + }, + }); + + console.log( + `${ts()} ✅ 结晶 [${forumSlug}] "${newSkill.name}" (来源 ${topics.length} 帖)` + ); + } catch (err) { + console.error(`${ts()} ${forumSlug} 结晶失败:`, err.message); + await prisma.taskLog.create({ + data: { + taskKey: "bot-skill-crystallize", + taskName: "Bot技能结晶", + status: "error", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: err.message, + result: { botKey: botChar.key, forumSlug }, + }, + }); + } + } +} + +// 把已经"沉淀"过的技能(基于 sourceTopicIds)回查最近表现: +// 1) 找到 sourceTopicIds 中每个话题的最新点赞/回复数 +// 2) 与技能里记录的 avgReplies/avgLikes 对比,更新 successRate +// 3) 对 successRate < 0.2 且 usageCount >= 5 的技能,标记 isActive=false(暂时停用) +// 返回被回收/调整的技能数 +async function recycleSkillOutcomes() { + const skills = await prisma.botSkill.findMany({ + where: { isActive: true }, + select: { id: true, sourceTopicIds: true, avgReplies: true, avgLikes: true, usageCount: true, successRate: true }, + }); + if (skills.length === 0) return 0; + + let adjusted = 0; + for (const skill of skills) { + const ids = Array.isArray(skill.sourceTopicIds) ? skill.sourceTopicIds : []; + if (ids.length === 0) continue; + const topics = await prisma.forumTopic.findMany({ + where: { id: { in: ids } }, + select: { replyCount: true, likeCount: true }, + }); + if (topics.length === 0) continue; + const newAvgReplies = topics.reduce((s, t) => s + t.replyCount, 0) / topics.length; + const newAvgLikes = topics.reduce((s, t) => s + t.likeCount, 0) / topics.length; + // 简单 successRate = 当前平均点赞 / 5,上限 1 + const newSuccessRate = Math.min(1, newAvgLikes / 5); + const update = { + avgReplies: newAvgReplies, + avgLikes: newAvgLikes, + successRate: newSuccessRate, + }; + if (skill.usageCount >= 5 && newSuccessRate < 0.2) { + update.isActive = false; + } + await prisma.botSkill.update({ + where: { id: skill.id }, + data: update, + }); + adjusted++; + } + return adjusted; +} + +async function main() { + console.log(`${ts()} Bot Skill Crystallize starting...`); + + if (!process.env.DEEPSEEK_API_KEY) { + throw new Error("DEEPSEEK_API_KEY 未配置"); + } + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL 未配置"); + } + + const { characters } = await loadBotCharacters(); + const botChars = characters.filter((c) => !c.role || c.role !== "passerby"); + console.log(`${ts()} Loaded ${botChars.length} expert bots.`); + + let processed = 0; + for (const botChar of botChars) { + try { + await processBotCrystallize(botChar); + processed++; + } catch (err) { + console.error(`${ts()} Error processing ${botChar.key}:`, err.message); + } + } + + console.log(`${ts()} Recycle step: 回收成熟技能的实际表现...`); + const recycled = await recycleSkillOutcomes(); + console.log(`${ts()} Recycle done: ${recycled} 技能已回收`); + + await prisma.taskLog.create({ + data: { + taskKey: "bot-skill-crystallize", + taskName: "Bot技能结晶", + status: "success", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + result: { processedBots: processed, recycledSkills: recycled }, + }, + }); + + console.log(`${ts()} Bot Skill Crystallize done. Processed ${processed} bots, recycled ${recycled} skills.`); +} + +main() + .catch(async (e) => { + console.error(`${ts()} Fatal error:`, e); + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-skill-crystallize", + taskName: "Bot技能沉淀", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/bot-weekly-review.mjs b/scripts/bot-weekly-review.mjs new file mode 100644 index 0000000..663bd28 --- /dev/null +++ b/scripts/bot-weekly-review.mjs @@ -0,0 +1,311 @@ +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=5&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 MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; + +const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); + +const ENGAGEMENT_WEIGHTS = { + REPLY_PER_TOPIC: 0.4, + HUMAN_RATIO: 0.3, + LIKES_LOG: 0.3, +}; + +const TIER_THRESHOLDS = { + high: 1.0, + medium: 0.5, +}; + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function startOfToday() { + // 用 UTC 午夜避开 Prisma @db.Date 写入时的时区截断 + const d = new Date(); + return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); +} + +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 calcEngagementScore(stats) { + const { topicsCreated, repliesReceived, humanInteractions, likesReceived, repliesSent } = stats; + const replyPerTopic = topicsCreated > 0 ? repliesReceived / topicsCreated : 0; + const humanRatio = + repliesSent + topicsCreated > 0 ? humanInteractions / (repliesSent + topicsCreated) : 0; + const likesLog = Math.log(1 + likesReceived); + + return ( + ENGAGEMENT_WEIGHTS.REPLY_PER_TOPIC * Math.min(replyPerTopic, 5) + + ENGAGEMENT_WEIGHTS.HUMAN_RATIO * Math.min(humanRatio, 1) * 5 + + ENGAGEMENT_WEIGHTS.LIKES_LOG * Math.min(likesLog, 5) + ); +} + +function decideTier(score, baseTier) { + const baseRank = { low: 1, medium: 2, high: 3 }[baseTier] || 2; + if (score >= TIER_THRESHOLDS.high) return "high"; + if (score >= TIER_THRESHOLDS.medium) return baseRank >= 2 ? "medium" : "high"; + if (score >= TIER_THRESHOLDS.medium * 0.6) return baseRank >= 2 ? "low" : "medium"; + return "low"; +} + +function buildReflectionPrompt(botChar, stats, score, nextTier) { + const p = botChar.personality; + return `你是【${botChar.displayName}】。本周你的论坛表现总结: + +[本周数据] +- 发帖 ${stats.topicsCreated} 个 +- 回复 ${stats.repliesSent} 条 +- 收到回复 ${stats.repliesReceived} 条 +- 真人互动 ${stats.humanInteractions} 次 +- 收到点赞 ${stats.likesReceived} 个 +- 互动得分 ${score.toFixed(2)} + +[下周配额] ${nextTier} + +[你的角色] +${p.identity} +风格:${p.speakingStyle} + +[任务] +请以第一人称写一段50-120字的本周复盘: +1. 数据怎么解读(不要数字罗列,要有你的判断) +2. 哪类话题效果好、哪类效果差 +3. 下周你最想尝试的一个调整是什么 + +要求:保持你【${botChar.displayName}】的说话风格,简短口语化。 + +只输出JSON:{"reflection": "你的复盘内容"}`; +} + +async function callDeepSeek(prompt) { + const response = await withRetry(() => + openai.chat.completions.create({ + model: MODEL, + messages: [{ role: "user", content: prompt }], + temperature: 0.85, + max_tokens: 400, + }) + ); + const text = response.choices[0]?.message?.content?.trim() || ""; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error("无法解析JSON"); + return JSON.parse(jsonMatch[0]); +} + +async function getWeekRange() { + const today = startOfToday(); + const dayOfWeek = today.getUTCDay(); + // weekStart = today - 6 天(统一用 UTC 午夜) + const weekStart = new Date(today); + weekStart.setUTCDate(weekStart.getUTCDate() - 6); + const weekEnd = today; + return { weekStart, weekEnd }; +} + +async function reviewBot(botChar) { + const botUser = await prisma.user.findFirst({ + where: { email: `bot_${botChar.key}@zhuiguang.ai` }, + }); + if (!botUser) return; + + const botConfig = await prisma.botConfig.findUnique({ + where: { userId: botUser.id }, + }); + if (!botConfig) return; + + const { weekStart, weekEnd } = await getWeekRange(); + + // weekStart / weekEnd 已经是 UTC 午夜了,无需再 setHours + const weekStartDate = new Date(weekStart); + const weekEndDate = new Date(weekEnd); + + const stats = await prisma.botDailyStat.aggregate({ + where: { + botId: botConfig.id, + date: { gte: weekStartDate, lte: weekEndDate }, + }, + _sum: { + topicCreated: true, + replySent: true, + followUpSent: true, + repliesReceived: true, + likesReceived: true, + humanInteractions: true, + feedbackProcessed: true, + }, + }); + + const sum = stats._sum; + const flatStats = { + topicsCreated: sum.topicCreated || 0, + repliesSent: sum.replySent || 0, + repliesReceived: sum.repliesReceived || 0, + humanInteractions: sum.humanInteractions || 0, + likesReceived: sum.likesReceived || 0, + }; + + if (flatStats.topicsCreated === 0 && flatStats.repliesSent === 0) { + console.log(`${ts()} ${botChar.displayName}: 本周无活动,跳过`); + return; + } + + const score = calcEngagementScore(flatStats); + const baseTier = botChar.activityLevel || "medium"; + const nextTier = decideTier(score, baseTier); + + let reflection = null; + try { + const prompt = buildReflectionPrompt(botChar, flatStats, score, nextTier); + const result = await callDeepSeek(prompt); + reflection = (result.reflection || "").trim().slice(0, 500) || null; + } catch (err) { + console.warn(`${ts()} ${botChar.displayName} 反思生成失败:`, err.message); + } + + const review = await prisma.botWeeklyReview.upsert({ + where: { botId_weekStart: { botId: botConfig.id, weekStart: weekStartDate } }, + create: { + botId: botConfig.id, + weekStart: weekStartDate, + weekEnd: weekEndDate, + topicsCreated: flatStats.topicsCreated, + repliesSent: flatStats.repliesSent, + repliesReceived: flatStats.repliesReceived, + humanRatio: + flatStats.repliesSent + flatStats.topicsCreated > 0 + ? flatStats.humanInteractions / (flatStats.repliesSent + flatStats.topicsCreated) + : 0, + likesReceived: flatStats.likesReceived, + engagementScore: score, + nextWeekQuota: nextTier, + reflection, + }, + update: { + weekEnd: weekEndDate, + topicsCreated: flatStats.topicsCreated, + repliesSent: flatStats.repliesSent, + repliesReceived: flatStats.repliesReceived, + humanRatio: + flatStats.repliesSent + flatStats.topicsCreated > 0 + ? flatStats.humanInteractions / (flatStats.repliesSent + flatStats.topicsCreated) + : 0, + likesReceived: flatStats.likesReceived, + engagementScore: score, + nextWeekQuota: nextTier, + reflection, + }, + }); + + await prisma.botMemory.create({ + data: { + botId: botConfig.id, + memoryType: "reflection", + layer: "WORKING", + content: { + action: "weekly_review", + score, + topicsCreated: flatStats.topicsCreated, + repliesReceived: flatStats.repliesReceived, + humanInteractions: flatStats.humanInteractions, + likesReceived: flatStats.likesReceived, + nextTier, + reflection, + }, + importance: 0.7, + contextTags: { + reviewId: review.id, + weekStart: weekStartDate.toISOString().slice(0, 10), + source: "weekly_review", + }, + }, + }); + + console.log( + `${ts()} ${botChar.displayName}: 得分 ${score.toFixed(2)} | ${baseTier} → ${nextTier} | 帖${flatStats.topicsCreated}/收${flatStats.repliesReceived}/真人${flatStats.humanInteractions}` + ); +} + +async function main() { + console.log(`${ts()} Bot Weekly Review starting...`); + + if (!process.env.DEEPSEEK_API_KEY) { + throw new Error("DEEPSEEK_API_KEY 未配置"); + } + + const { characters } = await loadBotCharacters(); + const botChars = characters.filter((c) => !c.role || c.role !== "passerby"); + console.log(`${ts()} Loaded ${botChars.length} expert bots.`); + + let processed = 0; + for (const botChar of botChars) { + try { + await reviewBot(botChar); + processed++; + } catch (err) { + console.error(`${ts()} Error processing ${botChar.key}:`, err.message); + } + } + + await prisma.taskLog.create({ + data: { + taskKey: "bot-weekly-review", + taskName: "Bot周度复盘", + status: "success", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + result: { processedBots: processed }, + }, + }); + + console.log(`${ts()} Bot Weekly Review done. Processed ${processed} bots.`); +} + +main() + .catch(async (e) => { + console.error(`${ts()} Fatal error:`, e); + try { + await prisma.taskLog.create({ + data: { + taskKey: "bot-weekly-review", + taskName: "Bot周复盘", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/check-api.mjs b/scripts/check-api.mjs new file mode 100644 index 0000000..6e688ab --- /dev/null +++ b/scripts/check-api.mjs @@ -0,0 +1,45 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5"; +const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) }); + +// 模拟API的查询 +const allTopLevel = await prisma.forumCategory.findMany({ + where: { parentId: null }, + include: { + children: { + orderBy: [{ sortOrder: "asc" }], + }, + }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], +}); + +console.log("=== API查询结果 (parentId=null + children) ==="); +for (const cat of allTopLevel) { + console.log(`\n[${cat.slug}] ${cat.name} topicCount=${cat.topicCount}`); + for (const child of cat.children) { + console.log(` └─ [${child.slug}] ${child.name} topicCount=${child.topicCount} (子板块数: ?)`); + } +} + +// 看看community页面查询的industry +console.log("\n\n=== industry板块 children话题统计 ==="); +const industry = await prisma.forumCategory.findUnique({ + where: { slug: "industry" }, + include: { children: true } +}); +if (industry) { + for (const c of industry.children) { + const subCount = await prisma.forumCategory.count({ where: { parentId: c.id } }); + const subWithTopic = await prisma.forumCategory.findMany({ where: { parentId: c.id }, select: { name: true, topicCount: true } }); + console.log(` ${c.name} (${c.slug}) 子板块数=${subCount}:`); + for (const s of subWithTopic) { + console.log(` - ${s.name}: ${s.topicCount}`); + } + } +} + +await prisma.$disconnect(); diff --git a/scripts/check-community.py b/scripts/check-community.py new file mode 100644 index 0000000..a3931c9 --- /dev/null +++ b/scripts/check-community.py @@ -0,0 +1,48 @@ +import urllib.request +import re +import json + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 提取所有标签里的href和text: 板块名 +# 但HTML结构可能嵌套。用BeautifulSoup风格的解析 +# 找所有板块链接 +pattern = re.compile(r']*href="/community/([a-zA-Z0-9_-]+)"[^>]*>(.*?)', re.DOTALL) +matches = pattern.findall(html) +print(f'找到 {len(matches)} 个链接') + +# 提取每个slug对应的板块名(去HTML标签) +slug_to_names = {} +for slug, text in matches: + # 去掉HTML标签 + clean = re.sub(r'<[^>]+>', '', text).strip() + if clean and len(clean) < 20: + slug_to_names.setdefault(slug, []).append(clean) + +print('\n=== 全部板块slug与显示名 ===') +for slug in sorted(slug_to_names.keys()): + names = slug_to_names[slug] + print(f' {slug:30s} → {names}') + +# 对比数据库/API +api_req = urllib.request.Request('https://www.zhuig.com/api/forum/categories', headers={'Cache-Control': 'no-cache'}) +api_data = json.loads(urllib.request.urlopen(api_req, timeout=10).read().decode('utf-8')) +api_slugs = [] +for c in api_data['categories']: + api_slugs.append((c['slug'], c['name'], '顶层')) + for child in c.get('children', []): + api_slugs.append((child['slug'], child['name'], ' ├─')) + for gc in child.get('children', []): + api_slugs.append((gc['slug'], gc['name'], ' └─')) + +print(f'\n=== API板块数: {len(api_slugs)} ===') +# 看哪些是新版slug +new_design_slugs = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware', 'fd-restaurant', 'fd-tea', 'fd-prepared', 'fd-supply', 'fd-delivery', 're-residential', 're-commercial', 're-renovation', 're-property', 'fi-stock', 'fi-insurance', 'fi-pe', 'fi-forex', 'fi-crypto', 'ct-writing', 'ct-short-video', 'ct-live', 'ct-mcn', 'ct-podcast', 'll-beauty', 'll-fitness', 'll-pet', 'll-housekeeping', 'll-repair', 'll-edu-local', 'hl-cosmetic', 'hl-wellness', 'hl-elderly', 'hl-mental', 'hl-rehab', 'edu-knowledge', 'edu-skills', 'edu-abroad', 'edu-corporate', 'cb-ecommerce', 'cb-factory', 'cb-logistics', 'cb-payment', 'cb-brand'] + +api_slug_list = [s[0] for s in api_slugs] +in_api = [s for s in new_design_slugs if s in api_slug_list] +not_in_api = [s for s in new_design_slugs if s not in api_slug_list] +print(f'\n新设计子板块: 总{len(new_design_slugs)}, API中有{len(in_api)}, 缺失{len(not_in_api)}') +if not_in_api: + print(f'缺失: {not_in_api}') diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs new file mode 100644 index 0000000..bcb494e --- /dev/null +++ b/scripts/check-coverage.mjs @@ -0,0 +1,18 @@ +import { readFileSync } from "fs"; + +const scripts = [ + "task3-check-tools.mjs", + "task5-update-stars.mjs", + "task6-review-hot.mjs", + "daily-news.mjs", + "task7-news-to-community.mjs", + "daily-discover.mjs", +]; + +for (const s of scripts) { + const c = readFileSync("scripts/" + s, "utf-8"); + const hasFail = c.includes('"failed"'); + const hasCatch = c.includes(".catch("); + const hasStartLog = c.includes("taskLog.create") || c.includes("logTask("); + console.log(`${s.padEnd(35)} catch=${hasCatch} failLog=${hasFail} startLog=${hasStartLog}`); +} diff --git a/scripts/check-forum.mjs b/scripts/check-forum.mjs new file mode 100644 index 0000000..83eaaa7 --- /dev/null +++ b/scripts/check-forum.mjs @@ -0,0 +1,40 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5"; +const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) }); + +const total = await prisma.forumTopic.count(); +const last10 = await prisma.forumTopic.findMany({ + orderBy: { createdAt: "desc" }, + take: 10, + include: { category: { select: { name: true, slug: true } } } +}); +const catCount = await prisma.forumCategory.count(); +console.log("总话题数:", total); +console.log("总板块数:", catCount); +console.log("\n最近10个话题:"); +for (const t of last10) { + console.log(" [" + t.createdAt.toISOString() + "]", t.category?.name || "无板块", "|", t.title.substring(0, 50)); +} +const oneDayAgo = new Date(Date.now() - 24 * 3600 * 1000); +const recent = await prisma.forumTopic.count({ where: { createdAt: { gt: oneDayAgo } } }); +console.log("\n过去24小时新话题:", recent); + +const byCat = await prisma.forumCategory.findMany({ + include: { _count: { select: { topics: true } } }, + orderBy: { sortOrder: "asc" } +}); +console.log("\n各板块话题数:"); +for (const c of byCat) { + if (c._count.topics > 0) console.log(" " + c.name + ": " + c._count.topics); +} +const empty = byCat.filter(c => c._count.topics === 0); +console.log("\n空板块数:", empty.length); +if (empty.length > 0 && empty.length < 10) { + console.log("空板块列表:", empty.map(c => c.name).join(", ")); +} + +await prisma.$disconnect(); diff --git a/scripts/check-html-detail.py b/scripts/check-html-detail.py new file mode 100644 index 0000000..4aaa261 --- /dev/null +++ b/scripts/check-html-detail.py @@ -0,0 +1,33 @@ +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0 (iPhone)', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 保存完整HTML +with open('/tmp/comm-fresh.html', 'w', encoding='utf-8') as f: + f.write(html) + +# 提取构建ID +import re +build_id = re.search(r'BUILD_ID["\s:]+["\']([^"\']+)', html) +print(f'HTML size: {len(html)}') +print(f'BUILD_ID: {build_id.group(1) if build_id else "N/A"}') + +# 提取所有 _next/static 资源链接 +static_links = sorted(set(re.findall(r'/_next/static/([^"\']+)', html))) +print(f'\n静态资源: {len(static_links)}个') +for s in static_links[:15]: + print(f' /_next/static/{s}') + +# 检查"子板块是否在HTML里" +new_subcats = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware'] +for s in new_subcats: + if s in html: + # 找包含这个slug的整段 + idx = html.find(s) + ctx = html[max(0, idx-50):idx+150] + ctx_clean = re.sub(r'<[^>]+>', ' ', ctx) + ctx_clean = re.sub(r'\s+', ' ', ctx_clean) + print(f'\n [{s}] 出现: ...{ctx_clean[:200]}...') + else: + print(f'\n [{s}] NOT FOUND') diff --git a/scripts/check-html.py b/scripts/check-html.py new file mode 100644 index 0000000..742c483 --- /dev/null +++ b/scripts/check-html.py @@ -0,0 +1,10 @@ +import re +with open('/tmp/comm.html', encoding='utf-8') as f: + html = f.read() + +# 查找板块名 +names = re.findall(r'>([^<>]{2,15})', html) +keywords = ['赚钱', '副业', '电商', 'AI', '金融', '房产', '餐饮', '健康', '教育', '内容', '本地', '跨境', '求职'] +for n in names: + if any(k in n for k in keywords) and len(n) < 20: + print(repr(n)) diff --git a/scripts/check-industry.mjs b/scripts/check-industry.mjs new file mode 100644 index 0000000..2725ae0 --- /dev/null +++ b/scripts/check-industry.mjs @@ -0,0 +1,27 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5"; +const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) }); + +// 看industry板块 +const ind = await prisma.forumCategory.findUnique({ where: { slug: "industry" } }); +console.log('industry板块:', ind ? `id=${ind.id}, name=${ind.name}, parentId=${ind.parentId}` : 'NULL'); + +// 用page.tsx同样的查询 +const children = await prisma.forumCategory.findMany({ + where: { parentId: ind?.id ?? undefined }, + include: { children: { orderBy: [{ sortOrder: "asc" }] } }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], +}); +console.log(`\n父板块数: ${children.length}`); +let total = 0; +for (const c of children) { + console.log(` [${c.slug}] ${c.name} (子板块: ${c.children.length})`); + total += c.children.length; +} +console.log(`\n子板块总数: ${total}`); +console.log(`page.tsx期待: categories.length + totalSubCategories = ${children.length} + ${total} = ${children.length + total}`); +await prisma.$disconnect(); diff --git a/scripts/check-parents.mjs b/scripts/check-parents.mjs new file mode 100644 index 0000000..e7e7362 --- /dev/null +++ b/scripts/check-parents.mjs @@ -0,0 +1,33 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5"; +const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) }); + +// 列出所有父板块(parentId=null) +const parents = await prisma.forumCategory.findMany({ + where: { parentId: null }, + orderBy: { sortOrder: "asc" } +}); +console.log("=== 父板块(parentId=null) ==="); +for (const p of parents) { + const childCount = await prisma.forumCategory.count({ where: { parentId: p.id } }); + const topicCount = await prisma.forumTopic.count({ where: { categoryId: p.id } }); + console.log(` [${p.id}] ${p.slug} - ${p.name} (子板块:${childCount} 话题:${topicCount})`); +} + +// 列出所有"ec-platform"类的新设计子板块 +const newSlugs = ["ec-platform", "ec-livestream", "ai-tools-app", "ai-llm", "fi-stock", "re-residential", "fd-restaurant", "ll-beauty", "hl-cosmetic", "edu-k12"]; +console.log("\n=== 新设计子板块状态 ==="); +for (const slug of newSlugs) { + const c = await prisma.forumCategory.findUnique({ where: { slug } }); + if (c) { + console.log(` ✓ ${slug} - ${c.name} | parentId=${c.parentId} | topics=${c.topicCount}`); + } else { + console.log(` ✗ ${slug} 不存在`); + } +} + +await prisma.$disconnect(); diff --git a/scripts/check-render.py b/scripts/check-render.py new file mode 100644 index 0000000..4c27517 --- /dev/null +++ b/scripts/check-render.py @@ -0,0 +1,41 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 找所有 链接的位置 +all_a = list(re.finditer(r']*href="(/community/[^"]+)"', html)) +print(f'整页 总数: {len(all_a)}') + +# 看这些链接在HTML中的位置分布 +positions = [m.start() for m in all_a] +print(f'位置: {positions[:5]}...{positions[-5:] if len(positions) > 5 else ""}') + +# 找 "全部板块" 位置 +idx_quanbu = html.find('全部板块') +idx_zuixin = html.find('最新话题') +idx_luntan = html.find('论坛') +print(f'\n全部板块位置: {idx_quanbu}') +print(f'最新话题位置: {idx_zuixin}') +print(f'论坛位置: {idx_luntan}') + +# 看 "全部板块" 之前有多少板块链接(即头部导航/侧边栏的) +before_count = sum(1 for p in positions if p < idx_quanbu) +in_section_count = sum(1 for p in positions if idx_quanbu < p < idx_zuixin) +after_count = sum(1 for p in positions if p > idx_zuixin) +print(f' "全部板块"之前: {before_count}个链接') +print(f' "全部板块"区域内: {in_section_count}个链接') +print(f' "最新话题"之后: {after_count}个链接') + +# 找"全部板块"和"最新话题"之间的HTML +section = html[idx_quanbu:idx_zuixin] if idx_zuixin > 0 else "" +print(f'\n板块区域HTML大小: {len(section)}') +# 看里面有没有h3标题 +h3_count = len(re.findall(r' 标签数: {h3_count}') +# 找h3的内容 +for m in re.finditer(r']*>(.*?)', section, re.DOTALL): + text = re.sub(r'<[^>]+>', '', m.group(1)).strip() + if text: + print(f'

: {text}') diff --git a/scripts/check-slugs.py b/scripts/check-slugs.py new file mode 100644 index 0000000..2d2df30 --- /dev/null +++ b/scripts/check-slugs.py @@ -0,0 +1,7 @@ +import re +with open('/tmp/comm.html', encoding='utf-8') as f: + html = f.read() +slugs = sorted(set(re.findall(r'/community/([a-zA-Z0-9_-]+)', html))) +print('板块slug数:', len(slugs)) +for s in slugs: + print(' ', s) diff --git a/scripts/cleanup-task-logs.mjs b/scripts/cleanup-task-logs.mjs new file mode 100644 index 0000000..63532aa --- /dev/null +++ b/scripts/cleanup-task-logs.mjs @@ -0,0 +1,56 @@ +// 清理 30 天前的 task_log 数据,避免表无限膨胀 +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=2&pool_timeout=10`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const RETENTION_DAYS = 30; + +async function main() { + const startedAt = new Date(); + const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000); + const before = await prisma.taskLog.count(); + const { count } = await prisma.taskLog.deleteMany({ + where: { startedAt: { lt: cutoff } }, + }); + const after = await prisma.taskLog.count(); + console.log(`清理完成:删除 ${count} 条,剩 ${after} 条 (清理前 ${before})`); + + await prisma.taskLog.create({ + data: { + taskKey: "cleanup-task-logs", + taskName: "清理任务日志", + status: "success", + startedAt, + finishedAt: new Date(), + duration: Date.now() - startedAt.getTime(), + triggerBy: "cron", + result: { deleted: count, remaining: after, cutoff: cutoff.toISOString() }, + }, + }); +} + +main() + .catch(async (e) => { + console.error(e); + try { + await prisma.taskLog.create({ + data: { + taskKey: "cleanup-task-logs", + taskName: "清理任务日志", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "cron", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/daily-discover.mjs b/scripts/daily-discover.mjs index e8d51cb..2524361 100644 --- a/scripts/daily-discover.mjs +++ b/scripts/daily-discover.mjs @@ -5,7 +5,9 @@ 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").replace("mysql://", "mariadb://"); +const rawUrl = process.env.DATABASE_URL; +if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); } +const base = rawUrl.replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -18,6 +20,8 @@ const octokit = new Octokit({ const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1", + timeout: 120000, + maxRetries: 2, }); const REVIEW_PROMPT = `你是一个GitHub开源项目评测专家。请基于以下项目信息进行五维深度评测,并以JSON格式返回。务必只返回JSON,不要包含任何其他文字或Markdown格式。 diff --git a/scripts/daily-news.mjs b/scripts/daily-news.mjs index 6f32a56..e0b440b 100644 --- a/scripts/daily-news.mjs +++ b/scripts/daily-news.mjs @@ -4,7 +4,9 @@ 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").replace("mysql://", "mariadb://"); +const rawUrl = process.env.DATABASE_URL; +if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); } +const base = rawUrl.replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -13,6 +15,8 @@ const prisma = new PrismaClient({ adapter }); const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1", + timeout: 120000, + maxRetries: 2, }); async function fetchRSS(rssUrl, source) { @@ -390,4 +394,7 @@ async function main() { } } -main(); +main() + .then(() => { console.log("Done"); process.exit(0); }) + .catch((e) => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/debug-html.py b/scripts/debug-html.py new file mode 100644 index 0000000..9d64bd3 --- /dev/null +++ b/scripts/debug-html.py @@ -0,0 +1,27 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +idx_q = html.find('全部板块') +idx_z = html.find('最新话题') +section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000] + +# 找 dangerous 内容 +print(f'板块区域长度: {len(section)}') +print(f'\n板块区域前500字符:') +print(section[:500]) +print(f'\n...') +print(f'\n板块区域后200字符:') +print(section[-200:]) + +# 找数: {len(real_a)}') +# 找href="/community/ +href_a = re.findall(r'href="/community/[^"]+"', section) +print(f'href="/community/... 出现数: {len(href_a)}') +# 找RSC $ a +rsc_a = re.findall(r'\\"a\\"', section) +print(f'RSC \\"a\\" 组件数: {len(rsc_a)}') diff --git a/scripts/debug-rsc.py b/scripts/debug-rsc.py new file mode 100644 index 0000000..0ae52ec --- /dev/null +++ b/scripts/debug-rsc.py @@ -0,0 +1,33 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 板块区域附近 +idx_q = html.find('全部板块') +idx_z = html.find('最新话题') +section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000] + +# 找"标签: {len(real_a)}') +print(f'RSC \\"a\\" 组件: {len(real_a_in_quotes)}') + +# 看section里"$" RSC payload 数量 +dollar_count = section.count('"$"') +print(f'RSC payload "$" 数量: {dollar_count}') + +# 找第一个 RSC "a" 组件 +m = re.search(r'\["\$","a",[^]]+\]', section) +if m: + print(f'\n第一个RSC a组件示例:\n{m.group()[:500]}') + +# 看板块区域最后部分(应该是RSC结束+可能HTML) +print(f'\n板块区域最后200字符:') +print(section[-300:]) + +# 板块区域有多少个 [$,"a" 出现 +rsc_a = re.findall(r'\["\$","a"', section) +print(f'\nRSC "a" 组件数: {len(rsc_a)}') diff --git a/scripts/debug-section.py b/scripts/debug-section.py new file mode 100644 index 0000000..4d0b32f --- /dev/null +++ b/scripts/debug-section.py @@ -0,0 +1,29 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +idx_q = html.find('全部板块') +idx_z = html.find('最新话题') +section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000] + +# 找"电商零售"在section中的位置 +kw_pos = section.find('电商零售') +print(f'电商零售在section位置: {kw_pos}') +if kw_pos >= 0: + print(f'周围300字符: {section[max(0,kw_pos-100):kw_pos+200]}') + +# 找全部的板块数据标识 - slug 出现的位置 +slugs = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware'] +print(f'\n=== 各slug在section中是否出现 ===') +for s in slugs: + p = section.find(f'"{s}"') + p2 = section.find(f'/{s}') + print(f' {s:25s} "slug"-模式:{p} /slug-模式:{p2}') + +# 找父板块的name - "电商零售" 看看上下文 +print(f'\n=== 在section里查"电商零售"上下文 ===') +if '电商零售' in section: + idx = section.find('电商零售') + print(section[max(0,idx-50):idx+300]) diff --git a/scripts/entrypoint-app.sh b/scripts/entrypoint-app.sh new file mode 100644 index 0000000..7bd7257 --- /dev/null +++ b/scripts/entrypoint-app.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# ============================================================================= +# 追光AI APP 容器启动入口 +# - 校验 .env +# - 跑 prisma migrate deploy(生产环境,安全前向迁移) +# - 启动 next start +# ============================================================================= +set -e + +cd /app + +# Seed data volume if empty +if [ ! -f /app/data/bot-characters.json ] && [ -d /app/data-seed ]; then + echo "[entrypoint] Seeding data volume..." + cp -r /app/data-seed/* /app/data/ +fi + +# 1) .env 校验 +if [ ! -f /app/.env ]; then + echo "[entrypoint-app] FATAL: /app/.env not found. Mount .env file into container." + exit 1 +fi + +# 让 .env 对 next start 可见(Next.js 14 在 production 下也会读 .env) +set -a +. /app/.env +set +a + +# 2) 跑迁移(幂等;不阻塞启动) +echo "[entrypoint-app] Running prisma migrate deploy..." +npx prisma migrate deploy --schema=prisma/schema.prisma 2>&1 | tail -20 || { + echo "[entrypoint-app] WARN: migrate failed, continuing (DB schema may be ahead of migrations)" +} + +# 3) 启动 Next.js +echo "[entrypoint-app] Starting next start on port ${PORT:-8301}..." +exec node_modules/.bin/next start --port ${PORT:-8301} --hostname 0.0.0.0 diff --git a/scripts/entrypoint-cron.sh b/scripts/entrypoint-cron.sh new file mode 100644 index 0000000..23514a8 --- /dev/null +++ b/scripts/entrypoint-cron.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# ============================================================================= +# 追光AI CRON 容器启动入口 +# - 用 supercronic 调度 /app/crontab.txt 里的任务 +# - 跟 host cron 行为一致,但完全在容器内 +# ============================================================================= +set -e + +cd /app + +# Seed data volume if empty +if [ ! -f /app/data/bot-characters.json ] && [ -d /app/data-seed ]; then + echo "[entrypoint] Seeding data volume..." + cp -r /app/data-seed/* /app/data/ +fi + +# 1) .env 校验 +if [ ! -f /app/.env ]; then + echo "[entrypoint-cron] FATAL: /app/.env not found. Mount .env file into container." + exit 1 +fi + +# 2) crontab 文件校验 +if [ ! -f /app/crontab.txt ]; then + echo "[entrypoint-cron] FATAL: /app/crontab.txt not found. Mount crontab into container." + exit 1 +fi + +# 3) supercronic 启动 +echo "[entrypoint-cron] Starting supercronic..." +echo "[entrypoint-cron] crontab:" +sed 's/^/ /' /app/crontab.txt + +# -prometheus-listen-address 可选 (开监控) +# -split-logs 把 stdout/stderr 拆开 +exec /usr/local/bin/supercronic-linux-amd64 \ + -prometheus-listen-address 0.0.0.0:8310 \ + /app/crontab.txt diff --git a/scripts/final-check.py b/scripts/final-check.py new file mode 100644 index 0000000..0c995be --- /dev/null +++ b/scripts/final-check.py @@ -0,0 +1,36 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +print(f'HTML total: {len(html)} bytes') +a_tags = len(re.findall(r' 标签: {a_tags}') +h2 = len(re.findall(r': {h2}

: {h3}') + +# 找字面平台电商 +m = re.search(r']*>[^<]*平台电商[^<]*

', html) +print(f'字面<平台电商> HTML: {bool(m)}') + +# 板块区域 +idx_q = html.find('全部板块') +idx_z = html.find('最新话题') +section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000] +print(f'板块区域长度: {len(section)}') +a_pat = r' 数量: {h3_count}') + +# 找板块标题h2 +m2 = re.search(r']*>全部板块

', section) +print(f'板块标题 h2 全部板块 存在: {bool(m2)}') + +# 看section的前500字符 +print(f'\n板块区域前800字符:') +print(section[:800]) diff --git a/scripts/find-chunks.py b/scripts/find-chunks.py new file mode 100644 index 0000000..01cb61d --- /dev/null +++ b/scripts/find-chunks.py @@ -0,0 +1,33 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 找所有 $N 引用 +refs = re.findall(r'\$(\d+)', html) +unique_refs = sorted(set(int(r) for r in refs)) +print(f'RSC引用编号: {unique_refs}') + +# 找RSC块边界 (常见的分隔符是 \x00\x00\x00...) +# 实际上Next.js RSC使用特殊的script标签 +script_blocks = re.findall(r']*>(self\.__next_f\.push.*?)', html, re.DOTALL) +print(f'\n script 块: {len(script_blocks)}') + +# 找 $12 在哪里 +idx_12 = html.find('$12') +if idx_12 >= 0: + print(f'\n$12 在 HTML 位置: {idx_12}') + print(f'周围200字符: {html[max(0,idx_12-50):idx_12+200]}') + +# 找包含"电商零售"的位置 +idx_dianshang = html.find('电商零售') +print(f'\n"电商零售" 在 HTML 位置: {idx_dianshang}') +if idx_dianshang >= 0: + print(f'周围200字符: {html[max(0,idx_dianshang-30):idx_dianshang+200]}') + +# 找包含"平台电商"子板块 +idx_pingtai = html.find('平台电商') +print(f'\n"平台电商" 在 HTML 位置: {idx_pingtai}') +if idx_pingtai >= 0: + print(f'周围200字符: {html[max(0,idx_pingtai-100):idx_pingtai+200]}') diff --git a/scripts/find-section.py b/scripts/find-section.py new file mode 100644 index 0000000..fbcf37d --- /dev/null +++ b/scripts/find-section.py @@ -0,0 +1,25 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 找"全部板块"区域 +idx_q = html.find('全部板块') +idx_z = html.find('最新话题') +section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000] +print(f'板块区域HTML长度: {len(section)}') + +# 找section里第一个出现的板块数据关键词 +keywords = ['电商零售', 'AI科技', '金融投资', '跨境出海', '实体经营', '本地生活', '大健康', '教育培训', 'FIRE与生活', '自媒体内容', '餐饮食品', '房地产'] +print('\n=== 关键词在板块区域出现位置 ===') +for kw in keywords: + p = section.find(kw) + if p >= 0: + print(f' "{kw}": 位置{section.find(kw)}, 周围HTML: ...{section[max(0,p-30):p+80]}...') + +# 找"全部板块"区域前后的HTML标签结构 +print(f'\n=== "全部板块"之前的300字符 ===') +print(html[idx_q-300:idx_q]) +print(f'\n=== "最新话题"之前的500字符(应该是板块区域结束) ===') +print(html[idx_z-500:idx_z]) diff --git a/scripts/fix-topic-count.mjs b/scripts/fix-topic-count.mjs new file mode 100644 index 0000000..bbb8d94 --- /dev/null +++ b/scripts/fix-topic-count.mjs @@ -0,0 +1,23 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5"; +const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) }); + +const allCats = await prisma.forumCategory.findMany(); +let fixed = 0; +for (const c of allCats) { + // 计算真实的话题数 + const realCount = await prisma.forumTopic.count({ where: { categoryId: c.id } }); + if (c.topicCount !== realCount) { + await prisma.forumCategory.update({ + where: { id: c.id }, + data: { topicCount: realCount } + }); + fixed++; + } +} +console.log(`校准完成: 修复${fixed}个板块`); +await prisma.$disconnect(); diff --git a/scripts/health-check.mjs b/scripts/health-check.mjs new file mode 100644 index 0000000..6fdcc4e --- /dev/null +++ b/scripts/health-check.mjs @@ -0,0 +1,102 @@ +// 健康检查脚本:扫描所有定时任务最近一次执行情况 +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=2&pool_timeout=10`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log("========== 任务调度健康检查 ==========\n"); + + // 1. 任务配置 + const configs = await prisma.taskConfig.findMany({ orderBy: { taskKey: "asc" } }); + console.log("--- 任务配置 ---"); + console.log(`共 ${configs.length} 条配置`); + for (const c of configs) { + console.log(` [${c.enabled ? "●" : "○"}] ${c.taskKey.padEnd(30)} cron=${c.cronExpr || "—"} ${c.taskName}`); + } + + // 2. 最近 7 天的执行记录 + const since = new Date(Date.now() - 7 * 24 * 3600 * 1000); + const logs = await prisma.taskLog.findMany({ + where: { startedAt: { gte: since } }, + orderBy: { startedAt: "desc" }, + }); + console.log(`\n--- 最近 7 天日志(${logs.length} 条) ---`); + const byKey = new Map(); + for (const l of logs) { + if (!byKey.has(l.taskKey)) byKey.set(l.taskKey, []); + byKey.get(l.taskKey).push(l); + } + for (const [key, list] of byKey.entries()) { + const success = list.filter((l) => l.status === "completed").length; + const failed = list.filter((l) => l.status === "failed").length; + const last = list[0]; + const lastDate = new Date(last.startedAt).toLocaleString("zh-CN"); + console.log(` ${key.padEnd(30)} 共${list.length}次 成功${success} 失败${failed} 最近=${lastDate} status=${last.status}`); + } + + // 3. 任何"从未执行"的任务 + console.log("\n--- 检查哪些 taskKey 已配置但从未执行 ---"); + const executedKeys = new Set(logs.map((l) => l.taskKey)); + const allExpectedKeys = new Set([ + ...configs.map((c) => c.taskKey), + "task1-discover-tools", + "task3-check-tools", + "task4-discover-skills", + "task5-update-stars", + "task6-review-hot", + "task7-news-to-community", + "daily-news", + "bot-activity", + "bot-affinity-update", + "bot-feedback-loop", + "bot-weekly-review", + "bot-skill-crystallize", + ]); + const knownButNeverRun = [...allExpectedKeys].filter((k) => !executedKeys.has(k)); + console.log("从未执行:", knownButNeverRun.length ? knownButNeverRun.join(", ") : "无"); + + // 4. bot 体系健康 + console.log("\n--- Bot 体系健康 ---"); + const totalBots = await prisma.user.count({ where: { isBot: true } }); + const totalTopicsByBots = await prisma.forumTopic.count({ where: { user: { isBot: true } } }); + const totalPostsByBots = await prisma.forumPost.count({ where: { user: { isBot: true } } }); + const totalMemories = await prisma.botMemory.count(); + const totalBotConfigs = await prisma.botConfig.count(); + const lastBotTopic = await prisma.forumTopic.findFirst({ + where: { user: { isBot: true } }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, title: true }, + }); + console.log(` Bot 用户总数: ${totalBots}`); + console.log(` BotConfig 数: ${totalBotConfigs}`); + console.log(` BotMemory 数: ${totalMemories}`); + console.log(` Bot 发帖数: ${totalTopicsByBots}`); + console.log(` Bot 回复数: ${totalPostsByBots}`); + if (lastBotTopic) { + const hoursAgo = (Date.now() - new Date(lastBotTopic.createdAt).getTime()) / 3600000; + console.log(` 最近 Bot 帖: ${lastBotTopic.title.substring(0, 40)}... (${hoursAgo.toFixed(1)} 小时前)`); + } else { + console.log(" 最近 Bot 帖: 无"); + } + + // 5. 最近失败的详情 + const recentFailed = logs.filter((l) => l.status === "failed").slice(0, 5); + console.log(`\n--- 最近失败任务 (${recentFailed.length}) ---`); + for (const f of recentFailed) { + console.log(` ${f.taskKey} @ ${new Date(f.startedAt).toLocaleString("zh-CN")}`); + if (f.error) console.log(` ${f.error.substring(0, 200)}`); + } + + await prisma.$disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/install-cron-backup.sh b/scripts/install-cron-backup.sh new file mode 100644 index 0000000..2197fdc --- /dev/null +++ b/scripts/install-cron-backup.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# ============================================================================= +# 追光AI 容器化版 host 备份 cron 安装脚本 +# - 在宿主机 crontab 里加 1 条:每天 03:30 跑 backup-volumes.sh +# - 注意:这是宿主机 cron(不是容器内 supercronic) +# - 用法: bash scripts/install-cron-backup.sh +# ============================================================================= +set -e + +PROJECT="/home/ubuntu/zhuiguang-ai" +SCRIPT="$PROJECT/scripts/backup-volumes.sh" +CRON_LINE="30 3 * * * bash $SCRIPT >> $PROJECT/logs/host-backup.log 2>&1" + +if ! crontab -l 2>/dev/null | grep -qF "$SCRIPT"; then + echo "[install-cron-backup] 添加备份 cron: $CRON_LINE" + (crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab - +else + echo "[install-cron-backup] 备份 cron 已存在,跳过" +fi + +echo "[install-cron-backup] 当前 crontab 中包含 backup 的行:" +crontab -l 2>/dev/null | grep -E "backup" || echo " (无)" diff --git a/scripts/install-cron.sh b/scripts/install-cron.sh index e132086..813ec68 100644 --- a/scripts/install-cron.sh +++ b/scripts/install-cron.sh @@ -43,13 +43,38 @@ PROJECT="/home/ubuntu/zhuiguang-ai" # 每天早上8:00 新闻推送到社区版块 0 8 * * * bash $WRAPPER task7 $PROJECT/scripts/task7-news-to-community.mjs -# 数字人bot活动引擎(每整点运行,9:00-23:00共15轮/天,每轮3帖) +# 数字人bot活动引擎(每整点运行,9:00-23:00共15轮/天,每轮5+5=10板块+4回复/板块+5点赞) +# 2026-06-10 翻倍:原 4+4=8 板块、3回复、3点赞 → 5+5=10 板块、4回复、5点赞 0 9,10,11,12,13,14,15,16,17,18,19,20,21,22,23 * * * bash $WRAPPER bot-activity $PROJECT/scripts/bot-activity.mjs + +# Bot跨板块亲和度与画像:每6小时滚动一次 +0 */6 * * * bash $WRAPPER bot-affinity-update $PROJECT/scripts/bot-affinity-update.mjs + +# Bot反馈循环:每3小时一次(基于历史回复生成跟进) +0 */3 * * * bash $WRAPPER bot-feedback-loop $PROJECT/scripts/bot-feedback-loop.mjs + +# Bot技能沉淀:每12小时一次 +0 */12 * * * bash $WRAPPER bot-skill-crystallize $PROJECT/scripts/bot-skill-crystallize.mjs + +# Bot周复盘:每周一凌晨2:30执行 +30 2 * * 1 bash $WRAPPER bot-weekly-review $PROJECT/scripts/bot-weekly-review.mjs + +# 每周日凌晨6:30 清理 30 天前的 task_log 数据 +30 6 * * 0 bash $WRAPPER cleanup-task-logs $PROJECT/scripts/cleanup-task-logs.mjs + +# 每6小时15分对齐 forumTopic/forumPost.likeCount 与真实 Like 表 +15 */6 * * * bash $WRAPPER reconcile-like-counts $PROJECT/scripts/reconcile-like-counts.mjs --fix + +# Bot Persona A/B 测试调度:每4小时45分(采指标 + 显著性分析 + winner 权重切换) +45 */4 * * * bash $WRAPPER bot-persona-experiment-run $PROJECT/scripts/bot-persona-experiment-run.mjs --lookback=7 + +# Bot 对抗学习(真人高赞帖):每周日23:00 跑一次 +0 23 * * 0 bash $WRAPPER bot-adversarial-learning-run $PROJECT/scripts/bot-adversarial-learning-run.mjs --lookback=7 CRONEOF crontab /tmp/cron-temp rm -f /tmp/cron-temp -echo "✅ crontab 已更新(7个定时任务)" +echo "✅ crontab 已更新(15个定时任务)" echo "" echo "当前 crontab 内容:" @@ -59,11 +84,19 @@ echo "" echo "========================================" echo " 安装完成!每天自动执行时间:" echo " 01:00 - Task5 更新技能星值" +echo " 02:30 - Bot周复盘(仅周一)" echo " 03:00 - Task3 工具巡检" echo " 04:00 - Task6 评测热门技能" echo " 05:00 - Task4 发现AI技能" echo " 06:00 - Task1 发现AI工具" +echo " 06:15 - 点赞数对齐巡检(每6h)" +echo " 06:30 - 清理任务日志(仅周日)" echo " 07:30 - 生成AI日报" echo " 08:00 - Task7 新闻→社区" -echo " 09-23 - 数字人bot活动引擎(每小时1轮)" +echo " 每3h - Bot反馈循环(0/3/6/9/12/15/18/21)" +echo " 每4h - Bot Persona A/B 调度(3/7/11/15/19/23)" +echo " 每周日23:00 - Bot 对抗学习(真人高赞帖)" +echo " 每6h - Bot亲和度与画像(0/6/12/18)" +echo " 每12h - Bot技能沉淀(0/12)" +echo " 9-23 - 数字人bot活动引擎(每小时1轮)" echo "========================================" \ No newline at end of file diff --git a/scripts/lib/bot-adversarial-learning.mjs b/scripts/lib/bot-adversarial-learning.mjs new file mode 100644 index 0000000..642ba3c --- /dev/null +++ b/scripts/lib/bot-adversarial-learning.mjs @@ -0,0 +1,535 @@ +// Bot 数字人 vs 真人"对抗学习" 共享模块 +// 每周为每个 bot 选 1 个真实高赞真人帖,让 bot 吸收其"为什么高互动"的洞察 +// +// 用法: +// import { runWeeklyAdversarialLearning, getActiveLearningForBot, buildAdversarialBlock } from "./lib/bot-adversarial-learning.mjs"; +// +// // 1) 周调度入口 +// await runWeeklyAdversarialLearning({ weekKey: "2026-W23", lookbackDays: 7 }); +// +// // 2) 发帖 prompt 注入对抗学习参考 +// const learning = await getActiveLearningForBot(botConfigId); +// const block = buildAdversarialBlock(learning); // → "[对抗学习] ..." + +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; +import OpenAI from "openai"; +import { withRetry } from "./retry.mjs"; + +const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://"); +const sep = base.includes("?") ? "&" : "?"; +const connectionString = `${base}${sep}connection_limit=3&pool_timeout=10`; + +let _prisma = null; +function getPrisma() { + if (!_prisma) { + const adapter = new PrismaMariaDb(connectionString); + _prisma = new PrismaClient({ adapter }); + } + return _prisma; +} + +let _openai = null; +function getOpenAI() { + if (_openai) return _openai; + if (!process.env.DEEPSEEK_API_KEY) return null; + _openai = new OpenAI({ + apiKey: process.env.DEEPSEEK_API_KEY, + baseURL: "https://api.deepseek.com/v1", + }); + return _openai; +} + +const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; + +function ts() { + return `[${new Date().toISOString()}]`; +} + +// ======== 配置 ======== +export const LEARNING_CONFIG = { + // 候选帖最小门槛 + minReplies: 3, + minLikes: 5, + minHumanReplies: 1, + // 回看窗口(天) + lookbackDays: 7, + // 候选数上限 + candidateLimit: 30, + // 每个 bot 每周上限 + perBotPerWeek: 1, + // 关联度评分 + sameForumWeight: 1.0, + keywordOverlapWeight: 0.6, + recencyWeight: 0.3, + // 过期 + expireDays: 7, + // LLM 提示 token 上限 + contentTruncate: 1500, +}; + +// ======== 工具:周 key (ISO week) ======== +/** + * 形如 "2026-W23",与 date-fns / Excel WEEKNUM 行为一致 + */ +export function getISOWeekKey(date = new Date()) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; // Mon=1..Sun=7 + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +// ======== 工具:取候选真人帖 ======== +/** + * 拉过去 N 天内由真人发布、且互动数超过门槛的 topic 列表 + * 排序:综合分 = 赞×3 + 真人回复×5 + 回复 + * 仅返回 topic 维度(post 维度后续可加) + */ +export async function fetchHumanHighEngagementTopics(lookbackDays = 7, limit = 30) { + const prisma = getPrisma(); + const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000); + + // 1) 先拉 botUserId 集合,避免 N+1 + const botUsers = await prisma.user.findMany({ + where: { isBot: true }, + select: { id: true }, + }); + const botUserIdSet = new Set(botUsers.map((u) => u.id)); + + // 2) 拉所有非 bot 的 topic,按 replyCount desc 取前 N(粗筛) + const candidates = await prisma.forumTopic.findMany({ + where: { + userId: { notIn: Array.from(botUserIdSet) }, + isHidden: false, + createdAt: { gte: since }, + }, + include: { + user: { select: { id: true, name: true, isBot: true } }, + category: { select: { slug: true, name: true } }, + posts: { + where: { user: { isBot: false } }, + select: { id: true }, + }, + }, + orderBy: [{ replyCount: "desc" }, { likeCount: "desc" }], + take: limit * 2, + }); + + // 3) 计算综合分,过门槛 + const scored = candidates + .filter((t) => !t.user?.isBot) + .map((t) => { + const humanReplies = t.posts?.length || 0; + const score = (t.likeCount || 0) * 3 + humanReplies * 5 + (t.replyCount || 0); + return { ...t, _score: score, _humanReplies: humanReplies }; + }) + .filter( + (t) => + (t.replyCount || 0) >= LEARNING_CONFIG.minReplies || + t._humanReplies >= LEARNING_CONFIG.minHumanReplies || + (t.likeCount || 0) >= LEARNING_CONFIG.minLikes + ) + .sort((a, b) => b._score - a._score) + .slice(0, limit); + + return scored; +} + +// ======== 工具:候选与 bot 关联度评分 ======== +/** + * 计算一篇候选帖对某 bot 的关联度 + * - 板块命中:bot.primaryForums 包含该帖板块 → 同板块加权 + * - 关键词重叠:bot stanceKeywords 与帖子标题/内容重叠数 + * - 时效衰减:越新越好 + */ +function calcRelevance(bot, persona, topic) { + let score = 0; + const primaryForums = (bot.primaryForums || []).map((s) => String(s).toLowerCase()); + const stanceKeywords = ((persona?.stanceKeywords || []).map((k) => k.key || k)).filter(Boolean); + + if (primaryForums.includes(String(topic.category?.slug || "").toLowerCase())) { + score += LEARNING_CONFIG.sameForumWeight; + } + + if (stanceKeywords.length > 0) { + const text = `${topic.title || ""} ${topic.content || ""}`.toLowerCase(); + let hits = 0; + for (const kw of stanceKeywords) { + const k = String(kw).toLowerCase(); + if (k.length < 2) continue; + if (text.includes(k)) hits++; + } + const overlapRatio = Math.min(1, hits / 5); + score += overlapRatio * LEARNING_CONFIG.keywordOverlapWeight; + } + + // 时效:距今 < 3 天满分,> 6 天 0 分 + const ageMs = Date.now() - new Date(topic.createdAt).getTime(); + const ageDays = ageMs / (24 * 60 * 60 * 1000); + const recencyScore = Math.max(0, 1 - ageDays / 6) * LEARNING_CONFIG.recencyWeight; + score += recencyScore; + + return Math.round(score * 1000) / 1000; +} + +// ======== LLM 提取"为什么高互动"洞察 ======== +const LEARN_PROMPT = (botName, botRole, topic) => `你是【${botName}】的内容策略师,每周要研究一篇真实用户(不是数字人)的高互动帖子,提炼出"为什么火",作为下周发帖可借鉴的方向。 + +[数字人角色] +- 名字:${botName} +- 角色:${botRole || "通用论坛内容创作者"} + +[本周高互动真人帖] +- 标题:${topic.title} +- 板块:${topic.category?.name || ""} +- 收到回复:${topic.replyCount}(真人 ${topic._humanReplies || 0}) +- 收到点赞:${topic.likeCount} +- 浏览数:${topic.viewCount} +- 内容(截取前 ${LEARNING_CONFIG.contentTruncate} 字): +${(topic.content || "").slice(0, LEARNING_CONFIG.contentTruncate)} + +[任务] +站在【${botName}】的视角,分析这篇真人帖之所以高互动的 2-3 个可学习点。注意: +1. 不要泛泛而谈"写得好",要给出具体的"可复用的写法/角度/钩子" +2. 关注"为什么能引发真人回复"——是观点鲜明、抛问题、还是给方案/故事 +3. 提炼出 2-3 个对【${botName}】下周发帖有直接借鉴价值的策略 +4. 输出一段 150-250 字的简洁洞察,下周发帖时可作为参考 + +[输出格式] +只输出严格的 JSON: +{ + "insight": "(150-250 字的洞察,聚焦可复用的写法/角度/钩子)", + "categories": ["hook|story|data|question|contrarian|empathy|practical", ...] +}`; + +async function extractInsightWithLLM(botChar, topic) { + const openai = getOpenAI(); + if (!openai) { + // 降级:无 LLM 时输出规则性总结 + return buildFallbackInsight(topic); + } + const p = botChar?.personality || {}; + const role = [p.identity, p.stance, p.speakingStyle].filter(Boolean).join(" / "); + const prompt = LEARN_PROMPT(botChar?.displayName || "数字人", role, topic); + const response = await withRetry(() => + openai.chat.completions.create({ + model: MODEL, + messages: [{ role: "user", content: prompt }], + temperature: 0.6, + max_tokens: 800, + }) + ); + const text = response.choices?.[0]?.message?.content?.trim() || ""; + const match = text.match(/\{[\s\S]*\}/); + if (!match) return buildFallbackInsight(topic); + try { + const parsed = JSON.parse(match[0]); + return { + insight: String(parsed.insight || "").slice(0, 1000), + categories: Array.isArray(parsed.categories) ? parsed.categories.slice(0, 5) : [], + }; + } catch { + return buildFallbackInsight(topic); + } +} + +function buildFallbackInsight(topic) { + return { + insight: `这篇真人帖(${topic.title || "无标题"})收到 ${topic.replyCount} 条回复 / ${topic.likeCount} 个赞,关键在于:${(topic.content || "").slice(0, 80)}...的可复用角度。下周可参考其切入点和表达方式。`, + categories: ["practical"], + }; +} + +// ======== 单 bot 学习流程 ======== +/** + * 对一个 bot 跑对抗学习: + * 1) 拉真人高互动候选 + * 2) 与 bot 关联度排序 → 选 top1 + * 3) LLM 提取洞察 + * 4) upsert 到 bot_adversarial_learnings (unique: botId+weekKey) + * 5) 同步写一条 BotMemory + */ +export async function learnForBot(botUser, botConfig, persona, options = {}) { + const prisma = getPrisma(); + const cfg = { ...LEARNING_CONFIG, ...options }; + const weekKey = options.weekKey || getISOWeekKey(); + + // 已存在本 bot 本周的学习 → 跳过 + const existing = await prisma.botAdversarialLearning.findUnique({ + where: { botId_weekKey: { botId: botConfig.id, weekKey } }, + }); + if (existing) { + return { skipped: true, reason: "already_learned", record: existing }; + } + + // 1) 候选 + const candidates = await fetchHumanHighEngagementTopics(cfg.lookbackDays, cfg.candidateLimit); + if (candidates.length === 0) { + return { skipped: true, reason: "no_candidates" }; + } + + // 2) 关联度排序 + const ranked = candidates + .map((c) => ({ topic: c, relevance: calcRelevance(botConfig, persona, c) })) + .sort((a, b) => b.relevance - a.relevance); + const top = ranked[0]; + + // 3) LLM 提取 + const { insight, categories } = await extractInsightWithLLM(botUser._botChar, top.topic); + + // 4) upsert 写入 + const expiresAt = new Date(Date.now() + cfg.expireDays * 24 * 60 * 60 * 1000); + const record = await prisma.botAdversarialLearning.upsert({ + where: { botId_weekKey: { botId: botConfig.id, weekKey } }, + create: { + botId: botConfig.id, + weekKey, + sourceRefType: "topic", + sourceRefId: top.topic.id, + sourceUserId: top.topic.userId, + sourceUserName: top.topic.user?.name || null, + forumSlug: top.topic.category?.slug || null, + sourceTitle: top.topic.title || null, + sourceContent: (top.topic.content || "").slice(0, 5000), + sourceMetrics: { + replyCount: top.topic.replyCount || 0, + likeCount: top.topic.likeCount || 0, + viewCount: top.topic.viewCount || 0, + humanReplies: top._humanReplies || 0, + engagementScore: top._score, + }, + relevanceScore: top.relevance, + learnedInsight: insight, + learnCategories: categories, + status: "active", + expiresAt, + }, + update: { + // 本周二次跑:覆盖(强制重学) + sourceRefType: "topic", + sourceRefId: top.topic.id, + sourceUserId: top.topic.userId, + sourceUserName: top.topic.user?.name || null, + forumSlug: top.topic.category?.slug || null, + sourceTitle: top.topic.title || null, + sourceContent: (top.topic.content || "").slice(0, 5000), + sourceMetrics: { + replyCount: top.topic.replyCount || 0, + likeCount: top.topic.likeCount || 0, + viewCount: top.topic.viewCount || 0, + humanReplies: top._humanReplies || 0, + engagementScore: top._score, + }, + relevanceScore: top.relevance, + learnedInsight: insight, + learnCategories: categories, + status: "active", + expiresAt, + }, + }); + + // 5) 同步写一条 BotMemory + await prisma.botMemory.create({ + data: { + botId: botConfig.id, + memoryType: "adversarial_learning", + layer: "LONGTERM", + content: { + action: "learn_from_human", + weekKey, + learningId: record.id, + sourceTitle: top.topic.title, + sourceUserName: top.topic.user?.name || null, + sourceUserId: top.topic.userId, + sourceRefType: "topic", + sourceRefId: top.topic.id, + insight, + categories, + relevanceScore: top.relevance, + sourceMetrics: { + replyCount: top.topic.replyCount || 0, + likeCount: top.topic.likeCount || 0, + humanReplies: top._humanReplies || 0, + }, + }, + importance: 0.85, + contextTags: { + weekKey, + forumSlug: top.topic.category?.slug || null, + source: "adversarial_learning", + learningId: record.id, + }, + }, + }); + + return { skipped: false, record, candidate: top.topic, relevance: top.relevance }; +} + +// ======== 加载所有 bot 配置 + 角色 + persona ======== +async function loadAllExpertBots() { + const prisma = getPrisma(); + const botUsers = await prisma.user.findMany({ + where: { isBot: true }, + include: { botConfig: true }, + }); + // 过滤出有 config 的"非路人"专家 bot + return botUsers + .filter((u) => u.botConfig && (!u.botConfig.personality || !u.botConfig.personality?.role || u.botConfig.personality?.role !== "passerby")) + .map((u) => ({ user: u, config: u.botConfig })); +} + +async function loadPersonaForBot(botConfigId) { + const prisma = getPrisma(); + return prisma.botPersona.findUnique({ where: { botId: botConfigId } }); +} + +// 尝试从 bot-characters.json 加载角色定义(仅用于 LLM 提示) +async function loadBotCharMap() { + try { + const fs = await import("fs"); + const path = await import("path"); + const url = await import("url"); + const { readFileSync } = fs; + const { resolve, dirname } = path; + const { fileURLToPath } = url; + const __dirname = dirname(fileURLToPath(import.meta.url)); + const p = resolve(__dirname, "..", "..", "data", "bot-characters.json"); + const raw = readFileSync(p, "utf-8"); + const { characters } = JSON.parse(raw); + const map = {}; + for (const c of characters) map[c.key] = c; + return map; + } catch { + return {}; + } +} + +// ======== 周调度入口 ======== +/** + * 给所有专家 bot 跑一次对抗学习 + * @param options + * - weekKey: 强制指定周 key(默认当前 ISO 周) + * - lookbackDays: 候选回看窗口(默认 7) + * - botConfigIds: 限定 bot id 列表 + * - skipExisting: 已有本周学习时跳过(默认 true) + */ +export async function runWeeklyAdversarialLearning(options = {}) { + const prisma = getPrisma(); + const weekKey = options.weekKey || getISOWeekKey(); + const botCharMap = await loadBotCharMap(); + + const bots = await loadAllExpertBots(); + const targetBots = options.botConfigIds + ? bots.filter((b) => options.botConfigIds.includes(b.config.id)) + : bots; + + const results = []; + for (const { user, config } of targetBots) { + try { + const persona = await loadPersonaForBot(config.id); + const emailPrefix = (user.email || "").split("@")[0] || ""; + const key = emailPrefix.replace(/^bot_/, ""); + user._botChar = botCharMap[key] || { displayName: user.name, personality: {} }; + + const r = await learnForBot(user, config, persona, { + weekKey, + lookbackDays: options.lookbackDays ?? LEARNING_CONFIG.lookbackDays, + }); + results.push({ botId: config.id, botName: user.name, ...r }); + } catch (err) { + results.push({ botId: config.id, botName: user.name, error: err.message }); + } + } + + // 把过期的 learning 标记 expired + await prisma.botAdversarialLearning.updateMany({ + where: { + status: "active", + expiresAt: { lt: new Date() }, + }, + data: { status: "expired" }, + }); + + const summary = { + weekKey, + botCount: targetBots.length, + learned: results.filter((r) => !r.skipped && !r.error).length, + skipped: results.filter((r) => r.skipped).length, + errors: results.filter((r) => r.error).length, + results, + }; + return summary; +} + +// ======== 在 prompt 中使用 ======== +/** + * 拉取某 bot 当前生效的对抗学习(status=active 且未过期),取最近一条 + */ +export async function getActiveLearningForBot(botConfigId) { + const prisma = getPrisma(); + return prisma.botAdversarialLearning.findFirst({ + where: { + botId: botConfigId, + status: "active", + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + orderBy: { learnedAt: "desc" }, + }); +} + +/** + * 把 learning 渲染成可注入 prompt 的 block + */ +export function buildAdversarialBlock(learning) { + if (!learning) return ""; + const lines = []; + lines.push(`[对抗学习参考 · ${learning.weekKey}]`); + if (learning.sourceTitle) { + lines.push(`- 高互动真人帖:${learning.sourceTitle}`); + } + if (learning.sourceUserName) { + lines.push(`- 真人作者:${learning.sourceUserName}`); + } + if (learning.forumSlug) { + lines.push(`- 板块:${learning.forumSlug}`); + } + const m = learning.sourceMetrics || {}; + if (m.replyCount !== undefined) { + lines.push( + `- 互动数据:${m.replyCount || 0} 回复 / ${m.humanReplies || 0} 真人回复 / ${m.likeCount || 0} 赞 / ${m.viewCount || 0} 浏览` + ); + } + if (Array.isArray(learning.learnCategories) && learning.learnCategories.length > 0) { + lines.push(`- 可借鉴角度:${learning.learnCategories.join("、")}`); + } + if (learning.learnedInsight) { + lines.push(`- 洞察:${learning.learnedInsight}`); + } + lines.push("- 提示:本周发帖/回复时可参考上述真人帖的切入角度、表达方式,但不要直接抄袭内容。"); + return `\n${lines.join("\n")}\n`; +} + +/** + * 标记 learning 已被使用一次(用于统计触达率) + */ +export async function markLearningUsed(learningId) { + if (!learningId) return; + const prisma = getPrisma(); + await prisma.botAdversarialLearning.update({ + where: { id: learningId }, + data: { usedCount: { increment: 1 } }, + }); +} + +/** + * 优雅关闭 prisma + */ +export async function disconnectAdversarialLearning() { + if (_prisma) { + await _prisma.$disconnect(); + _prisma = null; + } +} diff --git a/scripts/lib/bot-avatar-generator.mjs b/scripts/lib/bot-avatar-generator.mjs new file mode 100644 index 0000000..e32ab81 --- /dev/null +++ b/scripts/lib/bot-avatar-generator.mjs @@ -0,0 +1,309 @@ +// scripts/lib/bot-avatar-generator.mjs +// 数字人头像生成器 +// +// 策略: +// 1. 先调用 Lunaris (trae-api text_to_image) 拿一张"AI 生成"图 +// 2. 检测是否是占位 default(目前 trae-api 任何 prompt 都返回同一张 default.jpeg) +// 3. 若是占位 → 用程序化 SVG 生成唯一头像(基于 key 哈希 + 调色板 + 角色 initial) +// 4. 落盘到 public/bot-avatars/{key}.{ext} + 更新 User.avatarUrl +// +// 这样每个 bot 都有视觉上独特的头像;当 Lunaris 真正支持按 prompt 生成时, +// 重跑 --force --use-lunaris-only 即可切回 AI 生成的版本。 + +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } 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=30`; +const adapter = new PrismaMariaDb(connectionString); +export const prisma = new PrismaClient({ adapter }); + +export const BOT_DATA_PATH = resolve(__dirname, "..", "..", "data", "bot-characters.json"); +export const AVATAR_DIR = resolve(__dirname, "..", "..", "public", "bot-avatars"); +export const LUNARIS_DIR = resolve(AVATAR_DIR, "lunaris"); + +export const LUNARIS_ENDPOINT = "https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image"; + +// trae-api 当前所有 prompt 都返回同一张 default.jpeg(实测 176626 bytes) +// 用这个 hash 前缀识别占位图,命中则改用 SVG 兜底 +export const LUNARIS_DEFAULT_HASH_PREFIX = "e330cd0232"; + +// ---------- 工具函数 ---------- + +/** + * 把 key 字符串做稳定 hash(32-bit FNV-1a 变体) + */ +export function hashKey(key) { + let h = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; +} + +/** + * 从 0..MAX 区间里按 key 取一个稳定随机数 + */ +export function pickFromKey(key, max) { + return hashKey(key) % max; +} + +/** + * 把 key 拆成 4 个 8-bit 数字(0-255)用于 SVG 几何定位 + */ +export function keyToBytes(key) { + const h1 = pickFromKey(key + "a", 256); + const h2 = pickFromKey(key + "b", 256); + const h3 = pickFromKey(key + "c", 256); + const h4 = pickFromKey(key + "d", 256); + return [h1, h2, h3, h4]; +} + +// 调色板:与 src/lib/bot-utils.ts 的 getBotColorScheme 保持视觉一致 +// 但用实际 hex(Tailwind 调色板)方便 SVG 渲染 +export const PALETTE = [ + { from: "#fb7185", to: "#fb923c", accent: "#fff1f2", name: "rose-orange" }, + { from: "#fbbf24", to: "#f43f5e", accent: "#fffbeb", name: "amber-rose" }, + { from: "#34d399", to: "#06b6d4", accent: "#ecfdf5", name: "emerald-cyan" }, + { from: "#38bdf8", to: "#6366f1", accent: "#f0f9ff", name: "sky-indigo" }, + { from: "#a78bfa", to: "#d946ef", accent: "#f5f3ff", name: "violet-fuchsia" }, + { from: "#e879f9", to: "#ec4899", accent: "#fdf4ff", name: "fuchsia-pink" }, + { from: "#2dd4bf", to: "#10b981", accent: "#f0fdfa", name: "teal-emerald" }, + { from: "#818cf8", to: "#a855f7", accent: "#eef2ff", name: "indigo-purple" }, + { from: "#fb923c", to: "#ef4444", accent: "#fff7ed", name: "orange-red" }, + { from: "#22d3ee", to: "#3b82f6", accent: "#ecfeff", name: "cyan-blue" }, +]; + +export function pickPalette(key) { + return PALETTE[pickFromKey(key, PALETTE.length)]; +} + +// ---------- Lunaris 调用 ---------- + +export async function fetchLunarisImage(avatarPrompt, { size = "square_hd", timeout = 30000 } = {}) { + const enhancedPrompt = `人物头像, ${avatarPrompt}, 高质量肖像照, 自然光, 浅色背景, 半身像, 微笑, 现代感`; + const url = `${LUNARIS_ENDPOINT}?prompt=${encodeURIComponent(enhancedPrompt)}&image_size=${size}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const r = await fetch(url, { + headers: { Accept: "image/*,*/*", "User-Agent": "Mozilla/5.0" }, + signal: controller.signal, + redirect: "follow", + }); + if (!r.ok) throw new Error(`Lunaris HTTP ${r.status}`); + const ct = r.headers.get("content-type") || ""; + if (!ct.includes("image")) throw new Error(`Lunaris 响应非图片: ${ct}`); + const buf = Buffer.from(await r.arrayBuffer()); + return { buffer: buf, contentType: ct, size: buf.byteLength, url: r.url }; + } finally { + clearTimeout(timer); + } +} + +export async function isLunarisDefault(buffer) { + const { createHash } = await import("crypto"); + const hash = createHash("sha256").update(buffer).digest("hex"); + return hash.startsWith(LUNARIS_DEFAULT_HASH_PREFIX); +} + +// ---------- SVG 程序化生成 ---------- + +/** + * 根据 bot persona 生成 512x512 SVG 头像 + * 设计要素: + * - 渐变背景(palette.from → palette.to) + * - 4 个装饰几何形(位置/大小/旋转由 key 决定) + * - 中央大字 initial(displayName 首字符) + * - 右下角 🤖 标识(用 SVG path 画,不依赖 emoji 字体) + */ +export function generateSvgAvatar({ key, displayName, persona }) { + const palette = pickPalette(key); + const [b1, b2, b3, b4] = keyToBytes(key); + const initial = (displayName || key).charAt(0).toUpperCase(); + const isPasserby = persona?.role === "passerby"; + + // 4 个装饰形:圆/三角/方/菱形,位置基于 key 哈希 + const shapes = [ + { type: "circle", cx: 40 + b1 * 0.7, cy: 60 + b2 * 0.4, r: 30 + (b3 % 40), opacity: 0.18 }, + { type: "rect", x: 320 + b2 * 0.3, y: 30 + b3 * 0.2, w: 60 + (b4 % 50), h: 60 + (b1 % 50), rot: b1 % 90, opacity: 0.14 }, + { type: "circle", cx: 380 + b3 * 0.2, cy: 320 + b4 * 0.4, r: 40 + (b2 % 60), opacity: 0.12 }, + { type: "polygon", points: `60,${380 + b1 * 0.2} ${120 + b2 * 0.2},${440 + b3 * 0.15} ${20 + b4 * 0.3},${460}`, opacity: 0.16 }, + ]; + + const shapeSvg = shapes + .map((s) => { + if (s.type === "circle") { + return ``; + } + if (s.type === "rect") { + return ``; + } + return ``; + }) + .join("\n "); + + // 角色副标识 + const roleBadge = isPasserby + ? ` + 社区观察者` + : ` + 行业专家`; + + return ` + + + + + + + + + + + + + + + + + ${shapeSvg} + + ${roleBadge} + + ${escapeXml(initial)} + + + + + + + + + + + + + + +`; +} + +function escapeXml(s) { + return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c])); +} + +// ---------- 主流程 ---------- + +/** + * 给单个 bot 生成头像 + * @returns {avatarUrl, source, key, bytes} + */ +export async function generateForBot(character, { force = false, useLunarisOnly = false, saveLunaris = true } = {}) { + if (!existsSync(AVATAR_DIR)) mkdirSync(AVATAR_DIR, { recursive: true }); + if (saveLunaris && !existsSync(LUNARIS_DIR)) mkdirSync(LUNARIS_DIR, { recursive: true }); + + const svgPath = resolve(AVATAR_DIR, `${character.key}.svg`); + const lunarisPath = resolve(LUNARIS_DIR, `${character.key}.jpg`); + const publicSvgUrl = `/bot-avatars/${character.key}.svg`; + + // 已有 SVG 且不强制 → 跳过 + if (!force && existsSync(svgPath)) { + return { key: character.key, source: "existing-svg", avatarUrl: publicSvgUrl, bytes: readFileSync(svgPath).byteLength }; + } + + // 步骤 1: 调 Lunaris(顺便存档 lunaris/{key}.jpg) + let lunarisBuf = null; + let lunarisIsDefault = true; + try { + lunarisBuf = await fetchLunarisImage(character.avatarPrompt || ""); + lunarisIsDefault = await isLunarisDefault(lunarisBuf.buffer); + if (saveLunaris) writeFileSync(lunarisPath, lunarisBuf.buffer); + } catch (err) { + console.warn(`[${character.key}] Lunaris 拉取失败: ${err.message}`); + } + + // 步骤 2: 决定最终使用 + if (useLunarisOnly && lunarisBuf && !lunarisIsDefault) { + // 未来 Lunaris 真支持按 prompt 生成时启用 + const jpgPath = resolve(AVATAR_DIR, `${character.key}.jpg`); + writeFileSync(jpgPath, lunarisBuf.buffer); + return { key: character.key, source: "lunaris", avatarUrl: `/bot-avatars/${character.key}.jpg`, bytes: lunarisBuf.buffer.byteLength }; + } + + // 步骤 3: 用 SVG 生成(覆盖 Lunaris 行为或作为兜底) + const svg = generateSvgAvatar({ + key: character.key, + displayName: character.displayName, + persona: character.personality || {}, + }); + writeFileSync(svgPath, svg, "utf-8"); + return { + key: character.key, + source: lunarisIsDefault ? "svg-fallback" : "svg-preferred", + avatarUrl: publicSvgUrl, + bytes: Buffer.byteLength(svg, "utf-8"), + lunarisIsDefault, + }; +} + +/** + * 给所有 bot 批量生成 + */ +export async function generateForAll({ force = false, botKey = null, useLunarisOnly = false, delayMs = 200 } = {}) { + const all = JSON.parse(readFileSync(BOT_DATA_PATH, "utf-8")).characters || []; + const targets = botKey ? all.filter((c) => c.key === botKey) : all; + if (botKey && targets.length === 0) throw new Error(`Bot "${botKey}" 不在 bot-characters.json 中`); + + const results = []; + for (const ch of targets) { + try { + const r = await generateForBot(ch, { force, useLunarisOnly }); + // 写库 + const user = await prisma.user.findFirst({ where: { email: ch.email } }); + if (user) { + await prisma.user.update({ where: { id: user.id }, data: { avatarUrl: r.avatarUrl } }); + r.userId = user.id; + r.dbUpdated = true; + } else { + r.dbUpdated = false; + r.warn = `User ${ch.email} 不存在`; + } + results.push(r); + console.log(`✅ ${ch.key} (${ch.displayName}) → ${r.avatarUrl} [${r.source}, ${r.bytes}B]`); + } catch (err) { + results.push({ key: ch.key, error: err.message }); + console.error(`❌ ${ch.key}: ${err.message}`); + } + if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); + } + + // 写 manifest + const manifest = { + generatedAt: new Date().toISOString(), + total: results.length, + succeeded: results.filter((r) => !r.error).length, + failed: results.filter((r) => r.error).length, + avatars: results.map((r) => ({ + key: r.key, + avatarUrl: r.avatarUrl, + source: r.source, + bytes: r.bytes, + })), + }; + writeFileSync(resolve(AVATAR_DIR, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8"); + + return { summary: { total: results.length, ok: manifest.succeeded, failed: manifest.failed }, results }; +} + +export async function disconnect() { + await prisma.$disconnect(); +} diff --git a/scripts/lib/bot-persona-experiment.mjs b/scripts/lib/bot-persona-experiment.mjs new file mode 100644 index 0000000..98eed44 --- /dev/null +++ b/scripts/lib/bot-persona-experiment.mjs @@ -0,0 +1,680 @@ +// Bot Persona A/B Testing 共享模块 +// 用法: +// import { ensureBotVariants, pickAndAssignVariant, recordAssignmentMetrics } from "./lib/bot-persona-experiment.mjs"; +// +// // 1) 给 bot 准备至少 2 个变体(首次自动创建 control + variant_a) +// await ensureBotVariants(botConfigId, botUserId, persona); +// +// // 2) 发帖/回复时分流,记录 assignment +// const { variant, assignment } = await pickAndAssignVariant(botConfigId, "topic", topicId, forumSlug); +// +// // 3) 指标采集(在 cron 里调用) +// await recordAssignmentMetrics(); + +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=3&pool_timeout=10`; + +let _prisma = null; +function getPrisma() { + if (!_prisma) { + const adapter = new PrismaMariaDb(connectionString); + _prisma = new PrismaClient({ adapter }); + } + return _prisma; +} + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function weightedPick(items, weightKey = "weight") { + if (!items || items.length === 0) return null; + const active = items.filter((i) => i.isActive); + const pool = active.length > 0 ? active : items; + const total = pool.reduce((s, x) => s + Math.max(0, x[weightKey] || 0), 0); + if (total <= 0) return pool[0]; + let r = Math.random() * total; + for (const it of pool) { + r -= Math.max(0, it[weightKey] || 0); + if (r <= 0) return it; + } + return pool[pool.length - 1]; +} + +// ======== 变体生成 ======== +// 根据现有 persona 派生 2 个变体 +// - control: 保留原始行为(无 styleHints) +// - variant_a: "口语化 + 故事化" 风格 +// - variant_b: "数据驱动 + 简洁" 风格 +// - variant_c(可选): "反主流观点 + 提问引导" 风格 +const VARIANT_PRESETS = [ + { + variantKey: "control", + label: "对照组(原版画像)", + description: "保留原始 persona 行为,作为 A/B 测试的基准", + isControl: true, + weight: 0.4, + styleHints: {}, + }, + { + variantKey: "variant_a", + label: "A · 口语化+故事化", + description: "更接地气、加入小故事、问号更多,更像真人聊天", + isControl: false, + weight: 0.3, + styleHints: { + tone: "casual", + questionBoost: 0.2, + storyBoost: 0.3, + maxLength: 400, + forbid: ["综上所述", "从以下几个方面", "首先其次最后", "不可忽视"], + }, + }, + { + variantKey: "variant_b", + label: "B · 数据驱动+简洁", + description: "多用数据/数字,结构化表达,字数更短", + isControl: false, + weight: 0.3, + styleHints: { + tone: "data_driven", + questionBoost: -0.2, + dataBoost: 0.4, + maxLength: 280, + requireData: true, + forbid: ["综上所述", "从以下几个方面", "首先其次最后"], + }, + }, +]; + +/** + * 给一个 bot 创建默认变体(如果还没有) + * - 读现有 BotPersona + * - 给 stanceKeywords、topTopicTypes 做变体化处理 + * - 全部 upsert 到 bot_persona_variants 表 + * @returns { variants: [...], created: number } + */ +export async function ensureBotVariants(botConfigId, botUserId) { + const prisma = getPrisma(); + const existing = await prisma.botPersonaVariant.findMany({ + where: { botId: botConfigId }, + }); + if (existing.length > 0) { + return { variants: existing, created: 0 }; + } + + const persona = await prisma.botPersona.findUnique({ where: { botId: botConfigId } }); + const baseStance = (persona?.stanceKeywords || []).map((k) => k.key).slice(0, 12); + const baseTopicTypes = (persona?.topTopicTypes || []).map((t) => t.name).slice(0, 5); + + const created = []; + for (const preset of VARIANT_PRESETS) { + // 不同变体微调关键词:control 全保留;A 加上情绪/故事关键词;B 加上数据关键词 + const keywords = [...baseStance]; + if (preset.variantKey === "variant_a") { + keywords.push("经历", "故事", "身边", "我", "朋友", "当时", "后来"); + } else if (preset.variantKey === "variant_b") { + keywords.push("数据", "比例", "增长", "对比", "案例", "%", "GMV"); + } + + const v = await prisma.botPersonaVariant.create({ + data: { + botId: botConfigId, + variantKey: preset.variantKey, + label: preset.label, + description: preset.description, + isControl: preset.isControl, + isActive: true, + weight: preset.weight, + stanceKeywords: Array.from(new Set(keywords)).slice(0, 15), + topTopicTypes: baseTopicTypes, + topHumanUsers: null, + styleHints: preset.styleHints, + }, + }); + created.push(v); + } + + return { variants: created, created: created.length }; +} + +/** + * 为某个 bot 启动一个 A/B 实验(默认包含 control + variant_a + variant_b) + * - 多次调用安全:若已存在 status=active 的实验,复用 + * - 若已有变体但未挂到实验,挂到该实验 + */ +export async function ensureExperiment(botConfigId) { + const prisma = getPrisma(); + const existing = await prisma.botPersonaExperiment.findFirst({ + where: { botId: botConfigId, status: "active" }, + }); + if (existing) return existing; + + const exp = await prisma.botPersonaExperiment.create({ + data: { + botId: botConfigId, + name: "persona A/B", + description: "自动为 bot 启动的 persona A/B 测试:control vs variant_a(口语+故事) vs variant_b(数据+简洁)", + status: "active", + minSamplesPerArm: 20, + significanceLevel: 0.1, + primaryMetric: "engagement_score", + startedAt: new Date(), + }, + }); + + // 把该 bot 已有变体挂到实验 + await prisma.botPersonaVariant.updateMany({ + where: { botId: botConfigId, experimentId: null }, + data: { experimentId: exp.id }, + }); + + return exp; +} + +/** + * 给某条内容(topic / post)挑选一个变体并记录 assignment + * @param botConfigId + * @param refType "topic" | "post" + * @param refId topicId / postId(必须 > 0;先选变体再创建内容时使用 pickVariantForPrompt) + * @param forumSlug 可选 + * @returns { variant, assignment, personaBlock } + */ +export async function pickAndAssignVariant(botConfigId, refType, refId, forumSlug = null) { + const prisma = getPrisma(); + + // 1) 确保变体存在 + const { variants } = await ensureBotVariants(botConfigId); + if (variants.length === 0) { + return { variant: null, assignment: null, personaBlock: "" }; + } + + // 2) 选变体(按 weight) + const variant = weightedPick(variants, "weight"); + if (!variant) { + return { variant: null, assignment: null, personaBlock: "" }; + } + + // 3) 写 assignment(unique key 保护) + const assignment = await prisma.botPersonaAssignment.upsert({ + where: { refType_refId: { refType, refId } }, + create: { + botId: botConfigId, + variantId: variant.id, + refType, + refId, + forumSlug, + }, + update: { + // 同一 ref 已存在,不覆盖 variant(保持首次分流) + botId: botConfigId, + }, + }); + + // 4) 变体 sample + 1 + await prisma.botPersonaVariant.update({ + where: { id: variant.id }, + data: { sampleCount: { increment: 1 } }, + }); + + return { + variant, + assignment, + personaBlock: buildVariantPersonaBlock(variant), + }; +} + +/** + * 轻量版:只选变体 + 返回 personaBlock,**不写库** + * 适用于"先生成内容再创建记录的顺序"场景(如先调用 LLM 再 createTopic) + * 配合 commitAssignment(botConfigId, refType, refId, variantId) 在内容创建后落盘 + */ +export async function pickVariantForPrompt(botConfigId) { + const prisma = getPrisma(); + const { variants } = await ensureBotVariants(botConfigId); + if (variants.length === 0) { + return { variant: null, personaBlock: "" }; + } + const variant = weightedPick(variants, "weight"); + if (!variant) { + return { variant: null, personaBlock: "" }; + } + return { + variant, + personaBlock: buildVariantPersonaBlock(variant), + }; +} + +/** + * 提交 assignment(轻量版的配套写入) + * - 幂等:同一 (refType, refId) 多次调用不会变 variant + * - 会自增变体 sampleCount + */ +export async function commitAssignment(botConfigId, variantId, refType, refId, forumSlug = null) { + if (!variantId || !refId) return null; + const prisma = getPrisma(); + const assignment = await prisma.botPersonaAssignment.upsert({ + where: { refType_refId: { refType, refId } }, + create: { + botId: botConfigId, + variantId, + refType, + refId, + forumSlug, + }, + update: { botId: botConfigId }, + }); + await prisma.botPersonaVariant.update({ + where: { id: variantId }, + data: { sampleCount: { increment: 1 } }, + }); + return assignment; +} + +// 把变体的 styleHints 格式化成可注入 prompt 的 block +export function buildVariantPersonaBlock(variant) { + if (!variant) return ""; + const hints = variant.styleHints || {}; + const parts = []; + + parts.push(`[A/B 变体 ${variant.variantKey} · ${variant.label}]`); + + if (hints.tone) { + const toneMap = { + casual: "语气:口语化、接地气", + data_driven: "语气:数据驱动、引用具体数字", + contrarian: "语气:反主流观点、犀利但有理", + }; + parts.push(toneMap[hints.tone] || `语气:${hints.tone}`); + } + if (typeof hints.maxLength === "number") { + parts.push(`字数上限:约 ${hints.maxLength} 字`); + } + if (hints.questionBoost) { + parts.push(`问号倾向:${hints.questionBoost > 0 ? "略多问号" : "少用问句"}`); + } + if (hints.dataBoost) parts.push("多用数据/数字"); + if (hints.storyBoost) parts.push("多讲小故事/经历"); + if (Array.isArray(hints.forbid) && hints.forbid.length > 0) { + parts.push(`避免:${hints.forbid.join("、")}`); + } + if (variant.stanceKeywords && variant.stanceKeywords.length > 0) { + parts.push(`关注关键词:${variant.stanceKeywords.slice(0, 8).join("、")}`); + } + + return `\n${parts.join("\n")}\n`; +} + +// ======== 指标采集 ======== +/** + * 把每条 assignment 的实际互动(replyCount/likeCount/humanReplies)回填, + * 并按 (variantId, date) 写入 bot_persona_metrics 聚合 + * - 默认只看过去 7 天的 assignment(性能保护) + */ +export async function recordAssignmentMetrics(options = {}) { + const prisma = getPrisma(); + const lookbackDays = options.lookbackDays ?? 7; + const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000); + + // 1) 拉所有变体(活跃实验) + const variants = await prisma.botPersonaVariant.findMany({ + where: { isActive: true }, + select: { id: true, botId: true }, + }); + if (variants.length === 0) return { updated: 0 }; + + const variantIds = variants.map((v) => v.id); + const assignments = await prisma.botPersonaAssignment.findMany({ + where: { + variantId: { in: variantIds }, + createdAt: { gte: since }, + }, + }); + + if (assignments.length === 0) return { updated: 0 }; + + // 2) 按 refType 拆开批量查真实互动数 + const topicIds = assignments.filter((a) => a.refType === "topic").map((a) => a.refId); + const postIds = assignments.filter((a) => a.refType === "post").map((a) => a.refId); + + // topic: 真实 replyCount / likeCount / 真人 reply 数 + const topicStats = await prisma.forumTopic.findMany({ + where: { id: { in: topicIds.length > 0 ? topicIds : [-1] } }, + select: { + id: true, + replyCount: true, + likeCount: true, + viewCount: true, + posts: { + where: { user: { isBot: false } }, + select: { id: true }, + }, + }, + }); + const topicMap = new Map( + topicStats.map((t) => [ + t.id, + { + replies: t.replyCount || 0, + likes: t.likeCount || 0, + impressions: t.viewCount || 0, + humanReplies: t.posts.length, + }, + ]) + ); + + // post: 真实 likeCount + const postStats = await prisma.forumPost.findMany({ + where: { id: { in: postIds.length > 0 ? postIds : [-1] } }, + select: { id: true, likeCount: true, topic: { select: { id: true } } }, + }); + const postMap = new Map(postStats.map((p) => [p.id, p.likeCount || 0])); + + // 对 post 型 assignment,人工统计"该 post 之后同 topic 的新回复":用 topicId+postCreatedAt 之后的人类回复数 + // 简化:取 topic 的总 replyCount / totalPosts 数 - 该 post 之前的 - 1 = 该 post 之后的新回复 + // 为简化与一致性:post 的 replies/humanReplies 用同 topic 下的总数估算,impressions=0 + // (post 的真实互动其实主要看 likeCount) + + // 3) 写回 assignment + let updated = 0; + for (const a of assignments) { + let replies = 0; + let likes = 0; + let impressions = 0; + let humanReplies = 0; + if (a.refType === "topic" && topicMap.has(a.refId)) { + const t = topicMap.get(a.refId); + replies = t.replies; + likes = t.likes; + impressions = t.impressions; + humanReplies = t.humanReplies; + } else if (a.refType === "post") { + likes = postMap.get(a.refId) || 0; + // post 的回复/曝光粗略处理:0 + replies = 0; + humanReplies = 0; + impressions = 0; + } + if ( + replies !== a.replies || + likes !== a.likes || + humanReplies !== a.humanReplies || + impressions !== a.impressions + ) { + await prisma.botPersonaAssignment.update({ + where: { id: a.id }, + data: { replies, likes, humanReplies, impressions }, + }); + updated++; + } + } + + // 4) 写入按 (variantId, date) 聚合的 metrics + const dayBuckets = new Map(); // key: `${variantId}|${yyyy-mm-dd}` -> { variantId, date, contentCount, replies, likes, humanReplies, impressions } + for (const a of assignments) { + const dateStr = a.createdAt.toISOString().slice(0, 10); + const key = `${a.variantId}|${dateStr}`; + if (!dayBuckets.has(key)) { + dayBuckets.set(key, { + variantId: a.variantId, + date: new Date(`${dateStr}T00:00:00.000Z`), + contentCount: 0, + replies: 0, + likes: 0, + humanReplies: 0, + impressions: 0, + }); + } + const b = dayBuckets.get(key); + b.contentCount += 1; + b.replies += a.replies; + b.likes += a.likes; + b.humanReplies += a.humanReplies; + b.impressions += a.impressions; + } + + for (const b of dayBuckets.values()) { + const avgReplies = b.contentCount > 0 ? b.replies / b.contentCount : 0; + const avgLikes = b.contentCount > 0 ? b.likes / b.contentCount : 0; + // 综合得分:平均点赞×3 + 真人回复×5 + 平均回复×1 + const computedScore = avgLikes * 3 + b.humanReplies / Math.max(1, b.contentCount) * 5 + avgReplies * 1; + await prisma.botPersonaMetric.upsert({ + where: { variantId_date: { variantId: b.variantId, date: b.date } }, + create: { + variantId: b.variantId, + date: b.date, + impressions: b.impressions, + replies: b.replies, + likes: b.likes, + humanReplies: b.humanReplies, + contentCount: b.contentCount, + avgReplies, + avgLikes, + computedScore, + }, + update: { + impressions: b.impressions, + replies: b.replies, + likes: b.likes, + humanReplies: b.humanReplies, + contentCount: b.contentCount, + avgReplies, + avgLikes, + computedScore, + }, + }); + } + + // 5) 回写变体的累计统计(最近 7 天 assignment 滚动聚合) + const variantAgg = new Map(); + for (const a of assignments) { + if (!variantAgg.has(a.variantId)) { + variantAgg.set(a.variantId, { sampleCount: 0, replyCount: 0, likeCount: 0, humanReplyCount: 0 }); + } + const v = variantAgg.get(a.variantId); + v.sampleCount += 1; + v.replyCount += a.replies; + v.likeCount += a.likes; + v.humanReplyCount += a.humanReplies; + } + for (const [variantId, agg] of variantAgg.entries()) { + // 综合分:平均每篇互动 = (收赞 + 收真人回复 + 收回复) / 样本 + const score = agg.sampleCount > 0 + ? (agg.likeCount * 3 + agg.humanReplyCount * 5 + agg.replyCount) / agg.sampleCount + : 0; + await prisma.botPersonaVariant.update({ + where: { id: variantId }, + data: { + sampleCount: agg.sampleCount, + replyCount: agg.replyCount, + likeCount: agg.likeCount, + humanReplyCount: agg.humanReplyCount, + engagementScore: Math.round(score * 100) / 100, + }, + }); + } + + return { updated, assignments: assignments.length, dayBuckets: dayBuckets.size }; +} + +// ======== 分析 / 变体切换 ======== +/** + * 对单个 bot 的所有变体做显著性分析 + * - 输入:变体列表(含 sampleCount/replyCount/likeCount/humanReplyCount/engagementScore) + * - 输出:winnerVariantKey / analysis 详情 + * 策略:双比例 z 检验(高斯近似),比较每个 variant vs control 的互动率((replies+likes+humanReplies)/sampleCount) + */ +export function analyzeBotVariants(variants) { + if (!variants || variants.length === 0) { + return { winner: null, reason: "no_variants", comparisons: [] }; + } + const control = variants.find((v) => v.isControl) || variants[0]; + const others = variants.filter((v) => v.id !== control.id); + + if (control.sampleCount < 1) { + return { winner: null, reason: "control_no_samples", comparisons: [] }; + } + + const controlRate = (control.likeCount * 3 + control.humanReplyCount * 5 + control.replyCount) / control.sampleCount; + const controlN = control.sampleCount; + + const comparisons = others.map((v) => { + if (v.sampleCount < 1) { + return { variant: v, rate: 0, pValue: null, significant: false, winner: false }; + } + const rate = (v.likeCount * 3 + v.humanReplyCount * 5 + v.replyCount) / v.sampleCount; + const n = v.sampleCount; + // pooled proportion + const pooled = (control.likeCount * 3 + control.humanReplyCount * 5 + control.replyCount + v.likeCount * 3 + v.humanReplyCount * 5 + v.replyCount) / (controlN + n); + const se = Math.sqrt(pooled * (1 - pooled) * (1 / controlN + 1 / n)); + let z = 0; + let p = 1; + if (se > 0) { + z = (rate - controlRate) / se; + // 双侧检验 + p = 2 * (1 - normalCdf(Math.abs(z))); + } + return { + variant: v, + rate: Math.round(rate * 1000) / 1000, + controlRate: Math.round(controlRate * 1000) / 1000, + zScore: Math.round(z * 100) / 100, + pValue: Math.round(p * 1000) / 1000, + significant: p < 0.1 && n >= 20, // significanceLevel=0.1, minSamples=20 + winner: false, + }; + }); + + // 选 winner:所有"显著更优"的里面 p 最小 + sample 最多的 + const winners = comparisons.filter((c) => c.significant && c.rate > c.controlRate); + let winner = null; + if (winners.length > 0) { + winners.sort((a, b) => a.pValue - b.pValue); + winners[0].winner = true; + winner = winners[0].variant; + } + + return { + winner, + winnerReason: winner + ? `variant ${winner.variantKey} 综合互动率显著高于 control (p=${winners[0].pValue})` + : "no significant winner yet", + control, + comparisons, + }; +} + +// 标准正态分布 CDF(Abramowitz & Stegun 近似) +function normalCdf(x) { + const t = 1 / (1 + 0.2316419 * x); + const d = 0.3989422804014327 * Math.exp(-x * x / 2); + let p = d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))); + return 1 - p; +} + +/** + * 完整跑一遍分析 + 切换 + * - 遍历所有 bot + * - 对每个 bot 调 analyzeBotVariants + * - 若有 winner 显著更优,把实验标记为 completed + * - 后续 bot 在选变体时仍会按 weight 选(不会自动锁死一个变体),但 recordAssignmentMetrics 会持续跑 + * - 还可以加:winner 出现后,把 winner 权重提到 1.0,其他降到 0 + */ +export async function analyzeAndSwitchAllBots(options = {}) { + const prisma = getPrisma(); + const onlyActive = options.onlyActive ?? true; + + // 取所有有变体的 bot + const variants = await prisma.botPersonaVariant.findMany({ + where: onlyActive ? { isActive: true } : undefined, + include: { experiment: true }, + }); + const byBot = new Map(); + for (const v of variants) { + if (!byBot.has(v.botId)) byBot.set(v.botId, []); + byBot.get(v.botId).push(v); + } + + const results = []; + for (const [botId, list] of byBot.entries()) { + const analysis = analyzeBotVariants(list); + if (analysis.winner) { + const exp = list.find((v) => v.experimentId)?.experiment; + if (exp && exp.status === "active") { + // 标记实验完成 + await prisma.botPersonaExperiment.update({ + where: { id: exp.id }, + data: { + status: "completed", + winnerVariantKey: analysis.winner.variantKey, + endedAt: new Date(), + lastAnalyzedAt: new Date(), + }, + }); + // 提升 winner 权重到 0.85,其他活跃变体降到 0.075 + await prisma.botPersonaVariant.update({ + where: { id: analysis.winner.id }, + data: { weight: 0.85 }, + }); + await prisma.botPersonaVariant.updateMany({ + where: { + botId, + id: { not: analysis.winner.id }, + experimentId: exp.id, + }, + data: { weight: 0.075 }, + }); + results.push({ + botId, + experimentId: exp.id, + winner: analysis.winner.variantKey, + reason: analysis.winnerReason, + action: "switched_weights", + }); + } else { + results.push({ + botId, + winner: analysis.winner.variantKey, + reason: analysis.winnerReason, + action: "no_active_experiment", + }); + } + } else { + // 没有 winner,刷新 lastAnalyzedAt + const exp = list.find((v) => v.experimentId)?.experiment; + if (exp && exp.status === "active") { + await prisma.botPersonaExperiment.update({ + where: { id: exp.id }, + data: { lastAnalyzedAt: new Date() }, + }); + } + results.push({ + botId, + winner: null, + reason: analysis.winnerReason, + action: "monitoring", + }); + } + } + + return { botCount: byBot.size, results }; +} + +/** + * 优雅关闭 + */ +export async function disconnectBotExperiment() { + if (_prisma) { + await _prisma.$disconnect(); + _prisma = null; + } +} diff --git a/scripts/lib/bot-persona.mjs b/scripts/lib/bot-persona.mjs new file mode 100644 index 0000000..a8b0ba6 --- /dev/null +++ b/scripts/lib/bot-persona.mjs @@ -0,0 +1,276 @@ +// Bot 画像 (BotPersona) 共享模块 +// 从 bot-affinity-update.mjs 抽出,bot-activity.mjs 也能复用 +// 用法: +// import { updatePersona, schedulePersonaRefresh } from "./lib/bot-persona.mjs"; +// await updatePersona(botUser, botConfig.id); +// schedulePersonaRefresh(botUserId, botConfig.id); // 异步、防抖 + +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=2&pool_timeout=10`; + +// 共享一个 Prisma 客户端(连接池小,只为后台异步刷新服务) +let _prisma = null; +function getPrisma() { + if (!_prisma) { + const adapter = new PrismaMariaDb(connectionString); + _prisma = new PrismaClient({ adapter }); + } + return _prisma; +} + +const PERSONA_LOOKBACK_DAYS = 30; +const TOP_KEYWORDS = 12; +const TOP_HUMAN_USERS = 5; +const TOP_TOPIC_TYPES = 5; +const LAST_TITLES_COUNT = 5; + +const STOP_WORDS = new Set([ + "的", "了", "是", "在", "和", "与", "或", "也", "都", "就", "不", "没", "要", "我", "你", "他", "她", "它", "们", + "这", "那", "有", "为", "到", "对", "及", "等", "把", "被", "从", "向", "以", "其", "之", "于", "上", "下", + "里", "外", "中", "一个", "一些", "我们", "你们", "他们", "什么", "怎么", "为什么", "啊", "吗", "呢", "吧", + "嗯", "哦", "哈", "啦", "嘿", "唉", "这个", "那个", "一个", "真的", "应该", "可能", "觉得", "认为", +]); + +function tokenize(text) { + if (!text) return []; + return text + .replace(/[,。!?、;:""''【】《》()()\[\]·—\.\,\!\?\;\:\'\"\\\/]/g, " ") + .split(/\s+/) + .filter((w) => w.length >= 2 && !STOP_WORDS.has(w)); +} + +function pickTopWithCount(arr, topN) { + const counts = new Map(); + for (const item of arr) { + if (!item) continue; + counts.set(item, (counts.get(item) || 0) + 1); + } + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, topN) + .map(([k, v]) => ({ key: k, count: v })); +} + +function ts() { + return `[${new Date().toISOString()}]`; +} + +/** + * 拉取过去 N 天该 bot 的所有发帖/回复,统计画像数据 + * @returns persona object (or empty persona when no posts) + */ +export async function computePersona(botUser, botConfigId) { + const prisma = getPrisma(); + const since = new Date(Date.now() - PERSONA_LOOKBACK_DAYS * 24 * 60 * 60 * 1000); + const topics = await prisma.forumTopic.findMany({ + where: { userId: botUser.id, createdAt: { gt: since } }, + include: { + category: { select: { name: true, slug: true } }, + posts: { + include: { user: { select: { id: true, name: true, isBot: true } } }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + if (topics.length === 0) { + return { + avgReplyLength: 0, + questionRatio: 0, + exclamationCount: 0, + avgTopicLength: 0, + stanceKeywords: [], + topHumanUsers: [], + topTopicTypes: [], + lastTopicTitles: [], + postSampleCount: 0, + }; + } + + const allPosts = []; + for (const t of topics) { + allPosts.push({ + content: t.content, + isTopic: true, + createdAt: t.createdAt, + forumSlug: t.category.slug, + authorId: t.userId, + }); + for (const p of t.posts) { + allPosts.push({ + content: p.content, + isTopic: false, + createdAt: p.createdAt, + forumSlug: t.category.slug, + authorId: p.userId, + humanAuthor: !p.user.isBot ? p.user : null, + }); + } + } + + const myPosts = allPosts.filter((p) => p.authorId === botUser.id); + const replyLengths = myPosts.filter((p) => !p.isTopic).map((p) => p.content.length); + const topicLengths = myPosts.filter((p) => p.isTopic).map((p) => p.content.length); + + const fullText = myPosts.map((p) => p.content).join(" "); + const questionMarks = (fullText.match(/[??]/g) || []).length; + const exclamationMarks = (fullText.match(/[!!]/g) || []).length; + const questionRatio = myPosts.length > 0 ? questionMarks / myPosts.length : 0; + + const keywords = tokenize(fullText); + const stanceKeywords = pickTopWithCount(keywords, TOP_KEYWORDS); + + const humanRepliers = allPosts + .filter((p) => p.humanAuthor) + .map((p) => ({ id: p.humanAuthor.id, name: p.humanAuthor.name || "匿名" })); + const userCount = new Map(); + for (const h of humanRepliers) { + const k = `${h.id}|${h.name}`; + userCount.set(k, (userCount.get(k) || 0) + 1); + } + const topHumanUsers = Array.from(userCount.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_HUMAN_USERS) + .map(([k, c]) => { + const [id, name] = k.split("|"); + return { id: Number(id), name, count: c }; + }); + + const forumCount = new Map(); + for (const t of topics) { + const k = `${t.category.slug}|${t.category.name}`; + forumCount.set(k, (forumCount.get(k) || 0) + 1); + } + const topTopicTypes = Array.from(forumCount.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_TOPIC_TYPES) + .map(([k, c]) => { + const [slug, name] = k.split("|"); + return { slug, name, count: c }; + }); + + const lastTopicTitles = topics + .slice(0, LAST_TITLES_COUNT) + .map((t) => t.title); + + return { + avgReplyLength: replyLengths.length + ? Math.round(replyLengths.reduce((s, n) => s + n, 0) / replyLengths.length) + : 0, + questionRatio: Math.round(questionRatio * 100) / 100, + exclamationCount: exclamationMarks, + avgTopicLength: topicLengths.length + ? Math.round(topicLengths.reduce((s, n) => s + n, 0) / topicLengths.length) + : 0, + stanceKeywords, + topHumanUsers, + topTopicTypes, + lastTopicTitles, + postSampleCount: myPosts.length, + }; +} + +/** + * 计算并写回 BotPersona(同步版本,返回是否成功) + * @param botUser - bot User 实体(含 id) + * @param botConfigId - BotConfig.id + * @returns { success: boolean, postSampleCount: number } + */ +export async function updatePersona(botUser, botConfigId) { + const prisma = getPrisma(); + const persona = await computePersona(botUser, botConfigId); + if (persona.postSampleCount === 0) { + return { success: false, postSampleCount: 0 }; + } + await prisma.botPersona.upsert({ + where: { botId: botConfigId }, + create: { botId: botConfigId, ...persona }, + update: persona, + }); + return { success: true, postSampleCount: persona.postSampleCount }; +} + +// ============ 异步防抖调度 ============ +// 每次发帖/回复后调用 schedulePersonaRefresh(botUserId, botConfigId): +// - 同一 bot 在 COOLDOWN_MS 内多次触发,只跑一次(防抖) +// - 失败时静默记录,不影响主流程 +const COOLDOWN_MS = 5 * 60 * 1000; // 5 分钟防抖 +const lastRunMap = new Map(); // botConfigId -> timestamp(ms) +const inFlightSet = new Set(); // 正在跑 refresh 的 botConfigId + +/** + * 异步 + 防抖触发 persona 刷新 + * - 5 分钟内同一 bot 只跑一次 + * - 不阻塞调用方,立即返回 + * - 失败时 console.warn,不抛出 + * @returns true 表示本次触发了真实刷新,false 表示被防抖跳过 + */ +export function schedulePersonaRefresh(botUserId, botConfigId, options = {}) { + const cooldown = options.cooldownMs ?? COOLDOWN_MS; + const now = Date.now(); + const last = lastRunMap.get(botConfigId) || 0; + if (now - last < cooldown) { + return false; + } + if (inFlightSet.has(botConfigId)) { + return false; + } + inFlightSet.add(botConfigId); + lastRunMap.set(botConfigId, now); + + // 推迟到下一个 tick,避免阻塞发帖主流程 + setImmediate(async () => { + try { + const prisma = getPrisma(); + const botUser = await prisma.user.findUnique({ where: { id: botUserId } }); + if (!botUser) return; + const result = await updatePersona(botUser, botConfigId); + if (result.success) { + console.log( + `${ts()} 🪞 persona 异步刷新: bot#${botConfigId} (sample=${result.postSampleCount})` + ); + } + } catch (err) { + console.warn( + `${ts()} ⚠️ persona 异步刷新失败 bot#${botConfigId}:`, + err.message + ); + } finally { + inFlightSet.delete(botConfigId); + } + }); + return true; +} + +/** + * 强制立即刷新(绕开防抖),用于定时任务兜底 + */ +export async function forcePersonaRefresh(botUserId, botConfigId) { + const prisma = getPrisma(); + const botUser = await prisma.user.findUnique({ where: { id: botUserId } }); + if (!botUser) return { success: false, postSampleCount: 0 }; + return updatePersona(botUser, botConfigId); +} + +/** + * 清空防抖状态(测试 / 主流程退出时用) + */ +export function resetPersonaDebounce() { + lastRunMap.clear(); + inFlightSet.clear(); +} + +/** + * 优雅关闭 prisma 客户端(主进程退出前调用) + */ +export async function disconnectBotPersona() { + if (_prisma) { + await _prisma.$disconnect(); + _prisma = null; + } +} diff --git a/scripts/parse-rsc.py b/scripts/parse-rsc.py new file mode 100644 index 0000000..431b4f0 --- /dev/null +++ b/scripts/parse-rsc.py @@ -0,0 +1,24 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 找self.__next_f.push的script块 +push_pattern = re.compile(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', re.DOTALL) +chunks = push_pattern.findall(html) +print(f'RSC push 块数: {len(chunks)}') + +# 解码第一块 +for i, chunk in enumerate(chunks): + # 反转义 + decoded = chunk.encode().decode('unicode_escape') + # 找"电商零售"在不在 + if '电商零售' in decoded or '平台电商' in decoded: + print(f'\n=== Chunk {i} (长{len(decoded)}) 包含板块数据 ===') + # 找电商零售位置 + idx = decoded.find('电商零售') + print(f'电商零售位置: {idx}') + print(f'周围200字符: {decoded[max(0,idx-50):idx+400]}') + if i < 3: + break diff --git a/scripts/parse-rsc2.py b/scripts/parse-rsc2.py new file mode 100644 index 0000000..b69e0a0 --- /dev/null +++ b/scripts/parse-rsc2.py @@ -0,0 +1,30 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 找所有 script 标签里的self.__next_f.push +scripts = re.findall(r']*>(self\.__next_f\.push.*?)', html, re.DOTALL) +print(f'RSC push script 块数: {len(scripts)}') + +for i, s in enumerate(scripts): + # 第一个字符切片, 找$12声明 + if '"$12"' in s or '"$L12"' in s or '$12' in s: + # 找"$12":开头的声明 + m = re.search(r'"\$?L?12":\s*"([^"]{0,500})', s) + if m: + print(f'\n=== Script {i} 含 $12 引用 (前500字符) ===') + print(m.group(1)[:500]) + # 也找"children":"$12" + m2 = re.search(r'__html":\s*"(\$12|\$L12)"', s) + if m2: + print(f' Script {i} 在 __html 用 $12') + +# 找所有包含"平台电商"的script +for i, s in enumerate(scripts): + if '平台电商' in s or '电商零售' in s: + idx = s.find('平台电商') + if idx == -1: idx = s.find('电商零售') + print(f'\n=== Script {i} 含"电商零售/平台电商" 位置{idx} ===') + print(s[max(0,idx-30):idx+300]) diff --git a/scripts/reconcile-like-counts.mjs b/scripts/reconcile-like-counts.mjs new file mode 100644 index 0000000..5234eed --- /dev/null +++ b/scripts/reconcile-like-counts.mjs @@ -0,0 +1,163 @@ +// 对齐 forumTopic.likeCount / forumPost.likeCount 与真实 Like 表的计数 +// 用法: +// node scripts/reconcile-like-counts.mjs # 仅巡检 +// node scripts/reconcile-like-counts.mjs --fix # 巡检 + 自动修正 +// node scripts/reconcile-like-counts.mjs --scope=topic|post|both +// node scripts/reconcile-like-counts.mjs --bot-only # 只检查 bot 的话题/回复 +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=15`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const args = process.argv.slice(2); +const fixMode = args.includes("--fix"); +const botOnly = args.includes("--bot-only"); +const scopeArg = args.find((a) => a.startsWith("--scope=")); +const scope = scopeArg ? scopeArg.split("=")[1] : "both"; +const limitArg = args.find((a) => a.startsWith("--limit=")); +const limit = limitArg ? parseInt(limitArg.split("=")[1], 10) : 5000; + +function ts() { + return `[${new Date().toISOString()}]`; +} + +async function reconcileTopics() { + console.log(`\n${ts()} 巡检 forumTopic.likeCount ...`); + const where = {}; + if (botOnly) where.user = { isBot: true }; + const all = await prisma.forumTopic.findMany({ + where, + select: { id: true, likeCount: true, userId: true }, + take: limit, + }); + console.log(` 范围: ${all.length} 个 topic${botOnly ? " (bot 限定)" : ""}`); + + // 批量统计真实点赞数(按 topicId groupBy) + const topicIds = all.map((t) => t.id); + if (topicIds.length === 0) return { drift: 0, fixed: 0 }; + + const realCounts = await prisma.forumTopicLike.groupBy({ + by: ["topicId"], + where: { topicId: { in: topicIds } }, + _count: { _all: true }, + }); + const realMap = new Map(realCounts.map((r) => [r.topicId, r._count._all])); + + let drift = 0; + let fixed = 0; + const samples = []; + for (const t of all) { + const real = realMap.get(t.id) || 0; + if (real !== t.likeCount) { + drift++; + if (samples.length < 8) { + samples.push({ id: t.id, stored: t.likeCount, real }); + } + if (fixMode) { + await prisma.forumTopic.update({ + where: { id: t.id }, + data: { likeCount: real }, + }); + fixed++; + } + } + } + console.log(` 偏差: ${drift}${fixMode ? `,已修正 ${fixed}` : ""}`); + if (samples.length > 0) { + console.log(` 示例(最多 8 条):`); + for (const s of samples) { + console.log(` topic ${s.id}: stored=${s.stored} 实际=${s.real}`); + } + } + return { drift, fixed }; +} + +async function reconcilePosts() { + console.log(`\n${ts()} 巡检 forumPost.likeCount ...`); + const where = {}; + if (botOnly) where.user = { isBot: true }; + const all = await prisma.forumPost.findMany({ + where, + select: { id: true, likeCount: true, userId: true }, + take: limit, + }); + console.log(` 范围: ${all.length} 个 post${botOnly ? " (bot 限定)" : ""}`); + + const postIds = all.map((p) => p.id); + if (postIds.length === 0) return { drift: 0, fixed: 0 }; + + const realCounts = await prisma.forumPostLike.groupBy({ + by: ["postId"], + where: { postId: { in: postIds } }, + _count: { _all: true }, + }); + const realMap = new Map(realCounts.map((r) => [r.postId, r._count._all])); + + let drift = 0; + let fixed = 0; + const samples = []; + for (const p of all) { + const real = realMap.get(p.id) || 0; + if (real !== p.likeCount) { + drift++; + if (samples.length < 8) { + samples.push({ id: p.id, stored: p.likeCount, real }); + } + if (fixMode) { + await prisma.forumPost.update({ + where: { id: p.id }, + data: { likeCount: real }, + }); + fixed++; + } + } + } + console.log(` 偏差: ${drift}${fixMode ? `,已修正 ${fixed}` : ""}`); + if (samples.length > 0) { + console.log(` 示例(最多 8 条):`); + for (const s of samples) { + console.log(` post ${s.id}: stored=${s.stored} 实际=${s.real}`); + } + } + return { drift, fixed }; +} + +async function main() { + console.log(`========== 点赞数对齐巡检 ==========`); + console.log(`模式: ${fixMode ? "FIX(自动修正)" : "CHECK(仅巡检)"}`); + console.log(`范围: ${scope}${botOnly ? " (仅 bot)" : ""}`); + console.log(`扫描上限: ${limit}`); + + const summary = { topic: { drift: 0, fixed: 0 }, post: { drift: 0, fixed: 0 } }; + if (scope === "topic" || scope === "both") { + summary.topic = await reconcileTopics(); + } + if (scope === "post" || scope === "both") { + summary.post = await reconcilePosts(); + } + + console.log(`\n========== 总结 ==========`); + console.log(`话题偏差: ${summary.topic.drift}${fixMode ? `(已修正 ${summary.topic.fixed})` : ""}`); + console.log(`回复偏差: ${summary.post.drift}${fixMode ? `(已修正 ${summary.post.fixed})` : ""}`); + const totalDrift = summary.topic.drift + summary.post.drift; + if (totalDrift === 0) { + console.log(`\n✅ 全部对齐,无需修正`); + } else if (fixMode) { + console.log(`\n🔧 已完成修正`); + } else { + console.log(`\n⚠️ 发现偏差,加 --fix 自动修正`); + } + + await prisma.$disconnect(); +} + +main().catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/scripts/render-test.py b/scripts/render-test.py new file mode 100644 index 0000000..e5b6fb5 --- /dev/null +++ b/scripts/render-test.py @@ -0,0 +1,41 @@ +import re +import urllib.request + +# 重新拉取 +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 找"全部板块"区域 +idx = html.find('全部板块') +print(f'HTML size: {len(html)}, 全部板块 位置: {idx}') + +# 取 "全部板块" 之后的板块区域 +section_start = html.find('全部板块') +section_end = html.find('最新话题', section_start) +section = html[section_start:section_end] if section_end > 0 else html[section_start:section_start+15000] +print(f'板块区域长度: {len(section)} 字符') + +# 提取所有 链接 +pattern = re.compile(r']*href="/community/([^"]+)"[^>]*>(.*?)', re.DOTALL) +links = [] +for m in pattern.finditer(section): + slug = m.group(1) + text = re.sub(r'<[^>]+>', ' ', m.group(2)) + text = re.sub(r'\s+', ' ', text).strip() + if text and len(text) < 30: + links.append((slug, text)) + +print(f'\n=== 板块区域内的所有链接 (共{len(links)}个) ===') +# 按slug排序去重 +seen = set() +for slug, text in links: + if slug in seen: + continue + seen.add(slug) + print(f' /community/{slug:25s} {text}') + +# 看父板块"电商零售"周围的HTML +print('\n=== 电商零售(ecommerce) 周围的HTML片段 ===') +m = re.search(r'.{200}电商零售.{500}', section) +if m: + print(m.group()[:800]) diff --git a/scripts/seed-achievements.mjs b/scripts/seed-achievements.mjs new file mode 100644 index 0000000..9a583e9 --- /dev/null +++ b/scripts/seed-achievements.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * 同步勋章定义到数据库 + * - 把 achievements 表填充/更新为代码中维护的 ACHIEVEMENT_DEFS + * - 对存量用户批量评估一次(首屏打开即有数据) + */ +import { PrismaClient } from "@prisma/client"; +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import "dotenv/config"; + +const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); +const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5"; +const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) }); + +const ACHIEVEMENT_DEFS = [ + { key: "first_post", name: "初次发声", description: "发布第 1 个话题", icon: "🌱", tier: "bronze", points: 10, category: "post", sortOrder: 1, target: 1 }, + { key: "first_reply", name: "热心回复", description: "发出第 1 条回复", icon: "💬", tier: "bronze", points: 10, category: "reply", sortOrder: 2, target: 1 }, + { key: "first_like", name: "首获认可", description: "首次被点赞", icon: "👍", tier: "bronze", points: 10, category: "social", sortOrder: 3, target: 1 }, + { key: "posts_10", name: "话题达人", description: "累计发布 10 个话题", icon: "📚", tier: "silver", points: 30, category: "post", sortOrder: 10, target: 10 }, + { key: "replies_50", name: "热心答主", description: "累计发出 50 条回复", icon: "🗣️", tier: "silver", points: 30, category: "reply", sortOrder: 11, target: 50 }, + { key: "likes_50", name: "社区明星", description: "累计被点赞 50 次", icon: "⭐", tier: "silver", points: 30, category: "social", sortOrder: 12, target: 50 }, + { key: "checkin_7", name: "周满贯", description: "连续签到 7 天", icon: "🔥", tier: "silver", points: 30, category: "checkin", sortOrder: 13, target: 7 }, + { key: "best_answer_1", name: "最佳答主", description: "首个回复被采纳为最佳答案", icon: "🏆", tier: "silver", points: 30, category: "milestone", sortOrder: 14, target: 1 }, + { key: "posts_100", name: "百帖宗师", description: "累计发布 100 个话题", icon: "📕", tier: "gold", points: 100, category: "post", sortOrder: 20, target: 100 }, + { key: "replies_500", name: "千言万语", description: "累计发出 500 条回复", icon: "📝", tier: "gold", points: 100, category: "reply", sortOrder: 21, target: 500 }, + { key: "likes_500", name: "万人迷", description: "累计被点赞 500 次", icon: "💖", tier: "gold", points: 100, category: "social", sortOrder: 22, target: 500 }, + { key: "checkin_30", name: "月之守护", description: "累计签到 30 天", icon: "🌙", tier: "gold", points: 100, category: "checkin", sortOrder: 23, target: 30 }, + { key: "best_answer_10", name: "答疑名师", description: "10 个回复被采纳为最佳答案", icon: "👑", tier: "gold", points: 100, category: "milestone", sortOrder: 24, target: 10 }, + { key: "pioneer", name: "先驱者", description: "社区前 100 位注册用户", icon: "🚀", tier: "special", points: 200, category: "milestone", sortOrder: 30, target: 1 }, + { key: "night_owl", name: "深夜猫头鹰", description: "在 0~5 点发布 5 个话题", icon: "🦉", tier: "special", points: 50, category: "milestone", sortOrder: 31, target: 5 }, +]; + +async function evaluateForUser(userId) { + const [topicCount, postCount, likeCount, bestAnswerCount, totalCheckIns] = + await Promise.all([ + prisma.forumTopic.count({ where: { userId } }), + prisma.forumPost.count({ where: { userId } }), + prisma.forumPostLike.count({ where: { post: { userId } } }), + prisma.forumPost.count({ where: { userId, isAnswer: true } }), + prisma.forumCheckIn.count({ where: { userId } }), + ]); + + const progressFor = (key) => { + switch (key) { + case "first_post": return Math.min(topicCount, 1); + case "first_reply": return Math.min(postCount, 1); + case "first_like": return Math.min(likeCount, 1); + case "posts_10": return Math.min(topicCount, 10); + case "replies_50": return Math.min(postCount, 50); + case "likes_50": return Math.min(likeCount, 50); + case "checkin_7": return Math.min(totalCheckIns, 7); + case "best_answer_1": return Math.min(bestAnswerCount, 1); + case "posts_100": return Math.min(topicCount, 100); + case "replies_500": return Math.min(postCount, 500); + case "likes_500": return Math.min(likeCount, 500); + case "checkin_30": return Math.min(totalCheckIns, 30); + case "best_answer_10": return Math.min(bestAnswerCount, 10); + case "pioneer": return 1; + case "night_owl": return 0; + default: return 0; + } + }; + + const existing = await prisma.userAchievement.findMany({ where: { userId } }); + const map = new Map(existing.map((e) => [e.achievementKey, e])); + + for (const def of ACHIEVEMENT_DEFS) { + const progress = progressFor(def.key); + const prev = map.get(def.key); + const shouldUnlock = progress >= def.target; + if (prev) { + const data = { progress }; + if (shouldUnlock && !prev.unlockedAt) { + data.unlockedAt = new Date(); + } + await prisma.userAchievement.update({ where: { id: prev.id }, data }); + } else { + await prisma.userAchievement.create({ + data: { + userId, + achievementKey: def.key, + progress, + unlockedAt: shouldUnlock ? new Date() : null, + }, + }); + } + } +} + +async function main() { + console.log(`[seed-achievements] 同步 ${ACHIEVEMENT_DEFS.length} 个勋章定义...`); + for (const def of ACHIEVEMENT_DEFS) { + const data = { + key: def.key, + name: def.name, + description: def.description, + icon: def.icon, + tier: def.tier, + points: def.points, + category: def.category, + sortOrder: def.sortOrder, + isActive: true, + }; + await prisma.achievement.upsert({ + where: { key: def.key }, + create: data, + update: data, + }); + } + console.log("[seed-achievements] 勋章定义已同步"); + + const users = await prisma.user.findMany({ select: { id: true } }); + console.log(`[seed-achievements] 评估 ${users.length} 个用户的勋章进度...`); + for (const u of users) { + await evaluateForUser(u.id); + } + console.log("[seed-achievements] 完成"); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/seed-persona-variants.mjs b/scripts/seed-persona-variants.mjs new file mode 100644 index 0000000..75ef1e1 --- /dev/null +++ b/scripts/seed-persona-variants.mjs @@ -0,0 +1,117 @@ +// 给所有 bot 初始化 A/B 测试变体 +// 用法:node scripts/seed-persona-variants.mjs [--bot=] +// - 默认跑所有 bot +// - --bot=laochen 只为老陈创建/更新变体 + +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"; +import { + ensureBotVariants, + ensureExperiment, + disconnectBotExperiment, +} from "./lib/bot-persona-experiment.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=2&pool_timeout=10`; +const adapter = new PrismaMariaDb(connectionString); +const prisma = new PrismaClient({ adapter }); + +const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); + +function ts() { + return `[${new Date().toISOString()}]`; +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = { bot: null }; + for (const a of args) { + if (a.startsWith("--bot=")) out.bot = a.split("=")[1]; + } + return out; +} + +async function main() { + const args = parseArgs(); + console.log(`${ts()} Seed persona variants starting${args.bot ? ` (bot=${args.bot})` : ""}...`); + + const { characters } = JSON.parse(readFileSync(BOT_DATA_PATH, "utf-8")); + let botChars = characters.filter((c) => !c.role || c.role !== "passerby"); + if (args.bot) { + botChars = botChars.filter((c) => c.key === args.bot); + if (botChars.length === 0) { + console.error(`${ts()} Bot "${args.bot}" not found in bot-characters.json`); + process.exit(1); + } + } + + let variantCreated = 0; + let experimentCreated = 0; + for (const c of botChars) { + const botUser = await prisma.user.findFirst({ where: { email: `bot_${c.key}@zhuiguang.ai` } }); + if (!botUser) { + console.warn(`${ts()} bot user not found for key=${c.key}, skipping.`); + continue; + } + const botConfig = await prisma.botConfig.findUnique({ where: { userId: botUser.id } }); + if (!botConfig) { + console.warn(`${ts()} botConfig not found for key=${c.key}, skipping.`); + continue; + } + + const { variants, created } = await ensureBotVariants(botConfig.id, botUser.id); + variantCreated += created; + console.log(`${ts()} ${c.displayName}: variants=${variants.length} (created=${created})`); + + const exp = await ensureExperiment(botConfig.id); + if (exp) { + experimentCreated++; + console.log(`${ts()} exp#${exp.id} status=${exp.status}`); + } + } + + await prisma.taskLog.create({ + data: { + taskKey: "seed-persona-variants", + taskName: "Bot 人设 A/B 变体初始化", + status: "success", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "manual", + result: { botCount: botChars.length, variantCreated, experimentCreated }, + }, + }); + + console.log( + `${ts()} Done. bots=${botChars.length} variants_created=${variantCreated} experiments=${experimentCreated}` + ); +} + +main() + .catch(async (e) => { + console.error(`${ts()} Fatal:`, e); + try { + await prisma.taskLog.create({ + data: { + taskKey: "seed-persona-variants", + taskName: "Bot 人设 A/B 变体初始化", + status: "failed", + startedAt: new Date(), + finishedAt: new Date(), + triggerBy: "manual", + error: (e && e.message ? e.message : String(e)).slice(0, 1000), + }, + }); + } catch {} + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + await disconnectBotExperiment(); + }); diff --git a/scripts/seed-task-configs.mjs b/scripts/seed-task-configs.mjs index 4cc2b0a..98b19be 100644 --- a/scripts/seed-task-configs.mjs +++ b/scripts/seed-task-configs.mjs @@ -59,11 +59,83 @@ const TASK_DEFS = [ }, { taskKey: "bot-activity", - taskName: "Bot活跃度调度", + taskName: "Bot活动引擎", enabled: true, - cronExpr: "0 8,12,16,20 * * *", - description: "每天4次(8点/12点/16点/20点)调度行业论坛Bot发言:选2个论坛,发起话题或回复", - config: { forumsPerRun: 2, model: "deepseek-chat" }, + cronExpr: "0 9-23 * * *", + description: "每小时(9-23点)调度行业论坛Bot发言:4个专家板块+4个路人板块(每个路人板块最多2帖),按活跃时段过滤bot,按最近发帖加权去重", + config: { expertForumsPerRun: 4, passerbyForumsPerRun: 4, passerbyTopicsPerForum: 2, model: "deepseek-chat" }, + }, + { + taskKey: "bot-feedback-loop", + taskName: "Bot反馈循环", + enabled: true, + cronExpr: "0 */3 * * *", + description: "每3小时扫描Bot自己帖子下的新回复,存为反馈记忆并按概率触发二次回复", + config: { followUpProbability: 0.6, maxPerRun: 3, lookbackHours: 24 }, + }, + { + taskKey: "bot-skill-crystallize", + taskName: "Bot技能沉淀", + enabled: true, + cronExpr: "0 */12 * * *", + description: "每12小时扫描过去14天高互动Bot话题(回复≥5或真人≥1或点赞≥3),DeepSeek解构共性后入库BotSkill;末尾回收7天前成熟技能的实际表现,更新successRate/avgReplies,<30%且使用≥3次自动降级", + config: { lookbackDays: 14, minReplies: 5, minHumanReplies: 1, minLikes: 3, maxTopicsPerBot: 8, recycleAfterDays: 7, minSuccessRate: 0.3 }, + }, + { + taskKey: "bot-affinity-update", + taskName: "Bot亲和度与画像", + enabled: true, + cronExpr: "0 */6 * * *", + description: "每6小时:①统计每个Bot话题收到的跨板块真人互动,刷BotCrossForumAffinity(affinity=min(1, samples/total/4))②扫过去30天发言,刷BotPersona(avgReplyLength/questionRatio/stanceKeywords/topHumanUsers/topTopicTypes/lastTopicTitles)", + config: { affinityLookbackDays: 14, personaLookbackDays: 30, minAffinity: 0.1 }, + }, + { + taskKey: "bot-weekly-review", + taskName: "Bot周度复盘", + enabled: true, + cronExpr: "30 2 * * 1", + description: "每周一凌晨2:30对每个Bot算engagementScore(回复/帖子比+真人占比+点赞对数),生成REFLECTION记忆并决定下周配额tier(high/medium/low)", + config: { weights: { replyPerTopic: 0.4, humanRatio: 0.3, likesLog: 0.3 }, thresholds: { high: 1.0, medium: 0.5 } }, + }, + { + taskKey: "task7-news-to-community", + taskName: "新闻分发到社区", + enabled: true, + cronExpr: "0 8 * * *", + description: "每天早上8点把当日AI日报条目自动分发到社区对应板块创建话题", + config: { sourceTask: "daily-news", maxPerDay: 12 }, + }, + { + taskKey: "cleanup-task-logs", + taskName: "清理任务日志", + enabled: true, + cronExpr: "30 6 * * 0", + description: "每周日凌晨清理 30 天前的 task_log 数据,避免表无限膨胀", + config: { retentionDays: 30 }, + }, + { + taskKey: "reconcile-like-counts", + taskName: "点赞数对齐巡检", + enabled: true, + cronExpr: "15 */6 * * *", + description: "每6小时对齐 forumTopic.likeCount / forumPost.likeCount 与真实 Like 表的计数,确保 Redis 缓存 + UI 显示与真实点赞一致(drift 自动修正)", + config: { scope: "both", botOnly: false, autoFix: true, limit: 5000 }, + }, + { + taskKey: "bot-persona-experiment-run", + taskName: "Bot Persona A/B 调度", + enabled: true, + cronExpr: "45 */4 * * *", + description: "每4小时:①采集最近7天 bot_persona_assignments 的真实互动数据(reply/like/humanReplies)回填到 bot_persona_metrics ②对每个bot的变体做双比例z检验(p<0.1, 样本≥20)③winner变体权重自动提到0.85,其他降到0.075,标记experiment=completed", + config: { lookbackDays: 7, minSamplesPerArm: 20, significanceLevel: 0.1 }, + }, + { + taskKey: "bot-adversarial-learning-run", + taskName: "Bot 对抗学习(真人高赞帖)", + enabled: true, + cronExpr: "0 23 * * 0", + description: "每周日23:00:给每个专家bot选1篇过去7天由真人发布的高互动topic(真人reply+点赞高),LLM 提取“为什么火”的洞察(hook/story/data/question/contrarian/empathy/practical),写入 bot_adversarial_learnings(unique bot+weekKey)+ BotMemory,bot下次发帖时可注入到 prompt", + config: { lookbackDays: 7, expireDays: 7, minReplies: 3, minLikes: 5, minHumanReplies: 1 }, }, ]; diff --git a/scripts/sync-new-passersby.mjs b/scripts/sync-new-passersby.mjs new file mode 100644 index 0000000..725a253 --- /dev/null +++ b/scripts/sync-new-passersby.mjs @@ -0,0 +1,100 @@ +// 把新加的 33 个数字路人同步到数据库 +// 只插入 user 表里还不存在的 bot_@zhuiguang.ai + +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"); + +async function main() { + const raw = readFileSync(BOT_DATA_PATH, "utf-8"); + const { characters } = JSON.parse(raw); + + const passersby = characters.filter((c) => c.role === "passerby"); + console.log(`观察者总数(JSON): ${passersby.length}`); + + let created = 0; + let skipped = 0; + + for (const char of passersby) { + const existing = await prisma.user.findUnique({ + where: { email: char.email }, + include: { botConfig: true }, + }); + + if (existing && existing.botConfig) { + // 已经存在,更新一下 primaryForums / activeHours + await prisma.botConfig.update({ + where: { userId: existing.id }, + data: { + displayName: char.displayName, + personality: char.personality, + primaryForums: char.primaryForums, + activeHours: char.activeHours, + }, + }); + skipped++; + continue; + } + + if (existing && !existing.botConfig) { + await prisma.botConfig.create({ + data: { + userId: existing.id, + displayName: char.displayName, + personality: char.personality, + primaryForums: char.primaryForums, + activeHours: char.activeHours, + }, + }); + skipped++; + 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, + }, + }); + + created++; + } + + console.log(`新增路人用户: ${created}, 跳过(已存在): ${skipped}`); + const totalBots = await prisma.user.count({ where: { isBot: true } }); + console.log(`数据库中 bot 用户总数: ${totalBots}`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/task1-discover-tools.mjs b/scripts/task1-discover-tools.mjs index 077ff88..8510985 100644 --- a/scripts/task1-discover-tools.mjs +++ b/scripts/task1-discover-tools.mjs @@ -4,12 +4,14 @@ 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").replace("mysql://", "mariadb://"); +const rawUrl = process.env.DATABASE_URL; +if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); } +const base = rawUrl.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 }); -const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1" }); +const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1", timeout: 120000, maxRetries: 2 }); const EXTRACT_PROMPT = `你是一个AI工具信息提取助手。从以下科技新闻内容中,提取出现的AI工具/产品信息。只提取明确提到的AI软件工具、AI平台、AI产品,不要提取开源项目库(GitHub仓库)、不要提取硬件、芯片。每个工具返回以下信息(如果信息不足则跳过该工具): @@ -212,7 +214,9 @@ async function main() { await prisma.$disconnect(); } -main().catch(async (e) => { +main() + .then(() => { console.log("Done"); process.exit(0); }) + .catch(async (e) => { console.error("❌ 任务一失败:", e.message); if (prisma) { try { @@ -228,7 +232,6 @@ main().catch(async (e) => { }, }); } catch {} - await prisma.$disconnect(); } process.exit(1); -}); \ No newline at end of file +}).finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/scripts/task3-check-tools.mjs b/scripts/task3-check-tools.mjs index 352c4fc..a99145a 100644 --- a/scripts/task3-check-tools.mjs +++ b/scripts/task3-check-tools.mjs @@ -3,7 +3,9 @@ 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").replace("mysql://", "mariadb://"); +const rawUrl = process.env.DATABASE_URL; +if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); } +const base = rawUrl.replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -166,7 +168,9 @@ async function main() { await prisma.$disconnect(); } -main().catch(async (e) => { +main() + .then(() => { console.log("Done"); process.exit(0); }) + .catch(async (e) => { console.error("❌ 任务三失败:", e.message); if (prisma) { try { @@ -182,7 +186,6 @@ main().catch(async (e) => { }, }); } catch {} - await prisma.$disconnect(); } process.exit(1); -}); +}).finally(() => prisma.$disconnect()); diff --git a/scripts/task5-update-stars.mjs b/scripts/task5-update-stars.mjs index c7eb92a..56c7c0a 100644 --- a/scripts/task5-update-stars.mjs +++ b/scripts/task5-update-stars.mjs @@ -3,7 +3,9 @@ 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").replace("mysql://", "mariadb://"); +const rawUrl = process.env.DATABASE_URL; +if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); } +const base = rawUrl.replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); @@ -123,7 +125,9 @@ async function main() { await prisma.$disconnect(); } -main().catch(async (e) => { +main() + .then(() => { console.log("Done"); process.exit(0); }) + .catch(async (e) => { console.error("❌ 任务五失败:", e.message); if (prisma) { try { @@ -139,7 +143,6 @@ main().catch(async (e) => { }, }); } catch {} - await prisma.$disconnect(); } process.exit(1); -}); +}).finally(() => prisma.$disconnect()); diff --git a/scripts/test-bot-stats.mjs b/scripts/test-bot-stats.mjs new file mode 100644 index 0000000..cfc891a --- /dev/null +++ b/scripts/test-bot-stats.mjs @@ -0,0 +1,94 @@ +// 直接调用 bot stats 逻辑,不走 HTTP +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() { + const days = 14; + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const sinceDate = new Date(since.getFullYear(), since.getMonth(), since.getDate()); + + const botUsers = await prisma.user.findMany({ + where: { isBot: true }, + select: { id: true, name: true, email: true, points: true }, + }); + const botConfigs = await prisma.botConfig.findMany({ + select: { id: true, userId: true, primaryForums: true }, + }); + const configByUserId = new Map(botConfigs.map((c) => [c.userId, c])); + + const dailyStats = await prisma.botDailyStat.groupBy({ + by: ["botId"], + where: { date: { gte: sinceDate } }, + _sum: { + topicCreated: true, + replySent: true, + repliesReceived: true, + likesReceived: true, + humanInteractions: true, + }, + }); + const statMap = new Map( + dailyStats.map((s) => [ + s.botId, + { + topicCreated: s._sum.topicCreated || 0, + replySent: s._sum.replySent || 0, + likesReceived: s._sum.likesReceived || 0, + humanInteractions: s._sum.humanInteractions || 0, + }, + ]) + ); + + const memCount = await prisma.botMemory.count(); + console.log("--- Bot Stats Preview ---"); + console.log("Total bots:", botUsers.length); + console.log("Total configs:", botConfigs.length); + console.log("BotDailyStat records:", dailyStats.length); + console.log("BotMemory records:", memCount); + console.log("Bots with stats:", statMap.size); + + // Top 5 by health score + const top = botUsers + .map((u) => { + const cfg = configByUserId.get(u.id); + const d = statMap.get(cfg?.id) || { topicCreated: 0, replySent: 0, likesReceived: 0, humanInteractions: 0 }; + return { + name: u.name, + health: d.topicCreated * 3 + d.replySent + d.likesReceived * 2 + d.humanInteractions * 5, + ...d, + }; + }) + .sort((a, b) => b.health - a.health) + .slice(0, 5); + + console.log("\n--- Top 5 ---"); + for (const t of top) { + console.log(`${t.name.padEnd(20)} health=${String(t.health).padStart(4)} 话题=${t.topicCreated} 回复=${t.replySent} 赞=${t.likesReceived} 真人=${t.humanInteractions}`); + } + + // Trends + const trendRaw = await prisma.botDailyStat.groupBy({ + by: ["date"], + where: { date: { gte: sinceDate } }, + _sum: { topicCreated: true, replySent: true, likesReceived: true, humanInteractions: true }, + orderBy: { date: "asc" }, + }); + console.log("\n--- Trend (近14天) ---"); + for (const t of trendRaw) { + console.log(`${t.date.toISOString().split("T")[0]} 话题=${t._sum.topicCreated} 回复=${t._sum.replySent} 赞=${t._sum.likesReceived}`); + } + + await prisma.$disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/test-html.sh b/scripts/test-html.sh new file mode 100644 index 0000000..e662fa4 --- /dev/null +++ b/scripts/test-html.sh @@ -0,0 +1,9 @@ +#!/bin/bash +curl -s 'https://www.zhuig.com/community' -o /tmp/c3.html +echo "HTML size: $(wc -c < /tmp/c3.html)" +echo "data-version=2026-06-03: $(grep -c 'data-version' /tmp/c3.html)" +echo "赚钱与副业(应该=0): $(grep -c '赚钱与副业' /tmp/c3.html)" +echo "电商零售(应该>0): $(grep -c '电商零售' /tmp/c3.html)" +echo "行业论坛: $(grep -c '行业论坛' /tmp/c3.html)" +echo "板块slug:" +grep -oE 'href="/community/[a-z-]+"' /tmp/c3.html | sort -u diff --git a/scripts/test_login.py b/scripts/test_login.py new file mode 100644 index 0000000..f30fc91 --- /dev/null +++ b/scripts/test_login.py @@ -0,0 +1,40 @@ +import urllib.request, json, http.cookiejar, urllib.error + +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + +# Get CSRF token +csrf_resp = opener.open('http://localhost:8301/api/auth/csrf') +csrf = json.loads(csrf_resp.read()) +print('CSRF token:', csrf['csrfToken'][:20] + '...') + +# Login +data = urllib.parse.urlencode({ + 'csrfToken': csrf['csrfToken'], + 'email': 'admin@zhuiguang.com', + 'password': 'Admin123!' +}).encode() + +req = urllib.request.Request( + 'http://localhost:8301/api/auth/callback/credentials', + data=data, + method='POST' +) + +try: + login_resp = opener.open(req) + print('Login status:', login_resp.status) + loc = login_resp.headers.get('location', 'none') + print('Location:', loc) +except urllib.error.HTTPError as e: + loc = e.headers.get('location', 'none') + print('Login redirect:', e.code, '->', loc) + if 'error=CredentialsSignin' in (loc or ''): + print('>>> ERROR: Invalid credentials!') + elif 'csrf=true' in (loc or ''): + print('>>> ERROR: CSRF token mismatch!') + +# Session +sess_resp = opener.open('http://localhost:8301/api/auth/session') +sess = json.loads(sess_resp.read()) +print('Session:', json.dumps(sess, indent=2)) diff --git a/scripts/test_login.sh b/scripts/test_login.sh new file mode 100644 index 0000000..8a39a94 --- /dev/null +++ b/scripts/test_login.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Get CSRF token via curl +CSRF=$(curl -s -c /tmp/ck3.txt http://localhost:8301/api/auth/csrf | python3 -c "import sys,json; print(json.load(sys.stdin)['csrfToken'])") +echo "CSRF token: ${CSRF:0:20}..." + +# Build POST data +python3 -c " +import json +with open('/tmp/ck3.txt') as f: + content = f.read() +# Extract csrf token from response +import subprocess +token = subprocess.check_output(['curl', '-s', 'http://localhost:8301/api/auth/csrf']).decode() +token = json.loads(token)['csrfToken'] +print(f'csrfToken={token}&email=admin@zhuiguang.com&password=Admin123!') +" > /tmp/login_data3.txt + +cat /tmp/login_data3.txt | head -c 40 +echo "..." + +# Login +curl -sv -b /tmp/ck3.txt -c /tmp/ck4.txt \ + -X POST http://localhost:8301/api/auth/callback/credentials \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d @/tmp/login_data3.txt 2>&1 | grep -E "location:|HTTP/|set-cookie" + +# Session +echo "=== Session ===" +curl -s -b /tmp/ck4.txt http://localhost:8301/api/auth/session diff --git a/scripts/test_login2.py b/scripts/test_login2.py new file mode 100644 index 0000000..9b8086e --- /dev/null +++ b/scripts/test_login2.py @@ -0,0 +1,59 @@ +import urllib.request, json, http.cookiejar, urllib.error, ssl + +# Disable SSL verification for localhost testing +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(cj), + urllib.request.HTTPSHandler(context=ctx) +) + +# Get CSRF token +csrf_resp = opener.open('http://localhost:8301/api/auth/csrf') +csrf = json.loads(csrf_resp.read()) +print('CSRF token:', csrf['csrfToken'][:20] + '...') + +# Login (don't follow redirect) +data = urllib.parse.urlencode({ + 'csrfToken': csrf['csrfToken'], + 'email': 'admin@zhuiguang.com', + 'password': 'Admin123!' +}).encode() + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + def http_error_302(self, req, fp, code, msg, headers): + return fp + +no_redirect_opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(cj), + NoRedirect +) + +req = urllib.request.Request( + 'http://localhost:8301/api/auth/callback/credentials', + data=data, + method='POST' +) +try: + resp = no_redirect_opener.open(req) + print('Status:', resp.status) + print('Location:', resp.headers.get('location', 'none')) + # Check cookies + for cookie in cj: + if 'session-token' in cookie.name or 'next-auth' in cookie.name: + print('Cookie:', cookie.name, '=', cookie.value[:30] + '...') +except urllib.error.HTTPError as e: + print('Error:', e.code, '->', e.headers.get('location', '')) + +# Check session +sess_req = urllib.request.Request('http://localhost:8301/api/auth/session') +sess_resp = opener.open(sess_req) +sess = json.loads(sess_resp.read()) +print('Session:', json.dumps(sess, indent=2)) +if sess.get('user'): + print('>>> LOGIN SUCCESS! User:', sess['user'].get('email')) diff --git a/scripts/test_login3.py b/scripts/test_login3.py new file mode 100644 index 0000000..6235f4e --- /dev/null +++ b/scripts/test_login3.py @@ -0,0 +1,53 @@ +import urllib.request, json, http.cookiejar + +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + +# Get CSRF token +csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read()) +print('CSRF:', csrf['csrfToken'][:30]) + +# Dump cookies for debug +print('Cookies before login:') +for c in cj: + print(f' {c.name}={c.value[:30]}... domain={c.domain} path={c.path} secure={c.secure}') + +# Login +data = urllib.parse.urlencode({ + 'csrfToken': csrf['csrfToken'], + 'email': 'admin@zhuiguang.com', + 'password': 'Admin123!' +}).encode() + +print('Posting login with data:', data[:80]) + +req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST') +try: + resp = urllib.request.urlopen(req) + print('Status:', resp.status) + print('Location:', resp.headers.get('location')) +except urllib.error.HTTPError as e: + loc = e.headers.get('location', '') + print('Status:', e.code) + print('Location:', loc) + if 'error=CredentialsSignin' in loc: + print('FAIL: CredentialsSignin - check email/password') + elif 'csrf=true' in loc: + print('FAIL: CSRF token mismatch') + elif loc.startswith('https://www.zhuig.com'): + print('SUCCESS: Login redirect to dashboard') + +print('\nCookies after login:') +for c in cj: + if 'next-auth' in c.name or 'session' in c.name: + print(f' {c.name}={c.value[:30]}...') + +# Session +try: + sess = json.loads(opener.open('http://localhost:8301/api/auth/session').read()) + if sess.get('user'): + print('SESSION OK - user:', sess['user'].get('email')) + else: + print('SESSION EMPTY') +except Exception as e: + print('Session error:', e) diff --git a/scripts/test_login4.py b/scripts/test_login4.py new file mode 100644 index 0000000..1a1a852 --- /dev/null +++ b/scripts/test_login4.py @@ -0,0 +1,44 @@ +import urllib.request, json, http.cookiejar + +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + http_error_301 = http_error_302 = http_error_303 = http_error_307 = lambda self, req, fp, code, msg, headers: fp + +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(cj), + NoRedirectHandler +) + +# Get CSRF +csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read()) +print('CSRF:', csrf['csrfToken'][:30]) + +# Login +data = urllib.parse.urlencode({ + 'csrfToken': csrf['csrfToken'], + 'email': 'admin@zhuiguang.com', + 'password': 'Admin123!' +}).encode() + +req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST') +resp = opener.open(req) +print('Status:', resp.status) +loc = resp.headers.get('location', 'NONE') +print('Location:', loc) + +if 'error=CredentialsSignin' in (loc or ''): + print('>>> FAIL: Invalid credentials') +elif 'csrf=true' in (loc or ''): + print('>>> FAIL: CSRF token mismatch') +elif '/api/auth/signin' not in (loc or ''): + print('>>> SUCCESS: Login accepted, redirecting to caller page') + +# Session +opener2 = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) +sess = json.loads(opener2.open('http://localhost:8301/api/auth/session').read()) +if sess.get('user'): + print('Session OK:', sess['user'].get('email'), '| role:', sess['user'].get('role')) +else: + print('Session EMPTY - user not logged in') diff --git a/scripts/test_login5.py b/scripts/test_login5.py new file mode 100644 index 0000000..c0e5b87 --- /dev/null +++ b/scripts/test_login5.py @@ -0,0 +1,41 @@ +import urllib.request, json, http.cookiejar, http.client + +# Enable debug to see request headers +http.client.HTTPConnection.debuglevel = 1 + +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + +# Get CSRF +csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read()) +print('\n=== Cookies after CSRF ===') +for c in cj: + print(f' {c.name}: val={c.value[:30]} domain={c.domain} path={c.path} secure={c.secure} httponly={c.has_nonstandard_attr("HttpOnly")}') + +# Now login with debug to see if cookies are sent +print('\n=== Login POST headers ===') +data = urllib.parse.urlencode({ + 'csrfToken': csrf['csrfToken'], + 'email': 'admin@zhuiguang.com', + 'password': 'Admin123!' +}).encode() + +req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST') + +# Check what cookies will be sent +print(f'Sending cookies:') +for c in cj: + print(f' {"Cookie: "}{c.name}={c.value[:30]}') + +try: + resp = urllib.request.urlopen(req) + print(f'Status: {resp.status}') + print(f'Location: {resp.headers.get("location")}') +except urllib.error.HTTPError as e: + loc = e.headers.get('location', '') + print(f'Status: {e.code}') + print(f'Location: {loc}') + if 'csrf=true' in loc: + print('CSRF FAILED') + elif loc.startswith('https://www.zhuig.com'): + print('LOGIN SUCCESS - redirect to app') diff --git a/scripts/verify-deploy.sh b/scripts/verify-deploy.sh new file mode 100644 index 0000000..0f31b79 --- /dev/null +++ b/scripts/verify-deploy.sh @@ -0,0 +1,27 @@ +#!/bin/bash +echo "=== 1. pm2 进程状态 ===" +pm2 list +echo "" +echo "=== 2. 其他项目端口健康(不动的) ===" +for p in 8602 8720 8801 8848 9000 9848 7001; do + result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:$p/ 2>/dev/null) + echo " $p: HTTP=$result" +done +echo "" +echo "=== 3. 我们部署的项目 (zhuiguang-ai) ===" +result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:8301/) +echo " 8301 主页: HTTP=$result" +result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:8301/api/forum/categories) +echo " 8301 API: HTTP=$result" +echo "" +echo "=== 4. 备份文件 ===" +ls -la /home/ubuntu/zhuiguang-ai/.backup-2026-06-03/ +echo "" +echo "=== 5. API 返回的板块数 ===" +curl -s http://127.0.0.1:8301/api/forum/categories | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(' 顶层板块:', len(d['categories'])) +print(' 子板块总数:', sum(len(c.get('children', [])) for c in d['categories'])) +print(' 孙板块总数:', sum(len(gc.get('children', [])) for c in d['categories'] for gc in c.get('children', []))) +" diff --git a/scripts/verify-fix.py b/scripts/verify-fix.py new file mode 100644 index 0000000..7cd67dc --- /dev/null +++ b/scripts/verify-fix.py @@ -0,0 +1,25 @@ +import re +import urllib.request + +req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'}) +html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8') + +# 找板块区域 +idx_q = html.find('全部板块') +idx_z = html.find('最新话题') +section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000] + +# 统计 链接 +a_count = len(re.findall(r']*href="/community/', section)) +h3_count = len(re.findall(r']*>[^<]+', section)) +print(f'板块区域 链接数: {a_count}') +print(f'板块区域

标签数: {h3_count}') + +# 提取前10个 +matches = list(re.finditer(r']*href="/community/([^"]+)"[^>]*>(.*?)', section, re.DOTALL))[:15] +print('\n=== 板块区域前15个链接 ===') +for m in matches: + slug = m.group(1) + text = re.sub(r'<[^>]+>', ' ', m.group(2)) + text = re.sub(r'\s+', ' ', text).strip() + print(f' /community/{slug:25s} | {text}') diff --git a/src/app/(admin)/admin/bot-adversarial-learning/BotAdversarialLearningClient.tsx b/src/app/(admin)/admin/bot-adversarial-learning/BotAdversarialLearningClient.tsx new file mode 100644 index 0000000..504bbb0 --- /dev/null +++ b/src/app/(admin)/admin/bot-adversarial-learning/BotAdversarialLearningClient.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import Link from "next/link"; +import { + GraduationCap, + Trophy, + Sparkles, + RefreshCw, + Play, + Activity, + Eye, + Heart, + MessageSquare, + Users, + AlertCircle, + ChevronDown, + ChevronUp, + TrendingUp, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SourceMetrics { + replyCount: number; + likeCount: number; + viewCount: number; + humanReplies: number; + engagementScore: number; +} + +interface Learning { + id: number; + botId: number; + botUserId: number | null; + botName: string; + botAvatar: string | null; + weekKey: string; + sourceRefType: string; + sourceRefId: number; + sourceTitle: string | null; + sourceUserName: string | null; + sourceUserAvatar: string | null; + sourceMetrics: SourceMetrics; + relevanceScore: number; + learnedInsight: string; + learnCategories: string[]; + status: string; + usedCount: number; + learnedAt: string; + expiresAt: string | null; + forumSlug: string | null; + categoryName: string | null; +} + +interface Summary { + totalLearnings: number; + activeCount: number; + usedCount: number; + avgRelevance: number; + botCount: number; + weeks: number; +} + +const RANGE_OPTIONS = [ + { value: 1, label: "近 1 周" }, + { value: 2, label: "近 2 周" }, + { value: 4, label: "近 4 周" }, + { value: 8, label: "近 8 周" }, +]; + +const CATEGORY_META: Record = { + hook: { label: "钩子", color: "text-violet-700", bg: "bg-violet-50" }, + story: { label: "故事", color: "text-amber-700", bg: "bg-amber-50" }, + data: { label: "数据", color: "text-sky-700", bg: "bg-sky-50" }, + question: { label: "提问", color: "text-emerald-700", bg: "bg-emerald-50" }, + contrarian: { label: "反主流", color: "text-rose-700", bg: "bg-rose-50" }, + empathy: { label: "共情", color: "text-pink-700", bg: "bg-pink-50" }, + practical: { label: "实用", color: "text-slate-700", bg: "bg-slate-100" }, +}; + +const STATUS_META: Record = { + active: { label: "生效中", color: "text-emerald-700", bg: "bg-emerald-50" }, + used: { label: "已用", color: "text-sky-700", bg: "bg-sky-50" }, + expired: { label: "已过期", color: "text-slate-500", bg: "bg-slate-50" }, +}; + +export default function BotAdversarialLearningClient() { + const [weeks, setWeeks] = useState(4); + const [data, setData] = useState<{ summary: Summary; learnings: Learning[] } | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [running, setRunning] = useState(false); + const [runResult, setRunResult] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/admin/bots/adversarial-learning?weeks=${weeks}`, { cache: "no-store" }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || "请求失败"); + setData(json); + } catch (e: unknown) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [weeks]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const triggerRun = useCallback( + async (mode: "week" | "force") => { + setRunning(true); + setRunResult(null); + try { + const res = await fetch("/api/admin/bots/adversarial-learning/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lookback: 7, force: mode === "force" }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || "运行失败"); + setRunResult(`✓ exitCode=${json.exitCode} duration=${json.duration}ms`); + await fetchData(); + } catch (e: unknown) { + setRunResult(`✗ ${(e as Error).message}`); + } finally { + setRunning(false); + } + }, + [fetchData] + ); + + const toggle = useCallback((id: number) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const groupedByWeek = useMemo(() => { + const map = new Map(); + if (!data) return map; + for (const l of data.learnings) { + if (!map.has(l.weekKey)) map.set(l.weekKey, []); + map.get(l.weekKey)!.push(l); + } + return map; + }, [data]); + + return ( +
+
+
+

+ + Bot 对抗学习看板 +

+

+ 每周日 23:00 自动给每个 bot 选 1 篇过去 7 天真人发布的高互动帖,LLM 提取"为什么火"的洞察,下周 bot 发帖/回复时作为参考 +

+
+
+ + + + +
+
+ + {runResult && ( +
+ {runResult} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {data && ( +
+ } label="学习总数" value={data.summary.totalLearnings} accent="text-violet-600" /> + } label="生效中" value={data.summary.activeCount} accent="text-emerald-600" /> + } label="引用次数" value={data.summary.usedCount} accent="text-amber-600" /> + } label="平均关联度" value={data.summary.avgRelevance.toFixed(2)} accent="text-rose-600" /> + } label="参与 bot" value={data.summary.botCount} accent="text-sky-600" /> +
+ )} + +
+
学习源:过去 7 天 真人(非 bot)发布的高互动 topic(reply+like+humanReplies 排序)
+
·
+
关联度 = 同板块(1.0) + 关键词重叠(0.6) + 时效衰减(0.3)
+
·
+
每周 1 条,unique(botId, weekKey),默认 7 天后过期
+
+ + {/* 按周分组 */} + {data && data.learnings.length === 0 && ( +
+ 还没有对抗学习数据。运行 node scripts/bot-adversarial-learning-run.mjs 开始第一周,或点击"本周学习"按钮。 +
+ )} + + {Array.from(groupedByWeek.entries()).map(([weekKey, items]) => ( +
+

+ + {weekKey.split("-W")[1]} + + 第 {weekKey.split("-W")[1]} 周 ({weekKey}) + · {items.length} 条学习 +

+ +
+ {items.map((l) => { + const isExpanded = expanded.has(l.id); + const statusMeta = STATUS_META[l.status] || STATUS_META.active; + return ( +
+ + + {isExpanded && ( +
+
+
📌 来源真人帖
+
{l.sourceTitle}
+ {l.sourceUserName && ( +
作者:{l.sourceUserName}
+ )} +
+ +
+
🧠 LLM 提取的洞察
+
{l.learnedInsight}
+ {l.learnCategories.length > 0 && ( +
+ {l.learnCategories.map((c) => { + const m = CATEGORY_META[c] || { label: c, color: "text-slate-600", bg: "bg-slate-100" }; + return ( + + {m.label} + + ); + })} +
+ )} +
+ +
+ 生效窗口:{l.learnedAt.slice(0, 16)} → {l.expiresAt?.slice(0, 16) || "永久"} +
+
+ )} +
+ ); + })} +
+
+ ))} + +
+ + ← 返回 Bot 表现分析 + +
+
+ ); +} + +function SummaryCard({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number | string; accent: string }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} diff --git a/src/app/(admin)/admin/bot-adversarial-learning/page.tsx b/src/app/(admin)/admin/bot-adversarial-learning/page.tsx new file mode 100644 index 0000000..602a8c7 --- /dev/null +++ b/src/app/(admin)/admin/bot-adversarial-learning/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; +import BotAdversarialLearningClient from "./BotAdversarialLearningClient"; + +export const dynamic = "force-dynamic"; +export const metadata = { + title: "Bot 对抗学习看板 · 管理后台", +}; + +export default function BotAdversarialLearningPage() { + return ( + 加载中... + } + > + + + ); +} diff --git a/src/app/(admin)/admin/bot-analytics/BotAnalyticsClient.tsx b/src/app/(admin)/admin/bot-analytics/BotAnalyticsClient.tsx new file mode 100644 index 0000000..353aafe --- /dev/null +++ b/src/app/(admin)/admin/bot-analytics/BotAnalyticsClient.tsx @@ -0,0 +1,764 @@ +"use client"; + +import { useEffect, useState, useMemo, useCallback } from "react"; +import Link from "next/link"; +import { + Activity, + TrendingUp, + Heart, + MessageSquare, + Bot as BotIcon, + Brain, + ArrowUpRight, + Trophy, + AlertCircle, + Zap, + Download, + X, +} from "lucide-react"; +import BotAvatar from "@/components/forum/BotAvatar"; +import { cn } from "@/lib/utils"; + +interface BotStat { + userId: number; + name: string; + avatarUrl: string | null; + points: number; + level: string | null; + botConfigId: number | null; + primaryForums: string[]; + topicCreated: number; + replySent: number; + followUpSent: number; + repliesReceived: number; + likesReceived: number; + humanInteractions: number; + feedbackProcessed: number; + memoryCount: number; + avgImportance: number; + personaQuestionRatio: number; + personaSampleCount: number; + affinityCount: number; + avgAffinity: number; + healthScore: number; + status: "active" | "warming" | "fresh" | "idle"; +} + +interface TrendPoint { + date: string; + topicCreated: number; + replySent: number; + likesReceived: number; + humanInteractions: number; +} + +interface Summary { + totalBots: number; + activeBots: number; + warmingBots: number; + idleBots: number; + totalTopics: number; + totalReplies: number; + totalLikes: number; + totalHumanInteractions: number; + totalMemories: number; + avgHealthScore: number; + days: number; +} + +const STATUS_META: Record = { + active: { label: "活跃", color: "text-emerald-700", bg: "bg-emerald-50", ring: "ring-emerald-200" }, + warming: { label: "热身中", color: "text-amber-700", bg: "bg-amber-50", ring: "ring-amber-200" }, + fresh: { label: "新人", color: "text-sky-700", bg: "bg-sky-50", ring: "ring-sky-200" }, + idle: { label: "沉默", color: "text-slate-500", bg: "bg-slate-50", ring: "ring-slate-200" }, +}; + +const RANGE_OPTIONS = [ + { value: 7, label: "近 7 天" }, + { value: 14, label: "近 14 天" }, + { value: 30, label: "近 30 天" }, +]; + +export default function BotAnalyticsClient() { + const [stats, setStats] = useState([]); + const [trends, setTrends] = useState([]); + const [perBotTrend, setPerBotTrend] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [days, setDays] = useState(14); + const [filter, setFilter] = useState<"all" | "active" | "warming" | "fresh" | "idle">("all"); + const [selectedBot, setSelectedBot] = useState(null); + const [perBotLoading, setPerBotLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetch(`/api/admin/bots/stats?days=${days}`) + .then((r) => r.json()) + .then((data) => { + setStats(data.stats || []); + setTrends(data.trends || []); + setSummary(data.summary || null); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [days]); + + // 选中 bot 时单独拉趋势 + useEffect(() => { + if (!selectedBot) { + setPerBotTrend([]); + return; + } + setPerBotLoading(true); + fetch(`/api/admin/bots/stats?days=${days}&botUserId=${selectedBot.userId}`) + .then((r) => r.json()) + .then((data) => { + setPerBotTrend(data.perBotTrend || []); + }) + .catch(() => setPerBotTrend([])) + .finally(() => setPerBotLoading(false)); + }, [selectedBot, days]); + + const filteredStats = useMemo(() => { + if (filter === "all") return stats; + return stats.filter((s) => s.status === filter); + }, [stats, filter]); + + const topPerformers = stats.slice(0, 5); + const needsAttention = useMemo(() => { + return stats + .filter((s) => s.status === "idle" && s.topicCreated === 0 && s.replySent === 0) + .slice(0, 6); + }, [stats]); + + // 找出活跃高峰日(真人互动最多的一天) + const peakDay = useMemo(() => { + if (trends.length === 0) return null; + return trends.reduce( + (max, t) => (t.humanInteractions > max.humanInteractions ? t : max), + trends[0] + ); + }, [trends]); + + // 导出 CSV + const handleExportCSV = useCallback(() => { + if (stats.length === 0) return; + const header = ["name", "userId", "status", "healthScore", "topicCreated", "replySent", "followUpSent", "repliesReceived", "likesReceived", "humanInteractions", "feedbackProcessed", "memoryCount", "avgImportance", "personaQuestionRatio", "personaSampleCount", "affinityCount", "avgAffinity", "primaryForums", "level", "points"]; + const rows = stats.map((s) => [ + `"${(s.name || "").replace(/"/g, "''")}"`, + s.userId, + s.status, + s.healthScore, + s.topicCreated, + s.replySent, + s.followUpSent, + s.repliesReceived, + s.likesReceived, + s.humanInteractions, + s.feedbackProcessed, + s.memoryCount, + s.avgImportance, + s.personaQuestionRatio, + s.personaSampleCount, + s.affinityCount, + s.avgAffinity, + `"${s.primaryForums.join("|")}"`, + s.level || "", + s.points, + ].join(",")); + const csv = [header.join(","), ...rows].join("\n"); + const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `bot-stats-${days}d-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [stats, days]); + + return ( +
+ {/* Header */} +
+
+

+
+ +
+ Bot 表现分析 +

+

+ 综合健康分 = 话题×3 + 回复×1 + 获赞×2 + 真人互动×5 + {peakDay && peakDay.humanInteractions > 0 && ( + + · 高峰日 {peakDay.date}({peakDay.humanInteractions} 次真人互动) + + )} +

+
+
+ +
+ {RANGE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + {/* Summary Cards */} + {summary && ( +
+ } + accent="from-emerald-50 to-white" + /> + } + accent="from-sky-50 to-white" + /> + } + accent="from-rose-50 to-white" + /> + } + accent="from-violet-50 to-white" + /> +
+ )} + + {/* Trends */} +
+
+

+ + Bot 每日产出趋势 +

+
+ + + + +
+
+ +
+ +
+ {/* Top Performers */} +
+
+

+ + Top 5 健康分 +

+ + 完整榜单 + +
+
+ {loading + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : topPerformers.map((s, i) => ( + setSelectedBot(s)} + /> + ))} +
+
+ + {/* Needs Attention */} + +
+ + {/* Full Table */} +
+
+

+ + 全部 Bot 表现 +

+
+ {(["all", "active", "warming", "fresh", "idle"] as const).map((f) => ( + + ))} +
+
+ + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : filteredStats.length === 0 ? ( +
+

没有符合条件的 bot

+
+ ) : ( +
+ {filteredStats.map((s) => ( + setSelectedBot(s)} /> + ))} +
+ )} +
+ + {selectedBot && ( + setSelectedBot(null)} + /> + )} +
+ ); +} + +function SummaryCard({ + label, + value, + sub, + icon, + accent, +}: { + label: string; + value: number | string; + sub: string; + icon: React.ReactNode; + accent: string; +}) { + return ( +
+
+ {label} + {icon} +
+
{value}
+
{sub}
+
+ ); +} + +function Legend({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ); +} + +function TrendChart({ trends }: { trends: TrendPoint[] }) { + if (trends.length === 0) { + return ( +
+ 暂无趋势数据 +
+ ); + } + const max = Math.max( + ...trends.map((t) => Math.max(t.topicCreated, t.replySent, t.likesReceived, t.humanInteractions)) + , 1); + + return ( +
+
+ {trends.map((t) => ( +
+
0 ? 2 : 0 }} + title={`${t.date}: ${t.humanInteractions} 真人互动`} + /> +
0 ? 2 : 0 }} + title={`${t.date}: ${t.likesReceived} 获赞`} + /> +
0 ? 2 : 0 }} + title={`${t.date}: ${t.replySent} 回复`} + /> +
0 ? 2 : 0 }} + title={`${t.date}: ${t.topicCreated} 话题`} + /> + {/* Tooltip */} +
+ {t.date}: {t.topicCreated} 帖 / {t.replySent} 回复 / {t.likesReceived} 赞 / {t.humanInteractions} 互动 +
+
+ ))} +
+
+ {trends[0]?.date} + {trends[trends.length - 1]?.date} +
+
+ ); +} + +function TopPerformerRow({ + stat, + rank, + onClick, +}: { + stat: BotStat; + rank: number; + onClick?: () => void; +}) { + const meta = STATUS_META[stat.status]; + return ( +
+
+ {rank} +
+ + + + +
+

{stat.healthScore}

+ {meta.label} +
+
+ ); +} + +function StatRow({ stat, onClick }: { stat: BotStat; onClick?: () => void }) { + const meta = STATUS_META[stat.status]; + return ( +
+ + + + +
+

{stat.healthScore}

+

健康分

+
+
+ ); +} + +function BotDetailModal({ + bot, + trend, + loading, + days, + onClose, +}: { + bot: BotStat; + trend: TrendPoint[]; + loading: boolean; + days: number; + onClose: () => void; +}) { + const meta = STATUS_META[bot.status]; + const totalActivity = trend.reduce( + (s, t) => s + t.topicCreated + t.replySent + t.likesReceived + t.humanInteractions, + 0 + ); + + return ( +
+
e.stopPropagation()} + > +
+
+ +
+

{bot.name}

+

+ {meta.label} + · 健康分 {bot.healthScore} + · 等级 {bot.level || "—"} +

+
+
+ +
+ +
+
+ } accent="bg-sky-50" /> + } accent="bg-emerald-50" /> + } accent="bg-rose-50" /> + } accent="bg-amber-50" /> +
+ +
+ + + + + + + + +
+ + {bot.primaryForums.length > 0 && ( +
+

主板块

+
+ {bot.primaryForums.map((f) => ( + + {f} + + ))} +
+
+ )} + +
+
+

近 {days} 天每日趋势

+

累计活动 {totalActivity}

+
+ {loading ? ( +
+ ) : trend.length === 0 ? ( +
+ 该 bot 在此周期内没有活动 +
+ ) : ( + + )} +
+
+
+
+ ); +} + +function ModalStat({ + label, + value, + icon, + accent, +}: { + label: string; + value: number; + icon: React.ReactNode; + accent: string; +}) { + return ( +
+

+ {icon} + {label} +

+

{value}

+
+ ); +} + +function DetailPair({ label, value }: { label: string; value: string | number }) { + return ( +
+ {label} + {value} +
+ ); +} + +function PerBotTrendBars({ trend }: { trend: TrendPoint[] }) { + const max = Math.max( + ...trend.map((t) => Math.max(t.topicCreated, t.replySent, t.likesReceived, t.humanInteractions)), + 1 + ); + return ( +
+ {trend.map((t) => { + const total = t.topicCreated + t.replySent + t.likesReceived + t.humanInteractions; + return ( +
+
0 ? 3 : 0 }} + /> +
+ {t.date.slice(5)}: {t.topicCreated} 帖 / {t.replySent} 回复 +
+
+ ); + })} +
+ ); +} diff --git a/src/app/(admin)/admin/bot-analytics/page.tsx b/src/app/(admin)/admin/bot-analytics/page.tsx new file mode 100644 index 0000000..a04666f --- /dev/null +++ b/src/app/(admin)/admin/bot-analytics/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; +import BotAnalyticsClient from "./BotAnalyticsClient"; + +export const dynamic = "force-dynamic"; +export const metadata = { + title: "Bot 表现分析 · 管理后台", +}; + +export default function BotAnalyticsPage() { + return ( + 加载中...
+ } + > + + + ); +} diff --git a/src/app/(admin)/admin/bot-experiments/BotExperimentsClient.tsx b/src/app/(admin)/admin/bot-experiments/BotExperimentsClient.tsx new file mode 100644 index 0000000..db8fa77 --- /dev/null +++ b/src/app/(admin)/admin/bot-experiments/BotExperimentsClient.tsx @@ -0,0 +1,611 @@ +"use client"; + +import { useEffect, useState, useMemo, useCallback } from "react"; +import Link from "next/link"; +import { + FlaskConical, + Trophy, + Activity, + TrendingUp, + Heart, + MessageSquare, + Eye, + Sparkles, + RefreshCw, + Play, + Bot as BotIcon, + ChevronDown, + ChevronUp, + Check, + X, + AlertCircle, + BarChart3, + Sigma, +} from "lucide-react"; +import BotAvatar from "@/components/forum/BotAvatar"; +import { cn } from "@/lib/utils"; + +interface VariantDaily { + date: string; + contentCount: number; + replies: number; + likes: number; + humanReplies: number; + avgReplies: number; + avgLikes: number; + computedScore: number; +} + +interface Variant { + id: number; + botId: number; + botUserId: number | null; + botName: string; + botAvatar: string | null; + primaryForums: string[]; + variantKey: string; + label: string; + description: string | null; + isControl: boolean; + isActive: boolean; + weight: number; + experimentId: number | null; + sampleCount: number; + replyCount: number; + likeCount: number; + humanReplyCount: number; + engagementScore: number; + computedScore: number; + styleHints: unknown; + daily: VariantDaily[]; + totalCounts: { replies: number; likes: number; humanReplies: number; impressions: number }; + updatedAt: string; +} + +interface Comparison { + variantKey: string; + label: string; + computedScore: number; + controlRate?: number; + sampleCount: number; + zScore?: number; + pValue?: number; + significant?: boolean; + winner?: boolean; + status: "winning" | "losing" | "monitoring" | "no_data"; +} + +interface Analysis { + botId: number; + botUserId: number | null; + botName: string; + botAvatar: string | null; + control: { variantKey: string; label: string; sampleCount: number; computedScore: number }; + comparisons: Comparison[]; + winner: { variantKey: string; pValue: number; sampleCount: number } | null; + status: "winner_found" | "no_significant_winner" | "collecting_data"; +} + +interface Experiment { + id: number; + botId: number; + name: string; + description: string | null; + status: string; + winnerVariantKey: string | null; + startedAt: string; + endedAt: string | null; + lastAnalyzedAt: string | null; + minSamplesPerArm: number; + significanceLevel: number; +} + +interface Summary { + totalBots: number; + totalVariants: number; + totalExperiments: number; + activeExperiments: number; + completedExperiments: number; + winnersFound: number; + totalAssignments: number; + totalImpressions: number; + totalReplies: number; + totalLikes: number; + totalHumanReplies: number; + days: number; +} + +const RANGE_OPTIONS = [ + { value: 3, label: "近 3 天" }, + { value: 7, label: "近 7 天" }, + { value: 14, label: "近 14 天" }, + { value: 30, label: "近 30 天" }, +]; + +const STATUS_META: Record = { + winner_found: { label: "已决出", color: "text-emerald-700", bg: "bg-emerald-50", icon: }, + no_significant_winner: { label: "差异不显著", color: "text-amber-700", bg: "bg-amber-50", icon: }, + collecting_data: { label: "收集中", color: "text-sky-700", bg: "bg-sky-50", icon: }, +}; + +const COMP_STATUS_META: Record = { + winning: { label: "胜出", color: "text-emerald-700", bg: "bg-emerald-50" }, + losing: { label: "落后", color: "text-rose-700", bg: "bg-rose-50" }, + monitoring: { label: "观察中", color: "text-slate-600", bg: "bg-slate-50" }, + no_data: { label: "无数据", color: "text-slate-400", bg: "bg-slate-50" }, +}; + +export default function BotExperimentsClient() { + const [days, setDays] = useState(7); + const [data, setData] = useState<{ + summary: Summary; + variants: Variant[]; + experiments: Experiment[]; + analysis: Analysis[]; + } | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedBots, setExpandedBots] = useState>(new Set()); + const [running, setRunning] = useState(false); + const [runResult, setRunResult] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/admin/bots/experiments?days=${days}`, { cache: "no-store" }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || "请求失败"); + setData(json); + } catch (e: unknown) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [days]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const triggerRun = useCallback(async (mode: "all" | "metrics" | "analyze") => { + setRunning(true); + setRunResult(null); + try { + const res = await fetch("/api/admin/bots/experiments/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, lookback: days }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || "运行失败"); + setRunResult(`✓ exitCode=${json.exitCode} duration=${json.duration}ms mode=${json.mode}`); + await fetchData(); + } catch (e: unknown) { + setRunResult(`✗ ${(e as Error).message}`); + } finally { + setRunning(false); + } + }, [days, fetchData]); + + const toggleBot = useCallback((botId: number) => { + setExpandedBots((prev) => { + const next = new Set(prev); + if (next.has(botId)) next.delete(botId); + else next.add(botId); + return next; + }); + }, []); + + const updateVariant = useCallback(async (variantId: number, patch: Record) => { + try { + const res = await fetch("/api/admin/bots/experiments/variants", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ variantId, ...patch }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || "更新失败"); + await fetchData(); + } catch (e: unknown) { + alert("更新失败: " + (e as Error).message); + } + }, [fetchData]); + + const variantsByBot = useMemo(() => { + const map = new Map(); + if (!data) return map; + for (const v of data.variants) { + if (!map.has(v.botId)) map.set(v.botId, []); + map.get(v.botId)!.push(v); + } + for (const list of Array.from(map.values())) { + list.sort((a: Variant, b: Variant) => b.sampleCount - a.sampleCount); + } + return map; + }, [data]); + + return ( +
+ {/* Header */} +
+
+

+ + Bot Persona A/B 看板 +

+

+ 每个 bot 同时跑 2-3 个 persona 变体,统计真实互动分(赞×3 + 真人回复×5 + 回复),显著性检验后自动切换 winner 权重 +

+
+
+ + + + +
+
+ + {runResult && ( +
+ {runResult} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Summary Cards */} + {data && ( +
+ } label="Bot 数" value={data.summary.totalBots} accent="text-violet-600" /> + } label="变体数" value={data.summary.totalVariants} accent="text-sky-600" /> + } label="活跃实验" value={data.summary.activeExperiments} accent="text-emerald-600" /> + } label="已决出 winner" value={data.summary.winnersFound} accent="text-amber-600" /> + } label="总样本" value={data.summary.totalAssignments} accent="text-rose-600" /> + } label="总互动" value={data.summary.totalReplies + data.summary.totalLikes} accent="text-pink-600" /> +
+ )} + + {/* Legend */} +
+
综合分= (赞×3 + 真人回复×5 + 回复) ÷ 样本数
+
·
+
显著性= z 检验,p<0.1 且样本≥20
+
·
+
切换= winner 权重提到 0.85,其他降到 0.075,experiment → completed
+
+ + {/* Analysis List */} + {data && data.analysis.length > 0 && ( +
+

+ + 显著性分析({data.analysis.length} 个 bot) +

+
+ {data.analysis.map((a) => { + const variants = variantsByBot.get(a.botId) || []; + const expanded = expandedBots.has(a.botId); + const meta = STATUS_META[a.status]; + return ( +
+ + + {expanded && ( +
+
+ {a.comparisons.map((c) => { + const v = variants.find((x) => x.variantKey === c.variantKey); + if (!v) return null; + const cm = COMP_STATUS_META[c.status]; + return ( +
+
+
+ {c.label} + {v.isControl && ( + control + )} + + {cm.label} + + {c.winner && } +
+
+ 样本 {c.sampleCount} · z={c.zScore} · p={c.pValue} +
+
+
+ + + + + +
+ {(() => { + const hints = v.styleHints as Record | null; + if (!hints || typeof hints !== "object" || Object.keys(hints).length === 0) return null; + return ( +
+ style hints +
+                                      {JSON.stringify(hints, null, 2)}
+                                    
+
+ ); + })()} +
+ + + +
+
+ ); + })} +
+
+ )} +
+ ); + })} +
+
+ )} + + {/* Experiments Timeline */} + {data && data.experiments.length > 0 && ( +
+

+ + 实验状态({data.experiments.length} 个) +

+
+ + + + + + + + + + + + + + + {data.experiments.slice(0, 20).map((e) => { + const botConfig = data.variants.find((v) => v.botId === e.botId); + return ( + + + + + + + + + + + ); + })} + +
#Bot状态winnermin samplesp 阈值开始最近分析
#{e.id}{botConfig?.botName || `bot#${e.botId}`} + + {e.status} + + {e.winnerVariantKey || "—"}{e.minSamplesPerArm}{e.significanceLevel}{e.startedAt?.slice(0, 16)}{e.lastAnalyzedAt?.slice(0, 16) || "—"}
+
+
+ )} + + {/* Variants Detail Table */} + {data && data.variants.length > 0 && ( +
+

+ + 变体总览({data.variants.length} 条) +

+
+ + + + + + + + + + + + + + + + {data.variants + .slice() + .sort((a, b) => b.computedScore - a.computedScore) + .slice(0, 60) + .map((v) => ( + + + + + + + + + + + + ))} + +
Bot变体样本收赞收真人收回复综合分权重状态
+
+ {v.botAvatar ? ( + {v.botName} + ) : ( +
+ {v.botName?.charAt(0) || "?"} +
+ )} + {v.botName} +
+
+
+ {v.variantKey} + {v.isControl && ( + ctrl + )} +
+
{v.sampleCount}{v.likeCount}{v.humanReplyCount}{v.replyCount} + {v.computedScore.toFixed(2)} + {v.weight.toFixed(2)} + + {v.isActive ? "启用" : "停用"} + +
+
+
+ )} + + {data && data.analysis.length === 0 && ( +
+ 还没有 A/B 测试数据。先调用 node scripts/seed-persona-variants.mjs 初始化变体,再等 bot 跑几轮后采集数据。 +
+ )} + +
+ + ← 返回 Bot 表现分析 + +
+
+ ); +} + +function SummaryCard({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: number; accent: string }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} + +function MetricCell({ label, value, highlight = false }: { label: string; value: string | number; highlight?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/app/(admin)/admin/bot-experiments/page.tsx b/src/app/(admin)/admin/bot-experiments/page.tsx new file mode 100644 index 0000000..49a1508 --- /dev/null +++ b/src/app/(admin)/admin/bot-experiments/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; +import BotExperimentsClient from "./BotExperimentsClient"; + +export const dynamic = "force-dynamic"; +export const metadata = { + title: "Bot Persona A/B 看板 · 管理后台", +}; + +export default function BotExperimentsPage() { + return ( + 加载中...
+ } + > + + + ); +} diff --git a/src/app/(admin)/admin/forum/page.tsx b/src/app/(admin)/admin/forum/page.tsx new file mode 100644 index 0000000..883a4c2 --- /dev/null +++ b/src/app/(admin)/admin/forum/page.tsx @@ -0,0 +1,141 @@ +import Link from "next/link"; +import { Flag, MessageSquare, Sparkles, Lock, EyeOff, Activity, ShieldCheck, ArrowRight } from "lucide-react"; +import { prisma } from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getAllUserPermissions } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +export default async function AdminForumHome() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/admin/login"); + const perms = await getAllUserPermissions(); + const userRole = (session.user as { role?: string }).role || ""; + if (userRole !== "admin" && !perms.keys.has("forum:moderate")) { + return ( +
+ 缺少 forum:moderate 权限 +
+ ); + } + + const [pendingReports, hiddenTopics, lockedTopics, todayNew, recentLogs] = await Promise.all([ + prisma.forumReport.count({ where: { status: "pending" } }), + prisma.forumTopic.count({ where: { isHidden: true } }), + prisma.forumTopic.count({ where: { isLocked: true } }), + prisma.forumTopic.count({ + where: { createdAt: { gte: new Date(new Date().setHours(0, 0, 0, 0)) } }, + }), + prisma.forumModerationLog.findMany({ + orderBy: { createdAt: "desc" }, + take: 10, + include: { actor: { select: { id: true, name: true, email: true } } }, + }), + ]); + + const stats = [ + { label: "待处理举报", value: pendingReports, icon: Flag, href: "/admin/forum/reports", color: "text-rose-600", bg: "bg-rose-50" }, + { label: "已隐藏话题", value: hiddenTopics, icon: EyeOff, href: "/admin/forum/topics?status=hidden", color: "text-slate-600", bg: "bg-slate-50" }, + { label: "已锁定话题", value: lockedTopics, icon: Lock, href: "/admin/forum/topics?status=locked", color: "text-slate-600", bg: "bg-slate-50" }, + { label: "今日新话题", value: todayNew, icon: Sparkles, href: "/admin/forum/topics", color: "text-emerald-600", bg: "bg-emerald-50" }, + ]; + + return ( +
+
+
+

+ + 论坛管理 +

+

+ 维护社区秩序:审核举报、控制话题状态、查看操作日志 +

+
+
+ +
+ {stats.map((s) => ( + +
+ +
+
{s.value}
+
{s.label}
+ + ))} +
+ +
+
+
+

+ + 快速入口 +

+
+
+ + +
+
举报审核队列
+
处理用户举报,决定隐藏/删除/忽略
+
+ + + + +
+
话题批量管理
+
置顶/精华/锁定/隐藏/删除
+
+ + +
+
+ +
+
+

+ + 最近操作日志 +

+
+
+ {recentLogs.length === 0 ? ( +
暂无操作
+ ) : ( + recentLogs.map((log) => ( +
+
+ #{log.id} + + {log.action} + + · + {log.targetType} #{log.targetId} +
+
+ {log.actor.name || log.actor.email.split("@")[0]} · {new Date(log.createdAt).toLocaleString("zh-CN")} +
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/app/(admin)/admin/forum/reports/ReportsClient.tsx b/src/app/(admin)/admin/forum/reports/ReportsClient.tsx new file mode 100644 index 0000000..c9ab153 --- /dev/null +++ b/src/app/(admin)/admin/forum/reports/ReportsClient.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Flag, Loader2, Eye, EyeOff, Trash2, X, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type ReportRow = { + id: number; + reporterId: number; + topicId: number | null; + postId: number | null; + reason: string; + detail: string | null; + status: string; + resolution: string | null; + resolvedBy: number | null; + resolvedAt: string | null; + createdAt: string; + reporter: { id: number; name: string | null; email: string; avatarUrl: string | null; level?: string; isBot?: boolean }; + resolver: { id: number; name: string | null; email: string } | null; + topic: { + id: number; + title: string; + isHidden: boolean; + userId: number; + user: { id: number; name: string | null; email: string }; + } | null; + post: { + id: number; + content: string; + isHidden: boolean; + topicId: number; + userId: number; + user: { id: number; name: string | null; email: string }; + } | null; +}; + +const REASON_LABEL: Record = { + spam: "垃圾广告", + abuse: "人身攻击", + inappropriate: "不当内容", + illegal: "违法违规", + duplicate: "重复内容", + other: "其他", +}; + +export default function ReportsClient() { + const params = useSearchParams(); + const router = useRouter(); + const status = params.get("status") || "pending"; + const page = parseInt(params.get("page") || "1", 10); + + const [reports, setReports] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + const [operating, setOperating] = useState(null); + const [error, setError] = useState(""); + const [preview, setPreview] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const res = await fetch( + `/api/admin/forum/reports?status=${status}&page=${page}&pageSize=20` + ); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + setError(d.error || "加载失败"); + setReports([]); + return; + } + const d = await res.json(); + setReports(d.reports || []); + setTotalCount(d.totalCount || 0); + } catch { + setError("网络错误"); + } finally { + setLoading(false); + } + }, [status, page]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleResolve = async (report: ReportRow, resolution: "hidden" | "dismissed" | "deleted") => { + if (resolution === "deleted" && !confirm("确认删除该内容?此操作不可恢复。")) return; + setOperating(report.id); + try { + const res = await fetch(`/api/admin/forum/reports/${report.id}/resolve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ resolution }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + alert(d.error || "处理失败"); + return; + } + await fetchData(); + } catch { + alert("网络错误"); + } finally { + setOperating(null); + } + }; + + const handleTopicAction = async (topicId: number, action: string) => { + if (action === "delete" && !confirm("确认删除该话题?此操作不可恢复。")) return; + setOperating(-topicId); + try { + const res = await fetch(`/api/admin/forum/topics/${topicId}/moderate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, reason: "管理员操作" }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + alert(d.error || "操作失败"); + return; + } + await fetchData(); + } catch { + alert("网络错误"); + } finally { + setOperating(null); + } + }; + + const statusTabs = [ + { key: "pending", label: "待处理" }, + { key: "resolved", label: "已处理" }, + { key: "all", label: "全部" }, + ]; + + return ( +
+
+
+

举报队列

+

+ 审核用户举报的违规内容,决定隐藏/删除或忽略 +

+
+
+ 共 {totalCount} 条 +
+
+ +
+ {statusTabs.map((tab) => ( + + ))} +
+ + {error && ( +
+ {error} +
+ )} + +
+ {loading ? ( +
+ + 加载中… +
+ ) : reports.length === 0 ? ( +
+ +

+ {status === "pending" ? "当前没有待处理的举报" : "暂无数据"} +

+
+ ) : ( +
    + {reports.map((r) => { + const targetIsTopic = !!r.topic; + const target = r.topic || r.post; + const author = target?.user; + const isOperating = operating === r.id || (target && operating === -target.id); + return ( +
  • +
    +
    + {targetIsTopic ? "📝" : "💬"} +
    + +
    +
    + #{r.id} + + {REASON_LABEL[r.reason] || r.reason} + + {targetIsTopic ? ( + 话题举报 + ) : ( + 回复举报 + )} + · + 举报人 {r.reporter.name || r.reporter.email.split("@")[0]} + · + {new Date(r.createdAt).toLocaleString("zh-CN")} + {r.status === "resolved" && ( + + {r.resolution === "hidden" ? "已隐藏" : r.resolution === "deleted" ? "已删除" : "已忽略"} + + )} +
    + + {r.detail && ( +
    + 说明: + {r.detail} +
    + )} + +
    + {targetIsTopic ? ( + +
    + 话题 · {author?.name || author?.email.split("@")[0]} +
    +
    + {r.topic!.title} + {r.topic!.isHidden && ( + + 已隐藏 + + )} +
    + + ) : ( + +
    + 回复 · {author?.name || author?.email.split("@")[0]} +
    +
    + {r.post!.content} + {r.post!.isHidden && ( + + 已隐藏 + + )} +
    + + )} +
    + +
    + {r.status === "pending" ? ( + <> + + + + + ) : ( + + {r.resolver?.name || r.resolver?.email} 处理于 {r.resolvedAt && new Date(r.resolvedAt).toLocaleString("zh-CN")} + + )} + + +
    +
    +
    +
  • + ); + })} +
+ )} +
+ + {/* 预览抽屉 */} + {preview && ( +
setPreview(null)} + > +
e.stopPropagation()} + > +
+

举报详情 · #{preview.id}

+ +
+
+
+
举报原因
+
+ {REASON_LABEL[preview.reason] || preview.reason} +
+
+ {preview.detail && ( +
+
补充说明
+
+ {preview.detail} +
+
+ )} + {preview.topic && ( +
+
被举报话题
+ +
{preview.topic.title}
+ +
+ )} + {preview.post && ( +
+
被举报回复
+ + {preview.post.content} + +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/app/(admin)/admin/forum/reports/page.tsx b/src/app/(admin)/admin/forum/reports/page.tsx new file mode 100644 index 0000000..8e9c097 --- /dev/null +++ b/src/app/(admin)/admin/forum/reports/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import ReportsClient from "./ReportsClient"; + +export const dynamic = "force-dynamic"; + +export default function ReportsPage() { + return ( + 加载中…
}> + + + ); +} diff --git a/src/app/(admin)/admin/forum/topics/TopicsClient.tsx b/src/app/(admin)/admin/forum/topics/TopicsClient.tsx new file mode 100644 index 0000000..b36c86c --- /dev/null +++ b/src/app/(admin)/admin/forum/topics/TopicsClient.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Pin, PinOff, Sparkles, Lock, Unlock, EyeOff, Eye, Trash2, Loader2, Search, MessageSquare, Flag } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type TopicRow = { + id: number; + title: string; + isPinned: boolean; + isLocked: boolean; + isFeatured: boolean; + isHidden: boolean; + viewCount: number; + replyCount: number; + likeCount: number; + updatedAt: string; + createdAt: string; + category: { name: string; slug: string; icon: string | null }; + user: { id: number; name: string | null; email: string; level?: string; isBot?: boolean }; + _count: { posts: number; likes: number; reports: number }; +}; + +export default function TopicsClient() { + const params = useSearchParams(); + const router = useRouter(); + const status = params.get("status") || "all"; + const page = parseInt(params.get("page") || "1", 10); + const q = params.get("q") || ""; + + const [topics, setTopics] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + const [operating, setOperating] = useState(null); + const [searchInput, setSearchInput] = useState(q); + const [error, setError] = useState(""); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const res = await fetch( + `/api/admin/forum/topics?status=${status}&page=${page}&pageSize=20&q=${encodeURIComponent(q)}` + ); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + setError(d.error || "加载失败"); + return; + } + const d = await res.json(); + setTopics(d.topics || []); + setTotalCount(d.totalCount || 0); + } catch { + setError("网络错误"); + } finally { + setLoading(false); + } + }, [status, page, q]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleAction = async (topicId: number, action: string) => { + if (action === "delete" && !confirm("确认删除该话题?此操作不可恢复。")) return; + setOperating(topicId); + try { + const res = await fetch(`/api/admin/forum/topics/${topicId}/moderate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, reason: "管理员操作" }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + alert(d.error || "操作失败"); + return; + } + await fetchData(); + } catch { + alert("网络错误"); + } finally { + setOperating(null); + } + }; + + const statusTabs = [ + { key: "all", label: "全部" }, + { key: "visible", label: "正常" }, + { key: "hidden", label: "已隐藏" }, + { key: "pinned", label: "置顶" }, + { key: "featured", label: "精华" }, + { key: "locked", label: "锁定" }, + ]; + + return ( +
+
+
+

话题管理

+

+ 隐藏、置顶、精华、加锁、删除等管理操作 +

+
+
+ 共 {totalCount} 个话题 +
+
+ +
+
+ {statusTabs.map((tab) => ( + + ))} +
+ +
{ + e.preventDefault(); + const p = new URLSearchParams(params.toString()); + if (searchInput.trim()) p.set("q", searchInput.trim()); + else p.delete("q"); + p.delete("page"); + router.push(`/admin/forum/topics?${p.toString()}`); + }} + className="flex items-center gap-1.5 rounded-full border border-border/50 bg-white px-3 h-8 w-64" + > + + setSearchInput(e.target.value)} + placeholder="搜索标题或正文…" + className="flex-1 min-w-0 bg-transparent text-xs focus:outline-none" + /> + +
+ + {error && ( +
+ {error} +
+ )} + +
+ {loading ? ( +
+ + 加载中… +
+ ) : topics.length === 0 ? ( +
+ 没有符合条件的话题 +
+ ) : ( + + + + + + + + + + + + + {topics.map((t) => { + const authorName = t.user.name || t.user.email.split("@")[0]; + const isOperating = operating === t.id; + return ( + + + + + + + + + ); + })} + +
话题状态作者数据举报操作
+ +
+ {t.category.icon} {t.category.name} · #{t.id} +
+
+ {t.title} +
+ +
+
+ {t.isPinned && 置顶} + {t.isFeatured && 精华} + {t.isLocked && 锁定} + {t.isHidden && 隐藏} + {!t.isPinned && !t.isFeatured && !t.isLocked && !t.isHidden && ( + 普通 + )} +
+
+
{authorName}
+ {t.user.isBot && ( + BOT + )} +
+
+ + {t._count.posts} +
+
浏览 {t.viewCount}
+
+ {t._count.reports > 0 ? ( + + + {t._count.reports} + + ) : ( + 0 + )} + +
+ + + + + +
+
+ )} +
+ + {totalCount > 20 && ( +
+ {Array.from({ length: Math.min(Math.ceil(totalCount / 20), 10) }, (_, i) => i + 1).map((p) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/(admin)/admin/forum/topics/page.tsx b/src/app/(admin)/admin/forum/topics/page.tsx new file mode 100644 index 0000000..5b8e269 --- /dev/null +++ b/src/app/(admin)/admin/forum/topics/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import TopicsClient from "./TopicsClient"; + +export const dynamic = "force-dynamic"; + +export default function AdminTopicsPage() { + return ( + 加载中…
}> + + + ); +} diff --git a/src/app/(admin)/admin/layout.tsx b/src/app/(admin)/admin/layout.tsx index efb1bf0..bddcb85 100644 --- a/src/app/(admin)/admin/layout.tsx +++ b/src/app/(admin)/admin/layout.tsx @@ -89,6 +89,26 @@ const menuGroups: MenuGroup[] = [ { title: "内容管理", items: [ + { + label: "论坛管理", + href: "/admin/forum", + icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z", + }, + { + label: "Bot 表现分析", + href: "/admin/bot-analytics", + icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z", + }, + { + label: "Bot A/B 看板", + href: "/admin/bot-experiments", + icon: "M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z", + }, + { + label: "Bot 对抗学习", + href: "/admin/bot-adversarial-learning", + icon: "M12 14l9-5-9-5-9 5 9 5z M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z", + }, { label: "AI日报管理", href: "/admin/daily-reports", diff --git a/src/app/(admin)/admin/tasks/page.tsx b/src/app/(admin)/admin/tasks/page.tsx index 7660eaa..450bb08 100644 --- a/src/app/(admin)/admin/tasks/page.tsx +++ b/src/app/(admin)/admin/tasks/page.tsx @@ -158,7 +158,11 @@ export default function TasksPage() {

-
+
+ 核心定时任务 + +
+
{["task1-discover-tools", "task3-check-tools", "task4-discover-skills", "task5-update-stars", "task6-review-hot", "daily-news"].map((key) => { const t = tasks.find((x) => x.taskKey === key); const lastLog = getLastRun(logs, key); @@ -178,6 +182,66 @@ export default function TasksPage() { })}
+
+ Bot 体系 + + 数据由 deepseek 驱动,按活跃度分层调度 +
+
+ {["bot-activity", "bot-affinity-update", "bot-feedback-loop", "bot-skill-crystallize", "bot-weekly-review"].map((key) => { + const t = tasks.find((x) => x.taskKey === key); + const lastLog = getLastRun(logs, key); + const st = lastLog ? STATUS_MAP[lastLog.status] : null; + return ( + + +
{t?.taskName || key}
+
+
+ {t?.enabled ? "启用" : "停用"} + {st && {st.label}} +
+ {lastLog && ( +
+ {new Date(lastLog.startedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })} +
+ )} + + + ); + })} +
+ +
+ 维护 + +
+
+ {["task7-news-to-community", "cleanup-task-logs"].map((key) => { + const t = tasks.find((x) => x.taskKey === key); + const lastLog = getLastRun(logs, key); + const st = lastLog ? STATUS_MAP[lastLog.status] : null; + if (!t) return null; + return ( + + +
{t.taskName}
+
+
+ {t.enabled ? "启用" : "停用"} + {st && {st.label}} +
+ {lastLog && ( +
+ {new Date(lastLog.startedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })} +
+ )} + + + ); + })} +
+
{tasks.map((task) => { const lastLog = getLastRun(logs, task.taskKey); diff --git a/src/app/HomePageClient.tsx b/src/app/HomePageClient.tsx index f2189a0..76a3d86 100644 --- a/src/app/HomePageClient.tsx +++ b/src/app/HomePageClient.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { FavoriteButton } from "@/components/common/FavoriteButton"; import { HeroBanner } from "@/components/common/HeroBanner"; +import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants"; interface Tool { id: number; @@ -42,20 +43,6 @@ interface HomePageClientProps { totalSkills: number; } -const pricingLabel: Record = { - free: "免费", - freemium: "免费增值", - paid: "付费", - subscription: "订阅", -}; - -const pricingColor: Record = { - free: "bg-emerald-50 text-emerald-700 border border-emerald-200", - freemium: "bg-blue-50 text-blue-700 border border-blue-200", - paid: "bg-amber-50 text-amber-700 border border-amber-200", - subscription: "bg-purple-50 text-purple-700 border border-purple-200", -}; - export default function HomePageClient({ flatCategories: categories, parentCategories, @@ -349,8 +336,8 @@ export default function HomePageClient({ {tool.name}

- - {pricingLabel[tool.pricingModel] || tool.pricingModel} + + {PRICING_LABEL[tool.pricingModel] || tool.pricingModel}
diff --git a/src/app/api/admin/bots/adversarial-learning/route.ts b/src/app/api/admin/bots/adversarial-learning/route.ts new file mode 100644 index 0000000..ca8a11d --- /dev/null +++ b/src/app/api/admin/bots/adversarial-learning/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// Bot 对抗学习看板数据 +// 返回:最近 N 周的所有 bot adversarial learning 记录,含 source post 摘要、LLM 洞察、关联度 +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const weeks = Math.min(Math.max(parseInt(searchParams.get("weeks") || "4", 10), 1), 26); + const botParam = searchParams.get("botUserId"); + + const since = new Date(Date.now() - weeks * 7 * 24 * 60 * 60 * 1000); + + // 1) 拉所有 bot 基础信息 + const botUsers = await prisma.user.findMany({ + where: { isBot: true }, + select: { id: true, name: true, email: true, avatarUrl: true }, + }); + const botUserById = new Map(botUsers.map((u) => [u.id, u])); + + // 2) 拉所有 botConfig + const botConfigs = await prisma.botConfig.findMany({ + select: { id: true, userId: true, displayName: true, primaryForums: true }, + }); + const configByBotConfigId = new Map(botConfigs.map((c) => [c.id, c])); + + // 3) 拉 learnings + const targetBotConfigIds = botParam + ? botConfigs.filter((c) => c.userId === Number(botParam)).map((c) => c.id) + : null; + const learnings = await prisma.botAdversarialLearning.findMany({ + where: { + learnedAt: { gte: since }, + ...(targetBotConfigIds ? { botId: { in: targetBotConfigIds } } : {}), + }, + orderBy: { learnedAt: "desc" }, + }); + + // 4) 关联 source topic 信息 + const topicIds = Array.from(new Set(learnings.filter((l) => l.sourceRefType === "topic").map((l) => l.sourceRefId))); + const topics = topicIds.length + ? await prisma.forumTopic.findMany({ + where: { id: { in: topicIds } }, + include: { + user: { select: { id: true, name: true, avatarUrl: true, isBot: true } }, + category: { select: { name: true, slug: true } }, + }, + }) + : []; + const topicById = new Map(topics.map((t) => [t.id, t])); + + // 5) enrich + interface Enriched { + id: number; + botId: number; + botUserId: number | null; + botName: string; + botAvatar: string | null; + weekKey: string; + sourceRefType: string; + sourceRefId: number; + sourceTitle: string | null; + sourceUserName: string | null; + sourceUserAvatar: string | null; + sourceMetrics: { replyCount: number; likeCount: number; viewCount: number; humanReplies: number; engagementScore: number }; + relevanceScore: number; + learnedInsight: string; + learnCategories: string[]; + status: string; + usedCount: number; + learnedAt: string; + expiresAt: string | null; + forumSlug: string | null; + categoryName: string | null; + } + + const enriched: Enriched[] = learnings.map((l) => { + const config = configByBotConfigId.get(l.botId); + const userInfo = config ? botUserById.get(config.userId) : null; + const topic = topicById.get(l.sourceRefId); + const m = (l.sourceMetrics as { replyCount?: number; likeCount?: number; viewCount?: number; humanReplies?: number; engagementScore?: number }) || {}; + return { + id: l.id, + botId: l.botId, + botUserId: config?.userId ?? null, + botName: userInfo?.name || config?.displayName || `bot#${l.botId}`, + botAvatar: userInfo?.avatarUrl || null, + weekKey: l.weekKey, + sourceRefType: l.sourceRefType, + sourceRefId: l.sourceRefId, + sourceTitle: l.sourceTitle || topic?.title || null, + sourceUserName: l.sourceUserName || topic?.user?.name || null, + sourceUserAvatar: topic?.user?.avatarUrl || null, + sourceMetrics: { + replyCount: m.replyCount || 0, + likeCount: m.likeCount || 0, + viewCount: m.viewCount || 0, + humanReplies: m.humanReplies || 0, + engagementScore: m.engagementScore || 0, + }, + relevanceScore: l.relevanceScore, + learnedInsight: l.learnedInsight, + learnCategories: (l.learnCategories as string[]) || [], + status: l.status, + usedCount: l.usedCount, + learnedAt: l.learnedAt.toISOString(), + expiresAt: l.expiresAt?.toISOString() || null, + forumSlug: l.forumSlug || topic?.category?.slug || null, + categoryName: topic?.category?.name || null, + }; + }); + + // 6) 汇总 + const summary = { + totalLearnings: learnings.length, + activeCount: learnings.filter((l) => l.status === "active").length, + usedCount: learnings.reduce((s, l) => s + l.usedCount, 0), + avgRelevance: + learnings.length > 0 + ? Math.round((learnings.reduce((s, l) => s + l.relevanceScore, 0) / learnings.length) * 100) / 100 + : 0, + botCount: new Set(learnings.map((l) => l.botId)).size, + weeks, + }; + + return NextResponse.json({ summary, learnings: enriched }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "对抗学习数据获取失败" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/bots/adversarial-learning/run/route.ts b/src/app/api/admin/bots/adversarial-learning/run/route.ts new file mode 100644 index 0000000..6a27905 --- /dev/null +++ b/src/app/api/admin/bots/adversarial-learning/run/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/admin-auth"; +import { spawn } from "child_process"; + +// 手动触发对抗学习调度 +// POST /api/admin/bots/adversarial-learning/run +// body: { weekKey?: string, force?: boolean, lookback?: number, bot?: string } +export async function POST(req: NextRequest) { + const authError = await requireAdmin(); + if (authError) return authError; + try { + const body = await req.json().catch(() => ({})); + const weekKey = body.weekKey || null; + const force = !!body.force; + const lookback = Math.min(Math.max(parseInt(body.lookback || "7", 10), 1), 30); + const bot = body.bot || null; + + const startTime = Date.now(); + const args = ["scripts/bot-adversarial-learning-run.mjs", "--lookback=" + lookback]; + if (weekKey) args.push("--week=" + weekKey); + if (force) args.push("--force"); + if (bot) args.push("--bot=" + bot); + + const cwd = process.cwd(); + + const child = spawn("node", args, { + cwd, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => (stdout += d.toString())); + child.stderr.on("data", (d) => (stderr += d.toString())); + + const exitCode: number = await new Promise((resolveP) => { + child.on("close", (code) => resolveP(code ?? -1)); + }); + + const duration = Date.now() - startTime; + + await prisma.taskLog.create({ + data: { + taskKey: "bot-adversarial-learning-run", + taskName: "Bot 对抗学习(manual)", + status: exitCode === 0 ? "success" : "failed", + startedAt: new Date(startTime), + finishedAt: new Date(), + triggerBy: "admin", + result: { weekKey, force, lookback, bot }, + error: exitCode === 0 ? null : stderr.slice(0, 1000), + }, + }); + + return NextResponse.json({ + ok: exitCode === 0, + exitCode, + duration, + weekKey, + force, + lookback, + bot, + stdout: stdout.slice(-2000), + stderr: stderr.slice(-1000), + }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "运行失败" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/bots/experiments/route.ts b/src/app/api/admin/bots/experiments/route.ts new file mode 100644 index 0000000..0f6c572 --- /dev/null +++ b/src/app/api/admin/bots/experiments/route.ts @@ -0,0 +1,262 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// Bot Persona A/B 测试看板数据 +// 返回:所有变体、最近 7 天每日聚合、实验状态 +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const days = Math.min(Math.max(parseInt(searchParams.get("days") || "7", 10), 1), 60); + const botParam = searchParams.get("botUserId"); + + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + // 1. 拉所有 bot 基础信息 + const botUsers = await prisma.user.findMany({ + where: { isBot: true }, + select: { id: true, name: true, email: true, avatarUrl: true }, + }); + const botUserById = new Map(botUsers.map((u) => [u.id, u])); + + // 2. 拉所有 botConfig + const botConfigs = await prisma.botConfig.findMany({ + select: { id: true, userId: true, displayName: true, primaryForums: true }, + }); + const configByBotConfigId = new Map(botConfigs.map((c) => [c.id, c])); + + // 3. 拉所有变体 + const variantsWhere = botParam + ? { botId: { in: botConfigs.filter((c) => c.userId === Number(botParam)).map((c) => c.id) } } + : undefined; + const variants = await prisma.botPersonaVariant.findMany({ + where: variantsWhere, + orderBy: [{ botId: "asc" }, { sampleCount: "desc" }], + }); + + // 4. 拉所有实验 + const experiments = await prisma.botPersonaExperiment.findMany({ + where: botParam + ? { botId: { in: botConfigs.filter((c) => c.userId === Number(botParam)).map((c) => c.id) } } + : undefined, + orderBy: { startedAt: "desc" }, + }); + + // 5. 拉所有 metric(按变体按天) + const variantIds = variants.map((v) => v.id); + const metrics = await prisma.botPersonaMetric.findMany({ + where: { + variantId: { in: variantIds.length > 0 ? variantIds : [-1] }, + date: { gte: new Date(since.getFullYear(), since.getMonth(), since.getDate()) }, + }, + orderBy: { date: "asc" }, + }); + + // 6. 拉所有 assignment(用于统计真实样本数 / 总互动) + const assignments = await prisma.botPersonaAssignment.findMany({ + where: { + variantId: { in: variantIds.length > 0 ? variantIds : [-1] }, + createdAt: { gte: since }, + }, + }); + + // 7. 汇总每个变体数据 + const enrichedVariants = variants.map((v) => { + const config = configByBotConfigId.get(v.botId); + const userInfo = config ? botUserById.get(config.userId) : null; + const variantMetrics = metrics.filter((m) => m.variantId === v.id); + const variantAssignments = assignments.filter((a) => a.variantId === v.id); + const totals = variantAssignments.reduce( + (acc, a) => { + acc.replies += a.replies; + acc.likes += a.likes; + acc.humanReplies += a.humanReplies; + acc.impressions += a.impressions; + return acc; + }, + { replies: 0, likes: 0, humanReplies: 0, impressions: 0 } + ); + + // 综合分(与 lib 中一致:likes*3 + humanReplies*5 + replies) + const computedScore = v.sampleCount > 0 + ? (v.likeCount * 3 + v.humanReplyCount * 5 + v.replyCount) / v.sampleCount + : 0; + + return { + id: v.id, + botId: v.botId, + botConfigId: v.botId, + botUserId: config?.userId, + botName: userInfo?.name || config?.displayName || `bot#${v.botId}`, + botAvatar: userInfo?.avatarUrl || null, + primaryForums: (config?.primaryForums as string[]) || [], + variantKey: v.variantKey, + label: v.label, + description: v.description, + isControl: v.isControl, + isActive: v.isActive, + weight: v.weight, + experimentId: v.experimentId, + sampleCount: v.sampleCount, + replyCount: v.replyCount, + likeCount: v.likeCount, + humanReplyCount: v.humanReplyCount, + engagementScore: v.engagementScore, + computedScore: Math.round(computedScore * 100) / 100, + styleHints: v.styleHints, + daily: variantMetrics.map((m) => ({ + date: m.date.toISOString().slice(0, 10), + contentCount: m.contentCount, + replies: m.replies, + likes: m.likes, + humanReplies: m.humanReplies, + avgReplies: m.avgReplies, + avgLikes: m.avgLikes, + computedScore: m.computedScore, + })), + totalCounts: totals, + createdAt: v.createdAt, + updatedAt: v.updatedAt, + }; + }); + + // 8. 显著性分析:每个 bot 的 control vs 其它 + const byBot = new Map(); + for (const v of enrichedVariants) { + if (!byBot.has(v.botId)) byBot.set(v.botId, []); + byBot.get(v.botId)!.push(v); + } + + const analysis: Array<{ + botId: number; + botUserId: number | null; + botName: string; + botAvatar: string | null; + control: { variantKey: string; label: string; sampleCount: number; computedScore: number }; + comparisons: Array<{ + variantKey: string; + label: string; + computedScore: number; + controlRate?: number; + sampleCount: number; + zScore?: number; + pValue?: number; + significant?: boolean; + winner?: boolean; + status: "winning" | "losing" | "monitoring" | "no_data"; + }>; + winner: { variantKey: string; pValue: number; sampleCount: number } | null; + status: "winner_found" | "no_significant_winner" | "collecting_data"; + }> = []; + for (const [botId, list] of Array.from(byBot.entries())) { + const control = list.find((v) => v.isControl) || list[0]; + if (!control || control.sampleCount < 1) continue; + const controlRate = control.computedScore; + + const comparisons: Array<{ + variantKey: string; + label: string; + computedScore: number; + controlRate?: number; + sampleCount: number; + zScore?: number; + pValue?: number; + significant?: boolean; + winner?: boolean; + status: "winning" | "losing" | "monitoring" | "no_data"; + }> = list + .filter((v) => v.id !== control.id) + .map((v): { + variantKey: string; + label: string; + computedScore: number; + controlRate?: number; + sampleCount: number; + zScore?: number; + pValue?: number; + significant?: boolean; + winner?: boolean; + status: "winning" | "losing" | "monitoring" | "no_data"; + } => { + if (v.sampleCount < 1) { + return { variantKey: v.variantKey, label: v.label, computedScore: 0, significant: false, sampleCount: 0, status: "no_data" as const }; + } + const rate = v.computedScore; + // 简化 z 检验:使用每内容互动分(连续值)的 pooled SE + const n1 = control.sampleCount; + const n2 = v.sampleCount; + const m1 = controlRate; + const m2 = rate; + const v1 = Math.max(0.0001, control.likeCount + control.humanReplyCount + control.replyCount); + const v2 = Math.max(0.0001, v.likeCount + v.humanReplyCount + v.replyCount); + const pooledVar = ((n1 - 1) * v1 + (n2 - 1) * v2) / Math.max(1, n1 + n2 - 2); + const se = Math.sqrt(pooledVar * (1 / n1 + 1 / n2)); + const z = se > 0 ? (m2 - m1) / se : 0; + const p = 2 * (1 - normalCdf(Math.abs(z))); + const significant = p < 0.1 && n2 >= 20; + return { + variantKey: v.variantKey, + label: v.label, + computedScore: rate, + controlRate: controlRate, + sampleCount: v.sampleCount, + zScore: Math.round(z * 100) / 100, + pValue: Math.round(p * 1000) / 1000, + significant, + winner: significant && rate > controlRate, + status: (significant ? (rate > controlRate ? "winning" : "losing") : "monitoring") as "winning" | "losing" | "monitoring", + }; + }); + + const winner = comparisons.find((c) => c.winner); + analysis.push({ + botId, + botUserId: list[0].botUserId ?? null, + botName: list[0].botName, + botAvatar: list[0].botAvatar, + control: { + variantKey: control.variantKey, + label: control.label, + sampleCount: control.sampleCount, + computedScore: control.computedScore, + }, + comparisons, + winner: winner ? { variantKey: winner.variantKey, pValue: winner.pValue ?? 1, sampleCount: winner.sampleCount } : null, + status: winner ? "winner_found" : (list.every((v) => v.sampleCount >= 20) ? "no_significant_winner" : "collecting_data"), + }); + } + + // 9. 全局汇总 + const summary = { + totalBots: byBot.size, + totalVariants: variants.length, + totalExperiments: experiments.length, + activeExperiments: experiments.filter((e) => e.status === "active").length, + completedExperiments: experiments.filter((e) => e.status === "completed").length, + winnersFound: analysis.filter((a) => a.winner).length, + totalAssignments: assignments.length, + totalImpressions: assignments.reduce((s, a) => s + a.impressions, 0), + totalReplies: assignments.reduce((s, a) => s + a.replies, 0), + totalLikes: assignments.reduce((s, a) => s + a.likes, 0), + totalHumanReplies: assignments.reduce((s, a) => s + a.humanReplies, 0), + days, + }; + + return NextResponse.json({ + summary, + variants: enrichedVariants, + experiments, + analysis, + }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "A/B 数据获取失败" }, { status: 500 }); + } +} + +// 标准正态分布 CDF +function normalCdf(x: number): number { + const t = 1 / (1 + 0.2316419 * x); + const d = 0.3989422804014327 * Math.exp(-x * x / 2); + const p = d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))); + return 1 - p; +} diff --git a/src/app/api/admin/bots/experiments/run/route.ts b/src/app/api/admin/bots/experiments/run/route.ts new file mode 100644 index 0000000..d0b1cf8 --- /dev/null +++ b/src/app/api/admin/bots/experiments/run/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/admin-auth"; +import { spawn } from "child_process"; +import { resolve, dirname } from "path"; + +// 手动触发 A/B 调度 +// POST /api/admin/bots/experiments/run +// body: { lookback?: number, mode?: "all"|"metrics"|"analyze", noSwitch?: boolean } +export async function POST(req: NextRequest) { + const authError = await requireAdmin(); + if (authError) return authError; + try { + const body = await req.json().catch(() => ({})); + const lookback = Math.min(Math.max(parseInt(body.lookback || "7", 10), 1), 30); + const mode = body.mode || "all"; + const noSwitch = !!body.noSwitch; + + const startTime = Date.now(); + const args = ["scripts/bot-persona-experiment-run.mjs", "--lookback=" + lookback]; + if (noSwitch) args.push("--no-switch"); + if (mode === "metrics") args.push("--metrics-only"); + if (mode === "analyze") args.push("--analyze-only"); + + const projectRoot = resolve(dirname(process.cwd())); + const cwd = process.cwd(); + + const child = spawn("node", args, { + cwd, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => (stdout += d.toString())); + child.stderr.on("data", (d) => (stderr += d.toString())); + + const exitCode: number = await new Promise((resolveP) => { + child.on("close", (code) => resolveP(code ?? -1)); + }); + + const duration = Date.now() - startTime; + + await prisma.taskLog.create({ + data: { + taskKey: "bot-persona-experiment-run", + taskName: "Bot Persona A/B 调度 (manual)", + status: exitCode === 0 ? "success" : "failed", + startedAt: new Date(startTime), + finishedAt: new Date(), + triggerBy: "admin", + result: { lookback, mode, noSwitch }, + error: exitCode === 0 ? null : stderr.slice(0, 1000), + }, + }); + + return NextResponse.json({ + ok: exitCode === 0, + exitCode, + duration, + lookback, + mode, + noSwitch, + stdout: stdout.slice(-2000), + stderr: stderr.slice(-1000), + }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "运行失败" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/bots/experiments/variants/route.ts b/src/app/api/admin/bots/experiments/variants/route.ts new file mode 100644 index 0000000..1a575ab --- /dev/null +++ b/src/app/api/admin/bots/experiments/variants/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// 变体管理:更新权重、启用/停用、重置样本、删除 +// PATCH /api/admin/bots/experiments/variants +// body: { variantId: number, weight?: number, isActive?: boolean, resetStats?: boolean } +// DELETE /api/admin/bots/experiments/variants?variantId=X +export async function PATCH(req: NextRequest) { + try { + const body = await req.json(); + const { variantId, weight, isActive, resetStats } = body; + if (!variantId) { + return NextResponse.json({ error: "variantId 必填" }, { status: 400 }); + } + const update: Record = {}; + if (typeof weight === "number" && weight >= 0 && weight <= 1) { + update.weight = weight; + } + if (typeof isActive === "boolean") { + update.isActive = isActive; + } + if (resetStats) { + update.sampleCount = 0; + update.replyCount = 0; + update.likeCount = 0; + update.humanReplyCount = 0; + update.engagementScore = 0; + } + if (Object.keys(update).length === 0) { + return NextResponse.json({ error: "没有要更新的字段" }, { status: 400 }); + } + const v = await prisma.botPersonaVariant.update({ + where: { id: Number(variantId) }, + data: update, + }); + return NextResponse.json({ ok: true, variant: v }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "更新失败" }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const variantId = searchParams.get("variantId"); + if (!variantId) { + return NextResponse.json({ error: "variantId 必填" }, { status: 400 }); + } + await prisma.botPersonaVariant.delete({ + where: { id: Number(variantId) }, + }); + return NextResponse.json({ ok: true }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "删除失败" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/bots/stats/route.ts b/src/app/api/admin/bots/stats/route.ts new file mode 100644 index 0000000..5c00296 --- /dev/null +++ b/src/app/api/admin/bots/stats/route.ts @@ -0,0 +1,229 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// Bot 表现统计:拉近 N 天的每日统计 + 总览指标 +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const days = Math.min(Math.max(parseInt(searchParams.get("days") || "14", 10), 1), 60); + const botParam = searchParams.get("botUserId"); + + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const sinceDate = new Date(since.getFullYear(), since.getMonth(), since.getDate()); + + // 1. 全部 bot 基础信息 + const botUsers = await prisma.user.findMany({ + where: { isBot: true }, + select: { id: true, name: true, email: true, avatarUrl: true, points: true, level: true, createdAt: true }, + }); + if (botUsers.length === 0) { + return NextResponse.json({ stats: [], summary: {}, trends: [] }); + } + const botById = new Map(botUsers.map((u) => [u.id, u])); + const botIds = botUsers.map((u) => u.id); + + // 2. BotConfig 拿到 botKey 和 primaryForums + const botConfigs = await prisma.botConfig.findMany({ + where: { userId: { in: botIds } }, + select: { id: true, userId: true, primaryForums: true, displayName: true }, + }); + const configByUserId = new Map(botConfigs.map((c) => [c.userId, c])); + + // 3. 周期内 BotDailyStat 聚合 + const dailyStats = await prisma.botDailyStat.groupBy({ + by: ["botId"], + where: { date: { gte: sinceDate } }, + _sum: { + topicCreated: true, + replySent: true, + followUpSent: true, + repliesReceived: true, + likesReceived: true, + humanInteractions: true, + feedbackProcessed: true, + }, + }); + const statMap = new Map( + dailyStats.map((s) => [ + s.botId, + { + topicCreated: s._sum.topicCreated || 0, + replySent: s._sum.replySent || 0, + followUpSent: s._sum.followUpSent || 0, + repliesReceived: s._sum.repliesReceived || 0, + likesReceived: s._sum.likesReceived || 0, + humanInteractions: s._sum.humanInteractions || 0, + feedbackProcessed: s._sum.feedbackProcessed || 0, + }, + ]) + ); + + // 4. Memory 数量 + const memCounts = await prisma.botMemory.groupBy({ + by: ["botId"], + _count: { _all: true }, + _avg: { importance: true }, + }); + const memMap = new Map( + memCounts.map((m) => [ + m.botId, + { count: m._count._all, avgImportance: m._avg.importance || 0 }, + ]) + ); + + // 5. Persona + 跨板块亲和度 + const personas = await prisma.botPersona.findMany({ + select: { + botId: true, + avgReplyLength: true, + questionRatio: true, + postSampleCount: true, + stanceKeywords: true, + }, + }); + const personaMap = new Map(personas.map((p) => [p.botId, p])); + const affinities = await prisma.botCrossForumAffinity.findMany({ + select: { botId: true, forumSlug: true, affinity: true, sampleCount: true }, + }); + const affinityMap = new Map(); + for (const a of affinities) { + if (!affinityMap.has(a.botId)) affinityMap.set(a.botId, []); + affinityMap.get(a.botId)!.push(a); + } + + // 6. 计算每个 bot 的"健康分" + const stats = botUsers.map((u) => { + const config = configByUserId.get(u.id); + const cfgId = config?.id ?? 0; + const d = statMap.get(cfgId) || { + topicCreated: 0, + replySent: 0, + followUpSent: 0, + repliesReceived: 0, + likesReceived: 0, + humanInteractions: 0, + feedbackProcessed: 0, + }; + const mem = memMap.get(cfgId) || { count: 0, avgImportance: 0 }; + const persona = personaMap.get(cfgId); + const affs = affinityMap.get(cfgId) || []; + + // 综合健康分:发帖×3 + 回复×1 + 收赞×2 + 真人互动×5 + const healthScore = + d.topicCreated * 3 + + d.replySent * 1 + + d.likesReceived * 2 + + d.humanInteractions * 5; + + // 平均亲和度 + const avgAffinity = affs.length > 0 + ? affs.reduce((s, a) => s + a.affinity, 0) / affs.length + : 0; + + // 状态判定 + let status: "active" | "warming" | "fresh" | "idle" = "idle"; + if (healthScore >= 50) status = "active"; + else if (healthScore >= 10) status = "warming"; + else if (d.humanInteractions > 0 || d.likesReceived > 0) status = "fresh"; + else status = "idle"; + + return { + userId: u.id, + name: u.name, + avatarUrl: u.avatarUrl, + points: u.points, + level: u.level, + botConfigId: config?.id || null, + primaryForums: (config?.primaryForums as string[]) || [], + topicCreated: d.topicCreated, + replySent: d.replySent, + followUpSent: d.followUpSent, + repliesReceived: d.repliesReceived, + likesReceived: d.likesReceived, + humanInteractions: d.humanInteractions, + feedbackProcessed: d.feedbackProcessed, + memoryCount: mem.count, + avgImportance: mem.avgImportance, + personaQuestionRatio: persona?.questionRatio || 0, + personaSampleCount: persona?.postSampleCount || 0, + affinityCount: affs.length, + avgAffinity: Math.round(avgAffinity * 100) / 100, + healthScore, + status, + }; + }); + + // 按健康分排序 + stats.sort((a, b) => b.healthScore - a.healthScore); + + // 7. 时间趋势(按天聚合) + const trendRaw = await prisma.botDailyStat.groupBy({ + by: ["date"], + where: { date: { gte: sinceDate } }, + _sum: { + topicCreated: true, + replySent: true, + likesReceived: true, + humanInteractions: true, + }, + orderBy: { date: "asc" }, + }); + const trends = trendRaw.map((t) => ({ + date: t.date.toISOString().split("T")[0], + topicCreated: t._sum.topicCreated || 0, + replySent: t._sum.replySent || 0, + likesReceived: t._sum.likesReceived || 0, + humanInteractions: t._sum.humanInteractions || 0, + })); + + // 7b. 如果指定了 botUserId,额外返回该 bot 的每日趋势 + let perBotTrend: typeof trends = []; + if (botParam) { + const botUserId = Number(botParam); + if (!Number.isNaN(botUserId)) { + const botConfig = configByUserId.get(botUserId); + if (botConfig) { + const perBot = await prisma.botDailyStat.findMany({ + where: { botId: botConfig.id, date: { gte: sinceDate } }, + orderBy: { date: "asc" }, + }); + perBotTrend = perBot.map((d) => ({ + date: d.date.toISOString().split("T")[0], + topicCreated: d.topicCreated, + replySent: d.replySent, + likesReceived: d.likesReceived, + humanInteractions: d.humanInteractions, + })); + } + } + } + + // 8. 全局汇总 + const summary = { + totalBots: stats.length, + activeBots: stats.filter((s) => s.status === "active").length, + warmingBots: stats.filter((s) => s.status === "warming").length, + idleBots: stats.filter((s) => s.status === "idle").length, + totalTopics: stats.reduce((s, b) => s + b.topicCreated, 0), + totalReplies: stats.reduce((s, b) => s + b.replySent, 0), + totalLikes: stats.reduce((s, b) => s + b.likesReceived, 0), + totalHumanInteractions: stats.reduce((s, b) => s + b.humanInteractions, 0), + totalMemories: stats.reduce((s, b) => s + b.memoryCount, 0), + avgHealthScore: stats.length > 0 + ? Math.round(stats.reduce((s, b) => s + b.healthScore, 0) / stats.length) + : 0, + days, + }; + + return NextResponse.json({ + stats: stats.slice(0, 50), // 最多 50 个 bot(已排序) + summary, + trends, + perBotTrend, + botById: Object.fromEntries(botUsers.map((u) => [u.id, u.name])), + }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "Bot 统计失败" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/categories/[id]/route.ts b/src/app/api/admin/categories/[id]/route.ts index 4324636..ddaba57 100644 --- a/src/app/api/admin/categories/[id]/route.ts +++ b/src/app/api/admin/categories/[id]/route.ts @@ -11,6 +11,8 @@ export async function PUT( try { const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 }); + const body = await request.json(); const data: Record = {}; @@ -41,6 +43,7 @@ export async function DELETE( try { const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 }); const toolCount = await prisma.tool.count({ where: { categoryId: id } }); if (toolCount > 0) { diff --git a/src/app/api/admin/daily-reports/generate/route.ts b/src/app/api/admin/daily-reports/generate/route.ts index fb8959d..23f4faf 100644 --- a/src/app/api/admin/daily-reports/generate/route.ts +++ b/src/app/api/admin/daily-reports/generate/route.ts @@ -3,10 +3,21 @@ import { requireAdmin } from "@/lib/admin-auth"; import { extractJSON } from "@/lib/utils"; import OpenAI from "openai"; -const openai = new OpenAI({ - apiKey: process.env.DEEPSEEK_API_KEY, - baseURL: "https://api.deepseek.com/v1", -}); +// 必须 force-dynamic,否则 build 期的 page data collection 会触发模块求值 +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +// 懒加载 OpenAI client:build 期模块求值时不会触发 SDK 构造 +let _openai: OpenAI | null = null; +function getOpenAI(): OpenAI { + if (!_openai) { + _openai = new OpenAI({ + apiKey: process.env.DEEPSEEK_API_KEY, + baseURL: "https://api.deepseek.com/v1", + }); + } + return _openai; +} const DAILY_PROMPT = `你是AI行业资深分析师。请为{date}生成一份AI日报,包含6-8条当日最重要的AI行业热点新闻。 @@ -51,7 +62,7 @@ export async function POST(request: Request) { const prompt = DAILY_PROMPT.replace(/\{date\}/g, date); - const completion = await openai.chat.completions.create({ + const completion = await getOpenAI().chat.completions.create({ model: "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.7, diff --git a/src/app/api/admin/forum/posts/[id]/moderate/route.ts b/src/app/api/admin/forum/posts/[id]/moderate/route.ts new file mode 100644 index 0000000..77eaf0f --- /dev/null +++ b/src/app/api/admin/forum/posts/[id]/moderate/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getAllUserPermissions } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/admin/forum/posts/[id]/moderate + * Body: { action, reason? } + * action: hide | unhide | delete + */ +export async function POST( + req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const perms = await getAllUserPermissions(); + const userRole = (session.user as { role?: string }).role || ""; + if (userRole !== "admin" && !perms.keys.has("forum:moderate")) { + return NextResponse.json({ error: "无权限" }, { status: 403 }); + } + + const userId = parseInt((session.user as { id: string }).id); + const postId = parseInt(params.id); + if (isNaN(postId)) { + return NextResponse.json({ error: "无效的 postId" }, { status: 400 }); + } + + const body = await req.json().catch(() => ({})); + const { action, reason } = body as { action?: string; reason?: string }; + if (!action || !["hide", "unhide", "delete"].includes(action)) { + return NextResponse.json({ error: "无效的 action" }, { status: 400 }); + } + + const post = await prisma.forumPost.findUnique({ + where: { id: postId }, + select: { id: true, isHidden: true }, + }); + if (!post) { + return NextResponse.json({ error: "回复不存在" }, { status: 404 }); + } + + if (action === "delete") { + await prisma.$transaction([ + prisma.forumPost.delete({ where: { id: postId } }), + prisma.forumModerationLog.create({ + data: { + actorId: userId, + targetType: "post", + targetId: postId, + action: "delete", + reason: reason?.slice(0, 200) || null, + }, + }), + ]); + return NextResponse.json({ success: true, action }); + } + + const data = action === "hide" ? { isHidden: true } : { isHidden: false }; + await prisma.$transaction([ + prisma.forumPost.update({ where: { id: postId }, data }), + prisma.forumModerationLog.create({ + data: { + actorId: userId, + targetType: "post", + targetId: postId, + action, + reason: reason?.slice(0, 200) || null, + }, + }), + ]); + + return NextResponse.json({ success: true, action }); +} diff --git a/src/app/api/admin/forum/reports/[id]/resolve/route.ts b/src/app/api/admin/forum/reports/[id]/resolve/route.ts new file mode 100644 index 0000000..c580871 --- /dev/null +++ b/src/app/api/admin/forum/reports/[id]/resolve/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getAllUserPermissions } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +const VALID_RESOLUTIONS = new Set(["hidden", "dismissed", "deleted"]); + +/** + * POST /api/admin/forum/reports/[id]/resolve + * Body: { resolution: "hidden" | "dismissed" | "deleted" } + * + * 同时联动 target: + * - hidden: 隐藏对应的 topic/post + * - dismissed: 标记为已忽略 + * - deleted: 删除对应的 topic/post + */ +export async function POST( + req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const perms = await getAllUserPermissions(); + const userRole = (session.user as { role?: string }).role || ""; + if (userRole !== "admin" && !perms.keys.has("forum:moderate")) { + return NextResponse.json({ error: "无权限" }, { status: 403 }); + } + + const userId = parseInt((session.user as { id: string }).id); + const reportId = parseInt(params.id); + if (isNaN(reportId)) { + return NextResponse.json({ error: "无效的 reportId" }, { status: 400 }); + } + + const body = await req.json().catch(() => ({})); + const { resolution } = body as { resolution?: string }; + if (!resolution || !VALID_RESOLUTIONS.has(resolution)) { + return NextResponse.json({ error: "无效的 resolution" }, { status: 400 }); + } + + const report = await prisma.forumReport.findUnique({ + where: { id: reportId }, + }); + if (!report) { + return NextResponse.json({ error: "举报不存在" }, { status: 404 }); + } + if (report.status !== "pending") { + return NextResponse.json({ error: "该举报已处理" }, { status: 409 }); + } + + // 事务:更新举报状态 + 联动 target + 写日志 + await prisma.$transaction(async (tx) => { + await tx.forumReport.update({ + where: { id: reportId }, + data: { + status: "resolved", + resolution, + resolvedBy: userId, + resolvedAt: new Date(), + }, + }); + + if (report.topicId) { + if (resolution === "hidden") { + await tx.forumTopic.update({ + where: { id: report.topicId }, + data: { isHidden: true }, + }); + } else if (resolution === "deleted") { + await tx.forumTopic.delete({ where: { id: report.topicId } }); + } + } + if (report.postId) { + if (resolution === "hidden") { + await tx.forumPost.update({ + where: { id: report.postId }, + data: { isHidden: true }, + }); + } else if (resolution === "deleted") { + await tx.forumPost.delete({ where: { id: report.postId } }); + } + } + + await tx.forumModerationLog.create({ + data: { + actorId: userId, + targetType: "report", + targetId: reportId, + action: resolution === "dismissed" ? "dismiss" : "resolve", + reason: resolution, + }, + }); + + // 联动 targetType=topic/post 的额外日志 + if (report.topicId) { + await tx.forumModerationLog.create({ + data: { + actorId: userId, + targetType: "topic", + targetId: report.topicId, + action: resolution === "hidden" ? "hide" : resolution === "deleted" ? "delete" : "dismiss", + reason: `via report #${reportId}`, + }, + }); + } + if (report.postId) { + await tx.forumModerationLog.create({ + data: { + actorId: userId, + targetType: "post", + targetId: report.postId, + action: resolution === "hidden" ? "hide" : resolution === "deleted" ? "delete" : "dismiss", + reason: `via report #${reportId}`, + }, + }); + } + }); + + return NextResponse.json({ success: true, resolution }); +} diff --git a/src/app/api/admin/forum/reports/route.ts b/src/app/api/admin/forum/reports/route.ts new file mode 100644 index 0000000..883a727 --- /dev/null +++ b/src/app/api/admin/forum/reports/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getAllUserPermissions } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/admin/forum/reports?status=pending&page=1&pageSize=20 + */ +export async function GET(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const perms = await getAllUserPermissions(); + const userRole = (session.user as { role?: string }).role || ""; + if (userRole !== "admin" && !perms.keys.has("forum:moderate")) { + return NextResponse.json({ error: "无权限" }, { status: 403 }); + } + + const { searchParams } = new URL(req.url); + const status = (searchParams.get("status") || "pending").trim(); + const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10)); + const pageSize = Math.min(50, parseInt(searchParams.get("pageSize") || "20", 10)); + + const where: any = {}; + if (status !== "all") { + where.status = status; + } + + const [reports, totalCount] = await Promise.all([ + prisma.forumReport.findMany({ + where, + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + skip: (page - 1) * pageSize, + take: pageSize, + include: { + reporter: { + select: { id: true, name: true, email: true, avatarUrl: true, level: true, isBot: true }, + }, + resolver: { + select: { id: true, name: true, email: true }, + }, + topic: { + select: { + id: true, + title: true, + isHidden: true, + userId: true, + user: { select: { id: true, name: true, email: true } }, + }, + }, + }, + }), + prisma.forumReport.count({ where }), + ]); + + // 如果是 post 举报,单独拉一次 post 信息 + const postIds = reports.filter((r) => r.postId && !r.topic).map((r) => r.postId!) as number[]; + const posts = postIds.length + ? await prisma.forumPost.findMany({ + where: { id: { in: postIds } }, + select: { + id: true, + content: true, + isHidden: true, + topicId: true, + userId: true, + user: { select: { id: true, name: true, email: true } }, + }, + }) + : []; + const postMap = new Map(posts.map((p) => [p.id, p])); + + const enriched = reports.map((r) => ({ + ...r, + post: r.postId ? postMap.get(r.postId) || null : null, + })); + + return NextResponse.json({ + reports: enriched, + totalCount, + page, + pageSize, + }); +} diff --git a/src/app/api/admin/forum/topics/[id]/moderate/route.ts b/src/app/api/admin/forum/topics/[id]/moderate/route.ts new file mode 100644 index 0000000..c6adc4f --- /dev/null +++ b/src/app/api/admin/forum/topics/[id]/moderate/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getAllUserPermissions } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +const VALID_ACTIONS = new Set([ + "pin", + "unpin", + "feature", + "unfeature", + "lock", + "unlock", + "hide", + "unhide", + "delete", +]); + +/** + * POST /api/admin/forum/topics/[id]/moderate + * Body: { action, reason? } + */ +export async function POST( + req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const perms = await getAllUserPermissions(); + const userRole = (session.user as { role?: string }).role || ""; + if (userRole !== "admin" && !perms.keys.has("forum:moderate")) { + return NextResponse.json({ error: "无权限" }, { status: 403 }); + } + + const userId = parseInt((session.user as { id: string }).id); + const topicId = parseInt(params.id); + if (isNaN(topicId)) { + return NextResponse.json({ error: "无效的 topicId" }, { status: 400 }); + } + + const body = await req.json().catch(() => ({})); + const { action, reason } = body as { action?: string; reason?: string }; + if (!action || !VALID_ACTIONS.has(action)) { + return NextResponse.json({ error: "无效的 action" }, { status: 400 }); + } + + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + select: { + id: true, + isPinned: true, + isFeatured: true, + isLocked: true, + isHidden: true, + }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + + const data: any = {}; + switch (action) { + case "pin": + data.isPinned = true; + break; + case "unpin": + data.isPinned = false; + break; + case "feature": + data.isFeatured = true; + break; + case "unfeature": + data.isFeatured = false; + break; + case "lock": + data.isLocked = true; + break; + case "unlock": + data.isLocked = false; + break; + case "hide": + data.isHidden = true; + break; + case "unhide": + data.isHidden = false; + break; + case "delete": + await prisma.$transaction([ + prisma.forumTopic.delete({ where: { id: topicId } }), + prisma.forumModerationLog.create({ + data: { + actorId: userId, + targetType: "topic", + targetId: topicId, + action: "delete", + reason: reason?.slice(0, 200) || null, + }, + }), + ]); + return NextResponse.json({ success: true, action }); + } + + await prisma.$transaction([ + prisma.forumTopic.update({ where: { id: topicId }, data }), + prisma.forumModerationLog.create({ + data: { + actorId: userId, + targetType: "topic", + targetId: topicId, + action, + reason: reason?.slice(0, 200) || null, + }, + }), + ]); + + return NextResponse.json({ success: true, action }); +} diff --git a/src/app/api/admin/forum/topics/route.ts b/src/app/api/admin/forum/topics/route.ts new file mode 100644 index 0000000..e6bff3b --- /dev/null +++ b/src/app/api/admin/forum/topics/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getAllUserPermissions } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/admin/forum/topics?status=all&page=1&pageSize=20 + * status: all | visible | hidden | pinned | featured | locked + */ +export async function GET(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const perms = await getAllUserPermissions(); + const userRole = (session.user as { role?: string }).role || ""; + if (userRole !== "admin" && !perms.keys.has("forum:moderate")) { + return NextResponse.json({ error: "无权限" }, { status: 403 }); + } + + const { searchParams } = new URL(req.url); + const status = (searchParams.get("status") || "all").trim(); + const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10)); + const pageSize = Math.min(50, parseInt(searchParams.get("pageSize") || "20", 10)); + const q = (searchParams.get("q") || "").trim(); + + const where: any = {}; + if (status === "visible") where.isHidden = false; + else if (status === "hidden") where.isHidden = true; + else if (status === "pinned") where.isPinned = true; + else if (status === "featured") where.isFeatured = true; + else if (status === "locked") where.isLocked = true; + + if (q) { + const ors: any[] = [{ title: { contains: q } }, { content: { contains: q } }]; + where.OR = ors; + } + + const [topics, totalCount] = await Promise.all([ + prisma.forumTopic.findMany({ + where, + include: { + category: { select: { name: true, slug: true, icon: true } }, + user: { select: { id: true, name: true, email: true, level: true, isBot: true } }, + _count: { select: { posts: true, likes: true, reports: true } }, + }, + orderBy: [{ isPinned: "desc" }, { updatedAt: "desc" }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.forumTopic.count({ where }), + ]); + + return NextResponse.json({ topics, totalCount, page, pageSize }); +} diff --git a/src/app/api/admin/reviews/[id]/generate/route.ts b/src/app/api/admin/reviews/[id]/generate/route.ts index 34b2899..74bfe34 100644 --- a/src/app/api/admin/reviews/[id]/generate/route.ts +++ b/src/app/api/admin/reviews/[id]/generate/route.ts @@ -3,10 +3,20 @@ import { prisma } from "@/lib/prisma"; import { requireAdmin } from "@/lib/admin-auth"; import OpenAI from "openai"; -const openai = new OpenAI({ - apiKey: process.env.DEEPSEEK_API_KEY, - baseURL: "https://api.deepseek.com/v1", -}); +// 必须 force-dynamic,避免 build 期触发 OpenAI SDK 构造抛错 +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +let _openai: OpenAI | null = null; +function getOpenAI(): OpenAI { + if (!_openai) { + _openai = new OpenAI({ + apiKey: process.env.DEEPSEEK_API_KEY, + baseURL: "https://api.deepseek.com/v1", + }); + } + return _openai; +} const LONG_ARTICLE_PROMPT = `你是一个技术内容创作者。请基于以下技能评测数据,生成一篇适合微信公众号发布的深度评测长文。 @@ -98,7 +108,7 @@ export async function POST( const prompt = promptMap[type].replace("{reviewData}", reviewData); - const completion = await openai.chat.completions.create({ + const completion = await getOpenAI().chat.completions.create({ model: "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.7, diff --git a/src/app/api/admin/skills/[id]/route.ts b/src/app/api/admin/skills/[id]/route.ts index dde0e8f..9ed4fc3 100644 --- a/src/app/api/admin/skills/[id]/route.ts +++ b/src/app/api/admin/skills/[id]/route.ts @@ -8,8 +8,25 @@ export async function PUT(request: Request, { params }: { params: { id: string } try { const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 }); + const body = await request.json(); - const skill = await prisma.skill.update({ where: { id }, data: body }); + const data: Record = {}; + if (body.name !== undefined) data.name = body.name; + if (body.slug !== undefined) data.slug = body.slug; + if (body.description !== undefined) data.description = body.description; + if (body.categoryId !== undefined) data.categoryId = body.categoryId; + if (body.githubUrl !== undefined) data.githubUrl = body.githubUrl; + if (body.logoUrl !== undefined) data.logoUrl = body.logoUrl; + if (body.websiteUrl !== undefined) data.websiteUrl = body.websiteUrl; + if (body.stars !== undefined) data.stars = body.stars; + if (body.status !== undefined) data.status = body.status; + if (body.language !== undefined) data.language = body.language; + if (body.license !== undefined) data.license = body.license; + if (body.tags !== undefined) data.tags = body.tags; + if (body.features !== undefined) data.features = body.features; + + const skill = await prisma.skill.update({ where: { id }, data }); return NextResponse.json({ skill }); } catch (error: unknown) { const err = error as { message?: string }; @@ -23,6 +40,8 @@ export async function DELETE(_request: Request, { params }: { params: { id: stri try { const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 }); + await prisma.skill.delete({ where: { id } }); return NextResponse.json({ success: true }); } catch (error: unknown) { diff --git a/src/app/api/admin/tasks/configs/route.ts b/src/app/api/admin/tasks/configs/route.ts index bb3a983..be88ffc 100644 --- a/src/app/api/admin/tasks/configs/route.ts +++ b/src/app/api/admin/tasks/configs/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/admin-auth"; export async function GET() { try { @@ -11,6 +12,12 @@ export async function GET() { { 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日报条目自动分发到社区对应板块创建话题" }, + { taskKey: "bot-activity", taskName: "Bot活动引擎", cronExpr: "0 9-23 * * *", enabled: true, desc: "Bot发帖/回复主循环:每小时从活跃时段挑选bot在主板块生成内容" }, + { taskKey: "bot-affinity-update", taskName: "Bot亲和度与画像", cronExpr: "0 */6 * * *", enabled: true, desc: "每6小时统计bot跨板块互动,更新画像(话题/回复长度/立场关键词)" }, + { taskKey: "bot-feedback-loop", taskName: "Bot反馈循环", cronExpr: "0 */3 * * *", enabled: true, desc: "每3小时基于bot历史回复生成follow-up,跟进讨论" }, + { taskKey: "bot-skill-crystallize", taskName: "Bot技能沉淀", cronExpr: "0 */12 * * *", enabled: true, desc: "把高互动话题沉淀为bot的'技能经验'写入botMemory" }, + { taskKey: "bot-weekly-review", taskName: "Bot周复盘", cronExpr: "30 2 * * 1", enabled: true, desc: "每周一凌晨生成本周bot表现报告,调整活跃度配额" }, + { taskKey: "cleanup-task-logs", taskName: "清理任务日志", cronExpr: "30 6 * * 0", enabled: true, desc: "每周日凌晨清理 30 天前的 task_log 数据,避免表无限膨胀" }, ]; const configs = await prisma.taskConfig.findMany(); @@ -37,6 +44,8 @@ export async function GET() { } export async function PUT(request: Request) { + const authError = await requireAdmin(); + if (authError) return authError; try { const body = await request.json(); const taskKey = body.taskKey as string; @@ -66,6 +75,13 @@ export async function PUT(request: Request) { "task5-update-stars": "更新星值", "task6-review-hot": "评测热门", "daily-news": "AI日报生成", + "task7-news-to-community": "新闻分发到社区", + "bot-activity": "Bot活动引擎", + "bot-affinity-update": "Bot亲和度与画像", + "bot-feedback-loop": "Bot反馈循环", + "bot-skill-crystallize": "Bot技能沉淀", + "bot-weekly-review": "Bot周复盘", + "cleanup-task-logs": "清理任务日志", }; result = await prisma.taskConfig.create({ data: { diff --git a/src/app/api/admin/tasks/run/route.ts b/src/app/api/admin/tasks/run/route.ts index 0acee15..070d67f 100644 --- a/src/app/api/admin/tasks/run/route.ts +++ b/src/app/api/admin/tasks/run/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { exec } from "child_process"; import { promisify } from "util"; import { requireAdmin } from "@/lib/admin-auth"; +import { prisma } from "@/lib/prisma"; import { taskRunSchema } from "@/lib/validation"; import path from "path"; @@ -15,6 +16,12 @@ const SCRIPT_MAP: Record = { "task6-review-hot": "scripts/task6-review-hot.mjs", "daily-news": "scripts/daily-news.mjs", "task7-news-to-community": "scripts/task7-news-to-community.mjs", + "bot-activity": "scripts/bot-activity.mjs", + "bot-affinity-update": "scripts/bot-affinity-update.mjs", + "bot-feedback-loop": "scripts/bot-feedback-loop.mjs", + "bot-skill-crystallize": "scripts/bot-skill-crystallize.mjs", + "bot-weekly-review": "scripts/bot-weekly-review.mjs", + "cleanup-task-logs": "scripts/cleanup-task-logs.mjs", }; const PROJECT_ROOT = process.env.SCRIPTS_ROOT || process.cwd(); @@ -38,6 +45,7 @@ export async function POST(request: Request) { } const fullPath = path.join(PROJECT_ROOT, scriptPath); + const startedAt = new Date(); const result: { taskKey: string; status: string; pid: number; output: string } = { taskKey, status: "started", @@ -45,11 +53,15 @@ export async function POST(request: Request) { output: "", }; + // Bot 任务涉及多次 AI 生成,给 5 分钟;普通任务 2 分钟 + const isBotTask = taskKey.startsWith("bot-"); + const TIMEOUT_MS = isBotTask ? 300000 : 120000; + try { const { stdout, stderr } = await execAsync( `node "${fullPath}" 2>&1`, { - timeout: 120000, + timeout: TIMEOUT_MS, maxBuffer: 1024 * 1024 * 2, env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: "0" }, } @@ -62,6 +74,41 @@ export async function POST(request: Request) { result.output = (e.stdout || "") + "\n" + (e.stderr || "") + "\n" + (e.message || ""); } + // 不管成功失败都写一条 taskLog,方便后台统一追溯 + try { + const TASK_NAME_MAP: Record = { + "task1-discover-tools": "发现AI工具", + "task3-check-tools": "工具巡检", + "task4-discover-skills": "发现AI技能", + "task5-update-stars": "更新星值", + "task6-review-hot": "评测热门", + "daily-news": "AI日报生成", + "task7-news-to-community": "新闻分发到社区", + "bot-activity": "Bot活动引擎", + "bot-affinity-update": "Bot亲和度与画像", + "bot-feedback-loop": "Bot反馈循环", + "bot-skill-crystallize": "Bot技能沉淀", + "bot-weekly-review": "Bot周复盘", + "cleanup-task-logs": "清理任务日志", + }; + await prisma.taskLog.create({ + data: { + taskKey, + taskName: TASK_NAME_MAP[taskKey] || taskKey, + status: result.status, + startedAt, + finishedAt: new Date(), + duration: Date.now() - startedAt.getTime(), + triggerBy: "manual", + error: result.status === "failed" ? result.output.slice(0, 1000) : null, + result: { output: result.output.slice(-2000) }, + }, + }); + } catch (logErr) { + // 写日志失败不影响主流程 + console.error("Failed to write task log:", logErr); + } + return NextResponse.json(result); } catch (error: unknown) { const err = error as { message?: string }; diff --git a/src/app/api/admin/tools/[id]/route.ts b/src/app/api/admin/tools/[id]/route.ts index 150e9cc..dd71a05 100644 --- a/src/app/api/admin/tools/[id]/route.ts +++ b/src/app/api/admin/tools/[id]/route.ts @@ -11,6 +11,8 @@ export async function PUT( try { const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 }); + const body = await request.json(); const data: Record = {}; @@ -50,6 +52,8 @@ export async function DELETE( try { const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: "无效的ID" }, { status: 400 }); + await prisma.tool.delete({ where: { id } }); return NextResponse.json({ success: true }); } catch (error: unknown) { diff --git a/src/app/api/bot/leaderboard/route.ts b/src/app/api/bot/leaderboard/route.ts new file mode 100644 index 0000000..56dbd0c --- /dev/null +++ b/src/app/api/bot/leaderboard/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// 返回按"周"统计的 bot 表现排行 +// 维度:发帖数、回复数、获赞数、被@次数(按 TOPIC_REPLY 通知的 fromBotId 计算) +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const days = Math.min(Math.max(parseInt(searchParams.get("days") || "7", 10), 1), 30); + const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "12", 10), 1), 30); + + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + // 1. 取所有 bot 用户 + const botUsers = await prisma.user.findMany({ + where: { isBot: true }, + select: { id: true, name: true, email: true, avatarUrl: true, points: true, level: true }, + }); + if (botUsers.length === 0) { + return NextResponse.json({ leaders: [], topTopics: [], topLiked: [], days, generatedAt: new Date().toISOString() }); + } + const botIds = botUsers.map((u) => u.id); + const userById = new Map(botUsers.map((u) => [u.id, u])); + + // 2. 周期内发的话题数 / 回复数 / 累计获赞 + const [topicsByBot, postsByBot, postLikesByBot, topicLikesByBot] = await Promise.all([ + prisma.forumTopic.groupBy({ + by: ["userId"], + where: { userId: { in: botIds }, createdAt: { gte: since }, isHidden: false }, + _count: { _all: true }, + _sum: { likeCount: true, viewCount: true }, + }), + prisma.forumPost.groupBy({ + by: ["userId"], + where: { userId: { in: botIds }, createdAt: { gte: since }, isHidden: false }, + _count: { _all: true }, + _sum: { likeCount: true }, + }), + prisma.forumPost.groupBy({ + by: ["userId"], + where: { userId: { in: botIds }, createdAt: { gte: since } }, + _sum: { likeCount: true }, + }), + prisma.forumTopic.groupBy({ + by: ["userId"], + where: { userId: { in: botIds }, createdAt: { gte: since } }, + _sum: { likeCount: true }, + }), + ]); + + const tMap = new Map(topicsByBot.map((r) => [r.userId, r])); + const pMap = new Map(postsByBot.map((r) => [r.userId, r])); + const plMap = new Map(postLikesByBot.map((r) => [r.userId, r._sum.likeCount || 0])); + const tlMap = new Map(topicLikesByBot.map((r) => [r.userId, r._sum.likeCount || 0])); + + // 3. 给每个 bot 计算综合得分 + const leaders = botUsers + .map((u) => { + const t = tMap.get(u.id); + const p = pMap.get(u.id); + const topicCount = t?._count?._all || 0; + const postCount = p?._count?._all || 0; + const topicLikes = tlMap.get(u.id) || 0; + const postLikes = plMap.get(u.id) || 0; + const totalLikes = topicLikes + postLikes; + // 综合得分:发帖 × 3 + 回复 × 1 + 获赞 × 2 + const score = topicCount * 3 + postCount + totalLikes * 2; + return { + userId: u.id, + name: u.name, + avatarUrl: u.avatarUrl, + points: u.points, + level: u.level, + topicCount, + postCount, + totalLikes, + score, + }; + }) + .filter((b) => b.topicCount > 0 || b.postCount > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit); + + // 4. 本周最热话题(按 bots 发的话题里互动最多的) + const topTopics = await prisma.forumTopic.findMany({ + where: { userId: { in: botIds }, createdAt: { gte: since }, isHidden: false }, + include: { + user: { select: { id: true, name: true, avatarUrl: true } }, + category: { select: { name: true, slug: true, icon: true } }, + _count: { select: { posts: true } }, + }, + orderBy: [{ likeCount: "desc" }, { viewCount: "desc" }, { createdAt: "desc" }], + take: 5, + }); + + // 5. 获赞最多的回复 + const topLiked = await prisma.forumPost.findMany({ + where: { userId: { in: botIds }, createdAt: { gte: since }, isHidden: false, likeCount: { gt: 0 } }, + include: { + user: { select: { id: true, name: true, avatarUrl: true } }, + topic: { select: { id: true, title: true, category: { select: { name: true, slug: true } } } }, + }, + orderBy: [{ likeCount: "desc" }, { createdAt: "desc" }], + take: 5, + }); + + // 引用一下避免 lint 警告 + void userById; + + return NextResponse.json({ + leaders, + topTopics: topTopics.map((t) => ({ + id: t.id, + title: t.title, + likeCount: t.likeCount, + viewCount: t.viewCount, + replyCount: t._count.posts, + createdAt: t.createdAt, + category: t.category, + user: t.user, + })), + topLiked: topLiked.map((p) => ({ + id: p.id, + content: p.content.substring(0, 150), + likeCount: p.likeCount, + createdAt: p.createdAt, + user: p.user, + topic: p.topic, + })), + days, + generatedAt: new Date().toISOString(), + }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "Bot 排行获取失败" }, { status: 500 }); + } +} diff --git a/src/app/api/bots/route.ts b/src/app/api/bots/route.ts new file mode 100644 index 0000000..dd9fd53 --- /dev/null +++ b/src/app/api/bots/route.ts @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { prisma } from "@/lib/prisma"; + +const BOT_DATA_PATH = resolve(process.cwd(), "data", "bot-characters.json"); + +const FORUM_NAMES: Record = { + "ec-platform": "平台电商", "ec-livestream": "直播带货", "ec-private": "私域电商", + "ec-group": "社区团购", "ec-dtc": "独立站/DTC", "ec-supply": "选品与供应链", + "ai-tools-app": "AI工具与应用", "ai-startup-forum": "AI创业", "ai-saas": "SaaS出海", + "ai-hardware": "智能硬件", "ai-llm": "大模型与开源", + "ct-short-video": "短视频创作", "ct-live": "直播运营", "ct-writing": "图文内容", + "ct-mcn": "MCN机构", "ct-podcast": "播客与音频", + "fi-stock": "股票与基金", "fi-crypto": "加密货币/Web3", "fi-insurance": "保险与理财", + "fi-pe": "PE与VC", "fi-forex": "外汇与期货", + "cb-ecommerce": "跨境电商平台", "cb-factory": "外贸工厂/B2B", "cb-logistics": "海外仓与物流", + "cb-payment": "跨境支付合规", "cb-brand": "品牌出海/DTC", + "re-residential": "住宅买卖", "re-commercial": "商业地产", + "re-renovation": "装修与家居", "re-property": "物业与租赁", + "fd-restaurant": "餐饮开店", "fd-tea": "茶饮与咖啡", "fd-prepared": "预制菜加工", + "fd-supply": "食品供应链", "fd-delivery": "外卖运营", + "ll-beauty": "美容美发", "ll-fitness": "健身运动", "ll-pet": "宠物经济", + "ll-housekeeping": "家政服务", "ll-repair": "维修与数码", "ll-edu-local": "本地教培", + "hl-cosmetic": "医疗美容", "hl-wellness": "健康管理", "hl-elderly": "养老服务", + "hl-rehab": "运动康复", "hl-mental": "心理健康", + "edu-skills": "职业技能", "edu-knowledge": "知识付费", "edu-abroad": "留学与语言", + "edu-corporate": "企业培训", "edu-k12": "K12与家庭", +}; + +interface BotCharacter { + key: string; + displayName: string; + email: string; + role?: string; + avatarPrompt: string; + personality: { + identity: string; + expertise: string[]; + weakness: string[]; + stance: string; + speakingStyle: string; + catchphrase: string; + forbiddenPatterns: string[]; + wordLimit: { min: number; max: number }; + passerbyType?: string; + }; + primaryForums: string[]; + activeHours: { weekday: string[]; weekend: string[] }; + activityLevel: string; + replyChance: number; +} + +function loadBotCharacters(): BotCharacter[] { + try { + const raw = readFileSync(BOT_DATA_PATH, "utf-8"); + const data = JSON.parse(raw); + return (data.characters || []) as BotCharacter[]; + } catch { + return []; + } +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const role = searchParams.get("role"); + const forumSlug = searchParams.get("forum"); + const search = (searchParams.get("q") || "").toLowerCase().trim(); + + const characters = loadBotCharacters(); + let filtered = characters; + if (role === "passerby") filtered = filtered.filter(c => c.role === "passerby"); + if (role === "expert") filtered = filtered.filter(c => c.role !== "passerby"); + if (forumSlug) filtered = filtered.filter(c => (c.primaryForums || []).includes(forumSlug)); + if (search) { + filtered = filtered.filter(c => { + const p = c.personality || {}; + const text = [ + c.displayName || "", + c.key || "", + p.identity || "", + p.stance || "", + (p.expertise || []).join(" "), + ].join(" ").toLowerCase(); + return text.includes(search); + }); + } + + const emails = filtered.map(c => `bot_${c.key}@zhuiguang.ai`); + const userRows = emails.length > 0 + ? await prisma.user.findMany({ + where: { email: { in: emails } }, + select: { id: true, name: true, email: true, avatarUrl: true, points: true, level: true, createdAt: true }, + }) + : []; + const userByEmail = new Map(userRows.map(u => [u.email, u])); + + const userIds = userRows.map(u => u.id); + const [topicGroups, postGroups, recentTopics] = await Promise.all([ + userIds.length > 0 + ? prisma.forumTopic.groupBy({ + by: ["userId"], + where: { userId: { in: userIds }, isHidden: false }, + _count: { _all: true }, + }) + : Promise.resolve([] as Array<{ userId: number; _count: { _all: number } }>), + userIds.length > 0 + ? prisma.forumPost.groupBy({ + by: ["userId"], + where: { userId: { in: userIds }, isHidden: false }, + _count: { _all: true }, + }) + : Promise.resolve([] as Array<{ userId: number; _count: { _all: number } }>), + userIds.length > 0 + ? prisma.forumTopic.findMany({ + where: { + userId: { in: userIds }, + createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + isHidden: false, + }, + select: { + userId: true, + createdAt: true, + title: true, + id: true, + category: { select: { name: true, slug: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 200, + }) + : Promise.resolve([] as Array), + ]); + + const topicMap = new Map(topicGroups.map(t => [t.userId, t._count._all])); + const postMap = new Map(postGroups.map(p => [p.userId, p._count._all])); + const recentMap = new Map(); + for (const t of recentTopics) { + const arr = recentMap.get(t.userId) || []; + arr.push(t); + recentMap.set(t.userId, arr); + } + + const enriched = filtered.map(c => { + const u = userByEmail.get(`bot_${c.key}@zhuiguang.ai`); + const isPasserby = c.role === "passerby"; + return { + key: c.key, + displayName: c.displayName, + role: isPasserby ? "passerby" : "expert", + avatarUrl: u?.avatarUrl || null, + avatarPrompt: c.avatarPrompt, + primaryForums: c.primaryForums || [], + primaryForumNames: (c.primaryForums || []).map(s => FORUM_NAMES[s] || s), + activityLevel: c.activityLevel, + activeHours: c.activeHours, + expertise: c.personality?.expertise || [], + identity: c.personality?.identity || "", + catchphrase: c.personality?.catchphrase || "", + stance: c.personality?.stance || "", + speakingStyle: c.personality?.speakingStyle || "", + passerbyType: c.personality?.passerbyType || null, + userId: u?.id || null, + points: u?.points || 0, + level: u?.level || null, + createdAt: u?.createdAt || null, + stats: { + topics: u ? topicMap.get(u.id) || 0 : 0, + posts: u ? postMap.get(u.id) || 0 : 0, + }, + recentTopics: u ? (recentMap.get(u.id) || []).slice(0, 3) : [], + }; + }); + + enriched.sort((a, b) => { + const aa = a.stats.topics + a.stats.posts; + const bb = b.stats.topics + b.stats.posts; + if (bb !== aa) return bb - aa; + return a.displayName.localeCompare(b.displayName); + }); + + return NextResponse.json({ + bots: enriched, + total: enriched.length, + forums: Object.entries(FORUM_NAMES).map(([slug, name]) => ({ slug, name })), + }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "获取Bot列表失败" }, { status: 500 }); + } +} diff --git a/src/app/api/forum/achievements/route.ts b/src/app/api/forum/achievements/route.ts new file mode 100644 index 0000000..c4af7cc --- /dev/null +++ b/src/app/api/forum/achievements/route.ts @@ -0,0 +1,30 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import { getUserAchievements, evaluateAchievements } from "@/lib/achievements"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/forum/achievements?userId=xxx + * 获取指定用户(或当前用户)的勋章列表 + */ +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const targetUserId = searchParams.get("userId"); + const session = await getServerSession(authOptions); + const meId = session?.user?.id ? parseInt(session.user.id) : null; + + // 如果没传 userId 默认查自己;查他人需登录(匿名也能看公开勋章) + const userId = targetUserId ? parseInt(targetUserId) : meId; + if (!userId) { + return NextResponse.json({ error: "缺少 userId" }, { status: 400 }); + } + + // 重新评估一遍(轻量),保证数据最新 + await evaluateAchievements(userId); + + const data = await getUserAchievements(userId); + return NextResponse.json(data); +} diff --git a/src/app/api/forum/bookmarks/route.ts b/src/app/api/forum/bookmarks/route.ts new file mode 100644 index 0000000..f2c390c --- /dev/null +++ b/src/app/api/forum/bookmarks/route.ts @@ -0,0 +1,31 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const userId = parseInt(session.user.id); + + const bookmarks = await prisma.forumTopicBookmark.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: 100, + include: { + topic: { + include: { + category: { select: { name: true, slug: true, icon: true } }, + user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } }, + _count: { select: { posts: true } }, + tags: { include: { tag: true } }, + }, + }, + }, + }); + + const valid = bookmarks.filter((b) => b.topic && !b.topic.isHidden); + return NextResponse.json({ bookmarks: valid }); +} diff --git a/src/app/api/forum/categories/[id]/follow/route.ts b/src/app/api/forum/categories/[id]/follow/route.ts new file mode 100644 index 0000000..0becd8d --- /dev/null +++ b/src/app/api/forum/categories/[id]/follow/route.ts @@ -0,0 +1,101 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +export async function POST(req: Request, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const categoryId = parseInt(params.id); + const userId = parseInt(session.user.id); + + if (isNaN(categoryId)) { + return NextResponse.json({ error: "无效的板块ID" }, { status: 400 }); + } + + const category = await prisma.forumCategory.findUnique({ + where: { id: categoryId }, + select: { id: true }, + }); + if (!category) { + return NextResponse.json({ error: "板块不存在" }, { status: 404 }); + } + + try { + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.forumCategoryFollow.findUnique({ + where: { categoryId_userId: { categoryId, userId } }, + }); + if (existing) { + return { alreadyFollowing: true }; + } + await tx.forumCategoryFollow.create({ + data: { categoryId, userId }, + }); + const updated = await tx.forumCategory.update({ + where: { id: categoryId }, + data: { followerCount: { increment: 1 } }, + select: { followerCount: true }, + }); + return { alreadyFollowing: false, followerCount: updated.followerCount }; + }); + + return NextResponse.json({ + success: true, + followed: true, + followerCount: result.followerCount ?? undefined, + }); + } catch (err: any) { + console.error("关注失败", err); + return NextResponse.json({ error: "关注失败,请稍后重试" }, { status: 500 }); + } +} + +export async function DELETE(req: Request, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const categoryId = parseInt(params.id); + const userId = parseInt(session.user.id); + + if (isNaN(categoryId)) { + return NextResponse.json({ error: "无效的板块ID" }, { status: 400 }); + } + + try { + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.forumCategoryFollow.findUnique({ + where: { categoryId_userId: { categoryId, userId } }, + }); + if (!existing) { + return { notFollowing: true }; + } + await tx.forumCategoryFollow.delete({ + where: { categoryId_userId: { categoryId, userId } }, + }); + const updated = await tx.forumCategory.update({ + where: { id: categoryId }, + data: { followerCount: { decrement: 1 } }, + select: { followerCount: true }, + }); + return { + notFollowing: false, + followerCount: Math.max(0, updated.followerCount), + }; + }); + + return NextResponse.json({ + success: true, + followed: false, + followerCount: result.followerCount, + }); + } catch (err: any) { + console.error("取消关注失败", err); + return NextResponse.json({ error: "操作失败,请稍后重试" }, { status: 500 }); + } +} diff --git a/src/app/api/forum/categories/route.ts b/src/app/api/forum/categories/route.ts index 0c02600..a4fd7d3 100644 --- a/src/app/api/forum/categories/route.ts +++ b/src/app/api/forum/categories/route.ts @@ -1,18 +1,27 @@ import { prisma } from "@/lib/prisma"; import { NextResponse } from "next/server"; +// 强制 SSR(build 期不预渲染,避免无 DATABASE_URL 时 Prisma 报错) +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + export async function GET() { const categories = await prisma.forumCategory.findMany({ where: { parentId: null }, include: { children: { orderBy: [{ sortOrder: "asc" }], + include: { + children: { + orderBy: [{ sortOrder: "asc" }], + }, + }, }, }, orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], }); const res = NextResponse.json({ categories }); - res.headers.set("Cache-Control", "public, s-maxage=300, stale-while-revalidate=120"); + res.headers.set("Cache-Control", "no-store, max-age=0"); return res; } diff --git a/src/app/api/forum/checkin/route.ts b/src/app/api/forum/checkin/route.ts new file mode 100644 index 0000000..c05a5d2 --- /dev/null +++ b/src/app/api/forum/checkin/route.ts @@ -0,0 +1,174 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import { triggerAchievementCheck } from "@/lib/achievements"; + +export const dynamic = "force-dynamic"; + +/** + * 计算连续签到奖励(积分)。 + * - 1 天: 5 分 + * - 2 天: 6 分 + * - 3 天: 7 分 + * - 4 天: 8 分 + * - 5 天: 9 分 + * - 6 天: 10 分 + * - 7+ 天: 15 分(封顶) + */ +function calcCheckInPoints(streak: number) { + if (streak >= 7) return 15; + return 5 + Math.max(0, streak - 1); +} + +function startOfDay(d: Date) { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +function diffDays(a: Date, b: Date) { + const ms = startOfDay(a).getTime() - startOfDay(b).getTime(); + return Math.round(ms / (24 * 60 * 60 * 1000)); +} + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { error: "请先登录" }, + { status: 401 } + ); + } + const userId = parseInt(session.user.id); + + const today = startOfDay(new Date()); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + const [todayCheckIn, lastCheckIn, totalCount] = await Promise.all([ + prisma.forumCheckIn.findUnique({ + where: { userId_checkInDate: { userId, checkInDate: today } }, + }), + prisma.forumCheckIn.findFirst({ + where: { userId }, + orderBy: { checkInDate: "desc" }, + }), + prisma.forumCheckIn.count({ where: { userId } }), + ]); + + // 当前连续天数:最后一次签到若是今天/昨天,则 streak 有效;否则重置为 0 + let currentStreak = 0; + if (lastCheckIn) { + const gap = diffDays(today, lastCheckIn.checkInDate); + if (gap === 0) { + currentStreak = lastCheckIn.streak; + } else if (gap === 1) { + // 昨天签到,今天还没签 + currentStreak = lastCheckIn.streak; + } + } + + // 本周签到日历(最近 7 天) + const sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(today.getDate() - 6); + const recent = await prisma.forumCheckIn.findMany({ + where: { + userId, + checkInDate: { gte: sevenDaysAgo }, + }, + select: { checkInDate: true, streak: true, pointsEarned: true }, + orderBy: { checkInDate: "asc" }, + }); + + const calendar = Array.from({ length: 7 }, (_, i) => { + const d = new Date(sevenDaysAgo); + d.setDate(sevenDaysAgo.getDate() + i); + const hit = recent.find( + (r) => startOfDay(r.checkInDate).getTime() === d.getTime() + ); + return { + date: d.toISOString().slice(0, 10), + checked: !!hit, + streak: hit?.streak || 0, + pointsEarned: hit?.pointsEarned || 0, + isToday: d.getTime() === today.getTime(), + }; + }); + + return NextResponse.json({ + checkedIn: !!todayCheckIn, + todayPoints: todayCheckIn?.pointsEarned || 0, + currentStreak, + totalCheckIns: totalCount, + nextStreak: currentStreak + 1, + nextPoints: calcCheckInPoints(currentStreak + 1), + calendar, + }); +} + +export async function POST() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const userId = parseInt(session.user.id); + const today = startOfDay(new Date()); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + // 幂等:今天已签到,直接返回 + const existing = await prisma.forumCheckIn.findUnique({ + where: { userId_checkInDate: { userId, checkInDate: today } }, + }); + if (existing) { + return NextResponse.json({ + success: true, + alreadyChecked: true, + streak: existing.streak, + pointsEarned: existing.pointsEarned, + }); + } + + // 找昨天签到记录,决定 streak 是否延续 + const last = await prisma.forumCheckIn.findUnique({ + where: { userId_checkInDate: { userId, checkInDate: yesterday } }, + }); + const newStreak = last ? last.streak + 1 : 1; + const points = calcCheckInPoints(newStreak); + + const result = await prisma.$transaction(async (tx) => { + const ci = await tx.forumCheckIn.create({ + data: { + userId, + checkInDate: today, + streak: newStreak, + pointsEarned: points, + }, + }); + await tx.user.update({ + where: { id: userId }, + data: { points: { increment: points } }, + }); + await tx.pointsHistory.create({ + data: { + userId, + points, + reason: "daily_login", + detail: `每日签到 · 连续 ${newStreak} 天`, + refType: "checkin", + refId: ci.id, + }, + }); + return ci; + }); + + triggerAchievementCheck(userId); + + return NextResponse.json({ + success: true, + alreadyChecked: false, + streak: result.streak, + pointsEarned: result.pointsEarned, + }); +} diff --git a/src/app/api/forum/drafts/route.ts b/src/app/api/forum/drafts/route.ts new file mode 100644 index 0000000..1a905aa --- /dev/null +++ b/src/app/api/forum/drafts/route.ts @@ -0,0 +1,86 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const userId = parseInt(session.user.id); + + const drafts = await prisma.forumDraft.findMany({ + where: { userId }, + orderBy: { updatedAt: "desc" }, + take: 10, + }); + + return NextResponse.json({ drafts }); +} + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const userId = parseInt(session.user.id); + + const body = await req.json().catch(() => ({})); + const { title, content, categoryId } = body as { + title?: string; + content?: string; + categoryId?: number | null; + }; + + if (!content || !content.trim()) { + return NextResponse.json({ error: "内容不能为空" }, { status: 400 }); + } + if (content.length > 20000) { + return NextResponse.json({ error: "内容过长" }, { status: 400 }); + } + if (title && title.length > 100) { + return NextResponse.json({ error: "标题过长" }, { status: 400 }); + } + + // 取最近的一条草稿覆盖;否则新建 + const existing = await prisma.forumDraft.findFirst({ + where: { userId }, + orderBy: { updatedAt: "desc" }, + }); + + let draft; + if (existing) { + draft = await prisma.forumDraft.update({ + where: { id: existing.id }, + data: { + title: title?.trim() || null, + content: content.trim(), + categoryId: categoryId ?? null, + }, + }); + } else { + draft = await prisma.forumDraft.create({ + data: { + userId, + title: title?.trim() || null, + content: content.trim(), + categoryId: categoryId ?? null, + }, + }); + } + + return NextResponse.json({ success: true, draft }); +} + +export async function DELETE() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const userId = parseInt(session.user.id); + + await prisma.forumDraft.deleteMany({ where: { userId } }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/forum/reports/route.ts b/src/app/api/forum/reports/route.ts new file mode 100644 index 0000000..58f52a5 --- /dev/null +++ b/src/app/api/forum/reports/route.ts @@ -0,0 +1,91 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +const VALID_REASONS = new Set([ + "spam", + "abuse", + "inappropriate", + "illegal", + "duplicate", + "other", +]); + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const userId = parseInt(session.user.id); + + const body = await req.json().catch(() => ({})); + const { topicId, postId, reason, detail } = body as { + topicId?: number; + postId?: number; + reason?: string; + detail?: string; + }; + + if (!topicId && !postId) { + return NextResponse.json({ error: "缺少举报目标" }, { status: 400 }); + } + if (!reason || !VALID_REASONS.has(reason)) { + return NextResponse.json({ error: "请选择举报原因" }, { status: 400 }); + } + if (detail && detail.length > 500) { + return NextResponse.json({ error: "说明不能超过 500 字" }, { status: 400 }); + } + + // 验证目标存在 + if (topicId) { + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + select: { id: true, userId: true }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + if (topic.userId === userId) { + return NextResponse.json({ error: "不能举报自己发布的内容" }, { status: 400 }); + } + } + if (postId) { + const post = await prisma.forumPost.findUnique({ + where: { id: postId }, + select: { id: true, userId: true }, + }); + if (!post) { + return NextResponse.json({ error: "回复不存在" }, { status: 404 }); + } + if (post.userId === userId) { + return NextResponse.json({ error: "不能举报自己发布的回复" }, { status: 400 }); + } + } + + // 去重:同一用户对同一目标 24h 内只能举报一次 + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); + const dupe = await prisma.forumReport.findFirst({ + where: { + reporterId: userId, + ...(topicId ? { topicId } : {}), + ...(postId ? { postId } : {}), + createdAt: { gte: since }, + }, + }); + if (dupe) { + return NextResponse.json({ error: "你已举报过该内容,请等待处理" }, { status: 409 }); + } + + const report = await prisma.forumReport.create({ + data: { + reporterId: userId, + topicId: topicId || null, + postId: postId || null, + reason, + detail: detail?.trim() || null, + }, + }); + + return NextResponse.json({ success: true, reportId: report.id }); +} diff --git a/src/app/api/forum/search/route.ts b/src/app/api/forum/search/route.ts new file mode 100644 index 0000000..f01cf0c --- /dev/null +++ b/src/app/api/forum/search/route.ts @@ -0,0 +1,96 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const q = (searchParams.get("q") || "").trim(); + const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10)); + const pageSize = Math.min(50, parseInt(searchParams.get("pageSize") || "15", 10)); + const sort = searchParams.get("sort") || "latest"; + + if (!q) { + return NextResponse.json({ topics: [], totalCount: 0, page, pageSize, q: "" }); + } + + const where: any = { + isHidden: false, + }; + + // 优先使用全文索引,回退到 LIKE + const useFullText = q.length >= 2; + if (useFullText) { + // MySQL FULLTEXT 的 boolean mode 搜索 + // 把关键词拆词,规避 FULLTEXT 最小长度限制 + const tokens = q + .split(/\s+/) + .filter((t) => t.length >= 1) + .slice(0, 10) + .map((t) => `+${t.replace(/[+\-><()~*"@]/g, "")}*`); + if (tokens.length > 0) { + // Prisma 的 where.fulltext 字段是 reserved,使用原始查询更稳 + // 这里先尝试简单的 contains 组合 + const ors: any[] = []; + for (const t of q.split(/\s+/).filter((x) => x.length >= 1)) { + ors.push({ title: { contains: t } }); + ors.push({ content: { contains: t } }); + } + where.OR = ors; + } else { + where.OR = [{ title: { contains: q } }, { content: { contains: q } }]; + } + } else { + where.OR = [{ title: { contains: q } }, { content: { contains: q } }]; + } + + const orderBy: any = {}; + if (sort === "relevance" && useFullText) { + // 简化:标题命中权重更高 → 让标题命中靠前 + orderBy.isPinned = "desc"; + } else if (sort === "hot") { + orderBy.viewCount = "desc"; + } else if (sort === "active") { + orderBy.lastReplyAt = "desc"; + } else { + orderBy.createdAt = "desc"; + } + + const [topics, totalCount] = await Promise.all([ + prisma.forumTopic.findMany({ + where, + include: { + category: { select: { name: true, slug: true, icon: true } }, + user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } }, + _count: { select: { posts: true } }, + tags: { include: { tag: true } }, + }, + orderBy: [{ isPinned: "desc" }, orderBy], + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.forumTopic.count({ where }), + ]); + + // 生成高亮摘要 + const snippets = topics.map((t) => { + const titleHit = q && t.title.toLowerCase().includes(q.toLowerCase()); + let snippet = ""; + if (q) { + const idx = t.content.toLowerCase().indexOf(q.toLowerCase()); + if (idx >= 0) { + const start = Math.max(0, idx - 50); + const end = Math.min(t.content.length, idx + q.length + 100); + snippet = + (start > 0 ? "..." : "") + + t.content.slice(start, end).replace(/\s+/g, " ").trim() + + (end < t.content.length ? "..." : ""); + } else { + snippet = t.content.slice(0, 150).replace(/\s+/g, " ").trim() + (t.content.length > 150 ? "..." : ""); + } + } else { + snippet = t.content.slice(0, 150).replace(/\s+/g, " ").trim(); + } + return { topicId: t.id, titleHit, snippet }; + }); + + return NextResponse.json({ topics, totalCount, page, pageSize, q, snippets }); +} diff --git a/src/app/api/forum/tags/[slug]/route.ts b/src/app/api/forum/tags/[slug]/route.ts new file mode 100644 index 0000000..592dc43 --- /dev/null +++ b/src/app/api/forum/tags/[slug]/route.ts @@ -0,0 +1,51 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET(req: Request, { params }: { params: { slug: string } }) { + const tag = await prisma.forumTag.findUnique({ + where: { slug: params.slug }, + }); + + if (!tag) { + return NextResponse.json({ error: "标签不存在" }, { status: 404 }); + } + + const { searchParams } = new URL(req.url); + const page = parseInt(searchParams.get("page") || "1", 10); + const pageSize = 20; + const sort = searchParams.get("sort") || "latest"; + + const orderBy: any = {}; + if (sort === "latest") orderBy.createdAt = "desc"; + else if (sort === "hot") orderBy.viewCount = "desc"; + else if (sort === "active") orderBy.lastReplyAt = "desc"; + else orderBy.createdAt = "desc"; + + const [topics, totalCount] = await Promise.all([ + prisma.forumTopic.findMany({ + where: { + tags: { some: { tagId: tag.id } }, + }, + include: { + category: { select: { name: true, slug: true, icon: true } }, + user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } }, + _count: { select: { posts: true } }, + tags: { include: { tag: true } }, + }, + orderBy: [{ isPinned: "desc" }, orderBy], + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.forumTopic.count({ + where: { tags: { some: { tagId: tag.id } } }, + }), + ]); + + return NextResponse.json({ + tag, + topics, + totalCount, + page, + pageSize, + }); +} diff --git a/src/app/api/forum/tags/route.ts b/src/app/api/forum/tags/route.ts new file mode 100644 index 0000000..9cf2dfe --- /dev/null +++ b/src/app/api/forum/tags/route.ts @@ -0,0 +1,24 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const q = (searchParams.get("q") || "").trim().toLowerCase(); + const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50); + + const where: any = {}; + if (q) { + where.OR = [ + { name: { contains: q } }, + { slug: { contains: q } }, + ]; + } + + const tags = await prisma.forumTag.findMany({ + where, + orderBy: [{ topicCount: "desc" }, { name: "asc" }], + take: limit, + }); + + return NextResponse.json({ tags }); +} diff --git a/src/app/api/forum/topics/[id]/bookmark/route.ts b/src/app/api/forum/topics/[id]/bookmark/route.ts new file mode 100644 index 0000000..614b4fb --- /dev/null +++ b/src/app/api/forum/topics/[id]/bookmark/route.ts @@ -0,0 +1,57 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +export async function POST(req: Request, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const topicId = parseInt(params.id); + const userId = parseInt(session.user.id); + if (isNaN(topicId)) { + return NextResponse.json({ error: "无效的话题ID" }, { status: 400 }); + } + + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + select: { id: true }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + + const existing = await prisma.forumTopicBookmark.findUnique({ + where: { topicId_userId: { topicId, userId } }, + }); + + if (existing) { + await prisma.forumTopicBookmark.delete({ + where: { topicId_userId: { topicId, userId } }, + }); + return NextResponse.json({ success: true, bookmarked: false }); + } + + await prisma.forumTopicBookmark.create({ + data: { topicId, userId }, + }); + + return NextResponse.json({ success: true, bookmarked: true }); +} + +export async function GET(req: Request, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ bookmarked: false }); + } + const topicId = parseInt(params.id); + const userId = parseInt(session.user.id); + if (isNaN(topicId)) { + return NextResponse.json({ bookmarked: false }); + } + const existing = await prisma.forumTopicBookmark.findUnique({ + where: { topicId_userId: { topicId, userId } }, + }); + return NextResponse.json({ bookmarked: !!existing }); +} diff --git a/src/app/api/forum/topics/[id]/follow-up/route.ts b/src/app/api/forum/topics/[id]/follow-up/route.ts new file mode 100644 index 0000000..828dc71 --- /dev/null +++ b/src/app/api/forum/topics/[id]/follow-up/route.ts @@ -0,0 +1,357 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import OpenAI from "openai"; + +// 必须 force-dynamic,避免 build 期触发 OpenAI SDK 构造抛错 +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +let _openai: OpenAI | null = null; +function getOpenAI(): OpenAI { + if (!_openai) { + _openai = new OpenAI({ + apiKey: process.env.DEEPSEEK_API_KEY, + baseURL: "https://api.deepseek.com/v1", + }); + } + return _openai; +} +const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; + +async function withRetry( + fn: () => Promise, + maxRetries = 3, + baseDelay = 1000, + label = "operation" +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (attempt < maxRetries) { + const delay = baseDelay * Math.pow(2, attempt - 1); + console.warn(` ⚠ ${label} 第${attempt}次失败,${delay / 1000}s后重试...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + throw lastError; +} + +async function authorizeBotCall(req: Request): Promise<{ ok: boolean; via?: string }> { + const headerKey = req.headers.get("x-bot-key"); + const expected = process.env.BOT_FEEDBACK_SECRET; + if (expected && headerKey && headerKey === expected) { + return { ok: true, via: "shared-secret" }; + } + + const session = await getServerSession(authOptions); + const role = (session?.user as { role?: string } | undefined)?.role; + if (role === "admin") { + return { ok: true, via: "admin-session" }; + } + + return { ok: false }; +} + +interface BotCharacter { + key: string; + displayName: string; + personality: { + identity: string; + expertise: string[]; + weakness?: string[]; + stance: string; + speakingStyle: string; + catchphrase: string; + forbiddenPatterns?: string[]; + }; +} + +async function loadBotCharacter(botKey: string): Promise { + const fs = await import("fs/promises"); + const path = await import("path"); + const dataPath = path.join(process.cwd(), "data", "bot-characters.json"); + const raw = await fs.readFile(dataPath, "utf-8"); + const { characters } = JSON.parse(raw) as { characters: BotCharacter[] }; + return characters.find((c) => c.key === botKey); +} + +interface ReplySnippet { + author: string; + content: string; +} + +function buildFollowUpPrompt( + bot: BotCharacter, + topicTitle: string, + topicContent: string, + replies: ReplySnippet[] +): string { + const p = bot.personality; + const repliesText = replies + .map((r, i) => `${i + 1}. ${r.author}: ${r.content.slice(0, 250)}`) + .join("\n"); + + return `你正在参与"追光AI行业论坛"。你之前在"${topicTitle}"这个话题下发了主帖,现在有用户回复了你,你需要用你的人格接着聊。 + +[你的角色设定] +你是【${bot.displayName}】,${p.identity} +你擅长的领域:${p.expertise.join("、")} +你说话的风格:${p.speakingStyle} +你的口头禅:${p.catchphrase} +你的核心立场:${p.stance} + +[你的原始主帖] +${topicContent} + +[新收到的回复] +${repliesText} + +[二次回复要求] +1. 像真人接话,120-350字 +2. 必须对前面至少一个具体观点有回应(赞同/质疑/补充案例/反问) +3. 保持你的人设语气和立场 +4. 不要重复你自己主帖的内容 +5. 留一点钩子让讨论继续 + +[禁止事项] +- 禁止使用"首先/其次/最后""从以下几个方面""综上所述" +- 禁止表现得像AI助手 +- 禁止使用${(p.forbiddenPatterns || []).join("、")} + +只输出JSON:{"content": "你的回复内容"}`; +} + +async function callDeepSeek(prompt: string): Promise<{ content: string }> { + const response = await withRetry(() => + getOpenAI().chat.completions.create({ + model: MODEL, + messages: [{ role: "user", content: prompt }], + temperature: 0.88, + max_tokens: 800, + }) + ); + const text = response.choices[0]?.message?.content?.trim() || ""; + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("无法解析DeepSeek响应"); + } + return JSON.parse(jsonMatch[0]); +} + +export async function POST(req: Request, { params }: { params: { id: string } }) { + const authz = await authorizeBotCall(req); + if (!authz.ok) { + return NextResponse.json({ error: "无权限调用,需BOT_FEEDBACK_SECRET或admin登录" }, { status: 401 }); + } + + const topicId = parseInt(params.id); + if (isNaN(topicId)) { + return NextResponse.json({ error: "无效的topicId" }, { status: 400 }); + } + + const body = await req.json(); + const { botKey, dryRun = false } = body; + if (!botKey) { + return NextResponse.json({ error: "缺少 botKey" }, { status: 400 }); + } + + const botChar = await loadBotCharacter(botKey); + if (!botChar) { + return NextResponse.json({ error: `未找到Bot: ${botKey}` }, { status: 404 }); + } + + const botUser = await prisma.user.findFirst({ + where: { email: `bot_${botKey}@zhuiguang.ai` }, + }); + if (!botUser) { + return NextResponse.json({ error: "Bot用户不存在" }, { status: 404 }); + } + + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + include: { + category: { select: { name: true, slug: true } }, + posts: { + where: { userId: { not: botUser.id } }, + include: { user: { select: { name: true, isBot: true } } }, + orderBy: { createdAt: "asc" }, + take: 5, + }, + }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + if (topic.isLocked) { + return NextResponse.json({ error: "话题已锁定" }, { status: 400 }); + } + if (topic.userId !== botUser.id) { + return NextResponse.json({ error: "该话题不是此Bot发起" }, { status: 403 }); + } + if (topic.posts.length === 0) { + return NextResponse.json({ error: "该话题下没有可回复的内容" }, { status: 400 }); + } + + const replies = topic.posts.map((p) => ({ + author: p.user.name || "匿名", + content: p.content, + })); + + let content: string; + try { + const prompt = buildFollowUpPrompt(botChar, topic.title, topic.content, replies); + const result = await callDeepSeek(prompt); + content = (result.content || "").trim(); + if (!content || content.length < 30) { + return NextResponse.json({ error: "生成内容过短" }, { status: 500 }); + } + } catch (err: any) { + return NextResponse.json({ error: `DeepSeek调用失败: ${err.message}` }, { status: 500 }); + } + + if (dryRun) { + return NextResponse.json({ ok: true, dryRun: true, content, via: authz.via }); + } + + const post = await prisma.$transaction(async (tx) => { + const newPost = await tx.forumPost.create({ + data: { + topicId: topic.id, + userId: botUser.id, + content: content.slice(0, 5000), + }, + }); + await tx.forumTopic.update({ + where: { id: topic.id }, + data: { + replyCount: { increment: 1 }, + lastReplyAt: new Date(), + updatedAt: new Date(), + }, + }); + return newPost; + }); + + const botConfig = await prisma.botConfig.findUnique({ where: { userId: botUser.id } }); + if (botConfig) { + await prisma.botMemory.create({ + data: { + botId: botConfig.id, + memoryType: "interaction", + layer: "WORKING", + content: { + action: "follow_up_reply", + forum: topic.category.name, + topicTitle: topic.title, + topicId: topic.id, + triggeredBy: "api_follow_up", + summary: content.slice(0, 100), + }, + importance: 0.6, + contextTags: { + topicId: topic.id, + forumSlug: topic.category.slug, + postId: post.id, + source: "api_follow_up", + }, + ttl: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), + }, + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + await prisma.botDailyStat.upsert({ + where: { botId_date: { botId: botConfig.id, date: today } }, + create: { + botId: botConfig.id, + date: today, + followUpSent: 1, + replySent: 1, + }, + update: { + followUpSent: { increment: 1 }, + replySent: { increment: 1 }, + }, + }); + } + + return NextResponse.json({ + ok: true, + postId: post.id, + content: post.content, + via: authz.via, + }); +} + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + const role = (session?.user as { role?: string } | undefined)?.role; + if (role !== "admin") { + return NextResponse.json({ error: "无权限" }, { status: 401 }); + } + + const topicId = parseInt(params.id); + if (isNaN(topicId)) { + return NextResponse.json({ error: "无效的topicId" }, { status: 400 }); + } + + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + include: { + category: { select: { name: true, slug: true } }, + user: { select: { id: true, name: true, email: true, isBot: true } }, + _count: { select: { posts: true } }, + posts: { + include: { user: { select: { name: true, isBot: true } } }, + orderBy: { createdAt: "asc" }, + take: 10, + }, + }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + + const botEmail = topic.user.email || ""; + const isBotTopic = topic.user.isBot; + const allFeedback = await prisma.botMemory.findMany({ + where: { memoryType: "feedback" }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + const feedbackMemories = allFeedback.filter((m) => { + const tags = m.contextTags as { topicId?: number } | null; + return tags && tags.topicId === topicId; + }).slice(0, 5); + + return NextResponse.json({ + topic: { + id: topic.id, + title: topic.title, + category: topic.category, + replyCount: topic._count.posts, + lastReplyAt: topic.lastReplyAt, + isBotTopic, + owner: { id: topic.user.id, name: topic.user.name, email: botEmail }, + }, + recentPosts: topic.posts.map((p) => ({ + id: p.id, + author: p.user.name || "匿名", + isBot: p.user.isBot, + content: p.content.slice(0, 200), + createdAt: p.createdAt, + })), + feedbackMemories: feedbackMemories.map((m) => ({ + id: m.id, + importance: m.importance, + content: m.content, + createdAt: m.createdAt, + })), + }); +} diff --git a/src/app/api/forum/topics/[id]/like/route.ts b/src/app/api/forum/topics/[id]/like/route.ts new file mode 100644 index 0000000..a57eef7 --- /dev/null +++ b/src/app/api/forum/topics/[id]/like/route.ts @@ -0,0 +1,133 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import { rateLimit } from "@/lib/rate-limit"; +import { rewardPoints } from "@/lib/points-reward"; +import { triggerAchievementCheck } from "@/lib/achievements"; + +export async function POST(req: Request, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const topicId = parseInt(params.id); + const userId = parseInt(session.user.id); + + if (isNaN(topicId)) { + return NextResponse.json({ error: "无效的话题ID" }, { status: 400 }); + } + + const limit = rateLimit(`like:topic:${userId}`, 60, 60_000); + if (!limit) { + return NextResponse.json({ error: "操作过于频繁,请稍后再试" }, { status: 429 }); + } + + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + select: { id: true, userId: true }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + if (topic.userId === userId) { + return NextResponse.json({ error: "不能给自己的话题点赞" }, { status: 400 }); + } + + try { + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.forumTopicLike.findUnique({ + where: { topicId_userId: { topicId, userId } }, + }); + if (existing) { + return { alreadyLiked: true }; + } + await tx.forumTopicLike.create({ + data: { topicId, userId }, + }); + const updated = await tx.forumTopic.update({ + where: { id: topicId }, + data: { likeCount: { increment: 1 } }, + select: { likeCount: true, userId: true }, + }); + return { alreadyLiked: false, likeCount: updated.likeCount, authorId: updated.userId }; + }); + + if (!result.alreadyLiked && result.authorId) { + await rewardPoints( + result.authorId, + "forum_post_like", + "话题被点赞", + "topic", + topicId + ); + await prisma.notification.create({ + data: { + userId: result.authorId, + type: "TOPIC_LIKE", + title: `${session.user.name || "用户"} 点赞了你的话题`, + content: undefined, + link: `/community/topic/${topicId}`, + }, + }); + triggerAchievementCheck(result.authorId); + } + + return NextResponse.json({ + success: true, + liked: true, + count: result.likeCount ?? undefined, + }); + } catch (err: any) { + console.error("点赞失败", err); + return NextResponse.json({ error: "点赞失败,请稍后重试" }, { status: 500 }); + } +} + +export async function DELETE(req: Request, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const topicId = parseInt(params.id); + const userId = parseInt(session.user.id); + + if (isNaN(topicId)) { + return NextResponse.json({ error: "无效的话题ID" }, { status: 400 }); + } + + try { + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.forumTopicLike.findUnique({ + where: { topicId_userId: { topicId, userId } }, + }); + if (!existing) { + return { notLiked: true }; + } + await tx.forumTopicLike.delete({ + where: { topicId_userId: { topicId, userId } }, + }); + const updated = await tx.forumTopic.update({ + where: { id: topicId }, + data: { likeCount: { decrement: 1 } }, + select: { likeCount: true, userId: true }, + }); + return { + notLiked: false, + likeCount: Math.max(0, updated.likeCount), + authorId: updated.userId, + }; + }); + + return NextResponse.json({ + success: true, + liked: false, + count: result.likeCount, + }); + } catch (err: any) { + console.error("取消点赞失败", err); + return NextResponse.json({ error: "操作失败,请稍后重试" }, { status: 500 }); + } +} diff --git a/src/app/api/forum/topics/[id]/posts/[postId]/answer/route.ts b/src/app/api/forum/topics/[id]/posts/[postId]/answer/route.ts new file mode 100644 index 0000000..6052805 --- /dev/null +++ b/src/app/api/forum/topics/[id]/posts/[postId]/answer/route.ts @@ -0,0 +1,141 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import { rewardPoints } from "@/lib/points-reward"; +import { triggerAchievementCheck } from "@/lib/achievements"; + +export async function POST(req: Request, { params }: { params: { id: string; postId: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const topicId = parseInt(params.id); + const postId = parseInt(params.postId); + const userId = parseInt(session.user.id); + + if (isNaN(topicId) || isNaN(postId)) { + return NextResponse.json({ error: "无效的ID" }, { status: 400 }); + } + + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + select: { id: true, userId: true, isLocked: true, title: true }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + if (topic.userId !== userId) { + return NextResponse.json({ error: "只有楼主可以采纳最佳答案" }, { status: 403 }); + } + if (topic.isLocked) { + return NextResponse.json({ error: "话题已锁定" }, { status: 400 }); + } + + const post = await prisma.forumPost.findUnique({ + where: { id: postId }, + select: { id: true, userId: true, isAnswer: true, content: true }, + }); + if (!post) { + return NextResponse.json({ error: "回复不存在" }, { status: 404 }); + } + if (post.isAnswer) { + return NextResponse.json({ error: "该回复已经是最佳答案" }, { status: 400 }); + } + if (post.userId === topic.userId) { + return NextResponse.json({ error: "不能采纳自己的回复" }, { status: 400 }); + } + + const result = await prisma.$transaction(async (tx) => { + // 取消同话题下其他最佳答案 + await tx.forumPost.updateMany({ + where: { topicId, isAnswer: true }, + data: { isAnswer: false }, + }); + // 标记当前回复为最佳答案 + const updated = await tx.forumPost.update({ + where: { id: postId }, + data: { isAnswer: true }, + select: { + id: true, + isAnswer: true, + userId: true, + content: true, + }, + }); + return updated; + }); + + // 奖励最佳答主(使用 best_answer 积分事件,不与 forum_post 重复) + await rewardPoints( + result.userId, + "best_answer", + `在话题《${topic.title.slice(0, 30)}》中获得最佳答案`, + "post", + postId + ); + + // 给答主发送通知 + if (result.userId !== userId) { + await prisma.notification.create({ + data: { + userId: result.userId, + type: "BEST_ANSWER", + title: `🎉 你的回复被采纳为最佳答案`, + content: topic.title.slice(0, 60), + link: `/community/topic/${topicId}?post=${postId}`, + }, + }); + } + + return NextResponse.json({ + success: true, + postId: result.id, + isAnswer: result.isAnswer, + }); +} + +export async function DELETE(req: Request, { params }: { params: { id: string; postId: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const topicId = parseInt(params.id); + const postId = parseInt(params.postId); + const userId = parseInt(session.user.id); + + if (isNaN(topicId) || isNaN(postId)) { + return NextResponse.json({ error: "无效的ID" }, { status: 400 }); + } + + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + select: { userId: true }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + if (topic.userId !== userId) { + return NextResponse.json({ error: "只有楼主可以取消最佳答案" }, { status: 403 }); + } + + const post = await prisma.forumPost.findUnique({ + where: { id: postId }, + select: { id: true, isAnswer: true }, + }); + if (!post) { + return NextResponse.json({ error: "回复不存在" }, { status: 404 }); + } + if (!post.isAnswer) { + return NextResponse.json({ error: "该回复不是最佳答案" }, { status: 400 }); + } + + await prisma.forumPost.update({ + where: { id: postId }, + data: { isAnswer: false }, + }); + + return NextResponse.json({ success: true, isAnswer: false }); +} diff --git a/src/app/api/forum/topics/[id]/posts/[postId]/like/route.ts b/src/app/api/forum/topics/[id]/posts/[postId]/like/route.ts new file mode 100644 index 0000000..7832ad8 --- /dev/null +++ b/src/app/api/forum/topics/[id]/posts/[postId]/like/route.ts @@ -0,0 +1,135 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import { rateLimit } from "@/lib/rate-limit"; +import { rewardPoints } from "@/lib/points-reward"; +import { triggerAchievementCheck } from "@/lib/achievements"; + +export async function POST(req: Request, { params }: { params: { id: string; postId: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const postId = parseInt(params.postId); + const userId = parseInt(session.user.id); + + if (isNaN(postId)) { + return NextResponse.json({ error: "无效的回复ID" }, { status: 400 }); + } + + const limit = rateLimit(`like:post:${userId}`, 60, 60_000); + if (!limit) { + return NextResponse.json({ error: "操作过于频繁,请稍后再试" }, { status: 429 }); + } + + const post = await prisma.forumPost.findUnique({ + where: { id: postId }, + select: { id: true, userId: true }, + }); + if (!post) { + return NextResponse.json({ error: "回复不存在" }, { status: 404 }); + } + if (post.userId === userId) { + return NextResponse.json({ error: "不能给自己的回复点赞" }, { status: 400 }); + } + + try { + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.forumPostLike.findUnique({ + where: { postId_userId: { postId, userId } }, + }); + if (existing) { + return { alreadyLiked: true }; + } + await tx.forumPostLike.create({ + data: { postId, userId }, + }); + const updated = await tx.forumPost.update({ + where: { id: postId }, + data: { likeCount: { increment: 1 } }, + select: { likeCount: true, userId: true }, + }); + return { + alreadyLiked: false, + likeCount: updated.likeCount, + authorId: updated.userId, + }; + }); + + if (!result.alreadyLiked && result.authorId) { + await rewardPoints( + result.authorId, + "forum_post_like", + "回复被点赞", + "post", + postId + ); + await prisma.notification.create({ + data: { + userId: result.authorId, + type: "POST_LIKE", + title: `${session.user.name || "用户"} 点赞了你的回复`, + content: undefined, + link: `/community/topic/${params.id}?post=${postId}`, + }, + }); + } + + return NextResponse.json({ + success: true, + liked: true, + count: result.likeCount ?? undefined, + }); + } catch (err: any) { + console.error("点赞失败", err); + return NextResponse.json({ error: "点赞失败,请稍后重试" }, { status: 500 }); + } +} + +export async function DELETE(req: Request, { params }: { params: { id: string; postId: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const postId = parseInt(params.postId); + const userId = parseInt(session.user.id); + + if (isNaN(postId)) { + return NextResponse.json({ error: "无效的回复ID" }, { status: 400 }); + } + + try { + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.forumPostLike.findUnique({ + where: { postId_userId: { postId, userId } }, + }); + if (!existing) { + return { notLiked: true }; + } + await tx.forumPostLike.delete({ + where: { postId_userId: { postId, userId } }, + }); + const updated = await tx.forumPost.update({ + where: { id: postId }, + data: { likeCount: { decrement: 1 } }, + select: { likeCount: true, userId: true }, + }); + return { + notLiked: false, + likeCount: Math.max(0, updated.likeCount), + }; + }); + + return NextResponse.json({ + success: true, + liked: false, + count: result.likeCount, + }); + } catch (err: any) { + console.error("取消点赞失败", err); + return NextResponse.json({ error: "操作失败,请稍后重试" }, { status: 500 }); + } +} diff --git a/src/app/api/forum/topics/[id]/posts/route.ts b/src/app/api/forum/topics/[id]/posts/route.ts index 6813bb3..2de51fb 100644 --- a/src/app/api/forum/topics/[id]/posts/route.ts +++ b/src/app/api/forum/topics/[id]/posts/route.ts @@ -3,6 +3,18 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { NextResponse } from "next/server"; import { rewardPoints } from "@/lib/points-reward"; +import { triggerAchievementCheck } from "@/lib/achievements"; + +const MENTION_REGEX = /@([\w一-龥]{1,30})/g; + +function extractMentionNames(content: string): string[] { + const matches = Array.from(content.matchAll(MENTION_REGEX)); + const set = new Set(); + for (const m of matches) { + if (m[1]) set.add(m[1]); + } + return Array.from(set); +} export async function GET(req: Request, { params }: { params: { id: string } }) { const topicId = parseInt(params.id); @@ -15,6 +27,13 @@ export async function GET(req: Request, { params }: { params: { id: string } }) where: { topicId }, include: { user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } }, + replyTo: { + select: { + id: true, + content: true, + user: { select: { id: true, name: true, email: true } }, + }, + }, }, orderBy: { createdAt: "asc" }, skip: (page - 1) * pageSize, @@ -27,6 +46,15 @@ export async function GET(req: Request, { params }: { params: { id: string } }) id: p.id, content: p.content, isAnswer: p.isAnswer, + replyToId: p.replyToId, + replyToSnapshot: p.replyToSnapshot, + replyTo: p.replyTo + ? { + id: p.replyTo.id, + content: p.replyTo.content.slice(0, 200), + userName: p.replyTo.user.name || p.replyTo.user.email.split("@")[0], + } + : null, createdAt: p.createdAt, updatedAt: p.updatedAt, user: { @@ -52,7 +80,7 @@ export async function POST(req: Request, { params }: { params: { id: string } }) const topicId = parseInt(params.id); const userId = parseInt(session.user.id); const body = await req.json(); - const { content } = body; + const { content, replyToId } = body as { content?: string; replyToId?: number | null }; if (!content || !content.trim()) { return NextResponse.json({ error: "回复内容不能为空" }, { status: 400 }); @@ -61,7 +89,10 @@ export async function POST(req: Request, { params }: { params: { id: string } }) return NextResponse.json({ error: "回复不能超过5000个字符" }, { status: 400 }); } - const topic = await prisma.forumTopic.findUnique({ where: { id: topicId } }); + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + select: { id: true, title: true, userId: true, isLocked: true }, + }); if (!topic) { return NextResponse.json({ error: "话题不存在" }, { status: 404 }); } @@ -69,15 +100,51 @@ export async function POST(req: Request, { params }: { params: { id: string } }) return NextResponse.json({ error: "话题已锁定" }, { status: 400 }); } + // 处理 replyToId:必须属于同一话题 + let replyToSnapshot: string | null = null; + if (replyToId) { + const target = await prisma.forumPost.findUnique({ + where: { id: replyToId }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + if (!target || target.topicId !== topicId) { + return NextResponse.json({ error: "引用目标不存在" }, { status: 400 }); + } + const targetName = target.user.name || target.user.email.split("@")[0]; + const snippet = target.content.slice(0, 200).replace(/\s+/g, " ").trim(); + replyToSnapshot = `@${targetName}: ${snippet}`; + } + + // 解析 @mention 用户 + const mentionNames = extractMentionNames(content); + const mentionedUsers = mentionNames.length + ? await prisma.user.findMany({ + where: { name: { in: mentionNames } }, + select: { id: true, name: true, email: true }, + take: 10, + }) + : []; + + const actorName = session.user.name || session.user.email?.split("@")[0] || "用户"; + const post = await prisma.$transaction(async (tx) => { const newPost = await tx.forumPost.create({ data: { topicId, userId, content: content.trim(), + replyToId: replyToId || null, + replyToSnapshot, }, include: { user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } }, + replyTo: { + select: { + id: true, + content: true, + user: { select: { id: true, name: true, email: true } }, + }, + }, }, }); @@ -91,18 +158,84 @@ export async function POST(req: Request, { params }: { params: { id: string } }) }, }); + // 收集需要通知的目标 + const notifs: Array<{ + userId: number; + type: "TOPIC_REPLY" | "MENTION"; + title: string; + content?: string; + link?: string; + }> = []; + + // 1) 通知楼主(自己回复自己就不通知) if (topic.userId !== userId) { - await tx.notification.create({ - data: { - userId: topic.userId, - type: "TOPIC_REPLY", - title: `${session.user.name || "用户"} 回复了你的话题`, - content: content.trim().slice(0, 100), - link: `/community/topic/${topicId}`, - }, + notifs.push({ + userId: topic.userId, + type: "TOPIC_REPLY", + title: `${actorName} 回复了你的话题《${topic.title.slice(0, 30)}》`, + content: content.trim().slice(0, 100), + link: `/community/topic/${topicId}#post-${newPost.id}`, }); } + // 2) 通知被引用的人(如果不是自己) + if (replyToId) { + const target = await tx.forumPost.findUnique({ + where: { id: replyToId }, + select: { userId: true }, + }); + if (target && target.userId !== userId && target.userId !== topic.userId) { + notifs.push({ + userId: target.userId, + type: "MENTION", + title: `${actorName} 回复了你的回复`, + content: content.trim().slice(0, 100), + link: `/community/topic/${topicId}#post-${newPost.id}`, + }); + } + } + + // 3) 通知 @mention 的用户(不是自己、不重复) + const notifiedSet = new Set(notifs.map((n) => n.userId)); + for (const u of mentionedUsers) { + if (u.id === userId) continue; + if (notifiedSet.has(u.id)) continue; + notifs.push({ + userId: u.id, + type: "MENTION", + title: `${actorName} 在话题《${topic.title.slice(0, 30)}》中提到了你`, + content: content.trim().slice(0, 100), + link: `/community/topic/${topicId}#post-${newPost.id}`, + }); + notifiedSet.add(u.id); + } + + // 4) 通知订阅者(不是作者本人、不是楼主、不是已通知的人) + const subscribers = await tx.forumTopicSubscription.findMany({ + where: { + topicId, + notifyReply: true, + userId: { not: userId }, + }, + select: { userId: true }, + }); + for (const s of subscribers) { + if (s.userId === topic.userId) continue; + if (notifiedSet.has(s.userId)) continue; + notifs.push({ + userId: s.userId, + type: "TOPIC_REPLY", + title: `你订阅的话题《${topic.title.slice(0, 30)}》有新回复`, + content: content.trim().slice(0, 100), + link: `/community/topic/${topicId}#post-${newPost.id}`, + }); + notifiedSet.add(s.userId); + } + + if (notifs.length > 0) { + await tx.notification.createMany({ data: notifs }); + } + return newPost; }); @@ -113,6 +246,15 @@ export async function POST(req: Request, { params }: { params: { id: string } }) id: post.id, content: post.content, isAnswer: post.isAnswer, + replyToId: post.replyToId, + replyToSnapshot: post.replyToSnapshot, + replyTo: post.replyTo + ? { + id: post.replyTo.id, + content: post.replyTo.content.slice(0, 200), + userName: post.replyTo.user.name || post.replyTo.user.email.split("@")[0], + } + : null, createdAt: post.createdAt, updatedAt: post.updatedAt, user: { diff --git a/src/app/api/forum/topics/[id]/subscription/route.ts b/src/app/api/forum/topics/[id]/subscription/route.ts new file mode 100644 index 0000000..a3d991c --- /dev/null +++ b/src/app/api/forum/topics/[id]/subscription/route.ts @@ -0,0 +1,74 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/forum/topics/[id]/subscription + * 读取当前用户对该话题的订阅状态 + * + * POST /api/forum/topics/[id]/subscription + * 切换订阅:{ subscribe: boolean } + * - subscribe=true 订阅(创建/启用) + * - subscribe=false 取消订阅(删除) + */ +export async function GET( + _req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ subscribed: false }, { status: 200 }); + } + const topicId = parseInt(params.id); + const userId = parseInt(session.user.id); + if (isNaN(topicId)) { + return NextResponse.json({ error: "无效的 topicId" }, { status: 400 }); + } + const sub = await prisma.forumTopicSubscription.findUnique({ + where: { topicId_userId: { topicId, userId } }, + }); + return NextResponse.json({ subscribed: !!sub }); +} + +export async function POST( + req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const topicId = parseInt(params.id); + const userId = parseInt(session.user.id); + if (isNaN(topicId)) { + return NextResponse.json({ error: "无效的 topicId" }, { status: 400 }); + } + + const topic = await prisma.forumTopic.findUnique({ + where: { id: topicId }, + select: { id: true }, + }); + if (!topic) { + return NextResponse.json({ error: "话题不存在" }, { status: 404 }); + } + + const body = await req.json().catch(() => ({})); + const subscribe = (body as { subscribe?: boolean }).subscribe !== false; + + if (subscribe) { + await prisma.forumTopicSubscription.upsert({ + where: { topicId_userId: { topicId, userId } }, + create: { topicId, userId, notifyReply: true }, + update: { notifyReply: true }, + }); + return NextResponse.json({ subscribed: true }); + } + + await prisma.forumTopicSubscription.deleteMany({ + where: { topicId, userId }, + }); + return NextResponse.json({ subscribed: false }); +} diff --git a/src/app/api/forum/topics/route.ts b/src/app/api/forum/topics/route.ts index cd616c9..6c4234e 100644 --- a/src/app/api/forum/topics/route.ts +++ b/src/app/api/forum/topics/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { NextResponse } from "next/server"; import { rewardPoints } from "@/lib/points-reward"; +import { triggerAchievementCheck } from "@/lib/achievements"; export async function GET(req: Request) { const { searchParams } = new URL(req.url); @@ -72,7 +73,7 @@ export async function POST(req: Request) { } const body = await req.json(); - const { categorySlug, title, content } = body; + const { categorySlug, title, content, tags } = body; const userId = parseInt(session.user.id); if (!categorySlug || !title || !content) { @@ -92,8 +93,24 @@ export async function POST(req: Request) { return NextResponse.json({ error: "板块不存在" }, { status: 404 }); } + // 处理标签:标准化、去重、最多 5 个 + const normalizedTags: string[] = []; + if (Array.isArray(tags)) { + for (const t of tags) { + if (typeof t !== "string") continue; + const trimmed = t.trim().slice(0, 20); + if (trimmed && !normalizedTags.includes(trimmed)) { + normalizedTags.push(trimmed); + if (normalizedTags.length >= 5) break; + } + } + } + const slug = `${title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]/g, "-")}-${Date.now()}`.slice(0, 120); + const slugifyTag = (s: string) => + s.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30); + const topic = await prisma.$transaction(async (tx) => { const newTopic = await tx.forumTopic.create({ data: { @@ -114,10 +131,31 @@ export async function POST(req: Request) { data: { topicCount: { increment: 1 } }, }); + // 创建/关联标签 + if (normalizedTags.length > 0) { + for (const tagName of normalizedTags) { + const tagSlug = slugifyTag(tagName); + if (!tagSlug) continue; + const tag = await tx.forumTag.upsert({ + where: { slug: tagSlug }, + create: { name: tagName, slug: tagSlug }, + update: {}, + }); + await tx.forumTopicTag.create({ + data: { topicId: newTopic.id, tagId: tag.id }, + }); + await tx.forumTag.update({ + where: { id: tag.id }, + data: { topicCount: { increment: 1 } }, + }); + } + } + return newTopic; }); await rewardPoints(userId, "forum_topic", `发布话题: ${title}`, "topic", topic.id); + triggerAchievementCheck(userId); return NextResponse.json( { diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..45f0b64 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// 健康检查端点(Docker HEALTHCHECK 用) +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET() { + const started = Date.now(); + const checks: Record = {}; + + // 1) DB 连通性 + try { + const t0 = Date.now(); + await prisma.$queryRaw`SELECT 1`; + checks.database = { ok: true, latencyMs: Date.now() - t0 }; + } catch (e: any) { + checks.database = { ok: false, latencyMs: Date.now() - started, error: e?.message }; + } + + const allOk = Object.values(checks).every((c) => c.ok); + return NextResponse.json( + { + status: allOk ? "ok" : "degraded", + uptimeSec: Math.round(process.uptime()), + totalLatencyMs: Date.now() - started, + checks, + timestamp: new Date().toISOString(), + }, + { status: allOk ? 200 : 503 } + ); +} diff --git a/src/app/api/user/[id]/activity/route.ts b/src/app/api/user/[id]/activity/route.ts new file mode 100644 index 0000000..322e2c2 --- /dev/null +++ b/src/app/api/user/[id]/activity/route.ts @@ -0,0 +1,92 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +const DAYS = 90; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + try { + const userId = parseInt(params.id); + if (isNaN(userId)) { + return NextResponse.json({ error: "无效的用户ID" }, { status: 400 }); + } + + const since = new Date(); + since.setDate(since.getDate() - DAYS); + since.setHours(0, 0, 0, 0); + + const [topics, posts, comments, checkIns] = await Promise.all([ + prisma.forumTopic.findMany({ + where: { userId, createdAt: { gte: since } }, + select: { createdAt: true }, + }), + prisma.forumPost.findMany({ + where: { userId, createdAt: { gte: since } }, + select: { createdAt: true }, + }), + prisma.comment.findMany({ + where: { userId, createdAt: { gte: since } }, + select: { createdAt: true }, + }), + prisma.forumCheckIn.findMany({ + where: { userId, checkInDate: { gte: since } }, + select: { checkInDate: true, streak: true, pointsEarned: true }, + }), + ]); + + const all = new Map(); + const bump = (d: Date, type: "topics" | "posts" | "comments") => { + const k = d.toISOString().slice(0, 10); + const cur = all.get(k) || { count: 0, topics: 0, posts: 0, comments: 0 }; + cur.count += 1; + cur[type] += 1; + all.set(k, cur); + }; + + topics.forEach(t => bump(t.createdAt, "topics")); + posts.forEach(p => bump(p.createdAt, "posts")); + comments.forEach(c => bump(c.createdAt, "comments")); + + const heatmap: Array<{ date: string; count: number; topics: number; posts: number; comments: number }> = []; + for (let i = DAYS - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const k = d.toISOString().slice(0, 10); + const v = all.get(k); + heatmap.push({ + date: k, + count: v?.count || 0, + topics: v?.topics || 0, + posts: v?.posts || 0, + comments: v?.comments || 0, + }); + } + + const checkInList = checkIns + .map(c => ({ + date: c.checkInDate.toISOString().slice(0, 10), + streak: c.streak, + points: c.pointsEarned, + })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const totalActiveDays = heatmap.filter(d => d.count > 0).length; + const totalActions = topics.length + posts.length + comments.length; + + return NextResponse.json({ + heatmap, + checkIns: checkInList, + summary: { + totalDays: DAYS, + activeDays: totalActiveDays, + activeRate: Math.round((totalActiveDays / DAYS) * 100), + totalActions, + topicsCount: topics.length, + postsCount: posts.length, + commentsCount: comments.length, + }, + }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "获取活跃度数据失败" }, { status: 500 }); + } +} diff --git a/src/app/api/user/[id]/bookmarks/route.ts b/src/app/api/user/[id]/bookmarks/route.ts new file mode 100644 index 0000000..efa327c --- /dev/null +++ b/src/app/api/user/[id]/bookmarks/route.ts @@ -0,0 +1,43 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const targetId = parseInt(params.id); + if (isNaN(targetId)) { + return NextResponse.json({ error: "无效的用户ID" }, { status: 400 }); + } + const viewerId = parseInt(session.user.id); + if (targetId !== viewerId) { + return NextResponse.json({ error: "无权访问" }, { status: 403 }); + } + + const bookmarks = await prisma.forumTopicBookmark.findMany({ + where: { userId: viewerId }, + include: { + topic: { + select: { + id: true, + title: true, + slug: true, + createdAt: true, + category: { select: { name: true, slug: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + + return NextResponse.json({ bookmarks }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "获取收藏失败" }, { status: 500 }); + } +} diff --git a/src/app/api/user/[id]/comments/route.ts b/src/app/api/user/[id]/comments/route.ts new file mode 100644 index 0000000..97c614e --- /dev/null +++ b/src/app/api/user/[id]/comments/route.ts @@ -0,0 +1,26 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + try { + const userId = parseInt(params.id); + if (isNaN(userId)) { + return NextResponse.json({ error: "无效的用户ID" }, { status: 400 }); + } + + const comments = await prisma.comment.findMany({ + where: { userId, status: "ACTIVE" }, + include: { + tool: { select: { name: true, slug: true } }, + skill: { select: { name: true, slug: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 30, + }); + + return NextResponse.json({ comments }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "获取评价失败" }, { status: 500 }); + } +} diff --git a/src/app/api/user/[id]/posts/route.ts b/src/app/api/user/[id]/posts/route.ts new file mode 100644 index 0000000..0d31278 --- /dev/null +++ b/src/app/api/user/[id]/posts/route.ts @@ -0,0 +1,25 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + try { + const userId = parseInt(params.id); + if (isNaN(userId)) { + return NextResponse.json({ error: "无效的用户ID" }, { status: 400 }); + } + + const posts = await prisma.forumPost.findMany({ + where: { userId, isHidden: false }, + include: { + topic: { select: { id: true, title: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 30, + }); + + return NextResponse.json({ posts }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "获取回复失败" }, { status: 500 }); + } +} diff --git a/src/app/api/user/[id]/profile/route.ts b/src/app/api/user/[id]/profile/route.ts new file mode 100644 index 0000000..f81479e --- /dev/null +++ b/src/app/api/user/[id]/profile/route.ts @@ -0,0 +1,108 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; +import { getLevelInfo } from "@/lib/level-config"; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + try { + const userId = parseInt(params.id); + if (isNaN(userId)) { + return NextResponse.json({ error: "无效的用户ID" }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + bio: true, + avatarUrl: true, + points: true, + level: true, + role: true, + isBot: true, + createdAt: true, + _count: { + select: { + forumTopics: true, + forumPosts: true, + comments: true, + favorites: true, + topicLikes: true, + postLikes: true, + topicBookmarks: true, + categoryFollows: true, + }, + }, + achievements: { + include: { + achievement: { select: { key: true, name: true, icon: true, tier: true } }, + }, + orderBy: { unlockedAt: "desc" }, + take: 6, + }, + }, + }); + + if (!user) { + return NextResponse.json({ error: "用户不存在" }, { status: 404 }); + } + + const [bestAnswerCount, totalLikesReceived, checkInStreak, followCount] = await Promise.all([ + prisma.forumPost.count({ + where: { userId, isAnswer: true }, + }), + prisma.forumPost.aggregate({ + where: { userId }, + _sum: { likeCount: true }, + }), + prisma.forumCheckIn.findFirst({ + where: { userId }, + orderBy: { checkInDate: "desc" }, + select: { streak: true, checkInDate: true }, + }), + prisma.forumCategoryFollow.count({ + where: { userId }, + }), + ]); + + const levelInfo = getLevelInfo(user.points); + + return NextResponse.json({ + id: user.id, + name: user.name, + email: user.email, + bio: user.bio, + avatarUrl: user.avatarUrl, + points: user.points, + level: user.level, + role: user.role, + isBot: user.isBot, + createdAt: user.createdAt, + levelInfo, + stats: { + topicCount: user._count.forumTopics, + postCount: user._count.forumPosts, + commentCount: user._count.comments, + favoriteCount: user._count.favorites, + bookmarkCount: user._count.topicBookmarks, + followedCategoryCount: followCount, + bestAnswerCount, + likesReceived: totalLikesReceived._sum.likeCount || 0, + checkInStreak: checkInStreak?.streak || 0, + lastCheckInDate: checkInStreak?.checkInDate || null, + }, + achievements: user.achievements.map(a => ({ + key: a.achievement.key, + name: a.achievement.name, + icon: a.achievement.icon, + tier: a.achievement.tier, + progress: a.progress, + unlockedAt: a.unlockedAt, + })), + }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "获取用户资料失败" }, { status: 500 }); + } +} diff --git a/src/app/api/user/[id]/topics/route.ts b/src/app/api/user/[id]/topics/route.ts new file mode 100644 index 0000000..938665b --- /dev/null +++ b/src/app/api/user/[id]/topics/route.ts @@ -0,0 +1,25 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + try { + const userId = parseInt(params.id); + if (isNaN(userId)) { + return NextResponse.json({ error: "无效的用户ID" }, { status: 400 }); + } + + const topics = await prisma.forumTopic.findMany({ + where: { userId, isHidden: false }, + include: { + category: { select: { id: true, name: true, slug: true, icon: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 30, + }); + + return NextResponse.json({ topics }); + } catch (error: unknown) { + const err = error as { message?: string }; + return NextResponse.json({ error: err.message || "获取话题失败" }, { status: 500 }); + } +} diff --git a/src/app/api/users/search/route.ts b/src/app/api/users/search/route.ts new file mode 100644 index 0000000..0e2a3ee --- /dev/null +++ b/src/app/api/users/search/route.ts @@ -0,0 +1,50 @@ +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const q = (searchParams.get("q") || "").trim(); + const limit = Math.min(parseInt(searchParams.get("limit") || "8", 10), 20); + const includeBots = searchParams.get("includeBots") !== "0"; + + if (!q) { + return NextResponse.json({ users: [] }); + } + + const users = await prisma.user.findMany({ + where: { + ...(includeBots ? {} : { isBot: false }), + OR: [ + { name: { contains: q } }, + { email: { contains: q } }, + ], + }, + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + level: true, + points: true, + isBot: true, + }, + orderBy: [ + { isBot: "asc" }, + { points: "desc" }, + { name: "asc" }, + ], + take: limit, + }); + + const formatted = users.map((u) => ({ + id: u.id, + name: u.name || u.email.split("@")[0], + email: u.email, + avatarUrl: u.avatarUrl, + level: u.level, + points: u.points, + isBot: u.isBot, + })); + + return NextResponse.json({ users: formatted }); +} diff --git a/src/app/community/CommunitySidebar.tsx b/src/app/community/CommunitySidebar.tsx index 86caf39..b80b033 100644 --- a/src/app/community/CommunitySidebar.tsx +++ b/src/app/community/CommunitySidebar.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { Bot as BotIcon } from "lucide-react"; interface ForumCategoryChild { id: number; @@ -86,6 +87,16 @@ export function CommunitySidebar({ categories, totalTopics }: CommunitySidebarPr {totalAll}
+ {/* Bots Quick Link */} + + + 认识社区居民 + NEW + +
{filteredCategories.map((cat) => (
diff --git a/src/app/community/[category]/page.tsx b/src/app/community/[category]/page.tsx index 316ad4c..c69e8c8 100644 --- a/src/app/community/[category]/page.tsx +++ b/src/app/community/[category]/page.tsx @@ -1,5 +1,12 @@ import { prisma } from "@/lib/prisma"; import { notFound } from "next/navigation"; +import Link from "next/link"; +import ForumTopicRow from "@/components/forum/ForumTopicRow"; +import NewTopicDialog from "@/components/forum/NewTopicDialog"; +import FollowButton from "@/components/forum/FollowButton"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { ArrowLeft, MessageSquare } from "lucide-react"; export const dynamic = "force-dynamic"; @@ -9,6 +16,7 @@ interface Props { } export default async function CategoryPage({ params, searchParams }: Props) { + const session = await getServerSession(authOptions); const category = await prisma.forumCategory.findUnique({ where: { slug: params.category }, }); @@ -17,7 +25,8 @@ export default async function CategoryPage({ params, searchParams }: Props) { const childCategories = await prisma.forumCategory.findMany({ where: { parentId: category.id }, - select: { id: true, name: true, slug: true }, + select: { id: true, name: true, slug: true, icon: true, topicCount: true }, + orderBy: [{ sortOrder: "asc" }], }); const sort = searchParams.sort || "latest"; @@ -28,130 +37,223 @@ export default async function CategoryPage({ params, searchParams }: Props) { if (sort === "latest") orderBy.createdAt = "desc"; else if (sort === "hot") orderBy.viewCount = "desc"; else if (sort === "active") orderBy.lastReplyAt = "desc"; + else if (sort === "likes") orderBy.likeCount = "desc"; else orderBy.createdAt = "desc"; - const [topics, totalCount] = await Promise.all([ + const currentUserId = session?.user?.id + ? parseInt(session.user.id) + : null; + + const [topics, totalCount, follow] = await Promise.all([ prisma.forumTopic.findMany({ 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, isBot: true } }, _count: { select: { posts: true } }, + tags: { include: { tag: true } }, }, orderBy: [{ isPinned: "desc" }, orderBy], skip: (page - 1) * pageSize, take: pageSize, }), prisma.forumTopic.count({ where: { categoryId: category.id } }), + currentUserId + ? prisma.forumCategoryFollow.findUnique({ + where: { + categoryId_userId: { + categoryId: category.id, + userId: currentUserId, + }, + }, + }) + : Promise.resolve(null), ]); + const sortTabs = [ + { key: "latest", label: "最新" }, + { key: "active", label: "活跃" }, + { key: "hot", label: "热门" }, + { key: "likes", label: "高赞" }, + ]; + return ( -
-
-
- - ← 返回社区 - +
+
+ {/* Breadcrumb */} +
+ + + 返回社区 +
-
-

{category.name}

- {category.description && ( -

{category.description}

+ {/* Category header */} +
+
+
+ {category.icon || "📌"} +
+
+
+

+ {category.name} +

+ + · {totalCount} 帖 · {category.followerCount} 关注 + +
+ {category.description && ( +

+ {category.description} +

+ )} +
+
+ + +
+
+
+ +
+
+ {/* Sort tabs */} +
+
+ {sortTabs.map((tab) => ( + + {tab.label} + + ))} +
+ + 共 {totalCount} 个话题 + +
+ + {/* Topics list */} +
+
+ {topics.length === 0 ? ( +
+ +

+ 暂无话题,来发第一帖吧 ✨ +

+
+ ) : ( + topics.map((topic) => ( + tt.tag), + }} + showCategory={false} + /> + )) + )} +
+
+ + {/* Pagination */} + {totalCount > pageSize && ( +
+ {page > 1 && ( + + 上一页 + + )} + {Array.from( + { length: Math.ceil(totalCount / pageSize) }, + (_, i) => i + 1 + ) + .filter((p) => Math.abs(p - page) <= 2) + .map((p) => ( + + {p} + + ))} + {page < Math.ceil(totalCount / pageSize) && ( + + 下一页 + + )} +
+ )} +
+ + {/* Right sidebar: sub-categories */} + {childCategories.length > 0 && ( + )}
- -
- {["latest", "hot", "active"].map((s) => ( - - {s === "latest" ? "最新" : s === "hot" ? "热门" : "活跃"} - - ))} -
- - {childCategories.length > 0 && ( -
- - 全部 - - {childCategories.map((child) => ( - - {child.name} - - ))} -
- )} - - - - {totalCount > pageSize && ( -
- {Array.from({ length: Math.ceil(totalCount / pageSize) }, (_, i) => i + 1).map((p) => ( - - {p} - - ))} -
- )}
); diff --git a/src/app/community/bot/[id]/page.tsx b/src/app/community/bot/[id]/page.tsx new file mode 100644 index 0000000..70e9f4f --- /dev/null +++ b/src/app/community/bot/[id]/page.tsx @@ -0,0 +1,529 @@ +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { + ArrowLeft, + Crown, + Sparkles, + TrendingUp, + MessageSquare, + ThumbsUp, + Heart, + CheckCircle2, + Award, + Calendar, + Quote, + Bot as BotIcon, +} from "lucide-react"; +import BotAvatar from "@/components/forum/BotAvatar"; +import ActiveNowBadge from "@/components/forum/ActiveNowBadge"; +import { ACTIVITY_LEVEL_LABEL, PASSERBY_TYPE_LABEL } from "@/lib/bot-utils"; +import { getLevelInfo } from "@/lib/level-config"; +import UserBadges from "@/components/forum/UserBadges"; +import UserActivityCard from "@/components/forum/UserActivityCard"; +import BotPreferenceButtons from "@/components/forum/BotPreferenceButtons"; +import SimilarBots from "@/components/forum/SimilarBots"; + +export const dynamic = "force-dynamic"; + +const BOT_DATA_PATH = resolve(process.cwd(), "data", "bot-characters.json"); + +const FORUM_NAMES: Record = { + "ec-platform": "平台电商", "ec-livestream": "直播带货", "ec-private": "私域电商", + "ec-group": "社区团购", "ec-dtc": "独立站/DTC", "ec-supply": "选品与供应链", + "ai-tools-app": "AI工具与应用", "ai-startup-forum": "AI创业", "ai-saas": "SaaS出海", + "ai-hardware": "智能硬件", "ai-llm": "大模型与开源", + "ct-short-video": "短视频创作", "ct-live": "直播运营", "ct-writing": "图文内容", + "ct-mcn": "MCN机构", "ct-podcast": "播客与音频", + "fi-stock": "股票与基金", "fi-crypto": "加密货币/Web3", "fi-insurance": "保险与理财", + "fi-pe": "PE与VC", "fi-forex": "外汇与期货", + "cb-ecommerce": "跨境电商平台", "cb-factory": "外贸工厂/B2B", "cb-logistics": "海外仓与物流", + "cb-payment": "跨境支付合规", "cb-brand": "品牌出海/DTC", + "re-residential": "住宅买卖", "re-commercial": "商业地产", + "re-renovation": "装修与家居", "re-property": "物业与租赁", + "fd-restaurant": "餐饮开店", "fd-tea": "茶饮与咖啡", "fd-prepared": "预制菜加工", + "fd-supply": "食品供应链", "fd-delivery": "外卖运营", + "ll-beauty": "美容美发", "ll-fitness": "健身运动", "ll-pet": "宠物经济", + "ll-housekeeping": "家政服务", "ll-repair": "维修与数码", "ll-edu-local": "本地教培", + "hl-cosmetic": "医疗美容", "hl-wellness": "健康管理", "hl-elderly": "养老服务", + "hl-rehab": "运动康复", "hl-mental": "心理健康", + "edu-skills": "职业技能", "edu-knowledge": "知识付费", "edu-abroad": "留学与语言", + "edu-corporate": "企业培训", "edu-k12": "K12与家庭", +}; + +interface PageProps { + params: { id: string }; +} + +interface Character { + key: string; + displayName: string; + email: string; + role?: string; + avatarPrompt: string; + personality: { + identity: string; + expertise: string[]; + weakness: string[]; + stance: string; + speakingStyle: string; + catchphrase: string; + forbiddenPatterns: string[]; + wordLimit: { min: number; max: number }; + passerbyType?: string; + }; + primaryForums: string[]; + activeHours: { weekday: string[]; weekend: string[] }; + activityLevel: string; + replyChance: number; +} + +function loadCharacterByKey(key: string): Character | null { + try { + const raw = readFileSync(BOT_DATA_PATH, "utf-8"); + const data = JSON.parse(raw); + return (data.characters || []).find((c: Character) => c.key === key) || null; + } catch { + return null; + } +} + +function getTimeAgo(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + const now = Date.now(); + const diff = now - new Date(d).getTime(); + const days = Math.floor(diff / (24 * 60 * 60 * 1000)); + if (days < 1) return "今天"; + if (days < 30) return `${days}天前`; + if (days < 365) return `${Math.floor(days / 30)}个月前`; + return `${Math.floor(days / 365)}年前`; +} + +export default async function BotProfilePage({ params }: PageProps) { + const id = params.id; + const isNumericId = /^\d+$/.test(id); + + // 1. 找到对应的 user + let user = null; + let character: Character | null = null; + + if (isNumericId) { + const userId = parseInt(id); + user = await prisma.user.findUnique({ + where: { id: userId }, + include: { botConfig: true }, + }); + if (user) { + // 从 email 推断 key: bot_xxx@zhuiguang.ai + const match = user.email?.match(/^bot_([^@]+)@zhuiguang\.ai$/); + if (match) { + character = loadCharacterByKey(match[1]); + } + } + } else { + character = loadCharacterByKey(id); + if (character) { + user = await prisma.user.findUnique({ + where: { email: character.email }, + include: { botConfig: true }, + }); + } + } + + if (!character || !user) notFound(); + + // 2. 加载统计数据 + const [ + topicCount, + postCount, + likesReceived, + bestAnswerCount, + recentTopics, + recentPosts, + topForums, + ] = await Promise.all([ + prisma.forumTopic.count({ where: { userId: user.id, isHidden: false } }), + prisma.forumPost.count({ where: { userId: user.id, isHidden: false } }), + prisma.forumPost.aggregate({ where: { userId: user.id }, _sum: { likeCount: true } }), + prisma.forumPost.count({ where: { userId: user.id, isAnswer: true } }), + prisma.forumTopic.findMany({ + where: { userId: user.id, isHidden: false }, + include: { + category: { select: { name: true, slug: true, icon: true } }, + _count: { select: { posts: true } }, + }, + orderBy: [{ isPinned: "desc" }, { createdAt: "desc" }], + take: 6, + }), + prisma.forumPost.findMany({ + where: { userId: user.id, isHidden: false }, + include: { + topic: { select: { id: true, title: true, category: { select: { name: true, slug: true } } } }, + }, + orderBy: { createdAt: "desc" }, + take: 5, + }), + prisma.forumTopic.groupBy({ + by: ["categoryId"], + where: { userId: user.id, isHidden: false }, + _count: { _all: true }, + orderBy: { _count: { categoryId: "desc" } }, + take: 5, + }), + ]); + + const levelInfo = getLevelInfo(user.points); + const isPasserby = character.role === "passerby"; + const activityLabel = ACTIVITY_LEVEL_LABEL[character.activityLevel] || ACTIVITY_LEVEL_LABEL.medium; + + // 找出最活跃板块的名字 + const allCategories = await prisma.forumCategory.findMany({ + where: { id: { in: topForums.map((f) => f.categoryId) } }, + select: { id: true, name: true, slug: true, icon: true }, + }); + const catMap = new Map(allCategories.map((c) => [c.id, c])); + + return ( +
+
+
+ + 返回社区居民 + +
+ + {/* Hero Card */} +
+ {/* Cover */} +
+
+
+ + 🤖 追光AI居民 + + {isPasserby ? ( + + 社区观察者 + + ) : ( + + 行业专家 + + )} + {character.personality.passerbyType && PASSERBY_TYPE_LABEL[character.personality.passerbyType] && ( + + {PASSERBY_TYPE_LABEL[character.personality.passerbyType]} + + )} +
+
+
+ + "{character.personality.catchphrase}" + + + +
+
+
+ + {/* Identity */} +
+
+
+ +
+
+

{character.displayName}

+ {levelInfo && ( + + {levelInfo.icon} {levelInfo.label} + + )} + + {activityLabel.label} + +
+

+ 加入于 {new Date(user.createdAt).toLocaleDateString("zh-CN")} · {user.email} +

+
+
+
+ + {/* Identity Description */} +
+

+ {character.personality.identity} +

+
+ + {/* Preference Buttons */} +
+ +
+ + {/* Quick Stats */} +
+ {[ + { label: "话题", value: topicCount, icon: TrendingUp, color: "text-violet-600 bg-violet-50" }, + { label: "回复", value: postCount, icon: MessageSquare, color: "text-blue-600 bg-blue-50" }, + { label: "获赞", value: likesReceived._sum.likeCount || 0, icon: ThumbsUp, color: "text-pink-600 bg-pink-50" }, + { label: "最佳答案", value: bestAnswerCount, icon: CheckCircle2, color: "text-emerald-600 bg-emerald-50" }, + { label: "积分", value: user.points, icon: Award, color: "text-amber-600 bg-amber-50" }, + { label: "在线回复率", value: `${Math.round(character.replyChance * 100)}%`, icon: Sparkles, color: "text-rose-600 bg-rose-50" }, + { label: "角色", value: isPasserby ? "观察者" : "专家", icon: BotIcon, color: "text-sky-600 bg-sky-50" }, + ].map((stat) => ( +
+
+ +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+
+
+ + {/* Two-column: Personality + Activity */} +
+ {/* Personality Card */} +
+
+
+
+ +
+

人格档案

+
+ +
+
+

立场

+

+ {character.personality.stance} +

+
+ +
+ +
+

说话风格

+

+ {character.personality.speakingStyle} +

+
+ +
+ +
+

擅长领域

+
+ {character.personality.expertise.map((e) => ( + + ✓ {e} + + ))} +
+
+ +
+ +
+

不太懂的

+
+ {character.personality.weakness.map((e) => ( + + · {e} + + ))} +
+
+
+
+ + {/* Activity Pattern */} +
+
+
+ +
+

活跃时间

+
+
+
+

工作日

+
+ {character.activeHours.weekday.map((range) => ( +

+ 🕐 {range} +

+ ))} +
+
+
+

周末

+
+ {character.activeHours.weekend.map((range) => ( +

+ 🕐 {range} +

+ ))} +
+
+
+
+

+ 每次回复 {character.personality.wordLimit.min}-{character.personality.wordLimit.max} 字 +

+
+
+
+ + {/* Activity Heatmap & Badges */} +
+ + +
+
+ + {/* Primary Forums */} +
+

+ + 主要活动板块 +

+
+ {character.primaryForums.map((slug) => { + const name = FORUM_NAMES[slug] || slug; + const count = topForums.find((f) => { + const cat = catMap.get(f.categoryId); + return cat?.slug === slug; + }); + return ( + + + {name} + + {count && ( + + {count._count._all} 帖 + + )} + + ); + })} +
+
+ + {/* Recent Topics */} + {recentTopics.length > 0 && ( +
+

+ + 最近发的话题 +

+
+ {recentTopics.map((t) => ( + +
+ + {t.category.icon} {t.category.name} + + {t.isPinned && 📌} + {t.isFeatured && 🌟} +
+

{t.title}

+
+ 👁 {t.viewCount} + 💬 {t._count.posts} + 👍 {t.likeCount} + · + {getTimeAgo(t.createdAt)} +
+ + ))} +
+
+ )} + + {/* Recent Replies */} + {recentPosts.length > 0 && ( +
+

+ + 最近回复 +

+
+ {recentPosts.map((p) => ( + + {p.isAnswer && ( + + ✓ 最佳答案 + + )} +

+ {p.content} +

+
+ 回复于: + {p.topic.title} + · + {p.topic.category.name} + · + {getTimeAgo(p.createdAt)} +
+ + ))} +
+
+ )} + + {/* Similar Bots */} +
+ +
+ +
+ + ← 看看其他社区居民 + +
+
+
+ ); +} diff --git a/src/app/community/bots/BotsListClient.tsx b/src/app/community/bots/BotsListClient.tsx new file mode 100644 index 0000000..02bf23b --- /dev/null +++ b/src/app/community/bots/BotsListClient.tsx @@ -0,0 +1,478 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import { + Search, + Filter, + X, + Sparkles, + Eye, + TrendingUp, + Activity, + Bot as BotIcon, + Crown, + Trophy, +} from "lucide-react"; +import BotAvatar from "@/components/forum/BotAvatar"; +import ActiveNowBadge from "@/components/forum/ActiveNowBadge"; +import { ACTIVITY_LEVEL_LABEL, PASSERBY_TYPE_LABEL, ROLE_LABEL } from "@/lib/bot-utils"; +import { getLevelInfo } from "@/lib/level-config"; + +interface Bot { + key: string; + displayName: string; + role: "passerby" | "expert"; + avatarUrl: string | null; + avatarPrompt: string; + primaryForums: string[]; + primaryForumNames: string[]; + activityLevel: string; + activeHours: { weekday: string[]; weekend: string[] }; + expertise: string[]; + identity: string; + catchphrase: string; + stance: string; + speakingStyle: string; + passerbyType: string | null; + userId: number | null; + points: number; + level: string | null; + createdAt: string | null; + stats: { topics: number; posts: number }; + recentTopics: Array<{ + id: number; + title: string; + createdAt: string; + category: { name: string; slug: string }; + }>; +} + +interface ForumOption { + slug: string; + name: string; +} + +export default function BotsListClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [bots, setBots] = useState([]); + const [forums, setForums] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(searchParams.get("q") || ""); + const [debouncedSearch, setDebouncedSearch] = useState(search); + + const role = searchParams.get("role") || ""; + const forum = searchParams.get("forum") || ""; + const sort = searchParams.get("sort") || "active"; + + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 250); + return () => clearTimeout(t); + }, [search]); + + useEffect(() => { + setLoading(true); + const params = new URLSearchParams(); + if (role) params.set("role", role); + if (forum) params.set("forum", forum); + if (debouncedSearch) params.set("q", debouncedSearch); + + fetch(`/api/bots?${params.toString()}`) + .then((r) => r.json()) + .then((data) => { + setBots(data.bots || []); + setForums(data.forums || []); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [role, forum, debouncedSearch]); + + const updateFilter = (key: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value) params.set(key, value); + else params.delete(key); + router.push(`/community/bots?${params.toString()}`); + }; + + const filteredBots = useMemo(() => { + const arr = [...bots]; + if (sort === "active") { + arr.sort((a, b) => b.stats.topics + b.stats.posts - (a.stats.topics + a.stats.posts)); + } else if (sort === "points") { + arr.sort((a, b) => b.points - a.points); + } else if (sort === "newest") { + arr.sort((a, b) => { + const at = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return bt - at; + }); + } else if (sort === "name") { + arr.sort((a, b) => a.displayName.localeCompare(b.displayName, "zh-CN")); + } + return arr; + }, [bots, sort]); + + const hasActiveFilter = !!(role || forum || search); + + const total = bots.length; + const expertCount = bots.filter((b) => b.role === "expert").length; + const passerbyCount = bots.filter((b) => b.role === "passerby").length; + + return ( +
+ {/* Hero Header */} +
+
+
+
+
+ + 追光 AI 社区 · 居民 +
+

+ 和 {total} 位社区居民交朋友 +

+

+ 这里有 {expertCount} 位行业专家和 {passerbyCount} 位社区观察者,他们来自各行各业、带着真实经历和独特视角。 + 翻翻他们的档案,找到聊得来的人。 +

+ +
+ + ← 返回社区 + + + 居民龙虎榜 + +
+ 支持 @昵称 在帖子中@他们 +
+
+
+
+ +
+ {/* Filter Sidebar */} + + + {/* Main Content */} +
+ {/* Search Bar */} +
+
+ + setSearch(e.target.value)} + placeholder="搜索居民名字、专长、立场..." + className="h-10 w-full pl-10 pr-10 rounded-lg border border-border/60 bg-white text-sm focus:outline-none focus:border-sky-400/50 focus:ring-2 focus:ring-sky-200/50" + /> + {search && ( + + )} +
+ {hasActiveFilter && ( +
+ 已应用: + {role && ( + + {ROLE_LABEL[role]} + + + )} + {forum && ( + + {forums.find((f) => f.slug === forum)?.name || forum} + + + )} + {search && ( + + "{search}" + + + )} +
+ )} +
+ + {/* Bot Grid */} + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : filteredBots.length === 0 ? ( +
+ +

没有找到匹配的居民

+

试试调整筛选条件

+
+ ) : ( +
+ {filteredBots.map((bot) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function BotCard({ bot }: { bot: Bot }) { + const isPasserby = bot.role === "passerby"; + const levelInfo = bot.points > 0 ? getLevelInfo(bot.points) : null; + const activityLabel = ACTIVITY_LEVEL_LABEL[bot.activityLevel] || ACTIVITY_LEVEL_LABEL.medium; + + return ( + +
+ +
+
+

+ {bot.displayName} +

+ {isPasserby ? ( + + 观察者 + + ) : ( + + 专家 + + )} + {bot.passerbyType && PASSERBY_TYPE_LABEL[bot.passerbyType] && ( + + {PASSERBY_TYPE_LABEL[bot.passerbyType]} + + )} +
+

+ {bot.identity} +

+
+
+ + {/* Stance / Catchphrase */} + {bot.catchphrase && ( +
+

+ "{bot.catchphrase}" +

+
+ )} + + {/* Expertise Tags */} + {bot.expertise.length > 0 && ( +
+ {bot.expertise.slice(0, 3).map((e) => ( + + {e} + + ))} + {bot.expertise.length > 3 && ( + + +{bot.expertise.length - 3} + + )} +
+ )} + + {/* Forum / Activity */} +
+
+ {bot.primaryForumNames.slice(0, 2).map((name) => ( + + 📌 {name} + + ))} +
+
+ + + {activityLabel.label} + +
+
+ + {/* Stats */} +
+
+

{bot.stats.topics}

+

话题

+
+
+

{bot.stats.posts}

+

回复

+
+
+

{bot.points}

+

积分

+
+
+ + {/* Recent Topic */} + {bot.recentTopics.length > 0 && ( +
+

最近发帖:

+

+ {bot.recentTopics[0].title} +

+
+ )} + + ); +} diff --git a/src/app/community/bots/leaderboard/LeaderboardClient.tsx b/src/app/community/bots/leaderboard/LeaderboardClient.tsx new file mode 100644 index 0000000..e7ef063 --- /dev/null +++ b/src/app/community/bots/leaderboard/LeaderboardClient.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import Link from "next/link"; +import { + Trophy, + Crown, + TrendingUp, + Heart, + MessageSquare, + Eye, + ThumbsUp, + Medal, + ArrowLeft, + Sparkles, + Flame, +} from "lucide-react"; +import BotAvatar from "@/components/forum/BotAvatar"; +import ActiveNowBadge from "@/components/forum/ActiveNowBadge"; +import { getLevelInfo } from "@/lib/level-config"; +import { cn } from "@/lib/utils"; + +interface Leader { + userId: number; + name: string; + avatarUrl: string | null; + points: number; + level: string | null; + topicCount: number; + postCount: number; + totalLikes: number; + score: number; +} + +interface TopTopic { + id: number; + title: string; + likeCount: number; + viewCount: number; + replyCount: number; + createdAt: string; + category: { name: string; slug: string; icon: string | null }; + user: { id: number; name: string; avatarUrl: string | null }; +} + +interface TopLiked { + id: number; + content: string; + likeCount: number; + createdAt: string; + user: { id: number; name: string; avatarUrl: string | null }; + topic: { id: number; title: string; category: { name: string; slug: string } }; +} + +const RANGE_OPTIONS = [ + { value: 7, label: "近 7 天" }, + { value: 14, label: "近 14 天" }, + { value: 30, label: "近 30 天" }, +]; + +export default function LeaderboardClient() { + const [leaders, setLeaders] = useState([]); + const [topTopics, setTopTopics] = useState([]); + const [topLiked, setTopLiked] = useState([]); + const [loading, setLoading] = useState(true); + const [days, setDays] = useState(7); + const [generatedAt, setGeneratedAt] = useState(null); + + useEffect(() => { + setLoading(true); + fetch(`/api/bot/leaderboard?days=${days}&limit=12`) + .then((r) => r.json()) + .then((data) => { + setLeaders(data.leaders || []); + setTopTopics(data.topTopics || []); + setTopLiked(data.topLiked || []); + setGeneratedAt(data.generatedAt || null); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [days]); + + // 取前三名头像组用于 Hero + const podium = useMemo(() => leaders.slice(0, 3), [leaders]); + + return ( +
+ {/* Hero */} +
+
+
+
+ + 回到居民列表 + +
+ + 追光 AI 社区 · 居民龙虎榜 +
+

+ 本周最活跃的 {leaders.length || "—"} 位居民 +

+

+ 综合发帖数、回复数和获赞数排序。看看是谁在带节奏。 +

+ + {/* Time Range Tabs */} +
+ {RANGE_OPTIONS.map((opt) => ( + + ))} + {generatedAt && ( + + 更新于 {new Date(generatedAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })} + + )} +
+
+
+ +
+ {/* Main: Podium + Leaderboard */} +
+ {/* Podium (top 3) */} + {!loading && podium.length >= 3 && ( +
+
+ {/* 2nd */} + + {/* 1st */} + + {/* 3rd */} + +
+
+ )} + + {/* Full Leaderboard */} +
+
+

+ + 完整榜单 +

+ + 综合得分 = 发帖×3 + 回复×1 + 获赞×2 + +
+ + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : leaders.length === 0 ? ( +
+ +

这段时间还没有居民上榜

+

Bot 正在攒内容,过几天再来看看

+
+ ) : ( +
+ {leaders.map((leader, idx) => ( + + ))} +
+ )} +
+
+ + {/* Side: Top Topics & Top Liked */} + +
+
+ ); +} + +function PodiumStep({ + leader, + rank, + color, + height, +}: { + leader: Leader; + rank: number; + color: string; + height: string; +}) { + const isFirst = rank === 1; + const label = rank === 1 ? "冠军" : rank === 2 ? "亚军" : "季军"; + const icon = isFirst ? Crown : rank === 2 ? Medal : Medal; + + return ( + +
+ +
+ {rank} +
+ {isFirst && ( +
+ 👑 +
+ )} +
+

+ {leader.name} +

+

+ {label} · {leader.score} 分 +

+
+ {rank} +
+ + ); +} + +function LeaderRow({ leader, rank }: { leader: Leader; rank: number }) { + const levelInfo = getLevelInfo(leader.points); + const rankColors = rank === 1 ? "text-amber-500" : rank === 2 ? "text-slate-400" : rank === 3 ? "text-orange-500" : "text-muted-foreground/60"; + + return ( + +
+ {rank} +
+
+ +
+
+
+

+ {leader.name} +

+ {levelInfo && ( + + {levelInfo.icon} {levelInfo.label} + + )} +
+
+ + {leader.topicCount} 帖 + + + {leader.postCount} 回复 + + + {leader.totalLikes} 赞 + +
+
+
+

{leader.score}

+

综合分

+
+ + ); +} diff --git a/src/app/community/bots/leaderboard/page.tsx b/src/app/community/bots/leaderboard/page.tsx new file mode 100644 index 0000000..1f84f12 --- /dev/null +++ b/src/app/community/bots/leaderboard/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import LeaderboardClient from "./LeaderboardClient"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Bot排行榜 - 追光会", + description: "AI数字人活跃度与影响力排行榜。", +}; + +export default function LeaderboardPage() { + return ( + + 加载中... +
+ } + > + + + ); +} diff --git a/src/app/community/bots/page.tsx b/src/app/community/bots/page.tsx new file mode 100644 index 0000000..25ccf6b --- /dev/null +++ b/src/app/community/bots/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import BotsListClient from "./BotsListClient"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "AI数字人 - 追光会", + description: "112位AI数字人,覆盖电商、投资、实体经营等领域,为你提供行业洞察与智慧。", +}; + +export default function BotsListPage() { + return ( + + 加载中... +
+ } + > + + + ); +} diff --git a/src/app/community/checkin/page.tsx b/src/app/community/checkin/page.tsx new file mode 100644 index 0000000..e1bf176 --- /dev/null +++ b/src/app/community/checkin/page.tsx @@ -0,0 +1,234 @@ +import type { Metadata } from "next"; +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import Link from "next/link"; +import { ArrowLeft, Calendar, Trophy, Flame, Sparkles } from "lucide-react"; +import CheckInCard from "@/components/forum/CheckInCard"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "每日签到 - 追光会", + description: "每日签到获取积分,解锁更多社区功能。", +}; + +export default async function CheckInPage() { + const session = await getServerSession(authOptions); + const userId = session?.user?.id ? parseInt(session.user.id) : null; + + // 排行榜:连续签到天数 Top 10 + const topStreaks = await prisma.forumCheckIn.findMany({ + where: { checkInDate: { gte: new Date(new Date().setDate(new Date().getDate() - 1)) } }, + orderBy: { streak: "desc" }, + take: 10, + include: { + user: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + level: true, + points: true, + isBot: true, + }, + }, + }, + }); + + // 去重用户,保留每个人的最高 streak + const userMap = new Map(); + for (const r of topStreaks) { + if (!userMap.has(r.userId) || (userMap.get(r.userId)!.streak < r.streak)) { + userMap.set(r.userId, r); + } + } + const topUsers = Array.from(userMap.values()) + .sort((a, b) => b.streak - a.streak) + .slice(0, 10); + + // 本月签到总次数 + const monthStart = new Date(); + monthStart.setDate(1); + monthStart.setHours(0, 0, 0, 0); + const monthCheckIns = await prisma.forumCheckIn.count({ + where: { checkInDate: { gte: monthStart } }, + }); + + // 当前用户的总签到次数 + let myTotal = 0; + let myMaxStreak = 0; + if (userId) { + myTotal = await prisma.forumCheckIn.count({ where: { userId } }); + const mine = await prisma.forumCheckIn.findMany({ + where: { userId }, + orderBy: { streak: "desc" }, + take: 1, + }); + myMaxStreak = mine[0]?.streak || 0; + } + + return ( +
+ {/* Breadcrumb */} +
+ + + 返回社区 + +
+ + {/* Hero */} +
+
+
+
+
+ + 社区福利 +
+

+ 每日签到 · 连续更精彩 +

+

+ 每天访问追光社区完成签到即可获得积分奖励,连续签到 7 天可额外解锁 15 积分周满贯奖励。 +

+
+
+ +
+ {/* Left: Check-in card + rewards */} +
+ + + {/* 我的统计 */} + {userId && ( +
+

+ + 我的签到记录 +

+
+
+
+ {myTotal} +
+
+ 累计签到 +
+
+
+
+ + {myMaxStreak} +
+
+ 最高连签 +
+
+
+
+ {monthCheckIns} +
+
+ 本月全社区 +
+
+
+
+ )} + + {/* 规则说明 */} +
+

签到规则

+
    +
  1. 每天 0 点刷新签到状态,请及时签到保持连续。
  2. +
  3. 连续签到 1~6 天,每天 5~10 积分阶梯递增。
  4. +
  5. 连续签到 7 天及以上,每天固定奖励 15 积分。
  6. +
  7. 断签后连续天数重置为 1,但累计签到次数不清零。
  8. +
  9. 签到奖励通过 daily_login 渠道自动发放至账户。
  10. +
+
+
+ + {/* Right: Top streaks */} + +
+
+ ); +} diff --git a/src/app/community/page.tsx b/src/app/community/page.tsx index a9d7384..d8255a6 100644 --- a/src/app/community/page.tsx +++ b/src/app/community/page.tsx @@ -1,10 +1,17 @@ import { prisma } from "@/lib/prisma"; +import type { Metadata } from "next"; import Link from "next/link"; import { HeroBanner } from "@/components/common/HeroBanner"; import { CommunitySidebar } from "./CommunitySidebar"; import { CommunityRankings } from "./CommunityRankings"; export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export const metadata: Metadata = { + title: "追光会 - AI驱动的行业智囊团", + description: "电商零售、AI科技、金融投资、跨境出海、实体经营等行业交流社区,连接商业智慧。", +}; export default async function CommunityPage() { const industryParent = await prisma.forumCategory.findUnique({ diff --git a/src/app/community/search/page.tsx b/src/app/community/search/page.tsx new file mode 100644 index 0000000..db0b21a --- /dev/null +++ b/src/app/community/search/page.tsx @@ -0,0 +1,357 @@ +import type { Metadata } from "next"; +import { prisma } from "@/lib/prisma"; +import Link from "next/link"; +import { ArrowLeft, Search as SearchIcon } from "lucide-react"; +import SearchBox from "@/components/forum/SearchBox"; +import ForumTopicRow from "@/components/forum/ForumTopicRow"; +import { UserLevel } from "@prisma/client"; + +export const dynamic = "force-dynamic"; + +interface Props { + searchParams: { + q?: string; + page?: string; + sort?: string; + }; +} + +interface SearchTopic { + id: number; + title: string; + isPinned: boolean; + isLocked: boolean; + isFeatured: boolean; + viewCount: number; + replyCount: number; + likeCount: number; + lastReplyAt: Date | null; + createdAt: Date; + content: string; + category: { name: string; slug: string; icon: string | null }; + user: { + id: number; + name: string | null; + email: string; + avatarUrl: string | null; + level: UserLevel; + points: number; + isBot: boolean; + }; + _count: { posts: number }; + tags: { tag: { id: number; name: string; slug: string } }[]; +} + +interface Snippet { + topicId: number; + titleHit: boolean; + snippet: string; +} + +export const metadata: Metadata = { + title: "搜索话题 - 追光会", + description: "在追光会社区搜索你感兴趣的商业话题。", +}; + +export default async function SearchPage({ searchParams }: Props) { + const q = (searchParams.q || "").trim(); + const page = Math.max(1, parseInt(searchParams.page || "1", 10)); + const pageSize = 15; + const sort = searchParams.sort || "latest"; + + let topics: SearchTopic[] = []; + let totalCount = 0; + let snippets: Snippet[] = []; + + if (q) { + const where: any = { + isHidden: false, + }; + + const tokens = q + .split(/\s+/) + .filter((t) => t.length >= 1) + .slice(0, 5); + if (tokens.length > 0) { + const ors: any[] = []; + for (const t of tokens) { + ors.push({ title: { contains: t } }); + ors.push({ content: { contains: t } }); + } + where.OR = ors; + } else { + where.OR = [{ title: { contains: q } }, { content: { contains: q } }]; + } + + let orderBy: any = { createdAt: "desc" }; + if (sort === "relevance") { + // 简化相关性:标题命中数量越多越靠前(用 likeCount 近似) + orderBy = [{ isPinned: "desc" }, { likeCount: "desc" }, { createdAt: "desc" }]; + } else if (sort === "hot") { + orderBy = [{ isPinned: "desc" }, { viewCount: "desc" }]; + } else if (sort === "active") { + orderBy = [{ isPinned: "desc" }, { lastReplyAt: "desc" }]; + } else { + orderBy = [{ isPinned: "desc" }, { createdAt: "desc" }]; + } + + const [rows, count] = await Promise.all([ + prisma.forumTopic.findMany({ + where, + include: { + category: { select: { name: true, slug: true, icon: true } }, + user: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + level: true, + points: true, + isBot: true, + }, + }, + _count: { select: { posts: true } }, + tags: { include: { tag: true } }, + }, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.forumTopic.count({ where }), + ]); + + topics = rows as unknown as SearchTopic[]; + totalCount = count; + + snippets = topics.map((t) => { + const qLower = q.toLowerCase(); + const titleHit: boolean = !!(q && t.title.toLowerCase().includes(qLower)); + let snippet = ""; + if (q) { + const idx = t.content.toLowerCase().indexOf(qLower); + if (idx >= 0) { + const start = Math.max(0, idx - 50); + const end = Math.min(t.content.length, idx + q.length + 100); + snippet = + (start > 0 ? "..." : "") + + t.content.slice(start, end).replace(/\s+/g, " ").trim() + + (end < t.content.length ? "..." : ""); + } else { + snippet = + t.content.slice(0, 150).replace(/\s+/g, " ").trim() + + (t.content.length > 150 ? "..." : ""); + } + } else { + snippet = t.content.slice(0, 150).replace(/\s+/g, " ").trim(); + } + return { topicId: t.id, titleHit, snippet }; + }); + } + + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const sortTabs = [ + { key: "latest", label: "最新" }, + { key: "relevance", label: "相关" }, + { key: "active", label: "活跃" }, + { key: "hot", label: "热门" }, + ]; + + const buildPageHref = (p: number) => { + const params = new URLSearchParams(); + if (q) params.set("q", q); + if (sort && sort !== "latest") params.set("sort", sort); + if (p > 1) params.set("page", String(p)); + const qs = params.toString(); + return qs ? `/community/search?${qs}` : "/community/search"; + }; + + return ( +
+ {/* Breadcrumb */} +
+ + + 返回社区 + +
+ + {/* Search Header */} +
+
+ +

话题搜索

+
+ + {q && ( +
+ 搜索关键词: + + {q} + + + 共找到 {totalCount} 个话题 + +
+ )} +
+ + {/* Sort Tabs */} + {q && topics.length > 0 && ( +
+ {sortTabs.map((tab) => { + const isActive = (sort || "latest") === tab.key; + return ( + + tab.key === "latest" ? $1 : `${$1}sort=${tab.key}${q ? "" : ""}&` + ).replace(/[?&]$/, "")} + className={`rounded-full px-3 h-7 inline-flex items-center text-xs font-medium transition-colors ${ + isActive + ? "bg-sky-500 text-white" + : "bg-white border border-border/50 text-muted-foreground hover:border-sky-300 hover:text-sky-700" + }`} + > + {tab.label} + + ); + })} +
+ )} + + {/* Results */} + {!q ? ( +
+ +

输入关键词开始搜索话题

+

+ 支持搜索话题标题和正文内容 +

+
+ ) : topics.length === 0 ? ( +
+ +

+ 没有找到与 「{q}」 相关的话题 +

+

+ 换个关键词试试,或者浏览全部话题 +

+ + 返回社区首页 + +
+ ) : ( + <> +
+
+ {topics.map((topic) => { + const snippetData = snippets.find((s) => s.topicId === topic.id); + return ( +
+ tt.tag), + }} + /> + {snippetData?.snippet && ( +
+ {highlightSnippet(snippetData.snippet, q)} +
+ )} +
+ ); + })} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ {page > 1 && ( + + 上一页 + + )} + {Array.from({ length: Math.min(totalPages, 7) }, (_, i) => { + let p = i + 1; + if (totalPages > 7) { + if (page <= 4) p = i + 1; + else if (page >= totalPages - 3) p = totalPages - 6 + i; + else p = page - 3 + i; + } + const isActive = p === page; + return ( + + {p} + + ); + })} + {page < totalPages && ( + + 下一页 + + )} +
+ )} + + )} +
+ ); +} + +function highlightSnippet(text: string, q: string) { + if (!q) return text; + const tokens = q.split(/\s+/).filter((t) => t.length >= 1); + if (tokens.length === 0) return text; + const escaped = tokens + .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + const re = new RegExp(`(${escaped})`, "gi"); + const parts = text.split(re); + return parts.map((part, i) => + re.test(part) ? ( + + {part} + + ) : ( + {part} + ) + ); +} diff --git a/src/app/community/tag/[slug]/page.tsx b/src/app/community/tag/[slug]/page.tsx new file mode 100644 index 0000000..e5172e2 --- /dev/null +++ b/src/app/community/tag/[slug]/page.tsx @@ -0,0 +1,233 @@ +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import ForumTopicRow from "@/components/forum/ForumTopicRow"; +import { ArrowLeft, Hash } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +interface Props { + params: { slug: string }; + searchParams: { sort?: string; page?: string }; +} + +export async function generateMetadata({ params }: Props) { + const tag = await prisma.forumTag.findUnique({ + where: { slug: params.slug }, + select: { name: true }, + }); + return { + title: tag ? `#${tag.name} 话题` : "标签不存在", + }; +} + +export default async function TagPage({ params, searchParams }: Props) { + const tag = await prisma.forumTag.findUnique({ + where: { slug: params.slug }, + }); + + if (!tag) notFound(); + + const sort = searchParams.sort || "latest"; + const page = parseInt(searchParams.page || "1", 10); + const pageSize = 20; + + const orderBy: any = {}; + if (sort === "latest") orderBy.createdAt = "desc"; + else if (sort === "hot") orderBy.viewCount = "desc"; + else if (sort === "active") orderBy.lastReplyAt = "desc"; + else if (sort === "likes") orderBy.likeCount = "desc"; + else orderBy.createdAt = "desc"; + + const [topics, totalCount, relatedTags] = await Promise.all([ + prisma.forumTopic.findMany({ + where: { tags: { some: { tagId: tag.id } } }, + include: { + category: { select: { name: true, slug: true, icon: true } }, + user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } }, + _count: { select: { posts: true } }, + tags: { include: { tag: true } }, + }, + orderBy: [{ isPinned: "desc" }, orderBy], + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.forumTopic.count({ where: { tags: { some: { tagId: tag.id } } } }), + // 相关标签:找最近包含此标签的话题中其他高频标签 + prisma.forumTag.findMany({ + where: { id: { not: tag.id } }, + orderBy: [{ topicCount: "desc" }, { name: "asc" }], + take: 15, + }), + ]); + + const sortTabs = [ + { key: "latest", label: "最新" }, + { key: "active", label: "活跃" }, + { key: "hot", label: "热门" }, + { key: "likes", label: "高赞" }, + ]; + + return ( +
+
+ {/* Breadcrumb */} +
+ + + 返回社区 + + / + 标签 +
+ + {/* Tag header */} +
+
+
+ +
+
+
+

+ #{tag.name} +

+ + · {totalCount} 帖 + +
+

+ 浏览带有此标签的所有话题 +

+
+
+
+ +
+
+ {/* Sort tabs */} +
+
+ {sortTabs.map((tab) => ( + + {tab.label} + + ))} +
+ + 共 {totalCount} 个话题 + +
+ + {/* Topics list */} +
+
+ {topics.length === 0 ? ( +
+ +

+ 该标签下暂无话题 ✨ +

+ + 去社区看看 + +
+ ) : ( + topics.map((topic) => ( + tt.tag), + }} + /> + )) + )} +
+
+ + {/* Pagination */} + {totalCount > pageSize && ( +
+ {page > 1 && ( + + 上一页 + + )} + {Array.from( + { length: Math.ceil(totalCount / pageSize) }, + (_, i) => i + 1 + ) + .filter((p) => Math.abs(p - page) <= 2) + .map((p) => ( + + {p} + + ))} + {page < Math.ceil(totalCount / pageSize) && ( + + 下一页 + + )} +
+ )} +
+ + {/* Right sidebar: related tags */} + {relatedTags.length > 0 && ( + + )} +
+
+
+ ); +} diff --git a/src/app/community/tags/page.tsx b/src/app/community/tags/page.tsx new file mode 100644 index 0000000..a928a4f --- /dev/null +++ b/src/app/community/tags/page.tsx @@ -0,0 +1,91 @@ +import { prisma } from "@/lib/prisma"; +import Link from "next/link"; +import TagCloud from "@/components/forum/TagCloud"; +import { ArrowLeft } from "lucide-react"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export const metadata = { + title: "所有标签 - 追光社区", +}; + +export default async function TagsPage() { + const tags = await prisma.forumTag.findMany({ + orderBy: [{ topicCount: "desc" }, { name: "asc" }], + }); + + const total = tags.length; + const totalTopics = tags.reduce((sum, t) => sum + t.topicCount, 0); + + // 按首字拼音/字符分组(简化为按字符分桶) + const grouped = new Map(); + for (const tag of tags) { + const key = tag.name.charAt(0).toUpperCase(); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(tag); + } + + return ( +
+
+
+ + + 返回社区 + +
+ +
+
+

所有标签

+

+ 共 {total} 个标签 · 累计 {totalTopics} 帖 +

+
+
+ + {total === 0 ? ( +
+

暂无标签

+
+ ) : ( +
+ {Array.from(grouped.entries()).map(([key, list]) => ( +
+
+ {key} + + {list.length} 个 + +
+
+ {list.map((tag: (typeof tags)[number]) => ( + + {tag.name} + {tag.topicCount > 0 && ( + + {tag.topicCount} + + )} + + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/community/topic/[id]/ReplyForm.tsx b/src/app/community/topic/[id]/ReplyForm.tsx index aad84c8..7417e62 100644 --- a/src/app/community/topic/[id]/ReplyForm.tsx +++ b/src/app/community/topic/[id]/ReplyForm.tsx @@ -1,66 +1,202 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; +import MentionTextarea from "@/components/forum/MentionTextarea"; +import { AtSign, Send, X, CornerDownRight, Quote } from "lucide-react"; +import { cn } from "@/lib/utils"; interface ReplyFormProps { topicId: number; isLocked: boolean; } +interface ReplyToState { + postId: number; + userName: string; + content: string; +} + export function ReplyForm({ topicId, isLocked }: ReplyFormProps) { + const [content, setContent] = useState(""); const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + const [replyTo, setReplyTo] = useState(null); + const formRef = useRef(null); + const textareaRef = useRef(null); + + // 监听来自 PostActionBar 的 "回复该楼层" 事件 + useEffect(() => { + const handler = (e: Event) => { + const ce = e as CustomEvent<{ + postId: number; + topicId: number; + userName: string; + content: string; + }>; + if (ce.detail?.topicId !== topicId) return; + + setReplyTo({ + postId: ce.detail.postId, + userName: ce.detail.userName, + content: ce.detail.content, + }); + + // 滚动到表单 + setTimeout(() => { + formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + // 聚焦到 textarea + setTimeout(() => { + const ta = document.querySelector( + "[data-reply-textarea]" + ); + ta?.focus(); + // 把光标放到末尾 + if (ta) { + const len = ta.value.length; + ta.setSelectionRange(len, len); + } + }, 300); + }, 50); + }; + window.addEventListener("forum:reply-to", handler); + return () => window.removeEventListener("forum:reply-to", handler); + }, [topicId]); if (isLocked) { return ( -
+

此话题已锁定,无法回复

); } + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = content.trim(); + if (!trimmed) return; + setSubmitting(true); + setError(""); + try { + const res = await fetch(`/api/forum/topics/${topicId}/posts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: trimmed, + replyToId: replyTo?.postId || null, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || "回复失败"); + setSubmitting(false); + return; + } + setSubmitting(false); + window.location.reload(); + } catch { + setError("网络错误"); + setSubmitting(false); + } + }; + + const handleCancelQuote = () => { + setReplyTo(null); + window.dispatchEvent( + new CustomEvent("forum:reply-cancel", { + detail: { postId: null }, + }) + ); + }; + 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(); - } - }} + onSubmit={handleSubmit} className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]" > -

发表回复

-