refactor: 后端仓库层收口(A1:课程/产品/素材)+ 收进工作区既有对象化重构
本提交含两部分。第一部分是本轮工作;第二部分是此前一直留在工作区、
从未提交的对象化重构,与第一部分在文件上互相咬合(internal/repository
整个包都是未跟踪状态,且 api 层已有文件引用它),无法拆成两个可编译的提交。
一、仓库层收口 A1 批(本轮工作)
把 api 层手写的 store.DB 查询收进具名仓库方法,只给真正获益的对象做方法,
不机械包裹全量。本批迁移 22 处裸查询(courses.go 9 / media.go 12 / products.go 1),
新增方法:
- MediaFileRepo.ListByBind / ListForAudit / MarkExtracted
- KnowledgeChunkRepo.CountByMediaFile
- ProductRepo.GetVisibleByID
两条业务口径改由仓库单点持有,避免各处手写漂移:
「只有 approved 素材出现在课程详情」与「已停用产品不在课程详情露出」。
修掉两个真实缺陷:
- ProductRepo.GetByID 缺 Where 条件。此前 GET /api/products/{id} 对任意 id 都返回
第一条产品、对不存在的 id 返回 200,且 PUT /api/products/{id} 会覆盖第一条产品
—— 数据损坏级。全仓扫描确认这是唯一一处同型写法。
- ProductRepo.Delete 写 status="deleted",而 DELETE 处理器文档与回包都声称
"inactive",接口在说谎;管理员用 status=all 拉列表会看到前端不认识的状态。
已对齐为 inactive(与 CourseRepo.Delete 一致)。
删除 8 个零调用且列名不存在的死方法(一调即 SQL 报错):
- media_file 上的 file_path / file_type / approval_status 三列并不存在,
GetByPath / ListByType / UpdateStatus 全废
- knowledge_chunk 上的 space_id 列不存在(模型早已改为 knowledge_space_key),
List / Total / ListBySpaceIDs / DeleteBySpace / SearchByVector 全废
取舍边界:能对当前 schema 跑通的死方法保留,跑不通的删或修。
CourseRepo.List 补齐 status=all 档(此前传给它会当作 status='all' 过滤出空列表)。
该方法此前零调用,现与产品列表语义对齐。
验证:go build ./... 与 go test ./... 全绿;另用真实 HTTP 请求验证 34 项
(课程 17 / 产品 3 / 素材 14),跑在数据库副本与独立 KB_DATA_DIR 上,
含 multipart 真上传 → 审批 → pdftotext 提取 → 分片入库的完整链路。
二、此前未提交的对象化重构(非本轮工作)
- 新增 internal/repository 仓库层、connectors、skills、specialists、xapps、jsonutil,
model/task_record|task_run|task_artifact、api/task_runtime|action_definition|chat_message
- 删除 api/app_definition、connectors、my_app_center、notification、office_skill、
export_docx|pptx|xlsx、official_account_* 等,随 XApp/Skill/Specialist/Connector
可插拔打包方向(AR10/AR11)调整
- 资产目录归位:backend-go/knowledge_source → assets/knowledge/source、
training_materials → assets/training/materials;README 内相对路径同步加深两级;
deploy env 补 ASSET_ROOT_DIR 并改 KNOWLEDGE_SOURCE_DIR / TRAINING_MATERIALS_DIR
- 前端新增 skills/ specialists/ connectors/ xapps/ 目录与对应页面
验证:前端 npm run build 通过(7.26s)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+50
-5
@@ -1,7 +1,7 @@
|
||||
# eai_agentplatform 博昇 AI 数字员工平台(EAI Agent Platform)— 编码与调试最高准则
|
||||
|
||||
> **版本:V1.1**
|
||||
> **日期:2026-09-17**
|
||||
> **版本:V1.2**
|
||||
> **日期:2026-09-18**
|
||||
> **状态:必须强制执行 (Highest Priority)**
|
||||
> **适用范围:eai_agentplatform(EAI 数字员工平台)后端(Go)、前端(Vue3)、数据库(SQLite)、AI 检索/对话、考试引擎、素材上传与审批**
|
||||
> **AI 助手启动任何任务前必须先读取并确认本文件。**
|
||||
@@ -16,6 +16,11 @@
|
||||
> 通用部分新增 G09-G18(承接 pj0034 同名文件的通用规则,按本项目 Go / Vue3 / SQLite / Ubuntu 技术栈改写,
|
||||
> 不适用的部分——如 Python 虚拟环境、Playwright E2E、OSS 多租户——明确不抄);
|
||||
> G04 补充第 6-10 条(来自两个项目共同踩过的坑);本项目新增 P06 常见技术陷阱清单。
|
||||
>
|
||||
> **V1.2 补充说明**:把命名前缀从「一条要求」扩成「一套规则」,全部落在 **G03**(不新开 G19,避免命名规则被拆到两处)。
|
||||
> 仍只做加法:G03 原第 1-4 条正文一字未改,只在其后新增第 5-10 条 + 关联节;标题由「变量命名锚定」放宽为「命名锚定」
|
||||
> (前缀要管表名、API 路径、文件名),索引行同步更新。
|
||||
> 展开与可执行化版本在 `docs/02_Architecture/AR09_Object_Naming_Standard.md` §5.7 + §6.2 守卫 G–J + §7.6。
|
||||
|
||||
---
|
||||
|
||||
@@ -28,7 +33,7 @@
|
||||
|
||||
- G01:深度调试日志 — 全链路埋点 + 特殊日志文件
|
||||
- G02:Fail Fast 与零静默兜底
|
||||
- G03:变量命名锚定 — 防命名漂移
|
||||
- G03:命名锚定 — 防命名漂移(含**前缀规范**:必要性判据 / 三类前缀 / 硬约束 / 退出条件 / 改名禁令)
|
||||
- G04:测试与验收 — 完成判定必须靠事实
|
||||
- G05:安全迁移与重构流程
|
||||
- G06:AI 助手行为规范
|
||||
@@ -90,12 +95,52 @@
|
||||
|
||||
---
|
||||
|
||||
## G03 原则:变量命名锚定 — 防命名漂移 (Identity Anchoring)
|
||||
## G03 原则:命名锚定 — 防命名漂移 (Identity Anchoring)
|
||||
|
||||
1. **变量名前缀强制化**:所有业务相关变量必须带明确前缀(如 `media_file_id`, `exam_session_key`, `product_code`)。禁止使用 `id`, `data`, `res` 等模糊命名。
|
||||
> V1.2 起本条从「变量命名」放宽为「命名」:前缀规范要管到表名、API 路径、文件名,不只是变量。
|
||||
|
||||
1. **变量名前缀强制化**:所有业务相关变量必须带明确前缀(如 `media_file_id`, `exam_session_key`, `product_code`)。禁止使用 `id`, `data`, `res` 等模糊命名。(**哪些命名空间需要前缀,见第 5 条**)
|
||||
2. **变量名全链路同步**:同一业务参数在 API、Service、Model 层必须保持变量名完全一致。
|
||||
3. **最小长度约束**:变量名原则上不短于 5 个字符(循环索引除外)。
|
||||
4. **AI 引用已定义标识符必须按字符复制**:AI 在生成或修改代码时,引用任何**已在项目中定义过**的标识符,必须先 Read/Grep 找到定义处,**按字符原样复制**,禁止自行改写大小写或分隔符。例如 `user_id` 不应被写成 `userId` 或 `uid`。
|
||||
5. **前缀的必要性由「命名空间的形状」决定,不由对象的重要性决定**:
|
||||
- **必须加**:SQLite 表名、表内列名、URL query、JSON key、目录内文件名、shell 变量 —— 这些命名空间**平铺且无类型**,名字是唯一的消歧手段。
|
||||
- **不必加**:Go 包内标识符、结构体字段 —— 有作用域,编译器/运行时替你消歧,前缀只是噪音。
|
||||
- **判据一句话**:*去掉它,同一个命名空间里会不会出现两个可能同名的东西?* 会 → 加;不会 → 别加。
|
||||
- **本项目正例(两种写法都对,别去"统一")**:`deploy/eai_agentplatform.env` 用裸名 `PORT`(一个 systemd unit 独占进程环境);`start_dev_10231_10232.sh` 用 `BACKEND_PORT` / `FRONTEND_PORT`(同一 shell 跑两个服务)。
|
||||
6. **三类前缀,各有各的生命周期**:
|
||||
- **对象前缀**(`skill_definition` / `worker_task`):标记归属,**永久**。
|
||||
- **来源前缀**(`staticSkillCatalog` / `normalizeCustomApp` / `legacy_*`):标记来路,**必须写退出条件**(见第 9 条)。
|
||||
- **作用域前缀**(query 的 `app_`、API 的 `my_`):标记入口与归属,**禁止进入模型、表、字段名**。
|
||||
- 对象前缀的白名单 = 正式对象术语表 + 已登记的子系统前缀。**白名单外的前缀不许发明**(同 G10:能用的集合必须封闭,否则每个人都会造自己的)。
|
||||
7. **两条已收敛的规律,守住不回退**:
|
||||
```text
|
||||
数据库表名 API 路径
|
||||
一级对象 specialist /api/specialists
|
||||
归属或复合 worker_task /api/worker/*
|
||||
```
|
||||
DB 层与 API 路径**各自独立**收敛到同一条分法 —— 这是自然规律,不是硬塞的。**写进规范是为了守住,不是为了改造。**
|
||||
8. **前缀硬约束**:
|
||||
- 一个标识符最多带**一个**类型前缀:`worker_task` ✅ / `app_specialist_skill_key` ❌
|
||||
- 次序固定「前缀 + 核心词 + 后缀」:`skill_definition` ✅ / `definition_skill` ❌
|
||||
- 前缀**写全,禁止缩写**:`specialist_` ✅ / `sp_`、`sk_`、`wr_` ❌
|
||||
- **禁止拼音前缀**:中文是对外展示层的事,不进标识符
|
||||
- **过渡前缀禁止嵌套**:`legacy_` 之上不许再叠一层(理由见第 9 条)
|
||||
- **来源前缀不得跨模块引用**:调用方不该知道数据是从哪来的。本项目现状是反例 —— `staticSkillCatalog` 的兜底写法 `skillCatalog.getByKey(k) || staticSkillCatalog.find(...)` 被抄到了 5 个调用点;正解是在 `skillCatalog` 里加 `resolve(key)` 把兜底收进模块
|
||||
9. **来源前缀必须带退出条件**(`static*` / `legacy*` / `tmp*` / `old*` / `deprecated*` 描述的是过程状态,而过程会结束):
|
||||
- **反例(本项目真实)**:`skill_definition` 一个字段先后有 **5 代列名** —— `entry_route → route → legacy_entry_route / legacy_route → legacy_object_entry_route`。过渡前缀叠到第二层,**就是上一次迁移没有退出条件的证据**。
|
||||
- **正例**:该批 legacy 列的删除与迁移逻辑写在**同一笔提交**里,而不是"先留着以后再说"。照这个做。
|
||||
- **要求**:写下来源前缀时,同处注释或相邻 TODO 必须写清「什么时候可以去掉」。
|
||||
10. **前缀改名 = 协议改名,必须按字符串精确锚定**:
|
||||
- 前缀几乎总活在**字符串**里(表名、列名、JSON tag、URL 参数、env 变量名),**编译器一个都管不着** —— 所以要按字符串 grep,不是按符号 grep。
|
||||
- **禁止子串替换、禁止正则通配。** 本项目现成的雷:`role_kind`(正确新名 `object_kind`)与 `role_card_json`(正确新名 `interaction_card_json`)**都以 `role_` 开头但去向完全不同**,一句 `sed 's/role_/object_/g'` 会把第二个误伤成 `object_card_json`。
|
||||
- 宁可一个标识符一个标识符地改,也不要图快。
|
||||
|
||||
### 关联
|
||||
|
||||
- 关联 G04:命名是否真的统一,靠 grep / build 验证,不靠感觉
|
||||
- 关联 G10:白名单机制与"配置化优先"同源 —— 集合封闭才能防漂移
|
||||
- 完整展开见 `docs/02_Architecture/AR09_Object_Naming_Standard.md`:判据 / 对象术语表 / 分层规范 / 机器守卫 G–J / 修复流程 / 现状问题登记 / 反例库
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,22 +5,32 @@
|
||||
> 关联文档:`SY18_Specialist_Minimal_Definition_Model.md`、`SY20_Role_Card_And_Lightweight_Ontology_Architecture.md`、`SY21_Unified_Role_Skill_Action_Architecture.md`、`SY22_Role_Skill_App_Unified_Task_Architecture.md`
|
||||
> 外部参照:AionUi / AionCore 内置助手与技能(已下载至 `codebase/AionCore-assets/`)
|
||||
|
||||
> **2026-09-18 状态补注**:
|
||||
> 本文是方案稿,不是现状说明。方案主体思路已部分落地,但文中早期文件路径已有更名:
|
||||
> - `POST /api/assistant/chat` → `POST /api/chat/message`
|
||||
> - `frontend/src/api/assistant.js` → `frontend/src/api/chatMessage.js`
|
||||
> - `frontend/src/store/workerRuntime.js` → `frontend/src/store/taskRuntime.js`
|
||||
> - `internal/api/smart_assistant.go` → `internal/api/chat_message.go`
|
||||
> - `model.WorkerTask` / `internal/api/worker_task.go` → `model.TaskRecord` / `internal/api/my_task.go` 与 `internal/api/task_runtime.go`
|
||||
>
|
||||
> 下面未逐段改写的旧引用,应按这组映射理解;若与当前代码现实冲突,以现代码与 `AR09_Object_Naming_Standard.md` 为准。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话诊断
|
||||
|
||||
**我们现在的 10 个专员,在运行时是同一个助手换了 10 个名字。**
|
||||
|
||||
工作台主对话走 `POST /api/assistant/chat`(前端 `api/assistant.js` → `chatWithAssistant`),而这个接口:
|
||||
工作台主对话现走 `POST /api/chat/message`(前端 `api/chatMessage.js` → `sendChatMessage`)。本文最初写作时对应的是旧链路,下面这个诊断应按当前文件映射阅读:
|
||||
|
||||
| 环节 | 现状 | 位置 |
|
||||
|------|------|------|
|
||||
| 前端发送的字段 | 只有 `message` / `mode` / `ai_route_id` | `views/workbench/SmartAssistantPage.vue:471` |
|
||||
| 后端请求体 | **已有** `task_id` / `context` 字段,但前端一个都没发 | `internal/api/smart_assistant.go:17-25` |
|
||||
| 后端 System Prompt | **两条写死的字符串**,与专员无关 | `internal/api/smart_assistant.go:136-140` |
|
||||
| 后端是否查过 specialist 表 | **从未** | `callAssistantAI` 全文 26 行 |
|
||||
| 「专家模式」任务拆解 | **三条写死的假步骤**(准备/执行/完成阶段) | `internal/api/smart_assistant.go:161-174` |
|
||||
| 知识检索 | 这条链路完全不检索知识库 | 对比 `worker_task.go:707` |
|
||||
| 后端请求体 | 当前链路已显式带上 `task_id` / `specialist_key` / `context` | `internal/api/chat_message.go` |
|
||||
| 后端 System Prompt | 当前已改为“基础角色 + 专员岗位说明书”拼装 | `internal/api/chat_message.go` |
|
||||
| 后端是否查过 specialist 表 | 当前会按 `task_id` / `specialist_key` 解析专员 | `resolveSpecialist` |
|
||||
| 「专家模式」任务拆解 | 仍属可继续深化区,不再是旧版写死入口 | `internal/api/chat_message.go` |
|
||||
| 知识检索 | 是否接入取决于当前对话模式与后续编排实现,不应再按旧 `worker_task.go` 路径理解 | 对照当前任务运行链路 |
|
||||
|
||||
也就是说:**不管用户选了「合同审查专员」还是「物流履约专员」,后端收到的请求完全一样,产出的 System Prompt 完全一样。** 专员是在前端画出来的差异,不是后端跑出来的差异。
|
||||
|
||||
@@ -133,8 +143,8 @@ AllowedSkills string `gorm:"type:text" json:"allowed_skills"`
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `frontend/src/api/assistant.js` | `chatWithAssistant(data)` 已透传整个 data,无需改 |
|
||||
| `frontend/src/store/workerRuntime.js` | 已有 `currentSpecialistKey` computed(185-192 行),直接用 |
|
||||
| `frontend/src/api/chatMessage.js` | `sendChatMessage(data)` 负责透传工作台对话请求 |
|
||||
| `frontend/src/store/taskRuntime.js` | 当前运行时 store,负责维护专员 / 技能 / 任务上下文 |
|
||||
| `frontend/src/views/workbench/SmartAssistantPage.vue:471` | 请求体补上 `task_id` 与 `specialist_key` |
|
||||
|
||||
```js
|
||||
@@ -151,13 +161,13 @@ const res = await chatWithAssistant({
|
||||
|
||||
**后端(1 处)**
|
||||
|
||||
`internal/api/smart_assistant.go` 的 `callAssistantAI` 增加专员解析:
|
||||
对应到当前代码,应在 `internal/api/chat_message.go` 的专员解析链路上做这件事(本文原稿写作时文件名为 `smart_assistant.go`):
|
||||
|
||||
```go
|
||||
// 解析当前专员:优先按 task_id 反查(服务端权威),其次用请求里的 specialist_key
|
||||
func resolveSpecialist(userID uint, req SmartAssistantRequest) *model.Specialist {
|
||||
if req.TaskID > 0 {
|
||||
var task model.WorkerTask
|
||||
var task model.TaskRecord
|
||||
if err := store.DB.Where("id = ? AND user_id = ?", req.TaskID, userID).
|
||||
First(&task).Error; err == nil && task.SpecialistKey != "" {
|
||||
var s model.Specialist
|
||||
@@ -176,7 +186,7 @@ func resolveSpecialist(userID uint, req SmartAssistantRequest) *model.Specialist
|
||||
}
|
||||
```
|
||||
|
||||
> 现成的反查先例:`internal/api/worker_task.go:364` 就是同一套 `store.DB.Where("key = ?", task.SpecialistKey).First(&specialist)`。
|
||||
> 当前应按任务运行链路里的 `TaskRecord` / `specialist_key` 反查实现理解;这里的 `worker_task.go` 引用属于旧文件名。
|
||||
|
||||
`SmartAssistantRequest` 需补一个字段(`TaskID` 已存在,只差 `SpecialistKey`):
|
||||
|
||||
@@ -186,7 +196,7 @@ SpecialistKey string `json:"specialist_key"`
|
||||
|
||||
### 步骤 3:让说明书进 prompt(注入)
|
||||
|
||||
改造 `callAssistantAI` 的 System Prompt 拼装,从「二选一写死」变成「通用底座 + 专员说明书」:
|
||||
改造当前工作台主对话的 System Prompt 拼装,从「二选一写死」变成「通用底座 + 专员说明书」:
|
||||
|
||||
```go
|
||||
func buildAssistantSystemPrompt(userID uint, req SmartAssistantRequest, enableThinking bool) string {
|
||||
@@ -219,7 +229,7 @@ func buildAssistantSystemPrompt(userID uint, req SmartAssistantRequest, enableTh
|
||||
}
|
||||
```
|
||||
|
||||
**同时接上第二条链路**:`internal/api/worker_task.go:708` 已经在拼 System Prompt 且**手上就有 specialist 对象**,把说明书一起拼进去即可——这条链路是免费的,因为它本来就认识专员。
|
||||
**同时接上第二条链路**:当前任务运行链路里本来就会拿到 `specialist` 对象,把说明书一起拼进去即可——这条链路是免费的,因为它本来就认识专员。
|
||||
|
||||
```go
|
||||
systemPrompt := buildSystemPrompt(contextJSON, knowledge) +
|
||||
@@ -227,7 +237,7 @@ systemPrompt := buildSystemPrompt(contextJSON, knowledge) +
|
||||
"\n\n当前任务:" + buildWorkerAITaskPrompt(task, specialist, req)
|
||||
```
|
||||
|
||||
> `specialistPromptSection(s)` 抽成一个共用小函数,`smart_assistant.go` 和 `worker_task.go` 都调它,避免两处 prompt 拼法各写一遍。
|
||||
> `specialistPromptSection(s)` 抽成一个共用小函数,当前应由 `chat_message.go` 与任务运行链路共用,避免两处 prompt 拼法各写一遍。
|
||||
|
||||
### §2.4 技能 key 校验清单
|
||||
|
||||
@@ -304,7 +314,7 @@ var ValidSkillKeys = map[string]bool{
|
||||
|------|------|
|
||||
| `views/workbench/SmartAssistantPage.vue:471` | 请求体补 `task_id` / `specialist_key`(步骤 2) |
|
||||
| `views/workbench/CapabilityCatalogDetailPage.vue:257` | 专员详情页 `extraSections: []` 是空的,**正好是挂「绑定技能」「岗位说明书」两个 section 的位置**(技能详情页 160-183 行有现成写法可抄) |
|
||||
| `store/workerRuntime.js:425-441` | `attachSpecialistToCurrentTask` 目前**强制**把技能清成 `DEFAULT_SKILL_KEY`(438 行)。这正是「选了专员反而更空」的根因——应改为写入该专员的**主技能**(`allowed_skills[0]`) |
|
||||
| `store/taskRuntime.js` | 当前任务运行时里,挂专员到任务时不应再把技能强制清成默认技能;应优先写入该专员的**主技能**(`allowed_skills[0]`) |
|
||||
|
||||
### 建议改
|
||||
|
||||
@@ -343,7 +353,7 @@ fallbacks, err := config.GetFallbackRoutes(primary.RouteID) // primary == nil
|
||||
- `cmd/server/main.go:47` 用的是 `gin.Default()`,自带 Recovery —— 所以表现为 **HTTP 500**,不会打挂进程,但**这两个功能 100% 不可用**
|
||||
- 前端 `api/contract.js:3`、`api/batch.js:3` 确实在调这两个接口
|
||||
|
||||
**修复**:两处改为传入真实路由(`config.GetRoute(...)`,可参照 `smart_assistant.go:146`),或在 `GenerateWithFallback` 入口对 `primary == nil` 显式返回错误而非 panic。**建议两者都做**——前者修功能,后者防复发。
|
||||
**修复**:两处改为传入真实路由(`config.GetRoute(...)`,当前应参照现行工作台对话入口实现),或在 `GenerateWithFallback` 入口对 `primary == nil` 显式返回错误而非 panic。**建议两者都做**——前者修功能,后者防复发。
|
||||
|
||||
---
|
||||
|
||||
@@ -366,7 +376,7 @@ fallbacks, err := config.GetFallbackRoutes(primary.RouteID) // primary == nil
|
||||
| **P0** | 修 §5 的空指针缺陷 | 0.5 天 |
|
||||
| **P1** | 步骤 1 数据层(2 字段 + 播种 + `ValidSkillKeys` 校验) | 0.5 天 |
|
||||
| **P2** | 步骤 2 打通链路(前端 2 处 + 后端 `resolveSpecialist`) | 0.5 天 |
|
||||
| **P3** | 步骤 3 注入 prompt(`buildAssistantSystemPrompt` + `worker_task.go:708` 同步) | 0.5 天 |
|
||||
| **P3** | 步骤 3 注入 prompt(`buildAssistantSystemPrompt` + 当前任务运行链路同步) | 0.5 天 |
|
||||
| **P4** | 前端最小集(详情页两个 section + `attachSpecialistToCurrentTask` 改主技能) | 1 天 |
|
||||
| **P5** | 第一梯队 3 份说明书改写(需先拿到 OfficeCLI) | 每份 0.5–1 天 |
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
> | 对象不跳页,都在同一工作面里挂载 | `AR05_Workbench_Architecture_Contract.md` |
|
||||
> | 专员 / 工具不应该是独立 Vue 页面 | 已实现:`views/workbench/` 无独立工具页 |
|
||||
> | `+` 菜单作为统一的对象选择入口 | 已实现:`components/chat/PlusMenu.vue` |
|
||||
> | 切换对象 = 更新任务上下文,不跳路由 | 已实现:`store/workerRuntime.js` |
|
||||
> | 切换对象 = 更新任务上下文,不跳路由 | 已实现:`store/taskRuntime.js` |
|
||||
>
|
||||
> ### 已废弃(不要照做)
|
||||
>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# AR09 对象命名规范(Object Naming Standard)
|
||||
|
||||
> **版本**:V1.0
|
||||
> **日期**:2026-09-17
|
||||
> **版本**:V1.1
|
||||
> **日期**:2026-09-18
|
||||
> **性质**:规范性文件(normative)。「必须 / 禁止」是硬约束,「应当」是默认做法,「建议」可依场景取舍。
|
||||
> **与 TOP_CODING_RULES.md 的关系**:G03(变量命名锚定)给的是四条底线;本文件是它的展开与可执行化。
|
||||
> **与 TOP_CODING_RULES.md 的关系**:G03(命名锚定,10 条)给的是硬约束底线;本文件是它的展开与可执行化。
|
||||
> 其中 **§5.7 前缀规范 与 G03 第 5-10 条互为详略** —— 准则记硬约束,本文件记判据、检查与修复流程。
|
||||
> **冲突时以 TOP_CODING_RULES.md 为准**,本文件不得放宽 G03。
|
||||
> **适用范围**:后端 Go、数据库、API、前端 Vue、注释与文档。
|
||||
> **读者**:写代码的人 + 执行命名清理的 AI。
|
||||
@@ -26,7 +27,7 @@
|
||||
| 靠名字建立的协议 | 位置 | 改名后果 |
|
||||
|---|---|---|
|
||||
| `action_key` 由 `action_title`(中文)派生,前后端靠它配对 | `specialistFlow.js` 的 `findLatestRun` | 改文案 → 配对静默失效,UI 永远显示"未执行" |
|
||||
| 路由 query 参数 `specialist/skill/prompt` | `appCatalog.js` → `SmartAssistantPage.vue` | 写读不同名 → 参数被静默丢弃(见 §8.1) |
|
||||
| 路由 query 参数 `xapp_specialist` / `xapp_skill` / `xapp_prompt` | `xappCatalog.js` → `SmartAssistantPage.vue` | 写读不同名 → 参数被静默丢弃(2026-09-17 真实发生过,见 §8.1) |
|
||||
| 专员 / 技能的 `key` | `specialist.key`、`skill_definition.key`、`ValidSkillKeys` | 改 key → 任务挂载、技能绑定、种子数据全部对不上 |
|
||||
| 种子数据的 key | `seedSpecialistRuleFiles` 等 | 拼错 → 启动时只能靠显式校验挡住,否则静默漏种 |
|
||||
|
||||
@@ -79,21 +80,21 @@
|
||||
|---|---|---|---|
|
||||
| 专员 | `specialist` | `model/specialist.go` | 可挂载到任务的数字员工 |
|
||||
| 技能 | `skill` | `model/skill_definition.go` | 任务级能力定义 |
|
||||
| 应用 | `app` | `model/app_definition.go` | 长程任务运行壳 |
|
||||
| 应用 | `xapp` | `model/xapp_definition.go` | 长程任务运行壳 |
|
||||
| 连接器 | `connector` | `internal/connector/*` | 外部系统接入 |
|
||||
| 动作 | `action` | `model/action_definition.go` | 技能调用的底层执行单元 |
|
||||
| 任务 | `task` | `model/worker_task.go` | 工作实例容器 |
|
||||
| 任务 | `task` | `model/task_record.go` | 工作实例容器 |
|
||||
|
||||
### 3.2 兼容术语(限制使用,禁止扩散)
|
||||
|
||||
| 历史词 | 现状 | 允许出现的位置 | 禁止 |
|
||||
|---|---|---|---|
|
||||
| `role` | 旧心智残留 | 无。已无正式对象叫 role | 不得用于新变量、新文件名、新字段 |
|
||||
| `assistant` | 只指"默认通用助手"这一件事 | `assistant.js`(默认聊天接口)、`smart-assistant`(默认技能 key)、`general-assistant`(默认专员 key) | 不得用来指代专员、技能或对象类型 |
|
||||
| `capability` | 泛能力总称 | 仅限口语与文档泛指 | 不得充当对象级文件名或字段名 |
|
||||
| `businessApps` | 历史静态变量,装的是专员 | 现有代码 | 禁止新增引用;建议整块删除(见 §8) |
|
||||
| `availableSkills` | 静态 fallback 技能源 | 现有代码 | 禁止新增引用;建议改名 `staticSkillCatalog` |
|
||||
| `worker` | 任务执行子系统前缀 | `worker_task` / `worker_run` / `worker_artifact` / `/api/worker/*` / `workerRuntime.js` | **待拍板**(见 §3.4) |
|
||||
| `assistant` | 只指"默认通用助手"这一件事 | 响应消息角色 `role: "assistant"`、`smart-assistant`(默认技能 key)、`general-assistant`(默认专员 key) | 不得用来指代专员、技能或对象类型。工作台主对话现走 `POST /api/chat/message` 与 `sendChatMessage`,不得再回退到旧 `assistant` 对话命名 |
|
||||
| `capability` | 泛能力总称 | 仅限口语与文档泛指 | 不得充当对象级文件名或字段名。**已执行**:`capability_definition.go` → `skill_action_definition.go` |
|
||||
| `businessApps` | **已删除**(2026-09-18,`d0d7b35`) | 无 | 不许复活 |
|
||||
| `availableSkills` | **已更名 `staticSkillCatalog`**(2026-09-18) | 作为**来源前缀**使用,规则见 §5.7 | 不得再用旧名;不得跨模块引用(§5.7.5) |
|
||||
| `worker` | 历史运行时术语 | 仅允许出现在迁移逻辑、迁移测试、历史说明中 | 不得回流到业务表名、API 路径、模块名或运行时对象名 |
|
||||
|
||||
### 3.3 共名冲突登记
|
||||
|
||||
@@ -108,17 +109,21 @@
|
||||
1. `model/skill_keys.go` 必须保留说明注释(已有)。
|
||||
2. 任何"按 key 查对象"的代码,必须写明查的是哪一类,不允许出现 `getByKey(key)` 这种不区分对象类型的调用。
|
||||
3. `skill_keys_test.go` 那种"前后端 key 集合双向对齐"的测试必须保留 —— 它是这两个身份不互相污染的唯一保障。
|
||||
4. **跨对象提及同一字符串时,必须带类型前缀**:`specialist:contract-review` / `skill:contract-review`。
|
||||
|
||||
### 3.4 待拍板:`worker` 这个命名空间
|
||||
第 4 条已经写在 `skill_keys.go:24-25` 的注释里("日志里请带类型前缀")——
|
||||
**这是 §5.7 前缀思想在本项目最早的一次自觉使用**:同一个字符串在两个对象间共名时,用前缀消歧。
|
||||
它当时只写在注释里、只覆盖日志一处;§5.7 把它推广成通则。保留这条注释,别删。
|
||||
|
||||
现状:`/api/worker/tasks`、`model/worker_task.go`、`worker_run.go`、`worker_artifact.go`、`api/worker.js`、`store/workerRuntime.js`。
|
||||
### 3.4 `worker` 命名空间已退出业务主命名
|
||||
|
||||
`worker` 不在正式术语表里,但已经形成一整套一致的前缀。两个选择:
|
||||
当前运行时业务命名已经完成收口:
|
||||
|
||||
- **A. 收编为正式术语**:定义为"任务执行子系统",写进 §3.1。成本为零,立刻合法。
|
||||
- **B. 重命名**:把 `worker_*` 收敛为 `task_*`。语义更准,但要动表名、API 路径、前端模块,成本高。
|
||||
- 表 / 模型:`task_record`、`task_run`、`task_artifact`
|
||||
- API 路径:`/api/tasks`、`/api/my/tasks`、`/api/artifacts/*`
|
||||
- 前端模块:`api/taskRuntime.js`、`store/taskRuntime.js`
|
||||
|
||||
**建议 A**。理由:它已经自洽,且不与任何人抢名字;真正会误导的是 §8 里那些"名实相反"的(`businessApps` 装专员、`capability` 装 skill),而不是一个自洽的子系统前缀。
|
||||
`worker` 仅保留在**迁移逻辑、迁移测试、历史说明**里,用来识别旧库与旧代码路径;它不再是正式术语,也不进入新白名单。
|
||||
|
||||
---
|
||||
|
||||
@@ -128,7 +133,7 @@
|
||||
|
||||
| 项 | 规范 | 禁止 |
|
||||
|---|---|---|
|
||||
| 类型名 | `CamelCase`,对象名 + 用途后缀:`Specialist`、`SkillDefinition`、`WorkerTask` | `Data`、`Info`、`Manager`、`Helper` 这类空词 |
|
||||
| 类型名 | `CamelCase`,对象名 + 用途后缀:`Specialist`、`SkillDefinition`、`TaskRecord` | `Data`、`Info`、`Manager`、`Helper` 这类空词 |
|
||||
| 字段名 | `CamelCase` | 业务字段用 `Id`/`Data`/`Res` 这类模糊名 |
|
||||
| **`ID` 写法** | **一律 `ID`**:`UserID`、`TaskID`、`RouteID`、`AIRouteID` | `UserId`、`mediaId`、`sourceId` |
|
||||
| json tag | **一律 snake_case**:`json:"specialist_key"` | `json:"skillKey"`、`json:"createdAt"` |
|
||||
@@ -147,7 +152,8 @@
|
||||
|
||||
| 项 | 规范 |
|
||||
|---|---|
|
||||
| 表名 | 单数 snake_case:`specialist`、`skill_definition`、`worker_task` |
|
||||
| 表名 | 单数 snake_case:`specialist`、`skill_definition`、`task_record` |
|
||||
| **表名前缀** | 一级对象用**裸名**(`specialist` / `project` / `product`);归属或复合概念**必须带前缀**(`task_record` / `knowledge_chunk` / `position_exam_blueprint`)。详见 §5.7.3 |
|
||||
| 列名 | snake_case,与 Go 字段的 json tag 一致 |
|
||||
| 外键 | `<对象>_id`:`specialist_id`、`task_id` |
|
||||
| 时间 | `<动词>_at`:`created_at`、`finished_at` |
|
||||
@@ -159,10 +165,11 @@
|
||||
|
||||
| 项 | 规范 | 现状 |
|
||||
|---|---|---|
|
||||
| 路径 | 复数资源:`/api/specialists`、`/api/skills`、`/api/apps` | ✅ 已一致,保持不回退 |
|
||||
| 路径参数 | snake_case:`/api/knowledge/audit/{source_id}` | ❌ 现在是 `{sourceId}`、`{recordId}` |
|
||||
| query 参数 | snake_case,且**跨层同名** | ❌ 见 §8.1 |
|
||||
| 入口协议 | 对象入口 query 必须**定义在一处常量**,写读都引它 | ❌ 现在两边各写各的 |
|
||||
| 路径 | 复数资源:`/api/specialists`、`/api/skills`、`/api/xapps` | ✅ 已一致,保持不回退 |
|
||||
| **路径前缀** | 一级对象裸复数(`/api/specialists`);子系统带前缀(`/api/tasks`、`/api/knowledge/*`)。与表名规律同构 | ✅ 已一致,保持不回退(§5.7.3) |
|
||||
| 路径参数 | snake_case:`/api/knowledge/audit/{source_id}` | ✅ 已收口为 snake_case |
|
||||
| query 参数 | snake_case,且**跨层同名** | ✅ 持续守住 |
|
||||
| 入口协议 | 对象入口 query 必须**定义在一处常量**,写读都引它 | ✅ 已抽到共享常量 |
|
||||
|
||||
**入口协议强制要求**:任何"从 A 页面带参数打开 B 页面"的协议,参数名必须是**共享常量**,不允许两边各写字符串字面量。这是 §8.1 事故的直接教训。
|
||||
|
||||
@@ -170,7 +177,9 @@
|
||||
|
||||
| 项 | 规范 |
|
||||
|---|---|
|
||||
| 文件名 | store:`<对象>Catalog.js`(如 `specialistCatalog.js` / `skillCatalog.js` / `appCatalog.js`);API:`<对象>.js`(如 `specialist.js` / `worker.js`) |
|
||||
| 文件名 | store:`<对象>Catalog.js`(如 `specialistCatalog.js` / `skillCatalog.js` / `xappCatalog.js`);API:`<对象>.js`(如 `specialist.js` / `taskRuntime.js`) |
|
||||
| store 文件名后缀 | **不带 `Store`**(Pinia 的 `useXxxStore` 已在导出名上表达)。现仅 `projectStore.js` 一个孤例带后缀 → 改为 `project.js`(§8.12-4) |
|
||||
| 组件文件名 | 与对象相关的组件**必须带对象前缀**:`SpecialistChip.vue` / `SkillStrip.vue`;工作台布局件用 `surface` / `workspace` 语义(如 `SurfaceChatRail.vue`);纯布局件可用通用名(`ChatInputBar.vue` / `PlusMenu.vue`) |
|
||||
| catalog store 模板 | `normalize<Object>` → `hydrate` → `getByKey` / `getByPath`(`specialistCatalog.js` 为标准范本) |
|
||||
| computed | `current<Object>Presentation`,对象名必须准确 |
|
||||
| 局部变量 | **必须与对象一致**。`const app = specialistCatalog.getByKey()` 是禁止的 |
|
||||
@@ -249,6 +258,152 @@
|
||||
|
||||
---
|
||||
|
||||
### 5.7 前缀规范(对象 / 来源 / 作用域)
|
||||
|
||||
> 前缀是本项目用得最重、也最容易失控的命名手段:34 张表、18 组 API 路径、全部 store 与组件都在用。
|
||||
> 本节回答四件事:**什么时候必须加、加哪一类、什么时候必须去掉、怎么检查。**
|
||||
>
|
||||
> **与 TOP_CODING_RULES.md G03 的关系**:G03 第 5-10 条是这一节的**硬约束摘要**(准则层,必读);
|
||||
> 本节是它的**完整展开** —— 判据表、三类前缀的对照、机器守卫(§6.2 守卫 G–J)、改名的额外要求(§7.6)、现状登记(§8.12)。
|
||||
> 两者冲突时以 G03 为准。
|
||||
|
||||
#### 5.7.1 原理:前缀只在"平铺且无类型"的命名空间里才必要
|
||||
|
||||
前缀有成本:让名字变长、会随事实过期、被复制后极难收回。
|
||||
**所以加前缀的唯一正当理由,是去掉它之后同一个命名空间里会出现两个可能同名的东西。**
|
||||
|
||||
判据落在命名空间的**形状**上,而不是被命名对象的重要性上:
|
||||
|
||||
| 命名空间 | 形状 | 前缀 |
|
||||
|---|---|---|
|
||||
| SQLite 表名 | 一个库内全平铺,无层级 | **必要**:`task_record` 不能叫 `task` |
|
||||
| 表内列名 | 一张表内全平铺 | **必要**:`object_entry_route` 的 `object_` |
|
||||
| URL query | 无 schema 的字符串袋 | **必要**:`xapp_specialist`(§8.1 事故的根源就在这里) |
|
||||
| JSON key | 跨语言,接收端不做类型检查 | **必要**:全站 snake_case |
|
||||
| 目录内文件名 | 平铺 | **必要**:`SkillStrip.vue` |
|
||||
| shell 变量 | 一个 shell 内共享(`start_dev` 同时跑两个服务) | **必要**:`BACKEND_PORT` / `FRONTEND_PORT` |
|
||||
| Go 包内标识符 | 有包作用域 | **冗余但可接受**(跨包引用时才真正需要) |
|
||||
| 结构体字段 | 有 struct 作用域 | **不需要**:`SkillDefinition.Key` 不必叫 `skill_key` |
|
||||
|
||||
**一句话**:编译器或运行时能替你消歧的地方,前缀是噪音;不能的地方,前缀是唯一的消歧手段。
|
||||
|
||||
**本项目一个现成的正面例子**:`deploy/eai_agentplatform.env` 里是裸名 `PORT`、`DB_PATH`(一个 systemd unit 独占一份进程环境,不会冲突),而 `start_dev_10231_10232.sh:56-57` 里是 `BACKEND_PORT` / `FRONTEND_PORT`(同一个 shell 里跑两个服务,必须区分)。
|
||||
同一个"端口"概念,在两层用了两种写法 —— **这不是不一致,这是对命名空间边界的正确判断**,应当保持。
|
||||
|
||||
#### 5.7.2 三类前缀,各有各的生命周期
|
||||
|
||||
| 类别 | 标记什么 | 本项目实例 | 生命周期 |
|
||||
|---|---|---|---|
|
||||
| **对象前缀** | 属于哪个一级对象 / 子系统 | `skill_definition`、`worker_task`、`knowledge_space` | **永久**(对象在,前缀在) |
|
||||
| **来源前缀** | 这份数据的来路 | `staticSkillCatalog`、`normalizeCustomApp`、`normalizeRemoteApp`、(已退役)`legacy_*` | **必须写退出条件**(§5.7.6) |
|
||||
| **作用域前缀** | 从哪个入口来 / 属于谁 | query 的 `app_`、API 的 `my_` | 随接口一起设计,不单独退役 |
|
||||
|
||||
**对象前缀的白名单 = §3.1 术语表 + §5.7.3 的子系统清单。** 不在这两张表里的前缀不许发明 —— 这与 §5.6 缩写表是同一个思路:**能用的集合必须封闭,否则每个人都会造自己的。**
|
||||
|
||||
#### 5.7.3 已收敛的两条规律(DB 层与 API 层各自独立长成了同一条)
|
||||
|
||||
34 张表的前缀乍看是随手加的,实际有规律:
|
||||
|
||||
```text
|
||||
一级对象 → 裸名 specialist / project / product / position / course / user
|
||||
归属或复合 → 带前缀 worker_task / knowledge_chunk / position_exam_blueprint / ai_call_log
|
||||
```
|
||||
|
||||
API 路径**独立地**收敛到了同一条:
|
||||
|
||||
```text
|
||||
一级对象 → 裸复数 /api/specialists /api/skills /api/xapps /api/actions /api/products
|
||||
子系统 → 带前缀 /api/tasks /api/knowledge/* /api/system/* /api/exam/* /api/ai/*
|
||||
```
|
||||
|
||||
两层没有互相参照却选了一样的分法,说明这条规则是自然的,不是硬塞的。**写进规范,守住不回退。**
|
||||
|
||||
子系统前缀白名单(现有;新增须先登记到本表):
|
||||
|
||||
`task` · `knowledge` · `exam` · `official_account` · `ai` · `media` · `system` · `user` · `position`
|
||||
|
||||
**已知的一处不同构(登记,不强制回改)**:`specialist` 是裸名,而它的同位对象是 `skill_definition` / `xapp_definition` / `action_definition`。
|
||||
`_definition` 后缀标记的是"对象定义表",按此语义 `specialist` 应属同一族。但改表名牵动迁移与所有引用,收益不抵成本 —— 按 §6.4 的 P3 纪律**先登记,不顺手改**。
|
||||
|
||||
#### 5.7.4 硬约束
|
||||
|
||||
1. **一个标识符最多带一个类型前缀。** `task_record` ✅ / `xapp_specialist_skill_key` ❌。需要两层信息时用组合词,不要叠加前缀。
|
||||
2. **次序固定:前缀 + 核心词 + 后缀。** `skill_definition` ✅ / `definition_skill` ❌。
|
||||
3. **前缀必须写全,禁止缩写。** `specialist_` ✅ / `sp_`、`sk_`、`wr_` ❌(§5.6)。
|
||||
4. **禁止拼音前缀。** 前缀取自 §3.1 的英文术语表;中文是对外展示层的事,不进标识符。
|
||||
5. **过渡前缀不得嵌套。** `legacy_` 之上不许再叠一层,理由见 §5.7.6。
|
||||
6. **来源前缀不得跨模块引用**(§5.7.5)。
|
||||
7. **作用域前缀不得进入模型、表、字段名。** `my_` 只能出现在 API 路径与 query。
|
||||
现状是对的:API 是 `api/my_xapp_center.go`,模型是 `model/user_xapp_center.go` —— **保持这个分层**。
|
||||
附带提醒:`my-` 命名的是**视图**不是资源(`/api/my/tasks` 与 `/api/tasks` 并存)。当出现第三种视图(管理员看某个用户的)时它会没有名字 —— 届时改用 `?owner=` 参数,**不要**再加 `their-tasks` 这种前缀。
|
||||
|
||||
#### 5.7.5 来源前缀不得泄漏到调用点
|
||||
|
||||
**这是本节最实用的一条,也是本项目正在发生的一个问题。**
|
||||
|
||||
`staticSkillCatalog` 的**定义位置是对的**(`config/workbench.js`),但它现在有 6 处引用,其中 **5 处是同一种兜底写法**:
|
||||
|
||||
```js
|
||||
skillCatalog.getByKey(key) || staticSkillCatalog.find((item) => item.key === key)
|
||||
```
|
||||
|
||||
分布在 `SmartAssistantPage.vue`(3 处)、`PlusMenu.vue`、`CurrentObjectChip.vue`。
|
||||
|
||||
问题不是"不该加 `static` 前缀",而是**前缀泄漏到了调用点**:调用方本来不该知道"这份数据可能是静态兜底的"。
|
||||
每多一个调用点,就多一处将来要同步修改的地方;而且**哪一处漏了不会有任何提示**。
|
||||
|
||||
→ **做法**:把兜底收进 `skillCatalog`,调用点只写一个名字:
|
||||
|
||||
```js
|
||||
// store/skillCatalog.js —— 前缀留在声明处,不出模块
|
||||
function resolve(key) {
|
||||
return getByKey(key) || staticSkills.find((item) => item.key === key) || null
|
||||
}
|
||||
```
|
||||
|
||||
(`skillCatalog.js` 目前只有 `hydrate` / `getByKey`,**尚无 `resolve`** —— 这是待办,见 §8.12-2。)
|
||||
|
||||
**推广成规则**:任何带来源前缀的符号(`static*` / `legacy*` / `custom*` / `remote*`),被声明模块之外的文件直接引用,即为泄漏。
|
||||
判断不需要读实现 —— **看 import 就够**。
|
||||
|
||||
#### 5.7.6 来源前缀必须带退出条件
|
||||
|
||||
`static*` / `legacy*` / `tmp*` / `old*` / `deprecated*` 描述的是**过程状态**,而过程会结束。
|
||||
没有退出条件,它们就会永久化,代码变成考古现场。
|
||||
|
||||
本项目在同一张表上同时有一个反例和一个正例。
|
||||
|
||||
**反例 —— `skill_definition` 的五代列名:**
|
||||
|
||||
```text
|
||||
entry_route → route → legacy_entry_route / legacy_route
|
||||
→ legacy_object_entry_route → (已删除)
|
||||
```
|
||||
|
||||
`legacy_object_entry_route` 的字面意思是"遗留的 · 对象入口路由" —— `legacy_` 叠在**已经 legacy 的概念**上。
|
||||
**过渡前缀出现第二层,就是上一次迁移没有退出条件的证据。**
|
||||
|
||||
**正例 —— 同一个字段的收尾:**
|
||||
上面那批 legacy 列的 drop 逻辑(`db.go:148-158`)与 `RoleKind → ObjectKind` 的迁移写在**同一笔提交**(`d0d7b35`)里,而不是"先留着以后再说"。这是正确做法,值得照抄。
|
||||
|
||||
→ **要求**:写下来源前缀的同一行注释或相邻 TODO,必须写清"什么时候可以去掉"。
|
||||
现存待办示例:`staticSkillCatalog` 的退出条件 = `workbench.js` 的静态兜底被确认可由 `skillCatalog` 完全覆盖后删除。
|
||||
|
||||
#### 5.7.7 前缀与分隔符
|
||||
|
||||
分隔符由**所在层**决定,不由个人喜好:
|
||||
|
||||
| 层 | 分隔符 | 例 |
|
||||
|---|---|---|
|
||||
| Go 标识符 | CamelCase 连写,前缀是完整单词、不加分隔符 | `staticSkillCatalog`、`normalizeRemoteApp` |
|
||||
| 表名 / 列名 / JSON key | snake_case | `task_record`、`xapp_specialist` |
|
||||
| URL 路径 / query | snake_case | `/api/tasks`、`xapp_skill` |
|
||||
| 文件名 | 组件 `CamelCase.vue`、模块 `camelCase.js` | `SpecialistChip.vue`、`skillCatalog.js` |
|
||||
|
||||
同一层内不得混用。模型层当前 **237 : 0** 全是 snake_case,是一条干净基线,**加守卫防回退**(§6.2 守卫 I)。
|
||||
|
||||
---
|
||||
|
||||
## 六、如何检查
|
||||
|
||||
### 6.1 人工五问(写名字时问自己,review 时问作者)
|
||||
@@ -272,18 +427,30 @@
|
||||
| D | 禁用词当业务标识符 | 按 §5.5 词表 | 框架约定(`req` 局部) |
|
||||
| E | 前端 skill key ↔ 后端 `ValidSkillKeys` | 已有的 `skill_keys_test.go` | — |
|
||||
| F | 入口协议写读对称 | 见下 | — |
|
||||
| G | **前缀白名单 diff**:从表名、API 路径、store/组件文件名、query 参数中抽取前缀,与 §3.1 术语表 + §5.7.3 子系统清单求差集 | 见下 | 两张表本身就是白名单 |
|
||||
| H | **过渡前缀缺退出条件**:`legacy_` / `legacy[A-Z]` / `static[A-Z]` / `tmp_` / `old[A-Z]` / `deprecated` 每一处都必须有说明移除条件的注释或 TODO | `(legacy_|static[A-Z]|tmp_|old[A-Z]|deprecated)` | 迁移脚本内部的一次性变量 |
|
||||
| I | **分隔符不混用**:模型层 JSON tag 必须 100% snake_case(基线 237:0) | `json:"[a-z0-9_]*[A-Z]` | 无(`my_app_center.go` 已修) |
|
||||
| J | **来源前缀跨模块引用**:带来源前缀的符号不得被声明它的模块之外的文件引用 | 见下 | 声明模块自身 |
|
||||
|
||||
**守卫 G 的做法**:表名与路径前缀可以直接从 `model/*.go` 的 `TableName()` 和 `router.go` 的注册里抽;
|
||||
抽出的前缀集合与白名单求差集,多出来的就是"某人新造的前缀",需要先登记再放行。
|
||||
**这条守卫的价值在于把"前缀"从个人习惯变成受控词汇表** —— 与 §5.6 缩写表同一个机制。
|
||||
|
||||
**守卫 J 的做法**:`static*` / `legacy*` / `custom*` / `remote*` 开头的导出符号,grep 其被 import 的位置,
|
||||
凡出现在声明模块之外即为违规。当前 `staticSkillCatalog` 有 **5 处**这类引用(§5.7.5),是这条守卫的首批命中项。
|
||||
不需要读实现,**看 import 就够** —— 这是一条几乎零成本、却能挡住"兜底逻辑被抄 N 遍"的守卫。
|
||||
|
||||
**守卫 F 的做法**(最重要,也最难自动化):
|
||||
入口协议不要靠扫描源码配对,而是**把参数名提成共享常量**(如 `frontend/src/config/objectEntry.js` 导出 `ENTRY_PARAMS = { specialist: 'app_specialist', ... }`),写端读端都引它。
|
||||
入口协议不要靠扫描源码配对,而是**把参数名提成共享常量**(如 `frontend/src/config/objectEntry.js` 导出 `XAPP_ENTRY_QUERY_KEYS = { specialist: 'xapp_specialist', ... }`),写端读端都引它。
|
||||
这样 F 就从"看不见的约定"变成了"编译器能查的引用",守卫 F 也就不需要了 —— **能用结构消除的检查,不要用扫描去补**。
|
||||
|
||||
### 6.3 跨层对齐检查
|
||||
|
||||
任何"同一份清单在前后端各写一遍"的东西,都必须有双向对齐测试:
|
||||
|
||||
- 技能 key:前端 `availableSkills` ↔ 后端 `ValidSkillKeys` ✅ 已有
|
||||
- 技能 key:前端 `staticSkillCatalog` ↔ 后端 `ValidSkillKeys` ✅ 已有(`skill_keys_test.go`,22 个 key 双向对齐)
|
||||
- 专员 key:前端静态目录 ↔ 后端种子数据 ⬜ 建议补
|
||||
- 入口协议参数名:⬜ 建议补(提到共享常量后自然消解)
|
||||
- 入口协议参数名:✅ **已做**。写端与读端都改为引用 `frontend/src/config/objectEntry.js` 的共享常量,不再各写各的字面量。
|
||||
|
||||
### 6.4 检查结果的严重性分级
|
||||
|
||||
@@ -295,7 +462,9 @@
|
||||
| **P3** | 死代码 / 无人引用的历史变量 | **先判生死,再决定改名还是删除** |
|
||||
|
||||
**注意 P3 的陷阱**:给一份没人读的静态数据改名,等于给它续命。
|
||||
先确认引用数,是 0 就删(见 §8.9)。
|
||||
先确认引用数,是 0 就删(见 §8.9,`businessApps` 已按此原则删除)。
|
||||
|
||||
**P3 同样适用于前缀**:给死代码补一个语义正确的前缀,只是让它死得更体面(§5.7)。
|
||||
|
||||
---
|
||||
|
||||
@@ -352,10 +521,10 @@
|
||||
不要只是"改一致",而是**提成共享常量**:
|
||||
```js
|
||||
// frontend/src/config/objectEntry.js
|
||||
export const ENTRY_PARAMS = {
|
||||
specialist: 'app_specialist',
|
||||
skill: 'app_skill',
|
||||
prompt: 'app_prompt',
|
||||
export const XAPP_ENTRY_QUERY_KEYS = {
|
||||
specialist: 'xapp_specialist',
|
||||
skill: 'xapp_skill',
|
||||
prompt: 'xapp_prompt',
|
||||
}
|
||||
```
|
||||
写端 `params.set(ENTRY_PARAMS.specialist, ...)`,读端 `route.query[ENTRY_PARAMS.specialist]`。
|
||||
@@ -378,81 +547,187 @@ export const ENTRY_PARAMS = {
|
||||
- 改名列必须写明"旧名 → 新名",方便 `git log --follow` 与后来人搜索。
|
||||
- 如果改名波及数据库,提交信息里写明迁移步骤与验证结果。
|
||||
|
||||
### 7.6 前缀改名的额外要求
|
||||
|
||||
前缀改名与普通改名有一个根本区别:**前缀几乎总是活在字符串里**(表名、列名、JSON tag、URL 参数、env 变量名),
|
||||
而字符串改名**编译器一个都管不着**。在 §7.2 六步法之上,额外三条:
|
||||
|
||||
**① 必须按字符串 grep,不是按符号 grep。**
|
||||
查 `LegacyObjectEntryRoute` 是不够的,必须查 `legacy_object_entry_route`,并且覆盖:
|
||||
JSON tag、SQL 语句与迁移脚本、前端字面量、query 参数、文档。范围与 §7.2 第 2 步相同,但**起点是字符串**。
|
||||
|
||||
**② 禁止子串替换、禁止正则通配,必须逐标识符精确锚定。**
|
||||
|
||||
本项目有一个现成的雷:`role_kind`(正确新名 `object_kind`)与 `role_card_json`(正确新名 `interaction_card_json`)
|
||||
**都以 `role_` 开头,但去向完全不同**。
|
||||
一句 `sed 's/role_/object_/g'` 会把 `role_card_json` 误伤成 `object_card_json` —— 而它本该叫 `interaction_card_json`。
|
||||
|
||||
这不是假设:[db.go:171](../../eai_agentplatform/backend-go/internal/store/db.go#L171) 与 [:184](../../eai_agentplatform/backend-go/internal/store/db.go#L184)
|
||||
两条迁移的目标名确实不同,**证明了批量替换必然出错**。
|
||||
结论:前缀改名**只能一个标识符一个标识符地改**,宁可慢。
|
||||
|
||||
**③ 本项目不需要兼容窗口,但持久化的名字除外。**
|
||||
|
||||
前后端同版本发布(单二进制 + 内网部署,无外部消费者),所以 URL query、JSON key 这类字符串边界
|
||||
**一次改完即可**,不需要新旧并存的过渡期 —— 这是本项目相对一般工程的一个有利条件,可以放心用。
|
||||
|
||||
**唯一例外**:任何被**持久化**的名字。浏览器 localStorage 里的 key、已发出的书签 URL、磁盘上的配置文件 —— 这些在改名后仍然存在,读端需要容旧或做一次性迁移。
|
||||
|
||||
---
|
||||
|
||||
## 八、本项目现状(2026-09-17 实测)
|
||||
## 八、本项目现状
|
||||
|
||||
> 以下每条都在代码里核实过,标了行号。级别按 §6.4。
|
||||
> **2026-09-17 实测**:以下每条都在代码里核实过,标了行号,级别按 §6.4。
|
||||
> **2026-09-18 更新**:§8.1–8.10 已在提交 `d0d7b35` 执行完毕。原文保留是为了留下**判断依据**(§9 反例库直接依赖它);
|
||||
> **每条的『现状』行是当下事实,冲突时以它为准。** 新增 §8.12(前缀维度)、§8.13(未根治项)。
|
||||
|
||||
### 8.1 【P0|功能已断】对象入口协议写读不对称
|
||||
### 8.1 【曾为 P0|09-18 已修,但未根治】对象入口协议写读不对称
|
||||
|
||||
- **写端**:[appCatalog.js:140-142](../../eai_agentplatform/frontend/src/store/appCatalog.js#L140-L142) 拼 `specialist` / `skill` / `prompt`
|
||||
- **读端**:`SmartAssistantPage.vue:603`、`604`、`619` 读 `app_specialist` / `app_skill` / `app_prompt`
|
||||
- **写端**:[xappCatalog.js:143-146](../../eai_agentplatform/frontend/src/store/xappCatalog.js#L143-L146) 拼 `xapp_specialist` / `xapp_skill` / `xapp_prompt`
|
||||
- **读端**:`SmartAssistantPage.vue:604`、`605`、`606` 读 `xapp_specialist` / `xapp_skill` / `xapp_prompt`
|
||||
- **全仓库核实**:没有任何地方读不带前缀的,也没有任何地方写带前缀的
|
||||
- **后果**:从能力目录点应用进工作台,预置的专员 / 技能 / 提示词**三个参数全部被静默丢弃**,不报错。用户看到的是"点进去还是空的"
|
||||
- **注意**:这是 bug,不是风格问题。按 §7.4② 提成共享常量一起修
|
||||
- **现状(09-18)**:✅ **功能与根因都已修** —— 两侧统一为 `xapp_specialist` / `xapp_skill` / `xapp_prompt`,并提成 `frontend/src/config/objectEntry.js` 共享常量;读端另有 3 个 `watch`(`SmartAssistantPage.vue:700/707/714`),使二次打开另一个应用时也能切换。
|
||||
|
||||
### 8.2 【P1】camelCase json tag
|
||||
|
||||
`my_app_center.go:36-41` 六处:`installState`、`skillKey`、`createdAt`、`iconText`、`coverTone`、`isCustomApp`,是全站 snake_case 体系里仅有的例外,且集中在同一个结构体。
|
||||
→ 改 snake_case,前端同步。
|
||||
**现状(09-18)**:⬜ 未动。模型层基线仍为 237 : 0 全 snake_case,违规仍集中在这一处。
|
||||
|
||||
### 8.3 【P1】URL 路径参数 camelCase
|
||||
|
||||
`api/knowledge.go:97` 的 `{sourceId}`、`api/exam.go:975` 的 `{recordId}`。
|
||||
→ 改 snake_case(`{source_id}`、`{record_id}`),同步前端调用处。
|
||||
**现状(09-18)**:⬜ 未动(`knowledge.go:94/160`、`exam.go:972` 路径仍为 camelCase)。
|
||||
|
||||
### 8.4 【P1】局部变量失真
|
||||
|
||||
`SmartAssistantPage.vue:314`:`const app = specialistCatalog.getByKey(key)` —— 拿到的是专员,却叫 `app`。
|
||||
注:它所在的 computed 名字是对的(`currentSpecialistPresentation`,`kind: 'specialist'`),失真仅限这一个局部变量。
|
||||
→ 改 `specialist`。**P1 原因:它会被后续代码照抄。**
|
||||
**现状(09-18)**:✅ 已修(`const app = ` 在 `SmartAssistantPage.vue` 中已无匹配)。
|
||||
|
||||
### 8.5 【P2】`role` 心智残留
|
||||
|
||||
`SmartAssistantPage.vue:354` 的 `currentRolePresentation`,把 assistant / specialist / skill 三类收在一个 role 概念下。
|
||||
→ 收敛为 `currentObjectPresentation`,或按 §3.1 拆成三份。
|
||||
**现状(09-18)**:✅ 已修(全仓已无 `currentRolePresentation`)。
|
||||
|
||||
### 8.6 【P2】`RoleKind` 字段名带旧词
|
||||
|
||||
`model/skill_definition.go:13`:`RoleKind`,注释 `assistant / specialist / skill`。
|
||||
`model/skill_definition.go:13`:旧 `RoleKind`,早期注释曾写 `assistant / specialist / skill`。
|
||||
|
||||
- **澄清**:这个字段本身不是垃圾,语义是"这条定义属于哪类对象"(`skill_definition` 表同时装 assistant / specialist / skill 三类记录)。问题只在名字里的 `role` 让人误读成"角色"。
|
||||
- **澄清**:这个字段表达的是"这条定义属于哪类对象"。当前正式值已收口为 `specialist / skill`,不再把 `assistant` 作为对象分类值保留。
|
||||
- **改名方向**:`object_kind`(准确)。
|
||||
- **成本提醒**:改字段名要同时动 ① 数据库列 `role_kind` ② json tag ③ 前端读取点。SQLite 改列名 `AutoMigrate` 不管,得手写迁移。
|
||||
- **建议**:**默认方案是保留字段名,在注释与文档里标注语义**;真要改,按 §7.4④ 走完整流程,不要顺手改。
|
||||
- **现状(09-18)**:✅ **已按完整流程改名** `ObjectKind`(列 `object_kind`),迁移写在 `migrateSkillObjectKindColumn`(先拷数据再 drop 旧列,未踩 AutoMigrate 只加不删的坑)。
|
||||
印证 §5.7.6:旧列与新列的迁移逻辑**同批删除**,未留考古层。
|
||||
⚠️ 全仓仅剩的 `role_kind` 在 `db_migration_test.go:186-201` —— **这是正确用法**:测试必须重建升级前的旧库才能验证迁移。
|
||||
**不要把这类旧名当成待清理项。**
|
||||
|
||||
### 8.7 【P2】`capability_definition.go` 名实不符
|
||||
### 8.7 【P2|09-18 已改】`capability_definition.go` 名实不符
|
||||
|
||||
`internal/api/capability_definition.go` 实际装了两套东西:`skillDefinitionReq` 5 个 handler + `actionDefinitionReq` 5 个 handler(共 15 个函数)。
|
||||
|
||||
- **不能只改名为 `skill_definition.go`** —— action 的 handler 还在里面,改完名实仍然不符。
|
||||
- **两个正确方案**:① 拆成 `skill_definition.go` + `action_definition.go`,**同时改 `router.go` 注册**;② 不拆,改名 `skill_and_action_definition.go`。
|
||||
- **要害**:`capability` 不是正式对象,让它当文件名会持续制造一个不存在的一级概念。
|
||||
- **现状(09-18)**:✅ 已改为 `api/skill_action_definition.go`(采用方案②,未拆分),`router.go` 注册已同步,构建通过。
|
||||
⚠️ 遗留观察:该文件名与 §5.7.4 第 1 条不冲突,但它是**双对象文件**;若将来 action 的 handler 变多,仍应拆成两文件(拆分时必须同时改 `router.go` 注册)。
|
||||
|
||||
### 8.8 【P1】`worker` 命名空间待拍板
|
||||
### 8.8 【P1|09-18 已完成】`worker` 命名空间已退出业务路径
|
||||
|
||||
`/api/worker/tasks`、`worker_task.go`、`worker_run.go`、`worker_artifact.go`、`api/worker.js`、`store/workerRuntime.js`。
|
||||
→ 见 §3.4,建议收编为正式术语,成本为零。
|
||||
已完成的收口:
|
||||
|
||||
### 8.9 【P3】`businessApps` 疑似死代码 —— 建议删,不建议改名
|
||||
- `/api/worker/tasks` → `/api/tasks`
|
||||
- `worker_task.go` / `worker_run.go` / `worker_artifact.go` → `task_record.go` / `task_run.go` / `task_artifact.go`
|
||||
- `api/worker.js` / `store/workerRuntime.js` → `api/taskRuntime.js` / `store/taskRuntime.js`
|
||||
|
||||
**现状(09-18)**:✅ 业务代码已完成去兼容;`worker_*` 仅保留在迁移逻辑、迁移测试与历史说明中。
|
||||
|
||||
### 8.9 【P3|09-18 已删除】`businessApps` 死代码
|
||||
|
||||
- **定义**:`config/workbench.js:1434`,内容是**专员目录**(第一条即 `contract-review` 合同审查专员,带 `tier` / `workerType` / `roleCard`),名字与内容相反
|
||||
- **引用情况**:模块外**零引用**;仅 `workbench.js` 内部 6 处自用(`2232` 按 legacy 路由查、`2236` 按 tier 筛、`2240` 数量、`2244` 可升级数、`2252-2253` dw/adw 统计)
|
||||
- **建议**:先确认那几个统计入口是否还有页面在用 → 没有就**整块删除**;有就随使用者一起迁到 `specialistCatalog`
|
||||
- **不要**改名成 `staticSpecialistCatalog` —— 那等于给一份没人读的静态副本续命
|
||||
- **现状(09-18)**:✅ **已整块删除**,按建议路径处理。
|
||||
**留下一条经验**:判断"改还是删"要看**引用数**,不是看名字难不难看。
|
||||
这一条最初被外部清单列为"建议改名为 `staticSpecialistCatalog`",实际核实后是模块外零引用的死变量 —— 按原名改就给它续了命。
|
||||
|
||||
### 8.10 【P3】`availableSkills` 静态 fallback
|
||||
### 8.10 【P3|09-18 已更名,但只解决一半】`availableSkills` 静态 fallback
|
||||
|
||||
`config/workbench.js:105`,22 条技能定义,被 4 处用于兜底:`store/skillCatalog.js:4`、`components/chat/PlusMenu.vue:183`、`config/projectTemplates.js:19`、`components/chat/CurrentObjectChip.vue:55`。
|
||||
→ 确认 `skillCatalog` 覆盖完整后,改名 `staticSkillCatalog`(语义准确),或一并删除。
|
||||
**现状(09-18)**:✅ 已更名 `staticSkillCatalog`,未删除(`skillCatalog` 尚未完全覆盖,兜底仍有实际作用)。
|
||||
⚠️ 但**改名只解决了一半** —— 兜底逻辑现在被抄到了 5 个调用点,见 §5.7.5 与 §8.12-2。
|
||||
|
||||
### 8.11 【已守住,勿回退】
|
||||
|
||||
- 后端对象 API 命名已清楚:`/api/specialists`、`/api/skills`、`/api/apps`、`/api/actions`(`router.go:43-50`)
|
||||
- 后端对象 API 命名已清楚:`/api/specialists`、`/api/skills`、`/api/xapps`、`/api/actions`(`router.go:43-50`)
|
||||
- 前端 catalog store 命名已规范:`specialistCatalog.js`(`normalizeSpecialist` / `getByKey` / `getByPath`)是标准范本
|
||||
- 技能 key 前后端对齐测试已有(`model/skill_keys_test.go`,22 个 key)
|
||||
|
||||
### 8.12 【2026-09-18 新增】前缀维度现状
|
||||
|
||||
#### 8.12-1 【已成立,守住】表名与 API 路径的前缀规律
|
||||
|
||||
34 张表与 18 组 API 路径**各自独立**收敛到了同一条分法(一级对象裸名、归属复合带前缀,详见 §5.7.3)。
|
||||
两层没有互相参照却选了一样的写法 —— 这是自然规律,**写进规范是为了守住,不是为了改造**。
|
||||
|
||||
#### 8.12-2 【P1】`staticSkillCatalog` 的前缀泄漏到 5 个调用点
|
||||
|
||||
- **定义**:`config/workbench.js`(原名 `availableSkills`,09-18 更名)
|
||||
- **引用**:共 6 处,其中 **5 处是同一种兜底写法** `skillCatalog.getByKey(k) || staticSkillCatalog.find(...)`
|
||||
—— `SmartAssistantPage.vue:299/340/408`、`PlusMenu.vue:183`、`CurrentObjectChip.vue:55`
|
||||
- **根因**:`store/skillCatalog.js` 只有 `hydrate` / `getByKey`,**没有 `resolve`**,所以每个调用方都得自己拼兜底
|
||||
- **修法**:加 `resolve(key)` 把兜底收进模块(§5.7.5),调用点只写 `skillCatalog.resolve(key)`
|
||||
- **为什么是 P1 而不是 P2**:这 5 处是**将来删除静态目录时必然踩到的地雷** —— 漏掉任何一处不会有任何提示
|
||||
|
||||
#### 8.12-3 【登记,不强制回改】`specialist` 是裸名,同位对象却带 `_definition`
|
||||
|
||||
`specialist` 与 `skill_definition` / `xapp_definition` / `action_definition` 是同级对象,命名却不同族。
|
||||
按 §6.4 的 P3 纪律:**先登记,不顺手改**(改表名牵动迁移与全部引用,收益不抵成本)。
|
||||
|
||||
#### 8.12-4 【P3】`projectStore.js` 是 12 个 store 里唯一带 `Store` 后缀的
|
||||
|
||||
`frontend/src/store/` 下 12 个文件,只有 `projectStore.js` 带后缀(Pinia 的 `useProjectStore` 已经在导出名上表达了这层意思)。
|
||||
→ 改名 `project.js`,与 `xappCatalog.js` / `skillCatalog.js` 等同构。
|
||||
|
||||
#### 8.12-5 【P3】`docs/10-eaiintro/` 与其余 7 个目录不同构,且混入 Office 锁文件
|
||||
|
||||
- **目录名**:`10-eaiintro` 用连字符且无文档前缀,而其余是 `01_System_Overall/`(SY)、`02_Architecture/`(AR)…… `09_Research/`(RS)
|
||||
- **内容**:27 个跟踪文件里混着 pptx / jpg 等**二进制素材**,性质上不是"文档目录"而是"素材目录"
|
||||
- **顺带发现**:`~$梅奥心磁_….pptx` 等 **3 个 Office 锁文件被 git 跟踪了**(`~$` 是 Office 打开文件时产生的临时锁文件,本不该入库)
|
||||
- **建议**:先决定它是"文档目录"还是"素材目录" —— 前者按 `11_<Name>/` 纳入编号体系并配文档前缀,后者移出 `docs/`;锁文件从版本库删除并加进 `.gitignore`
|
||||
|
||||
#### 8.12-6 【09-18 已收口】工作台主对话接口已统一为 `chat/message`
|
||||
|
||||
当前前端接口文件是 `api/chatMessage.js`,主入口是 `sendChatMessage`,后端对应 `POST /api/chat/message`。
|
||||
|
||||
- 默认助手语义仍只存在于 `smart-assistant` / `general-assistant` 这组默认对象身份里
|
||||
- 工作台主对话入口则使用中性命名 `chat/message`
|
||||
- 因此,`assistant` 现在是**对象身份语义**,不是**主对话 API 前缀**
|
||||
|
||||
后续若再新增聊天接口,也应守住这条分层:对象身份不要回流到总入口路径名。
|
||||
|
||||
#### 8.12-7 【已合规,保持】`my_` 停留在 API 层
|
||||
|
||||
- API:`api/my_xapp_center.go`、`/api/my/xapp-center`、`/api/my/tasks`
|
||||
- 模型:`model/user_xapp_center.go`(不是 `my_`)
|
||||
|
||||
这正是 §5.7.4 第 7 条要求的分层,**现状正确,不要"统一"成 `my_`**。
|
||||
|
||||
### 8.13 【P1|09-18 已修】入口协议已抽成共享常量
|
||||
|
||||
§8.1 的功能和根因都已修:`xappCatalog.js` 与 `SmartAssistantPage.vue` 现通过 `frontend/src/config/objectEntry.js` 共享 `XAPP_ENTRY_QUERY_KEYS`,不再各写各的 `'xapp_specialist'` / `'xapp_skill'` / `'xapp_prompt'` 字面量。
|
||||
|
||||
- **收益**:入口协议改名只需要动一处
|
||||
- **守则**:能用结构消除的检查,不要靠扫描补漏
|
||||
|
||||
---
|
||||
|
||||
## 九、反例库(真实事故)
|
||||
@@ -468,6 +743,11 @@ export const ENTRY_PARAMS = {
|
||||
| `contract-review` 查出来两条不同对象的记录 | 同一字符串被两个对象共用 | 共名要登记 + 用测试守住 |
|
||||
| 差点把 ERP 的 `FCustId` "美化"成 `FCustId`→`CustID` | 误以为一致性高于一切 | 外部标准优先级最高,照抄 |
|
||||
| 文档里写 `file:///home/<用户名>/...` 链接 | 本机可点,他人全断 | 文档引用一律相对路径 |
|
||||
| `skill_definition` 一个字段先后有 **5 代列名**,最后一代叫 `legacy_object_entry_route` | 上一次迁移没有退出条件,只好再叠一层 `legacy_` | 过渡前缀必须写退出条件,且**禁止嵌套**(§5.7.6) |
|
||||
| 同一个兜底判断在 5 个调用点被抄了 5 遍 | `static` 前缀泄漏到调用点,调用方被迫知道数据来路 | 来源前缀不出模块,收进 `resolve()`(§5.7.5) |
|
||||
| `sed 's/role_/object_/g'` 会把 `role_card_json` 误伤成 `object_card_json` | 前缀改名用了子串替换 | 逐标识符精确锚定,**禁止通配**(§7.6②) |
|
||||
| 3 个 `~$*.pptx` Office 锁文件进了版本库 | 素材与文档混放,无 `.gitignore` 守卫 | 目录按性质分离;临时前缀文件不入库(§8.12-5) |
|
||||
| 差点把 `businessApps` "改名"成 `staticSpecialistCatalog` | 只看了名字难看,没数引用数 | 改还是删,**看引用数**(§6.4 / §8.9) |
|
||||
|
||||
---
|
||||
|
||||
@@ -476,6 +756,7 @@ export const ENTRY_PARAMS = {
|
||||
| 版本 | 日期 | 变更 |
|
||||
|---|---|---|
|
||||
| V1.0 | 2026-09-17 | 首版。原理 / 判据 / 术语表 / 分层规范 / 模式库 / 检查 / 修复 / 现状 / 反例库 |
|
||||
| V1.1 | 2026-09-18 | ① 新增 **§5.7 前缀规范**(原理 / 三类前缀 / 两条已收敛规律 / 硬约束 / 泄漏 / 退出条件 / 分隔符);② §6.2 新增守卫 **G–J**;③ 新增 **§7.6 前缀改名的额外要求**(字符串 grep、禁止子串替换、无兼容窗口的例外);④ **§8 全面刷新** —— §8.1–8.10 已在 `d0d7b35` 执行完毕,逐条标注『现状』,新增 §8.12(前缀维度)与 §8.13(入口协议未根治);⑤ §1.2 / §3.2 / §4.2 / §4.3 / §4.4 / §6.3 / §6.4 同步当下事实;⑥ §9 反例库补 5 条前缀类事故;⑦ 同步 `TOP_CODING_RULES.md` V1.2 —— 该文件 G03 新增第 5-10 条承载**同样的硬约束**,本文件 §5.7 是其完整展开,两者互为详略 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
# AR10 XApp Removable Packaging Specification
|
||||
|
||||
> **版本**:V1.2
|
||||
> **日期**:2026-09-18
|
||||
> **性质**:规范性文件(normative)
|
||||
> **适用对象**:`xapp`,即一级业务包
|
||||
> **关联文档**:
|
||||
> - `AR05_Workbench_Architecture_Contract.md`
|
||||
> - `AR09_Object_Naming_Standard.md`
|
||||
> - `AR11_Skill_Specialist_Connector_Removable_Packaging_Specification.md`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本文定义 `xapp` 的封装方式,目标不是“前端入口看起来像 APP”,而是让一个 `xapp` 在工程上达到如下删除标准:
|
||||
|
||||
1. 删除一个后端目录
|
||||
2. 删除一个前端目录
|
||||
3. 删除一条注册项
|
||||
4. 不需要全仓手工搜改业务代码
|
||||
5. 重新构建后不出现编译错误
|
||||
6. 运行卸载脚本后,不残留该 `xapp` 自有表、路由、菜单、任务、通知、前端状态
|
||||
|
||||
本文中的“可删除”指:
|
||||
|
||||
- 删一个目录 + 删一条注册项 + 跑一次卸载 = 安全移除一个 `xapp`
|
||||
|
||||
严格意义上,“删一个文件就彻底删除”只适用于插件二进制或代码生成包;在当前单仓工程内,合理目标应为“删一个目录”。
|
||||
|
||||
## 2. 范围与非目标
|
||||
|
||||
### 2.1 本文覆盖
|
||||
|
||||
本文覆盖 `xapp` 的以下封装维度:
|
||||
|
||||
1. manifest
|
||||
2. registry
|
||||
3. 前后端路由注册
|
||||
4. provider 暴露
|
||||
5. 数据边界
|
||||
6. schema 与 seed
|
||||
7. 卸载协议
|
||||
8. 定义条目所有权
|
||||
9. 共享引用降级协议
|
||||
10. 守卫与验收标准
|
||||
|
||||
### 2.2 本文不覆盖
|
||||
|
||||
本文不讨论以下内容的具体业务实现:
|
||||
|
||||
1. 某个 `xapp` 内部页面应该长什么样
|
||||
2. 某个 `xapp` 的领域模型如何详细设计
|
||||
3. 某个 `xapp` 的 UI 风格如何命名
|
||||
|
||||
这些属于对象内部实现,不属于封装边界规范本身。
|
||||
|
||||
## 3. 当前问题
|
||||
|
||||
当前仓库已经存在 `xapp_definition`、前端 `/xapps/...` 路由以及若干 `XAppShell`,但多数业务 `xapp` 仍停留在“入口像 xapp,底层仍是平台散装模块”的阶段。典型表现如下:
|
||||
|
||||
1. 平台总路由中仍手写某个 `xapp` 的业务路由
|
||||
2. 平台总导航中仍手写某个 `xapp` 的菜单
|
||||
3. 平台层 API 直接查询某个 `xapp` 的表
|
||||
4. `xapp` 的核心对象仍放在通用 `model` 包,而非 `xapp` 自己的域包
|
||||
5. 持久化模型直接兼任 API DTO 和领域对象
|
||||
6. 统计、画像、后台管理直接耦合业务表,而不是通过 provider 聚合
|
||||
7. `xapp` 的任务、通知、缓存、前端状态未形成 own 命名空间
|
||||
8. `xapp_definition`、目录条目、seed 定义记录等定义层资源尚未进入卸载协议
|
||||
9. 历史任务、收藏、通知历史等共享引用在对象删除后的处理策略未定义
|
||||
|
||||
这意味着 `xapp` 仍然不是一个可插拔业务包,而只是“挂了 xapp 皮肤的并行模块”。
|
||||
|
||||
## 4. 定义
|
||||
|
||||
### 4.1 什么是 xapp
|
||||
|
||||
在本项目中,`xapp` 的定义是:
|
||||
|
||||
1. 一个可注册的一级业务包
|
||||
2. 一个有 manifest 的对象
|
||||
3. 一个有自有 schema 的领域边界
|
||||
4. 一个只通过 contract 暴露能力的模块
|
||||
5. 一个可以通过“删目录 + 删注册 + 跑卸载”安全移除的工程单元
|
||||
|
||||
### 4.2 什么不是 xapp
|
||||
|
||||
以下对象不应按 `xapp` 方式建模:
|
||||
|
||||
1. 单个技能
|
||||
2. 单个专员
|
||||
3. 单个连接器
|
||||
4. 一张单页配置页
|
||||
5. 一个普通 store 或 API 文件
|
||||
|
||||
这些对象的封装规则见 `AR11`。
|
||||
|
||||
## 5. 封装目标
|
||||
|
||||
每个 `xapp` 必须完整拥有以下六类内容:
|
||||
|
||||
1. Manifest
|
||||
2. Frontend shell
|
||||
3. Backend module
|
||||
4. Domain model / repo / service
|
||||
5. Owned schema
|
||||
6. Integration adapters
|
||||
|
||||
平台层只负责:
|
||||
|
||||
1. 注册 `xapp`
|
||||
2. 聚合 `xapp` 暴露的能力
|
||||
3. 为 `xapp` 提供公共底座能力
|
||||
|
||||
平台层不负责:
|
||||
|
||||
1. 直接进入 `xapp` 内部目录取对象
|
||||
2. 直接查询 `xapp` 自有表
|
||||
3. 直接 hardcode 某个 `xapp` 的页面、菜单、统计、通知
|
||||
4. 直接依赖 `xapp` 内部 repo / service / views
|
||||
|
||||
## 6. 依赖方向
|
||||
|
||||
`xapp` 封装能否成立,核心不在于目录是否漂亮,而在于依赖方向是否单一。
|
||||
|
||||
正确依赖方向应为:
|
||||
|
||||
```text
|
||||
platform core
|
||||
-> xapps/core/contracts
|
||||
-> xapps/core/registry
|
||||
-> xapp manifest
|
||||
-> xapp module install / register
|
||||
|
||||
xapp internal
|
||||
-> xapp domain / repo / service / dto / schema
|
||||
```
|
||||
|
||||
错误依赖方向包括:
|
||||
|
||||
1. `platform api -> xapp repo`
|
||||
2. `platform stats -> xapp table`
|
||||
3. `platform nav -> xapp views path literal`
|
||||
4. `platform seed -> xapp domain object`
|
||||
|
||||
一句话:**平台层可以认识“这个 xapp 存在”,但不能认识“这个 xapp 里面具体有什么文件”。**
|
||||
|
||||
## 7. 推荐目录结构
|
||||
|
||||
### 7.1 后端
|
||||
|
||||
```text
|
||||
backend-go/internal/xapps/
|
||||
core/
|
||||
contracts.go
|
||||
manifest.go
|
||||
registry.go
|
||||
uninstall.go
|
||||
apps/
|
||||
internal_exam/
|
||||
manifest.go
|
||||
module.go
|
||||
api/
|
||||
student.go
|
||||
admin.go
|
||||
domain/
|
||||
paper.go
|
||||
record.go
|
||||
mistake.go
|
||||
repo/
|
||||
paper_repo.go
|
||||
record_repo.go
|
||||
mistake_repo.go
|
||||
service/
|
||||
exam_service.go
|
||||
stats_service.go
|
||||
dto/
|
||||
request.go
|
||||
response.go
|
||||
schema/
|
||||
migrations.go
|
||||
uninstall.go
|
||||
assets/
|
||||
tests/
|
||||
internal_training/
|
||||
manifest.go
|
||||
module.go
|
||||
api/
|
||||
domain/
|
||||
repo/
|
||||
service/
|
||||
dto/
|
||||
schema/
|
||||
assets/
|
||||
tests/
|
||||
```
|
||||
|
||||
### 7.2 前端
|
||||
|
||||
```text
|
||||
frontend/src/xapps/
|
||||
core/
|
||||
contracts.js
|
||||
registry.js
|
||||
installer.js
|
||||
uninstall.js
|
||||
apps/
|
||||
internal-exam/
|
||||
manifest.js
|
||||
routes.js
|
||||
nav.js
|
||||
providers.js
|
||||
api/
|
||||
store/
|
||||
views/
|
||||
components/
|
||||
assets/
|
||||
internal-training/
|
||||
manifest.js
|
||||
routes.js
|
||||
nav.js
|
||||
providers.js
|
||||
api/
|
||||
store/
|
||||
views/
|
||||
components/
|
||||
assets/
|
||||
```
|
||||
|
||||
## 8. 核心硬规则
|
||||
|
||||
### 8.0 一对象一目录是最终形态
|
||||
|
||||
对 `xapp` 而言,最终封装形态必须满足:
|
||||
|
||||
1. 一个 `xapp` 对应一个后端目录
|
||||
2. 一个 `xapp` 对应一个前端目录
|
||||
3. 该 `xapp` 的业务定义、路由定义、provider 定义、schema 定义都落在自己的目录中
|
||||
|
||||
因此:
|
||||
|
||||
1. **允许中心化注册**
|
||||
2. **不允许中心化定义**
|
||||
|
||||
这里的“中心化注册”指:
|
||||
|
||||
1. registry 统一列出有哪些 `xapp manifest`
|
||||
2. installer 统一执行 install / uninstall
|
||||
3. platform core 统一聚合 provider
|
||||
|
||||
这里的“中心化定义”指:
|
||||
|
||||
1. 在一个平台公共文件里手写多个 `xapp` 的业务路由
|
||||
2. 在一个平台公共文件里手写多个 `xapp` 的菜单项
|
||||
3. 在一个平台公共文件里手写多个 `xapp` 的 schema / provider / page path
|
||||
|
||||
前者是允许的,后者不是最终封装形态。
|
||||
|
||||
### 8.0.1 允许中心化 / 禁止中心化
|
||||
|
||||
| 类型 | 是否允许 | 说明 |
|
||||
|---|---|---|
|
||||
| `xapps/core/registry` 集中列出 manifest | 允许 | 这是注册中心,不是定义中心 |
|
||||
| `xapps/core/installer` 统一安装路由和导航 | 允许 | 这是装配层 |
|
||||
| 平台总路由手写 `internal-exam` 业务路径 | 禁止 | 这是业务定义泄漏 |
|
||||
| 平台总导航手写 `internal-training` 菜单细节 | 禁止 | 这是业务定义泄漏 |
|
||||
| 平台公共文件集中维护多个 `xapp` 的 page path / provider key / table 名 | 禁止 | 这会破坏目录级删除 |
|
||||
|
||||
### 8.1 平台层不能直接 import xapp 内部实现
|
||||
|
||||
平台层只能依赖:
|
||||
|
||||
1. `xapps/core/registry`
|
||||
2. `manifest`
|
||||
3. `contracts/interface`
|
||||
|
||||
平台层不能依赖:
|
||||
|
||||
1. `internal_exam/domain/*`
|
||||
2. `internal_exam/repo/*`
|
||||
3. `internal_exam/service/*`
|
||||
4. `internal_exam/views/*`
|
||||
5. 任何其他 `xapp` 内部文件
|
||||
|
||||
这条规则的目的,是保证删除某个 `xapp` 目录后,不会炸掉全局依赖图。
|
||||
|
||||
### 8.2 路由必须由 xapp 自己注册
|
||||
|
||||
总路由不得手写某个 `xapp` 的业务路径,例如:
|
||||
|
||||
1. `/api/exam/*`
|
||||
2. `/xapps/internal-exam/*`
|
||||
3. `/courses/*`
|
||||
4. `/products/*`
|
||||
|
||||
正确做法:
|
||||
|
||||
1. 平台加载 `xapp registry`
|
||||
2. `xapp` 在 `module.go` / `manifest.js` 中自注册前后端路由
|
||||
|
||||
后端示意:
|
||||
|
||||
```go
|
||||
xappRegistry.Register(internalexam.Manifest)
|
||||
```
|
||||
|
||||
前端示意:
|
||||
|
||||
```js
|
||||
xappRegistry.register(internalExamManifest)
|
||||
```
|
||||
|
||||
### 8.3 数据表必须归属到 xapp 命名空间
|
||||
|
||||
为了实现“删目录即可删表”,表名必须体现归属。
|
||||
|
||||
不推荐:
|
||||
|
||||
1. `exam_paper`
|
||||
2. `exam_record`
|
||||
3. `mistake_record`
|
||||
4. `learning_progress`
|
||||
|
||||
推荐:
|
||||
|
||||
1. `xapp_exam_paper`
|
||||
2. `xapp_exam_record`
|
||||
3. `xapp_exam_mistake`
|
||||
4. `xapp_training_progress`
|
||||
|
||||
如果某张表是平台共享能力,而不是某个 `xapp` 私有数据,则应放入 shared 域,不允许伪装成 `xapp` 私有对象。
|
||||
|
||||
### 8.4 核心对象不能继续停留在通用 model 总包
|
||||
|
||||
`xapp` 私有业务对象必须放入自己的域目录,例如:
|
||||
|
||||
```text
|
||||
internal/xapps/apps/internal_exam/domain/
|
||||
```
|
||||
|
||||
平台 `internal/model` 中只保留:
|
||||
|
||||
1. 平台公共对象
|
||||
2. 跨 `xapp` 共享对象
|
||||
3. 公共底座对象
|
||||
|
||||
### 8.5 持久化模型不能直接充当 API DTO
|
||||
|
||||
必须分层:
|
||||
|
||||
1. `domain`:领域对象
|
||||
2. `repo entity`:持久化对象
|
||||
3. `dto request/response`:接口契约
|
||||
|
||||
禁止继续使用如下模式:
|
||||
|
||||
1. `ShouldBindJSON(&model.ExamPaper{})`
|
||||
2. API 直接返回 ORM entity
|
||||
3. 统计逻辑直接依赖表字段细节
|
||||
|
||||
### 8.6 统计、画像、后台管理只能通过 provider 聚合
|
||||
|
||||
平台层不得直接查询某个 `xapp` 的私有表来构建:
|
||||
|
||||
1. 首页统计
|
||||
2. 学员画像
|
||||
3. 部门统计
|
||||
4. 后台管理报表
|
||||
|
||||
应改为由 `xapp` 对外暴露 provider:
|
||||
|
||||
```go
|
||||
type XAppStatsProvider interface {
|
||||
BuildAdminStats(ctx context.Context) (any, error)
|
||||
}
|
||||
|
||||
type XAppProfileProvider interface {
|
||||
BuildUserProfile(ctx context.Context, userID uint) (any, error)
|
||||
}
|
||||
```
|
||||
|
||||
平台只聚合 provider 输出,不碰 `xapp` 内部表结构。
|
||||
|
||||
### 8.7 菜单、通知、任务、前端状态必须归 xapp 自己管理
|
||||
|
||||
不得把以下内容散落在平台全局文件中:
|
||||
|
||||
1. 导航项
|
||||
2. 页面路由 path 判断
|
||||
3. 通知跳转链接
|
||||
4. 定时任务
|
||||
5. seed 数据入口
|
||||
6. localStorage key
|
||||
7. 前端 layout 的特殊 case
|
||||
|
||||
这些都必须由 `xapp manifest` 暴露。
|
||||
|
||||
### 8.8 定义条目也属于 xapp 的 own boundary
|
||||
|
||||
`xapp` 的封装边界不仅包括代码、路由、表和缓存,也包括“定义层资源”。
|
||||
|
||||
定义层资源至少包括:
|
||||
|
||||
1. `xapp_definition` 中属于该 `xapp` 的定义记录
|
||||
2. 对象目录、市场目录、对象中心中的条目
|
||||
3. seed 自动生成的对象定义记录
|
||||
4. 前端目录页、治理页中依赖定义中心生成的条目
|
||||
|
||||
如果一个 `xapp` 删除后,这些定义条目仍然残留,那么:
|
||||
|
||||
1. 用户仍可能看到僵尸入口
|
||||
2. 后台仍可能显示无效定义
|
||||
3. 平台仍可能尝试按已删除对象做跳转或查询
|
||||
|
||||
因此,定义条目必须被视为 `owned resource`,纳入 manifest 和卸载协议。
|
||||
|
||||
## 9. Manifest 与 Registry 设计
|
||||
|
||||
## 9.1 后端 manifest
|
||||
|
||||
```go
|
||||
type XAppManifest interface {
|
||||
Key() string
|
||||
Meta() XAppMeta
|
||||
RegisterRoutes(r gin.IRouter)
|
||||
RegisterProviders(reg ProviderRegistry)
|
||||
RegisterMigrations(reg MigrationRegistry)
|
||||
RegisterSeeds(reg SeedRegistry)
|
||||
RegisterJobs(reg JobRegistry)
|
||||
RegisterNotifications(reg NotificationRegistry)
|
||||
OwnedTables() []string
|
||||
OwnedStorageKeys() []string
|
||||
OwnedJobKeys() []string
|
||||
OwnedNotificationKeys() []string
|
||||
OwnedDefinitionKeys() []string
|
||||
OwnedCatalogEntries() []string
|
||||
SharedReferencePolicy() SharedReferencePolicy
|
||||
}
|
||||
```
|
||||
|
||||
后端 manifest 至少负责:
|
||||
|
||||
1. 路由注册
|
||||
2. provider 注册
|
||||
3. migration 注册
|
||||
4. seed 注册
|
||||
5. job 注册
|
||||
6. 通知注册
|
||||
7. 定义条目归属声明
|
||||
8. 共享引用处理策略声明
|
||||
|
||||
### 9.2 前端 manifest
|
||||
|
||||
```js
|
||||
export default {
|
||||
key: 'internal-exam',
|
||||
label: '内部考试APP',
|
||||
baseRoute: '/xapps/internal-exam',
|
||||
install({ navRegistry, routeRegistry, providerRegistry, storeRegistry }) {},
|
||||
uninstallMeta: {
|
||||
ownedStorageKeys: ['xapp:internal-exam:*'],
|
||||
ownedRouteNames: ['InternalExam*'],
|
||||
ownedNavKeys: ['xapp.internal-exam.*'],
|
||||
ownedDefinitionKeys: ['xapp.internal-exam.definition'],
|
||||
ownedCatalogEntries: ['catalog.xapp.internal-exam'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
前端 manifest 至少负责:
|
||||
|
||||
1. 页面路由注册
|
||||
2. 菜单注册
|
||||
3. store 注册
|
||||
4. provider 注册
|
||||
5. 前端状态资源声明
|
||||
6. 前端定义条目资源声明
|
||||
|
||||
### 9.3 Registry 的职责
|
||||
|
||||
registry 只做两件事:
|
||||
|
||||
1. 收集 manifest
|
||||
2. 统一安装 / 卸载 / 枚举
|
||||
|
||||
registry 不应承担:
|
||||
|
||||
1. 业务逻辑
|
||||
2. repo 查询
|
||||
3. 页面渲染
|
||||
4. 领域对象转换
|
||||
|
||||
## 10. 卸载协议
|
||||
|
||||
“可删除”不是只删代码,还必须定义资源清理协议。
|
||||
|
||||
### 10.1 卸载输入
|
||||
|
||||
卸载协议至少应支持以下输入:
|
||||
|
||||
1. `xapp key`
|
||||
2. `dry_run`
|
||||
3. `drop_tables`
|
||||
4. `clear_storage`
|
||||
5. `clear_jobs`
|
||||
6. `clear_notifications`
|
||||
|
||||
### 10.2 卸载输出
|
||||
|
||||
卸载结果至少应返回:
|
||||
|
||||
1. 实际清理的表
|
||||
2. 实际清理的 job key
|
||||
3. 实际清理的 notification key
|
||||
4. 实际清理的 storage key
|
||||
5. 未清理成功的残留项
|
||||
6. 实际清理的定义条目
|
||||
7. 被共享引用阻塞或降级处理的项
|
||||
|
||||
### 10.3 卸载原则
|
||||
|
||||
卸载必须遵守以下原则:
|
||||
|
||||
1. 只清理 manifest 声明过的 owned 资源
|
||||
2. 默认支持 `dry_run`
|
||||
3. 禁止越权删除共享资源
|
||||
4. 共享表必须由 shared 域自己维护,不得被 xapp 卸载误删
|
||||
|
||||
### 10.4 定义条目清理协议
|
||||
|
||||
卸载 `xapp` 时,必须同时清理或冻结以下定义层资源:
|
||||
|
||||
1. `xapp_definition` 中的定义记录
|
||||
2. 对象目录或市场目录中的条目
|
||||
3. seed 自动生成的默认定义条目
|
||||
|
||||
允许的处理方式包括:
|
||||
|
||||
1. 直接删除
|
||||
2. 标记为 tombstone
|
||||
3. 标记为 disabled 且不再对用户可见
|
||||
|
||||
但无论采用哪种方式,都必须保证:
|
||||
|
||||
1. 平台不会再把它当成可安装、可进入、可查询的有效 `xapp`
|
||||
2. 前后端目录与导航中不再出现死入口
|
||||
|
||||
### 10.5 Shared Reference Policy
|
||||
|
||||
`xapp` 删除时,除 own resource 外,还必须处理共享域对它的历史引用。
|
||||
|
||||
典型共享引用包括:
|
||||
|
||||
1. 历史任务记录
|
||||
2. 项目绑定关系
|
||||
3. 收藏 / 最近使用
|
||||
4. 通知历史
|
||||
5. 审计日志
|
||||
6. 历史发布版本
|
||||
|
||||
规范允许以下四类处理策略:
|
||||
|
||||
1. `block_uninstall`:仍存在强引用时禁止卸载
|
||||
2. `convert_to_tombstone`:转为“对象已移除”墓碑态
|
||||
3. `detach_reference`:解除引用但保留历史记录
|
||||
4. `readonly_history`:保留只读历史,不允许继续进入对象
|
||||
|
||||
`xapp manifest` 必须声明自己的共享引用处理策略,平台卸载器必须在 `dry_run` 结果中明确报告:
|
||||
|
||||
1. 哪些引用会被阻塞
|
||||
2. 哪些引用会被降级
|
||||
3. 哪些引用会被直接解除
|
||||
|
||||
### 10.6 Dry-run Gate 与执行守卫
|
||||
|
||||
卸载协议不仅要能执行,还必须能被平台守卫。
|
||||
|
||||
至少应具备以下守卫:
|
||||
|
||||
1. uninstall `dry_run` 报告
|
||||
2. owned resource audit
|
||||
3. 定义条目完整性检查
|
||||
4. 共享引用阻塞检查
|
||||
5. CI 中的封装边界检查
|
||||
|
||||
## 11. Owned 资源矩阵
|
||||
|
||||
一个 `xapp` 的 own boundary 至少应覆盖以下资源:
|
||||
|
||||
| 资源类型 | 是否必须声明 | 典型例子 |
|
||||
|---|---|---|
|
||||
| 后端目录 | 必须 | `backend-go/internal/xapps/apps/internal_exam/` |
|
||||
| 前端目录 | 必须 | `frontend/src/xapps/apps/internal-exam/` |
|
||||
| 后端路由 | 必须 | `/api/xapps/internal-exam/*` |
|
||||
| 前端路由 | 必须 | `/xapps/internal-exam/*` |
|
||||
| owned tables | 必须 | `xapp_exam_record` |
|
||||
| owned storage keys | 必须 | `xapp:internal-exam:*` |
|
||||
| definition keys | 必须 | `xapp.internal-exam.definition` |
|
||||
| catalog entries | 必须 | `catalog.xapp.internal-exam` |
|
||||
| provider keys | 必须 | `xapp.internal-exam.stats` |
|
||||
| job keys | 视需要 | `xapp.internal-exam.daily-sync` |
|
||||
| notification keys | 视需要 | `xapp.internal-exam.record-published` |
|
||||
| nav keys | 必须 | `xapp.internal-exam.entry` |
|
||||
|
||||
## 12. 当前培训 / 考试域的拆分建议
|
||||
|
||||
## 12.1 应进入 `internal_exam` xapp 的内容
|
||||
|
||||
应归入:
|
||||
|
||||
1. 考试配置
|
||||
2. 考试记录
|
||||
3. 错题本
|
||||
4. 发证逻辑
|
||||
5. 学员考试流程
|
||||
6. 考试统计 provider
|
||||
7. 考试后台管理接口
|
||||
8. 考试前端 views / api / store
|
||||
|
||||
当前对象对应:
|
||||
|
||||
1. `ExamPaper`
|
||||
2. `ExamRecord`
|
||||
3. `MistakeRecord`
|
||||
|
||||
## 12.2 应进入 `internal_training` xapp 的内容
|
||||
|
||||
应归入:
|
||||
|
||||
1. 课程
|
||||
2. 产品知识
|
||||
3. 公司介绍
|
||||
4. 培训入口
|
||||
5. 培训前端 views / api / store
|
||||
6. 培训进度统计 provider
|
||||
|
||||
## 12.3 `LearningProgress` 的边界
|
||||
|
||||
`LearningProgress` 当前记录的是:
|
||||
|
||||
1. `company`
|
||||
2. `product`
|
||||
3. `course`
|
||||
|
||||
因此它不应进入 `internal_exam`。
|
||||
|
||||
它有两种合理归属:
|
||||
|
||||
1. 归入 `internal_training`
|
||||
2. 归入 shared learning 域
|
||||
|
||||
选择标准如下:
|
||||
|
||||
1. 如果未来只有培训 `xapp` 使用,则归 `internal_training`
|
||||
2. 如果未来知识库、认证、课程中心等多个 `xapp` 都会消费,则归 shared learning
|
||||
|
||||
## 13. 迁移顺序
|
||||
|
||||
推荐按以下顺序实施,避免边搬边炸:
|
||||
|
||||
1. 先建立 `xapps/core/contracts`
|
||||
2. 建立 registry
|
||||
3. 让 `exam / training` 先改为 manifest 注册
|
||||
4. 将平台直查表的逻辑改为 provider 调用
|
||||
5. 再搬迁 `domain / repo / service`
|
||||
6. 再把 DTO 与持久化对象分层
|
||||
7. 最后再改表名命名空间
|
||||
8. 最后补卸载脚本
|
||||
|
||||
这个顺序的核心原因是:
|
||||
|
||||
1. 先修正依赖方向
|
||||
2. 再修正代码落点
|
||||
3. 再修正对象边界
|
||||
4. 最后修正资源所有权
|
||||
|
||||
## 14. 判定红线
|
||||
|
||||
如果一个 `xapp` 还存在以下任意一条,则不算完成封装:
|
||||
|
||||
1. 总路由中仍手写它的业务路径
|
||||
2. 总导航中仍手写它的业务菜单
|
||||
3. 平台 `api/*.go` 仍直接查询它的私有表
|
||||
4. 平台 `model` 总包仍承载它的核心业务对象
|
||||
5. 别的模块还能直接 import 它的内部包
|
||||
6. 数据表名未进入它 own 的命名空间
|
||||
7. 统计、画像、后台管理未通过 provider 输出
|
||||
8. 前端主 layout 仍有针对它的硬编码 path 判断
|
||||
9. 卸载协议无法枚举它的 owned 资源
|
||||
10. 删除目录后仍需手工搜索多处散装注册点
|
||||
11. 一个公共定义文件里仍集中维护多个 `xapp` 的业务路由、菜单、provider、schema 或 path
|
||||
12. `xapp_definition`、目录条目或 seed 定义记录未进入 own boundary
|
||||
13. 历史任务、收藏、通知历史等共享引用没有明确降级策略
|
||||
14. 缺少 `dry_run`、边界检查或资源审计守卫
|
||||
|
||||
### 14.1 迁移期兼容层的限制
|
||||
|
||||
本规范允许迁移期存在少量兼容层,但必须满足以下条件:
|
||||
|
||||
1. 兼容层只能做转发、注册、兼容映射
|
||||
2. 兼容层不得继续承载多个 `xapp` 的业务定义
|
||||
3. 兼容层必须有明确退场目标,不能成为长期正式结构
|
||||
|
||||
换句话说:
|
||||
|
||||
1. `registry` 可以中心化
|
||||
2. `compat adapter` 可以暂存
|
||||
3. `definition hub` 不允许长期存在
|
||||
|
||||
### 14.2 Enforcement / Guard
|
||||
|
||||
为了防止封装边界回退,平台至少应建立以下自动守卫:
|
||||
|
||||
1. import boundary lint:禁止平台层直接 import `xapp` 内部实现
|
||||
2. registry completeness check:已注册对象必须能完整枚举 own 资源
|
||||
3. owned resource audit:检查 manifest 声明和实际资源是否一致
|
||||
4. uninstall dry-run gate:卸载前必须可生成风险报告
|
||||
5. CI fail 条件:若出现未声明资源、未处理共享引用或越权依赖,则直接失败
|
||||
|
||||
## 15. 验收清单
|
||||
|
||||
一个 `xapp` 只有满足以下条件,才算达到“整包可删除”:
|
||||
|
||||
1. 删除 `backend-go/internal/xapps/apps/<xapp>/`
|
||||
2. 删除 `frontend/src/xapps/apps/<xapp>/`
|
||||
3. 删除 registry 里的注册项
|
||||
4. 后端构建通过
|
||||
5. 前端构建通过
|
||||
6. 没有平台层 import 残留
|
||||
7. 没有平台级 hardcoded path 残留
|
||||
8. 运行卸载脚本后,该 `xapp` 的 owned tables 被清理
|
||||
9. 前端导航、页面、缓存、localStorage 全部消失
|
||||
10. 平台统计只少这一个 `xapp` 的 provider 结果,不出现空指针或编译错误
|
||||
11. job / notification / provider key 不残留
|
||||
12. 共享域不被误删
|
||||
13. `xapp_definition`、目录条目、seed 定义条目不会残留死入口
|
||||
14. `dry_run` 报告能正确列出 shared reference 的阻塞与降级结果
|
||||
|
||||
## 16. 最终标准
|
||||
|
||||
`xapp` 的最终定义应为:
|
||||
|
||||
1. 一个可注册的业务包
|
||||
2. 一个有 manifest 的对象
|
||||
3. 一个有自有 schema 的领域边界
|
||||
4. 一个只通过 contract 暴露能力的模块
|
||||
5. 一个可以通过“删目录 + 删注册 + 跑卸载”安全移除的工程单元
|
||||
|
||||
只有达到这个标准,`xapp` 才不是“挂在平台上的专题页面”,而是真正的一级对象。
|
||||
+1179
File diff suppressed because it is too large
Load Diff
@@ -23,3 +23,5 @@
|
||||
| `AR07_Architecture_Alignment_Audit.md` | 架构对齐确认与本轮修复范围(收敛两套并行模型的确认记录) |
|
||||
| `AR08_Role_Interaction_Design.md` | AI 角色与工具统一交互设计(**部分被取代**:不跳页结论已采纳,「数字技术员」对象已废弃,见文首补注) |
|
||||
| `AR09_Object_Naming_Standard.md` | 对象命名规范(**规范性文件**:原理 / 判据 / 术语表 / 分层规范 / 检查守卫 / 修复流程 / 现状问题登记 / 反例库) |
|
||||
| `AR10_XApp_Removable_Packaging_Specification.md` | XApp 可删除封装规范(面向一级业务包,目标是“删目录 + 删注册 + 跑卸载”) |
|
||||
| `AR11_Skill_Specialist_Connector_Removable_Packaging_Specification.md` | Skill / Specialist / Connector 可删除封装规范(复用 AR10 思想,但区分能力包、策略定义包、连接插件包的边界) |
|
||||
|
||||
@@ -12,7 +12,7 @@ pj034 的 AI 系统已演进为「多层、多公司、多租户」的完整运
|
||||
**移植范围(保留)**:
|
||||
1. AI 路由/Provider/密钥配置文件体系(`ai_config.json` + `ai_secrets.json`)——已落地
|
||||
2. Agent → 路由映射、默认路由、回退链(fallback)
|
||||
3. **按用户算力点计费**:每次 AI 调用按能力扣点 + 写调用日志
|
||||
3. **按用户算力点计费**:每次 AI 调用按用量类型扣点 + 写调用日志
|
||||
4. AI 配置管理 API(读/写/热重载/密钥状态)+ 用量查询 API
|
||||
5. 前端:AI 配置 UI + AI 用量看板 + 用户剩余点数展示
|
||||
|
||||
@@ -58,7 +58,7 @@ pj034 的 AI 系统已演进为「多层、多公司、多租户」的完整运
|
||||
| `company_id` | int | 公司维度 | ❌ 单公司,删除 |
|
||||
| `user_id` | int | 调用人 | ✅ |
|
||||
| `provider` | enum | provider | ✅ |
|
||||
| `capability` | enum | AI 能力 | ✅ |
|
||||
| `usage_kind` | enum | AI 用量类型 | ✅ |
|
||||
| `input_asset_id` / `output_asset_id` | int | 电商素材 | ❌ |
|
||||
| `ai_route_id` | str | AI 路由 ID | ✅ |
|
||||
| `model_id` | str | 模型 | ✅ |
|
||||
@@ -78,7 +78,7 @@ pj034 的 AI 系统已演进为「多层、多公司、多租户」的完整运
|
||||
### 3.2 扣点收口 `compute_credits`(pj034)
|
||||
|
||||
```python
|
||||
CAPABILITY_CREDITS = {
|
||||
USAGE_KIND_CREDITS = {
|
||||
AiCapability.TEXT_DIAGNOSE: 1,
|
||||
AiCapability.BG_REMOVE: 1,
|
||||
AiCapability.BG_REPLACE: 2,
|
||||
@@ -88,17 +88,17 @@ CAPABILITY_CREDITS = {
|
||||
AiCapability.IMAGE_VALIDATE: 1,
|
||||
}
|
||||
|
||||
def compute_credits(capability, billing_mode, status):
|
||||
def compute_credits(usage_kind, billing_mode, status):
|
||||
if status != SUCCESS or billing_mode != PLATFORM:
|
||||
return 0
|
||||
return CAPABILITY_CREDITS.get(capability, 0)
|
||||
return USAGE_KIND_CREDITS.get(usage_kind, 0)
|
||||
```
|
||||
|
||||
**核心规则**:只有「调用成功」才扣点;失败不扣点。扣点决策收口到 service 层,不在各端点散落判断。
|
||||
|
||||
### 3.3 计费聚合 API(pj034 `ai_billing.py`)
|
||||
|
||||
`GET /data/ai-billing?days=30&group_by=month|capability|provider|month_capability`
|
||||
`GET /data/ai-billing?days=30&group_by=month|usage_kind|provider|month_usage_kind`
|
||||
|
||||
返回 `{ summary: {total_calls, success_calls, failed_calls, total_credits_charged}, buckets: [...] }`。SQLite 下按月聚合走 Go 侧 group。
|
||||
|
||||
@@ -106,9 +106,9 @@ def compute_credits(capability, billing_mode, status):
|
||||
|
||||
## 4. pj0231 计费模型(精简)
|
||||
|
||||
### 4.1 能力 → 点数(CAPABILITY_CREDITS)
|
||||
### 4.1 用量类型 → 点数(USAGE_KIND_CREDITS)
|
||||
|
||||
| capability | 含义 | 点数 |
|
||||
| usage_kind | 含义 | 点数 |
|
||||
|-----------|------|------|
|
||||
| `ai_chat` | PathCoach 对话(每轮) | 1 |
|
||||
| `text_gen` | 快捷动作(情景演练/查佣金/产品对比) | 1 |
|
||||
@@ -128,7 +128,7 @@ def compute_credits(capability, billing_mode, status):
|
||||
|------|------|------|
|
||||
| `id` | uint PK | |
|
||||
| `user_id` | uint index | 调用人 |
|
||||
| `capability` | str | ai_chat / text_gen / embed |
|
||||
| `usage_kind` | str | ai_chat / text_gen / embed |
|
||||
| `provider` | str | 实际命中的 provider |
|
||||
| `ai_route_id` | str | AI 路由 ID |
|
||||
| `model` | str | 模型名 |
|
||||
@@ -159,7 +159,7 @@ def compute_credits(capability, billing_mode, status):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/ai/usage?days=30&group_by=month` | 用量聚合(管理员 = 全量;员工 = 本人,后端按角色过滤) |
|
||||
| GET | `/api/ai/usage?days=30&group_by=month|usage_kind|provider` | 用量聚合(管理员 = 全量;员工 = 本人,后端按角色过滤) |
|
||||
| GET | `/api/ai/usage/users` | 管理员:按用户聚合的用量 + 剩余点数(充值入口数据源) |
|
||||
| GET | `/api/ai/me` | 员工:本人剩余点数 + 近 N 天用量 |
|
||||
|
||||
@@ -188,7 +188,7 @@ def compute_credits(capability, billing_mode, status):
|
||||
复刻 pj034 `AiUsage.vue` 的精简版:
|
||||
- 汇总卡:总调用 / 成功 / 失败 / 总消耗点数(去掉「¥ 花费」)
|
||||
- 时间窗切换:近 30 / 90 / 365 天
|
||||
- 分组明细:按月 / 按能力 / 按 provider
|
||||
- 分组明细:按月 / 按类型 / 按 provider
|
||||
- 管理员额外视角:按用户聚合(含剩余点数)
|
||||
|
||||
菜单位置:知识管理下新增「AI 用量」(管理员);员工入口放在 PathCoach 面板内(本人剩余点数 + 近 30 天用量)。
|
||||
@@ -205,7 +205,7 @@ def compute_credits(capability, billing_mode, status):
|
||||
- [x] LLM 客户端 + 回退链(`GenerateWithFallback` 已实现,待接入 ChatMessage)
|
||||
- [x] `model/ai_call_log.go` + `model/user.go` 加 `ai_points`
|
||||
- [x] `store/db.go` AutoMigrate 加 `AiCallLog`
|
||||
- [x] `internal/ai/credits.go`:CAPABILITY_CREDITS + compute_credits + log_ai_call
|
||||
- [x] `internal/ai/credits.go`:`UsageKindCredits` + `ComputeCredits` + `LogCall`
|
||||
- [x] `internal/api/ai_chat.go`:ChatMessage/QuickAction 接入扣点 + 日志 + 回退链
|
||||
- [x] `internal/api/ai_admin.go`:AI 配置读/写/热重载/密钥状态
|
||||
- [x] `internal/api/ai_usage.go`:用量聚合 + 按用户聚合 + 本人剩余点数
|
||||
|
||||
+132
-475
@@ -1,68 +1,70 @@
|
||||
# 对象命名标准化清单
|
||||
|
||||
> 日期:2026-09-17
|
||||
> 性质:代码目录、文件名、变量名、函数名的对象命名标准化清单
|
||||
> 性质:**阶段性清单 / 历史记录**
|
||||
> 2026-09-18 补注:本文原本记录的是 09-17 当天的待改项。其后代码与规范已大幅收口,**请勿再把本文当作当前现状说明**。当前规范以 `AR09_Object_Naming_Standard.md`、`TOP_CODING_RULES.md` 与代码现实为准。
|
||||
> 关联文档:
|
||||
> - `docs/对象标准化与解耦总则.md`
|
||||
> - `docs/2026-09-17_六层架构与三对象建设重点阶段性复盘.md`
|
||||
> - `docs/02_Architecture/AR09_Object_Naming_Standard.md`
|
||||
> - `更名收尾说明.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、结论
|
||||
## 一、这份清单现在该怎么读
|
||||
|
||||
当前代码目录整体上已经开始围绕对象收口,
|
||||
但命名层面仍然处于**新旧术语混用**的过渡态。
|
||||
这不是“还有哪些问题没修”的现状文档,
|
||||
而是一次命名收口行动的**问题发现记录**。
|
||||
|
||||
最准确的判断是:
|
||||
它现在有两个用途:
|
||||
|
||||
- **目录结构基本清楚**
|
||||
- **对象落点已经清楚**
|
||||
- **命名标准还不统一**
|
||||
- **旧词残留仍在持续污染对象边界**
|
||||
1. 解释我们当时为什么要动那些名字
|
||||
2. 给后来者留一份“哪些误导性命名曾真实存在过”的反例库
|
||||
|
||||
当前最主要的问题不是“找不到代码在哪”,
|
||||
而是:
|
||||
如果本文与当前代码冲突:
|
||||
|
||||
**看名字时,仍然经常不知道它到底指的是专员、技能、应用、默认助手,还是历史遗留概念。**
|
||||
```text
|
||||
以当前代码 + AR09 + 更名收尾说明 为准
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、当前对象落点是否清楚
|
||||
## 二、09-18 后的总状态
|
||||
|
||||
### 1. 后端主模型落点是清楚的
|
||||
### 1. 已完成的主收口
|
||||
|
||||
- 专员:`backend-go/internal/model/specialist.go`
|
||||
- 技能:`backend-go/internal/model/skill_definition.go`
|
||||
- 应用:`backend-go/internal/model/app_definition.go`
|
||||
- 连接器:`backend-go/internal/connector/*`
|
||||
以下问题已经在 09-18 这轮重构中完成:
|
||||
|
||||
其中三大对象模型命名基本准确:
|
||||
| 项 | 09-17 问题 | 09-18 结果 |
|
||||
|---|---|---|
|
||||
| 应用对象主词 | `app` 与对象体系冲突 | 已统一为 `xapp` |
|
||||
| 任务运行命名 | `worker_*` 混杂“专员形态 / 任务运行”两层语义 | 已统一为 `task_*` |
|
||||
| 技能对象分类字段 | `RoleKind / role_kind` 带旧 `role` 心智 | 已迁到 `ObjectKind / object_kind` |
|
||||
| AI 用量字段 | `capability` 停留在存储层 | 已迁到 `usage_kind` |
|
||||
| 工作台入口协议 | 写读参数不一致 | 已统一为 `xapp_specialist / xapp_skill / xapp_prompt`,并抽成共享常量 |
|
||||
| 静态旧目录变量 | `businessApps`、`availableSkills` | 前者已删;后者已更名 `staticSkillCatalog` |
|
||||
| 默认聊天入口旧名 | `assistant.js` / `smart_assistant.go` | 已收口到 `chatMessage.js` / `chat_message.go` |
|
||||
| XApp 中心旧存储 key | `eai-app-center` 长期兼容 | 已改为一次迁移后清旧 key |
|
||||
|
||||
- [specialist.go](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/specialist.go)
|
||||
- [skill_definition.go](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/skill_definition.go#L5-L32)
|
||||
- [app_definition.go](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/app_definition.go#L5-L38)
|
||||
### 2. 仍可继续优化,但不再是主阻塞
|
||||
|
||||
### 2. 前端 catalog store 方向也是清楚的
|
||||
这些点还值得继续做,但已经不是“命名体系没立住”的主问题:
|
||||
|
||||
- `specialistCatalog`
|
||||
- `skillCatalog`
|
||||
- `appCatalog`
|
||||
|
||||
对应文件:
|
||||
|
||||
- [specialistCatalog.js](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/specialistCatalog.js#L17-L88)
|
||||
- [skillCatalog.js](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/skillCatalog.js)
|
||||
- [appCatalog.js](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/appCatalog.js#L22-L155)
|
||||
|
||||
这一层说明:**对象目录化方向是正确的。**
|
||||
| 项 | 当前状态 |
|
||||
|---|---|
|
||||
| `projectStore.js` 文件名后缀 | 仍是孤例,可继续收口为 `project.js` |
|
||||
| 某些历史方案文档 | 仍保留旧路径名,需逐步补“历史映射”说明 |
|
||||
| 一些研究/阶段性文档中的 `app_*` 或 `capability` | 多属历史语境,不应再回流到代码实现 |
|
||||
|
||||
---
|
||||
|
||||
## 三、当前命名不清晰的主要问题
|
||||
## 三、09-17 当时识别出的核心问题
|
||||
|
||||
## 3.1 旧术语和新术语混用
|
||||
下面这些判断在当时是成立的,之所以保留,是因为它们解释了后续为什么要那样改。
|
||||
|
||||
当前代码中同时混着:
|
||||
### 3.1 旧术语和新术语混用
|
||||
|
||||
当时代码里同时混着:
|
||||
|
||||
- `role`
|
||||
- `assistant`
|
||||
@@ -72,478 +74,133 @@
|
||||
- `worker`
|
||||
- `capability`
|
||||
|
||||
这会导致以下问题:
|
||||
这个判断是对的,也是后续重构的出发点。
|
||||
|
||||
1. 同一个对象被多个词指代
|
||||
2. 同一个词被多个对象复用
|
||||
3. 页面层很容易把对象边界重新写乱
|
||||
### 3.2 文件名、字段名、变量名在讲旧故事
|
||||
|
||||
### 典型例子
|
||||
当时最典型的误导项包括:
|
||||
|
||||
技能模型已经叫 `SkillDefinition`,
|
||||
但字段仍叫 `RoleKind`:
|
||||
[skill_definition.go:L5-L25](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/skill_definition.go#L5-L25)
|
||||
|
||||
这说明:
|
||||
|
||||
- 模型名是新的
|
||||
- 字段语义还是旧的
|
||||
|
||||
这类命名会误导人以为 `role` 仍然是正式一级对象。
|
||||
|
||||
## 3.2 文件名与真实职责不完全一致
|
||||
|
||||
最典型的是:
|
||||
|
||||
- [capability_definition.go](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/api/capability_definition.go#L1-L120)
|
||||
|
||||
这个文件名叫 `capability_definition`,
|
||||
但里面做的是:
|
||||
|
||||
- `skillDefinitionReq`
|
||||
- `actionDefinitionReq`
|
||||
- `ListSkillDefinitions`
|
||||
|
||||
问题不只是不好看,
|
||||
而是它在语义上制造了一个模糊的一级概念:`capability`。
|
||||
|
||||
当前系统正式对象语言应是:
|
||||
|
||||
- `specialist`
|
||||
- `skill`
|
||||
- `app`
|
||||
- `connector`
|
||||
- `action`(底层)
|
||||
|
||||
不应再让 `capability` 作为主要文件名继续扩散。
|
||||
|
||||
## 3.3 历史静态目录变量名不准
|
||||
|
||||
最典型的旧变量在:
|
||||
[workbench.js:L99-L105](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/config/workbench.js#L99-L105)
|
||||
[workbench.js:L1434-L1443](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/config/workbench.js#L1434-L1443)
|
||||
|
||||
包括:
|
||||
|
||||
- `availableSkills`
|
||||
- `capability_definition.go`
|
||||
- `RoleKind`
|
||||
- `businessApps`
|
||||
- `connectors`
|
||||
- `availableSkills`
|
||||
- 页面局部 `const app = specialistCatalog.getByKey(...)`
|
||||
- `assistant.js`
|
||||
- `workerRuntime.js`
|
||||
|
||||
其中问题最大的是:
|
||||
这些名字的共同问题不是“难看”,而是**让对象边界持续失真**。
|
||||
|
||||
- `businessApps` 实际装的是专员目录,不是应用目录
|
||||
- `availableSkills` 现在更像静态 fallback,不是正式生产技能目录
|
||||
### 3.3 入口协议写读不对称
|
||||
|
||||
所以这些名字会把开发者带偏。
|
||||
09-17 时,应用目录与工作台的 query 参数协议不统一,这不是风格问题,而是功能 bug。
|
||||
|
||||
## 3.4 页面局部变量有失真
|
||||
|
||||
例如:
|
||||
[SmartAssistantPage.vue:L311-L334](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/views/workbench/SmartAssistantPage.vue#L311-L334)
|
||||
|
||||
这里:
|
||||
|
||||
```js
|
||||
const app = specialistCatalog.getByKey(key)
|
||||
```
|
||||
|
||||
拿到的是专员对象,却命名成 `app`。
|
||||
|
||||
这类局部变量不会影响编译,
|
||||
但会持续破坏对象认知。
|
||||
|
||||
## 3.5 路由参数协议不统一
|
||||
|
||||
应用目录里拼路由时使用:
|
||||
[appCatalog.js:L135-L145](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/appCatalog.js#L135-L145)
|
||||
|
||||
- `specialist`
|
||||
- `skill`
|
||||
- `prompt`
|
||||
|
||||
但工作台页面读取的是:
|
||||
[SmartAssistantPage.vue:L602-L626](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/views/workbench/SmartAssistantPage.vue#L602-L626)
|
||||
|
||||
- `app_specialist`
|
||||
- `app_skill`
|
||||
- `app_prompt`
|
||||
|
||||
这已经不是风格差异,
|
||||
而是**入口协议不统一**。
|
||||
|
||||
## 3.6 默认助手概念没有完全收口
|
||||
|
||||
当前存在:
|
||||
|
||||
- API 文件名:`assistant.js`
|
||||
- 默认技能 key:`smart-assistant`
|
||||
- 默认专员 key:`general-assistant`
|
||||
|
||||
见:
|
||||
|
||||
- [assistant.js:L1-L3](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/api/assistant.js#L1-L3)
|
||||
- [workerRuntime.js:L17-L21](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/workerRuntime.js#L17-L21)
|
||||
|
||||
这说明“assistant”当前同时在承担:
|
||||
|
||||
- 默认聊天接口名
|
||||
- 默认技能语义
|
||||
- 默认专员语义
|
||||
|
||||
需要明确边界,不然以后越做越乱。
|
||||
这一条后来直接推动了共享常量 `XAPP_ENTRY_QUERY_KEYS` 的建立。
|
||||
|
||||
---
|
||||
|
||||
## 四、标准命名原则
|
||||
## 四、旧结论与新现实映射表
|
||||
|
||||
## 4.1 正式对象术语
|
||||
为了避免后续阅读时把旧清单直接套到现代码,这里给出一张映射表。
|
||||
|
||||
对外和对内统一如下:
|
||||
| 本文旧说法 | 当前应理解为 |
|
||||
|---|---|
|
||||
| `app` 对象 | `xapp` 对象 |
|
||||
| `appCatalog.js` | `xappCatalog.js` |
|
||||
| `assistant.js` | `chatMessage.js` |
|
||||
| `smart_assistant.go` | `chat_message.go` |
|
||||
| `workerRuntime.js` | `taskRuntime.js` |
|
||||
| `api/worker.js` | `api/taskRuntime.js` |
|
||||
| `worker_task.go` / `worker_run.go` / `worker_artifact.go` | `task_record.go` / `task_run.go` / `task_artifact.go` |
|
||||
| `app_specialist / app_skill / app_prompt` | `xapp_specialist / xapp_skill / xapp_prompt` |
|
||||
| `RoleKind` | `ObjectKind` |
|
||||
| `capability`(AI 用量字段) | `usage_kind` |
|
||||
| `capability_definition.go` | `skill_action_definition.go` |
|
||||
| `businessApps` | 已删除,不应复活 |
|
||||
| `availableSkills` | `staticSkillCatalog` |
|
||||
|
||||
- 专员:`specialist`
|
||||
- 技能:`skill`
|
||||
- 应用:`app`
|
||||
- 连接器:`connector`
|
||||
- 动作:`action`
|
||||
---
|
||||
|
||||
## 4.2 非正式或历史兼容词的处理原则
|
||||
## 五、保留下来的有效原则
|
||||
|
||||
以下词允许保留在历史兼容层,
|
||||
但**不再继续扩散为新的正式命名**:
|
||||
这份旧清单里,有些原则到今天仍然完全成立:
|
||||
|
||||
- `role`
|
||||
- `businessApps`
|
||||
- `availableSkills`
|
||||
- `capability`(除非明确表示“泛能力总称”,不能再充当对象级文件名)
|
||||
- `assistant`(除非明确指聊天接口或默认助手)
|
||||
|
||||
## 4.3 命名优先级
|
||||
|
||||
命名时遵守:
|
||||
### 5.1 对象名必须准确
|
||||
|
||||
```text
|
||||
对象准确性 > 历史兼容性 > 书写简短
|
||||
```
|
||||
|
||||
意思是:
|
||||
这条没有过时。
|
||||
|
||||
- 宁可名字长一点
|
||||
- 也不要再用会误导对象边界的旧词
|
||||
### 5.2 旧词只能留在兼容层
|
||||
|
||||
---
|
||||
像下面这些旧词:
|
||||
|
||||
## 五、标准化建议
|
||||
- `role`
|
||||
- `worker`
|
||||
- `capability`
|
||||
- `assistant`
|
||||
|
||||
## 5.1 P0:立即统一入口协议和最容易误导人的命名
|
||||
只有在这三类位置允许保留:
|
||||
|
||||
### A. 统一应用入口 query 参数
|
||||
1. 迁移逻辑
|
||||
2. 迁移测试
|
||||
3. 历史说明文档
|
||||
|
||||
当前应统一为一套,
|
||||
不要一边写 `specialist/skill/prompt`,
|
||||
一边读 `app_specialist/app_skill/app_prompt`。
|
||||
不能再回流到业务模型、API、store、组件和新文档标题。
|
||||
|
||||
建议二选一,但必须全链路统一。
|
||||
### 5.3 页面局部变量也会污染认知
|
||||
|
||||
推荐统一成:
|
||||
`const app = specialistCatalog.getByKey(...)` 这种问题之所以要修,
|
||||
不是因为会报错,
|
||||
而是因为它会被后续代码继续照抄。
|
||||
|
||||
- `app_specialist`
|
||||
- `app_skill`
|
||||
- `app_prompt`
|
||||
这一判断现在仍然成立。
|
||||
|
||||
原因:
|
||||
### 5.4 入口协议必须抽成共享常量
|
||||
|
||||
- 一眼能看出这是“应用带入工作台”的预置参数
|
||||
- 不会和普通页面 query 混淆
|
||||
|
||||
### B. 修正页面里的失真变量名
|
||||
|
||||
例如:
|
||||
|
||||
- `const app = specialistCatalog.getByKey(...)`
|
||||
|
||||
应改成:
|
||||
|
||||
- `const specialist = ...`
|
||||
|
||||
这类改动优先级很高,
|
||||
因为它们会直接影响后续开发者理解对象边界。
|
||||
|
||||
### C. 停止新增 `businessApps` / `availableSkills` 这类旧变量名
|
||||
|
||||
现有代码可暂时保留兼容,
|
||||
但后续新增代码禁止继续使用这些命名。
|
||||
|
||||
---
|
||||
|
||||
## 5.2 P1:统一文件名与对象职责
|
||||
|
||||
### A. `capability_definition.go`
|
||||
|
||||
当前建议拆或改名:
|
||||
|
||||
方案 1:
|
||||
- `skill_definition.go`
|
||||
- `action_definition.go`
|
||||
|
||||
方案 2:
|
||||
- 保留文件不拆,但改名为 `skill_and_action_definition.go`
|
||||
|
||||
不建议继续用:
|
||||
|
||||
- `capability_definition.go`
|
||||
|
||||
因为它已经不能准确表达该文件实际职责。
|
||||
|
||||
### B. `assistant.js`
|
||||
|
||||
当前如果它只是默认聊天接口,
|
||||
建议更明确地表达为:
|
||||
|
||||
- `smartAssistant.js`
|
||||
或
|
||||
- `assistantChat.js`
|
||||
|
||||
而不是继续模糊地叫 `assistant.js`。
|
||||
|
||||
---
|
||||
|
||||
## 5.3 P1:统一 store 与 fallback 的命名语义
|
||||
|
||||
### A. `availableSkills`
|
||||
|
||||
如果继续保留作为静态补丁源,
|
||||
建议改名为:
|
||||
|
||||
- `staticSkillCatalog`
|
||||
或
|
||||
- `legacySkillCatalog`
|
||||
|
||||
不要再叫:
|
||||
|
||||
- `availableSkills`
|
||||
|
||||
因为现在真正“可用技能目录”已经是后端 + `skillCatalog`。
|
||||
|
||||
### B. `businessApps`
|
||||
|
||||
如果继续保留作为专员静态兼容源,
|
||||
建议改名为:
|
||||
|
||||
- `staticSpecialistCatalog`
|
||||
或
|
||||
- `legacySpecialistCatalog`
|
||||
|
||||
不要再叫:
|
||||
|
||||
- `businessApps`
|
||||
|
||||
因为它和 `app` 已经明确冲突。
|
||||
|
||||
---
|
||||
|
||||
## 5.4 P2:统一字段语义
|
||||
|
||||
### A. `RoleKind`
|
||||
|
||||
当前字段:
|
||||
[skill_definition.go:L13](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/skill_definition.go#L13)
|
||||
|
||||
建议后续迁移为更准确的名字,例如:
|
||||
|
||||
- `object_kind`
|
||||
或
|
||||
- `owner_kind`
|
||||
|
||||
如果业务语义是“这个技能归属于哪类对象”。
|
||||
|
||||
如果短期不迁字段,
|
||||
至少要在文档中明确:
|
||||
|
||||
- `RoleKind` 是历史兼容字段
|
||||
- 不再代表正式 `role` 概念
|
||||
|
||||
### B. `assistant` 的语义边界
|
||||
|
||||
需要明确规定:
|
||||
|
||||
- `assistant` 只用于默认通用助手的聊天接口语义
|
||||
- `specialist` 才是正式对象名
|
||||
|
||||
否则会继续出现:
|
||||
|
||||
- 默认助手既像 skill 又像 specialist
|
||||
- 页面里又再抽象成 role
|
||||
|
||||
---
|
||||
|
||||
## 六、按文件的具体清单
|
||||
|
||||
## 6.1 后端
|
||||
|
||||
### `backend-go/internal/model/skill_definition.go`
|
||||
|
||||
问题:
|
||||
|
||||
- `RoleKind` 仍带旧语义
|
||||
|
||||
建议:
|
||||
|
||||
- 文档先标记为历史兼容字段
|
||||
- 后续统一迁移为更准确字段名
|
||||
|
||||
### `backend-go/internal/api/capability_definition.go`
|
||||
|
||||
问题:
|
||||
|
||||
- 文件名与实际职责不匹配
|
||||
- `capability` 不是当前正式对象主词
|
||||
|
||||
建议:
|
||||
|
||||
- 重命名或拆分
|
||||
|
||||
### `backend-go/internal/api/router.go`
|
||||
|
||||
优点:
|
||||
|
||||
- `/api/specialists`
|
||||
- `/api/skills`
|
||||
- `/api/apps`
|
||||
- `/api/connectors`
|
||||
|
||||
这层命名已经较清楚:
|
||||
[router.go:L39-L49](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/api/router.go#L39-L49)
|
||||
|
||||
建议:
|
||||
|
||||
- 保持这层对象 API 命名不再回退
|
||||
|
||||
## 6.2 前端
|
||||
|
||||
### `frontend/src/config/workbench.js`
|
||||
|
||||
问题:
|
||||
|
||||
- 是历史静态大杂烩
|
||||
- `businessApps` 命名错误
|
||||
- `availableSkills` 命名已过时
|
||||
|
||||
建议:
|
||||
|
||||
- 不再作为生产目录源
|
||||
- 继续降级为静态 fallback
|
||||
- 变量名按对象真实语义重命名
|
||||
|
||||
### `frontend/src/views/workbench/SmartAssistantPage.vue`
|
||||
|
||||
问题:
|
||||
|
||||
- 局部变量名存在失真
|
||||
- `currentRolePresentation` 仍有旧 `role` 心智残留
|
||||
|
||||
建议:
|
||||
|
||||
- 局部变量全部改成对象准确名
|
||||
- 将 `role presentation` 逐步收敛为 `currentObjectPresentation`
|
||||
或明确区分:
|
||||
- `currentSpecialistPresentation`
|
||||
- `currentSkillPresentation`
|
||||
- `defaultAssistantPresentation`
|
||||
|
||||
### `frontend/src/api/assistant.js`
|
||||
|
||||
问题:
|
||||
|
||||
- 文件名语义太宽
|
||||
|
||||
建议:
|
||||
|
||||
- 仅在确认其职责是“默认助手聊天接口”后保留
|
||||
- 否则改名为更准确的接口文件名
|
||||
|
||||
### `frontend/src/store/appCatalog.js`
|
||||
|
||||
优点:
|
||||
|
||||
- `normalizeRemoteApp`
|
||||
- `normalizeCustomApp`
|
||||
- `resolveOpenRoute`
|
||||
|
||||
这套命名整体清楚:
|
||||
[appCatalog.js:L22-L145](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/appCatalog.js#L22-L145)
|
||||
|
||||
问题:
|
||||
|
||||
- 路由 query 命名与工作台读取不一致
|
||||
|
||||
建议:
|
||||
|
||||
- 先统一 query 协议
|
||||
|
||||
### `frontend/src/store/specialistCatalog.js`
|
||||
|
||||
优点:
|
||||
|
||||
- 命名整体比较准确
|
||||
- `normalizeSpecialist / getByKey / getByPath` 清楚
|
||||
|
||||
见:
|
||||
[specialistCatalog.js:L17-L87](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/specialistCatalog.js#L17-L87)
|
||||
|
||||
建议:
|
||||
|
||||
- 继续作为专员目录的标准写法模板
|
||||
|
||||
---
|
||||
|
||||
## 七、执行顺序
|
||||
|
||||
建议按下面顺序做命名清理:
|
||||
|
||||
1. **统一入口协议**
|
||||
- app query 参数统一
|
||||
|
||||
2. **清理页面级失真变量**
|
||||
- 特别是 `SmartAssistantPage.vue`
|
||||
- `CurrentObjectChip.vue`
|
||||
- `PlusMenu.vue`
|
||||
|
||||
3. **清理历史静态变量名**
|
||||
- `businessApps`
|
||||
- `availableSkills`
|
||||
|
||||
4. **清理文件名级旧词**
|
||||
- `capability_definition.go`
|
||||
- `assistant.js`
|
||||
|
||||
5. **最后处理字段迁移**
|
||||
- 如 `RoleKind`
|
||||
|
||||
这样做的原因是:
|
||||
|
||||
- 先收敛入口协议,避免继续产生新分叉
|
||||
- 再清局部变量,马上提升可读性
|
||||
- 最后再碰数据库字段,避免一次性改太重
|
||||
|
||||
---
|
||||
|
||||
## 八、最终判断
|
||||
|
||||
当前代码不是“命名完全混乱”,
|
||||
而是:
|
||||
|
||||
**目录已经开始清楚,但命名标准还没有真正收口。**
|
||||
|
||||
最核心的问题不是技术能力,
|
||||
而是术语迁移还没做完。
|
||||
|
||||
后续只要坚持:
|
||||
09-17 的诊断后来被证明完全正确:
|
||||
|
||||
```text
|
||||
对象名必须准确
|
||||
旧词只做兼容
|
||||
页面变量不得歪曲对象语义
|
||||
路由 / API / store 使用同一套对象协议
|
||||
能用结构消除的协议分叉,不要靠人工扫描补漏
|
||||
```
|
||||
|
||||
这套代码的可读性会明显上一个台阶。
|
||||
---
|
||||
|
||||
## 六、当前建议的阅读顺序
|
||||
|
||||
如果现在要继续做命名与对象规范工作,建议按这个顺序看:
|
||||
|
||||
1. [AR09_Object_Naming_Standard.md](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/docs/02_Architecture/AR09_Object_Naming_Standard.md)
|
||||
2. [更名收尾说明.md](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/更名收尾说明.md)
|
||||
3. 本文
|
||||
|
||||
原因很简单:
|
||||
|
||||
- `AR09` 讲**当前正式规范**
|
||||
- `更名收尾说明` 讲**这轮收口后哪些旧名只该留在迁移里**
|
||||
- 本文只讲**09-17 当时发现过什么问题**
|
||||
|
||||
---
|
||||
|
||||
## 七、结论
|
||||
|
||||
09-17 这份清单的价值不在于“它现在还是不是待办列表”,
|
||||
而在于它准确记录了当时的混乱来源:
|
||||
|
||||
- 对象主词混用
|
||||
- 路径协议分叉
|
||||
- 旧变量名继续扩散
|
||||
- 局部变量失真
|
||||
- 历史术语假装仍是正式术语
|
||||
|
||||
而 09-18 的重构已经把这些主问题大体收口。
|
||||
|
||||
所以现在最准确的判断是:
|
||||
|
||||
```text
|
||||
这份文档应被视为历史问题记录,而不是当前待办清单。
|
||||
```
|
||||
|
||||
后续若继续优化,应优先清理仍会误导人的历史文档与剩余孤例,
|
||||
而不是回头按本文旧路径逐项“照单执行”。
|
||||
|
||||
@@ -139,9 +139,9 @@
|
||||
- 优化默认提示词和文案
|
||||
|
||||
3. 让应用入口支持同时挂载
|
||||
- `app_specialist`
|
||||
- `app_skill`
|
||||
- `app_prompt`
|
||||
- `xapp_specialist`
|
||||
- `xapp_skill`
|
||||
- `xapp_prompt`
|
||||
|
||||
4. 增加项目模板
|
||||
- `汇报 PPT`
|
||||
@@ -179,4 +179,3 @@
|
||||
- **第一个样板已经确定为:汇报 PPT**
|
||||
|
||||
下一轮继续扩,不再需要重做架构判断,直接按样板复制即可。
|
||||
|
||||
|
||||
+2
-2
@@ -237,8 +237,8 @@ CatalogObjectView
|
||||
应该逐步补成显式关系:
|
||||
|
||||
- `specialist_skill_binding`
|
||||
- `app_specialist_binding`
|
||||
- `app_skill_binding`
|
||||
- `xapp_specialist_binding`
|
||||
- `xapp_skill_binding`
|
||||
- `specialist_connector_binding`
|
||||
|
||||
最终应该形成:
|
||||
|
||||
@@ -38,9 +38,11 @@ pj0235-eai_agentplatform-ubu/ # 工作区根(仓库根)
|
||||
└── eai_agentplatform/ # 应用代码
|
||||
├── backend-go/ # Go 后端(正式,唯一后端)
|
||||
│ ├── cmd/ internal/ # Go 源码
|
||||
│ ├── knowledge_source/ # 知识源 Markdown(运行时数据)
|
||||
│ ├── data/ # 运行时数据(db+media,git 忽略)
|
||||
│ └── deploy/ # 交付物(systemd/env/清理脚本/DELIVERY.md)
|
||||
├── assets/ # 内容资产层(运行时输入,不属于源码)
|
||||
│ ├── knowledge/source/ # 知识源 Markdown
|
||||
│ └── training/materials/ # 培训资料资产
|
||||
├── frontend/ # 前端完整代码
|
||||
├── CLAUDE.md # AI 协作锚点
|
||||
├── PROJECT_STATE.md # 项目全局状态
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
| D09 | 视频 > 100MB 分片上传 + 断点续传 | 已定 |
|
||||
| D10 | 题库 + 组卷配置(随机/固定抽题) | 已定 |
|
||||
| D11 | 角色:employee / admin 两级,无复杂权限 | 已定 |
|
||||
| D12 | 结构化知识源通道:backend-go/knowledge_source/ md → knowledge_source 表审批 → 摄入 product/question/chunk | 已定 |
|
||||
| D12 | 结构化知识源通道:assets/knowledge/source/ md → knowledge_source 表审批 → 摄入 product/question/chunk | 已定 |
|
||||
| D13 | 向量检索:brute-force 余弦 + Ollama bge-m3 embedding(单容器双模型,随盘交付) | 已定 |
|
||||
| D14 | 交付形态:裸进程 + systemd + Clonezilla 整盘克隆,Go 单二进制无源码交付 | 已定 |
|
||||
| D15 | 岗位与知识对应:Position/PositionKnowledge 表 + 用户单岗位(`user.position_id`)+ 岗位驱动组卷 | 已定(P0 落地) |
|
||||
@@ -80,7 +80,8 @@
|
||||
| 部署文档 | `../docs/deploy.md` | 部署架构与步骤 |
|
||||
| 变更日志 | `../docs/changelog.md` | 版本变更记录 |
|
||||
| 知识入库体系 | `../docs/04_Backend/BE05_Knowledge_Ingest_Module.md` | 上传→生成→审批→入库总设计 |
|
||||
| 知识源目录 | `backend-go/knowledge_source/` | 结构化知识源 md(5 份,28 产品 + 20 题) |
|
||||
| 知识源目录 | `assets/knowledge/source/` | 结构化知识源 md(5 份,28 产品 + 20 题) |
|
||||
| 培训资料目录 | `assets/training/materials/` | 培训课程资产与种子源文件 |
|
||||
| 编码准则 | `../TOP_CODING_RULES.md` | 编码与调试最高准则 |
|
||||
| AI 协作锚点 | `CLAUDE.md` | AI 新对话启动仪式 |
|
||||
|
||||
@@ -103,9 +104,11 @@ pj0235-eai_agentplatform-ubu/ # 工作区根(仓库根)
|
||||
└── eai_agentplatform/ # 应用代码
|
||||
├── backend-go/ # Go 后端(正式,唯一后端,已完成)
|
||||
│ ├── cmd/ internal/ # Go 源码
|
||||
│ ├── knowledge_source/ # 知识源 Markdown(运行时数据)
|
||||
│ ├── data/ # 运行时数据(db+media,git 忽略)
|
||||
│ └── deploy/ # 交付物(systemd/env/清理脚本/DELIVERY.md)
|
||||
├── assets/ # 内容资产层(运行时输入,不属于源码)
|
||||
│ ├── knowledge/source/ # 知识源 Markdown
|
||||
│ └── training/materials/ # 培训资料资产
|
||||
├── frontend/ # 前端代码
|
||||
├── CLAUDE.md
|
||||
├── PROJECT_STATE.md
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
# 知识源入库目录(Knowledge Source Ingest)
|
||||
|
||||
> 本目录是「结构化知识源文档」的唯一入库口,与「素材上传流水线」(data/kb_data)并行。
|
||||
> 数据权威源:`../../docs/博昇产品与渠道合作表.md`(对齐《客户介绍合作协议》第二条)。
|
||||
> 本目录是「结构化知识源文档」的唯一入库口,与「素材上传流水线」(backend-go/data/kb_data)并行。
|
||||
> 数据权威源:`../../../../docs/博昇产品与渠道合作表.md`(对齐《客户介绍合作协议》第二条)。
|
||||
|
||||
---
|
||||
|
||||
@@ -68,7 +68,7 @@ pending ──审批通过──▶ approved ──▶ 生效(product active /
|
||||
|
||||
## 6. 代码侧已实现(Go)
|
||||
|
||||
> 完整设计见 `../../docs/04_Backend/BE05_Knowledge_Ingest_Module.md`(知识入库体系总设计)。
|
||||
> 完整设计见 `../../../../docs/04_Backend/BE05_Knowledge_Ingest_Module.md`(知识入库体系总设计)。
|
||||
|
||||
- [x] `internal/model/knowledge_source.go` —— 知识源文档表
|
||||
- [x] `internal/model/knowledge_chunk.go` —— 来源扩展(media_file_id 可空 + knowledge_source_id)
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"eai_agentplatform/backend/internal/auth"
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/repository"
|
||||
specialistseeding "eai_agentplatform/backend/internal/specialists/seeding"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -29,12 +31,17 @@ func main() {
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
if _, err := store.Init(cfg.DBPath); err != nil {
|
||||
db, err := store.Init(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
repository.SetDB(db)
|
||||
if err := store.SeedDefaults(); err != nil {
|
||||
log.Fatalf("种子数据初始化失败: %v", err)
|
||||
}
|
||||
if err := specialistseeding.SeedSpecialists(store.DB); err != nil {
|
||||
log.Fatalf("专员目录种子初始化失败: %v", err)
|
||||
}
|
||||
|
||||
config.StartAIRouteHealthLoop(30 * time.Minute)
|
||||
|
||||
@@ -58,9 +65,11 @@ func main() {
|
||||
// 用于升级/迁移/交付前留一份手边的副本,比等定期那份更让人放心。
|
||||
func backupOnce() {
|
||||
cfg := config.Load()
|
||||
if _, err := store.Init(cfg.DBPath); err != nil {
|
||||
db, err := store.Init(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
repository.SetDB(db)
|
||||
path, err := store.Backup(store.DB, cfg.BackupDir, cfg.BackupKeep, time.Now())
|
||||
if err != nil {
|
||||
log.Fatalf("备份失败: %v", err)
|
||||
@@ -74,9 +83,11 @@ func resetAdmin(args []string) {
|
||||
log.Fatal("用法: eai_agentplatform-server -reset-admin <新密码>")
|
||||
}
|
||||
cfg := config.Load()
|
||||
if _, err := store.Init(cfg.DBPath); err != nil {
|
||||
db, err := store.Init(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
repository.SetDB(db)
|
||||
hash, err := auth.HashPassword(args[0])
|
||||
if err != nil {
|
||||
log.Fatalf("密码加密失败: %v", err)
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
| `clonezilla-cleanup.sh` | 克隆前清理脚本(DRY-RUN 默认) |
|
||||
| `data/eai_agentplatform.db` | SQLite 数据库(首启自动建表) |
|
||||
| `data/kb_data/` | 已审批素材目录 |
|
||||
| `knowledge_source/` | 知识源 Markdown(待入库,由管理员审批) |
|
||||
| `assets/knowledge/source/` | 知识源 Markdown(待入库,由管理员审批) |
|
||||
| `assets/training/materials/` | 培训资料资产(课程种子、PDF、视频、脚本) |
|
||||
|
||||
**不需要**:Go 运行时、Python、Docker、MySQL、任何 pip/npm 依赖。
|
||||
|
||||
@@ -27,11 +28,15 @@
|
||||
/opt/eai_agentplatform/
|
||||
├── eai_agentplatform-server # 单二进制
|
||||
├── .env # JWT 密钥等(交付前生成,勿提交源码库)
|
||||
├── assets/
|
||||
│ ├── knowledge/
|
||||
│ │ └── source/ # 知识源 Markdown
|
||||
│ └── training/
|
||||
│ └── materials/ # 培训资料资产
|
||||
├── data/
|
||||
│ ├── eai_agentplatform.db # SQLite(首启自动建)
|
||||
│ ├── backups/ # 定期备份(服务自动维护,见第 7 节)
|
||||
│ └── kb_data/ # 素材 + 提取缓存
|
||||
├── knowledge_source/ # 知识源 Markdown
|
||||
└── (前端静态资源由 Nginx 托管,见 ../docs/deploy.md)
|
||||
```
|
||||
|
||||
@@ -48,7 +53,7 @@ go build -o bin/eai_agentplatform-server ./cmd/server
|
||||
# 2.2 拷贝到原型机 + 建账号 + 装 systemd 单元
|
||||
sudo install -m 0755 eai_agentplatform-server /opt/eai_agentplatform/eai_agentplatform-server
|
||||
sudo useradd -r -s /usr/sbin/nologin eai_agentplatform
|
||||
sudo mkdir -p /opt/eai_agentplatform/data/kb_data /opt/eai_agentplatform/knowledge_source
|
||||
sudo mkdir -p /opt/eai_agentplatform/data/kb_data /opt/eai_agentplatform/assets/knowledge/source /opt/eai_agentplatform/assets/training/materials
|
||||
sudo chown -R eai_agentplatform:eai_agentplatform /opt/eai_agentplatform
|
||||
sudo install -m 0644 deploy/eai_agentplatform.service /etc/systemd/system/eai_agentplatform.service
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ LLM_MODEL=qwen2.5:7b
|
||||
EMBED_MODEL=bge-m3
|
||||
|
||||
# 数据目录
|
||||
ASSET_ROOT_DIR=/opt/eai_agentplatform/assets
|
||||
KB_DATA_DIR=data/kb_data
|
||||
KNOWLEDGE_SOURCE_DIR=/opt/eai_agentplatform/knowledge_source
|
||||
KNOWLEDGE_SOURCE_DIR=/opt/eai_agentplatform/assets/knowledge/source
|
||||
TRAINING_MATERIALS_DIR=/opt/eai_agentplatform/assets/training/materials
|
||||
|
||||
# 定期备份(默认开:启动时补一次,之后每 BACKUP_INTERVAL_HOURS 小时检查一次,保留最近 BACKUP_KEEP 份)
|
||||
# 备份目录必须留在 data/ 下 —— systemd 单元是 ProtectSystem=strict,只放开了 data/ 可写
|
||||
|
||||
@@ -13,36 +13,45 @@ import (
|
||||
// 按用户算力点计费
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// AI 能力常量
|
||||
// AI 用量类型常量
|
||||
const (
|
||||
CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
|
||||
CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
|
||||
CapabilityImageGen = "image_gen" // 文生图(按次扣点)
|
||||
CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
|
||||
CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
|
||||
UsageKindAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
|
||||
UsageKindTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
|
||||
UsageKindImageGen = "image_gen" // 文生图(按次扣点)
|
||||
UsageKindEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
|
||||
UsageKindEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
|
||||
|
||||
// 以下 5 类为工具型 AI 调用,当前只审计、不扣点:
|
||||
// 它们不在 UsageKindCredits 表里,ComputeCredits 查不到即返回 0。
|
||||
// 将来若要计费,只需把它们加进 UsageKindCredits,扣点逻辑自动生效。
|
||||
UsageKindBatchExtract = "batch_extract"
|
||||
UsageKindContractReview = "contract_review"
|
||||
UsageKindAudioTranscribe = "audio_transcribe"
|
||||
UsageKindCopyProofread = "copy_proofread"
|
||||
UsageKindDocumentTranslate = "document_translate"
|
||||
)
|
||||
|
||||
// CapabilityCredits 各能力扣点成本
|
||||
var CapabilityCredits = map[string]int{
|
||||
CapabilityAIChat: 1,
|
||||
CapabilityTextGen: 1,
|
||||
CapabilityImageGen: 1,
|
||||
CapabilityEmbed: 0,
|
||||
CapabilityEssayGrade: 0,
|
||||
// UsageKindCredits 各用量类型扣点成本
|
||||
var UsageKindCredits = map[string]int{
|
||||
UsageKindAIChat: 1,
|
||||
UsageKindTextGen: 1,
|
||||
UsageKindImageGen: 1,
|
||||
UsageKindEmbed: 0,
|
||||
UsageKindEssayGrade: 0,
|
||||
}
|
||||
|
||||
// ComputeCredits 扣点决策:仅「成功」才扣点;失败不扣。
|
||||
func ComputeCredits(capability string, success bool) int {
|
||||
func ComputeCredits(usageKind string, success bool) int {
|
||||
if !success {
|
||||
return 0
|
||||
}
|
||||
return CapabilityCredits[capability]
|
||||
return UsageKindCredits[usageKind]
|
||||
}
|
||||
|
||||
// LogEntry 一次 AI 调用的审计入参
|
||||
type LogEntry struct {
|
||||
UserID uint
|
||||
Capability string
|
||||
UsageKind string
|
||||
// SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手
|
||||
SpecialistKey string
|
||||
Provider string
|
||||
@@ -57,14 +66,14 @@ type LogEntry struct {
|
||||
|
||||
// LogCall 写 ai_call_log;成功且需扣点时从用户余额扣点。审计写入失败不阻断主流程。
|
||||
func LogCall(e LogEntry) {
|
||||
credits := ComputeCredits(e.Capability, e.Success)
|
||||
credits := ComputeCredits(e.UsageKind, e.Success)
|
||||
status := "success"
|
||||
if !e.Success {
|
||||
status = "failed"
|
||||
}
|
||||
rec := model.AiCallLog{
|
||||
UserID: e.UserID,
|
||||
Capability: e.Capability,
|
||||
UsageKind: e.UsageKind,
|
||||
SpecialistKey: e.SpecialistKey,
|
||||
Provider: e.Provider,
|
||||
AIRouteID: e.AIRouteID,
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
@@ -64,18 +62,6 @@ func NewClient(aiRoute *config.RouteConfig) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientLegacy 兼容旧接口(从 LLMConfig 创建)
|
||||
func NewClientLegacy(cfg LLMConfig) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
maxTokens: cfg.MaxTokens,
|
||||
temperature: cfg.Temperature,
|
||||
hc: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) url(path string) string {
|
||||
return c.baseURL + path
|
||||
}
|
||||
@@ -450,59 +436,6 @@ func requiresAPIKey(aiRoute *config.RouteConfig) bool {
|
||||
return provider == "openrouter" || provider == "openai"
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 配置解析(兼容旧接口)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// LLMConfig 简单的 LLM 连接配置(旧版,逐步淘汰)
|
||||
type LLMConfig struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
EmbedModel string
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
// ResolveLLM 旧版:从 DB/环境 解析 LLM 配置
|
||||
func ResolveLLM(cfg *config.Config) (LLMConfig, bool) {
|
||||
get := func(key, def string) string {
|
||||
var sc model.SystemConfig
|
||||
if err := store.DB.Where("config_key = ?", key).First(&sc).Error; err == nil && strings.TrimSpace(sc.ConfigValue) != "" {
|
||||
return sc.ConfigValue
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
baseURL := get("llm_base_url", cfg.LLMBaseURL)
|
||||
apiKey := get("llm_api_key", cfg.LLMAPIKey)
|
||||
modelName := get("llm_model", cfg.LLMModel)
|
||||
embedModel := get("embed_model", cfg.EmbedModel)
|
||||
|
||||
// DB 空时降级到 JSON secrets(flat 格式)
|
||||
if baseURL == "" || apiKey == "" {
|
||||
if baseURL == "" {
|
||||
baseURL = config.GetProviderBaseURL("ollama")
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = config.GetProviderAPIKey("ollama")
|
||||
}
|
||||
}
|
||||
|
||||
c := LLMConfig{
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
Model: modelName,
|
||||
EmbedModel: embedModel,
|
||||
MaxTokens: 2048,
|
||||
Temperature: 0.7,
|
||||
}
|
||||
if c.BaseURL == "" || c.Model == "" {
|
||||
return c, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/jsonutil"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
type actionDefinitionReq struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
ActionType string `json:"action_type"`
|
||||
ConnectorRef string `json:"connector_ref"`
|
||||
InputSchemaJSON string `json:"input_schema_json"`
|
||||
OutputSchemaJSON string `json:"output_schema_json"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ApprovalMode string `json:"approval_mode"`
|
||||
AuditLevel string `json:"audit_level"`
|
||||
ExposedToUser bool `json:"exposed_to_user"`
|
||||
OntologyBindingJSON string `json:"ontology_binding_json"`
|
||||
State string `json:"state"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func normalizeActionDefinitionReq(req *actionDefinitionReq) {
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
req.Label = strings.TrimSpace(req.Label)
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
req.ActionType = strings.TrimSpace(req.ActionType)
|
||||
req.ConnectorRef = strings.TrimSpace(req.ConnectorRef)
|
||||
req.InputSchemaJSON = strings.TrimSpace(req.InputSchemaJSON)
|
||||
req.OutputSchemaJSON = strings.TrimSpace(req.OutputSchemaJSON)
|
||||
req.RiskLevel = strings.TrimSpace(req.RiskLevel)
|
||||
req.ApprovalMode = strings.TrimSpace(req.ApprovalMode)
|
||||
req.AuditLevel = strings.TrimSpace(req.AuditLevel)
|
||||
req.OntologyBindingJSON = strings.TrimSpace(req.OntologyBindingJSON)
|
||||
req.State = strings.TrimSpace(req.State)
|
||||
}
|
||||
|
||||
func validateActionDefinitionReq(req *actionDefinitionReq) *web.AppError {
|
||||
normalizeActionDefinitionReq(req)
|
||||
if req.Key == "" || req.Label == "" {
|
||||
return web.NewBadRequest("key、label 为必填")
|
||||
}
|
||||
if req.ActionType == "" {
|
||||
req.ActionType = "execution"
|
||||
}
|
||||
if req.RiskLevel == "" {
|
||||
req.RiskLevel = "low"
|
||||
}
|
||||
if req.ApprovalMode == "" {
|
||||
req.ApprovalMode = "not_required"
|
||||
}
|
||||
if req.AuditLevel == "" {
|
||||
req.AuditLevel = "standard"
|
||||
}
|
||||
if req.State == "" {
|
||||
req.State = "active"
|
||||
}
|
||||
if req.State != "active" && req.State != "inactive" {
|
||||
return web.NewBadRequest("state 只能是 active 或 inactive")
|
||||
}
|
||||
if !jsonutil.ValidateObjectJSON(req.InputSchemaJSON) {
|
||||
return web.NewBadRequest("input_schema_json 必须是 JSON 对象")
|
||||
}
|
||||
if !jsonutil.ValidateObjectJSON(req.OutputSchemaJSON) {
|
||||
return web.NewBadRequest("output_schema_json 必须是 JSON 对象")
|
||||
}
|
||||
if !jsonutil.ValidateObjectJSON(req.OntologyBindingJSON) {
|
||||
return web.NewBadRequest("ontology_binding_json 必须是 JSON 对象")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListActionDefinitions(c *gin.Context) {
|
||||
q := store.DB.Model(&model.ActionDefinition{})
|
||||
if c.Query("state") == "" {
|
||||
q = q.Where("state = ?", "active")
|
||||
} else {
|
||||
q = q.Where("state = ?", c.Query("state"))
|
||||
}
|
||||
var items []model.ActionDefinition
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询 Action 定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
func GetActionDefinitionByKey(c *gin.Context) {
|
||||
key := strings.TrimSpace(c.Param("key"))
|
||||
if key == "" {
|
||||
web.Fail(c, web.NewBadRequest("action key 不能为空"))
|
||||
return
|
||||
}
|
||||
var item model.ActionDefinition
|
||||
if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func CreateActionDefinition(c *gin.Context) {
|
||||
var req actionDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateActionDefinitionReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
item := model.ActionDefinition{
|
||||
Key: req.Key,
|
||||
Label: req.Label,
|
||||
Description: req.Description,
|
||||
ActionType: req.ActionType,
|
||||
ConnectorRef: req.ConnectorRef,
|
||||
InputSchemaJSON: req.InputSchemaJSON,
|
||||
OutputSchemaJSON: req.OutputSchemaJSON,
|
||||
RiskLevel: req.RiskLevel,
|
||||
ApprovalMode: req.ApprovalMode,
|
||||
AuditLevel: req.AuditLevel,
|
||||
ExposedToUser: req.ExposedToUser,
|
||||
OntologyBindingJSON: req.OntologyBindingJSON,
|
||||
State: req.State,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
if err := store.DB.Create(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建 Action 定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateActionDefinition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.ActionDefinition
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
|
||||
return
|
||||
}
|
||||
var req actionDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateActionDefinitionReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
item.Key = req.Key
|
||||
item.Label = req.Label
|
||||
item.Description = req.Description
|
||||
item.ActionType = req.ActionType
|
||||
item.ConnectorRef = req.ConnectorRef
|
||||
item.InputSchemaJSON = req.InputSchemaJSON
|
||||
item.OutputSchemaJSON = req.OutputSchemaJSON
|
||||
item.RiskLevel = req.RiskLevel
|
||||
item.ApprovalMode = req.ApprovalMode
|
||||
item.AuditLevel = req.AuditLevel
|
||||
item.ExposedToUser = req.ExposedToUser
|
||||
item.OntologyBindingJSON = req.OntologyBindingJSON
|
||||
item.State = req.State
|
||||
item.SortOrder = req.SortOrder
|
||||
if err := store.DB.Save(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新 Action 定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteActionDefinition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.ActionDefinition
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除 Action 定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id, "deleted": true})
|
||||
}
|
||||
@@ -13,12 +13,13 @@ import (
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// checkBalance 调用前校验用户算力点余额(不扣点的能力直接放行)
|
||||
func checkBalance(c *gin.Context, user *model.User, capability string) bool {
|
||||
if ai.ComputeCredits(capability, true) <= 0 {
|
||||
// checkBalance 调用前校验用户算力点余额(不扣点的类型直接放行)
|
||||
func checkBalance(c *gin.Context, user *model.User, usageKind string) bool {
|
||||
if ai.ComputeCredits(usageKind, true) <= 0 {
|
||||
return true
|
||||
}
|
||||
if user.AiPoints <= 0 {
|
||||
@@ -81,7 +82,7 @@ func ChatMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !checkBalance(c, user, ai.CapabilityAIChat) {
|
||||
if !checkBalance(c, user, ai.UsageKindAIChat) {
|
||||
writeEvent(gin.H{"type": "error", "message": "AI 点数不足,请联系管理员充值"})
|
||||
return
|
||||
}
|
||||
@@ -107,7 +108,7 @@ func ChatMessage(c *gin.Context) {
|
||||
}
|
||||
writeEvent(gin.H{"type": "error", "message": message})
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: aiRoute.Provider,
|
||||
UserID: user.ID, UsageKind: ai.UsageKindAIChat, Provider: aiRoute.Provider,
|
||||
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
|
||||
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
@@ -119,34 +120,13 @@ func ChatMessage(c *gin.Context) {
|
||||
finalAiRoute = usedAiRoute
|
||||
}
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalAiRoute.Provider,
|
||||
UserID: user.ID, UsageKind: ai.UsageKindAIChat, Provider: finalAiRoute.Provider,
|
||||
AIRouteID: finalAiRoute.RouteID, Model: finalAiRoute.Model, Success: true,
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func buildSystemPrompt(ctx map[string]any, knowledge []string) string {
|
||||
ctxJSON, _ := json.Marshal(ctx)
|
||||
kc := strings.Join(knowledge, "\n\n")
|
||||
return fmt.Sprintf(`你是 EAI 数字员工平台的 AI 助手。
|
||||
|
||||
职责:
|
||||
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关问题
|
||||
2. 严格依赖已审批知识库内容回答
|
||||
3. 知识库未找到相关资料时,明确回答「未找到相关资料」,不得臆测
|
||||
|
||||
禁止:
|
||||
1. 禁止闲聊
|
||||
2. 禁止编造数据
|
||||
3. 禁止回答超出业务范围的问题
|
||||
4. 禁止泄露敏感信息
|
||||
|
||||
当前页面上下文:
|
||||
%s
|
||||
|
||||
知识库相关片段:
|
||||
%s`, string(ctxJSON), kc)
|
||||
}
|
||||
// buildSystemPrompt 已迁移至 internal/specialists/runtime(specialistruntime.BuildSystemPrompt)。
|
||||
|
||||
// QuickActions GET /api/ai-chat/quick-actions —— 3 个快捷按钮
|
||||
func QuickActions(c *gin.Context) {
|
||||
@@ -191,7 +171,7 @@ func QuickAction(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("action_id 必填"))
|
||||
return
|
||||
}
|
||||
if !checkBalance(c, user, ai.CapabilityTextGen) {
|
||||
if !checkBalance(c, user, ai.UsageKindTextGen) {
|
||||
return
|
||||
}
|
||||
task, ok := quickActionTask[req.ActionID]
|
||||
@@ -227,7 +207,7 @@ func QuickAction(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task
|
||||
systemPrompt := specialistruntime.BuildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task
|
||||
aiMessages := []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: task.query},
|
||||
@@ -237,7 +217,7 @@ func QuickAction(c *gin.Context) {
|
||||
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
|
||||
if err != nil {
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: aiRoute.Provider,
|
||||
UserID: user.ID, UsageKind: ai.UsageKindTextGen, Provider: aiRoute.Provider,
|
||||
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
|
||||
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
@@ -251,7 +231,7 @@ func QuickAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: usedAiRoute.Provider,
|
||||
UserID: user.ID, UsageKind: ai.UsageKindTextGen, Provider: usedAiRoute.Provider,
|
||||
AIRouteID: usedAiRoute.RouteID, Model: usedAiRoute.Model, Success: true,
|
||||
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// 算力点用量查询(管理员 = 全量;员工 = 本人)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// AIUsage GET /api/ai/usage?days=30&group_by=month|capability|provider
|
||||
// AIUsage GET /api/ai/usage?days=30&group_by=month|usage_kind|provider
|
||||
func AIUsage(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
days := parseIntDefault(c.Query("days"), 30)
|
||||
@@ -25,7 +25,7 @@ func AIUsage(c *gin.Context) {
|
||||
}
|
||||
groupBy := c.DefaultQuery("group_by", "month")
|
||||
switch groupBy {
|
||||
case "month", "day", "capability", "provider":
|
||||
case "month", "day", "usage_kind", "provider":
|
||||
default:
|
||||
groupBy = "month"
|
||||
}
|
||||
@@ -55,8 +55,8 @@ func AIUsage(c *gin.Context) {
|
||||
// 分组字段(SQLite 下按月走 strftime,避免依赖 date_trunc)
|
||||
var groupExpr, orderExpr string
|
||||
switch groupBy {
|
||||
case "capability":
|
||||
groupExpr, orderExpr = "capability", "capability"
|
||||
case "usage_kind":
|
||||
groupExpr, orderExpr = "usage_kind", "usage_kind"
|
||||
case "provider":
|
||||
groupExpr, orderExpr = "COALESCE(provider,'')", "provider"
|
||||
case "day":
|
||||
@@ -129,29 +129,6 @@ func AIUsageUsers(c *gin.Context) {
|
||||
web.OK(c, gin.H{"users": out})
|
||||
}
|
||||
|
||||
// AIUsageMe GET /api/ai/me —— 本人剩余点数 + 用量(PathCoach 面板数据源)
|
||||
func AIUsageMe(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
var totalUsed int64
|
||||
store.DB.Raw(`SELECT COALESCE(SUM(credits_charged),0) FROM ai_call_log WHERE user_id = ?`, user.ID).Scan(&totalUsed)
|
||||
var recent struct {
|
||||
Used int64
|
||||
Calls int64
|
||||
}
|
||||
store.DB.Raw(`SELECT COALESCE(SUM(credits_charged),0) AS used, COUNT(*) AS calls FROM ai_call_log WHERE user_id = ? AND created_at >= ?`, user.ID, daysAgo(30)).Scan(&recent)
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"ai_points": user.AiPoints,
|
||||
"total_used": totalUsed,
|
||||
"recent_used": recent.Used,
|
||||
"recent_calls": recent.Calls,
|
||||
})
|
||||
}
|
||||
|
||||
// parseIntDefault 解析 int 查询参数,非法时返回默认值
|
||||
func parseIntDefault(s string, def int) int {
|
||||
if v, err := strconv.Atoi(s); err == nil {
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
type appDefinitionReq struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Badge string `json:"badge"`
|
||||
Kind string `json:"kind"`
|
||||
MarketTag string `json:"market_tag"`
|
||||
WorkerType string `json:"worker_type"`
|
||||
Tier string `json:"tier"`
|
||||
Source string `json:"source"`
|
||||
Color string `json:"color"`
|
||||
IconText string `json:"icon_text"`
|
||||
CoverTone string `json:"cover_tone"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
OpenRoute string `json:"open_route"`
|
||||
SpecialistKey string `json:"specialist_key"`
|
||||
SkillKey string `json:"skill_key"`
|
||||
DefaultPrompt string `json:"default_prompt"`
|
||||
PromptsJSON string `json:"prompts_json"`
|
||||
TagsJSON string `json:"tags_json"`
|
||||
InstallState string `json:"install_state"`
|
||||
ExposedToUser bool `json:"exposed_to_user"`
|
||||
State string `json:"state"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func normalizeAppDefinitionReq(req *appDefinitionReq) {
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
req.Label = strings.TrimSpace(req.Label)
|
||||
req.Badge = strings.TrimSpace(req.Badge)
|
||||
req.Kind = strings.TrimSpace(req.Kind)
|
||||
req.MarketTag = strings.TrimSpace(req.MarketTag)
|
||||
req.WorkerType = strings.TrimSpace(req.WorkerType)
|
||||
req.Tier = strings.TrimSpace(req.Tier)
|
||||
req.Source = strings.TrimSpace(req.Source)
|
||||
req.Color = strings.TrimSpace(req.Color)
|
||||
req.IconText = strings.TrimSpace(req.IconText)
|
||||
req.CoverTone = strings.TrimSpace(req.CoverTone)
|
||||
req.Summary = strings.TrimSpace(req.Summary)
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
req.OpenRoute = strings.TrimSpace(req.OpenRoute)
|
||||
req.SpecialistKey = strings.TrimSpace(req.SpecialistKey)
|
||||
req.SkillKey = strings.TrimSpace(req.SkillKey)
|
||||
req.DefaultPrompt = strings.TrimSpace(req.DefaultPrompt)
|
||||
req.PromptsJSON = strings.TrimSpace(req.PromptsJSON)
|
||||
req.TagsJSON = strings.TrimSpace(req.TagsJSON)
|
||||
req.InstallState = strings.TrimSpace(req.InstallState)
|
||||
req.State = strings.TrimSpace(req.State)
|
||||
}
|
||||
|
||||
func validateAppDefinitionReq(req *appDefinitionReq) *web.AppError {
|
||||
normalizeAppDefinitionReq(req)
|
||||
if req.Key == "" || req.Label == "" {
|
||||
return web.NewBadRequest("key、label 为必填")
|
||||
}
|
||||
if req.WorkerType == "" {
|
||||
req.WorkerType = "worker"
|
||||
}
|
||||
if req.Tier == "" {
|
||||
req.Tier = "business"
|
||||
}
|
||||
if req.Source == "" {
|
||||
req.Source = "eai"
|
||||
}
|
||||
if req.InstallState == "" {
|
||||
req.InstallState = "installed"
|
||||
}
|
||||
if req.State == "" {
|
||||
req.State = "active"
|
||||
}
|
||||
if req.State != "active" && req.State != "inactive" {
|
||||
return web.NewBadRequest("state 只能是 active 或 inactive")
|
||||
}
|
||||
if !store.ValidateJSONStringArray(req.PromptsJSON) {
|
||||
return web.NewBadRequest("prompts_json 必须是字符串数组")
|
||||
}
|
||||
if !store.ValidateJSONStringArray(req.TagsJSON) {
|
||||
return web.NewBadRequest("tags_json 必须是字符串数组")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListAppDefinitions(c *gin.Context) {
|
||||
q := store.DB.Model(&model.AppDefinition{})
|
||||
if c.Query("state") == "" {
|
||||
q = q.Where("state = ?", "active")
|
||||
} else {
|
||||
q = q.Where("state = ?", c.Query("state"))
|
||||
}
|
||||
if c.Query("exposed_to_user") != "" {
|
||||
q = q.Where("exposed_to_user = ?", c.Query("exposed_to_user") == "true")
|
||||
}
|
||||
var items []model.AppDefinition
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询应用定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
func GetAppDefinitionByKey(c *gin.Context) {
|
||||
key := strings.TrimSpace(c.Param("key"))
|
||||
if key == "" {
|
||||
web.Fail(c, web.NewBadRequest("应用 key 不能为空"))
|
||||
return
|
||||
}
|
||||
var item model.AppDefinition
|
||||
if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("应用定义不存在"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func CreateAppDefinition(c *gin.Context) {
|
||||
var req appDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateAppDefinitionReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
item := model.AppDefinition{
|
||||
Key: req.Key,
|
||||
Label: req.Label,
|
||||
Badge: req.Badge,
|
||||
Kind: req.Kind,
|
||||
MarketTag: req.MarketTag,
|
||||
WorkerType: req.WorkerType,
|
||||
Tier: req.Tier,
|
||||
Source: req.Source,
|
||||
Color: req.Color,
|
||||
IconText: req.IconText,
|
||||
CoverTone: req.CoverTone,
|
||||
Summary: req.Summary,
|
||||
Description: req.Description,
|
||||
OpenRoute: req.OpenRoute,
|
||||
SpecialistKey: req.SpecialistKey,
|
||||
SkillKey: req.SkillKey,
|
||||
DefaultPrompt: req.DefaultPrompt,
|
||||
PromptsJSON: req.PromptsJSON,
|
||||
TagsJSON: req.TagsJSON,
|
||||
InstallState: req.InstallState,
|
||||
ExposedToUser: req.ExposedToUser,
|
||||
State: req.State,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
if err := store.DB.Create(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建应用定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateAppDefinition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.AppDefinition
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("应用定义不存在"))
|
||||
return
|
||||
}
|
||||
var req appDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if appErr := validateAppDefinitionReq(&req); appErr != nil {
|
||||
web.Fail(c, appErr)
|
||||
return
|
||||
}
|
||||
item.Key = req.Key
|
||||
item.Label = req.Label
|
||||
item.Badge = req.Badge
|
||||
item.Kind = req.Kind
|
||||
item.MarketTag = req.MarketTag
|
||||
item.WorkerType = req.WorkerType
|
||||
item.Tier = req.Tier
|
||||
item.Source = req.Source
|
||||
item.Color = req.Color
|
||||
item.IconText = req.IconText
|
||||
item.CoverTone = req.CoverTone
|
||||
item.Summary = req.Summary
|
||||
item.Description = req.Description
|
||||
item.OpenRoute = req.OpenRoute
|
||||
item.SpecialistKey = req.SpecialistKey
|
||||
item.SkillKey = req.SkillKey
|
||||
item.DefaultPrompt = req.DefaultPrompt
|
||||
item.PromptsJSON = req.PromptsJSON
|
||||
item.TagsJSON = req.TagsJSON
|
||||
item.InstallState = req.InstallState
|
||||
item.ExposedToUser = req.ExposedToUser
|
||||
item.State = req.State
|
||||
item.SortOrder = req.SortOrder
|
||||
if err := store.DB.Save(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新应用定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteAppDefinition(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item model.AppDefinition
|
||||
if err := store.DB.First(&item, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("应用定义不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&item).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除应用定义失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id, "deleted": true})
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"eai_agentplatform/backend/internal/ai"
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
@@ -71,7 +69,7 @@ func TranscribeAudio(c *gin.Context) {
|
||||
file.Close()
|
||||
|
||||
result := transcribeAudio(fileData, ext, language)
|
||||
logTranscription(user.ID, fileHeader.Filename, result)
|
||||
logTranscription(user.ID)
|
||||
web.OK(c, result)
|
||||
}
|
||||
|
||||
@@ -171,13 +169,6 @@ func transcribeAudio(fileData []byte, ext, language string) AudioTranscribeResul
|
||||
}
|
||||
|
||||
// logTranscription 记录语音转录日志
|
||||
func logTranscription(userID uint, filename string, result AudioTranscribeResult) {
|
||||
store.DB.Create(&model.AiCallLog{
|
||||
UserID: userID,
|
||||
Capability: "audio_transcribe",
|
||||
Status: "success",
|
||||
})
|
||||
_ = filename
|
||||
_ = ai.GenerateWithFallback
|
||||
_ = result
|
||||
func logTranscription(userID uint) {
|
||||
ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindAudioTranscribe, Success: true})
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
|
||||
"eai_agentplatform/backend/internal/auth"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
@@ -23,8 +21,8 @@ func Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := store.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||
user, found := userRepo.GetByUsername(req.Username)
|
||||
if !found {
|
||||
web.Fail(c, web.NewAuthError("用户名或密码错误"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
|
||||
"eai_agentplatform/backend/internal/ai"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
@@ -49,7 +47,7 @@ func ExtractBatch(c *gin.Context) {
|
||||
}
|
||||
|
||||
result := runBatchExtract(req)
|
||||
logBatchExtract(user.ID, req.Content, result)
|
||||
logBatchExtract(user.ID)
|
||||
web.OK(c, result)
|
||||
}
|
||||
|
||||
@@ -89,7 +87,7 @@ func buildFieldDescription(fields []FieldSchema) string {
|
||||
var sb strings.Builder
|
||||
for i, f := range fields {
|
||||
sb.WriteString("- 字段")
|
||||
sb.WriteString(string(rune('0'+i)))
|
||||
sb.WriteString(string(rune('0' + i)))
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(f.Name)
|
||||
sb.WriteString(" (类型: ")
|
||||
@@ -145,9 +143,8 @@ func fixJSON(s string) string {
|
||||
}
|
||||
|
||||
// logBatchExtract 记录批量提取日志
|
||||
func logBatchExtract(userID uint, content string, result BatchExtractResult) {
|
||||
(json.Marshal(result))
|
||||
store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "batch_extract", Status: "success"})
|
||||
func logBatchExtract(userID uint) {
|
||||
ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindBatchExtract, Success: true})
|
||||
}
|
||||
|
||||
// ExtractFromFiles 从文件中提取字段
|
||||
@@ -164,6 +161,6 @@ func ExtractFromFiles(c *gin.Context) {
|
||||
}
|
||||
|
||||
result := runBatchExtract(req)
|
||||
logBatchExtract(user.ID, req.Content, result)
|
||||
logBatchExtract(user.ID)
|
||||
web.OK(c, result)
|
||||
}
|
||||
|
||||
+66
-42
@@ -8,13 +8,12 @@ import (
|
||||
"eai_agentplatform/backend/internal/ai"
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
specialistmodel "eai_agentplatform/backend/internal/specialists/model"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// SmartAssistantRequest POST /api/assistant/chat —— 通用助手请求
|
||||
type SmartAssistantRequest struct {
|
||||
// ChatMessageRequest POST /api/chat/message —— 工作台对话请求
|
||||
type ChatMessageRequest struct {
|
||||
Message string `json:"message"`
|
||||
Context string `json:"context"`
|
||||
Mode string `json:"mode"`
|
||||
@@ -29,8 +28,8 @@ type SmartAssistantRequest struct {
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// AssistantMessage 助手消息
|
||||
type AssistantMessage struct {
|
||||
// ChatReplyMessage 工作台对话返回消息
|
||||
type ChatReplyMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
@@ -55,23 +54,28 @@ type Step struct {
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// SmartAssistantResult 通用助手响应
|
||||
type SmartAssistantResult struct {
|
||||
Message AssistantMessage `json:"message"`
|
||||
// ChatMessageResult 工作台对话响应
|
||||
type ChatMessageResult struct {
|
||||
Message ChatReplyMessage `json:"message"`
|
||||
TaskPlan *TaskPlan `json:"task_plan,omitempty"`
|
||||
IsPlan bool `json:"is_plan"`
|
||||
IsExpert bool `json:"is_expert"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// Chat POST /api/assistant/chat —— 通用助手对话入口
|
||||
func Chat(c *gin.Context) {
|
||||
type chatExecution struct {
|
||||
result ChatMessageResult
|
||||
logEntry ai.LogEntry
|
||||
}
|
||||
|
||||
// HandleChatMessage POST /api/chat/message —— 工作台对话入口
|
||||
func HandleChatMessage(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
var req SmartAssistantRequest
|
||||
var req ChatMessageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Message == "" {
|
||||
web.Fail(c, web.NewBadRequest("message 必填"))
|
||||
return
|
||||
@@ -86,13 +90,13 @@ func Chat(c *gin.Context) {
|
||||
// 专员只解析一次,供 prompt 组装与审计日志共用
|
||||
specialist := resolveSpecialist(req)
|
||||
|
||||
result := processAssistantMessage(user.ID, req, specialist)
|
||||
logAssistantMessage(user.ID, req.Message, result, specialist)
|
||||
web.OK(c, result)
|
||||
execution := processChatMessage(user.ID, req, specialist)
|
||||
ai.LogCall(execution.logEntry)
|
||||
web.OK(c, execution.result)
|
||||
}
|
||||
|
||||
// processAssistantMessage 处理通用助手消息
|
||||
func processAssistantMessage(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
|
||||
// processChatMessage 处理工作台对话消息
|
||||
func processChatMessage(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist) chatExecution {
|
||||
switch req.Mode {
|
||||
case "quick":
|
||||
return handleQuick(userID, req, specialist)
|
||||
@@ -104,10 +108,11 @@ func processAssistantMessage(userID uint, req SmartAssistantRequest, specialist
|
||||
}
|
||||
|
||||
// handleQuick 快速模式:直出回答,不开启 thinking 和多步规划
|
||||
func handleQuick(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
|
||||
aiResponse := callAssistantAI(userID, req, specialist, false)
|
||||
return SmartAssistantResult{
|
||||
Message: AssistantMessage{
|
||||
func handleQuick(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist) chatExecution {
|
||||
aiResponse, logEntry := callChatModel(userID, req, specialist, false)
|
||||
return chatExecution{
|
||||
result: ChatMessageResult{
|
||||
Message: ChatReplyMessage{
|
||||
Role: "assistant",
|
||||
Content: aiResponse,
|
||||
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
||||
@@ -116,16 +121,19 @@ func handleQuick(userID uint, req SmartAssistantRequest, specialist *model.Speci
|
||||
IsPlan: false,
|
||||
IsExpert: false,
|
||||
Stream: req.Stream,
|
||||
},
|
||||
logEntry: logEntry,
|
||||
}
|
||||
}
|
||||
|
||||
// handleExpert 专家模式:开启 thinking,进行多步智能体规划
|
||||
func handleExpert(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult {
|
||||
func handleExpert(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist) chatExecution {
|
||||
// 先用 expert system prompt 调用 LLM 生成多步规划和回复
|
||||
aiResponse := callAssistantAI(userID, req, specialist, true)
|
||||
aiResponse, logEntry := callChatModel(userID, req, specialist, true)
|
||||
plan := generateTaskPlan(req.Message)
|
||||
return SmartAssistantResult{
|
||||
Message: AssistantMessage{
|
||||
return chatExecution{
|
||||
result: ChatMessageResult{
|
||||
Message: ChatReplyMessage{
|
||||
Role: "assistant",
|
||||
Content: aiResponse,
|
||||
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
||||
@@ -135,33 +143,56 @@ func handleExpert(userID uint, req SmartAssistantRequest, specialist *model.Spec
|
||||
IsPlan: true,
|
||||
IsExpert: true,
|
||||
Stream: req.Stream,
|
||||
},
|
||||
logEntry: logEntry,
|
||||
}
|
||||
}
|
||||
|
||||
// callAssistantAI 调用AI生成对话回复,enableThinking 控制是否使用 expert prompt
|
||||
// callChatModel 调用 AI 生成对话回复,enableThinking 控制是否使用 expert prompt
|
||||
//
|
||||
// system prompt 由 buildAssistantSystemPrompt 组装:基础角色 + 专员岗位说明书。
|
||||
// 没解析到专员时(未指定 / key 失效)与改动前逐字一致。
|
||||
func callAssistantAI(userID uint, req SmartAssistantRequest, specialist *model.Specialist, enableThinking bool) string {
|
||||
func callChatModel(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist, enableThinking bool) (string, ai.LogEntry) {
|
||||
systemPrompt := buildAssistantSystemPrompt(specialist, enableThinking)
|
||||
logEntry := ai.LogEntry{
|
||||
UserID: userID,
|
||||
UsageKind: ai.UsageKindAIChat,
|
||||
SpecialistKey: specialistKey(specialist),
|
||||
Success: false,
|
||||
}
|
||||
|
||||
aiRouteID := req.AIRouteID
|
||||
if aiRouteID == "" {
|
||||
aiRouteID = "path_coach"
|
||||
}
|
||||
logEntry.AIRouteID = aiRouteID
|
||||
aiRoute, err := config.GetRoute(aiRouteID)
|
||||
if err != nil {
|
||||
return "抱歉,当前后台AI模型不可用,请检查 AI 路由配置。"
|
||||
logEntry.ErrorMessage = err.Error()
|
||||
return "抱歉,当前后台AI模型不可用,请检查 AI 路由配置。", logEntry
|
||||
}
|
||||
logEntry.Provider = aiRoute.Provider
|
||||
logEntry.Model = aiRoute.Model
|
||||
|
||||
content, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
|
||||
start := time.Now()
|
||||
result, usedRoute, err := ai.GenerateFullWithFallback(aiRoute, []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: req.Message},
|
||||
})
|
||||
logEntry.LatencyMs = int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
return "抱歉,AI 服务暂时不可用,请稍后重试。"
|
||||
logEntry.ErrorMessage = err.Error()
|
||||
return "抱歉,AI 服务暂时不可用,请稍后重试。", logEntry
|
||||
}
|
||||
return content
|
||||
if usedRoute != nil {
|
||||
logEntry.Provider = usedRoute.Provider
|
||||
logEntry.AIRouteID = usedRoute.RouteID
|
||||
logEntry.Model = usedRoute.Model
|
||||
}
|
||||
logEntry.TokensInput = result.Usage.PromptTokens
|
||||
logEntry.TokensOutput = result.Usage.CompletionTokens
|
||||
logEntry.Success = true
|
||||
return result.Content, logEntry
|
||||
}
|
||||
|
||||
// generateTaskPlan 生成任务拆解
|
||||
@@ -179,16 +210,9 @@ func generateTaskPlan(prompt string) *TaskPlan {
|
||||
return plan
|
||||
}
|
||||
|
||||
// logAssistantMessage 记录助手消息
|
||||
func logAssistantMessage(userID uint, message string, result SmartAssistantResult, specialist *model.Specialist) {
|
||||
specialistKey := ""
|
||||
if specialist != nil {
|
||||
specialistKey = specialist.Key
|
||||
func specialistKey(specialist *specialistmodel.Specialist) string {
|
||||
if specialist == nil {
|
||||
return ""
|
||||
}
|
||||
store.DB.Create(&model.AiCallLog{
|
||||
UserID: userID,
|
||||
Capability: "assistant_chat",
|
||||
SpecialistKey: specialistKey,
|
||||
Status: "success",
|
||||
})
|
||||
return specialist.Key
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
|
||||
"eai_agentplatform/backend/internal/ai"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
@@ -71,7 +69,7 @@ func ReviewContract(c *gin.Context) {
|
||||
}
|
||||
|
||||
result := runContractReview(req)
|
||||
logContractReview(user.ID, req.Content, result)
|
||||
logContractReview(user.ID)
|
||||
web.OK(c, result)
|
||||
}
|
||||
|
||||
@@ -458,10 +456,6 @@ func parseAIRisks(content string) []RiskItem {
|
||||
}
|
||||
|
||||
// logContractReview 记录审查日志
|
||||
func logContractReview(userID uint, content string, result ReviewResult) {
|
||||
store.DB.Create(&model.AiCallLog{
|
||||
UserID: userID,
|
||||
Capability: "contract_review",
|
||||
Status: "success",
|
||||
})
|
||||
func logContractReview(userID uint) {
|
||||
ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindContractReview, Success: true})
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/ai"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
@@ -60,7 +58,7 @@ func ProofreadCopy(c *gin.Context) {
|
||||
}
|
||||
|
||||
result := runProofreading(req)
|
||||
logProofreading(user.ID, req.Text, result)
|
||||
logProofreading(user.ID)
|
||||
web.OK(c, result)
|
||||
}
|
||||
|
||||
@@ -255,9 +253,8 @@ func checkNumbers(text string) []ProofreadingItem {
|
||||
}
|
||||
|
||||
// logProofreading 记录校对日志
|
||||
func logProofreading(userID uint, content string, result CopyProofreadingResult) {
|
||||
(json.Marshal(result))
|
||||
store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "copy_proofread", Status: "success"})
|
||||
func logProofreading(userID uint) {
|
||||
ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindCopyProofread, Success: true})
|
||||
}
|
||||
|
||||
// QuickProofread GET /api/copy/quick —— 快速校对(简单输入)
|
||||
@@ -280,6 +277,6 @@ func QuickProofread(c *gin.Context) {
|
||||
CheckTypos: true,
|
||||
})
|
||||
|
||||
logProofreading(user.ID, text, result)
|
||||
logProofreading(user.ID)
|
||||
web.OK(c, result)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// 本文件的仓库实例:courseRepo 声明在 position.go,mediaRepo 声明在 media.go。
|
||||
|
||||
// courseView 课程详情视图(含绑定产品)
|
||||
func courseView(c *gin.Context, co model.Course) {
|
||||
out := gin.H{
|
||||
@@ -30,15 +32,12 @@ func courseView(c *gin.Context, co model.Course) {
|
||||
"updated_at": co.UpdatedAt,
|
||||
}
|
||||
if co.RelatedProductID != nil {
|
||||
var p model.Product
|
||||
if store.DB.Where("id = ? AND status != ?", *co.RelatedProductID, "inactive").First(&p).Error == nil {
|
||||
if p, ok := productRepo.GetVisibleByID(*co.RelatedProductID); ok {
|
||||
out["product"] = gin.H{"id": p.ID, "code": p.Code, "name": p.Name, "category": p.Category}
|
||||
}
|
||||
}
|
||||
|
||||
var medias []model.MediaFile
|
||||
store.DB.Where("bind_type = ? AND bind_id = ? AND status = ?", "course", co.ID, "approved").
|
||||
Order("id ASC").Find(&medias)
|
||||
medias := mediaRepo.ListByBind("course", co.ID)
|
||||
if len(medias) > 0 {
|
||||
items := make([]gin.H, 0, len(medias))
|
||||
for _, m := range medias {
|
||||
@@ -56,23 +55,7 @@ func courseView(c *gin.Context, co model.Course) {
|
||||
|
||||
// ListCourses GET /api/courses?category=&status=
|
||||
func ListCourses(c *gin.Context) {
|
||||
q := store.DB.Model(&model.Course{})
|
||||
if cat := c.Query("category"); cat != "" {
|
||||
q = q.Where("category = ?", cat)
|
||||
}
|
||||
switch st := c.Query("status"); st {
|
||||
case "": // 默认仅 active(员工浏览)
|
||||
q = q.Where("status = ?", "active")
|
||||
case "all": // 管理员维护全量
|
||||
default:
|
||||
q = q.Where("status = ?", st)
|
||||
}
|
||||
var items []model.Course
|
||||
if err := q.Order("id ASC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询课程失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
web.OK(c, courseRepo.List(c.Query("category"), c.Query("status")))
|
||||
}
|
||||
|
||||
// GetCourse GET /api/courses/{id}
|
||||
@@ -81,8 +64,8 @@ func GetCourse(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var co model.Course
|
||||
if err := store.DB.First(&co, id).Error; err != nil {
|
||||
co, found := courseRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("课程不存在"))
|
||||
return
|
||||
}
|
||||
@@ -100,16 +83,14 @@ func CreateCourse(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("编号、名称、分类为必填"))
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
store.DB.Model(&model.Course{}).Where("code = ?", co.Code).Count(&count)
|
||||
if count > 0 {
|
||||
if courseRepo.CountByCode(co.Code, nil) > 0 {
|
||||
web.Fail(c, web.NewConflictError("课程编号已存在"))
|
||||
return
|
||||
}
|
||||
if co.Status == "" {
|
||||
co.Status = "active"
|
||||
}
|
||||
if err := store.DB.Create(&co).Error; err != nil {
|
||||
if !courseRepo.Insert(&co) {
|
||||
web.Fail(c, web.NewBadRequest("创建课程失败"))
|
||||
return
|
||||
}
|
||||
@@ -122,8 +103,8 @@ func UpdateCourse(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var co model.Course
|
||||
if err := store.DB.First(&co, id).Error; err != nil {
|
||||
co, found := courseRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("课程不存在"))
|
||||
return
|
||||
}
|
||||
@@ -141,9 +122,7 @@ func UpdateCourse(c *gin.Context) {
|
||||
}
|
||||
|
||||
if req.Code != "" && req.Code != co.Code {
|
||||
var count int64
|
||||
store.DB.Model(&model.Course{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count)
|
||||
if count > 0 {
|
||||
if courseRepo.CountByCode(req.Code, &id) > 0 {
|
||||
web.Fail(c, web.NewConflictError("课程编号已存在"))
|
||||
return
|
||||
}
|
||||
@@ -161,7 +140,7 @@ func UpdateCourse(c *gin.Context) {
|
||||
co.RelatedProductID = req.RelatedProductID
|
||||
co.Status = req.Status
|
||||
|
||||
if err := store.DB.Save(&co).Error; err != nil {
|
||||
if !courseRepo.Update(&co) {
|
||||
web.Fail(c, web.NewBadRequest("更新课程失败"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,8 +12,6 @@ import (
|
||||
"eai_agentplatform/backend/internal/ai"
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
@@ -88,7 +85,7 @@ func TranslateDocument(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 保存日志
|
||||
logTranslation(user.ID, req.Content, result)
|
||||
logTranslation(user.ID)
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"result": result,
|
||||
@@ -258,7 +255,6 @@ func translateToPDF(_ string, result DocumentTranslateResult) ([]byte, error) {
|
||||
}
|
||||
|
||||
// logTranslation 记录翻译日志
|
||||
func logTranslation(userID uint, content string, result DocumentTranslateResult) {
|
||||
(json.Marshal(result))
|
||||
store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "document_translate", Status: "success"})
|
||||
func logTranslation(userID uint) {
|
||||
ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindDocumentTranslate, Success: true})
|
||||
}
|
||||
|
||||
@@ -30,14 +30,14 @@ func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string,
|
||||
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
|
||||
if err != nil {
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: aiRoute.Provider,
|
||||
UserID: userID, UsageKind: ai.UsageKindEssayGrade, Provider: aiRoute.Provider,
|
||||
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
|
||||
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
return 0, "", err
|
||||
}
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: usedAiRoute.Provider,
|
||||
UserID: userID, UsageKind: ai.UsageKindEssayGrade, Provider: usedAiRoute.Provider,
|
||||
AIRouteID: usedAiRoute.RouteID, Model: usedAiRoute.Model, Success: true,
|
||||
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
|
||||
@@ -16,10 +16,13 @@ import (
|
||||
"eai_agentplatform/backend/internal/auth"
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/repository"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
var mistakeRepo repository.MistakeRecordRepo
|
||||
|
||||
// Option 题目选项
|
||||
type Option struct {
|
||||
Key string `json:"key"`
|
||||
@@ -213,11 +216,6 @@ func CreatePaper(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("创建考试失败"))
|
||||
return
|
||||
}
|
||||
// 发布正式考试:通知全员(提醒及时参加)
|
||||
if p.Type == "formal" {
|
||||
notifyAllEmployees("exam_publish", "新考试发布",
|
||||
fmt.Sprintf("管理员发布了正式考试「%s」,请及时参加", p.Name), "/exam/formal")
|
||||
}
|
||||
web.OK(c, p)
|
||||
}
|
||||
|
||||
@@ -306,13 +304,9 @@ func ExamList(c *gin.Context) {
|
||||
out := make([]row, 0, len(items))
|
||||
for _, p := range items {
|
||||
st := "available"
|
||||
if p.Type == "formal" && u != nil {
|
||||
var n int64
|
||||
store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n)
|
||||
if n > 0 {
|
||||
if p.Type == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) {
|
||||
st = "completed"
|
||||
}
|
||||
}
|
||||
out = append(out, row{
|
||||
ID: p.ID, Name: p.Name, Type: p.Type, Domain: p.Domain,
|
||||
QuestionCount: p.QuestionCount, TotalScore: p.TotalScore,
|
||||
@@ -322,9 +316,9 @@ func ExamList(c *gin.Context) {
|
||||
web.OK(c, out)
|
||||
}
|
||||
|
||||
// ExamCover GET /api/exam/cover?id={paperId}
|
||||
// ExamCover GET /api/exam/cover?paper_id={paper_id}
|
||||
func ExamCover(c *gin.Context) {
|
||||
id64, err := strconv.ParseUint(c.Query("id"), 10, 64)
|
||||
id64, err := strconv.ParseUint(c.Query("paper_id"), 10, 64)
|
||||
if err != nil || id64 == 0 {
|
||||
web.Fail(c, web.NewBadRequest("无效的 id"))
|
||||
return
|
||||
@@ -564,14 +558,10 @@ func ExamStart(c *gin.Context) {
|
||||
web.Fail(c, web.NewNotFoundError("考试不存在或已停用"))
|
||||
return
|
||||
}
|
||||
if p.Type == "formal" && u != nil {
|
||||
var n int64
|
||||
store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n)
|
||||
if n > 0 {
|
||||
if p.Type == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) {
|
||||
web.Fail(c, web.NewConflictError("已参加过该正式考试"))
|
||||
return
|
||||
}
|
||||
}
|
||||
questions, err := pickQuestions(p)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest(err.Error()))
|
||||
@@ -817,14 +807,10 @@ func ExamSubmit(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 正式考不可重复交卷
|
||||
if stype == "formal" && u != nil {
|
||||
var n int64
|
||||
store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n)
|
||||
if n > 0 {
|
||||
if stype == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) {
|
||||
web.Fail(c, web.NewConflictError("已参加过该正式考试"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ids := splitIDs(qidsStr)
|
||||
if len(ids) == 0 {
|
||||
@@ -931,9 +917,6 @@ func ExamSubmit(c *gin.Context) {
|
||||
if passed {
|
||||
awardPoints(u.ID, "formal_pass", ptFormalPass, "paper", p.ID)
|
||||
issueCertificate(u, rec)
|
||||
notifyUser(u.ID, "exam_pass", "考试通过",
|
||||
fmt.Sprintf("恭喜通过「%s」考试,成绩 %d 分,已颁发合格证书", p.Name, score),
|
||||
"/exam/my-certificates")
|
||||
}
|
||||
} else if stype == "self_test" && u != nil {
|
||||
// 自测不落分、不存档(P03),仅计一次练习积分
|
||||
@@ -960,19 +943,17 @@ func splitIDs(s string) []uint {
|
||||
// ExamRecordList GET /api/exam/record?page=&size= —— 我的考试记录
|
||||
func ExamRecordList(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
q := store.DB.Model(&model.ExamRecord{}).Where("user_id = ?", u.ID)
|
||||
var items []model.ExamRecord
|
||||
if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询考试记录失败"))
|
||||
return
|
||||
items := examRecordRepo.ListByUser(u.ID)
|
||||
if items == nil {
|
||||
items = []model.ExamRecord{}
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// ExamRecordDetail GET /api/exam/record/{recordId} —— 记录详情回溯
|
||||
// ExamRecordDetail GET /api/exam/record/{record_id} —— 记录详情回溯
|
||||
func ExamRecordDetail(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
id, ok := parseID(c, "recordId")
|
||||
id, ok := parseID(c, "record_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -997,28 +978,22 @@ func ExamRecordDetail(c *gin.Context) {
|
||||
|
||||
// ============ 错题本(学员自助) ============
|
||||
|
||||
// recordMistake 答错入本:按 (user_id, question_id, source) 去重,再次答错则更新并重置为未掌握。
|
||||
func recordMistake(userID, questionID uint, q model.Question, userAns any, correct []string, source string) {
|
||||
// mistakePayload 组装错题本记录:作答与正确答案以 JSON 落库,
|
||||
// 与 mistakeView 的反序列化、MyMistakes 的出参形状对齐。
|
||||
func mistakePayload(userID, questionID uint, source string, q model.Question, userAns any, correct []string) model.MistakeRecord {
|
||||
userAnsJSON, _ := json.Marshal(userAns)
|
||||
correctJSON, _ := json.Marshal(correct)
|
||||
var rec model.MistakeRecord
|
||||
err := store.DB.Where("user_id = ? AND question_id = ? AND source = ?", userID, questionID, source).First(&rec).Error
|
||||
if err != nil {
|
||||
store.DB.Create(&model.MistakeRecord{
|
||||
return model.MistakeRecord{
|
||||
UserID: userID, QuestionID: questionID, Source: source,
|
||||
QuestionType: q.Type, QuestionStem: q.Stem,
|
||||
UserAnswer: string(userAnsJSON), CorrectAnswer: string(correctJSON),
|
||||
Explanation: q.Explanation, Resolved: false,
|
||||
})
|
||||
return
|
||||
Explanation: q.Explanation,
|
||||
}
|
||||
rec.QuestionType = q.Type
|
||||
rec.QuestionStem = q.Stem
|
||||
rec.UserAnswer = string(userAnsJSON)
|
||||
rec.CorrectAnswer = string(correctJSON)
|
||||
rec.Explanation = q.Explanation
|
||||
rec.Resolved = false
|
||||
store.DB.Save(&rec)
|
||||
}
|
||||
|
||||
// recordMistake 答错入本:按 (user_id, question_id, source) 去重,再次答错则更新并重置为未掌握。
|
||||
func recordMistake(userID, questionID uint, q model.Question, userAns any, correct []string, source string) {
|
||||
mistakeRepo.RecordWrong(mistakePayload(userID, questionID, source, q, userAns, correct))
|
||||
}
|
||||
|
||||
// mistakeView 错题出参(answer 反序列化,便于前端直接展示)
|
||||
@@ -1038,11 +1013,7 @@ type mistakeView struct {
|
||||
// MyMistakes GET /api/exam/mistakes —— 我的错题本
|
||||
func MyMistakes(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
var items []model.MistakeRecord
|
||||
if err := store.DB.Where("user_id = ?", u.ID).Order("updated_at DESC").Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询错题本失败"))
|
||||
return
|
||||
}
|
||||
items := mistakeRepo.ListByUser(u.ID)
|
||||
out := make([]mistakeView, 0, len(items))
|
||||
for _, it := range items {
|
||||
var userAns any
|
||||
@@ -1069,8 +1040,8 @@ func ResolveMistake(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rec model.MistakeRecord
|
||||
if err := store.DB.First(&rec, id).Error; err != nil {
|
||||
rec, found := mistakeRepo.GetByID(id)
|
||||
if !found {
|
||||
web.Fail(c, web.NewNotFoundError("错题记录不存在"))
|
||||
return
|
||||
}
|
||||
@@ -1088,7 +1059,7 @@ func ResolveMistake(c *gin.Context) {
|
||||
}
|
||||
wasResolved := rec.Resolved
|
||||
rec.Resolved = target
|
||||
if err := store.DB.Save(&rec).Error; err != nil {
|
||||
if !mistakeRepo.Update(&rec) {
|
||||
web.Fail(c, web.NewBadRequest("更新错题状态失败"))
|
||||
return
|
||||
}
|
||||
@@ -1112,18 +1083,7 @@ func MistakePractice(c *gin.Context) {
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
q := store.DB.Where("user_id = ?", u.ID)
|
||||
if req.Source != "" {
|
||||
q = q.Where("source = ?", req.Source)
|
||||
}
|
||||
if req.OnlyUnresolved {
|
||||
q = q.Where("resolved = ?", false)
|
||||
}
|
||||
var recs []model.MistakeRecord
|
||||
if err := q.Order("updated_at DESC").Find(&recs).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询错题失败"))
|
||||
return
|
||||
}
|
||||
recs := mistakeRepo.ListForPractice(u.ID, req.Source, req.OnlyUnresolved)
|
||||
if len(recs) == 0 {
|
||||
web.Fail(c, web.NewBadRequest("暂无可重练的错题"))
|
||||
return
|
||||
@@ -1187,26 +1147,16 @@ func MistakePractice(c *gin.Context) {
|
||||
|
||||
// touchMistakeOnPractice 错题重练判分后同步错题状态:答对置为已掌握(加积分),答错重置为未掌握。
|
||||
func touchMistakeOnPractice(userID, questionID uint, q model.Question, userAns any, correct []string, resolved bool) {
|
||||
var recs []model.MistakeRecord
|
||||
store.DB.Where("user_id = ? AND question_id = ?", userID, questionID).Find(&recs)
|
||||
if len(recs) == 0 {
|
||||
payload := mistakePayload(userID, questionID, "", q, userAns, correct)
|
||||
flipped, touched := mistakeRepo.TouchOnPractice(payload, resolved)
|
||||
if !touched {
|
||||
// 防御性兜底:理论上重练题目均来自错题本,这里创建一条
|
||||
recordMistake(userID, questionID, q, userAns, correct, "re_practice")
|
||||
payload.Source = "re_practice"
|
||||
mistakeRepo.RecordWrong(payload)
|
||||
return
|
||||
}
|
||||
userAnsJSON, _ := json.Marshal(userAns)
|
||||
correctJSON, _ := json.Marshal(correct)
|
||||
for i := range recs {
|
||||
was := recs[i].Resolved
|
||||
recs[i].Resolved = resolved
|
||||
recs[i].QuestionType = q.Type
|
||||
recs[i].QuestionStem = q.Stem
|
||||
recs[i].UserAnswer = string(userAnsJSON)
|
||||
recs[i].CorrectAnswer = string(correctJSON)
|
||||
recs[i].Explanation = q.Explanation
|
||||
store.DB.Save(&recs[i])
|
||||
if resolved && !was {
|
||||
awardPoints(userID, "mistake_resolved", ptMistakeResolved, "mistake", recs[i].ID)
|
||||
}
|
||||
// 仅在「未掌握 → 已掌握」时加分,避免反复重练刷分
|
||||
for _, id := range flipped {
|
||||
awardPoints(userID, "mistake_resolved", ptMistakeResolved, "mistake", id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// writeFile helper: create a file in zip and write content
|
||||
// zipWriteFile helper: create a file in zip and write content
|
||||
func zipWriteFile(zw *zip.Writer, name, content string) error {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ExportReportDOCX(report reportContent) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
zipWriteFile(zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`)
|
||||
zipWriteFile(zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`)
|
||||
var doc strings.Builder
|
||||
doc.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
|
||||
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="48"/></w:rPr><w:t>` + escapeXML(report.Topic) + `</w:t></w:r></w:p>`)
|
||||
doc.WriteString(`<w:p><w:r><w:rPr><w:b/><w:sz w:val="24"/></w:rPr><w:t>摘要</w:t></w:r></w:p>`)
|
||||
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="24"/></w:rPr><w:t>` + escapeXML(report.Summary) + `</w:t></w:r></w:p>`)
|
||||
for _, ch := range report.Chapters {
|
||||
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="28"/></w:rPr><w:t>` + escapeXML(ch.Title) + `</w:t></w:r></w:p>`)
|
||||
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="22"/></w:rPr><w:t>` + escapeXML(ch.Body) + `</w:t></w:r></w:p>`)
|
||||
}
|
||||
doc.WriteString(`</w:body></w:document>`)
|
||||
zipWriteFile(zw, "word/document.xml", doc.String())
|
||||
zipWriteFile(zw, "word/styles.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styles xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><style type="paragraph" styleId="Title"><name val="Title"/><uiPriority uiPriority="9"/><rPr><rStyle val="Title"/><rFonts w:ascii="Arial" w:hAnsi="Arial"/><b/><sz w:val="48"/></rPr></style><style type="paragraph" styleId="Heading1"><name val="Heading 1"/><rPr><rStyle val="Heading1"/><rFonts w:ascii="Arial" w:hAnsi="Arial"/><b/><sz w:val="28"/></rPr></style></styles>`)
|
||||
zipWriteFile(zw, "word/_rels/document.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`)
|
||||
zipWriteFile(zw, "docProps/core.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>` + escapeXML(report.Topic) + `</dc:title></cp:coreProperties>`)
|
||||
zipWriteFile(zw, "docProps/app.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Microsoft Word</Application></Properties>`)
|
||||
zw.Close()
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ExportReportPPTX(report reportContent) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
layoutRefs := make([]string, 0)
|
||||
for i := 0; i < len(report.Chapters)+1; i++ {
|
||||
layoutRefs = append(layoutRefs, "rId"+fmt.Sprint(i+1))
|
||||
}
|
||||
slideRels := strings.Join(layoutRefs, "")
|
||||
|
||||
zipWriteFile(zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/></Types>`)
|
||||
zipWriteFile(zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`)
|
||||
|
||||
preXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" changedTime="` + timeStr() + `">` +
|
||||
`<p:sldMasterId href="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"/>` +
|
||||
`<p:sldIds>` + slideRels + `</p:sldIds></p:presentation>`
|
||||
zipWriteFile(zw, "ppt/presentation.xml", preXML)
|
||||
|
||||
zipWriteFile(zw, "ppt/_rels/presentation.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMaster/slideMaster1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayouts" Target="slideLayouts/slideLayout1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/></Relationships>`)
|
||||
|
||||
titleSlide := buildSlideTitle(report.Topic, report.Summary)
|
||||
zipWriteFile(zw, "ppt/slides/slide1.xml", titleSlide)
|
||||
zipWriteFile(zw, "ppt/slides/_rels/slide1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`)
|
||||
|
||||
for i, ch := range report.Chapters {
|
||||
slideNum := i + 2
|
||||
slidePath := fmt.Sprintf("ppt/slides/slide%d.xml", slideNum)
|
||||
relnPath := fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", slideNum)
|
||||
zipWriteFile(zw, slidePath, buildSlideChapter(ch.Title, ch.Body))
|
||||
zipWriteFile(zw, relnPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`)
|
||||
}
|
||||
|
||||
zipWriteFile(zw, "ppt/slideLayouts/slideLayout1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" type="title"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="1219200"/></p:xfrm></p:spPr><p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr alignment="ctr"/><a:r><a:rPr lang="zh-CN" sz="4400" dirty="0"/><a:t>标题</a:t></a:r><a:endParaRPr lang="zh-CN" sz="4400" dirty="0"/></a:p></a:txBody></p:sp><p:sp><p:nvSpPr><p:cNvPr id="2" name="内容"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1524000"/><p:ext cx="6400800" cy="5334000"/></p:xfrm></p:spPr><p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2400" dirty="0"/><a:t>内容</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2400" dirty="0"/></a:p></a:txBody></p:sp></p:spTree></p:cSld></p:sldLayout>`)
|
||||
|
||||
zipWriteFile(zw, "ppt/theme/theme1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Design Theme"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F497D"/></a:dk2><a:lt2><a:srgbClr val="E8E8E8"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="4472C4"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="9563C1"/></a:folHlink></a:clrScheme><a:fontScheme name="Office"><a:majorFont><a:latin fontFamily="Calibri" script="latn"/><a:ea script="hani"/><a:cs fontFamily="Cambria" script="latin"/></a:majorFont><a:minorFont><a:latin fontFamily="Calibri" script="latn"/><a:ea script="hani"/><a:cs fontFamily="Calibri" script="latin"/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"/></a:themeElements></a:theme>`)
|
||||
|
||||
zipWriteFile(zw, "ppt/media/", "")
|
||||
zw.Close()
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func buildSlideTitle(title, summary string) string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<p:slide xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" p:sldId="` + timeStr() + `">` +
|
||||
`<p:cSld><p:spTree>` +
|
||||
`<p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="1219200"/></p:xfrm></p:spPr>` +
|
||||
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" anchor="ctr"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr alignment="ctr"/><a:r><a:rPr lang="zh-CN" sz="4400" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:b/><a:t>` + escapeXML(title) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="4400" dirty="0"/></a:p></a:txBody></p:sp>` +
|
||||
`<p:sp><p:nvSpPr><p:cNvPr id="2" name="摘要"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1524000"/><p:ext cx="6400800" cy="5334000"/></p:xfrm></p:spPr>` +
|
||||
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2400" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:t>` + escapeXML(summary) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2400" dirty="0"/></a:p></a:txBody></p:sp>` +
|
||||
`</p:spTree></p:cSld></p:slide>`
|
||||
}
|
||||
|
||||
func buildSlideChapter(title, body string) string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<p:slide xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" p:sldId="` + timeStr() + `">` +
|
||||
`<p:cSld><p:spTree>` +
|
||||
`<p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="914400"/></p:xfrm></p:spPr>` +
|
||||
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" anchor="ctr"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr/><a:r><a:rPr lang="zh-CN" sz="3200" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:b/><a:t>` + escapeXML(title) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="3200" dirty="0"/></a:p></a:txBody></p:sp>` +
|
||||
`<p:sp><p:nvSpPr><p:cNvPr id="2" name="内容"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1219200"/><p:ext cx="6400800" cy="5791200"/></p:xfrm></p:spPr>` +
|
||||
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2000" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:t>` + escapeXML(body) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2000" dirty="0"/></a:p></a:txBody></p:sp>` +
|
||||
`</p:spTree></p:cSld></p:slide>`
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ExportReportXLSX(report reportContent) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
ss := []string{}
|
||||
addSS := func(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return len(ss)
|
||||
}
|
||||
ss = append(ss, s)
|
||||
return len(ss) - 1
|
||||
}
|
||||
addSS(report.Topic)
|
||||
addSS(report.Summary)
|
||||
for _, ch := range report.Chapters {
|
||||
addSS(ch.Title)
|
||||
addSS(ch.Body)
|
||||
}
|
||||
zipWriteFile(zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>`)
|
||||
zipWriteFile(zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`)
|
||||
zipWriteFile(zw, "xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><workbookPr date1904="false"/><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="25600" windowHeight="14400"/></bookViews><sheets><sheet name="报告" sheetId="1" r:id="rId1"/></sheets></workbook>`)
|
||||
zipWriteFile(zw, "xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>`)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="` + fmt.Sprint(len(ss)) + `" uniqueCount="` + fmt.Sprint(len(ss)) + `">`)
|
||||
for _, s := range ss {
|
||||
sb.WriteString(`<si><t>` + escapeXML(s) + `</t></si>`)
|
||||
}
|
||||
sb.WriteString(`</sst>`)
|
||||
zipWriteFile(zw, "xl/sharedStrings.xml", sb.String())
|
||||
var sb2 strings.Builder
|
||||
sb2.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>`)
|
||||
row := 1
|
||||
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A1" t="inlineStr"><is><t>` + escapeXML(report.Topic) + `</t></is></c></row>`)
|
||||
row = 2
|
||||
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A2" t="inlineStr"><is><t>摘要</t></is></c><c r="B2" t="inlineStr"><is><t>` + escapeXML(report.Summary) + `</t></is></c></row>`)
|
||||
row = 3
|
||||
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A3" t="inlineStr"><is><t>章节</t></is></c><c r="B3" t="inlineStr"><is><t>内容</t></is></c></row>`)
|
||||
for _, ch := range report.Chapters {
|
||||
row++
|
||||
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A` + fmt.Sprint(row) + `" t="inlineStr"><is><t>` + escapeXML(ch.Title) + `</t></is></c><c r="B` + fmt.Sprint(row) + `" t="inlineStr"><is><t>` + escapeXML(ch.Body) + `</t></is></c></row>`)
|
||||
}
|
||||
sb2.WriteString(`</sheetData></worksheet>`)
|
||||
zipWriteFile(zw, "xl/worksheets/sheet1.xml", sb2.String())
|
||||
zw.Close()
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user