Version: 0.9.82.dev.260507
后端: 1. 登录注册补齐极验行为验证与跨域入口:gateway 新增 `/user/captcha/register`,登录/注册先做 GeeTest 初始化与二次校验,再进入 user/auth RPC;补充验证码失败/初始化失败/服务不可用响应码,并新增可配置 CORS middleware 适配分域部署。 2. 容器部署配置入口收口:`bootstrap.LoadConfig` 支持 `SMARTFLOW_CONFIG_FILE` 与环境变量覆盖,`config.example.yaml` / `config.docker.yaml` 补齐 geetest 与容器内服务地址,网关新增配置列表解析,便于 compose 场景直接挂载配置启动。 3. LLM outbox 与助手时间线稳定性修正:`cmd/llm` 显式绑定 llm 自身 topic/group,避免误入 agent consumer group;agent timeline 在 Redis 热缓存未落 MySQL 时改用 `seq` 兜底临时 id,避免前端历史回放撞 key。 前端: 4. 认证页接入极验并补齐提交前校验:新增 GeeTest 脚本加载与实例封装,登录/注册面板支持 challenge 初始化、切换面板重挂载、失败提示与提交前校验,认证 API/types 同步透传 geetest 三元组。 5. 前端部署基址与网关对接收口:Axios `baseURL`、Vue Router `history base` 与 Vite `base/dev proxy` 改为读取环境变量,新增 `frontend/.env.example`,支持子路径部署、容器内反向代理和本地联调共存。 6. 助手与工作台展示细节修正:AssistantPanel 历史重建优先使用真实 timeline id、缺失时退回 `seq` 保证消息主键唯一;首页主面板改为纵向可滚动并补底部留白,避免内容截断。 仓库: 7. 整站容器化交付链路补齐并重写说明文档:新增后端/前端 Dockerfile、`.dockerignore`、前端 Nginx 代理、`docker-compose.full.yml`、`.env.full.example` 与镜像打包/导入脚本,README 改写数据库/路由/部署章节,并新增 `docs/容器化部署说明.md` 说明离线镜像分发方案。
This commit is contained in:
26
.env.full.example
Normal file
26
.env.full.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# SmartFlow 全栈容器化示例环境变量。
|
||||
#
|
||||
# 说明:
|
||||
# 1. 若国内服务器无法直接拉官方镜像,可把下列镜像名改成您已缓存或私有仓库中的地址。
|
||||
# 2. Compose 默认读取根目录 .env;请按需复制为 .env 后再启动。
|
||||
|
||||
SMARTFLOW_BACKEND_IMAGE=smartflow/backend-suite:latest
|
||||
SMARTFLOW_FRONTEND_IMAGE=smartflow/frontend:latest
|
||||
SMARTFLOW_NOTIFICATION_FRONTENDBASEURL=https://smartflow.example.com
|
||||
SMARTFLOW_CORS_ALLOWEDORIGINS=http://localhost:5173,https://smartflow.example.com
|
||||
|
||||
SMARTFLOW_MYSQL_IMAGE=mysql:8.0
|
||||
SMARTFLOW_REDIS_IMAGE=redis:7
|
||||
SMARTFLOW_KAFKA_IMAGE=apache/kafka:3.7.2
|
||||
SMARTFLOW_ETCD_IMAGE=quay.io/coreos/etcd:v3.5.5
|
||||
SMARTFLOW_MINIO_IMAGE=minio/minio:RELEASE.2023-03-20T20-16-18Z
|
||||
SMARTFLOW_MILVUS_IMAGE=milvusdb/milvus:v2.4.4
|
||||
SMARTFLOW_ATTU_IMAGE=zilliz/attu:v2.4.3
|
||||
|
||||
SMARTFLOW_API_PORT=8080
|
||||
SMARTFLOW_FRONTEND_PORT=80
|
||||
SMARTFLOW_MINIO_API_PORT=9000
|
||||
SMARTFLOW_MINIO_CONSOLE_PORT=9001
|
||||
SMARTFLOW_MILVUS_PORT=19530
|
||||
SMARTFLOW_MILVUS_HEALTH_PORT=9091
|
||||
SMARTFLOW_ATTU_PORT=8000
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -32,3 +32,5 @@ backend/config.yaml
|
||||
.claude/
|
||||
.omc/
|
||||
/backend/.dev/
|
||||
/.docker-bundles/
|
||||
.gopath/
|
||||
|
||||
756
README.md
756
README.md
@@ -8,33 +8,6 @@
|
||||
|
||||
> 越用越懂你的成长型 AI 排程伙伴 · 面向大学生的陪伴式日程管理平台
|
||||
|
||||
## 后端本地快速启动
|
||||
|
||||
后端开发统一使用 `backend` 根目录下的 PowerShell 启动脚本,不再维护 `cmd/all` 聚合入口。
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
.\scripts\dev-up.ps1
|
||||
.\scripts\services-up.ps1
|
||||
.\scripts\dev-status.ps1
|
||||
.\scripts\dev-logs.ps1 -Service api -Stream stdout -Follow
|
||||
.\scripts\service-restart.ps1 -Service api
|
||||
.\scripts\services-down.ps1
|
||||
.\scripts\dev-down.ps1
|
||||
.\scripts\dev-down.ps1 -StopInfra
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 所有后端脚本统一收敛在 `backend/scripts` 目录下。
|
||||
- `scripts/dev-up.ps1` 会先确保 Docker 基础设施就绪,再按顺序构建并拉起全部 RPC 服务与 API。
|
||||
- `scripts/services-up.ps1` 只拉起后端服务本身,不触碰 Docker 基础设施。
|
||||
- `scripts/dev-status.ps1` 用于查看各服务是脚本托管、外部运行还是未启动。
|
||||
- `scripts/dev-logs.ps1` 用于查看单个服务最新日志;可选 `-Stream stdout|stderr|both`,带 `-Follow` 可持续追日志。
|
||||
- `scripts/service-restart.ps1 -Service <name>` 用于重启单个脚本托管的后端服务;若该服务由外部进程托管,则会直接拒绝操作。
|
||||
- `scripts/services-down.ps1` 只停止脚本托管的后端服务进程。
|
||||
- `scripts/dev-down.ps1` 默认只停止脚本托管的后端进程;加 `-StopInfra` 才会一并停止 Docker 基础设施。
|
||||
|
||||
# 1 项目概览
|
||||
|
||||
## 1.1 总体介绍
|
||||
@@ -89,7 +62,7 @@ cd backend
|
||||
|
||||
目前暂未开放用户自定义时间尺度配置,当前仍以固定节次模型为主。后续会有更新计划的!
|
||||
|
||||
2. **导入学校课表。** 本项目后端已提供学校课表导入能力(当前主要尝试兼容CQUPT的课表格式),以便后续以课表为基底进行日程安排;前端完整导入流程入口仍在补齐。
|
||||
2. **导入学校课表。** 本项目后端已提供学校课表导入能力(当前主要尝试兼容CQUPT的课表图片识别与导入格式),前端也已在 `/schedule` 页面接入完整导入流程,便于后续直接以课表为基底进行日程安排。
|
||||
|
||||
3. **"水课"任务嵌入。** 正如上方**问题2**所言,在已导入课表的前提下,支持设置某一门你想拿来干其它事情的课为"可嵌入任务"状态,此时这门课所占据的时间区域就是可以嵌入任务的了,但是仍然有区别于其它完全空白的时间区域,便于真正安排适合在嘈杂环境下做的事情。
|
||||
|
||||
@@ -97,7 +70,7 @@ cd backend
|
||||
|
||||
5. **一键编排任务。** 结合算法与用户配置,将任务基于导入的课表和任务类设置先生成预览结果;确认无误后,再正式应用到日程中。
|
||||
|
||||
6. **AI随口记与任务查询。** 正如问题4所言,当前版本支持通过AI随手记录一些大小事,也支持按象限、关键词、截止时间等维度查询任务;部分日程调整能力已接入确认流。
|
||||
6. **AI 对话与排程辅助。** 当前版本支持通过 AI 进行对话、查看历史会话与思考过程,并结合结构化工具完成排程分析、日程微调与确认流。
|
||||
|
||||
7. **多用户。** 本系统可支持多个用户同时使用,并且记录AI对话、编排任务的Token使用情况等,并进行限额。
|
||||
|
||||
@@ -187,158 +160,224 @@ PS:此图截至版本v0.3.3
|
||||
|
||||
## 3.2 核心表结构
|
||||
|
||||
其实每个表都很核心。在此展示它们的创建语句:
|
||||
以下结构以 **2026 年 5 月 7 日** 对运行中的 `smartflow-mysql` 容器执行 `SHOW TABLES`、`SHOW CREATE TABLE` 与 `information_schema.columns` 查询结果为准。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `agent_chats`
|
||||
(
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int DEFAULT NULL,
|
||||
`message_content` text COMMENT '用户或AI的话',
|
||||
`role` varchar(255) DEFAULT NULL COMMENT 'user / assistant',
|
||||
`tokens_consumed` int DEFAULT '0' COMMENT '单次消耗,用于累加到 users 表',
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_agent_chats_id` (`id`),
|
||||
KEY `user_id` (`user_id`),
|
||||
CONSTRAINT `agent_chats_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_0900_ai_ci
|
||||
|
||||
CREATE TABLE `courses`
|
||||
(
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int DEFAULT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`location` varchar(255) DEFAULT NULL,
|
||||
`is_filler` tinyint(1) DEFAULT '0',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_courses_id` (`id`),
|
||||
KEY `user_id` (`user_id`),
|
||||
CONSTRAINT `courses_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_0900_ai_ci
|
||||
|
||||
CREATE TABLE `schedule_events`
|
||||
(
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int NOT NULL,
|
||||
`name` varchar(255) NOT NULL COMMENT '课程或任务名称',
|
||||
`location` varchar(255) DEFAULT '' COMMENT '地点 (教学楼/会议室)',
|
||||
`type` enum ('course','task') NOT NULL COMMENT '日程类型',
|
||||
`rel_id` int DEFAULT NULL COMMENT '关联原始数据ID (如教务系统的课程ID)',
|
||||
`can_be_embedded` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否允许在此时段嵌入其他任务',
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`start_time` datetime DEFAULT NULL COMMENT '任务开始的绝对时间',
|
||||
`end_time` datetime DEFAULT NULL COMMENT '任务结束的绝对时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_events` (`user_id`),
|
||||
KEY `idx_user_endtime` (`user_id`, `end_time` DESC),
|
||||
CONSTRAINT `fk_event_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE = InnoDB
|
||||
AUTO_INCREMENT = 148
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_0900_ai_ci
|
||||
|
||||
CREATE TABLE `schedules`
|
||||
(
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`event_id` int NOT NULL COMMENT '关联元数据ID',
|
||||
`user_id` int NOT NULL COMMENT '冗余UID方便直接查询',
|
||||
`week` int NOT NULL COMMENT '周次 (1-25)',
|
||||
`day_of_week` int NOT NULL COMMENT '星期 (1-7)',
|
||||
`section` int NOT NULL COMMENT '原子化节次 (1-12)',
|
||||
`embedded_task_id` int DEFAULT NULL COMMENT '若为水课嵌入,记录具体的任务项ID',
|
||||
`status` enum ('normal','interrupted') DEFAULT 'normal' COMMENT '状态: 正常/因故中断',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_user_slot_atomic` (`user_id`, `week`, `day_of_week`, `section`),
|
||||
KEY `idx_event_id` (`event_id`),
|
||||
KEY `fk_embedded_task` (`embedded_task_id`),
|
||||
CONSTRAINT `fk_embedded_task` FOREIGN KEY (`embedded_task_id`) REFERENCES `task_items` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_schedule_event` FOREIGN KEY (`event_id`) REFERENCES `schedule_events` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_schedule_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE = InnoDB
|
||||
AUTO_INCREMENT = 214
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_0900_ai_ci
|
||||
|
||||
CREATE TABLE `task_classes`
|
||||
(
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int DEFAULT NULL,
|
||||
`name` varchar(255) DEFAULT NULL,
|
||||
`mode` enum ('auto','manual') DEFAULT NULL,
|
||||
`start_date` date DEFAULT NULL,
|
||||
`end_date` date DEFAULT NULL,
|
||||
`total_slots` int DEFAULT NULL COMMENT '分配的总节数',
|
||||
`allow_filler_course` tinyint(1) DEFAULT '1',
|
||||
`strategy` enum ('steady','rapid') DEFAULT NULL,
|
||||
`excluded_slots` json DEFAULT NULL COMMENT '不想要的时段切片',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_task_classes_id` (`id`),
|
||||
KEY `idx_task_classes_user_id` (`user_id`),
|
||||
CONSTRAINT `task_classes_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
|
||||
) ENGINE = InnoDB
|
||||
AUTO_INCREMENT = 15
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_0900_ai_ci
|
||||
|
||||
CREATE TABLE `task_items`
|
||||
(
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`category_id` int DEFAULT NULL,
|
||||
`content` text,
|
||||
`embedded_time` json DEFAULT NULL COMMENT '目标时间{date,section_from,section_to}',
|
||||
`status` int DEFAULT NULL COMMENT '1:未安排, 2:已应用',
|
||||
`order` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_task_items_id` (`id`),
|
||||
KEY `task_items_ibfk_1` (`category_id`),
|
||||
CONSTRAINT `task_items_ibfk_1` FOREIGN KEY (`category_id`) REFERENCES `task_classes` (`id`) ON DELETE CASCADE
|
||||
) ENGINE = InnoDB
|
||||
AUTO_INCREMENT = 43
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_0900_ai_ci
|
||||
|
||||
CREATE TABLE `tasks`
|
||||
(
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int DEFAULT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`priority` int DEFAULT NULL,
|
||||
`is_completed` tinyint(1) DEFAULT '0',
|
||||
`deadline_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tasks_id` (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`),
|
||||
CONSTRAINT `chk_priority` CHECK ((`priority` in (1, 2, 3, 4)))
|
||||
) ENGINE = InnoDB
|
||||
AUTO_INCREMENT = 23
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_0900_ai_ci
|
||||
|
||||
CREATE TABLE `users`
|
||||
(
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(255) NOT NULL,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`phone_number` varchar(255) DEFAULT NULL,
|
||||
`token_limit` int DEFAULT '100000',
|
||||
`token_usage` int DEFAULT '0',
|
||||
`last_reset_at` timestamp NULL DEFAULT NULL COMMENT '上次周用量重置时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `username` (`username`),
|
||||
UNIQUE KEY `uk_users_id` (`id`)
|
||||
) ENGINE = InnoDB
|
||||
AUTO_INCREMENT = 4
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_0900_ai_ci
|
||||
### 3.2.0 全量表简述总览
|
||||
|
||||
| 领域 | 表名 | 简述 |
|
||||
| --- | --- | --- |
|
||||
| 用户与账户 | `users` | 用户主表,保存账号、密码、手机号与 token 配额。 |
|
||||
| 用户与账户 | `user_token_usage_adjustments` | 用户 token 用量调整流水,按事件记录增减量。 |
|
||||
| 用户与账户 | `user_notification_channels` | 用户通知通道配置表,保存 webhook、鉴权方式与最近一次测试结果。 |
|
||||
| 任务与课表 | `tasks` | 首页任务池主表,承载优先级、DDL、预计节数与紧迫阈值。 |
|
||||
| 任务与课表 | `task_classes` | 任务类定义表,保存任务类模式、策略、节数与学习属性配置。 |
|
||||
| 任务与课表 | `task_items` | 任务类下的任务块明细表,记录内容、顺序、嵌入时间与状态。 |
|
||||
| 任务与课表 | `schedule_events` | 课程 / 任务事件元数据表,是正式日程块的事实来源。 |
|
||||
| 任务与课表 | `schedules` | 节次级日程展开表,把事件落到周次、星期与节次坐标。 |
|
||||
| Agent | `agent_chats` | AI 会话头表,保存会话标题、模型、状态、消息数与 token 汇总。 |
|
||||
| Agent | `chat_histories` | AI 消息明细表,保存正文、角色、推理内容、重试分支与 token 消耗。 |
|
||||
| Agent | `agent_timeline_events` | Agent 时间线事件表,给前端时间线、状态流与调试回放使用。 |
|
||||
| Agent | `agent_schedule_states` | Agent 排程状态快照表,保存会话内的排程运行态与 revision。 |
|
||||
| Agent | `agent_state_snapshot_records` | Agent 阶段性状态快照归档表,按 phase 留存排障用 snapshot。 |
|
||||
| 主动调度 | `active_schedule_jobs` | 主动调度后台任务表,管理定时触发、扫描状态、去重键与错误信息。 |
|
||||
| 主动调度 | `active_schedule_triggers` | 主动调度触发表,记录触发来源、目标对象、幂等键、处理状态与 trace。 |
|
||||
| 主动调度 | `active_schedule_previews` | 主动调度预览结果表,保存候选方案、决策结果、风险说明与 apply 结果。 |
|
||||
| 主动调度 | `active_schedule_sessions` | 主动调度会话表,串起 trigger、preview 与对话侧 session 状态。 |
|
||||
| Memory / RAG | `memory_items` | 记忆条目主表,保存内容、置信度、重要度、敏感级别与向量状态。 |
|
||||
| Memory / RAG | `memory_jobs` | 记忆任务表,保存提取 / 管理类任务的 payload、重试与错误状态。 |
|
||||
| Memory / RAG | `memory_audit_logs` | 记忆审计日志表,记录 memory 条目的变更前后内容与操作原因。 |
|
||||
| Memory / RAG | `memory_user_settings` | 用户级记忆配置表,控制 memory 总开关与隐式 / 敏感记忆开关。 |
|
||||
| 通知 | `notification_records` | 通知发送记录表,保存触发源、摘要、兜底文案、重试状态与供应商响应。 |
|
||||
| 社区 / 任务类分享 | `forum_posts` | 社区帖子主表,对应任务类分享内容及其点赞 / 评论 / 导入统计。 |
|
||||
| 社区 / 任务类分享 | `forum_post_templates` | 帖子对应的任务类模板表,固化分享时的任务类配置快照。 |
|
||||
| 社区 / 任务类分享 | `forum_post_template_items` | 帖子模板下的任务块明细表,保存来源 task_item 与排序。 |
|
||||
| 社区 / 任务类分享 | `forum_likes` | 帖子点赞记录表,记录点赞人、作者、事件 ID 与取消状态。 |
|
||||
| 社区 / 任务类分享 | `forum_comments` | 帖子评论表,支持父子评论结构、幂等键与软删除。 |
|
||||
| 社区 / 任务类分享 | `forum_imports` | 社区模板导入记录表,记录导入目标任务类、状态与失败原因。 |
|
||||
| Credit / 计费 | `credit_accounts` | 用户 Credit 账户表,维护余额与累计充值 / 奖励 / 消耗。 |
|
||||
| Credit / 计费 | `credit_ledger` | Credit 台账流水表,记录每次变动的来源、方向、前后余额与描述。 |
|
||||
| Credit / 计费 | `credit_orders` | Credit 购买订单表,保存商品快照、数量、金额、支付状态与入账状态。 |
|
||||
| Credit / 计费 | `credit_products` | Credit 商品表,定义 SKU、额度、价格、排序与上下架状态。 |
|
||||
| Credit / 计费 | `credit_price_rules` | 模型计费规则表,定义 provider / model 的价格与利润率映射。 |
|
||||
| Credit / 计费 | `credit_reward_rules` | Credit 奖励规则表,定义奖励来源、额度、状态与配置。 |
|
||||
| Outbox | `agent_outbox_messages` | Agent 域 Outbox 表,承接聊天持久化、时间线与状态事件投递。 |
|
||||
| Outbox | `task_outbox_messages` | 任务域 Outbox 表,承接任务相关异步事件投递。 |
|
||||
| Outbox | `memory_outbox_messages` | Memory 域 Outbox 表,承接记忆提取 / 管理类异步事件投递。 |
|
||||
| Outbox | `active_scheduler_outbox_messages` | 主动调度域 Outbox 表,承接调度触发相关异步事件。 |
|
||||
| Outbox | `notification_outbox_messages` | 通知域 Outbox 表,承接外部通知发送事件。 |
|
||||
| Outbox | `taskclass_forum_outbox_messages` | 社区 / 任务类分享域 Outbox 表,承接点赞、导入等异步事件。 |
|
||||
| Outbox | `llm_outbox_messages` | LLM 域 Outbox 表,承接模型计费 / 记账等异步事件。 |
|
||||
| Outbox | `token_store_outbox_messages` | Token / Credit 域 Outbox 表,承接 token 或 credit 账务类异步事件。 |
|
||||
|
||||
当前库中与主业务链路最相关的核心表包括:
|
||||
|
||||
- `users`
|
||||
- `tasks`
|
||||
- `task_classes`
|
||||
- `task_items`
|
||||
- `schedule_events`
|
||||
- `schedules`
|
||||
- `agent_chats`
|
||||
- `chat_histories`
|
||||
|
||||
### 3.2.1 `users`
|
||||
|
||||
```text
|
||||
id bigint unsigned PK AUTO_INCREMENT
|
||||
username varchar(255) NOT NULL UNIQUE
|
||||
password varchar(255) NOT NULL
|
||||
phone_number varchar(255) NULL
|
||||
token_limit bigint DEFAULT 100000
|
||||
token_usage bigint DEFAULT 0
|
||||
last_reset_at datetime(3) NULL
|
||||
```
|
||||
|
||||
### 3.2.2 `tasks`
|
||||
|
||||
```text
|
||||
id bigint PK AUTO_INCREMENT
|
||||
user_id bigint NULL
|
||||
title varchar(255) NULL
|
||||
priority bigint NOT NULL
|
||||
is_completed tinyint(1) DEFAULT 0
|
||||
deadline_at datetime(3) NULL
|
||||
urgency_threshold_at datetime(3) NULL
|
||||
estimated_sections bigint NOT NULL DEFAULT 1
|
||||
索引:
|
||||
- idx_tasks_user_id(user_id)
|
||||
- idx_user_done_threshold_priority(user_id, is_completed, urgency_threshold_at, priority)
|
||||
```
|
||||
|
||||
### 3.2.3 `task_classes`
|
||||
|
||||
```text
|
||||
id bigint PK AUTO_INCREMENT
|
||||
user_id bigint NULL
|
||||
name varchar(255) NULL
|
||||
mode enum('auto','manual') NULL
|
||||
start_date datetime(3) NULL
|
||||
end_date datetime(3) NULL
|
||||
total_slots bigint NULL
|
||||
allow_filler_course tinyint(1) DEFAULT 1
|
||||
strategy enum('steady','rapid') NULL
|
||||
excluded_slots json NULL
|
||||
subject_type varchar(32) NULL
|
||||
difficulty_level varchar(16) NULL
|
||||
cognitive_intensity varchar(16) NULL
|
||||
excluded_days_of_week json NULL
|
||||
索引:
|
||||
- idx_task_classes_user_id(user_id)
|
||||
```
|
||||
|
||||
### 3.2.4 `task_items`
|
||||
|
||||
```text
|
||||
id bigint PK AUTO_INCREMENT
|
||||
category_id bigint NULL
|
||||
order bigint NULL
|
||||
content text NULL
|
||||
embedded_time json NULL
|
||||
status bigint NULL
|
||||
索引 / 约束:
|
||||
- fk_task_classes_items(category_id -> task_classes.id)
|
||||
```
|
||||
|
||||
### 3.2.5 `schedule_events`
|
||||
|
||||
```text
|
||||
id bigint PK AUTO_INCREMENT
|
||||
user_id bigint NOT NULL
|
||||
name varchar(255) NOT NULL
|
||||
location varchar(255) DEFAULT ''
|
||||
type enum('course','task') NOT NULL
|
||||
rel_id bigint NULL
|
||||
can_be_embedded tinyint(1) NOT NULL DEFAULT 0
|
||||
start_time datetime(3) NULL
|
||||
end_time datetime(3) NULL
|
||||
task_source_type varchar(32) NOT NULL DEFAULT ''
|
||||
makeup_for_event_id bigint NULL
|
||||
active_preview_id varchar(64) NULL
|
||||
索引:
|
||||
- idx_user_events(user_id)
|
||||
- idx_schedule_event_task_source(task_source_type)
|
||||
- idx_schedule_event_makeup_for(makeup_for_event_id)
|
||||
- idx_schedule_event_active_preview(active_preview_id)
|
||||
```
|
||||
|
||||
### 3.2.6 `schedules`
|
||||
|
||||
```text
|
||||
id bigint PK AUTO_INCREMENT
|
||||
event_id bigint NOT NULL
|
||||
user_id bigint NOT NULL
|
||||
week bigint NOT NULL
|
||||
day_of_week bigint NOT NULL
|
||||
section bigint NOT NULL
|
||||
embedded_task_id bigint NULL
|
||||
status enum('normal','interrupted') DEFAULT 'normal'
|
||||
索引 / 约束:
|
||||
- UNIQUE idx_user_slot_atomic(user_id, week, day_of_week, section)
|
||||
- idx_event_id(event_id)
|
||||
- fk_schedules_embedded_task(embedded_task_id -> task_items.id)
|
||||
- fk_schedules_event(event_id -> schedule_events.id)
|
||||
```
|
||||
|
||||
### 3.2.7 `agent_chats`
|
||||
|
||||
```text
|
||||
id bigint PK AUTO_INCREMENT
|
||||
chat_id varchar(36) NOT NULL UNIQUE
|
||||
user_id bigint NOT NULL
|
||||
title varchar(255) NULL
|
||||
system_prompt text NULL
|
||||
model varchar(100) NULL
|
||||
message_count bigint NOT NULL DEFAULT 0
|
||||
tokens_total bigint NOT NULL DEFAULT 0
|
||||
last_message_at datetime(3) NULL
|
||||
status varchar(32) NOT NULL DEFAULT 'active'
|
||||
created_at datetime(3) NULL
|
||||
updated_at datetime(3) NULL
|
||||
deleted_at datetime(3) NULL
|
||||
compaction_summary text NULL
|
||||
compaction_watermark bigint NOT NULL DEFAULT 0
|
||||
context_token_stats json NULL
|
||||
last_history_event_id varchar(64) NULL
|
||||
last_token_adjust_event_id varchar(64) NULL
|
||||
索引:
|
||||
- idx_user_last(user_id)
|
||||
- idx_user_status(user_id, status)
|
||||
```
|
||||
|
||||
### 3.2.8 `chat_histories`
|
||||
|
||||
```text
|
||||
id bigint PK AUTO_INCREMENT
|
||||
chat_id varchar(36) NOT NULL
|
||||
user_id bigint NOT NULL
|
||||
message_content text NULL
|
||||
role varchar(32) NULL
|
||||
tokens_consumed bigint NOT NULL DEFAULT 0
|
||||
created_at datetime(3) NULL
|
||||
reasoning_content text NULL
|
||||
reasoning_duration_seconds bigint NOT NULL DEFAULT 0
|
||||
retry_group_id varchar(64) NULL
|
||||
retry_index bigint NULL
|
||||
retry_from_user_message_id bigint NULL
|
||||
retry_from_assistant_message_id bigint NULL
|
||||
source_event_id varchar(64) NULL UNIQUE
|
||||
索引:
|
||||
- idx_user_chat(user_id, chat_id)
|
||||
- idx_chat_id(chat_id)
|
||||
- idx_retry_group(retry_group_id)
|
||||
```
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 运行库里已经不存在 README 旧版那种“`agent_chats` 直接承载单条消息正文”的结构;现在是 `agent_chats` 管会话头,`chat_histories` 管消息明细。
|
||||
2. `task_classes.start_date` / `end_date` 在运行库中是 `datetime(3)`,不是旧文档里的 `date`。
|
||||
3. `tasks` 现在多了 `urgency_threshold_at` 与 `estimated_sections`,`task_classes` 现在多了 `subject_type`、`difficulty_level`、`cognitive_intensity`、`excluded_days_of_week`。
|
||||
4. README 此处如果后续再更新,建议继续以运行中的 MySQL 查询结果为准,而不是只看代码结构体。
|
||||
|
||||
# 4 接口契约
|
||||
|
||||
## 4.1 核心API列表(ApiFox)
|
||||
@@ -358,40 +397,42 @@ CREATE TABLE `users`
|
||||
```
|
||||
|
||||
2. 日程写工具默认走 `confirm` 确认闸门(`always_execute=true` 时可跳过确认)。
|
||||
3. 非日程写工具(如 `quick_note_create`、`query_tasks`、`web_search`、`web_fetch`)走 `continue + tool_call`。
|
||||
3. 非日程类工具(如 `context_tools_add`、`context_tools_remove`、`web_search`、`web_fetch`)走 `continue + tool_call`;其中 `upsert_task_class` 虽不依赖 `ScheduleState`,但仍属于真实写库入口。
|
||||
4. 当前每轮只允许调用一个工具,不支持同轮批量工具数组。
|
||||
|
||||
### 4.2.2 工具清单(当前版本)
|
||||
|
||||
| 工具名 | 类型 | 是否需确认 | 是否依赖 ScheduleState | 核心参数 | 作用与约束 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `get_overview` | 读 | 否 | 是 | 无 | 获取规划窗口总览(任务视角,全量返回) |
|
||||
| `query_range` | 读 | 否 | 是 | `day`(必填), `slot_start`, `slot_end` | 查询某天/某时段占用详情 |
|
||||
| `context_tools_add` | 上下文控制 | 否 | 否 | `domain`, `packs`, `mode` | 激活 `schedule` / `taskclass` 工具域;`schedule` 可按 pack 增量注入 `mutation / analyze / detail_read / deep_analyze / queue / web` |
|
||||
| `context_tools_remove` | 上下文控制 | 否 | 否 | `domain`, `packs`, `all` | 移除指定工具域或 pack;`core` 固定包不允许 remove |
|
||||
| `get_overview` | 读 | 否 | 是 | 无 | 获取当前规划窗口总览(任务视角,全量返回) |
|
||||
| `query_range` | 读 | 否 | 是 | `day`(必填), `slot_start`, `slot_end` | 查询某天 / 某时段占用详情 |
|
||||
| `query_available_slots` | 读 | 否 | 是 | `span`, `duration`, `limit`, `day_scope`, `week_filter` 等 | 查询候选空位池(纯空位优先,不足再补可嵌入位) |
|
||||
| `query_target_tasks` | 读 | 否 | 是 | `status`, `category`, `task_ids`, `enqueue` 等 | 过滤任务集合,可选自动入队供后续队列工具处理 |
|
||||
| `queue_pop_head` | 读 | 否 | 是 | 无 | 取出/复用当前队首任务(一次只处理一个) |
|
||||
| `queue_status` | 读 | 否 | 是 | 无 | 查看队列状态(pending/current/completed/skipped) |
|
||||
| `query_target_tasks` | 读 | 否 | 是 | `status`, `category`, `task_ids`, `enqueue`, `reset_queue` 等 | 过滤任务集合,可选自动入队供后续队列工具处理 |
|
||||
| `queue_pop_head` | 队列读 | 否 | 是 | 无 | 取出 / 复用当前队首任务(一次只处理一个) |
|
||||
| `queue_status` | 队列读 | 否 | 是 | 无 | 查看队列状态(pending / current / completed / skipped) |
|
||||
| `get_task_info` | 读 | 否 | 是 | `task_id`(必填) | 查询单任务详细信息 |
|
||||
| `analyze_rhythm` | 分析 | 否 | 是 | `category`, `include_pending`, `detail`, `hard_categories` | 分析学习节奏、连续同类任务与切换情况 |
|
||||
| `analyze_health` | 分析 | 否 | 是 | `detail`, `dimensions`, `threshold` | 作为主动优化裁判入口,判断当前排程是否仍值得继续优化 |
|
||||
| `place` | 写 | 是 | 是 | `task_id`, `day`, `slot_start`(均必填) | 将待安排任务预排到指定位置 |
|
||||
| `move` | 写 | 是 | 是 | `task_id`, `new_day`, `new_slot_start`(均必填) | 仅允许移动 `suggested`;`existing` 不可 `move` |
|
||||
| `swap` | 写 | 是 | 是 | `task_a`, `task_b`(均必填) | 交换两个已落位任务,要求时长一致 |
|
||||
| `batch_move` | 写 | 是 | 是 | `moves[]`(必填) | 原子批量移动,当前最多 2 条,任一冲突整批回滚 |
|
||||
| `queue_apply_head_move` | 写 | 是 | 是 | `new_day`, `new_slot_start`(均必填) | 移动当前队首并自动出队,不接受 `task_id` |
|
||||
| `queue_apply_head_move` | 队列写 | 是 | 是 | `new_day`, `new_slot_start`(均必填) | 移动当前队首并自动出队,不接受 `task_id` |
|
||||
| `queue_skip_head` | 队列控制 | 否 | 是 | `reason` | 跳过当前队首并标记 `skipped`(不改日程) |
|
||||
| `spread_even` | 写 | 是 | 是 | `task_ids`(必填,兼容 `task_id`) | 在任务集合内做均匀铺开,按筛选条件原子落地 |
|
||||
| `min_context_switch` | 写 | 是 | 是 | `task_ids`(必填,兼容 `task_id`) | 减少上下文切换重排;仅在用户明确允许打乱顺序时可执行 |
|
||||
| `unplace` | 写 | 是 | 是 | `task_id`(必填) | 取消任务落位并恢复待安排状态 |
|
||||
| `quick_note_create` | 读写混合(业务写入) | 否 | 否 | `title`(必填), `deadline_at`, `priority_group` | 记录随口记任务,支持中文相对时间;优先级可自动推断 |
|
||||
| `query_tasks` | 读 | 否 | 否 | `quadrant`, `keyword`, `deadline_before/after`, `limit` 等 | 按象限/关键词/时间边界查询任务 |
|
||||
| `web_search` | 读 | 否 | 否 | `query`(必填), `top_k`, `domain_allow`, `recency_days` | Web 检索,返回结构化标题/摘要/URL;未启用时优雅返回错误 observation |
|
||||
| `upsert_task_class` | 任务类写入 | 是 | 否 | `id`, `task_class`, `items`, `source` | 统一创建 / 更新任务类入口;会写库,但不直接修改当前日程预览 |
|
||||
| `web_search` | 读 | 否 | 否 | `query`(必填), `top_k`, `domain_allow`, `recency_days` | Web 检索,返回结构化标题 / 摘要 / URL;未启用时优雅返回错误 observation |
|
||||
| `web_fetch` | 读 | 否 | 否 | `url`(必填), `max_chars` | 抓取并清洗网页正文;服务不可用时优雅返回错误 observation |
|
||||
|
||||
### 4.2.3 当前实现中的关键规则
|
||||
|
||||
1. `min_context_switch` 有顺序护栏:未授权“允许打乱顺序”会被后端拦截并返回拒绝结果。
|
||||
2. `batch_move` 有安全上限:当前最多支持 2 条移动请求,超出建议走队列化逐项处理。
|
||||
3. `quick_note_create` 和 `query_tasks` 不依赖 `ScheduleState`,由执行层注入 `_user_id` 后可直接调用。
|
||||
4. `web_search`/`web_fetch` 失败不会打断主链路,都会回传结构化错误 observation 给模型继续决策。
|
||||
1. `context_tools_add` / `context_tools_remove` 是动态区协议入口:`schedule` 支持按 pack 精细注入,`taskclass` 当前只有 `core` 固定包。
|
||||
2. 日程写工具默认走 `confirm` 确认闸门(`always_execute=true` 时可跳过)。
|
||||
3. `upsert_task_class` 虽不依赖 `ScheduleState`,但属于真实写库入口,仍要求 confirm。
|
||||
4. `batch_move` 有安全上限:当前最多支持 2 条移动请求,超出建议走队列化逐项处理。
|
||||
5. `web_search` / `web_fetch` 失败不会打断主链路,都会回传结构化错误 observation 给模型继续决策。
|
||||
|
||||
|
||||
# 5 后端实现
|
||||
@@ -400,22 +441,169 @@ CREATE TABLE `users`
|
||||
|
||||
| **分类** | **选用技术** | **在时伴中的应用场景** |
|
||||
| ----------------- | ---------------- | ------------------------------------------------------------ |
|
||||
| **Web 框架** | **Gin** | 负责全站 API 的路由分发,处理任务增删改查及智能排程的请求。 |
|
||||
| **持久层数据库** | **MySQL 8.0** | 存储用户、任务、课表及日程运行图(Schedules)的核心数据。 |
|
||||
| **ORM 框架** | **GORM** | 用于简化 Go 与数据库的交互,利用事务处理 `Apply` 接口的原子性操作。 |
|
||||
| **高性能缓存** | **Redis** | 缓存用户的周日程视图(避免频繁扫表)、存储 Token 临时限额、实现分布式锁防止重复排程。 |
|
||||
| **消息队列** | **Outbox + Kafka** | **可靠异步解耦**:请求主链路先写 Outbox,后台再投递 Kafka 并消费落库,既降低首字延迟又避免消息瞬时丢失。 |
|
||||
| **AI 编排框架** | **Eino** | 作为 AI Agent 的大脑,根据排程策略(Steady/Rapid)计算任务与水课的嵌入逻辑。 |
|
||||
| **身份认证** | **JWT** | 实现无状态登录,将 `user_id` 封装在 Token 中,确保数据的用户隔离。 |
|
||||
| **配置管理** | **Viper** | 管理数据库、Redis、Kafka 的连接参数,支持多环境(开发/生产)切换。 |
|
||||
| **API 文档/调试** | **Apifox** | 维护接口协议,进行前后端联调及自动化测试。 |
|
||||
| **日志监控** | **Zap / Logrus** | 记录系统运行状态,特别是 Kafka 消费失败或 AI 接口超时的错误日志。 |
|
||||
| **API 网关** | **Gin** | 负责统一 HTTP 入口、JWT 鉴权、限流、幂等控制与前端 API 聚合。 |
|
||||
| **服务间通信** | **go-zero zrpc + gRPC** | `api` 网关通过 zrpc 调用 `userauth / task / schedule / agent / memory / llm` 等后端服务,支撑当前多服务拆分。 |
|
||||
| **持久层数据库** | **MySQL 8.0** | 存储用户、任务、课表、Agent 会话、主动调度、社区、Credit、memory 等核心结构化数据。 |
|
||||
| **ORM / 数据访问** | **GORM** | 负责 MySQL 映射、事务处理、索引约束落地,以及日程 / Agent / memory 等域的数据访问。 |
|
||||
| **缓存与运行态存储** | **Redis** | 缓存周课表与会话状态,承接幂等键、限流、Agent 状态快照、日程预览与 memory 预取上下文。 |
|
||||
| **异步事件总线** | **Outbox + Kafka(segmentio/kafka-go)** | 用于聊天持久化、memory 抽取、主动调度触发、通知投递、Credit 记账等异步事件解耦。 |
|
||||
| **AI / Agent 编排** | **CloudWeGo Eino** | 承担对话规划、工具调用、确认流、排程执行与 deliver/interrupt 状态机编排。 |
|
||||
| **模型接入** | **Volcengine Ark + OpenAI Compatible 接口** | `llm` / `course` / `memory` / `agent` 等服务通过统一模型层调用文本模型、推理模型与课表识别视觉模型。 |
|
||||
| **向量检索** | **Milvus** | 为长期记忆与 RAG 检索提供向量索引、召回与相似度搜索能力。 |
|
||||
| **向量依赖** | **MinIO + etcd** | 作为 Milvus 的对象存储与元数据依赖,随容器编排一起部署。 |
|
||||
| **身份认证** | **JWT** | 实现无状态登录,并把 `user_id` 等身份信息透传到 API 与服务层。 |
|
||||
| **配置管理** | **Viper** | 管理数据库、Redis、Kafka、RPC、模型、memory/RAG、通知等多环境配置。 |
|
||||
| **容器化交付** | **Dockerfile + Docker Compose + Nginx** | 已支持基础设施容器化与整站容器化;前端镜像通过 Nginx 托管静态产物。 |
|
||||
| **接口调试 / 文档** | **Apifox** | 用于维护接口协议、联调接口与沉淀阶段性 API 文档。 |
|
||||
| **日志与观测** | **标准库 `log` + Gin Logger/Recovery** | 当前代码以标准库日志为主,HTTP 层使用 Gin 默认日志与恢复中间件;Kafka、RAG、memory、Agent 链路也会输出关键观测日志。 |
|
||||
|
||||
## 5.2 架构图
|
||||
|
||||
PS:截至v0.3.3。其中黑色箭头为请求数据链路,绿色箭头为返回数据,虚线箭头为控制流。
|
||||
基于当前 `docker-compose.full.yml`、`backend/cmd/*` 与各服务 `dao/connect.go` 整理。实线表示同步 HTTP / RPC / 直连存储关系,虚线表示 Outbox + Kafka 异步事件链路;标注“迁移期”的连线表示该服务仍在直接读写其他域的表。
|
||||
|
||||

|
||||
```mermaid
|
||||
flowchart TB
|
||||
FE["Frontend (Vue)"] --> API["api / Gin Gateway<br/>JWT 鉴权 / 限流 / 幂等"]
|
||||
|
||||
subgraph SVC["服务层"]
|
||||
direction LR
|
||||
UA["userauth"]
|
||||
TASK["task"]
|
||||
COURSE["course"]
|
||||
TCLASS["task-class"]
|
||||
SCH["schedule"]
|
||||
AGENT["agent"]
|
||||
MEM["memory"]
|
||||
AS["active-scheduler"]
|
||||
NOTI["notification"]
|
||||
FORUM["taskclassforum"]
|
||||
TOKEN["tokenstore"]
|
||||
LLM["llm"]
|
||||
RAG["RAG / Milvus"]
|
||||
end
|
||||
|
||||
API --> UA
|
||||
API --> TASK
|
||||
API --> COURSE
|
||||
API --> TCLASS
|
||||
API --> SCH
|
||||
API --> AGENT
|
||||
API --> MEM
|
||||
API --> AS
|
||||
API --> NOTI
|
||||
API --> FORUM
|
||||
API --> TOKEN
|
||||
|
||||
AGENT --> LLM
|
||||
AGENT --> TASK
|
||||
AGENT --> TCLASS
|
||||
AGENT --> SCH
|
||||
AGENT --> MEM
|
||||
AGENT --> RAG
|
||||
COURSE --> LLM
|
||||
MEM --> LLM
|
||||
MEM --> RAG
|
||||
AS --> LLM
|
||||
AS --> TASK
|
||||
AS --> SCH
|
||||
FORUM --> TCLASS
|
||||
LLM --> TOKEN
|
||||
|
||||
subgraph REDIS["Redis"]
|
||||
direction TB
|
||||
R_API["限流 / 幂等响应缓存"]
|
||||
R_AUTH["JWT 黑名单 / Token 配额快照"]
|
||||
R_TASK["任务列表缓存 / 紧急性提升去重锁"]
|
||||
R_SCH["今日日程 / 周视图 / 最近完成 / 当前进行中缓存"]
|
||||
R_TCLASS["任务集列表缓存"]
|
||||
R_AGENT["会话历史 / Timeline / Schedule Preview / Agent State / Memory Prefetch"]
|
||||
R_FORUM["评论树缓存"]
|
||||
R_CREDIT["Credit 余额快照 / Blocked 标记"]
|
||||
end
|
||||
|
||||
API --> R_API
|
||||
UA --> R_AUTH
|
||||
TASK --> R_TASK
|
||||
SCH --> R_SCH
|
||||
TCLASS --> R_TCLASS
|
||||
AGENT --> R_AGENT
|
||||
FORUM --> R_FORUM
|
||||
TOKEN --> R_CREDIT
|
||||
LLM --> R_CREDIT
|
||||
|
||||
subgraph MYSQL["MySQL"]
|
||||
direction TB
|
||||
T_USERS["users<br/>user_token_usage_adjustments"]
|
||||
T_TASK["tasks"]
|
||||
T_TCLASS["task_classes<br/>task_items"]
|
||||
T_SCH["schedules<br/>schedule_events"]
|
||||
T_AGENT["agent_chats<br/>chat_histories<br/>agent_timeline_events<br/>agent_schedule_states<br/>agent_state_snapshot_records"]
|
||||
T_AS["active_schedule_jobs<br/>active_schedule_triggers<br/>active_schedule_previews<br/>active_schedule_sessions"]
|
||||
T_MEM["memory_items<br/>memory_jobs<br/>memory_audit_logs<br/>memory_user_settings"]
|
||||
T_NOTI["notification_records<br/>user_notification_channels"]
|
||||
T_FORUM["forum_posts<br/>forum_post_templates<br/>forum_post_template_items<br/>forum_likes<br/>forum_comments<br/>forum_imports"]
|
||||
T_CREDIT["credit_accounts<br/>credit_ledger<br/>credit_products<br/>credit_orders<br/>credit_price_rules<br/>credit_reward_rules"]
|
||||
|
||||
OB_AGENT["agent_outbox_messages"]
|
||||
OB_TASK["task_outbox_messages"]
|
||||
OB_MEM["memory_outbox_messages"]
|
||||
OB_AS["active_scheduler_outbox_messages"]
|
||||
OB_NOTI["notification_outbox_messages"]
|
||||
OB_TOKEN["token_store_outbox_messages"]
|
||||
end
|
||||
|
||||
UA --> T_USERS
|
||||
TASK --> T_TASK
|
||||
TCLASS --> T_TCLASS
|
||||
TCLASS -.->|迁移期直写| T_SCH
|
||||
SCH --> T_SCH
|
||||
SCH -.->|迁移期依赖| T_TASK
|
||||
SCH -.->|迁移期依赖| T_TCLASS
|
||||
COURSE -.->|课表导入写入| T_SCH
|
||||
AGENT --> T_AGENT
|
||||
AGENT -.->|读取任务| T_TASK
|
||||
AGENT -.->|读取任务集| T_TCLASS
|
||||
AGENT -.->|读取日程 / 预览| T_SCH
|
||||
AS --> T_AS
|
||||
AS -.->|读取会话与时间线| T_AGENT
|
||||
MEM --> T_MEM
|
||||
NOTI --> T_NOTI
|
||||
FORUM --> T_FORUM
|
||||
TOKEN --> T_CREDIT
|
||||
LLM -.->|读取价格规则 / Credit 守卫| T_CREDIT
|
||||
|
||||
subgraph KAFKA["Outbox + Kafka"]
|
||||
direction TB
|
||||
K_AGENT["smartflow.agent.outbox"]
|
||||
K_TASK["smartflow.task.outbox"]
|
||||
K_MEM["smartflow.memory.outbox"]
|
||||
K_AS["smartflow.active-scheduler.outbox"]
|
||||
K_NOTI["smartflow.notification.outbox"]
|
||||
K_TOKEN["smartflow.token-store.outbox"]
|
||||
end
|
||||
|
||||
AGENT -.->|聊天持久化 / 状态快照 / Timeline 持久化| OB_AGENT
|
||||
AGENT -.->|memory.extract.requested| OB_MEM
|
||||
TASK -.->|task.urgency.promote.requested| OB_TASK
|
||||
AS -.->|active_schedule.triggered| OB_AS
|
||||
AS -.->|notification.feishu.requested| OB_NOTI
|
||||
FORUM -.->|forum.post.liked / forum.post.imported| OB_TOKEN
|
||||
LLM -.->|credit.charge.requested| OB_TOKEN
|
||||
|
||||
OB_AGENT -.->|relay| K_AGENT
|
||||
OB_TASK -.->|relay| K_TASK
|
||||
OB_MEM -.->|relay| K_MEM
|
||||
OB_AS -.->|relay| K_AS
|
||||
OB_NOTI -.->|relay| K_NOTI
|
||||
OB_TOKEN -.->|relay| K_TOKEN
|
||||
|
||||
K_AGENT -.->|consume| AGENT
|
||||
K_TASK -.->|consume| TASK
|
||||
K_MEM -.->|consume| MEM
|
||||
K_AS -.->|consume| AS
|
||||
K_NOTI -.->|consume| NOTI
|
||||
K_TOKEN -.->|consume| TOKEN
|
||||
```
|
||||
|
||||
## 5.3 核心算法
|
||||
|
||||
@@ -713,12 +901,12 @@ frontend/src
|
||||
│ ├─ assistant/ # 上下文窗口、排程结果卡片、微调弹窗、任务类选择器
|
||||
│ ├─ common/ # 全局主侧边栏 MainSidebar
|
||||
│ ├─ dashboard/ # 首页卡片与 AssistantPanel
|
||||
│ └─ schedule/ # 任务类侧栏、周课表画板、创建弹窗
|
||||
│ └─ schedule/ # 任务类侧栏、周课表画板、创建弹窗、课表导入弹窗
|
||||
├─ router/ # 路由定义与守卫
|
||||
├─ stores/ # Pinia store(当前主要是 auth)
|
||||
├─ types/ # dashboard / schedule / api 类型定义
|
||||
├─ utils/ # 日期、Markdown、HTTP 错误、幂等 key 等工具
|
||||
├─ views/ # Auth / Dashboard / Assistant / Schedule / Prototype
|
||||
├─ views/ # Home / Auth / Dashboard / Assistant / Schedule / Forum / Store / PlanDetail / debug
|
||||
├─ App.vue # 全局布局壳层与 router-view 容器
|
||||
└─ main.ts # Vue / Pinia / Router / Element Plus 挂载入口
|
||||
```
|
||||
@@ -729,8 +917,8 @@ frontend/src
|
||||
2. 本地开发通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8080`。
|
||||
3. 常规接口统一走 `frontend/src/api/http.ts`,内置 `401 -> refresh token -> 原请求重放`。
|
||||
4. 对话流接口 `POST /api/v1/agent/chat` 单独走原生 `fetch`,并在前端手动处理一次 refresh token 重试。
|
||||
5. 写操作尽量补 `X-Idempotency-Key`,当前任务类创建、日程应用、日程删除、任务块删除都已接入。
|
||||
6. `App.vue` 会对 `/dashboard`、`/assistant`、`/schedule` 统一套用主壳层与 `MainSidebar`,而不是每个页面各自维护一套侧边栏。
|
||||
5. 写操作尽量补 `X-Idempotency-Key`,当前任务类创建 / 更新、课表导入、日程应用、日程删除、任务块删除都已接入。
|
||||
6. `App.vue` 会对 `/dashboard`、`/assistant`、`/schedule`、`/forum`、`/store` 与 `/forum/:id` 统一套用主壳层与 `MainSidebar`,而不是每个页面各自维护一套侧边栏。
|
||||
|
||||
## 6.2 当前页面与路由状态
|
||||
|
||||
@@ -738,18 +926,23 @@ frontend/src
|
||||
|
||||
| 路由 | 页面状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/` | 已完成 | 默认重定向到 `/dashboard` |
|
||||
| `/` | 已完成 | 独立 Home 落地页;CTA 会根据登录态跳转 `/auth` 或 `/dashboard` |
|
||||
| `/auth` | 已完成 | 登录/注册同页切换,支持 `redirect` 回跳 |
|
||||
| `/dashboard` | 已完成 | 首页工作台,承接四象限任务与今日日程 |
|
||||
| `/assistant` | 已完成 | `newAgent` 对话主入口,支持时间线、确认卡片、排程结果卡片 |
|
||||
| `/schedule` | 已完成 | 传统课表中心,支持任务类、粗排、拖拽预览与正式应用 |
|
||||
| `/prototype/tool-trace` | 原型页 | 用于展示工具 trace / 阻断 / 排程卡片交互原型,不在主导航中暴露 |
|
||||
| `/assistant/:id?` | 已完成 | `newAgent` 对话主入口,支持按会话 ID 直达历史会话 |
|
||||
| `/schedule` | 已完成 | 传统课表中心,支持任务类、课表导入、粗排、拖拽预览与正式应用 |
|
||||
| `/forum` | 已接路由 | 社区 / 方案广场入口,不在首页工作台内重复承载 |
|
||||
| `/forum/:id` | 已接路由 | 方案详情页,沿用主壳层与登录态守卫 |
|
||||
| `/store` | 已接路由 | 商店页,沿用主壳层与登录态守卫 |
|
||||
| `/debug/tool-card` | 调试页 | 单卡片调试页,不在主导航中暴露 |
|
||||
| `/debug/tool-cards` | 调试页 | 工具卡片集合调试页,不在主导航中暴露 |
|
||||
| `/debug/assistant/:id?` | 调试页 | 助手调试页,不在主导航中暴露 |
|
||||
|
||||
当前仍未接出正式独立路由的入口:
|
||||
当前仍未纳入正式主导航 / 独立业务入口的部分:
|
||||
|
||||
1. “任务”独立页仍未落地,侧边栏没有 `/task` 路由。
|
||||
2. 侧边栏底部“设置”按钮仍是视觉占位,尚未接出 `/settings`。
|
||||
3. `src/views/layout`、`src/views/login`、`src/views/register`、`src/views/settings` 以及 `src/store/` 目前更偏预留/历史残留目录,不承载当前主运行链路。
|
||||
3. 调试页已经接出正式路由,但仍只用于内部联调,不纳入正式主导航。
|
||||
|
||||
## 6.3 认证页 `/auth`
|
||||
|
||||
@@ -763,7 +956,7 @@ frontend/src
|
||||
|
||||
1. 登录与注册共用一页,通过 tab 切换。
|
||||
2. 登录成功后会把 `access_token`、`refresh_token` 与最近一次用户名写入 `localStorage`。
|
||||
3. 路由守卫会阻止未登录用户进入 `/dashboard`、`/assistant`、`/schedule`。
|
||||
3. 路由守卫会阻止未登录用户进入 `/dashboard`、`/assistant`、`/schedule`、`/forum`、`/forum/:id` 与 `/store`。
|
||||
4. 普通 JSON 接口走 Axios 自动续签;流式对话接口走 `fetch` 时也会在前端手动尝试一次 refresh token 重试。
|
||||
5. 登出时会先尽力调用后端注销接口,再无条件清理本地登录态,避免前端出现“假在线”。
|
||||
|
||||
@@ -779,13 +972,13 @@ frontend/src
|
||||
|
||||
当前已实现能力:
|
||||
|
||||
1. `/dashboard`、`/assistant`、`/schedule` 已统一挂在同一套全局壳层下,由 `App.vue + MainSidebar` 负责外层布局。
|
||||
2. 侧边栏当前只保留“总览 / 日程 / 助手”三项主导航,避免过早把尚未做完的页面入口暴露出来。
|
||||
1. `/dashboard`、`/assistant`、`/schedule`、`/forum`、`/store` 与 `/forum/:id` 已统一挂在同一套全局壳层下,由 `App.vue + MainSidebar` 负责外层布局。
|
||||
2. 侧边栏当前提供“总览 / 日程 / 助手 / 社区 / 商店”五项主导航;底部“设置”按钮仍是视觉占位。
|
||||
3. 首页中心区展示四象限任务卡片,支持获取任务列表、创建任务、完成任务、撤销完成任务。
|
||||
4. 右侧展示“今日日程”,通过 `GET /api/v1/schedule/today` 拉取当天事件。
|
||||
5. 首页顶部已具备退出登录、用户昵称展示、当前日期展示等基础工作台能力。
|
||||
6. 首页整体做了缩放适配,目标是在常见笔记本分辨率下尽量完整展示主要内容,而不是依赖用户手动缩放浏览器。
|
||||
7. “课表导入”入口目前仍是占位提示,还没有真正接出导入向导页。
|
||||
7. 课表导入入口已经收敛到 `/schedule` 页工具栏,通过弹窗完成图片识别、确认与正式导入。
|
||||
|
||||
## 6.5 AI 对话页 `/assistant`
|
||||
|
||||
@@ -839,16 +1032,19 @@ frontend/src
|
||||
|
||||
1. `/schedule` 仍然是一套独立于 `newAgent` 对话页的传统编排中心,主要承接任务类管理、粗排预览与正式应用。
|
||||
2. 左侧为任务类侧栏,右侧为周课表/排程画板,支持单选与批量多选两种任务类操作模式。
|
||||
3. 任务类侧栏支持获取任务类列表、展开详情、删除任务块、新建任务类弹窗,以及长列表滚动。
|
||||
3. 任务类侧栏支持获取任务类列表、展开详情、删除任务块、新建 / 更新任务类弹窗;右侧工具栏已接入课表图片导入对话框。
|
||||
4. 周课表支持周次切换,当前前端将请求范围限制在 `1 ~ 24` 周,并且加入请求序列号保护,快速切周时只认最后一次响应。
|
||||
5. 已接通的课表/编排相关接口包括:
|
||||
- `GET /api/v1/schedule/week`
|
||||
- `GET /api/v1/task-class/list`
|
||||
- `GET /api/v1/task-class/get`
|
||||
- `POST /api/v1/task-class/add`
|
||||
- `PUT /api/v1/task-class/update`
|
||||
- `DELETE /api/v1/task-class/delete-item`
|
||||
- `GET /api/v1/schedule/smart-planning`
|
||||
- `POST /api/v1/schedule/smart-planning-multi`
|
||||
- `POST /api/v1/course/parse-image`
|
||||
- `POST /api/v1/course/import`
|
||||
- `PUT /api/v1/task-class/apply-batch-into-schedule`
|
||||
- `DELETE /api/v1/schedule/delete`
|
||||
6. 智能编排结果当前分为单任务类粗排和多任务类批量粗排,结果先进入前端运行时预览态,而不会立即写入正式课表。
|
||||
@@ -860,32 +1056,38 @@ frontend/src
|
||||
|
||||
## 6.7 当前前后端衔接边界
|
||||
|
||||
当前前端已经覆盖的主业务链路:
|
||||
当前前端已经覆盖的主业务链路与调试入口:
|
||||
|
||||
1. 登录 / 注册 / 自动续签 / 安全登出。
|
||||
2. 首页四象限任务获取、创建、完成、撤销与今日日程展示。
|
||||
3. `newAgent` 对话、历史会话、思考内容展示、消息重试、结构化时间线、确认覆盖层、上下文窗口计量。
|
||||
4. AI 对话页中的排程结果卡片、结构化预览拉取、弹窗微调、暂存到 Redis 状态、正式应用到课表。
|
||||
5. 传统 `/schedule` 页面中的任务类管理、智能粗排、批量粗排、拖拽预览、正式应用、删除日程。
|
||||
6. `/prototype/tool-trace` 原型页,用于展示工具 trace 与交互形态。
|
||||
5. 传统 `/schedule` 页面中的任务类管理、课表导入、智能粗排、批量粗排、拖拽预览、正式应用、删除日程。
|
||||
6. `/debug/tool-card`、`/debug/tool-cards` 与 `/debug/assistant/:id?` 调试页,用于展示工具卡片与助手调试交互形态。
|
||||
|
||||
当前仍明确留给后续迭代的部分:
|
||||
|
||||
1. “任务”独立页面与“设置”独立页面尚未接出。
|
||||
2. AI 输入区已经预留附件上传按钮,但上传、解析、落盘、发送给模型的完整链路尚未接通。
|
||||
3. `/assistant` 与 `/schedule` 目前还是两套并行入口,状态模型与交互语义尚未完全统一。
|
||||
4. 课表导入流程入口虽然已预留,但还没有完整的导入页与导入向导。
|
||||
5. 用户消息“修改后原地替换旧消息”的真正后端语义尚未实现,目前仍按“复制到输入框后再发送一条新消息”处理。
|
||||
6. 原型页、预留目录和历史壳层代码还未进一步收敛清理,说明前端仍处在快速迭代期。
|
||||
4. 用户消息“修改后原地替换旧消息”的真正后端语义尚未实现,目前仍按“复制到输入框后再发送一条新消息”处理。
|
||||
5. 调试页、预留目录和历史壳层代码还未进一步收敛清理,说明前端仍处在快速迭代期。
|
||||
|
||||
# 7 部署与监控
|
||||
|
||||
## 7.1 容器化部署方案
|
||||
当前项目已经具备一套**“依赖栈容器化 + 应用进程宿主机运行”**的落地方案,适合本地联调、答辩演示和单机部署。
|
||||
当前项目已经同时提供两档容器化形态:
|
||||
|
||||
1. **基础设施容器化**:使用 `docker-compose.yml` 只拉起 MySQL / Redis / Kafka / Milvus 等依赖栈,适合本地开发与脚本托管后端。
|
||||
2. **整站容器化**:使用 `docker-compose.full.yml` 连同后端多服务与前端一起拉起,适合单机演示、联调和离线交付。
|
||||
|
||||
### 当前部署形态
|
||||
|
||||
当前根目录已经提供 `docker-compose.yml`,用于启动以下基础设施:
|
||||
当前根目录已经同时提供 `docker-compose.yml` 与 `docker-compose.full.yml`:
|
||||
|
||||
### 方案 A:基础设施容器化(本地开发)
|
||||
|
||||
`docker-compose.yml` 用于启动以下基础设施:
|
||||
|
||||
| 服务 | 端口 | 用途 |
|
||||
| --- | --- | --- |
|
||||
@@ -896,64 +1098,39 @@ frontend/src
|
||||
| MinIO | `9000` / `9001` | Milvus 对象存储依赖 |
|
||||
| Milvus Standalone | `19530` / `9091` | RAG / memory 向量检索引擎 |
|
||||
| Attu | `8000` | Milvus 可视化管理台 |
|
||||
| kafka-init | 无外部端口 | 启动时自动创建 `smartflow.agent.outbox` topic |
|
||||
| kafka-init | 无外部端口 | 启动时自动创建 `smartflow.*.outbox` 相关 topic |
|
||||
|
||||
其中,`docker-compose.yml` 已经为 MySQL、Redis、Kafka、etcd、MinIO、Milvus 配好了 `healthcheck`,并通过 `depends_on.condition: service_healthy` 保证依赖按健康状态顺序启动。
|
||||
|
||||
### 推荐启动顺序
|
||||
### 方案 B:整站容器化(单机部署 / 演示)
|
||||
|
||||
1. 先启动依赖栈:
|
||||
`docker-compose.full.yml` 会在上述基础设施之上,继续拉起:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
1. 后端服务:`userauth`、`notification`、`active-scheduler`、`schedule`、`task`、`task-class`、`course`、`memory`、`agent`、`taskclassforum`、`tokenstore`、`llm`、`api`
|
||||
2. 前端服务:`frontend`
|
||||
|
||||
2. 准备后端配置文件:
|
||||
对应镜像来源如下:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
1. `backend/Dockerfile`:统一构建后端多服务二进制,运行时镜像默认以 `api` 为入口,可被 `docker-compose.full.yml` 复用为整套后端服务。
|
||||
2. `frontend/Dockerfile`:构建 Vite 产物并通过 Nginx 提供静态站点服务。
|
||||
3. `deploy/docker-pack.ps1`:可在 Windows 环境打出 `smartflow/backend-suite` 与 `smartflow/frontend` 镜像包。
|
||||
4. `deploy/docker-load.sh`:可在目标机器批量导入镜像 tar 包。
|
||||
|
||||
然后按实际环境修改以下配置:
|
||||
### 推荐使用方式
|
||||
|
||||
1. `database`:MySQL 地址、用户名、密码、库名。
|
||||
2. `redis`:Redis 地址、密码。
|
||||
3. `kafka`:Broker 地址与 topic / groupID。
|
||||
4. `jwt`:`accessSecret` 与 `refreshSecret`。
|
||||
5. `agent`:模型名、`baseURL`、推理开关。
|
||||
6. `rag` / `memory`:Milvus 地址、embedding 配置、memory 模式。
|
||||
7. `websearch`:联网搜索 provider 与 API Key。
|
||||
1. **本地开发**:先执行 `docker compose up -d` 拉起 `docker-compose.yml` 里的依赖栈,然后按第 8 章的脚本流启动后端与前端开发环境。
|
||||
2. **整站部署 / 演示**:先构建或导入应用镜像,再执行 `docker compose -f docker-compose.full.yml up -d` 拉起完整站点。
|
||||
|
||||
3. 启动后端:
|
||||
整站容器化默认暴露:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go run .
|
||||
```
|
||||
|
||||
后端默认监听 `8080`,并提供健康检查接口:
|
||||
|
||||
```text
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
4. 启动前端:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端默认运行在 `5173`,并通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8080`。
|
||||
1. `api`:`8080`
|
||||
2. `frontend`:`80`
|
||||
|
||||
### 当前方案的边界
|
||||
|
||||
1. **已容器化的部分**:MySQL、Redis、Kafka、Milvus 及其依赖。
|
||||
2. **未容器化的部分**:Go 后端进程与 Vue 前端进程当前仍以宿主机方式运行,仓库中尚未提供后端/前端 Dockerfile。
|
||||
3. **因此更准确的表述**:当前项目已经完成“基础设施容器化”,但还没有做到“整站一键镜像化部署”。
|
||||
4. **如果后续继续工程化**:优先补 `backend/Dockerfile`、`frontend/Dockerfile` 与生产态反向代理配置,再把前后端服务一并纳入 compose 编排。
|
||||
1. **已具备完整应用层容器化能力**:仓库已提供后端 / 前端 Dockerfile 与 full-stack compose,旧版 README 中“应用层尚未容器化”的表述已不再适用。
|
||||
2. **当前更偏单机编排**:`docker-compose.full.yml` 适合开发、演示与单机交付,尚未展开到多实例调度、灰度发布、集中式日志平台等更重的生产治理能力。
|
||||
3. **本地开发入口已切换**:仓库根 `go run .` 现在只是兼容壳入口;当前推荐的本地后端启动方式以 `backend/scripts/*.ps1` 为准,避免和第 8 章冲突。
|
||||
|
||||
## 7.2 性能监控&统计
|
||||
当前项目已经接入了一套**轻量级、以日志和内存计数器为主的观测方案**,但还没有完整接入 Prometheus / Grafana 这类统一监控平台。
|
||||
@@ -1011,7 +1188,34 @@ npm run dev
|
||||
|
||||
# 8 快速开始
|
||||
|
||||
## 8.1 启动前端开发环境
|
||||
## 8.1 后端本地快速启动
|
||||
|
||||
后端开发统一使用 `backend` 根目录下的 PowerShell 启动脚本,不再维护 `cmd/all` 聚合入口。
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
.\scripts\dev-up.ps1
|
||||
.\scripts\services-up.ps1
|
||||
.\scripts\dev-status.ps1
|
||||
.\scripts\dev-logs.ps1 -Service api -Stream stdout -Follow
|
||||
.\scripts\service-restart.ps1 -Service api
|
||||
.\scripts\services-down.ps1
|
||||
.\scripts\dev-down.ps1
|
||||
.\scripts\dev-down.ps1 -StopInfra
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. 所有后端脚本统一收敛在 `backend/scripts` 目录下。
|
||||
2. `scripts/dev-up.ps1` 会先确保 Docker 基础设施就绪,再按顺序构建并拉起全部 RPC 服务与 API。
|
||||
3. `scripts/services-up.ps1` 只拉起后端服务本身,不触碰 Docker 基础设施。
|
||||
4. `scripts/dev-status.ps1` 用于查看各服务是脚本托管、外部运行还是未启动。
|
||||
5. `scripts/dev-logs.ps1` 用于查看单个服务最新日志;可选 `-Stream stdout|stderr|both`,带 `-Follow` 可持续追日志。
|
||||
6. `scripts/service-restart.ps1 -Service <name>` 用于重启单个脚本托管的后端服务;若该服务由外部进程托管,则会直接拒绝操作。
|
||||
7. `scripts/services-down.ps1` 只停止脚本托管的后端服务进程。
|
||||
8. `scripts/dev-down.ps1` 默认只停止脚本托管的后端进程;加 `-StopInfra` 才会一并停止 Docker 基础设施。
|
||||
|
||||
## 8.2 启动前端开发环境
|
||||
|
||||
前端目录在 `frontend/`,本地开发步骤如下:
|
||||
|
||||
@@ -1027,7 +1231,7 @@ npm run dev
|
||||
2. 开发代理目标:`http://127.0.0.1:8080`
|
||||
3. 因此前端本地联调前,需要先确保后端服务已经启动在 `8080`
|
||||
|
||||
## 8.2 前端生产构建
|
||||
## 8.3 前端生产构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
@@ -1040,7 +1244,7 @@ npm run preview
|
||||
1. `npm run build` 会先执行 `vue-tsc -b` 做类型检查,再执行 `vite build`。
|
||||
2. 当前构建是可通过的;但由于主包仍然偏大,Vite 会给出 chunk size warning,这属于现阶段可接受状态。
|
||||
|
||||
## 8.3 建议的前后端联调顺序
|
||||
## 8.4 建议的前后端联调顺序
|
||||
|
||||
建议按下面顺序启动和验证:
|
||||
|
||||
|
||||
5
backend/.dockerignore
Normal file
5
backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.dev/
|
||||
.gocache/
|
||||
.gopath/
|
||||
bin/
|
||||
config.yaml
|
||||
42
backend/Dockerfile
Normal file
42
backend/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG GO_IMAGE=golang:1.25-bookworm
|
||||
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||
|
||||
FROM ${GO_IMAGE} AS builder
|
||||
|
||||
WORKDIR /src/backend
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
# 1. 统一构建所有需要部署的后端服务二进制,避免每个服务维护一份 Dockerfile。
|
||||
# 2. 输出目录固定为 /out,便于运行时镜像按命令复用同一套产物。
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
mkdir -p /out && \
|
||||
for service in userauth notification active-scheduler schedule task task-class course memory agent taskclassforum tokenstore llm api; do \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o "/out/${service}" "./cmd/${service}"; \
|
||||
done
|
||||
|
||||
FROM ${RUNTIME_IMAGE} AS runtime
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# 1. 运行时只保留证书与时区数据,保证 HTTPS 请求与国内时区配置可用。
|
||||
# 2. 默认附带一份容器配置模板,实际部署仍可通过挂载文件覆盖。
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /out /app/bin
|
||||
COPY config.docker.yaml /app/backend/config.docker.yaml
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV SMARTFLOW_CONFIG_FILE=/app/backend/config.docker.yaml
|
||||
|
||||
CMD ["/app/bin/api"]
|
||||
@@ -116,8 +116,13 @@ func buildLLMOutboxDispatchEngine(outboxRepo *outboxinfra.Repository) (*outboxin
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 1. LLM 进程这里只启动“LLM 自己 outbox 的 dispatch”,不应复用 kafka.LoadConfig() 里的全局默认 topic/group。
|
||||
// 2. 全局默认值当前仍兼容指向 agent outbox;若只改 ServiceName 不同步改 Topic/GroupID,llm.exe 会误入 agent consumer group。
|
||||
// 3. 因此这里显式绑定 llm 服务目录,确保 dispatch engine 只触达 llm_outbox_messages / smartflow.llm.outbox。
|
||||
route, _ := outboxinfra.ResolveServiceRoute(outboxinfra.ServiceLLM)
|
||||
kafkaCfg.ServiceName = outboxinfra.ServiceLLM
|
||||
kafkaCfg.ServiceName = route.ServiceName
|
||||
kafkaCfg.Topic = route.Topic
|
||||
kafkaCfg.GroupID = route.GroupID
|
||||
return outboxinfra.NewEngine(outboxRepo.WithRoute(route), kafkaCfg)
|
||||
}
|
||||
|
||||
|
||||
259
backend/config.docker.yaml
Normal file
259
backend/config.docker.yaml
Normal file
@@ -0,0 +1,259 @@
|
||||
# SmartFlow 容器化部署配置模板。
|
||||
#
|
||||
# 说明:
|
||||
# 1. 该文件面向 Docker Compose 内部网络,所有依赖地址都改为服务名。
|
||||
# 2. 本地开发仍可继续使用 backend/config.yaml,不与该文件冲突。
|
||||
# 3. 正式部署前请至少替换 JWT 密钥、模型密钥与外部服务凭证。
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
database:
|
||||
host: mysql
|
||||
port: 3306
|
||||
user: smartflow_user
|
||||
password: "smartflow_password_456"
|
||||
dbname: "smartflow"
|
||||
|
||||
jwt:
|
||||
accessSecret: "change_me_access_secret"
|
||||
refreshSecret: "change_me_refresh_secret"
|
||||
accessTokenExpire: 15min
|
||||
refreshTokenExpire: 7d
|
||||
|
||||
geetest:
|
||||
captchaID: "80eb5b33de9fba62c69f5e57e36d9638"
|
||||
privateKey: "991f9ad68191cbf6b823257ac67372f3"
|
||||
|
||||
redis:
|
||||
host: redis
|
||||
port: 6379
|
||||
password: "redis_password_789"
|
||||
|
||||
userauth:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9081"
|
||||
endpoints:
|
||||
- "userauth:9081"
|
||||
timeout: 2s
|
||||
|
||||
taskclassforum:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9090"
|
||||
endpoints:
|
||||
- "taskclassforum:9090"
|
||||
timeout: 2s
|
||||
|
||||
tokenstore:
|
||||
reward:
|
||||
forumLikeAmount: 1
|
||||
forumImportAmount: 5
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9095"
|
||||
endpoints:
|
||||
- "tokenstore:9095"
|
||||
timeout: 2s
|
||||
|
||||
llm:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9096"
|
||||
endpoints:
|
||||
- "llm:9096"
|
||||
timeout: 0s
|
||||
|
||||
kafka:
|
||||
enabled: true
|
||||
brokers:
|
||||
- "kafka:9092"
|
||||
topic: "smartflow.agent.outbox"
|
||||
groupID: "smartflow-agent-outbox-consumer"
|
||||
retryScanInterval: 1s
|
||||
retryBatchSize: 100
|
||||
maxRetry: 20
|
||||
|
||||
outbox:
|
||||
services:
|
||||
agent:
|
||||
topic: "smartflow.agent.outbox"
|
||||
groupID: "smartflow-agent-outbox-consumer"
|
||||
table: "agent_outbox_messages"
|
||||
task:
|
||||
topic: "smartflow.task.outbox"
|
||||
groupID: "smartflow-task-outbox-consumer"
|
||||
table: "task_outbox_messages"
|
||||
memory:
|
||||
topic: "smartflow.memory.outbox"
|
||||
groupID: "smartflow-memory-outbox-consumer"
|
||||
table: "memory_outbox_messages"
|
||||
active-scheduler:
|
||||
topic: "smartflow.active-scheduler.outbox"
|
||||
groupID: "smartflow-active-scheduler-outbox-consumer"
|
||||
table: "active_scheduler_outbox_messages"
|
||||
notification:
|
||||
topic: "smartflow.notification.outbox"
|
||||
groupID: "smartflow-notification-outbox-consumer"
|
||||
table: "notification_outbox_messages"
|
||||
taskclass-forum:
|
||||
topic: "smartflow.taskclass-forum.outbox"
|
||||
groupID: "smartflow-taskclass-forum-outbox-consumer"
|
||||
table: "taskclass_forum_outbox_messages"
|
||||
llm:
|
||||
topic: "smartflow.llm.outbox"
|
||||
groupID: "smartflow-llm-outbox-consumer"
|
||||
table: "llm_outbox_messages"
|
||||
token-store:
|
||||
topic: "smartflow.token-store.outbox"
|
||||
groupID: "smartflow-token-store-outbox-consumer"
|
||||
table: "token_store_outbox_messages"
|
||||
|
||||
notification:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9082"
|
||||
endpoints:
|
||||
- "notification:9082"
|
||||
timeout: 6s
|
||||
frontendBaseURL: "https://smartflow.example.com"
|
||||
retryScanEvery: 1m
|
||||
retryBatchSize: 50
|
||||
|
||||
cors:
|
||||
allowedOrigins:
|
||||
- "http://localhost:5173"
|
||||
- "https://smartflow.example.com"
|
||||
|
||||
schedule:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9084"
|
||||
endpoints:
|
||||
- "schedule:9084"
|
||||
timeout: 6s
|
||||
|
||||
task:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9085"
|
||||
endpoints:
|
||||
- "task:9085"
|
||||
timeout: 6s
|
||||
|
||||
taskClass:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9086"
|
||||
endpoints:
|
||||
- "task-class:9086"
|
||||
timeout: 6s
|
||||
|
||||
course:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9087"
|
||||
endpoints:
|
||||
- "course:9087"
|
||||
timeout: 5m
|
||||
|
||||
activeScheduler:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9083"
|
||||
endpoints:
|
||||
- "active-scheduler:9083"
|
||||
timeout: 8s
|
||||
jobScanEvery: 1m
|
||||
jobScanLimit: 50
|
||||
|
||||
time:
|
||||
zone: "Asia/Shanghai"
|
||||
semesterStartDate: "2026-03-02"
|
||||
semesterEndDate: "2026-07-19"
|
||||
|
||||
agent:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9089"
|
||||
endpoints:
|
||||
- "agent:9089"
|
||||
timeout: 0s
|
||||
chat:
|
||||
enabled: true
|
||||
api:
|
||||
enabled: true
|
||||
liteModel: "doubao-seed-2-0-code-preview-260215"
|
||||
proModel: "doubao-seed-2-0-code-preview-260215"
|
||||
maxModel: "doubao-seed-2-0-code-preview-260215"
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3"
|
||||
thinking:
|
||||
plan: true
|
||||
execute: true
|
||||
deliver: false
|
||||
memory: false
|
||||
|
||||
courseImport:
|
||||
visionModel: ""
|
||||
maxImageBytes: 5242880
|
||||
maxTokens: 8192
|
||||
|
||||
rag:
|
||||
enabled: true
|
||||
store: "milvus"
|
||||
topK: 8
|
||||
threshold: 0.55
|
||||
retrieve:
|
||||
timeoutMs: 1500
|
||||
ingest:
|
||||
chunkSize: 400
|
||||
chunkOverlap: 80
|
||||
embed:
|
||||
provider: "eino"
|
||||
model: "doubao-embedding-vision-251215"
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3"
|
||||
timeoutMs: 1200
|
||||
dimension: 1024
|
||||
reranker:
|
||||
enabled: false
|
||||
provider: "noop"
|
||||
timeoutMs: 1200
|
||||
milvus:
|
||||
address: "http://milvus-standalone:19530"
|
||||
token: "root:Milvus"
|
||||
dbName: ""
|
||||
collectionName: "smartflow_rag_chunks"
|
||||
metricType: "COSINE"
|
||||
requestTimeoutMs: 1500
|
||||
|
||||
memory:
|
||||
rpc:
|
||||
listenOn: "0.0.0.0:9088"
|
||||
endpoints:
|
||||
- "memory:9088"
|
||||
timeout: 6s
|
||||
enabled: true
|
||||
rag:
|
||||
enabled: true
|
||||
read:
|
||||
mode: legacy
|
||||
constraintLimit: 5
|
||||
preferenceLimit: 5
|
||||
factLimit: 5
|
||||
inject:
|
||||
renderMode: flat
|
||||
prompt:
|
||||
extract: ""
|
||||
decision: ""
|
||||
threshold: 0.55
|
||||
enableReranker: false
|
||||
llm:
|
||||
temperature: 0.1
|
||||
topP: 0.2
|
||||
job:
|
||||
maxRetry: 6
|
||||
worker:
|
||||
pollEvery: 2s
|
||||
claimBatch: 1
|
||||
decision:
|
||||
enabled: true
|
||||
candidateTopK: 5
|
||||
candidateMinScore: 0.6
|
||||
fallbackMode: legacy_add
|
||||
write:
|
||||
mode: legacy
|
||||
minConfidence: 0.5
|
||||
|
||||
websearch:
|
||||
provider: bocha
|
||||
apiKey: ""
|
||||
@@ -23,6 +23,11 @@ jwt:
|
||||
accessTokenExpire: 15min
|
||||
refreshTokenExpire: 7d
|
||||
|
||||
# 极验行为验证配置。
|
||||
geetest:
|
||||
captchaID: "put_your_geetest_captcha_id_here"
|
||||
privateKey: "put_your_geetest_private_key_here"
|
||||
|
||||
# Redis 配置。
|
||||
redis:
|
||||
host: localhost
|
||||
|
||||
59
backend/gateway/api/userauth/dto.go
Normal file
59
backend/gateway/api/userauth/dto.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package userauthapi
|
||||
|
||||
import contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
|
||||
|
||||
// geeTestValidatePayload 只承载 gateway 边界的人机验证字段。
|
||||
// 职责边界:
|
||||
// 1. 负责承接前端提交的 geetest 三元组;
|
||||
// 2. 不负责 user/auth RPC 入参映射,避免把第三方验证码字段带入内部服务契约;
|
||||
// 3. 不负责校验逻辑,真正校验由 GeeTestService 完成。
|
||||
type geeTestValidatePayload struct {
|
||||
Challenge string `json:"geetest_challenge"`
|
||||
Validate string `json:"geetest_validate"`
|
||||
Seccode string `json:"geetest_seccode"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
|
||||
geeTestValidatePayload
|
||||
}
|
||||
|
||||
func (r registerRequest) toContract() contracts.RegisterRequest {
|
||||
return contracts.RegisterRequest{
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
PhoneNumber: r.PhoneNumber,
|
||||
}
|
||||
}
|
||||
|
||||
func (r registerRequest) captchaPayload() geeTestValidatePayload {
|
||||
return r.geeTestValidatePayload
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
|
||||
geeTestValidatePayload
|
||||
}
|
||||
|
||||
func (r loginRequest) toContract() contracts.LoginRequest {
|
||||
return contracts.LoginRequest{
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
}
|
||||
}
|
||||
|
||||
func (r loginRequest) captchaPayload() geeTestValidatePayload {
|
||||
return r.geeTestValidatePayload
|
||||
}
|
||||
|
||||
type captchaRegisterResponse struct {
|
||||
Success int `json:"success"`
|
||||
GT string `json:"gt"`
|
||||
Challenge string `json:"challenge"`
|
||||
NewCaptcha bool `json:"new_captcha"`
|
||||
}
|
||||
188
backend/gateway/api/userauth/geetest.go
Normal file
188
backend/gateway/api/userauth/geetest.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package userauthapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
geeTestRegisterURL = "https://api.geetest.com/register.php"
|
||||
geeTestValidateURL = "http://api.geetest.com/validate.php"
|
||||
geeTestClientType = "web"
|
||||
geeTestSDKName = "smartflow-gateway-go/1.0"
|
||||
)
|
||||
|
||||
type geeTestRegisterUpstreamResponse struct {
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
|
||||
type geeTestValidateUpstreamResponse struct {
|
||||
Seccode string `json:"seccode"`
|
||||
}
|
||||
|
||||
// GeeTestService 负责封装 gateway 与极验 v3 接口的最小交互。
|
||||
// 职责边界:
|
||||
// 1. 只处理验证码初始化与二次校验,不承载登录/注册业务;
|
||||
// 2. 只暴露 gateway HTTP 层真正需要的最小方法,避免把第三方协议散落到 handler;
|
||||
// 3. 不做离线 failback 存储,当前阶段聚焦“在线校验闭环”这一个能力域。
|
||||
type GeeTestService struct {
|
||||
captchaID string
|
||||
privateKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewGeeTestServiceFromConfig() *GeeTestService {
|
||||
return &GeeTestService{
|
||||
captchaID: strings.TrimSpace(viper.GetString("geetest.captchaID")),
|
||||
privateKey: strings.TrimSpace(viper.GetString("geetest.privateKey")),
|
||||
httpClient: &http.Client{Timeout: 3 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Register 负责向极验申请当前页 challenge,并转成前端 `initGeetest` 可直接消费的结构。
|
||||
// 职责边界:
|
||||
// 1. 只对应官方 API1 初始化;
|
||||
// 2. 不负责缓存 challenge,也不负责表单业务字段;
|
||||
// 3. 若极验服务不可用,直接返回初始化失败,让前端走显式提示。
|
||||
func (s *GeeTestService) Register(ctx context.Context, clientIP string) (*captchaRegisterResponse, error) {
|
||||
if !s.isConfigured() {
|
||||
return nil, respond.CaptchaInitFailed
|
||||
}
|
||||
|
||||
// 1. 先按官方 API1 约定拉取原始 challenge。
|
||||
// 2. 再使用 privateKey 做一次签名混淆,避免把上游原始 challenge 直接暴露给前端。
|
||||
// 3. 任一步失败都直接中断,让登录/注册入口显式暴露初始化异常。
|
||||
query := url.Values{}
|
||||
query.Set("digestmod", "md5")
|
||||
query.Set("gt", s.captchaID)
|
||||
query.Set("json_format", "1")
|
||||
query.Set("sdk", geeTestSDKName)
|
||||
query.Set("client_type", geeTestClientType)
|
||||
if ip := strings.TrimSpace(clientIP); ip != "" {
|
||||
query.Set("ip_address", ip)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, geeTestRegisterURL+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, respond.CaptchaInitFailed
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, respond.CaptchaInitFailed
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
rawBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, respond.CaptchaInitFailed
|
||||
}
|
||||
|
||||
challenge := extractRegisterChallenge(rawBody)
|
||||
if challenge == "" {
|
||||
return nil, respond.CaptchaInitFailed
|
||||
}
|
||||
|
||||
return &captchaRegisterResponse{
|
||||
Success: 1,
|
||||
GT: s.captchaID,
|
||||
Challenge: md5Hex(challenge + s.privateKey),
|
||||
NewCaptcha: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify 负责校验前端回传的极验三元组。
|
||||
// 职责边界:
|
||||
// 1. 先做本地 challenge/validate 一致性检查,尽早拦截无效请求;
|
||||
// 2. 再调用官方 API2 校验 seccode,保证验证码结果真实有效;
|
||||
// 3. 只返回“通过/失败/服务不可用”三类结论,不混入登录注册业务判断。
|
||||
func (s *GeeTestService) Verify(ctx context.Context, payload geeTestValidatePayload, clientIP string) error {
|
||||
if !s.isConfigured() {
|
||||
return respond.CaptchaVerifyUnavailable
|
||||
}
|
||||
|
||||
challenge := strings.TrimSpace(payload.Challenge)
|
||||
validate := strings.TrimSpace(payload.Validate)
|
||||
seccode := strings.TrimSpace(payload.Seccode)
|
||||
if challenge == "" || validate == "" || seccode == "" {
|
||||
return respond.MissingParam
|
||||
}
|
||||
|
||||
// 1. 先按极验 v3 协议校验 validate 是否与 challenge/privateKey 匹配。
|
||||
// 2. 若本地签名都对不上,直接判失败,避免继续请求第三方接口。
|
||||
// 3. 只有本地签名通过后,才继续调用 API2 复核 seccode。
|
||||
expectedValidate := md5Hex(s.privateKey + "geetest" + challenge)
|
||||
if !strings.EqualFold(validate, expectedValidate) {
|
||||
return respond.CaptchaVerifyFailed
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("captchaid", s.captchaID)
|
||||
form.Set("challenge", challenge)
|
||||
form.Set("seccode", seccode)
|
||||
form.Set("json_format", "1")
|
||||
form.Set("sdk", geeTestSDKName)
|
||||
form.Set("client_type", geeTestClientType)
|
||||
if ip := strings.TrimSpace(clientIP); ip != "" {
|
||||
form.Set("ip_address", ip)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, geeTestValidateURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return respond.CaptchaVerifyUnavailable
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return respond.CaptchaVerifyUnavailable
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
rawBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return respond.CaptchaVerifyUnavailable
|
||||
}
|
||||
|
||||
if !matchValidateSeccode(rawBody, seccode) {
|
||||
return respond.CaptchaVerifyFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GeeTestService) isConfigured() bool {
|
||||
return s != nil && s.captchaID != "" && s.privateKey != ""
|
||||
}
|
||||
|
||||
func extractRegisterChallenge(rawBody []byte) string {
|
||||
var payload geeTestRegisterUpstreamResponse
|
||||
if err := json.Unmarshal(rawBody, &payload); err == nil {
|
||||
return strings.TrimSpace(payload.Challenge)
|
||||
}
|
||||
return strings.TrimSpace(string(rawBody))
|
||||
}
|
||||
|
||||
func matchValidateSeccode(rawBody []byte, seccode string) bool {
|
||||
expected := md5Hex(strings.TrimSpace(seccode))
|
||||
|
||||
var payload geeTestValidateUpstreamResponse
|
||||
if err := json.Unmarshal(rawBody, &payload); err == nil {
|
||||
return strings.EqualFold(strings.TrimSpace(payload.Seccode), expected)
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(string(rawBody)), expected)
|
||||
}
|
||||
|
||||
func md5Hex(input string) string {
|
||||
sum := md5.Sum([]byte(input))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -14,25 +14,51 @@ import (
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
client ports.UserCommandClient
|
||||
client ports.UserCommandClient
|
||||
captcha *GeeTestService
|
||||
}
|
||||
|
||||
// NewUserHandler 只接收 user/auth 客户端,不再直接依赖本地 user service。
|
||||
func NewUserHandler(client ports.UserCommandClient) *UserHandler {
|
||||
return &UserHandler{client: client}
|
||||
// NewUserHandler 只接收 user/auth 客户端与验证码服务,不再直接依赖本地 user service。
|
||||
func NewUserHandler(client ports.UserCommandClient, captcha *GeeTestService) *UserHandler {
|
||||
return &UserHandler{
|
||||
client: client,
|
||||
captcha: captcha,
|
||||
}
|
||||
}
|
||||
|
||||
func (api *UserHandler) CaptchaRegister(c *gin.Context) {
|
||||
captchaCtx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
registerData, err := api.captcha.Register(captchaCtx, c.ClientIP())
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, registerData))
|
||||
}
|
||||
|
||||
func (api *UserHandler) UserRegister(c *gin.Context) {
|
||||
var req contracts.RegisterRequest
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 先用独立超时完成极验二次校验,避免第三方接口抖动侵入内部 RPC 超时预算。
|
||||
// 2. 只有验证码通过后才继续调 user/auth 注册服务,防止无效流量进入内部链路。
|
||||
// 3. 内部 RPC 仍保留原先 2 秒超时边界,不改变现有 user/auth 服务 SLA。
|
||||
captchaCtx, cancelCaptcha := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancelCaptcha()
|
||||
if err := api.captcha.Verify(captchaCtx, req.captchaPayload(), c.ClientIP()); err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
retUser, err := api.client.Register(ctx, req)
|
||||
retUser, err := api.client.Register(ctx, req.toContract())
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
@@ -41,16 +67,23 @@ func (api *UserHandler) UserRegister(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (api *UserHandler) UserLogin(c *gin.Context) {
|
||||
var req contracts.LoginRequest
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
captchaCtx, cancelCaptcha := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancelCaptcha()
|
||||
if err := api.captcha.Verify(captchaCtx, req.captchaPayload(), c.ClientIP()); err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokens, err := api.client.Login(ctx, req)
|
||||
tokens, err := api.client.Login(ctx, req.toContract())
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
|
||||
@@ -20,6 +20,7 @@ func RegisterRoutes(apiGroup *gin.RouterGroup, handler *UserHandler, authClient
|
||||
|
||||
userGroup := apiGroup.Group("/user")
|
||||
{
|
||||
userGroup.GET("/captcha/register", handler.CaptchaRegister)
|
||||
userGroup.POST("/register", handler.UserRegister)
|
||||
userGroup.POST("/login", handler.UserLogin)
|
||||
userGroup.POST("/refresh-token", handler.RefreshTokenHandler)
|
||||
|
||||
147
backend/gateway/middleware/cors.go
Normal file
147
backend/gateway/middleware/cors.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CORSOptions struct {
|
||||
AllowedOrigins []string
|
||||
AllowedMethods []string
|
||||
AllowedHeaders []string
|
||||
ExposedHeaders []string
|
||||
AllowCredentials bool
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
func CORSMiddleware(opts CORSOptions) gin.HandlerFunc {
|
||||
origins := normalizeHeaderValues(opts.AllowedOrigins)
|
||||
if len(origins) == 0 {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
methods := normalizeHeaderValuesWithDefaults(opts.AllowedMethods, []string{
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
http.MethodPatch,
|
||||
http.MethodDelete,
|
||||
http.MethodOptions,
|
||||
})
|
||||
headers := normalizeHeaderValuesWithDefaults(opts.AllowedHeaders, []string{
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-Requested-With",
|
||||
"Idempotency-Key",
|
||||
})
|
||||
exposedHeaders := normalizeHeaderValues(opts.ExposedHeaders)
|
||||
maxAge := opts.MaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = 12 * time.Hour
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
if origin == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
allowedOrigin := matchAllowedOrigin(origin, origins)
|
||||
if allowedOrigin == "" {
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
setVaryHeader(c.Writer.Header(), "Origin")
|
||||
c.Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||
if opts.AllowCredentials && allowedOrigin != "*" {
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
if len(exposedHeaders) > 0 {
|
||||
c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
|
||||
}
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
setVaryHeader(c.Writer.Header(), "Access-Control-Request-Method")
|
||||
setVaryHeader(c.Writer.Header(), "Access-Control-Request-Headers")
|
||||
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
|
||||
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
|
||||
c.Header("Access-Control-Max-Age", formatMaxAgeSeconds(maxAge))
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func matchAllowedOrigin(origin string, allowedOrigins []string) string {
|
||||
for _, allowedOrigin := range allowedOrigins {
|
||||
if allowedOrigin == "*" {
|
||||
return "*"
|
||||
}
|
||||
if strings.EqualFold(origin, allowedOrigin) {
|
||||
return origin
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeHeaderValues(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, trimmed)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeHeaderValuesWithDefaults(values []string, defaults []string) []string {
|
||||
normalized := normalizeHeaderValues(values)
|
||||
if len(normalized) > 0 {
|
||||
return normalized
|
||||
}
|
||||
return normalizeHeaderValues(defaults)
|
||||
}
|
||||
|
||||
func setVaryHeader(header http.Header, value string) {
|
||||
existing := header.Values("Vary")
|
||||
for _, entry := range existing {
|
||||
for _, part := range strings.Split(entry, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(part), value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
header.Add("Vary", value)
|
||||
}
|
||||
|
||||
func formatMaxAgeSeconds(maxAge time.Duration) string {
|
||||
seconds := int(maxAge / time.Second)
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
return strconv.Itoa(seconds)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
taskclassforumclient "github.com/LoveLosita/smartflow/backend/client/taskclassforum"
|
||||
@@ -68,6 +69,13 @@ func RegisterRouters(
|
||||
limiter *ratelimit.RateLimiter,
|
||||
) *gin.Engine {
|
||||
r := gin.Default()
|
||||
r.Use(gatewaymiddleware.CORSMiddleware(gatewaymiddleware.CORSOptions{
|
||||
AllowedOrigins: readConfigList("cors.allowedOrigins"),
|
||||
AllowedMethods: readConfigList("cors.allowedMethods"),
|
||||
AllowedHeaders: readConfigList("cors.allowedHeaders"),
|
||||
ExposedHeaders: readConfigList("cors.exposedHeaders"),
|
||||
AllowCredentials: viper.GetBool("cors.allowCredentials"),
|
||||
}))
|
||||
apiGroup := r.Group("/api/v1")
|
||||
{
|
||||
apiGroup.GET("/health", func(c *gin.Context) {
|
||||
@@ -77,7 +85,7 @@ func RegisterRouters(
|
||||
})
|
||||
})
|
||||
|
||||
userauthapi.RegisterRoutes(apiGroup, userauthapi.NewUserHandler(authClient), authClient, limiter)
|
||||
userauthapi.RegisterRoutes(apiGroup, userauthapi.NewUserHandler(authClient, userauthapi.NewGeeTestServiceFromConfig()), authClient, limiter)
|
||||
forumapi.RegisterRoutes(apiGroup, forumapi.NewHandler(forumClient), authClient, cache, limiter)
|
||||
tokenstoreapi.RegisterRoutes(apiGroup, tokenstoreapi.NewHandler(tokenStoreClient), authClient, cache, limiter)
|
||||
|
||||
@@ -173,3 +181,53 @@ func RegisterRouters(
|
||||
log.Println("Routes setup completed")
|
||||
return r
|
||||
}
|
||||
|
||||
func readConfigList(key string) []string {
|
||||
values := viper.GetStringSlice(key)
|
||||
if len(values) > 0 {
|
||||
return compactConfigList(expandConfigList(values))
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(viper.GetString(key))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
splitted := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == ';'
|
||||
})
|
||||
return compactConfigList(splitted)
|
||||
}
|
||||
|
||||
func expandConfigList(values []string) []string {
|
||||
expanded := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == ';'
|
||||
})
|
||||
if len(parts) == 0 {
|
||||
expanded = append(expanded, value)
|
||||
continue
|
||||
}
|
||||
expanded = append(expanded, parts...)
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func compactConfigList(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ var (
|
||||
TokenUsageExceedsLimit = rootrespond.TokenUsageExceedsLimit
|
||||
ConversationNotFound = rootrespond.ConversationNotFound
|
||||
MissingConversationID = rootrespond.MissingConversationID
|
||||
CaptchaVerifyFailed = rootrespond.CaptchaVerifyFailed
|
||||
CaptchaInitFailed = rootrespond.CaptchaInitFailed
|
||||
CaptchaVerifyUnavailable = rootrespond.CaptchaVerifyUnavailable
|
||||
)
|
||||
|
||||
// RespWithData 为 gateway HTTP 门面生成带 data 的统一响应体。
|
||||
|
||||
@@ -382,6 +382,14 @@ func buildConversationTimelineCacheItem(
|
||||
tokensConsumed int,
|
||||
createdAt *time.Time,
|
||||
) model.GetConversationTimelineItem {
|
||||
// 1. Redis 热缓存先于 MySQL 落库写入时,eventID 可能暂时为 0。
|
||||
// 2. 如果把 0 原样透传给前端,历史重建阶段所有 cache-only 事件都会共享同一个“空 id”。
|
||||
// 3. 前端常把 timeline id 当消息/块的挂载主键,撞 key 后会把不同轮次的 assistant 状态桶错误复用。
|
||||
// 4. 因此这里在未拿到真实主键时,先退回使用会话内唯一的 seq 作为临时 id;待后续从 DB 回源时再自然切换为真实 id。
|
||||
if eventID <= 0 && seq > 0 {
|
||||
eventID = seq
|
||||
}
|
||||
|
||||
item := model.GetConversationTimelineItem{
|
||||
ID: eventID,
|
||||
Seq: seq,
|
||||
|
||||
@@ -3,17 +3,29 @@ package bootstrap
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// LoadConfig 统一加载后端进程配置。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把 config.yaml 读入 viper,不解释具体业务配置语义。
|
||||
// 2. 同时兼容从仓库根目录和 backend 目录启动的两种路径。
|
||||
// 3. 失败时返回 error,由各进程入口决定是否退出。
|
||||
const configFileEnv = "SMARTFLOW_CONFIG_FILE"
|
||||
|
||||
func LoadConfig() error {
|
||||
viper.Reset()
|
||||
viper.SetEnvPrefix("SMARTFLOW")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if configFile := os.Getenv(configFileEnv); configFile != "" {
|
||||
viper.SetConfigFile(configFile)
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return fmt.Errorf("failed to read config file %q: %w", configFile, err)
|
||||
}
|
||||
log.Printf("Config loaded successfully from %s", configFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(".")
|
||||
|
||||
@@ -391,6 +391,11 @@ var ( //请求相关的响应
|
||||
Info: "no fields to update",
|
||||
}
|
||||
|
||||
CaptchaVerifyFailed = Response{ //人机验证未通过
|
||||
Status: "40064",
|
||||
Info: "人机验证未通过",
|
||||
}
|
||||
|
||||
TaskAlreadyDeleted = Response{ //任务已删除或不存在(幂等信息码)
|
||||
Status: "10003",
|
||||
Info: "task already deleted or not found",
|
||||
@@ -405,4 +410,14 @@ var ( //请求相关的响应
|
||||
Status: "50002",
|
||||
Info: "schedule refine output parse failed",
|
||||
}
|
||||
|
||||
CaptchaInitFailed = Response{ //人机验证初始化失败
|
||||
Status: "50003",
|
||||
Info: "人机验证初始化失败",
|
||||
}
|
||||
|
||||
CaptchaVerifyUnavailable = Response{ //人机验证服务暂不可用
|
||||
Status: "50004",
|
||||
Info: "人机验证服务暂不可用",
|
||||
}
|
||||
)
|
||||
|
||||
29
deploy/docker-load.sh
Normal file
29
deploy/docker-load.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
set -eu
|
||||
|
||||
BUNDLE_DIR="${1:-$(cd "$(dirname "$0")/.." && pwd)/.docker-bundles}"
|
||||
|
||||
if [ ! -d "$BUNDLE_DIR" ]; then
|
||||
echo "Bundle directory not found: $BUNDLE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
found_bundle=0
|
||||
|
||||
for bundle in "$BUNDLE_DIR"/*.tar; do
|
||||
if [ ! -e "$bundle" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
found_bundle=1
|
||||
echo "==> Load bundle $bundle"
|
||||
docker load -i "$bundle"
|
||||
done
|
||||
|
||||
if [ "$found_bundle" -eq 0 ]; then
|
||||
echo "No tar bundles found in: $BUNDLE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Done"
|
||||
90
deploy/docker-pack.ps1
Normal file
90
deploy/docker-pack.ps1
Normal file
@@ -0,0 +1,90 @@
|
||||
param(
|
||||
[string]$AppTag = "latest",
|
||||
[string]$BackendImage = "smartflow/backend-suite",
|
||||
[string]$FrontendImage = "smartflow/frontend",
|
||||
[string]$OutputDir = ".docker-bundles",
|
||||
[switch]$IncludeInfra
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Get-ImageRef {
|
||||
param(
|
||||
[string]$EnvName,
|
||||
[string]$DefaultValue
|
||||
)
|
||||
|
||||
$value = [Environment]::GetEnvironmentVariable($EnvName)
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
return $DefaultValue
|
||||
}
|
||||
|
||||
return $value.Trim()
|
||||
}
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$bundleDir = Join-Path $repoRoot $OutputDir
|
||||
$backendRef = "{0}:{1}" -f $BackendImage, $AppTag
|
||||
$frontendRef = "{0}:{1}" -f $FrontendImage, $AppTag
|
||||
$appBundlePath = Join-Path $bundleDir ("smartflow-app-{0}.tar" -f $AppTag)
|
||||
$infraBundlePath = Join-Path $bundleDir ("smartflow-infra-{0}.tar" -f $AppTag)
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $bundleDir | Out-Null
|
||||
|
||||
Write-Host "==> Build backend image $backendRef"
|
||||
docker build --platform linux/amd64 -f (Join-Path $repoRoot "backend\Dockerfile") -t $backendRef (Join-Path $repoRoot "backend")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Backend image build failed."
|
||||
}
|
||||
|
||||
Write-Host "==> Build frontend image $frontendRef"
|
||||
docker build --platform linux/amd64 -f (Join-Path $repoRoot "frontend\Dockerfile") -t $frontendRef (Join-Path $repoRoot "frontend")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Frontend image build failed."
|
||||
}
|
||||
|
||||
if (Test-Path $appBundlePath) {
|
||||
Remove-Item -LiteralPath $appBundlePath -Force
|
||||
}
|
||||
|
||||
Write-Host "==> Export app bundle to $appBundlePath"
|
||||
docker save -o $appBundlePath $backendRef $frontendRef
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "App bundle export failed."
|
||||
}
|
||||
|
||||
if (-not $IncludeInfra) {
|
||||
Write-Host "==> Done. App bundle exported."
|
||||
return
|
||||
}
|
||||
|
||||
$infraImages = @(
|
||||
(Get-ImageRef -EnvName "SMARTFLOW_MYSQL_IMAGE" -DefaultValue "mysql:8.0"),
|
||||
(Get-ImageRef -EnvName "SMARTFLOW_REDIS_IMAGE" -DefaultValue "redis:7"),
|
||||
(Get-ImageRef -EnvName "SMARTFLOW_KAFKA_IMAGE" -DefaultValue "apache/kafka:3.7.2"),
|
||||
(Get-ImageRef -EnvName "SMARTFLOW_ETCD_IMAGE" -DefaultValue "quay.io/coreos/etcd:v3.5.5"),
|
||||
(Get-ImageRef -EnvName "SMARTFLOW_MINIO_IMAGE" -DefaultValue "minio/minio:RELEASE.2023-03-20T20-16-18Z"),
|
||||
(Get-ImageRef -EnvName "SMARTFLOW_MILVUS_IMAGE" -DefaultValue "milvusdb/milvus:v2.4.4"),
|
||||
(Get-ImageRef -EnvName "SMARTFLOW_ATTU_IMAGE" -DefaultValue "zilliz/attu:v2.4.3")
|
||||
)
|
||||
|
||||
foreach ($imageRef in $infraImages) {
|
||||
Write-Host "==> Pull infra image $imageRef"
|
||||
docker pull $imageRef
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw ("Infra image pull failed: {0}" -f $imageRef)
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $infraBundlePath) {
|
||||
Remove-Item -LiteralPath $infraBundlePath -Force
|
||||
}
|
||||
|
||||
Write-Host "==> Export infra bundle to $infraBundlePath"
|
||||
docker save -o $infraBundlePath @infraImages
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Infra bundle export failed."
|
||||
}
|
||||
|
||||
Write-Host "==> Done. App bundle and infra bundle exported."
|
||||
301
docker-compose.full.yml
Normal file
301
docker-compose.full.yml
Normal file
@@ -0,0 +1,301 @@
|
||||
name: smartflow-full
|
||||
|
||||
x-backend-common: &backend-common
|
||||
image: ${SMARTFLOW_BACKEND_IMAGE:-smartflow/backend-suite:latest}
|
||||
restart: unless-stopped
|
||||
working_dir: /app/backend
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
SMARTFLOW_CONFIG_FILE: /app/backend/config.docker.yaml
|
||||
SMARTFLOW_NOTIFICATION_FRONTENDBASEURL: ${SMARTFLOW_NOTIFICATION_FRONTENDBASEURL:-}
|
||||
SMARTFLOW_CORS_ALLOWEDORIGINS: ${SMARTFLOW_CORS_ALLOWEDORIGINS:-}
|
||||
volumes:
|
||||
- ./backend/config.docker.yaml:/app/backend/config.docker.yaml:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
kafka:
|
||||
condition: service_healthy
|
||||
kafka-init:
|
||||
condition: service_completed_successfully
|
||||
etcd:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
milvus-standalone:
|
||||
condition: service_healthy
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: ${SMARTFLOW_MYSQL_IMAGE:-mysql:8.0}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root_password_123
|
||||
MYSQL_DATABASE: smartflow
|
||||
MYSQL_USER: smartflow_user
|
||||
MYSQL_PASSWORD: smartflow_password_456
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -proot_password_123"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: ${SMARTFLOW_REDIS_IMAGE:-redis:7}
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass redis_password_789
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "redis_password_789", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
kafka:
|
||||
image: ${SMARTFLOW_KAFKA_IMAGE:-apache/kafka:3.7.2}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
KAFKA_NODE_ID: 1
|
||||
KAFKA_PROCESS_ROLES: broker,controller
|
||||
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
|
||||
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
|
||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
|
||||
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
||||
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
||||
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
|
||||
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
||||
KAFKA_NUM_PARTITIONS: 3
|
||||
KAFKA_DEFAULT_REPLICATION_FACTOR: 1
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
||||
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
||||
KAFKA_LOG_DIRS: /var/lib/kafka/data
|
||||
volumes:
|
||||
- kafka_data:/var/lib/kafka/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list >/dev/null 2>&1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
|
||||
kafka-init:
|
||||
image: ${SMARTFLOW_KAFKA_IMAGE:-apache/kafka:3.7.2}
|
||||
restart: "no"
|
||||
depends_on:
|
||||
kafka:
|
||||
condition: service_healthy
|
||||
entrypoint:
|
||||
- /bin/bash
|
||||
- -lc
|
||||
- |
|
||||
set -e
|
||||
for topic in \
|
||||
smartflow.agent.outbox \
|
||||
smartflow.task.outbox \
|
||||
smartflow.memory.outbox \
|
||||
smartflow.active-scheduler.outbox \
|
||||
smartflow.notification.outbox \
|
||||
smartflow.taskclass-forum.outbox \
|
||||
smartflow.llm.outbox \
|
||||
smartflow.token-store.outbox
|
||||
do
|
||||
/opt/kafka/bin/kafka-topics.sh \
|
||||
--bootstrap-server kafka:9092 \
|
||||
--create \
|
||||
--if-not-exists \
|
||||
--topic "$$topic" \
|
||||
--partitions 3 \
|
||||
--replication-factor 1
|
||||
done
|
||||
|
||||
etcd:
|
||||
image: ${SMARTFLOW_ETCD_IMAGE:-quay.io/coreos/etcd:v3.5.5}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ETCD_AUTO_COMPACTION_MODE: revision
|
||||
ETCD_AUTO_COMPACTION_RETENTION: "1000"
|
||||
ETCD_QUOTA_BACKEND_BYTES: "4294967296"
|
||||
ETCD_SNAPSHOT_COUNT: "50000"
|
||||
command: >
|
||||
etcd
|
||||
-advertise-client-urls=http://etcd:2379
|
||||
-listen-client-urls=http://0.0.0.0:2379
|
||||
--data-dir=/etcd
|
||||
volumes:
|
||||
- etcd_data:/etcd
|
||||
healthcheck:
|
||||
test: ["CMD", "etcdctl", "endpoint", "health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
minio:
|
||||
image: ${SMARTFLOW_MINIO_IMAGE:-minio/minio:RELEASE.2023-03-20T20-16-18Z}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: minio server /minio_data --console-address ":9001"
|
||||
ports:
|
||||
- "${SMARTFLOW_MINIO_API_PORT:-9000}:9000"
|
||||
- "${SMARTFLOW_MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
volumes:
|
||||
- minio_data:/minio_data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
milvus-standalone:
|
||||
image: ${SMARTFLOW_MILVUS_IMAGE:-milvusdb/milvus:v2.4.4}
|
||||
restart: unless-stopped
|
||||
command: ["milvus", "run", "standalone"]
|
||||
environment:
|
||||
ETCD_USE_EMBED: "false"
|
||||
ETCD_ENDPOINTS: etcd:2379
|
||||
MINIO_ADDRESS: minio:9000
|
||||
ports:
|
||||
- "${SMARTFLOW_MILVUS_PORT:-19530}:19530"
|
||||
- "${SMARTFLOW_MILVUS_HEALTH_PORT:-9091}:9091"
|
||||
volumes:
|
||||
- milvus_data:/var/lib/milvus
|
||||
depends_on:
|
||||
etcd:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
attu:
|
||||
image: ${SMARTFLOW_ATTU_IMAGE:-zilliz/attu:v2.4.3}
|
||||
restart: unless-stopped
|
||||
profiles: ["ops"]
|
||||
ports:
|
||||
- "${SMARTFLOW_ATTU_PORT:-8000}:3000"
|
||||
environment:
|
||||
MILVUS_URL: milvus-standalone:19530
|
||||
depends_on:
|
||||
milvus-standalone:
|
||||
condition: service_healthy
|
||||
|
||||
userauth:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/userauth"]
|
||||
|
||||
notification:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/notification"]
|
||||
|
||||
active-scheduler:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/active-scheduler"]
|
||||
|
||||
schedule:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/schedule"]
|
||||
|
||||
task:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/task"]
|
||||
|
||||
task-class:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/task-class"]
|
||||
|
||||
course:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/course"]
|
||||
|
||||
memory:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/memory"]
|
||||
|
||||
agent:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/agent"]
|
||||
|
||||
taskclassforum:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/taskclassforum"]
|
||||
|
||||
tokenstore:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/tokenstore"]
|
||||
|
||||
llm:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/llm"]
|
||||
|
||||
api:
|
||||
<<: *backend-common
|
||||
command: ["/app/bin/api"]
|
||||
ports:
|
||||
- "${SMARTFLOW_API_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
kafka:
|
||||
condition: service_healthy
|
||||
kafka-init:
|
||||
condition: service_completed_successfully
|
||||
etcd:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
milvus-standalone:
|
||||
condition: service_healthy
|
||||
userauth:
|
||||
condition: service_started
|
||||
notification:
|
||||
condition: service_started
|
||||
active-scheduler:
|
||||
condition: service_started
|
||||
schedule:
|
||||
condition: service_started
|
||||
task:
|
||||
condition: service_started
|
||||
task-class:
|
||||
condition: service_started
|
||||
course:
|
||||
condition: service_started
|
||||
memory:
|
||||
condition: service_started
|
||||
agent:
|
||||
condition: service_started
|
||||
taskclassforum:
|
||||
condition: service_started
|
||||
tokenstore:
|
||||
condition: service_started
|
||||
llm:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
image: ${SMARTFLOW_FRONTEND_IMAGE:-smartflow/frontend:latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${SMARTFLOW_FRONTEND_PORT:-80}:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
kafka_data:
|
||||
etcd_data:
|
||||
minio_data:
|
||||
milvus_data:
|
||||
133
docs/容器化部署说明.md
Normal file
133
docs/容器化部署说明.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# SmartFlow 容器化部署说明
|
||||
|
||||
## 1. 方案定位
|
||||
|
||||
这套方案把“开发启动”和“服务器部署”拆开处理。
|
||||
|
||||
1. 本地开发继续使用 `backend/scripts` 下的 PowerShell 脚本,适合频繁改代码与频繁重启。
|
||||
|
||||
2. 服务器部署使用 [`docker-compose.full.yml`](/D:/SmartFlow-Agent/docker-compose.full.yml) 管理“依赖 + 后端 + 前端”全栈容器。
|
||||
|
||||
3. 离线或半离线环境通过 [`deploy/docker-pack.ps1`](/D:/SmartFlow-Agent/deploy/docker-pack.ps1) 在本地打包镜像,再用 [`deploy/docker-load.sh`](/D:/SmartFlow-Agent/deploy/docker-load.sh) 在 Linux 服务器导入。
|
||||
|
||||
## 2. 为什么国内弱网更适合这个方案
|
||||
|
||||
如果服务器不稳定访问国外镜像源,真正要调整的不是“要不要继续用 Docker Compose”,而是“镜像分发方式”。
|
||||
|
||||
1. Compose 继续负责启动、重启、网络、卷和服务依赖。
|
||||
|
||||
2. 镜像构建与拉取放到本地或一台网络更好的机器上完成。
|
||||
|
||||
3. 服务器只做 `docker load` 和 `docker compose up -d`,避免上线时卡在拉镜像。
|
||||
|
||||
结论就是:弱网环境会让“脚本直起进程”更难维护,但会让“Compose + 离线镜像包”更有优势。
|
||||
|
||||
## 3. 关键文件
|
||||
|
||||
1. 后端共享镜像定义:[`backend/Dockerfile`](/D:/SmartFlow-Agent/backend/Dockerfile)
|
||||
|
||||
2. 前端镜像定义:[`frontend/Dockerfile`](/D:/SmartFlow-Agent/frontend/Dockerfile)
|
||||
|
||||
3. 前端反向代理配置:[`frontend/nginx.conf`](/D:/SmartFlow-Agent/frontend/nginx.conf)
|
||||
|
||||
4. 容器专用后端配置模板:[`backend/config.docker.yaml`](/D:/SmartFlow-Agent/backend/config.docker.yaml)
|
||||
|
||||
5. 全栈 Compose:[`docker-compose.full.yml`](/D:/SmartFlow-Agent/docker-compose.full.yml)
|
||||
|
||||
6. Compose 环境变量示例:[.env.full.example](/D:/SmartFlow-Agent/.env.full.example)
|
||||
|
||||
## 4. 推荐流程
|
||||
|
||||
### 4.1 本地构建并导出镜像
|
||||
|
||||
在 Windows 开发机执行:
|
||||
|
||||
```powershell
|
||||
cd D:\SmartFlow-Agent
|
||||
.\deploy\docker-pack.ps1 -AppTag 20260506 -IncludeInfra
|
||||
```
|
||||
|
||||
执行后会得到:
|
||||
|
||||
1. `.docker-bundles/smartflow-app-20260506.tar`
|
||||
|
||||
2. `.docker-bundles/smartflow-infra-20260506.tar`
|
||||
|
||||
### 4.2 传到 Linux 服务器并导入
|
||||
|
||||
首次使用前建议先给脚本执行权限:
|
||||
|
||||
```bash
|
||||
chmod +x ./deploy/docker-load.sh
|
||||
```
|
||||
|
||||
然后执行:
|
||||
|
||||
```bash
|
||||
cd /data/smartflow
|
||||
./deploy/docker-load.sh .docker-bundles
|
||||
```
|
||||
|
||||
### 4.3 准备配置并启动
|
||||
|
||||
1. 复制 [.env.full.example](/D:/SmartFlow-Agent/.env.full.example) 为根目录 `.env`。
|
||||
|
||||
2. 按实际情况修改 [`backend/config.docker.yaml`](/D:/SmartFlow-Agent/backend/config.docker.yaml) 中的密钥、模型配置与业务参数。
|
||||
|
||||
3. 启动全栈服务:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.full.yml up -d
|
||||
```
|
||||
|
||||
如需 Attu:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.full.yml --profile ops up -d
|
||||
```
|
||||
|
||||
## 5. 镜像源困难时的处理方式
|
||||
|
||||
### 5.1 最稳妥:完全离线导入
|
||||
|
||||
适合服务器几乎拉不到外网镜像的情况。
|
||||
|
||||
1. 本地 `docker build` / `docker pull`
|
||||
|
||||
2. 本地 `docker save`
|
||||
|
||||
3. 服务器 `docker load`
|
||||
|
||||
4. 服务器 `docker compose up -d`
|
||||
|
||||
这是当前最推荐的落地方式。
|
||||
|
||||
### 5.2 次优方案:改成国内可访问的镜像地址
|
||||
|
||||
如果您有私有仓库或云厂商镜像加速源,可以在 `.env` 里修改这些变量:
|
||||
|
||||
1. `SMARTFLOW_MYSQL_IMAGE`
|
||||
|
||||
2. `SMARTFLOW_REDIS_IMAGE`
|
||||
|
||||
3. `SMARTFLOW_KAFKA_IMAGE`
|
||||
|
||||
4. `SMARTFLOW_ETCD_IMAGE`
|
||||
|
||||
5. `SMARTFLOW_MINIO_IMAGE`
|
||||
|
||||
6. `SMARTFLOW_MILVUS_IMAGE`
|
||||
|
||||
7. `SMARTFLOW_ATTU_IMAGE`
|
||||
|
||||
这样无需改 Compose 文件本身。
|
||||
|
||||
## 6. 选型结论
|
||||
|
||||
把“国内服务器拉镜像困难”考虑进去之后,结论会更明确。
|
||||
|
||||
1. 开发启动继续保留本地脚本方案。
|
||||
|
||||
2. 生产与测试部署优先选 `Docker Compose`,不要回退到 `main.go` 或手写启动脚本。
|
||||
|
||||
3. 镜像分发优先选“本地打包 + 服务器导入”,而不是让服务器现场构建或现场拉国外镜像。
|
||||
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
3
frontend/.env.example
Normal file
3
frontend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_APP_BASE=/
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
VITE_DEV_API_ORIGIN=http://127.0.0.1:8080
|
||||
26
frontend/Dockerfile
Normal file
26
frontend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG NODE_IMAGE=node:22-bookworm
|
||||
ARG NGINX_IMAGE=nginx:1.27-alpine
|
||||
|
||||
FROM ${NODE_IMAGE} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_APP_BASE=/
|
||||
ARG VITE_API_BASE_URL=/api/v1
|
||||
ENV VITE_APP_BASE=${VITE_APP_BASE}
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM ${NGINX_IMAGE} AS runtime
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
29
frontend/nginx.conf
Normal file
29
frontend/nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 1. 生产环境统一由前端容器反代后端 API,前端继续使用相对路径 /api/v1。
|
||||
# 2. 关闭代理缓冲,避免 Agent SSE 流式响应被 Nginx 缓存后前端长时间收不到数据。
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
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_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
add_header X-Accel-Buffering no;
|
||||
}
|
||||
|
||||
# 1. Vue Router 走 history 模式时,静态资源未命中需要回落到 index.html。
|
||||
# 2. 这里不负责接口代理,接口统一由上面的 /api location 处理。
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import http from '@/api/http'
|
||||
import { extractErrorMessage } from '@/utils/http'
|
||||
import type {
|
||||
ApiResponse,
|
||||
GeeTestRegisterData,
|
||||
LoginPayload,
|
||||
PlainResponse,
|
||||
RefreshTokenPayload,
|
||||
@@ -10,6 +11,21 @@ import type {
|
||||
TokenPair,
|
||||
} from '@/types/api'
|
||||
|
||||
export async function fetchGeeTestRegisterData() {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<GeeTestRegisterData>>('/user/captcha/register', {
|
||||
params: {
|
||||
t: Date.now(),
|
||||
},
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
})
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '人机验证初始化失败,请刷新页面后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(payload: LoginPayload) {
|
||||
try {
|
||||
const response = await http.post<ApiResponse<TokenPair>>('/user/login', payload, {
|
||||
|
||||
@@ -18,13 +18,24 @@ declare module 'axios' {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApiBaseURL() {
|
||||
const rawBaseURL = import.meta.env.VITE_API_BASE_URL?.trim() || '/api/v1'
|
||||
if (/^https?:\/\//i.test(rawBaseURL)) {
|
||||
return rawBaseURL.replace(/\/+$/, '')
|
||||
}
|
||||
const normalizedPath = `/${rawBaseURL.replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||
return normalizedPath || '/api/v1'
|
||||
}
|
||||
|
||||
const apiBaseURL = resolveApiBaseURL()
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
baseURL: apiBaseURL,
|
||||
timeout: 12000,
|
||||
})
|
||||
|
||||
const refreshHttp = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
baseURL: apiBaseURL,
|
||||
timeout: 12000,
|
||||
})
|
||||
|
||||
|
||||
@@ -2249,6 +2249,24 @@ function toggleHistoryPanel() {
|
||||
historyExpanded.value = !historyExpanded.value
|
||||
}
|
||||
|
||||
function buildTimelineDisplayMessageId(event: TimelineEvent, role: 'user' | 'assistant') {
|
||||
// 1. Redis 热缓存里的 timeline 事件在未落 MySQL 前,event.id 可能暂时为 0。
|
||||
// 2. 如果这里仍直接使用 t-0 作为消息主键,不同轮次的 user / assistant 会撞到同一个前端状态桶。
|
||||
// 3. 一旦发生撞 key,工具卡片、正文块、thinking 块都会被后来的事件复用,表现成“每轮下面都挂着同一整份 assistant 内容”。
|
||||
// 4. 因此优先使用真实 event.id;缺失时退回会话内单调递增的 seq,保证历史重建阶段的主键稳定且唯一。
|
||||
const numericID = Number(event.id || 0)
|
||||
if (Number.isFinite(numericID) && numericID > 0) {
|
||||
return `t-${numericID}`
|
||||
}
|
||||
|
||||
const numericSeq = Number(event.seq || 0)
|
||||
if (Number.isFinite(numericSeq) && numericSeq > 0) {
|
||||
return `t-${role}-${numericSeq}`
|
||||
}
|
||||
|
||||
return createMessageId(role)
|
||||
}
|
||||
|
||||
function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[]) {
|
||||
const result: AssistantMessage[] = []
|
||||
let currentAssistantMessage: AssistantMessage | null = null
|
||||
@@ -2278,7 +2296,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
||||
if (isUser) {
|
||||
currentAssistantMessage = null
|
||||
result.push({
|
||||
id: `t-${event.id}`,
|
||||
id: buildTimelineDisplayMessageId(event, 'user'),
|
||||
role: 'user',
|
||||
content: event.content || '',
|
||||
createdAt: event.created_at,
|
||||
@@ -2289,7 +2307,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
||||
// 助手事件
|
||||
if (!currentAssistantMessage) {
|
||||
currentAssistantMessage = {
|
||||
id: `t-${event.id}`,
|
||||
id: buildTimelineDisplayMessageId(event, 'assistant'),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: event.created_at,
|
||||
|
||||
@@ -10,7 +10,7 @@ import AssistantReasoningDebug from '@/views/debug/AssistantReasoningDebug.vue'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
|
||||
@@ -14,12 +14,25 @@ export interface TokenPair {
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
export interface GeeTestValidateResult {
|
||||
geetest_challenge: string
|
||||
geetest_validate: string
|
||||
geetest_seccode: string
|
||||
}
|
||||
|
||||
export interface GeeTestRegisterData {
|
||||
success: number
|
||||
gt: string
|
||||
challenge: string
|
||||
new_captcha: boolean
|
||||
}
|
||||
|
||||
export interface LoginPayload extends GeeTestValidateResult {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
export interface RegisterPayload extends GeeTestValidateResult {
|
||||
username: string
|
||||
phone_number: string
|
||||
password: string
|
||||
|
||||
97
frontend/src/utils/geetest.ts
Normal file
97
frontend/src/utils/geetest.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { GeeTestRegisterData, GeeTestValidateResult } from '@/types/api'
|
||||
|
||||
const GEETEST_SCRIPT_URL = 'https://static.geetest.com/static/tools/gt.js'
|
||||
|
||||
export interface GeeTestCaptchaInstance {
|
||||
appendTo(element: string | HTMLElement): void
|
||||
onReady(callback: () => void): void
|
||||
onSuccess(callback: () => void): void
|
||||
onError(callback: (error?: unknown) => void): void
|
||||
getValidate(): GeeTestValidateResult | undefined
|
||||
reset(): void
|
||||
destroy?: () => void
|
||||
}
|
||||
|
||||
interface GeeTestInitOptions extends GeeTestRegisterData {
|
||||
product?: 'float' | 'popup' | 'custom'
|
||||
width?: string
|
||||
lang?: string
|
||||
https?: boolean
|
||||
}
|
||||
|
||||
type InitGeeTest = (
|
||||
options: GeeTestInitOptions,
|
||||
callback: (captcha: GeeTestCaptchaInstance) => void,
|
||||
) => void
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
initGeetest?: InitGeeTest
|
||||
}
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<void> | null = null
|
||||
|
||||
// loadGeeTestScript 只负责把极验浏览器脚本加载进页面。
|
||||
// 职责边界:
|
||||
// 1. 负责去重加载,避免登录/注册切换时重复插入 script 标签;
|
||||
// 2. 不负责初始化验证码实例,实例创建由 createGeeTestCaptcha 处理;
|
||||
// 3. 若脚本拉取失败,直接抛错给上层 UI,由页面决定如何提示用户。
|
||||
export async function loadGeeTestScript() {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('当前环境不支持加载极验脚本')
|
||||
}
|
||||
if (window.initGeetest) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!scriptPromise) {
|
||||
scriptPromise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = GEETEST_SCRIPT_URL
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.dataset.geetest = 'true'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('极验脚本加载失败'))
|
||||
document.head.appendChild(script)
|
||||
}).catch((error) => {
|
||||
scriptPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
await scriptPromise
|
||||
if (!window.initGeetest) {
|
||||
throw new Error('极验脚本加载完成,但初始化方法不可用')
|
||||
}
|
||||
}
|
||||
|
||||
// createGeeTestCaptcha 负责把后端返回的 challenge 转成一个可挂载到 DOM 的验证码实例。
|
||||
// 职责边界:
|
||||
// 1. 只负责 `initGeetest` 这一层包装,不关心表单提交逻辑;
|
||||
// 2. 默认使用 `float + 100%`,保证验证码块可以稳定嵌入登录/注册按钮上方;
|
||||
// 3. 返回值只暴露极验实例本身,验证结果仍由页面在提交前主动读取。
|
||||
export async function createGeeTestCaptcha(registerData: GeeTestRegisterData) {
|
||||
await loadGeeTestScript()
|
||||
|
||||
return new Promise<GeeTestCaptchaInstance>((resolve, reject) => {
|
||||
const initGeetest = window.initGeetest
|
||||
if (!initGeetest) {
|
||||
reject(new Error('极验初始化方法不可用'))
|
||||
return
|
||||
}
|
||||
|
||||
initGeetest(
|
||||
{
|
||||
...registerData,
|
||||
product: 'float',
|
||||
width: '100%',
|
||||
https: true,
|
||||
},
|
||||
(captcha) => {
|
||||
resolve(captcha)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { fetchGeeTestRegisterData } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { GeeTestValidateResult } from '@/types/api'
|
||||
import { createGeeTestCaptcha, type GeeTestCaptchaInstance } from '@/utils/geetest'
|
||||
|
||||
type PanelName = 'login' | 'register'
|
||||
|
||||
interface CaptchaPanelState {
|
||||
instance: GeeTestCaptchaInstance | null
|
||||
loading: boolean
|
||||
ready: boolean
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
@@ -26,8 +36,180 @@ const registerForm = reactive({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const loginCaptchaContainer = ref<HTMLDivElement | null>(null)
|
||||
const registerCaptchaContainer = ref<HTMLDivElement | null>(null)
|
||||
const captchaStates = reactive<Record<PanelName, CaptchaPanelState>>({
|
||||
login: createCaptchaState(),
|
||||
register: createCaptchaState(),
|
||||
})
|
||||
|
||||
const redirectPath = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
let captchaMountToken = 0
|
||||
|
||||
function createCaptchaState(): CaptchaPanelState {
|
||||
return {
|
||||
instance: null,
|
||||
loading: false,
|
||||
ready: false,
|
||||
errorMessage: '',
|
||||
}
|
||||
}
|
||||
|
||||
function getCaptchaContainer(panel: PanelName) {
|
||||
const container = panel === 'login' ? loginCaptchaContainer.value : registerCaptchaContainer.value
|
||||
if (container) {
|
||||
return container
|
||||
}
|
||||
if (typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
return document.querySelector<HTMLDivElement>(`[data-captcha-panel="${panel}"]`)
|
||||
}
|
||||
|
||||
function teardownCaptcha(panel: PanelName) {
|
||||
const state = captchaStates[panel]
|
||||
state.instance?.destroy?.()
|
||||
state.instance = null
|
||||
state.loading = false
|
||||
state.ready = false
|
||||
state.errorMessage = ''
|
||||
}
|
||||
|
||||
function getCaptchaHint(panel: PanelName) {
|
||||
const state = captchaStates[panel]
|
||||
if (state.errorMessage) {
|
||||
return state.errorMessage
|
||||
}
|
||||
if (state.loading) {
|
||||
return '人机验证加载中,请稍候...'
|
||||
}
|
||||
return '请先完成上方的人机验证,再继续提交。'
|
||||
}
|
||||
|
||||
async function waitForCaptchaContainer(panel: PanelName, mountToken: number) {
|
||||
// 1. 登录/注册面板切换使用了 `Transition mode="out-in"`。
|
||||
// 2. 这意味着新面板的 DOM 不会在当前 tick 立即出现,只等一个 nextTick 还不够。
|
||||
// 3. 这里用短轮询等待容器真正挂到页面上,超时后再显式报错,避免注册面板静默空白。
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
if (mountToken !== captchaMountToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
const container = getCaptchaContainer(panel)
|
||||
if (container) {
|
||||
return container
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 80))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function initCaptcha(panel: PanelName, mountToken: number) {
|
||||
const container = await waitForCaptchaContainer(panel, mountToken)
|
||||
if (!container) {
|
||||
if (mountToken === captchaMountToken) {
|
||||
captchaStates[panel].errorMessage = '人机验证容器加载超时,请切换面板后重试'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const state = captchaStates[panel]
|
||||
if (state.instance || state.loading) {
|
||||
return
|
||||
}
|
||||
state.loading = true
|
||||
state.ready = false
|
||||
state.errorMessage = ''
|
||||
|
||||
try {
|
||||
// 1. 先向后端申请一次最新 challenge,保证当前面板拿到的是可立即提交的新验证码。
|
||||
// 2. 再初始化极验实例并挂到按钮上方的容器里,满足页面布局要求。
|
||||
// 3. 若用户切换了登录/注册面板,则放弃当前异步结果,避免旧实例串到新容器里。
|
||||
const registerData = await fetchGeeTestRegisterData()
|
||||
const captcha = await createGeeTestCaptcha(registerData)
|
||||
if (mountToken !== captchaMountToken || activePanel.value !== panel) {
|
||||
captcha.destroy?.()
|
||||
return
|
||||
}
|
||||
|
||||
state.instance = captcha
|
||||
captcha.onReady(() => {
|
||||
state.loading = false
|
||||
state.ready = true
|
||||
state.errorMessage = ''
|
||||
})
|
||||
captcha.onSuccess(() => {
|
||||
state.errorMessage = ''
|
||||
})
|
||||
captcha.onError(() => {
|
||||
state.loading = false
|
||||
state.ready = false
|
||||
state.errorMessage = '人机验证加载失败,请刷新页面后重试'
|
||||
})
|
||||
captcha.appendTo(container)
|
||||
} catch (error) {
|
||||
state.instance = null
|
||||
state.loading = false
|
||||
state.ready = false
|
||||
state.errorMessage = error instanceof Error ? error.message : '人机验证初始化失败,请稍后重试'
|
||||
ElMessage.error(state.errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
function getCaptchaResult(panel: PanelName): GeeTestValidateResult | null {
|
||||
const state = captchaStates[panel]
|
||||
if (state.errorMessage) {
|
||||
ElMessage.warning(state.errorMessage)
|
||||
return null
|
||||
}
|
||||
if (!state.ready || !state.instance) {
|
||||
ElMessage.warning('人机验证正在初始化,请稍后再试')
|
||||
return null
|
||||
}
|
||||
|
||||
const validateResult = state.instance.getValidate()
|
||||
if (!validateResult) {
|
||||
ElMessage.warning('请先完成按钮上方的人机验证')
|
||||
return null
|
||||
}
|
||||
return validateResult
|
||||
}
|
||||
|
||||
function resetCaptcha(panel: PanelName) {
|
||||
captchaStates[panel].errorMessage = ''
|
||||
captchaStates[panel].instance?.reset()
|
||||
}
|
||||
|
||||
async function handlePanelAfterEnter() {
|
||||
await initCaptcha(activePanel.value, captchaMountToken)
|
||||
}
|
||||
|
||||
watch(
|
||||
activePanel,
|
||||
async (panel, previousPanel) => {
|
||||
captchaMountToken += 1
|
||||
const mountToken = captchaMountToken
|
||||
if (previousPanel) {
|
||||
teardownCaptcha(previousPanel)
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
if (mountToken !== captchaMountToken) {
|
||||
return
|
||||
}
|
||||
await initCaptcha(panel, mountToken)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
captchaMountToken += 1
|
||||
teardownCaptcha('login')
|
||||
teardownCaptcha('register')
|
||||
})
|
||||
|
||||
async function submitLogin() {
|
||||
if (!loginForm.username.trim() || !loginForm.password.trim()) {
|
||||
@@ -35,15 +217,22 @@ async function submitLogin() {
|
||||
return
|
||||
}
|
||||
|
||||
const captchaResult = getCaptchaResult('login')
|
||||
if (!captchaResult) {
|
||||
return
|
||||
}
|
||||
|
||||
loginLoading.value = true
|
||||
try {
|
||||
await authStore.login({
|
||||
username: loginForm.username.trim(),
|
||||
password: loginForm.password,
|
||||
...captchaResult,
|
||||
})
|
||||
ElMessage.success('登录成功,欢迎回来')
|
||||
await router.push(redirectPath)
|
||||
} catch (error) {
|
||||
resetCaptcha('login')
|
||||
ElMessage.error(error instanceof Error ? error.message : '登录失败')
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
@@ -71,12 +260,18 @@ async function submitRegister() {
|
||||
return
|
||||
}
|
||||
|
||||
const captchaResult = getCaptchaResult('register')
|
||||
if (!captchaResult) {
|
||||
return
|
||||
}
|
||||
|
||||
registerLoading.value = true
|
||||
try {
|
||||
await authStore.register({
|
||||
username: registerForm.username.trim(),
|
||||
phone_number: registerForm.phone_number.trim(),
|
||||
password: registerForm.password,
|
||||
...captchaResult,
|
||||
})
|
||||
loginForm.username = registerForm.username.trim()
|
||||
loginForm.password = ''
|
||||
@@ -85,6 +280,7 @@ async function submitRegister() {
|
||||
activePanel.value = 'login'
|
||||
ElMessage.success('注册成功,请使用新账号登录')
|
||||
} catch (error) {
|
||||
resetCaptcha('register')
|
||||
ElMessage.error(error instanceof Error ? error.message : '注册失败')
|
||||
} finally {
|
||||
registerLoading.value = false
|
||||
@@ -144,7 +340,7 @@ async function submitRegister() {
|
||||
</div>
|
||||
|
||||
<div class="auth-form-container">
|
||||
<Transition name="auth-fade" mode="out-in">
|
||||
<Transition name="auth-fade" mode="out-in" @after-enter="handlePanelAfterEnter">
|
||||
<div v-if="activePanel === 'login'" key="login">
|
||||
<el-form label-position="top" class="auth-form" @submit.prevent="submitLogin">
|
||||
<el-form-item label="用户名">
|
||||
@@ -166,6 +362,15 @@ async function submitRegister() {
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="人机验证" class="auth-captcha-item">
|
||||
<div class="auth-captcha">
|
||||
<div ref="loginCaptchaContainer" class="auth-captcha__box" data-captcha-panel="login" />
|
||||
<p :class="['auth-captcha__hint', { 'is-error': Boolean(captchaStates.login.errorMessage) }]">
|
||||
{{ getCaptchaHint('login') }}
|
||||
</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@@ -218,6 +423,15 @@ async function submitRegister() {
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="人机验证" class="auth-captcha-item">
|
||||
<div class="auth-captcha">
|
||||
<div ref="registerCaptchaContainer" class="auth-captcha__box" data-captcha-panel="register" />
|
||||
<p :class="['auth-captcha__hint', { 'is-error': Boolean(captchaStates.register.errorMessage) }]">
|
||||
{{ getCaptchaHint('register') }}
|
||||
</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@@ -438,6 +652,26 @@ async function submitRegister() {
|
||||
box-shadow: 0 0 0 2px #3b82f6 inset !important;
|
||||
}
|
||||
|
||||
.auth-captcha {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-captcha__box {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.auth-captcha__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.auth-captcha__hint.is-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
|
||||
@@ -410,7 +410,7 @@ watch([() => tasks.value.length, () => todayEvents.value.length, taskLoading, sc
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.dashboard-main { min-width: 0; min-height: 0; overflow: hidden; height: 100%; }
|
||||
.dashboard-main { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; height: 100%; }
|
||||
|
||||
.dashboard-main__scaled {
|
||||
--dashboard-main-scale: 1;
|
||||
@@ -447,7 +447,7 @@ watch([() => tasks.value.length, () => todayEvents.value.length, taskLoading, sc
|
||||
.dashboard-topbar__profile strong { font-size: 13px; }
|
||||
.dashboard-topbar__profile span { width: 38px; height: 38px; border-radius: 999px; background: #eef3fb; color: #314156; display: inline-flex; align-items: center; justify-content: center; font-weight: 800; }
|
||||
|
||||
.dashboard-content { width: 100%; display: grid; gap: 14px; align-content: start; }
|
||||
.dashboard-content { width: 100%; display: grid; gap: 14px; align-content: start; padding-bottom: 60px; }
|
||||
.dashboard-actions { display: flex; justify-content: flex-end; }
|
||||
.dashboard-actions__primary { height: 42px; padding: 0 20px; border: none; border-radius: 15px; background: #3b82f6; color: #fff; font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); }
|
||||
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
|
||||
// 1. 开发环境把 /api 代理到本地 Go 服务,避免前端先处理跨域。
|
||||
// 2. 这里只负责联调体验,不负责生产环境网关配置。
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
function normalizeBasePath(value: string | undefined) {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed || trimmed === '/') {
|
||||
return '/'
|
||||
}
|
||||
return `/${trimmed.replace(/^\/+|\/+$/g, '')}/`
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const appBase = normalizeBasePath(env.VITE_APP_BASE)
|
||||
const devApiOrigin = env.VITE_DEV_API_ORIGIN?.trim() || 'http://127.0.0.1:8080'
|
||||
|
||||
return {
|
||||
base: appBase,
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
// SSE 流必须禁用缓冲,否则 Vite proxy 会攒满 buffer 再转发,
|
||||
// 导致前端长时间收不到数据被判定为连接中断。
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
proxyRes.headers['x-accel-buffering'] = 'no'
|
||||
proxyRes.headers['cache-control'] = 'no-cache'
|
||||
})
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: devApiOrigin,
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
proxyRes.headers['x-accel-buffering'] = 'no'
|
||||
proxyRes.headers['cache-control'] = 'no-cache'
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user