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:
eaiadmin
2026-09-19 01:23:51 +08:00
co-authored by Claude Code
parent ddd2d2cbd8
commit 14f303459e
448 changed files with 18792 additions and 17027 deletions
+50 -5
View File
@@ -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` 才不是“挂在平台上的专题页面”,而是真正的一级对象。
+2
View File
@@ -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
View File
@@ -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
View File
@@ -237,8 +237,8 @@ CatalogObjectView
应该逐步补成显式关系:
- `specialist_skill_binding`
- `app_specialist_binding`
- `app_skill_binding`
- `xapp_specialist_binding`
- `xapp_skill_binding`
- `specialist_connector_binding`
最终应该形成:
+3 -1
View File
@@ -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 # 项目全局状态
+6 -3
View File
@@ -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
@@ -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)
}
@@ -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