Compare commits

..
10 Commits
60 changed files with 3580 additions and 35 deletions
@@ -0,0 +1,474 @@
---
reviewStatus: pending
reviewedAt: null
replacedBy: null
---
# 第一阶段工程基线后端落地设计
## 1. 文档定位
本文档定义 JobRadar 第一阶段工程基线的后端落地边界,供后续模型设计、编码、迁移、测试和验收使用。本文档是待审核设计,不代表相关代码已经实现。
设计依据:
- [第一阶段工程基线开发计划](../../../docs/engineering-baseline/dev-plan.md)
- [架构设计](../../../docs/architecture.md)
- [Agent 契约](../../../docs/agent-contract.md)
- [AI 开发约定](../../../docs/development-guide.md)
当前技术基线为 Python 3.13、Django 6.0.8 和 OpenAI Agents SDK 0.22.0。项目没有已登记的 Django 公共 profile,实现时以当前项目规则和对应版本的官方契约为准。
## 2. 目标与范围
第一阶段将现有 Django 配置骨架扩展为一个可登录、可隔离、可审计、可记录 Agent 运行,但暂不执行真实岗位研究的模块化单体。
阶段完成后应具备:
- Django 内置用户的登录、退出及基础资料维护能力。
- 普通用户之间的数据归属隔离。
- Agent Run、运行事件、工具调用和人工确认的持久化能力。
- 合法的运行状态转换、事件顺序和幂等控制。
- OpenAI Agents SDK 的稳定适配入口及无外部费用的测试替身。
- 包含请求和运行关联标识的脱敏日志。
- SQLite 完整测试及 PostgreSQL 核心兼容验证能力。
第一阶段明确不实现:
- 招聘网站采集、企业调查、岗位标准化、筛选、评分和推荐报告。
- 真实岗位研究 Agent 循环或付费模型调用。
- Celery、Redis、定时调度和失败任务自动重试。
- 公开注册、复杂 RBAC、前后端分离、REST API 和独立前端框架。
- 自动投递简历、自动联系招聘人员或其他不可逆外部操作。
## 3. 总体调用关系
```mermaid
flowchart LR
USER["普通用户"] --> AUTH["Django 登录与 Session"]
AUTH --> PROFILE["用户资料"]
AUTH --> RUN_VIEW["运行列表与详情"]
ADMIN["管理员"] --> ADMIN_SITE["Django Admin"]
ADMIN_SITE --> RUN_SERVICE["Agent Run 服务层"]
RUN_SERVICE --> RUN["AgentRun"]
RUN_SERVICE --> EVENT["AgentRunEvent"]
RUN_SERVICE --> TOOL["ToolCall"]
RUN_SERVICE --> APPROVAL["HumanApproval"]
SDK_ADAPTER["SDK Gateway / 测试替身"] --> RUN_SERVICE
```
所有页面、Admin 扩展和 SDK 适配器通过服务层改变运行状态,不直接拼装跨模型写入。
## 4. 模块职责
### 4.1 `common`
`common` 只提供跨业务复用的基础能力:
- 创建时间和更新时间抽象模型。
- 用户归属抽象模型及用户范围查询入口。
- 领域异常基类。
- 请求关联标识中间件。
- 日志上下文和敏感信息脱敏工具。
`common` 不包含账户、Agent 或未来岗位业务逻辑,也不依赖业务应用。
### 4.2 `accounts`
`accounts` 负责:
- Django 内置 `User` 的一对一扩展资料。
- 当前用户资料的读取和修改。
- 登录、退出及资料页面组织。
- 用户停用约定和资料管理后台。
第一阶段不自定义用户模型,不开放公开注册。用户由管理员创建,日常账户退出使用停用而非物理删除。
### 4.3 `agent_runtime`
`agent_runtime` 负责:
- Agent Run 生命周期和状态转换。
- 运行事件的有序追加。
- 工具调用、幂等键及结果摘要记录。
- 人工确认请求和处理结果。
- SDK 运行结果到本地持久化模型的适配。
- 运行列表、运行详情和管理后台。
该模块不拥有岗位、企业、筛选和评分数据。
### 4.4 依赖方向
```text
JobRadar 配置与 URL
↓
accounts ──────→ common
↓
agent_runtime ─→ common
↓
OpenAI Agents SDK 适配层
```
禁止 `common` 反向依赖业务应用,禁止 `accounts` 依赖 `agent_runtime`。
## 5. 领域对象与数据所有权
### 5.1 用户归属抽象模型
建议提供抽象模型 `UserOwnedModel`:
- `owner`:关联 Django 用户。
- `created_at`:创建时间。
- `updated_at`:更新时间。
规则:
- 所有用户私有模型必须继承该模型或提供等价的 `owner` 字段。
- 普通查询必须从 `owner=request.user` 的数据范围开始,不能先全表查询再在 Python 中校验。
- 审计数据对用户删除采用保护策略;包含历史运行记录的用户不得直接物理删除。
- 管理员跨用户查询仅允许出现在 Django Admin 或明确的管理服务中。
### 5.2 `UserProfile`
建议字段:
- `user`:与 Django `User` 一对一关联。
- `display_name`:可选显示名。
- `timezone`:默认 `Asia/Shanghai`。
- `created_at`、`updated_at`。
第一阶段不加入技能、薪资、地区和企业偏好。这些内容属于后续个人画像领域,应在需求明确后独立建模并支持版本化。
资料由账户服务显式 `get_or_create`,不依赖隐藏的 Signal 自动创建。
### 5.3 `AgentRun`
建议字段分组:
- 归属:`owner`。
- 标识与状态:主键、`title`、`status`、`lock_version`。
- 输入输出:`input_summary`、`output_summary`。
- 版本:`agent_name`、`agent_version`、`instruction_version`、`toolset_version`、`model_name`。
- 追踪:`trace_id`、`started_at`、`finished_at`、`duration_ms`。
- 用量:`input_tokens`、`output_tokens`、`estimated_cost`。
- 失败:`error_code`、`error_summary`。
- 审计:`created_at`、`updated_at`。
输入、输出和错误只保存经过脱敏、限制大小的摘要,不保存 API Key、密码、Cookie 或完整敏感工具参数。第一阶段不执行真实模型调用,因此 `model_name`、token 和费用字段允许为空。
### 5.4 `AgentRunEvent`
建议字段:
- `run`、`sequence`、`event_type`。
- `summary`、`payload_summary`。
- `occurred_at`、`created_at`。
约束:
- `(run, sequence)` 唯一。
- 默认按 Run 和序号升序读取。
- 事件作为审计记录追加,普通业务不得修改或删除。
事件类型至少包括:
- `run_created`、`run_started`、`run_paused`、`run_resumed`。
- `run_succeeded`、`run_failed`、`run_cancelled`。
- `tool_started`、`tool_completed`、`tool_failed`。
- `approval_requested`、`approval_resolved`。
### 5.5 `ToolCall`
建议字段分组:
- 归属:`run`、`call_id`。
- 工具:`tool_name`、`tool_version`、`idempotency_key`。
- 状态:`status`、`started_at`、`finished_at`、`duration_ms`。
- 摘要:`arguments_summary`、`result_summary`。
- 失败:`error_code`、`error_summary`。
- 审计:`created_at`、`updated_at`。
约束:
- `(run, call_id)` 唯一。
- 非空幂等键在明确的业务作用域内唯一。
- 已成功的相同幂等调用不得重复产生副作用。
- 第一阶段只验证记录能力,不实现真实采集工具。
### 5.6 `HumanApproval`
建议字段:
- `run`、`request_key`、`approval_type`、`status`。
- `request_summary`、`decision_summary`。
- `requested_at`、`resolved_at`、`resolved_by`。
- `created_at`、`updated_at`。
状态包括 `pending`、`approved`、`rejected`、`expired` 和 `cancelled`。
约束:
- `(run, request_key)` 唯一。
- 只有 `pending` 状态可以处理。
- `resolved_by` 必须是 Run 所属用户或具备管理权限的管理员。
- 第一阶段只在 Admin 中处理人工确认,不开发独立用户审批页面。
数据库字段类型、索引名和完整删除策略应在编码前通过数据库专项设计进一步确认。
## 6. Agent Run 状态机
运行状态:
- `pending`
- `running`
- `waiting_approval`
- `succeeded`
- `failed`
- `cancelled`
允许的转换:
| 当前状态 | 可转换状态 |
| --- | --- |
| `pending` | `running`、`cancelled` |
| `running` | `waiting_approval`、`succeeded`、`failed`、`cancelled` |
| `waiting_approval` | `running`、`failed`、`cancelled` |
| `succeeded` | 无 |
| `failed` | 无 |
| `cancelled` | 无 |
状态规则:
- 终态不可再次转换。
- 成功和失败必须记录完成时间;失败还必须记录脱敏错误摘要。
- 进入 `waiting_approval` 时必须在同一事务中创建待处理的 `HumanApproval` 和对应事件。
- 确认通过后才能恢复运行;确认拒绝默认将 Run 置为 `cancelled`。
- 状态转换和对应事件必须在同一事务中提交。
- 第一阶段不支持失败 Run 原地重试;后续重试应创建新 Run 并关联原 Run。
## 7. 服务层设计
### 7.1 账户服务
建议职责:
- 获取或显式创建当前用户资料。
- 修改当前用户自己的资料。
- 由管理员停用指定用户。
普通用户请求其他用户资料时按资源不存在处理,避免泄露目标用户是否存在。
### 7.2 Agent Run 服务
服务层应提供以下原子能力:
- 创建 Run。
- 开始、完成、失败或取消 Run。
- 追加运行事件。
- 开始、完成或失败一次工具调用。
- 创建人工确认请求。
- 处理人工确认结果。
每个状态写方法按统一顺序执行:
1. 校验操作者身份、用户归属或管理权限。
2. 锁定需要修改的记录。
3. 校验当前状态和目标状态。
4. 修改主记录。
5. 追加不可变事件或更新关联记录。
6. 提交事务后再触发任何外部操作。
## 8. 事务、并发与一致性
关键状态变更使用 `transaction.atomic()`。
同一 Run 的并发控制策略:
- PostgreSQL 使用行锁串行化状态更新。
- `lock_version` 提供乐观并发检测。
- 事件序号在锁定 Run 后生成。
- `(run, sequence)` 唯一约束作为最后一致性防线。
- SQLite 测试只验证业务约束,不能代替 PostgreSQL 行锁与并发行为验证。
外部 SDK 或工具调用不得包裹在数据库长事务中:
```text
事务 1:登记调用开始 → 提交
外部调用:不持有数据库事务
事务 2:登记成功或失败 → 更新 Run → 追加事件 → 提交
```
该边界避免网络或模型调用长期占用数据库连接和行锁。
## 9. SDK 适配边界
第一阶段只建立 SDK 适配协议和测试替身:
- `AgentExecutionRequest`:本地运行编号、用户编号、版本和预算摘要。
- `AgentExecutionResult`:状态、输出摘要、trace 标识、用量和错误摘要。
- `AgentRunnerGateway`:运行入口协议。
- `StubAgentRunnerGateway`:自动化测试替身。
- `OpenAIAgentRunnerGateway`:真实 SDK 适配位置,岗位研究逻辑留到第二阶段。
约束:
- 未配置 OpenAI API Key 时,登录、资料、Admin 和运行查询仍应正常工作。
- 只有调用真实 SDK Gateway 时才检查 API Key。
- API Key 不进入运行上下文、数据库或日志。
- 第一阶段不接入 Celery,也不在 Web 请求内执行长时间 Agent Run。
- 使用测试替身验证运行开始、事件记录、成功和失败链路。
Celery 是目标架构中的后续运行载体;第一阶段仅稳定持久化和适配边界,两者不冲突。
## 10. 页面、权限与错误语义
建议路由:
```text
/accounts/login/
/accounts/logout/
/accounts/profile/
/runs/
/runs/<uuid>/
```
访问规则:
- 未登录用户访问资料和 Run 页面时跳转到登录页。
- 普通用户查询始终限定 `owner=request.user`。
- 跨用户访问 Run 统一返回 404,避免暴露资源存在性。
- 资料修改使用 POST 并启用 CSRF。
- Run 列表分页并按创建时间倒序。
- Run 详情按事件序号展示事件和工具调用摘要。
- 第一阶段不提供公开注册、账户自助删除或 HTTP API。
Admin 规则:
- 管理员可以维护用户资料并查看全部 Agent Run。
- 运行事件和完成后的工具调用原则上只读。
- Admin 中展示的参数和结果同样必须脱敏。
领域错误分类:
- `ResourceNotFound`:资源不存在或无权访问,页面响应为 404。
- `ValidationError`:输入不合法,显示表单错误或返回 400。
- `InvalidStateTransition`:运行状态冲突,语义为 409。
- `DuplicateOperation`:重复操作或幂等冲突,语义为 409。
- `AgentConfigurationError`:SDK 配置缺失,明确提示该功能不可用。
- `ExternalServiceError`:外部调用失败,保存脱敏错误并将 Run 置为失败。
用户响应不得包含堆栈、数据库信息、完整外部响应或敏感配置。
## 11. 日志与审计
增加请求关联标识中间件:
- 接受格式合法的入站关联标识,否则生成新标识。
- 将关联标识加入响应头和日志上下文。
- Agent 日志同时携带 `run_id`、`trace_id` 和 `tool_call_id`。
- 用户相关日志只记录内部用户编号。
必须记录:
- 登录失败和账户停用。
- Run 创建及每次状态转换。
- 工具调用开始、完成和失败。
- 人工确认请求及处理结果。
- 非法状态转换和跨用户访问尝试。
禁止记录:
- API Key、密码和 Session Cookie。
- 招聘网站完整 Cookie。
- 未脱敏的工具参数。
- 模型返回的完整敏感内容。
## 12. 实施顺序
实现采用“功能闭环驱动、模块边界承载”的纵向切片,而不是依次把各技术模块全部做完:
1. 用户登录、资料维护和跨用户隔离闭环。
2. 管理员创建测试 Run 并审计状态、事件、工具调用和人工确认的闭环。
3. 普通用户查看自己的运行列表和运行轨迹闭环。
4. SDK 测试替身驱动一次成功或失败持久化运行的闭环。
5. SQLite、页面、日志、质量工具和 PostgreSQL 的完整验收闭环。
每个切片内部仍按模型与迁移、服务与事务、入口与页面、测试与文档的依赖顺序实施。切片必须形成可运行结果并完成相应验证,才进入下一片;没有当前需求的未来模块和抽象不提前创建。
## 13. 测试与验收策略
自动化测试至少覆盖:
- 用户资料首次创建和重复获取。
- 未登录访问拒绝。
- 用户只能读取和修改自己的资料。
- 用户 A 查询用户 B 的 Run 返回 404。
- 管理员能够查看全部运行记录。
- 所有合法和非法状态转换。
- 终态不可修改。
- 并发状态更新只能有一个成功。
- 事件序号连续且唯一。
- 工具调用幂等键冲突。
- 工具调用成功、失败和超时记录。
- 人工确认只能处理一次且处理人权限正确。
- 敏感字段不会写入日志和摘要。
- 未配置 API Key 时非 Agent 页面正常。
- SDK 测试替身的成功、失败和异常链路。
- SQLite 迁移和全量测试。
- PostgreSQL 空库迁移、唯一约束和并发验证。
阶段验收命令沿用开发计划:
```powershell
python manage.py check
python manage.py check --deploy --settings=JobRadar.settings.production
python manage.py makemigrations --check --dry-run
python manage.py migrate
pytest
ruff check .
```
生产配置检查需要注入非秘密的验证用配置和隔离的数据库参数,不得连接生产库执行测试。
## 14. 兼容、迁移与回滚
- 新应用按 `common`、`accounts`、`agent_runtime` 的依赖顺序生成迁移。
- 模型和约束同时兼容 SQLite 与 PostgreSQL,不使用数据库方言专属 SQL。
- 每组迁移先在空 SQLite 数据库验证,再在空 PostgreSQL 数据库验证。
- 第一阶段没有既有业务数据迁移,不提供破坏性数据转换。
- 单步实现失败时回滚对应代码和未发布迁移;不得通过修改已发布迁移掩盖问题。
- 外部 SDK 未配置或不可用时关闭真实执行入口,不影响认证、资料和运行查询。
## 15. 风险与待确认项
以下事项不阻塞第一阶段编码,但必须在对应节点前确认:
| 事项 | 当前建议 | 最迟确认时间 |
| --- | --- | --- |
| PostgreSQL 实例位置 | 本地开发继续使用 SQLite | PostgreSQL 集成验证前 |
| OpenAI 模型与预算 | 第一阶段不固定付费模型 | 第二阶段启动前 |
| API Key 注入 | 仅使用环境变量或部署平台密钥 | 真实调用前 |
| 用户物理删除 | 默认禁止,使用停用代替 | 开放账户管理能力前 |
| 摘要最大长度 | 编码前给出统一上限 | 模型详细设计时 |
| 日志和审计保留周期 | 暂不自动清理 | 公网部署前 |
| 人工确认超时 | 第一阶段仅支持手动取消 | 第二阶段运行设计时 |
## 16. 实现交接清单
编码前应进一步固化:
- 使用数据库设计明确字段类型、索引、约束名和删除策略。
- 如需 HTTP API,再使用 API 设计明确端点和响应契约;第一阶段默认只提供 Django 页面。
- 确认 `common`、`accounts`、`agent_runtime` 的应用包名和 URL 命名空间。
- 确认摘要长度、JSON 大小限制和日志脱敏词表。
- 确认 PostgreSQL 集成验证环境。
推荐实现结论:
- 使用 Django 内置用户和一对一 `UserProfile`。
- 使用 `common`、`accounts`、`agent_runtime` 三个应用。
- 由服务层统一权限检查、状态转换和审计写入。
- 运行事件采用不可变追加模式。
- 数据库事务与外部调用分离。
- SDK 使用 Gateway 和测试替身建立稳定边界。
- 第一阶段不引入 Celery、DRF、Vue 或真实模型调用。
@@ -0,0 +1,240 @@
---
reviewStatus: pending
reviewedAt: null
replacedBy: null
---
# 第一阶段工程基线数据库设计
## 1. 设计边界
本文档定义第一阶段用户资料和 Agent 运行审计数据的逻辑结构、约束、索引、迁移与回滚策略。开发和自动化测试使用 SQLite,集成与生产使用 PostgreSQL。
项目尚未确认 PostgreSQL 精确版本,因此本文档不采用版本专属字段、索引方法、表达式索引或方言 SQL。实现以 Django 6.0.8 ORM 和迁移能力表达;版本专属优化须在 PostgreSQL 版本确认后另行评审。
关联设计:
- [后端落地设计](backend-design.md)
- [前端落地设计](frontend-design.md)
- [接口与页面契约设计](interface-design.md)
## 2. 设计原则
- 用户私有数据必须具有明确的用户归属。
- 状态转换由服务层执行,数据库约束负责守住唯一性和基础合法性。
- 运行事件采用追加模式,保留审计轨迹。
- JSON 字段只保存经过脱敏和大小限制的摘要。
- 时间统一存储为支持时区的 Django 时间值,显示时转换到用户时区。
- 金额、token 和耗时使用确定类型,避免浮点误差。
- 表名、约束名和索引名在迁移中显式稳定,避免不同数据库自动命名差异影响排查。
## 3. 实体关系
```mermaid
erDiagram
AUTH_USER ||--o| USER_PROFILE : has
AUTH_USER ||--o{ AGENT_RUN : owns
AUTH_USER ||--o{ MODEL_PROVIDER_CONFIG : owns
AGENT_RUN ||--o{ AGENT_RUN_EVENT : contains
AGENT_RUN ||--o{ TOOL_CALL : contains
AGENT_RUN ||--o{ HUMAN_APPROVAL : requests
AUTH_USER ||--o{ HUMAN_APPROVAL : resolves
```
## 4. 表结构草案
### 4.1 `accounts_user_profile`
| 字段 | Django 类型建议 | 空值 | 约束与说明 |
| --- | --- | --- | --- |
| `id` | `BigAutoField` | 否 | 主键 |
| `user_id` | `OneToOneField` | 否 | 唯一,关联 Django 用户 |
| `display_name` | `CharField` | 是 | 空字符串表示未设置,长度上限实现时确认 |
| `timezone` | `CharField` | 否 | 默认 `Asia/Shanghai`,必须来自受控候选 |
| `created_at` | `DateTimeField` | 否 | 创建时写入 |
| `updated_at` | `DateTimeField` | 否 | 修改时更新 |
删除策略:用户尚无审计数据时资料可随用户删除;用户存在 Agent Run 时由运行记录的保护关系阻止用户物理删除。
### 4.2 `agent_runtime_agent_run`
| 字段组 | 字段 | 类型建议 | 说明 |
| --- | --- | --- | --- |
| 标识 | `id` | `UUIDField` | 主键,避免在页面暴露连续编号 |
| 归属 | `owner_id` | `ForeignKey(PROTECT)` | 用户私有归属 |
| 展示 | `title` | `CharField` | 简短标题 |
| 状态 | `status` | `CharField` | 使用代码枚举和数据库检查约束 |
| 并发 | `lock_version` | `PositiveBigIntegerField` | 每次状态写入递增 |
| 摘要 | `input_summary` | `JSONField` | 脱敏、限长输入摘要 |
| 摘要 | `output_summary` | `JSONField` | 可空,脱敏输出摘要 |
| 版本 | `agent_name` | `CharField` | Agent 稳定标识 |
| 版本 | `agent_version` | `CharField` | 可空 |
| 版本 | `instruction_version` | `CharField` | 可空 |
| 版本 | `toolset_version` | `CharField` | 可空 |
| 模型 | `model_name` | `CharField` | 第一阶段允许为空 |
| 追踪 | `trace_id` | `CharField` | 可空,非秘密标识 |
| 时间 | `started_at` | `DateTimeField` | 可空 |
| 时间 | `finished_at` | `DateTimeField` | 可空 |
| 耗时 | `duration_ms` | `PositiveBigIntegerField` | 可空 |
| 用量 | `input_tokens` | `PositiveBigIntegerField` | 可空 |
| 用量 | `output_tokens` | `PositiveBigIntegerField` | 可空 |
| 费用 | `estimated_cost` | `DecimalField` | 可空,精度实现前确认 |
| 失败 | `error_code` | `CharField` | 可空、稳定机器码 |
| 失败 | `error_summary` | `TextField` | 可空、脱敏且限长 |
| 审计 | `created_at` | `DateTimeField` | 否 |
| 审计 | `updated_at` | `DateTimeField` | 否 |
检查约束至少保证:
- `status` 属于已定义枚举。
- `lock_version >= 0`。
- token、耗时和费用非负。
### 4.2.1 `agent_runtime_model_provider_config`
| 字段 | 类型建议 | 说明 |
| --- | --- | --- |
| `owner_id` | `ForeignKey(PROTECT)` | 用户私有归属 |
| `provider_code` | `CharField` | 对应代码中的服务商稳定编码 |
| `encrypted_api_key` | `TextField` | Fernet 密文,禁止通过页面或 Admin 展示 |
| `key_hint` | `CharField` | 仅保存末四位提示 |
| `status`、`enabled`、`is_default` | 状态字段 | 连接状态和当前默认选择 |
| `default_model_id` | `CharField` | 用户选择或预设推荐模型 |
| `available_models` | `JSONField` | 最近一次验证取得的模型标识快照 |
| `last_verified_at`、`last_error_code` | 审计字段 | 验证时间和稳定错误码 |
唯一约束为 `(owner_id, provider_code)`。API Key 加密主密钥只从部署环境读取,不进入数据库;主密钥轮换必须先完成密文重加密,不能直接替换后重启。
跨字段状态不变量仍由服务层验证,因为 SQLite 与 PostgreSQL 对复杂检查约束和时间语义的行为需要保持一致。
### 4.3 `agent_runtime_agent_run_event`
| 字段 | 类型建议 | 说明 |
| --- | --- | --- |
| `id` | `BigAutoField` | 主键 |
| `run_id` | `ForeignKey(PROTECT)` | 关联 Run,审计事件不可级联丢失 |
| `sequence` | `PositiveBigIntegerField` | Run 内递增序号 |
| `event_type` | `CharField` | 事件枚举 |
| `summary` | `CharField` | 短摘要 |
| `payload_summary` | `JSONField` | 脱敏事件数据 |
| `occurred_at` | `DateTimeField` | 业务发生时间 |
| `created_at` | `DateTimeField` | 数据库记录时间 |
唯一约束:`(run_id, sequence)`。
索引:`(run_id, sequence)` 唯一索引已覆盖详情页时间线读取,无需重复创建同前缀普通索引。
### 4.4 `agent_runtime_tool_call`
| 字段组 | 字段 | 类型建议 | 说明 |
| --- | --- | --- | --- |
| 标识 | `id` | `BigAutoField` | 主键 |
| 归属 | `run_id` | `ForeignKey(PROTECT)` | 关联 Run |
| 调用 | `call_id` | `CharField` | Run 内调用标识 |
| 工具 | `tool_name` | `CharField` | 工具稳定名称 |
| 工具 | `tool_version` | `CharField` | 可空 |
| 幂等 | `idempotency_key` | `CharField` | 可空,作用域见约束 |
| 状态 | `status` | `CharField` | `started/succeeded/failed` |
| 摘要 | `arguments_summary` | `JSONField` | 脱敏、限长 |
| 摘要 | `result_summary` | `JSONField` | 可空、脱敏、限长 |
| 失败 | `error_code` | `CharField` | 可空 |
| 失败 | `error_summary` | `TextField` | 可空、限长 |
| 时间 | `started_at` | `DateTimeField` | 否 |
| 时间 | `finished_at` | `DateTimeField` | 可空 |
| 耗时 | `duration_ms` | `PositiveBigIntegerField` | 可空 |
| 审计 | `created_at`、`updated_at` | `DateTimeField` | 否 |
唯一约束:
- `(run_id, call_id)` 唯一。
- 非空幂等键的作用域在真实工具模型确定前采用 `(run_id, idempotency_key)`,后续如需跨 Run 幂等必须重新设计业务作用域。
### 4.5 `agent_runtime_human_approval`
| 字段 | 类型建议 | 说明 |
| --- | --- | --- |
| `id` | `UUIDField` | 主键 |
| `run_id` | `ForeignKey(PROTECT)` | 关联 Run |
| `request_key` | `CharField` | Run 内稳定请求键 |
| `approval_type` | `CharField` | 确认类型枚举 |
| `status` | `CharField` | 确认状态枚举 |
| `request_summary` | `JSONField` | 脱敏请求摘要 |
| `decision_summary` | `JSONField` | 可空、脱敏处理摘要 |
| `requested_at` | `DateTimeField` | 请求时间 |
| `resolved_at` | `DateTimeField` | 可空 |
| `resolved_by_id` | `ForeignKey(PROTECT)` | 可空,处理人 |
| `created_at`、`updated_at` | `DateTimeField` | 审计时间 |
唯一约束:`(run_id, request_key)`。
索引:`(status, requested_at)`,支持 Admin 查询待处理确认。
## 5. 主要访问模式与索引
| 访问模式 | 过滤与排序 | 索引建议 |
| --- | --- | --- |
| 当前用户 Run 列表 | `owner_id`,`created_at DESC` | `(owner_id, created_at)` |
| 当前用户按状态查询 | `owner_id`、`status`、`created_at DESC` | 第一阶段无筛选需求,暂不新增 |
| Run 事件时间线 | `run_id`、`sequence ASC` | 唯一索引 `(run_id, sequence)` |
| Run 工具调用列表 | `run_id`、`started_at ASC` | `(run_id, started_at)` |
| 待处理人工确认 | `status`、`requested_at ASC` | `(status, requested_at)` |
| trace 定位运行 | `trace_id` | 有真实查询需求后再决定唯一性和索引 |
索引以第一阶段真实页面和 Admin 查询为依据,不为未来假设查询提前增加索引。
## 6. 事务与并发
- Run 状态变更、状态事件和关联确认记录在同一事务内完成。
- 更新 Run 前锁定目标记录,并校验 `lock_version`。
- 事件序号在锁定 Run 后读取最大值并递增。
- 唯一约束捕获遗漏的并发冲突,并转换为领域冲突错误。
- 工具外部调用不持有数据库事务;开始和结束分别使用短事务。
- SQLite 不提供等价的行锁验证,PostgreSQL 集成测试必须覆盖双并发更新。
## 7. 数据生命周期
- 用户资料可修改,保留最新值即可。
- Agent Run、事件、工具调用和人工确认属于审计数据,第一阶段不提供删除入口。
- 用户存在运行记录时不得物理删除,改为停用账户。
- 第一阶段不自动清理日志或审计记录。
- 后续建立保留周期时必须区分运行摘要、完整原始证据和应用日志,不能使用同一删除策略。
## 8. 迁移顺序
1. 创建 `common` 基础抽象代码,不生成抽象表。
2. 创建 `accounts` 与 `UserProfile` 迁移。
3. 创建 `agent_runtime` 核心表和基础约束。
4. 单独迁移补充经验证的组合索引和检查约束。
5. 在空 SQLite 数据库执行完整迁移。
6. 确认 PostgreSQL 精确版本和隔离环境。
7. 在空 PostgreSQL 数据库执行完整迁移和核心约束测试。
迁移发布后不得直接修改历史迁移;修正通过新迁移完成。
## 9. 回滚策略
- 第一阶段没有存量业务数据转换,回滚以撤销未发布迁移为主。
- 已发布环境回滚前必须确认表内是否已产生审计数据。
- 存在数据时禁止直接反向删除表,应先停止写入、导出验证数据并制定显式迁移。
- 应用代码回滚必须与数据库迁移兼容,不能先删除旧代码仍会读取的字段。
## 10. 验证清单
- Django 迁移无遗漏。
- 空 SQLite 数据库全量迁移和反向迁移测试。
- 空 PostgreSQL 数据库全量迁移。
- 用户一对一资料唯一约束。
- Run 状态、非负数值和用户保护约束。
- 事件序号唯一约束。
- 工具调用编号和幂等键冲突。
- 人工确认请求键唯一约束。
- 两个并发状态更新只有一个成功。
- 主要列表查询使用预期索引;数据量不足时不声称完成性能验收。
## 11. 待确认项
- PostgreSQL 精确版本、部署方式和连接池策略。
- 摘要字段的最大序列化字节数。
- 字符字段长度、费用精度和稳定约束名称。
- trace 标识是否需要全局唯一。
- PostgreSQL 版本确认后是否需要部分索引或其他专属优化。
@@ -0,0 +1,221 @@
---
reviewStatus: pending
reviewedAt: null
replacedBy: null
---
# 第一阶段工程基线前端落地设计
## 1. 文档定位
本文档定义第一阶段 Django 服务端页面的页面结构、路由、交互、状态、权限与可访问性边界。当前项目没有独立前端框架或组件库,因此本阶段使用 Django Templates、Django Forms 和少量原生 CSS,不引入 Vue、React、前端状态库或前后端分离架构。
关联设计:
- [后端落地设计](backend-design.md)
- [数据库设计](database-design.md)
- [接口与页面契约设计](interface-design.md)
- [第一阶段工程基线开发计划](../../../docs/engineering-baseline/dev-plan.md)
## 2. 用户角色与核心场景
### 2.1 未登录访问者
- 可以进入登录页。
- 访问个人资料和运行页面时跳转登录页。
- 不提供公开注册入口。
### 2.2 普通用户
- 登录和退出系统。
- 查看、修改自己的基础资料。
- 查看自己的 Agent Run 列表。
- 查看自己的 Agent Run 详情、事件和工具调用摘要。
- 不能查看其他用户的数据,也不能从页面执行真实 Agent 任务。
### 2.3 管理员
- 使用 Django Admin 创建和停用用户。
- 查看全部用户资料和 Agent 运行记录。
- 创建用于验证持久化链路的最小 Run。
- 在 Admin 中处理待确认记录。
## 3. 页面与路由
| 页面 | 路由 | 访问角色 | 主要操作 |
| --- | --- | --- | --- |
| 登录 | `/accounts/login/` | 未登录用户 | 提交用户名和密码 |
| 退出 | `/accounts/logout/` | 已登录用户 | POST 确认退出 |
| 个人资料 | `/accounts/profile/` | 普通用户、管理员 | 查看和修改自己的资料 |
| 运行列表 | `/runs/` | 普通用户、管理员 | 查看当前用户的运行记录 |
| 运行详情 | `/runs/<uuid>/` | 记录所属用户、管理员 | 查看状态、事件和工具调用摘要 |
| API 服务商 | `/runs/providers/` | 普通用户、管理员 | 搜索、筛选并配置自己的模型服务商 |
| 管理后台 | `/admin/` | 管理员 | 管理用户和审计记录 |
登录成功默认进入运行列表;退出成功返回登录页。详情页返回列表时保留原分页参数,非法或越权的运行编号统一展示 404 页面。
## 4. 页面结构
### 4.1 全局布局
建立一个全局基础模板,结构包括:
- 跳转到主要内容的无障碍链接。
- 产品名称和主导航。
- 当前用户标识。
- 运行列表、个人资料和退出入口。
- 页面级消息区域。
- 主内容区域。
退出必须使用 POST 表单,不使用可被预加载或误触发的 GET 链接。
### 4.2 登录页
页面内容:
- 用户名输入框。
- 密码输入框。
- 登录按钮。
- 表单级错误和字段级错误。
登录失败时不区分“用户不存在”和“密码错误”,避免泄露账户信息。密码字段不得回显。
### 4.3 个人资料页
字段:
- 用户名:只读。
- 显示名:可编辑。
- 时区:第一阶段使用受控选择项,默认 `Asia/Shanghai`。
提交成功后采用 POST/Redirect/GET,刷新页面不会重复提交。并发更新暂不提供复杂冲突合并,后端仍必须校验当前用户归属。
### 4.4 Agent Run 列表页
每条记录展示:
- 标题或简短运行标识。
- 当前状态。
- Agent 名称和版本摘要。
- 创建、开始和结束时间。
- 运行耗时。
- 是否存在待处理人工确认。
列表按创建时间倒序分页。第一阶段没有运行记录时展示明确空状态,不显示“创建任务”按钮,避免暗示真实 Agent 功能已开放。
### 4.5 Agent Run 详情页
页面分为:
1. 运行概览:状态、标题、版本、开始和结束时间、用量摘要。
2. 结果摘要:只展示已脱敏的结构化摘要。
3. 运行时间线:按事件序号升序展示。
4. 工具调用:名称、状态、耗时、参数摘要、结果或错误摘要。
5. 人工确认:只读展示当前状态;第一阶段由 Admin 处理。
详情页不展示 API Key、Cookie、密码、完整工具参数或原始模型敏感内容。
### 4.6 API 服务商页
- 使用服务商卡片目录展示官方直连、国内平台、聚合平台和云平台。
- 卡片明确区分“仅 API Key”和“需要额外配置”,未完成专用适配的平台不开放保存操作。
- 配置抽屉只接收 API Key、可选模型标识和是否设为默认服务商;Base URL 与协议由服务端预设。
- 页面只显示密钥末四位提示,保存后不可读取完整密钥。
- 连接测试通过后才替换已有配置;失败输入不得破坏原有可用密钥。
- 搜索与分类使用 GET 查询参数,保存和移除使用 POST、CSRF 与重定向。
## 5. 模板复用边界
建议模板层级:
```text
templates/
├── base.html
├── registration/
│ └── login.html
├── accounts/
│ └── profile.html
├── agent_runtime/
│ ├── run_list.html
│ └── run_detail.html
└── errors/
├── 403.html
├── 404.html
└── 500.html
```
复用片段仅在存在稳定复用时抽取:
- 状态徽标。
- 分页导航。
- 表单错误摘要。
- 时间线事件项。
第一阶段不建立通用组件系统,也不为了文件数量提前抽象宏或模板标签库。
## 6. 状态与数据所有权
服务端拥有所有业务和权限状态,页面不保存跨请求客户端状态。
| 状态类型 | 所有者 | 页面行为 |
| --- | --- | --- |
| 登录状态 | Django Session | 未登录时跳转登录 |
| 用户资料 | `accounts` 服务 | GET 回显,POST 校验和保存 |
| Run 列表与详情 | `agent_runtime` 查询服务 | 仅返回当前用户范围 |
| 分页状态 | URL 查询参数 | 可复制、可返回 |
| 表单错误 | Django Form | 同页字段级与全局提示 |
| 消息反馈 | Django Messages | 重定向后展示一次 |
页面只接收格式化后的视图模型,不在模板中执行权限判断、状态转换或复杂数据加工。
## 7. 加载、空、错误和权限状态
- 服务端页面首屏不引入异步加载骨架。
- 空列表显示原因和当前阶段说明。
- 表单校验失败保留非敏感输入并聚焦错误摘要。
- 资源不存在和跨用户访问统一显示 404。
- 服务端异常显示通用错误页,并提供返回安全页面的入口。
- SDK 配置缺失不能阻止登录、资料和历史 Run 查询。
- 页面不得把后端异常堆栈直接呈现给用户。
## 8. 响应式和可访问性
- 页面以单列内容为主,在宽屏下限制最大阅读宽度。
- Run 详情中的宽表格在窄屏改为定义列表或允许局部横向滚动。
- 所有表单字段必须具有可关联的 `label`、帮助文本和错误描述。
- 当前导航项提供可感知状态,不只依赖颜色区分。
- 状态徽标同时包含文本。
- 焦点样式不可移除,键盘可以访问所有操作。
- 标题层级连续,每页只有一个主标题。
- 时间使用语义化 `<time>` 并提供完整时间值。
## 9. 安全与隐私
- 所有写操作使用 POST 和 CSRF 防护。
- 登录后重定向参数只允许站内安全地址。
- 模板启用默认转义,不使用未经审查的 `safe` 输出。
- 页面只显示脱敏摘要。
- 不在 URL、HTML 注释或前端脚本中包含密钥和敏感参数。
- 跨用户访问由查询层限制,模板隐藏按钮不能替代后端权限校验。
## 10. 验证清单
- 登录成功、登录失败和安全重定向。
- 未登录访问受保护页面。
- 个人资料成功保存和校验失败。
- 两个普通用户之间的资料和 Run 隔离。
- 运行列表空状态、单页和多页数据。
- 运行详情事件顺序及敏感字段不展示。
- 管理员后台访问边界。
- CSRF 拒绝路径。
- 键盘导航、焦点、标签和错误关联。
- 常见桌面和窄屏宽度下的真实浏览器检查。
未执行真实浏览器和辅助技术测试前,不得宣称前端交互验收通过。
## 11. 待确认项
- 产品视觉标识、颜色和字体尚未确定;第一阶段使用简洁系统字体和高对比度基础样式。
- 是否允许普通用户处理人工确认留到第二阶段决定。
- 运行列表默认分页大小应在实现时结合样例数据确定。
- 时区候选范围需在表单实现前明确,不能接受任意未校验字符串。
@@ -0,0 +1,252 @@
---
reviewStatus: pending
reviewedAt: null
replacedBy: null
---
# 第一阶段工程基线接口与页面契约设计
## 1. 设计范围
第一阶段不采用 Django REST Framework,也不提供公开 JSON API 或 OpenAPI 契约。本设计只定义浏览器访问的服务端页面 HTTP 契约,以及页面、Admin、Agent SDK 适配器调用的内部服务契约。
如果后续出现前后端分离、第三方调用或移动端需求,应重新执行正式 API 设计,不直接把本阶段内部服务暴露为外部 API。
关联设计:
- [后端落地设计](backend-design.md)
- [前端落地设计](frontend-design.md)
- [数据库设计](database-design.md)
## 2. 通用 HTTP 规则
- 用户认证使用 Django Session。
- 所有状态修改请求使用 POST 并验证 CSRF。
- GET 请求不得产生业务状态变更。
- 成功写入后使用 POST/Redirect/GET。
- 未登录访问受保护页面时跳转登录页,并携带安全的站内返回地址。
- 普通用户访问其他用户资源时返回 404,不暴露资源存在性。
- 表单输入错误返回原页面和字段错误,响应状态按 Django 页面约定实现。
- 意外异常返回通用错误页面,日志使用请求关联标识定位。
- 响应不得包含堆栈、数据库错误、密钥、Cookie 或未脱敏工具参数。
## 3. 页面端点
### 3.1 登录
`GET /accounts/login/`
- 返回登录表单。
- 已登录用户可重定向到运行列表。
`POST /accounts/login/`
- 输入:用户名、密码、可选站内 `next`。
- 成功:建立 Session,重定向到安全 `next` 或运行列表。
- 失败:返回登录页和统一认证错误,不区分用户名或密码错误。
### 3.2 退出
`POST /accounts/logout/`
- 前置条件:已登录。
- 成功:清除 Session,重定向登录页。
- 不提供 GET 退出。
### 3.3 个人资料
`GET /accounts/profile/`
- 前置条件:已登录。
- 返回当前用户的用户名、显示名和时区。
- 不接受目标用户编号参数。
`POST /accounts/profile/`
- 前置条件:已登录、CSRF 有效。
- 输入:显示名、时区。
- 成功:保存当前用户资料并重定向到资料页。
- 失败:原页显示字段错误,不保存部分数据。
### 3.4 Agent Run 列表
`GET /runs/`
- 前置条件:已登录。
- 查询参数:`page`,其他未知参数忽略或按统一规则拒绝。
- 返回当前用户拥有的 Run,按创建时间倒序分页。
- 管理员在普通站点页面仍默认只看自己的 Run;跨用户管理使用 Admin。
### 3.5 Agent Run 详情
`GET /runs/<uuid:run_id>/`
- 前置条件:已登录。
- 返回 Run 概览、有序事件、工具调用摘要和人工确认状态。
- 目标不存在或不属于当前用户时返回相同的 404。
- 页面端点不提供状态更新能力。
### 3.6 管理后台
`/admin/` 沿用 Django Admin 的认证和权限体系。
- 用户资料和 Run 主记录按配置开放管理能力。
- 事件和完成的工具调用设置为只读或限制修改。
- 人工确认处理必须调用领域服务,不能只修改单个状态字段。
### 3.7 模型服务商配置
`GET /runs/providers/`
- 返回内置服务商目录、当前用户连接状态、搜索和分类结果。
- 不返回密文 API Key,已配置密钥只显示末四位提示。
`POST /runs/providers/<provider_code>/configure/`
- 输入:API Key、可选默认模型、是否设为默认服务商。
- 服务端按固定预设端点请求模型目录;验证成功后才加密保存。
- 验证失败返回安全错误说明,不透传上游正文,不覆盖旧密钥。
`POST /runs/providers/<provider_code>/disconnect/`
- 只删除当前用户对应配置;删除默认配置后自动选择一个剩余可用配置。
- GET 请求不得删除或修改配置。
## 4. 表单契约
### 4.1 登录表单
| 字段 | 必填 | 规则 |
| --- | --- | --- |
| `username` | 是 | 使用 Django 认证表单规则 |
| `password` | 是 | 不回显、不写日志 |
| `next` | 否 | 只允许站内安全路径 |
### 4.2 用户资料表单
| 字段 | 必填 | 规则 |
| --- | --- | --- |
| `display_name` | 否 | 去除首尾空白,长度上限由模型统一定义 |
| `timezone` | 是 | 必须来自服务端受控候选 |
模型约束、表单约束和服务校验必须保持一致,不能只依赖浏览器端校验。
## 5. 内部服务契约
内部服务只接受明确的操作者和标识,不接受未经限定的 QuerySet。
### 5.1 账户服务
| 操作 | 输入 | 输出 | 失败语义 |
| --- | --- | --- | --- |
| 获取资料 | 当前用户 | `UserProfile` | 用户无效 |
| 更新资料 | 当前用户、已校验数据 | 更新后资料 | 校验失败、并发冲突 |
| 停用用户 | 管理员、目标用户 | 停用结果 | 无权限、目标不存在 |
### 5.2 Run 查询服务
| 操作 | 输入 | 输出 | 权限规则 |
| --- | --- | --- | --- |
| 查询列表 | 当前用户、分页参数 | 分页 Run | 强制用户归属过滤 |
| 查询详情 | 当前用户、Run UUID | Run 详情视图模型 | 越权与不存在统一处理 |
### 5.3 Run 命令服务
| 操作 | 关键输入 | 关键输出 |
| --- | --- | --- |
| 创建 Run | 所属用户、脱敏输入摘要、版本 | 新 Run 和创建事件 |
| 开始 Run | Run 标识、期望版本 | `running` Run 和开始事件 |
| 完成 Run | Run 标识、脱敏输出、用量 | `succeeded` Run 和完成事件 |
| 失败 Run | Run 标识、错误码、脱敏摘要 | `failed` Run 和失败事件 |
| 取消 Run | 操作者、Run 标识 | `cancelled` Run 和取消事件 |
| 请求确认 | Run、请求键、类型、摘要 | 待处理确认和暂停事件 |
| 处理确认 | 操作者、确认标识、决定 | 确认结果及 Run 状态变更 |
命令服务必须显式返回更新后的领域对象或结果对象,不依赖调用方猜测数据库状态。
## 6. SDK Gateway 契约
### 6.1 请求模型
`AgentExecutionRequest` 至少包含:
- `agent_run_id`
- `user_id`
- `agent_name`
- `agent_version`
- `instruction_version`
- `toolset_version`
- 脱敏运行输入
- 最大轮次、时间和工具调用预算
不包含 API Key、网站密码和 Cookie。
### 6.2 结果模型
`AgentExecutionResult` 至少包含:
- 结果状态。
- 脱敏结构化输出摘要。
- trace 标识。
- 输入和输出 token。
- 估算费用。
- 稳定错误码和脱敏错误摘要。
### 6.3 失败分类
- 配置缺失:调用真实 Gateway 前失败,不影响其他页面。
- 输入无效:不发起外部请求。
- 外部超时或服务失败:记录工具或 Run 失败事件。
- 输出校验失败:保留安全摘要并将 Run 标记失败。
- 取消:停止后续调用并记录取消事件。
第一阶段测试使用 Stub Gateway,禁止自动化测试发起真实付费调用。
## 7. 幂等、并发和重试
- 页面资料提交使用 POST/Redirect/GET,重复刷新不重复写入。
- Run 命令携带期望的 `lock_version` 或在服务内锁定记录。
- 工具调用使用 `(run_id, call_id)` 和幂等键防止重复记录。
- 人工确认使用 `(run_id, request_key)` 防止重复请求。
- 第一阶段不实现自动重试;失败结果保持终态。
- 外部调用开始和完成分别写入短事务,调用方可根据已有状态安全恢复记录。
## 8. 错误映射
| 领域错误 | 页面语义 | 外部 API 预留语义 |
| --- | --- | --- |
| 资源不存在或无权访问 | 404 页面 | 404 |
| 输入校验失败 | 表单错误 | 400 或 422,后续 API 设计确定 |
| 状态转换冲突 | 操作失败提示 | 409 |
| 重复或幂等冲突 | 操作失败提示 | 409 |
| SDK 配置缺失 | 功能不可用提示 | 503 |
| 外部服务失败 | 通用失败提示和关联编号 | 502 或 503,后续确定 |
表中外部 API 状态只作为未来语义预留,不代表第一阶段已经提供 JSON API。
## 9. 安全测试清单
- 登录失败信息不泄露账户存在性。
- `next` 参数不能跳转到站外地址。
- GET 退出请求不能改变 Session。
- 缺失或错误 CSRF 的写请求被拒绝。
- 用户 A 无法通过修改 URL 访问用户 B 的 Run。
- UUID 格式错误和不存在资源均安全处理。
- 表单和摘要输出经过 HTML 转义。
- 密码、Cookie、API Key 和完整敏感参数不出现在响应或日志。
- Admin 人工确认操作经过领域服务和权限检查。
## 10. 兼容与演进
- 页面 URL 使用命名空间和反向解析,避免模板硬编码路径。
- 第一阶段不承诺外部 API 兼容性。
- 后续新增 JSON API 时复用领域服务,不复用 HTML 视图作为接口层。
- 引入 DRF 或 OpenAPI 前必须确认调用方、认证方式、版本策略和错误模型。
- Celery 接入后通过同一 Run 命令服务记录状态,不改变页面查询契约。
## 11. 待确认项
- 页面表单错误是否统一使用 200 还是采用更严格的 4xx 状态,实施时按项目测试约定确定。
- 是否允许管理员在普通运行页面跨用户查询;当前设计仅允许 Admin。
- 第二阶段是否向普通用户开放人工确认处理页面。
- 是否存在未来外部调用方;确认前不创建 OpenAPI 文档。
+4 -1
View File
@@ -15,9 +15,12 @@ POSTGRES_PORT=5432
POSTGRES_SSLMODE=prefer
POSTGRES_CONN_MAX_AGE=60
# OpenAI Agents SDK:第一阶段仅预留配置,不执行真实模型调用
# OpenAI Agents SDK:第一阶段使用测试替身验证持久化编排,不执行真实模型调用
OPENAI_API_KEY=请替换为OpenAI_API_Key
OPENAI_MODEL=
AGENT_DEFAULT_NAME=job_research
# 至少 32 个随机字符;生产环境必须稳定保存,遗失后已有模型 API Key 无法解密。
MODEL_API_KEY_ENCRYPTION_KEY=请替换为独立生成的高强度随机密钥
# 生产安全配置
DJANGO_SECURE_SSL_REDIRECT=true
+41 -5
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import os
from pathlib import Path
# 项目根目录。所有本地文件路径统一从这里派生,避免依赖启动目录。
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -49,10 +48,14 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"common",
"accounts",
"agent_runtime",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"common.middleware.RequestContextMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
@@ -83,7 +86,7 @@ WSGI_APPLICATION = "JobRadar.wsgi.application"
ASGI_APPLICATION = "JobRadar.asgi.application"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": ("django.contrib.auth.password_validation.UserAttributeSimilarityValidator")},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
@@ -96,12 +99,45 @@ USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "login"
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "agent_runtime:run-list"
LOGOUT_REDIRECT_URL = "accounts:login"
# Agent 配置只从运行环境读取。第一阶段的自动化测试使用 Stub Gateway,
# 因此缺少真实密钥不能影响登录、资料和运行记录查询等非 Agent 功能。
OPENAI_API_KEY = env("OPENAI_API_KEY", "")
OPENAI_MODEL = env("OPENAI_MODEL", "")
AGENT_DEFAULT_NAME = env("AGENT_DEFAULT_NAME", "job_research")
# 用户在页面录入的模型 API Key 使用独立主密钥加密。该值不得与数据库一起保存,
# 生产部署必须稳定注入;轮换前需要先设计密文重加密流程。
MODEL_API_KEY_ENCRYPTION_KEY = env("MODEL_API_KEY_ENCRYPTION_KEY", "")
# 日志只输出可关联的结构化键值,不记录请求正文、Cookie 或认证头。
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"structured": {
"format": (
"time={asctime} level={levelname} logger={name} "
"request_id={request_id} message={message}"
),
"style": "{",
}
},
"filters": {"request_context": {"()": "common.logging.RequestContextFilter"}},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "structured",
"filters": ["request_context"],
}
},
"root": {"handlers": ["console"], "level": "INFO"},
}
+1 -3
View File
@@ -5,9 +5,8 @@ from __future__ import annotations
import secrets
import warnings
from .base import BASE_DIR, env, env_bool, env_list
from .base import * # noqa: F403
from .base import BASE_DIR, env, env_bool, env_list
DEBUG = env_bool("DJANGO_DEBUG", default=True)
ALLOWED_HOSTS = env_list(
@@ -50,4 +49,3 @@ elif database_engine in {"postgres", "postgresql"}:
}
else:
raise ValueError("DATABASE_ENGINE 仅支持 sqlite、postgres 或 postgresql。")
+1 -2
View File
@@ -1,7 +1,7 @@
"""PostgreSQL 集成验证配置,只允许显式提供测试数据库凭据。"""
from .base import env, env_list
from .base import * # noqa: F403
from .base import env, env_list
def required_env(name: str) -> str:
@@ -29,4 +29,3 @@ DATABASES = {
"OPTIONS": {"sslmode": env("POSTGRES_SSLMODE", "require")},
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
"""生产环境配置:关键安全参数和 PostgreSQL 凭据均必须显式注入。"""
from .base import env, env_bool, env_list
from .base import * # noqa: F403
from .base import env, env_bool, env_list
def required_env(name: str) -> str:
@@ -14,6 +14,7 @@ def required_env(name: str) -> str:
SECRET_KEY = required_env("DJANGO_SECRET_KEY")
MODEL_API_KEY_ENCRYPTION_KEY = required_env("MODEL_API_KEY_ENCRYPTION_KEY")
DEBUG = False
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS")
if not ALLOWED_HOSTS:
@@ -45,4 +46,3 @@ SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = env_bool("DJANGO_SECURE_HSTS_PRELOAD", default=False)
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
+2 -3
View File
@@ -1,10 +1,10 @@
"""自动化测试配置:始终使用独立的内存 SQLite 数据库。"""
from .base import BASE_DIR
from .base import * # noqa: F403
from .base import BASE_DIR
SECRET_KEY = "test-only-secret-key-not-for-production"
MODEL_API_KEY_ENCRYPTION_KEY = "test-only-model-key-32-characters-minimum"
DEBUG = False
ALLOWED_HOSTS = ["testserver", "localhost"]
@@ -19,4 +19,3 @@ DATABASES = {
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
MEDIA_ROOT = BASE_DIR / ".test-media"
+3 -1
View File
@@ -15,8 +15,10 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
path("accounts/", include("accounts.urls")),
path("runs/", include("agent_runtime.urls")),
]
+15 -3
View File
@@ -29,12 +29,12 @@ JobRadar 是一套面向个人使用、以 Agent 为核心的智能岗位发现
| 异步任务 | Celery + Redis | 规划中 |
| 定时调度 | Celery Beat | 规划中 |
| Agent 运行时 | OpenAI Agents SDK(Python) | 规划中 |
| 模型接口 | OpenAI Responses API | 规划中 |
| 模型接口 | 多服务商预设 + OpenAI/Anthropic/Gemini 协议 | 配置基础已完成,真实调用规划中 |
| Agent 输出 | Pydantic 结构化模型 | 规划中 |
| 可观测性 | Agents SDK Tracing + 业务运行记录 | 规划中 |
| Agent 评测 | 官方 Agent Evals 思路 + 本地评测集 | 规划中 |
| 用户系统 | Django 内置认证 + 简化用户资料 | 规划中 |
| WebUI | Django Admin + 自定义 Django 页面 | 规划中 |
| WebUI | Django Admin + 自定义 Django 页面 | 认证、运行记录与 API 服务商配置已完成 |
| 部署 | Docker Compose + Nginx/Caddy + HTTPS | 规划中 |
第一阶段优先采用 Django Admin 管理站点、规则、企业和任务数据,再为岗位浏览与决策流程开发自定义页面。出现明确的前后端分离需求后,再评估是否增加 Django REST Framework 和 Vue。
@@ -158,6 +158,18 @@ python -m playwright install chromium
- 采集器必须设置并发、频率、超时和指数退避,不能绕过验证码、登录保护或访问控制。
- 企业性质及 AI 判断必须保存来源、判断时间和置信度;信息不足时应标记为待人工复核。
### 模型服务商配置
登录后可访问 `/runs/providers/` 配置模型服务商。OpenAI、Claude、DeepSeek、Gemini、阿里云百炼、智谱 AI、硅基流动及部分 OpenAI 兼容平台已提供固定端点预设;Azure OpenAI、AWS Bedrock 等需要部署或 IAM 参数的平台目前仅展示目录状态。
页面保存 API Key 前必须配置独立主密钥:
```powershell
$env:MODEL_API_KEY_ENCRYPTION_KEY="请使用至少32个字符的高强度随机值"
```
该主密钥不得提交到仓库。遗失或直接替换主密钥会导致已有 API Key 无法解密;正式轮换前必须实现密文重加密流程。
## 用户系统
系统面向公网部署,因此第一阶段即接入用户认证,但不设计复杂的角色权限体系:
@@ -220,4 +232,4 @@ JobRadar 仅用于个人岗位信息整理与求职辅助。使用前应确认
当前版本:`0.1.0-dev`
当前阶段:架构与 Agent 契约已确定,阶段一的配置与依赖基线已落盘,下一步是用户认证与数据归属设计。
当前阶段:第一阶段工程基线及 SDK 测试替身编排闭环已经落地,正在完成质量工具环境同步、生产配置检查、页面人工验收和 PostgreSQL 集成验证;通过全部验收后进入第二阶段最小 Agent 闭环及真实 Agents SDK 接入。
+1
View File
@@ -0,0 +1 @@
"""用户资料与认证页面应用。"""
+11
View File
@@ -0,0 +1,11 @@
from django.contrib import admin
from .models import UserProfile
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
"""管理员维护用户基础资料。"""
list_display = ("user", "display_name", "timezone", "updated_at")
search_fields = ("user__username", "display_name")
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
"""用户资料应用配置。"""
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
+17
View File
@@ -0,0 +1,17 @@
"""用户资料表单。"""
from django import forms
from .models import UserProfile
TIMEZONE_CHOICES = [("Asia/Shanghai", "中国标准时间(上海)")]
class UserProfileForm(forms.ModelForm):
"""只暴露第一阶段允许用户自行修改的资料字段。"""
timezone = forms.ChoiceField(label="时区", choices=TIMEZONE_CHOICES)
class Meta:
model = UserProfile
fields = ("display_name", "timezone")
+32
View File
@@ -0,0 +1,32 @@
# Generated by Django 6.0.8 on 2026-09-09 02:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('display_name', models.CharField(blank=True, max_length=80, verbose_name='显示名')),
('timezone', models.CharField(default='Asia/Shanghai', max_length=64, verbose_name='时区')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '用户资料',
'verbose_name_plural': '用户资料',
},
),
]
View File
+26
View File
@@ -0,0 +1,26 @@
"""Django 内置用户的非认证资料模型。"""
from django.conf import settings
from django.db import models
from common.models import TimeStampedModel
class UserProfile(TimeStampedModel):
"""保存可独立演进的展示资料,不复制认证字段。"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="profile",
verbose_name="用户",
)
display_name = models.CharField("显示名", max_length=80, blank=True)
timezone = models.CharField("时区", max_length=64, default="Asia/Shanghai")
class Meta:
verbose_name = "用户资料"
verbose_name_plural = "用户资料"
def __str__(self):
return self.display_name or self.user.get_username()
+26
View File
@@ -0,0 +1,26 @@
"""用户资料服务。"""
from django.contrib.auth import get_user_model
from django.db import transaction
from .models import UserProfile
@transaction.atomic
def get_or_create_profile(user) -> UserProfile:
"""显式建立用户资料,避免依赖难以追踪的模型信号。"""
profile, _ = UserProfile.objects.get_or_create(user=user)
return profile
@transaction.atomic
def deactivate_user(actor, target_user_id: int):
"""仅允许管理员停用账户;保留关联审计记录。"""
if not actor.is_staff:
raise PermissionError("只有管理员可以停用用户。")
user = get_user_model().objects.select_for_update().get(pk=target_user_id)
user.is_active = False
user.save(update_fields=("is_active",))
return user
+45
View File
@@ -0,0 +1,45 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
class ProfileTests(TestCase):
"""验证资料显式创建、认证保护和更新边界。"""
def setUp(self):
self.user = get_user_model().objects.create_user(
username="alice",
password="safe-pass-123",
)
def test_profile_requires_login(self):
response = self.client.get(reverse("accounts:profile"))
self.assertEqual(response.status_code, 302)
def test_profile_is_created_and_updated(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("accounts:profile"),
{"display_name": "爱丽丝", "timezone": "Asia/Shanghai"},
)
self.assertRedirects(response, reverse("accounts:profile"))
self.user.refresh_from_db()
self.assertEqual(self.user.profile.display_name, "爱丽丝")
def test_profile_rejects_unknown_timezone(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("accounts:profile"),
{"display_name": "爱丽丝", "timezone": "Unknown/Timezone"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "选择一个有效的选项")
def test_login_and_post_logout_flow(self):
login_response = self.client.post(
reverse("accounts:login"),
{"username": "alice", "password": "safe-pass-123"},
)
self.assertRedirects(login_response, reverse("agent_runtime:run-list"))
logout_response = self.client.post(reverse("accounts:logout"))
self.assertRedirects(logout_response, reverse("accounts:login"))
+14
View File
@@ -0,0 +1,14 @@
"""账户页面路由。"""
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("login/", auth_views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
path("profile/", views.profile, name="profile"),
]
+24
View File
@@ -0,0 +1,24 @@
"""认证与当前用户资料页面。"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from .forms import UserProfileForm
from .services import get_or_create_profile
@login_required
def profile(request):
"""读取或更新当前用户自己的资料,不接受目标用户参数。"""
user_profile = get_or_create_profile(request.user)
if request.method == "POST":
form = UserProfileForm(request.POST, instance=user_profile)
if form.is_valid():
form.save()
messages.success(request, "个人资料已保存。")
return redirect("accounts:profile")
else:
form = UserProfileForm(instance=user_profile)
return render(request, "accounts/profile.html", {"form": form})
+1
View File
@@ -0,0 +1 @@
"""Agent 运行记录与生命周期应用。"""
+75
View File
@@ -0,0 +1,75 @@
from django.contrib import admin
from .models import AgentRun, AgentRunEvent, HumanApproval, ModelProviderConfig, ToolCall
from .services import resolve_approval
class ReadOnlyAuditAdmin(admin.ModelAdmin):
"""审计记录允许查看但禁止通过 Admin 改写或删除。"""
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return request.user.is_staff if obj is None else False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(AgentRun)
class AgentRunAdmin(admin.ModelAdmin):
list_display = ("title", "owner", "status", "created_at", "finished_at")
list_filter = ("status",)
search_fields = ("title", "owner__username", "trace_id")
admin.site.register(AgentRunEvent, ReadOnlyAuditAdmin)
admin.site.register(ToolCall, ReadOnlyAuditAdmin)
@admin.register(ModelProviderConfig)
class ModelProviderConfigAdmin(admin.ModelAdmin):
"""只展示连接元数据,密文 API Key 不进入 Admin 页面。"""
list_display = (
"provider_code",
"owner",
"status",
"is_default",
"last_verified_at",
)
list_filter = ("status", "is_default", "enabled")
search_fields = ("provider_code", "owner__username")
exclude = ("encrypted_api_key",)
readonly_fields = ("key_hint", "available_models", "last_verified_at", "last_error_code")
def has_add_permission(self, request):
"""服务商配置必须经过页面连接验证,Admin 不允许绕过验证直接创建。"""
return False
@admin.register(HumanApproval)
class HumanApprovalAdmin(admin.ModelAdmin):
"""人工确认正文只读,通过受控动作执行领域状态转换。"""
list_display = ("approval_type", "run", "status", "requested_at", "resolved_by")
list_filter = ("status", "approval_type")
readonly_fields = tuple(field.name for field in HumanApproval._meta.fields)
actions = ("approve_selected", "reject_selected")
@admin.action(description="通过选中的待确认请求")
def approve_selected(self, request, queryset):
for approval in queryset:
resolve_approval(request.user, approval.id, True, {"source": "admin"})
@admin.action(description="拒绝选中的待确认请求")
def reject_selected(self, request, queryset):
for approval in queryset:
resolve_approval(request.user, approval.id, False, {"source": "admin"})
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class AgentRuntimeConfig(AppConfig):
"""Agent 运行持久化应用配置。"""
default_auto_field = "django.db.models.BigAutoField"
name = "agent_runtime"
+57
View File
@@ -0,0 +1,57 @@
"""Agent 运行配置边界。"""
from dataclasses import dataclass
from django.conf import settings
from common.exceptions import AgentConfigurationError
@dataclass(frozen=True)
class AgentRuntimeConfig:
"""集中表达真实 SDK 执行所需配置,避免业务服务直接读取环境变量。"""
api_key: str
model: str
agent_name: str
provider_code: str = "openai"
protocol: str = "openai_responses"
base_url: str = "https://api.openai.com/v1"
@classmethod
def from_settings(cls) -> "AgentRuntimeConfig":
"""从 Django 设置构建配置;这里只读取,不在日志或异常中回显秘密。"""
return cls(
api_key=str(settings.OPENAI_API_KEY or "").strip(),
model=str(settings.OPENAI_MODEL or "").strip(),
agent_name=str(settings.AGENT_DEFAULT_NAME or "job_research").strip(),
)
def require_real_execution(self) -> "AgentRuntimeConfig":
"""真实执行前快速失败;非 Agent 页面无需调用此方法。"""
missing = []
if not self.api_key:
missing.append("OPENAI_API_KEY")
if not self.model:
missing.append("OPENAI_MODEL")
if missing:
raise AgentConfigurationError(f"真实 Agent 执行缺少配置:{', '.join(missing)}。")
return self
@classmethod
def from_user(cls, user) -> "AgentRuntimeConfig":
"""从用户已验证的服务商配置构建运行配置,供真实 Gateway 接入使用。"""
from .services import resolve_runtime_model_config
runtime = resolve_runtime_model_config(user)
return cls(
api_key=runtime.api_key,
model=runtime.model,
agent_name=str(settings.AGENT_DEFAULT_NAME or "job_research").strip(),
provider_code=runtime.provider_code,
protocol=runtime.protocol,
base_url=runtime.base_url,
)
+32
View File
@@ -0,0 +1,32 @@
"""模型服务商配置表单。"""
from django import forms
class ProviderConfigurationForm(forms.Form):
"""只接收用户需要决定的密钥和默认模型,不允许覆盖系统预设端点。"""
api_key = forms.CharField(
label="API Key",
max_length=500,
strip=True,
widget=forms.PasswordInput(
attrs={"autocomplete": "new-password", "placeholder": "粘贴服务商 API Key"}
),
)
model_id = forms.CharField(
label="默认模型",
max_length=160,
required=False,
strip=True,
widget=forms.TextInput(attrs={"placeholder": "自动选择推荐模型"}),
)
use_as_default = forms.BooleanField(label="设为 Agent 默认服务商", required=False)
def clean_api_key(self):
"""拒绝明显不完整的值,但不假设各厂商的固定前缀。"""
api_key = self.cleaned_data["api_key"]
if len(api_key) < 8:
raise forms.ValidationError("API Key 长度不足,请检查是否复制完整。")
return api_key
+39
View File
@@ -0,0 +1,39 @@
"""OpenAI Agents SDK 与本地运行模型之间的稳定适配边界。"""
from dataclasses import dataclass, field
from typing import Protocol
@dataclass(frozen=True)
class AgentExecutionRequest:
"""不包含 API Key、Cookie 等秘密的运行请求。"""
agent_run_id: str
user_id: int
input_summary: dict = field(default_factory=dict)
@dataclass(frozen=True)
class AgentExecutionResult:
"""Gateway 返回的脱敏运行结果。"""
succeeded: bool
output_summary: dict = field(default_factory=dict)
error_code: str = ""
error_summary: str = ""
class AgentRunnerGateway(Protocol):
"""第二阶段真实 SDK Gateway 必须实现的最小协议。"""
def run(self, request: AgentExecutionRequest) -> AgentExecutionResult: ...
class StubAgentRunnerGateway:
"""测试使用的确定性替身,不发起任何外部或付费调用。"""
def __init__(self, result: AgentExecutionResult):
self.result = result
def run(self, request: AgentExecutionRequest) -> AgentExecutionResult:
return self.result
+135
View File
@@ -0,0 +1,135 @@
# Generated by Django 6.0.8 on 2026-09-09 02:53
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AgentRun',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=160, verbose_name='标题')),
('status', models.CharField(choices=[('pending', '等待'), ('running', '运行中'), ('waiting_approval', '等待确认'), ('succeeded', '成功'), ('failed', '失败'), ('cancelled', '已取消')], default='pending', max_length=32, verbose_name='状态')),
('lock_version', models.PositiveBigIntegerField(default=0, verbose_name='锁版本')),
('input_summary', models.JSONField(default=dict, verbose_name='输入摘要')),
('output_summary', models.JSONField(blank=True, null=True, verbose_name='输出摘要')),
('agent_name', models.CharField(default='job_research', max_length=100, verbose_name='Agent 名称')),
('agent_version', models.CharField(blank=True, max_length=40, verbose_name='Agent 版本')),
('instruction_version', models.CharField(blank=True, max_length=40, verbose_name='指令版本')),
('toolset_version', models.CharField(blank=True, max_length=40, verbose_name='工具集版本')),
('model_name', models.CharField(blank=True, max_length=100, verbose_name='模型名称')),
('trace_id', models.CharField(blank=True, max_length=128, verbose_name='追踪标识')),
('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')),
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')),
('duration_ms', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='耗时毫秒')),
('input_tokens', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='输入 Token')),
('output_tokens', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='输出 Token')),
('estimated_cost', models.DecimalField(blank=True, decimal_places=6, max_digits=12, null=True, verbose_name='估算费用')),
('error_code', models.CharField(blank=True, max_length=80, verbose_name='错误码')),
('error_summary', models.TextField(blank=True, verbose_name='错误摘要')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(app_label)s_%(class)s_items', to=settings.AUTH_USER_MODEL, verbose_name='所属用户')),
],
options={
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='AgentRunEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sequence', models.PositiveBigIntegerField(verbose_name='事件序号')),
('event_type', models.CharField(max_length=64, verbose_name='事件类型')),
('summary', models.CharField(max_length=240, verbose_name='摘要')),
('payload_summary', models.JSONField(default=dict, verbose_name='数据摘要')),
('occurred_at', models.DateTimeField(verbose_name='发生时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='记录时间')),
('run', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='agent_runtime.agentrun')),
],
options={
'ordering': ('sequence',),
},
),
migrations.CreateModel(
name='HumanApproval',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('request_key', models.CharField(max_length=128, verbose_name='请求键')),
('approval_type', models.CharField(max_length=64, verbose_name='确认类型')),
('status', models.CharField(choices=[('pending', '待处理'), ('approved', '已通过'), ('rejected', '已拒绝'), ('expired', '已过期'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')),
('request_summary', models.JSONField(default=dict, verbose_name='请求摘要')),
('decision_summary', models.JSONField(blank=True, null=True, verbose_name='处理摘要')),
('requested_at', models.DateTimeField(verbose_name='请求时间')),
('resolved_at', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')),
('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='resolved_agent_approvals', to=settings.AUTH_USER_MODEL)),
('run', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='approvals', to='agent_runtime.agentrun')),
],
),
migrations.CreateModel(
name='ToolCall',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('call_id', models.CharField(max_length=128, verbose_name='调用标识')),
('tool_name', models.CharField(max_length=100, verbose_name='工具名称')),
('tool_version', models.CharField(blank=True, max_length=40, verbose_name='工具版本')),
('idempotency_key', models.CharField(blank=True, max_length=128, verbose_name='幂等键')),
('status', models.CharField(choices=[('started', '已开始'), ('succeeded', '成功'), ('failed', '失败')], default='started', max_length=20, verbose_name='状态')),
('arguments_summary', models.JSONField(default=dict, verbose_name='参数摘要')),
('result_summary', models.JSONField(blank=True, null=True, verbose_name='结果摘要')),
('error_code', models.CharField(blank=True, max_length=80, verbose_name='错误码')),
('error_summary', models.TextField(blank=True, verbose_name='错误摘要')),
('started_at', models.DateTimeField(verbose_name='开始时间')),
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')),
('duration_ms', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='耗时毫秒')),
('run', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tool_calls', to='agent_runtime.agentrun')),
],
),
migrations.AddIndex(
model_name='agentrun',
index=models.Index(fields=['owner', 'created_at'], name='run_owner_created_idx'),
),
migrations.AddConstraint(
model_name='agentrun',
constraint=models.CheckConstraint(condition=models.Q(('lock_version__gte', 0)), name='run_lock_nonnegative'),
),
migrations.AddConstraint(
model_name='agentrunevent',
constraint=models.UniqueConstraint(fields=('run', 'sequence'), name='event_run_sequence_uniq'),
),
migrations.AddIndex(
model_name='humanapproval',
index=models.Index(fields=['status', 'requested_at'], name='approval_status_time_idx'),
),
migrations.AddConstraint(
model_name='humanapproval',
constraint=models.UniqueConstraint(fields=('run', 'request_key'), name='approval_run_key_uniq'),
),
migrations.AddIndex(
model_name='toolcall',
index=models.Index(fields=['run', 'started_at'], name='tool_run_started_idx'),
),
migrations.AddConstraint(
model_name='toolcall',
constraint=models.UniqueConstraint(fields=('run', 'call_id'), name='tool_run_call_uniq'),
),
migrations.AddConstraint(
model_name='toolcall',
constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key', ''), _negated=True), fields=('run', 'idempotency_key'), name='tool_run_idempotency_uniq'),
),
]
@@ -0,0 +1,41 @@
# Generated by Django 6.0.8 on 2026-09-18 05:29
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agent_runtime', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ModelProviderConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('provider_code', models.CharField(max_length=64, verbose_name='服务商编码')),
('encrypted_api_key', models.TextField(verbose_name='加密 API Key')),
('key_hint', models.CharField(max_length=12, verbose_name='密钥提示')),
('status', models.CharField(choices=[('connected', '已连接'), ('failed', '需要重新验证')], default='connected', max_length=20, verbose_name='连接状态')),
('enabled', models.BooleanField(default=True, verbose_name='已启用')),
('is_default', models.BooleanField(default=False, verbose_name='默认服务商')),
('default_model_id', models.CharField(blank=True, max_length=160, verbose_name='默认模型')),
('available_models', models.JSONField(default=list, verbose_name='可用模型快照')),
('last_verified_at', models.DateTimeField(blank=True, null=True, verbose_name='最近验证时间')),
('last_error_code', models.CharField(blank=True, max_length=80, verbose_name='最近错误码')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(app_label)s_%(class)s_items', to=settings.AUTH_USER_MODEL, verbose_name='所属用户')),
],
options={
'verbose_name': '模型服务商配置',
'verbose_name_plural': '模型服务商配置',
'ordering': ('provider_code',),
'constraints': [models.UniqueConstraint(fields=('owner', 'provider_code'), name='provider_owner_code_uniq')],
},
),
]
+192
View File
@@ -0,0 +1,192 @@
"""Agent 运行、事件、工具调用和人工确认模型。"""
import uuid
from django.conf import settings
from django.db import models
from django.db.models import Q
from common.models import TimeStampedModel, UserOwnedModel
class RunStatus(models.TextChoices):
PENDING = "pending", "等待"
RUNNING = "running", "运行中"
WAITING_APPROVAL = "waiting_approval", "等待确认"
SUCCEEDED = "succeeded", "成功"
FAILED = "failed", "失败"
CANCELLED = "cancelled", "已取消"
class ProviderConnectionStatus(models.TextChoices):
"""模型服务商连接状态。"""
CONNECTED = "connected", "已连接"
FAILED = "failed", "需要重新验证"
class ModelProviderConfig(UserOwnedModel):
"""当前用户的模型服务商凭据与已验证模型快照。"""
provider_code = models.CharField("服务商编码", max_length=64)
encrypted_api_key = models.TextField("加密 API Key")
key_hint = models.CharField("密钥提示", max_length=12)
status = models.CharField(
"连接状态",
max_length=20,
choices=ProviderConnectionStatus,
default=ProviderConnectionStatus.CONNECTED,
)
enabled = models.BooleanField("已启用", default=True)
is_default = models.BooleanField("默认服务商", default=False)
default_model_id = models.CharField("默认模型", max_length=160, blank=True)
available_models = models.JSONField("可用模型快照", default=list)
last_verified_at = models.DateTimeField("最近验证时间", null=True, blank=True)
last_error_code = models.CharField("最近错误码", max_length=80, blank=True)
class Meta:
verbose_name = "模型服务商配置"
verbose_name_plural = "模型服务商配置"
ordering = ("provider_code",)
constraints = [
models.UniqueConstraint(
fields=("owner", "provider_code"), name="provider_owner_code_uniq"
)
]
def __str__(self):
return f"{self.owner} / {self.provider_code}"
class AgentRun(UserOwnedModel):
"""一次可审计的 Agent 运行;摘要字段不得保存敏感原文。"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField("标题", max_length=160)
status = models.CharField("状态", max_length=32, choices=RunStatus, default=RunStatus.PENDING)
lock_version = models.PositiveBigIntegerField("锁版本", default=0)
input_summary = models.JSONField("输入摘要", default=dict)
output_summary = models.JSONField("输出摘要", null=True, blank=True)
agent_name = models.CharField("Agent 名称", max_length=100, default="job_research")
agent_version = models.CharField("Agent 版本", max_length=40, blank=True)
instruction_version = models.CharField("指令版本", max_length=40, blank=True)
toolset_version = models.CharField("工具集版本", max_length=40, blank=True)
model_name = models.CharField("模型名称", max_length=100, blank=True)
trace_id = models.CharField("追踪标识", max_length=128, blank=True)
started_at = models.DateTimeField("开始时间", null=True, blank=True)
finished_at = models.DateTimeField("结束时间", null=True, blank=True)
duration_ms = models.PositiveBigIntegerField("耗时毫秒", null=True, blank=True)
input_tokens = models.PositiveBigIntegerField("输入 Token", null=True, blank=True)
output_tokens = models.PositiveBigIntegerField("输出 Token", null=True, blank=True)
estimated_cost = models.DecimalField(
"估算费用", max_digits=12, decimal_places=6, null=True, blank=True
)
error_code = models.CharField("错误码", max_length=80, blank=True)
error_summary = models.TextField("错误摘要", blank=True)
class Meta:
ordering = ("-created_at",)
indexes = [models.Index(fields=("owner", "created_at"), name="run_owner_created_idx")]
constraints = [
models.CheckConstraint(condition=Q(lock_version__gte=0), name="run_lock_nonnegative")
]
def __str__(self):
return self.title
class AgentRunEvent(models.Model):
"""按序追加的不可变运行事件。"""
run = models.ForeignKey(AgentRun, on_delete=models.PROTECT, related_name="events")
sequence = models.PositiveBigIntegerField("事件序号")
event_type = models.CharField("事件类型", max_length=64)
summary = models.CharField("摘要", max_length=240)
payload_summary = models.JSONField("数据摘要", default=dict)
occurred_at = models.DateTimeField("发生时间")
created_at = models.DateTimeField("记录时间", auto_now_add=True)
class Meta:
ordering = ("sequence",)
constraints = [
models.UniqueConstraint(fields=("run", "sequence"), name="event_run_sequence_uniq")
]
def __str__(self):
"""使用运行标题和序号提供稳定、可读的管理端标识。"""
return f"{self.run} / 事件 {self.sequence}"
class ToolCallStatus(models.TextChoices):
STARTED = "started", "已开始"
SUCCEEDED = "succeeded", "成功"
FAILED = "failed", "失败"
class ToolCall(TimeStampedModel):
"""记录一次工具调用的脱敏输入、结果和失败信息。"""
run = models.ForeignKey(AgentRun, on_delete=models.PROTECT, related_name="tool_calls")
call_id = models.CharField("调用标识", max_length=128)
tool_name = models.CharField("工具名称", max_length=100)
tool_version = models.CharField("工具版本", max_length=40, blank=True)
idempotency_key = models.CharField("幂等键", max_length=128, blank=True)
status = models.CharField(
"状态", max_length=20, choices=ToolCallStatus, default=ToolCallStatus.STARTED
)
arguments_summary = models.JSONField("参数摘要", default=dict)
result_summary = models.JSONField("结果摘要", null=True, blank=True)
error_code = models.CharField("错误码", max_length=80, blank=True)
error_summary = models.TextField("错误摘要", blank=True)
started_at = models.DateTimeField("开始时间")
finished_at = models.DateTimeField("结束时间", null=True, blank=True)
duration_ms = models.PositiveBigIntegerField("耗时毫秒", null=True, blank=True)
class Meta:
indexes = [models.Index(fields=("run", "started_at"), name="tool_run_started_idx")]
constraints = [
models.UniqueConstraint(fields=("run", "call_id"), name="tool_run_call_uniq"),
models.UniqueConstraint(
fields=("run", "idempotency_key"),
condition=~Q(idempotency_key=""),
name="tool_run_idempotency_uniq",
),
]
class ApprovalStatus(models.TextChoices):
PENDING = "pending", "待处理"
APPROVED = "approved", "已通过"
REJECTED = "rejected", "已拒绝"
EXPIRED = "expired", "已过期"
CANCELLED = "cancelled", "已取消"
class HumanApproval(TimeStampedModel):
"""保存高风险动作的人工确认请求与处理证据。"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
run = models.ForeignKey(AgentRun, on_delete=models.PROTECT, related_name="approvals")
request_key = models.CharField("请求键", max_length=128)
approval_type = models.CharField("确认类型", max_length=64)
status = models.CharField(
"状态", max_length=20, choices=ApprovalStatus, default=ApprovalStatus.PENDING
)
request_summary = models.JSONField("请求摘要", default=dict)
decision_summary = models.JSONField("处理摘要", null=True, blank=True)
requested_at = models.DateTimeField("请求时间")
resolved_at = models.DateTimeField("处理时间", null=True, blank=True)
resolved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="resolved_agent_approvals",
)
class Meta:
indexes = [models.Index(fields=("status", "requested_at"), name="approval_status_time_idx")]
constraints = [
models.UniqueConstraint(fields=("run", "request_key"), name="approval_run_key_uniq")
]
+51
View File
@@ -0,0 +1,51 @@
"""Gateway 执行与本地 Agent Run 持久化之间的编排服务。"""
import logging
from common.logging import sanitize_summary
from .gateway import AgentExecutionRequest, AgentRunnerGateway
from .models import AgentRun, RunStatus
from .services import transition_run
logger = logging.getLogger(__name__)
def execute_run(run_id, gateway: AgentRunnerGateway) -> AgentRun:
"""使用注入的 Gateway 驱动一次运行,并确保所有结果都进入稳定终态。
外部调用发生在两个短事务之间:开始状态先提交,Gateway 返回或抛错后再用
独立事务保存成功或失败结果。该函数不重试,避免第一阶段产生重复外部副作用。
"""
running = transition_run(run_id, RunStatus.RUNNING)
request = AgentExecutionRequest(
agent_run_id=str(running.id),
user_id=running.owner_id,
input_summary=sanitize_summary(running.input_summary),
)
try:
result = gateway.run(request)
except Exception as exc: # Gateway 是外部边界,必须把未知异常转换为可审计失败。
# 外部异常消息可能夹带请求参数或认证信息,因此日志和数据库只保留异常类型。
error_type = type(exc).__name__
logger.error(
"Agent Gateway 执行异常,异常类型=%s",
error_type,
extra={"agent_run_id": str(running.id)},
)
return transition_run(
running.id,
RunStatus.FAILED,
error_code="gateway_error",
error_summary=f"Agent Gateway 执行异常:{error_type}",
)
if result.succeeded:
return transition_run(running.id, RunStatus.SUCCEEDED, output=result.output_summary)
return transition_run(
running.id,
RunStatus.FAILED,
error_code=result.error_code or "agent_failed",
error_summary=result.error_summary,
)
+286
View File
@@ -0,0 +1,286 @@
"""模型服务商预设目录。
预设只保存公开的协议与端点,不保存用户凭据。需要云 IAM、部署名称或
业务空间域名的服务商仍展示在目录中,但不会误导用户只靠 API Key 即可连接。
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class ModelProvider:
"""描述一个可展示、可验证的模型服务商预设。"""
code: str
name: str
vendor: str
category: str
protocol: str
base_url: str = ""
models_path: str = "models"
default_model: str = ""
key_only: bool = True
note: str = ""
mark: str = "AI"
@property
def protocol_label(self) -> str:
labels = {
"openai_responses": "Responses API",
"openai_compatible": "OpenAI 兼容",
"anthropic_messages": "Messages API",
"gemini": "Gemini API",
"cloud": "云平台接口",
}
return labels.get(self.protocol, self.protocol)
PROVIDERS = (
ModelProvider(
"openai",
"OpenAI",
"OpenAI",
"official",
"openai_responses",
"https://api.openai.com/v1",
default_model="gpt-5.6-terra",
mark="OA",
),
ModelProvider(
"anthropic",
"Anthropic Claude",
"Anthropic",
"official",
"anthropic_messages",
"https://api.anthropic.com/v1",
default_model="claude-sonnet-4-6",
mark="CL",
),
ModelProvider(
"deepseek",
"DeepSeek",
"深度求索",
"domestic",
"openai_compatible",
"https://api.deepseek.com",
default_model="deepseek-chat",
mark="DS",
),
ModelProvider(
"gemini",
"Google Gemini",
"Google",
"official",
"gemini",
"https://generativelanguage.googleapis.com/v1beta",
models_path="models",
default_model="gemini-2.5-flash",
mark="GE",
),
ModelProvider(
"aliyun",
"阿里云百炼 / Qwen",
"阿里云",
"domestic",
"openai_compatible",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
default_model="qwen-plus",
mark="QW",
),
ModelProvider(
"zhipu",
"智谱 AI / Z.AI",
"智谱",
"domestic",
"openai_compatible",
"https://open.bigmodel.cn/api/paas/v4",
default_model="glm-4.5",
mark="GL",
),
ModelProvider(
"xiaomi_mimo",
"小米 MiMo",
"小米",
"domestic",
"openai_compatible",
key_only=False,
note="官方端点及账户区域确认后开放",
mark="MI",
),
ModelProvider(
"siliconflow",
"硅基流动",
"SiliconFlow",
"domestic",
"openai_compatible",
"https://api.siliconflow.cn/v1",
default_model="deepseek-ai/DeepSeek-V3",
mark="SF",
),
ModelProvider(
"moonshot",
"月之暗面 Kimi",
"Moonshot AI",
"domestic",
"openai_compatible",
"https://api.moonshot.cn/v1",
default_model="moonshot-v1-8k",
mark="KM",
),
ModelProvider(
"minimax",
"MiniMax",
"MiniMax",
"domestic",
"openai_compatible",
key_only=False,
note="部分账户还需要 Group ID",
mark="MM",
),
ModelProvider(
"volcengine",
"火山引擎方舟 / 豆包",
"火山引擎",
"domestic",
"cloud",
key_only=False,
note="需要接入点或推理端点配置",
mark="DB",
),
ModelProvider(
"baidu",
"百度智能云千帆 / 文心",
"百度智能云",
"domestic",
"cloud",
key_only=False,
note="需要应用或云鉴权配置",
mark="BD",
),
ModelProvider(
"tencent",
"腾讯混元",
"腾讯云",
"domestic",
"cloud",
key_only=False,
note="需要腾讯云鉴权配置",
mark="HY",
),
ModelProvider(
"huawei",
"华为云 ModelArts / 盘古",
"华为云",
"domestic",
"cloud",
key_only=False,
note="需要项目、区域及云鉴权配置",
mark="HW",
),
ModelProvider(
"openrouter",
"OpenRouter",
"OpenRouter",
"gateway",
"openai_compatible",
"https://openrouter.ai/api/v1",
default_model="openai/gpt-4o-mini",
mark="OR",
),
ModelProvider(
"groq",
"Groq",
"Groq",
"gateway",
"openai_compatible",
"https://api.groq.com/openai/v1",
default_model="llama-3.3-70b-versatile",
mark="GQ",
),
ModelProvider(
"together",
"Together AI",
"Together AI",
"gateway",
"openai_compatible",
"https://api.together.xyz/v1",
mark="TO",
),
ModelProvider(
"mistral",
"Mistral AI",
"Mistral",
"official",
"openai_compatible",
"https://api.mistral.ai/v1",
default_model="mistral-large-latest",
mark="MS",
),
ModelProvider(
"cohere",
"Cohere",
"Cohere",
"official",
"openai_compatible",
key_only=False,
note="原生协议适配待接入",
mark="CO",
),
ModelProvider(
"xai",
"xAI Grok",
"xAI",
"official",
"openai_compatible",
"https://api.x.ai/v1",
default_model="grok-4",
mark="XA",
),
ModelProvider(
"azure_openai",
"Azure OpenAI",
"Microsoft Azure",
"cloud",
"cloud",
key_only=False,
note="需要资源端点和部署名称",
mark="AZ",
),
ModelProvider(
"aws_bedrock",
"AWS Bedrock",
"Amazon Web Services",
"cloud",
"cloud",
key_only=False,
note="需要区域和 AWS IAM 凭据",
mark="AW",
),
ModelProvider(
"custom",
"自定义 OpenAI 兼容服务",
"自定义",
"gateway",
"openai_compatible",
key_only=False,
note="需要 Base URL 和模型 ID",
mark="+",
),
)
PROVIDER_BY_CODE = {provider.code: provider for provider in PROVIDERS}
CATEGORY_LABELS = {
"all": "全部",
"official": "官方直连",
"domestic": "国内平台",
"gateway": "云与聚合",
"cloud": "云平台",
"connected": "已连接",
}
def get_provider(code: str) -> ModelProvider | None:
"""按稳定编码读取预设,未知编码返回空值。"""
return PROVIDER_BY_CODE.get(code)
+34
View File
@@ -0,0 +1,34 @@
"""模型 API Key 的服务端加密边界。"""
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings
from common.exceptions import AgentConfigurationError
def _fernet() -> Fernet:
"""从独立部署密钥派生 Fernet 密钥,避免直接复用原始配置文本。"""
secret = str(getattr(settings, "MODEL_API_KEY_ENCRYPTION_KEY", "") or "").strip()
if len(secret) < 32:
raise AgentConfigurationError("模型密钥加密主密钥未配置或长度不足。")
derived = hashlib.sha256(secret.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(derived))
def encrypt_api_key(api_key: str) -> str:
"""加密 API Key;密文可以入库,但不得出现在页面和日志中。"""
return _fernet().encrypt(api_key.encode("utf-8")).decode("ascii")
def decrypt_api_key(ciphertext: str) -> str:
"""解密 API Key,并把密钥轮换或数据损坏转换为稳定配置错误。"""
try:
return _fernet().decrypt(ciphertext.encode("ascii")).decode("utf-8")
except (InvalidToken, UnicodeError, ValueError) as exc:
raise AgentConfigurationError("模型 API Key 无法解密,请重新配置。") from exc
+357
View File
@@ -0,0 +1,357 @@
"""Agent Run 与模型服务商配置的领域服务。"""
import json
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from common.exceptions import (
AgentConfigurationError,
InvalidStateTransition,
PermissionDenied,
ProviderConnectionError,
)
from common.logging import sanitize_summary
from .models import (
AgentRun,
AgentRunEvent,
ApprovalStatus,
HumanApproval,
ModelProviderConfig,
ProviderConnectionStatus,
RunStatus,
ToolCall,
ToolCallStatus,
)
from .providers import ModelProvider, get_provider
from .secrets import decrypt_api_key, encrypt_api_key
@dataclass(frozen=True)
class ProviderVerificationResult:
"""连接验证后的安全结果,不携带凭据或上游响应正文。"""
models: tuple[str, ...]
@dataclass(frozen=True)
class RuntimeModelConfig:
"""真实 Gateway 调用前解析出的用户级模型配置。"""
provider_code: str
protocol: str
base_url: str
api_key: str
model: str
def provider_configs_for_user(user):
"""返回当前用户配置,并由调用方按服务商编码组织展示。"""
return ModelProviderConfig.objects.filter(owner=user)
def _verification_headers(provider: ModelProvider, api_key: str) -> dict[str, str]:
"""按预设协议构造最小认证头;API Key 不进入 URL。"""
headers = {"Accept": "application/json", "User-Agent": "JobRadar/0.1"}
if provider.protocol == "anthropic_messages":
headers.update({"x-api-key": api_key, "anthropic-version": "2023-06-01"})
elif provider.protocol == "gemini":
headers["x-goog-api-key"] = api_key
else:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def verify_provider_api_key(
provider: ModelProvider, api_key: str, *, opener=None
) -> ProviderVerificationResult:
"""通过模型目录验证凭据,禁止执行会产生模型用量的对话请求。"""
if not provider.key_only or not provider.base_url:
raise ProviderConnectionError("extra_configuration_required", "该服务商需要额外配置。")
open_request = opener or urlopen
endpoint = f"{provider.base_url.rstrip('/')}/{provider.models_path.lstrip('/')}"
request = Request(endpoint, headers=_verification_headers(provider, api_key), method="GET")
try:
with open_request(request, timeout=12) as response:
payload = json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
if exc.code == 401:
raise ProviderConnectionError("invalid_api_key", "API Key 无效或已失效。") from exc
if exc.code == 403:
raise ProviderConnectionError(
"permission_denied", "API Key 没有访问模型目录的权限。"
) from exc
if exc.code == 429:
raise ProviderConnectionError(
"rate_limited", "服务商请求过于频繁,请稍后重试。"
) from exc
raise ProviderConnectionError("provider_http_error", "服务商暂时无法完成验证。") from exc
except (URLError, TimeoutError) as exc:
raise ProviderConnectionError(
"provider_unreachable", "无法连接服务商,请稍后重试。"
) from exc
except (json.JSONDecodeError, UnicodeError) as exc:
raise ProviderConnectionError(
"invalid_provider_response", "服务商返回了无法识别的数据。"
) from exc
if not isinstance(payload, dict):
raise ProviderConnectionError(
"invalid_provider_response", "服务商返回了无法识别的数据。"
)
raw_models = payload.get("data") or payload.get("models") or []
model_ids = []
for item in raw_models:
if not isinstance(item, dict):
continue
model_id = item.get("id") or item.get("name")
if isinstance(model_id, str) and model_id:
model_ids.append(model_id.removeprefix("models/"))
# 某些兼容端点只验证凭据但不返回模型目录,保留经过项目核验的推荐模型。
if not model_ids and provider.default_model:
model_ids.append(provider.default_model)
return ProviderVerificationResult(tuple(dict.fromkeys(model_ids)))
@transaction.atomic
def save_verified_provider_config(
owner,
provider: ModelProvider,
api_key: str,
result: ProviderVerificationResult,
*,
model_id: str = "",
use_as_default: bool = False,
) -> ModelProviderConfig:
"""只在连接验证成功后替换密钥,避免错误输入破坏已有可用配置。"""
selected_model = model_id.strip() or provider.default_model
if not selected_model and result.models:
selected_model = result.models[0]
existing_count = ModelProviderConfig.objects.filter(owner=owner).count()
should_default = use_as_default or existing_count == 0
if should_default:
ModelProviderConfig.objects.filter(owner=owner, is_default=True).update(is_default=False)
config, _ = ModelProviderConfig.objects.update_or_create(
owner=owner,
provider_code=provider.code,
defaults={
"encrypted_api_key": encrypt_api_key(api_key),
"key_hint": f"••••{api_key[-4:]}",
"status": ProviderConnectionStatus.CONNECTED,
"enabled": True,
"is_default": should_default,
"default_model_id": selected_model,
"available_models": list(result.models)[:200],
"last_verified_at": timezone.now(),
"last_error_code": "",
},
)
return config
@transaction.atomic
def disconnect_provider(owner, provider_code: str) -> bool:
"""删除当前用户指定服务商的密文配置,不影响其他用户或运行审计数据。"""
deleted, _ = ModelProviderConfig.objects.filter(
owner=owner, provider_code=provider_code
).delete()
if deleted and not ModelProviderConfig.objects.filter(owner=owner, is_default=True).exists():
fallback = ModelProviderConfig.objects.filter(owner=owner, enabled=True).first()
if fallback:
fallback.is_default = True
fallback.save(update_fields=("is_default", "updated_at"))
return bool(deleted)
def resolve_runtime_model_config(owner, provider_code: str = "") -> RuntimeModelConfig:
"""在真实执行边界解密当前用户配置,密钥不会进入运行请求或数据库摘要。"""
queryset = ModelProviderConfig.objects.filter(
owner=owner,
enabled=True,
status=ProviderConnectionStatus.CONNECTED,
)
config = queryset.filter(provider_code=provider_code).first() if provider_code else None
config = config or queryset.filter(is_default=True).first()
if config is None:
raise AgentConfigurationError("当前用户尚未配置可用的模型服务商。")
provider = get_provider(config.provider_code)
if provider is None or not provider.base_url:
raise AgentConfigurationError("当前模型服务商预设不可用,请重新配置。")
if not config.default_model_id:
raise AgentConfigurationError("当前服务商尚未选择默认模型。")
return RuntimeModelConfig(
provider_code=provider.code,
protocol=provider.protocol,
base_url=provider.base_url,
api_key=decrypt_api_key(config.encrypted_api_key),
model=config.default_model_id,
)
TRANSITIONS = {
RunStatus.PENDING: {RunStatus.RUNNING, RunStatus.CANCELLED},
RunStatus.RUNNING: {
RunStatus.WAITING_APPROVAL,
RunStatus.SUCCEEDED,
RunStatus.FAILED,
RunStatus.CANCELLED,
},
RunStatus.WAITING_APPROVAL: {RunStatus.RUNNING, RunStatus.FAILED, RunStatus.CANCELLED},
}
def runs_for_user(user):
"""普通站点始终限制为当前用户数据,管理员跨用户查询使用 Admin。"""
return AgentRun.objects.filter(owner=user)
def _append_event(run, event_type: str, summary: str, payload=None):
"""在持有 Run 写锁的事务中分配下一个事件序号。"""
last = run.events.aggregate(value=Max("sequence"))["value"] or 0
return AgentRunEvent.objects.create(
run=run,
sequence=last + 1,
event_type=event_type,
summary=summary,
payload_summary=sanitize_summary(payload or {}),
occurred_at=timezone.now(),
)
@transaction.atomic
def create_run(owner, title: str, input_summary=None) -> AgentRun:
"""创建等待运行的记录,并原子追加创建事件。"""
run = AgentRun.objects.create(
owner=owner, title=title.strip(), input_summary=sanitize_summary(input_summary or {})
)
_append_event(run, "run_created", "运行记录已创建")
return run
@transaction.atomic
def transition_run(run_id, target_status: str, *, error_code="", error_summary="", output=None):
"""校验并执行一次状态转换,主记录与审计事件同时提交。"""
run = AgentRun.objects.select_for_update().get(pk=run_id)
if target_status not in TRANSITIONS.get(run.status, set()):
raise InvalidStateTransition(f"不允许从 {run.status} 转换到 {target_status}。")
now = timezone.now()
run.status = target_status
run.lock_version += 1
if target_status == RunStatus.RUNNING and run.started_at is None:
run.started_at = now
if target_status in {RunStatus.SUCCEEDED, RunStatus.FAILED, RunStatus.CANCELLED}:
run.finished_at = now
if run.started_at:
run.duration_ms = max(0, int((now - run.started_at).total_seconds() * 1000))
if target_status == RunStatus.SUCCEEDED:
run.output_summary = sanitize_summary(output or {})
if target_status == RunStatus.FAILED:
run.error_code = error_code[:80]
run.error_summary = error_summary[:2000]
run.save()
_append_event(run, f"run_{target_status}", f"运行状态变更为 {run.get_status_display()}")
return run
@transaction.atomic
def request_approval(run_id, request_key: str, approval_type: str, summary=None):
"""暂停运行并创建唯一的待确认请求。"""
run = AgentRun.objects.select_for_update().get(pk=run_id)
if run.status != RunStatus.RUNNING:
raise InvalidStateTransition("只有运行中的任务可以请求人工确认。")
approval = HumanApproval.objects.create(
run=run,
request_key=request_key,
approval_type=approval_type,
request_summary=sanitize_summary(summary or {}),
requested_at=timezone.now(),
)
run.status = RunStatus.WAITING_APPROVAL
run.lock_version += 1
run.save(update_fields=("status", "lock_version", "updated_at"))
_append_event(run, "approval_requested", "运行等待人工确认", {"request_key": request_key})
return approval
@transaction.atomic
def start_tool_call(run_id, call_id: str, tool_name: str, arguments=None, idempotency_key=""):
"""在外部调用前登记开始状态;唯一约束负责阻止重复调用标识和幂等键。"""
run = AgentRun.objects.select_for_update().get(pk=run_id)
if run.status != RunStatus.RUNNING:
raise InvalidStateTransition("只有运行中的任务可以开始工具调用。")
call = ToolCall.objects.create(
run=run,
call_id=call_id,
tool_name=tool_name,
idempotency_key=idempotency_key,
arguments_summary=sanitize_summary(arguments or {}),
started_at=timezone.now(),
)
_append_event(run, "tool_started", f"工具 {tool_name} 开始执行", {"call_id": call_id})
return call
@transaction.atomic
def finish_tool_call(call_id: int, *, result=None, error_code="", error_summary=""):
"""在外部调用结束后的独立短事务中登记成功或失败结果。"""
call = ToolCall.objects.select_for_update().select_related("run").get(pk=call_id)
if call.status != ToolCallStatus.STARTED:
raise InvalidStateTransition("工具调用已经结束。")
now = timezone.now()
call.finished_at = now
call.duration_ms = max(0, int((now - call.started_at).total_seconds() * 1000))
if error_code:
call.status = ToolCallStatus.FAILED
call.error_code = error_code[:80]
call.error_summary = error_summary[:2000]
event_type, summary = "tool_failed", f"工具 {call.tool_name} 执行失败"
else:
call.status = ToolCallStatus.SUCCEEDED
call.result_summary = sanitize_summary(result or {})
event_type, summary = "tool_completed", f"工具 {call.tool_name} 执行完成"
call.save()
run = AgentRun.objects.select_for_update().get(pk=call.run_id)
_append_event(run, event_type, summary, {"call_id": call.call_id})
return call
@transaction.atomic
def resolve_approval(actor, approval_id, approved: bool, summary=None):
"""只允许所属用户或管理员处理一次待确认请求。"""
approval = HumanApproval.objects.select_for_update().select_related("run").get(pk=approval_id)
if actor != approval.run.owner and not actor.is_staff:
raise PermissionDenied("无权处理该确认请求。")
if approval.status != ApprovalStatus.PENDING:
raise InvalidStateTransition("该确认请求已经处理。")
approval.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
approval.resolved_by = actor
approval.resolved_at = timezone.now()
approval.decision_summary = sanitize_summary(summary or {})
approval.save()
run = AgentRun.objects.select_for_update().get(pk=approval.run_id)
run.status = RunStatus.RUNNING if approved else RunStatus.CANCELLED
run.lock_version += 1
if not approved:
run.finished_at = timezone.now()
run.save()
_append_event(run, "approval_resolved", "人工确认已处理", {"approved": approved})
return approval
+306
View File
@@ -0,0 +1,306 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.test import SimpleTestCase, TestCase, override_settings
from django.urls import reverse
from common.exceptions import (
AgentConfigurationError,
InvalidStateTransition,
PermissionDenied,
ProviderConnectionError,
)
from .config import AgentRuntimeConfig
from .gateway import AgentExecutionResult, StubAgentRunnerGateway
from .models import ApprovalStatus, ModelProviderConfig, RunStatus, ToolCallStatus
from .orchestration import execute_run
from .providers import get_provider
from .secrets import decrypt_api_key, encrypt_api_key
from .services import (
ProviderVerificationResult,
create_run,
disconnect_provider,
finish_tool_call,
request_approval,
resolve_approval,
resolve_runtime_model_config,
save_verified_provider_config,
start_tool_call,
transition_run,
verify_provider_api_key,
)
class ExplodingGateway:
"""模拟外部边界抛错,并故意在异常文本中携带不应落库的秘密。"""
def run(self, request):
raise RuntimeError("api_key=should-not-be-stored")
class AgentRunTests(TestCase):
"""覆盖运行状态、事件顺序、脱敏和跨用户拒绝路径。"""
def setUp(self):
users = get_user_model().objects
self.alice = users.create_user(username="alice", password="safe-pass-123")
self.bob = users.create_user(username="bob", password="safe-pass-123")
def test_run_lifecycle_and_sensitive_summary(self):
run = create_run(self.alice, "基线运行", {"api_key": "secret", "query": "Python"})
self.assertEqual(run.input_summary["api_key"], "***")
transition_run(run.id, RunStatus.RUNNING)
finished = transition_run(run.id, RunStatus.SUCCEEDED, output={"count": 1})
self.assertEqual(finished.status, RunStatus.SUCCEEDED)
self.assertEqual(list(finished.events.values_list("sequence", flat=True)), [1, 2, 3])
with self.assertRaises(InvalidStateTransition):
transition_run(run.id, RunStatus.RUNNING)
def test_cross_user_detail_returns_404(self):
run = create_run(self.alice, "私有运行")
self.client.force_login(self.bob)
response = self.client.get(reverse("agent_runtime:run-detail", args=(run.id,)))
self.assertEqual(response.status_code, 404)
def test_run_list_only_contains_current_user_data(self):
create_run(self.alice, "爱丽丝的运行")
create_run(self.bob, "鲍勃的运行")
self.client.force_login(self.alice)
response = self.client.get(reverse("agent_runtime:run-list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "爱丽丝的运行")
self.assertNotContains(response, "鲍勃的运行")
def test_tool_call_is_recorded_and_sanitized(self):
run = create_run(self.alice, "工具运行")
transition_run(run.id, RunStatus.RUNNING)
call = start_tool_call(run.id, "call-1", "demo_tool", {"password": "secret"})
self.assertEqual(call.arguments_summary["password"], "***")
finished = finish_tool_call(call.id, result={"count": 1})
self.assertEqual(finished.status, "succeeded")
self.assertEqual(finished.result_summary, {"count": 1})
def test_tool_call_failure_and_duplicate_finish_are_recorded(self):
run = create_run(self.alice, "失败工具运行")
transition_run(run.id, RunStatus.RUNNING)
call = start_tool_call(run.id, "call-1", "demo_tool", idempotency_key="same-operation")
failed = finish_tool_call(call.id, error_code="timeout", error_summary="请求超时")
self.assertEqual(failed.status, ToolCallStatus.FAILED)
self.assertEqual(failed.error_code, "timeout")
with self.assertRaises(InvalidStateTransition):
finish_tool_call(call.id, result={"unexpected": True})
def test_duplicate_tool_idempotency_key_is_rejected(self):
run = create_run(self.alice, "幂等工具运行")
transition_run(run.id, RunStatus.RUNNING)
start_tool_call(run.id, "call-1", "demo_tool", idempotency_key="same-operation")
with self.assertRaises(IntegrityError), transaction.atomic():
start_tool_call(run.id, "call-2", "demo_tool", idempotency_key="same-operation")
def test_approval_can_be_approved_once_by_owner(self):
run = create_run(self.alice, "确认运行")
transition_run(run.id, RunStatus.RUNNING)
approval = request_approval(run.id, "approval-1", "external_action", {"token": "secret"})
self.assertEqual(approval.request_summary["token"], "***")
resolved = resolve_approval(self.alice, approval.id, True, {"reason": "允许"})
self.assertEqual(resolved.status, ApprovalStatus.APPROVED)
run.refresh_from_db()
self.assertEqual(run.status, RunStatus.RUNNING)
with self.assertRaises(InvalidStateTransition):
resolve_approval(self.alice, approval.id, True)
def test_approval_rejects_cross_user_and_can_cancel_run(self):
run = create_run(self.alice, "拒绝确认运行")
transition_run(run.id, RunStatus.RUNNING)
approval = request_approval(run.id, "approval-1", "external_action")
with self.assertRaises(PermissionDenied):
resolve_approval(self.bob, approval.id, False)
resolve_approval(self.alice, approval.id, False)
run.refresh_from_db()
self.assertEqual(run.status, RunStatus.CANCELLED)
def test_stub_gateway_drives_successful_persistent_run(self):
run = create_run(self.alice, "Stub 成功运行", {"query": "Python"})
gateway = StubAgentRunnerGateway(AgentExecutionResult(True, {"count": 2}))
finished = execute_run(run.id, gateway)
self.assertEqual(finished.status, RunStatus.SUCCEEDED)
self.assertEqual(finished.output_summary, {"count": 2})
self.assertEqual(finished.events.count(), 3)
def test_stub_gateway_failure_and_exception_reach_failed_state(self):
failed_run = create_run(self.alice, "Stub 失败运行")
failed_gateway = StubAgentRunnerGateway(
AgentExecutionResult(False, error_code="model_error", error_summary="模型失败")
)
failed = execute_run(failed_run.id, failed_gateway)
self.assertEqual(failed.status, RunStatus.FAILED)
self.assertEqual(failed.error_code, "model_error")
exploding_run = create_run(self.alice, "Stub 异常运行")
exploded = execute_run(exploding_run.id, ExplodingGateway())
self.assertEqual(exploded.status, RunStatus.FAILED)
self.assertNotIn("should-not-be-stored", exploded.error_summary)
class AgentRuntimeConfigTests(SimpleTestCase):
"""验证真实执行配置只在调用边界检查,不影响其他 Django 功能。"""
@override_settings(OPENAI_API_KEY="", OPENAI_MODEL="", AGENT_DEFAULT_NAME="job_research")
def test_real_execution_requires_key_and_model(self):
with self.assertRaises(AgentConfigurationError):
AgentRuntimeConfig.from_settings().require_real_execution()
@override_settings(
OPENAI_API_KEY="test-key", OPENAI_MODEL="test-model", AGENT_DEFAULT_NAME="job_research"
)
def test_complete_configuration_is_accepted_without_external_call(self):
config = AgentRuntimeConfig.from_settings().require_real_execution()
self.assertEqual(config.model, "test-model")
class FakeModelsResponse:
"""模拟只返回公开模型标识的服务商模型目录。"""
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def read(self):
return b'{"data":[{"id":"model-a"},{"id":"model-b"}]}'
class ProviderVerificationTests(SimpleTestCase):
"""验证连接测试只访问模型目录,且凭据不会进入 URL。"""
def test_openai_compatible_verification_uses_bearer_header(self):
captured = {}
def opener(request, timeout):
captured["url"] = request.full_url
captured["authorization"] = request.get_header("Authorization")
captured["timeout"] = timeout
return FakeModelsResponse()
result = verify_provider_api_key(get_provider("deepseek"), "safe-test-key", opener=opener)
self.assertEqual(result.models, ("model-a", "model-b"))
self.assertEqual(captured["authorization"], "Bearer safe-test-key")
self.assertNotIn("safe-test-key", captured["url"])
self.assertEqual(captured["timeout"], 12)
def test_provider_requiring_extra_configuration_is_rejected_without_request(self):
with self.assertRaises(ProviderConnectionError) as context:
verify_provider_api_key(get_provider("azure_openai"), "safe-test-key")
self.assertEqual(context.exception.code, "extra_configuration_required")
@override_settings(MODEL_API_KEY_ENCRYPTION_KEY="test-only-model-key-32-characters-minimum")
class ModelProviderConfigTests(TestCase):
"""覆盖密钥加密、用户隔离、默认选择和页面不回显边界。"""
def setUp(self):
users = get_user_model().objects
self.alice = users.create_user(username="alice", password="safe-pass-123")
self.bob = users.create_user(username="bob", password="safe-pass-123")
self.provider = get_provider("openai")
self.result = ProviderVerificationResult(("model-a", "model-b"))
def test_api_key_round_trip_uses_ciphertext(self):
ciphertext = encrypt_api_key("safe-test-key")
self.assertNotIn("safe-test-key", ciphertext)
self.assertEqual(decrypt_api_key(ciphertext), "safe-test-key")
def test_verified_config_is_user_owned_and_resolves_for_runtime(self):
config = save_verified_provider_config(
self.alice,
self.provider,
"safe-test-key",
self.result,
model_id="model-a",
)
self.assertTrue(config.is_default)
runtime = resolve_runtime_model_config(self.alice)
self.assertEqual(runtime.api_key, "safe-test-key")
self.assertEqual(runtime.model, "model-a")
user_config = AgentRuntimeConfig.from_user(self.alice)
self.assertEqual(user_config.provider_code, "openai")
self.assertEqual(user_config.protocol, "openai_responses")
self.assertEqual(user_config.base_url, "https://api.openai.com/v1")
with self.assertRaises(AgentConfigurationError):
resolve_runtime_model_config(self.bob)
def test_disconnect_promotes_remaining_provider(self):
first = save_verified_provider_config(
self.alice, self.provider, "safe-test-key", self.result, model_id="model-a"
)
second = save_verified_provider_config(
self.alice,
get_provider("deepseek"),
"another-safe-key",
self.result,
model_id="model-b",
)
self.assertTrue(first.is_default)
self.assertFalse(second.is_default)
disconnect_provider(self.alice, "openai")
second.refresh_from_db()
self.assertTrue(second.is_default)
def test_provider_page_requires_login_and_lists_catalog(self):
response = self.client.get(reverse("agent_runtime:provider-list"))
self.assertEqual(response.status_code, 302)
self.client.force_login(self.alice)
response = self.client.get(reverse("agent_runtime:provider-list"))
self.assertContains(response, "阿里云百炼")
self.assertContains(response, "小米 MiMo")
self.assertContains(response, "共 23 个内置服务商")
@patch("agent_runtime.views.verify_provider_api_key")
def test_configure_page_saves_verified_key_without_rendering_it(self, verify):
verify.return_value = self.result
self.client.force_login(self.alice)
response = self.client.post(
reverse("agent_runtime:provider-configure", args=("openai",)),
{"api_key": "safe-test-key", "model_id": "model-a", "use_as_default": "on"},
)
self.assertRedirects(response, reverse("agent_runtime:provider-list"))
config = ModelProviderConfig.objects.get(owner=self.alice, provider_code="openai")
self.assertNotIn("safe-test-key", config.encrypted_api_key)
page = self.client.get(f"{reverse('agent_runtime:provider-list')}?provider=openai")
self.assertNotContains(page, "safe-test-key")
self.assertContains(page, config.key_hint)
@patch("agent_runtime.views.verify_provider_api_key")
def test_failed_reconfiguration_does_not_replace_existing_key(self, verify):
config = save_verified_provider_config(
self.alice, self.provider, "original-safe-key", self.result, model_id="model-a"
)
original_ciphertext = config.encrypted_api_key
verify.side_effect = ProviderConnectionError("invalid_api_key", "API Key 无效。")
self.client.force_login(self.alice)
response = self.client.post(
reverse("agent_runtime:provider-configure", args=("openai",)),
{"api_key": "incorrect-key", "model_id": "model-b"},
)
self.assertEqual(response.status_code, 400)
config.refresh_from_db()
self.assertEqual(config.encrypted_api_key, original_ciphertext)
self.assertEqual(decrypt_api_key(config.encrypted_api_key), "original-safe-key")
def test_connected_filter_never_exposes_another_users_provider(self):
save_verified_provider_config(
self.bob, self.provider, "bob-safe-key", self.result, model_id="model-a"
)
self.client.force_login(self.alice)
response = self.client.get(
reverse("agent_runtime:provider-list"), {"category": "connected"}
)
self.assertNotContains(response, "OpenAI")
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path
from . import views
app_name = "agent_runtime"
urlpatterns = [
path("", views.run_list, name="run-list"),
path("providers/", views.provider_list, name="provider-list"),
path(
"providers/<slug:provider_code>/configure/",
views.provider_configure,
name="provider-configure",
),
path(
"providers/<slug:provider_code>/disconnect/",
views.provider_disconnect,
name="provider-disconnect",
),
path("<uuid:run_id>/", views.run_detail, name="run-detail"),
]
+131
View File
@@ -0,0 +1,131 @@
"""当前用户的 Agent Run 与模型服务商配置页面。"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.shortcuts import get_object_or_404, redirect, render
from common.exceptions import AgentConfigurationError, ProviderConnectionError
from .forms import ProviderConfigurationForm
from .providers import CATEGORY_LABELS, PROVIDERS, get_provider
from .services import (
disconnect_provider,
provider_configs_for_user,
runs_for_user,
save_verified_provider_config,
verify_provider_api_key,
)
@login_required
def run_list(request):
"""分页显示当前用户的运行记录。"""
page = Paginator(runs_for_user(request.user), 20).get_page(request.GET.get("page"))
return render(request, "agent_runtime/run_list.html", {"page": page})
@login_required
def run_detail(request, run_id):
"""越权与不存在统一返回 404,避免泄露资源存在性。"""
run = get_object_or_404(
runs_for_user(request.user).prefetch_related("events", "tool_calls", "approvals"), pk=run_id
)
return render(request, "agent_runtime/run_detail.html", {"run": run})
def _provider_page_context(request, form=None):
"""构造纯展示视图模型,模板不承担查询、权限或协议判断。"""
configs = {item.provider_code: item for item in provider_configs_for_user(request.user)}
category = request.GET.get("category", "all")
query = request.GET.get("q", "").strip().lower()
selected = get_provider(request.GET.get("provider", ""))
cards = []
for provider in PROVIDERS:
config = configs.get(provider.code)
if category == "connected" and config is None:
continue
if category not in {"all", "connected"} and provider.category != category:
continue
searchable = f"{provider.name} {provider.vendor} {provider.code}".lower()
if query and query not in searchable:
continue
cards.append({"provider": provider, "config": config})
selected_config = configs.get(selected.code) if selected else None
return {
"cards": cards,
"provider_count": len(PROVIDERS),
"connected_count": len(configs),
"categories": CATEGORY_LABELS,
"active_category": category if category in CATEGORY_LABELS else "all",
"query": request.GET.get("q", "").strip(),
"selected_provider": selected,
"selected_config": selected_config,
"provider_form": form
or ProviderConfigurationForm(
initial={
"model_id": selected_config.default_model_id if selected_config else "",
"use_as_default": selected_config.is_default if selected_config else False,
}
),
}
@login_required
def provider_list(request):
"""展示当前用户的服务商目录和已连接状态。"""
return render(request, "agent_runtime/provider_list.html", _provider_page_context(request))
@login_required
def provider_configure(request, provider_code):
"""验证并保存当前用户的 API Key;失败时不覆盖已有配置。"""
provider = get_provider(provider_code)
if provider is None:
return redirect("agent_runtime:provider-list")
if request.method != "POST":
return redirect(f"{redirect('agent_runtime:provider-list').url}?provider={provider.code}")
form = ProviderConfigurationForm(request.POST)
if not provider.key_only:
messages.info(request, provider.note or "该服务商需要额外配置,当前版本尚未开放。")
return redirect(f"{redirect('agent_runtime:provider-list').url}?provider={provider.code}")
if form.is_valid():
try:
result = verify_provider_api_key(provider, form.cleaned_data["api_key"])
save_verified_provider_config(
request.user,
provider,
form.cleaned_data["api_key"],
result,
model_id=form.cleaned_data["model_id"],
use_as_default=form.cleaned_data["use_as_default"],
)
except (ProviderConnectionError, AgentConfigurationError) as exc:
form.add_error(None, str(exc))
else:
messages.success(request, f"{provider.name} 已验证并启用。")
return redirect("agent_runtime:provider-list")
# POST 出错时保持配置抽屉打开,且表单只回显非敏感错误,不回显已有密钥。
query = request.GET.copy()
query["provider"] = provider.code
request.GET = query
return render(
request,
"agent_runtime/provider_list.html",
_provider_page_context(request, form),
status=400,
)
@login_required
def provider_disconnect(request, provider_code):
"""仅接受 POST 断开当前用户自己的服务商配置。"""
if request.method == "POST" and disconnect_provider(request.user, provider_code):
messages.success(request, "服务商配置已移除。")
return redirect("agent_runtime:provider-list")
+1
View File
@@ -0,0 +1 @@
"""JobRadar 公共基础能力。"""
+10
View File
@@ -0,0 +1,10 @@
"""公共应用配置。"""
from django.apps import AppConfig
class CommonConfig(AppConfig):
"""注册不拥有独立业务数据的公共能力。"""
default_auto_field = "django.db.models.BigAutoField"
name = "common"
+29
View File
@@ -0,0 +1,29 @@
"""可由入口层稳定映射的领域异常。"""
class DomainError(Exception):
"""所有可预期业务错误的基类。"""
class InvalidStateTransition(DomainError):
"""运行状态不允许执行目标转换。"""
class DuplicateOperation(DomainError):
"""同一幂等操作已经执行或正在执行。"""
class PermissionDenied(DomainError):
"""操作者无权执行领域命令。"""
class AgentConfigurationError(DomainError):
"""真实 Agent 执行所需配置缺失或不合法。"""
class ProviderConnectionError(DomainError):
"""模型服务商凭据验证失败;消息不得包含上游原始响应。"""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
+42
View File
@@ -0,0 +1,42 @@
"""请求日志上下文和敏感摘要清理。"""
import contextvars
import logging
from collections.abc import Mapping
from typing import Any
request_id_context: contextvars.ContextVar[str] = contextvars.ContextVar(
"request_id", default="-"
)
SENSITIVE_KEYS = {"api_key", "authorization", "cookie", "password", "secret", "token"}
def _is_sensitive_key(key: object) -> bool:
"""识别常见秘密字段,同时避免把 `token_count` 等统计字段误判为秘密。"""
normalized = str(key).strip().lower().replace("-", "_")
if normalized in SENSITIVE_KEYS:
return True
return normalized.endswith(("_api_key", "_password", "_secret", "_cookie", "_token"))
class RequestContextFilter(logging.Filter):
"""向每条日志补充请求关联标识,后台任务没有请求时使用短横线。"""
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_context.get()
return True
def sanitize_summary(value: Any) -> Any:
"""递归遮蔽常见敏感键;调用方仍需限制摘要的业务范围和长度。"""
if isinstance(value, Mapping):
return {
str(key): "***" if _is_sensitive_key(key) else sanitize_summary(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_summary(item) for item in value]
return value
+27
View File
@@ -0,0 +1,27 @@
"""请求级关联标识中间件。"""
import re
import uuid
from .logging import request_id_context
REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
class RequestContextMiddleware:
"""复用合法入站标识或生成新标识,并确保上下文在请求结束后复位。"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
incoming = request.headers.get("X-Request-ID", "")
request_id = incoming if REQUEST_ID_PATTERN.fullmatch(incoming) else uuid.uuid4().hex
token = request_id_context.set(request_id)
request.request_id = request_id
try:
response = self.get_response(request)
response["X-Request-ID"] = request_id
return response
finally:
request_id_context.reset(token)
+28
View File
@@ -0,0 +1,28 @@
"""跨业务应用复用的抽象模型。"""
from django.conf import settings
from django.db import models
class TimeStampedModel(models.Model):
"""统一维护创建和更新时间,避免各业务模型重复声明。"""
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
abstract = True
class UserOwnedModel(TimeStampedModel):
"""为用户私有数据提供显式归属;审计数据存在时禁止删除用户。"""
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="%(app_label)s_%(class)s_items",
verbose_name="所属用户",
)
class Meta:
abstract = True
+44
View File
@@ -0,0 +1,44 @@
"""公共日志与请求上下文测试。"""
from django.test import SimpleTestCase
from django.urls import reverse
from .logging import request_id_context, sanitize_summary
class LoggingTests(SimpleTestCase):
"""验证敏感摘要递归脱敏,且统计字段不会被误删。"""
def test_nested_sensitive_values_are_masked(self):
result = sanitize_summary(
{
"access_token": "secret",
"nested": {"password": "secret", "token_count": 12},
"items": [{"api-key": "secret"}],
}
)
self.assertEqual(result["access_token"], "***")
self.assertEqual(result["nested"]["password"], "***")
self.assertEqual(result["nested"]["token_count"], 12)
self.assertEqual(result["items"][0]["api-key"], "***")
class RequestContextMiddlewareTests(SimpleTestCase):
"""验证入站关联标识的复用、非法值替换和请求结束后的上下文清理。"""
def test_valid_request_id_is_returned(self):
response = self.client.get(
reverse("accounts:login"),
headers={"X-Request-ID": "request-123"},
)
self.assertEqual(response.headers["X-Request-ID"], "request-123")
self.assertEqual(request_id_context.get(), "-")
def test_invalid_request_id_is_replaced(self):
response = self.client.get(
reverse("accounts:login"),
headers={"X-Request-ID": "invalid value"},
)
generated = response.headers["X-Request-ID"]
self.assertNotEqual(generated, "invalid value")
self.assertEqual(len(generated), 32)
+2
View File
@@ -39,6 +39,8 @@
运行上下文至少包含 `user_id`、`agent_run_id`、`search_task_id`、个人画像版本、规则版本、评分配置版本和当前运行预算。上下文不包含网站明文密码、Cookie、模型 API Key 等敏感值;工具在服务端根据授权范围读取凭据。
模型运行配置在 Gateway 调用前根据当前用户的默认服务商解析。Agent 上下文和持久化运行请求只记录服务商编码与模型标识,不携带可解密密文或明文 API Key。服务商配置缺失、密钥不可解密或默认模型为空时,应在真实调用前以配置错误终止,不影响登录、配置页和历史运行查询。
## 人工确认边界
以下情况必须暂停或转人工处理:
+9
View File
@@ -41,6 +41,15 @@ Agent 不是对岗位描述进行一次总结的接口,而是任务执行主
- 保存 run、turn、tool call、tool result、trace、耗时、token 用量、异常和人工确认状态。
- 第一版不使用 SandboxAgent;业务只需受控网络和数据库工具,不需要开放 Shell 或任意文件执行能力。
## 模型服务商配置边界
- 普通用户通过 Django 服务端页面维护自己的模型服务商配置,配置之间按用户归属隔离。
- 服务商公开协议、基础地址和推荐模型由代码预设;页面不能提交或覆盖预设地址,避免形成任意网络请求入口。
- OpenAI、Claude、DeepSeek、Gemini、阿里云百炼、智谱、硅基流动及常见聚合平台按各自协议验证模型目录;验证请求不发送对话内容,不产生模型推理调用。
- API Key 使用独立部署主密钥进行可逆加密,数据库、页面、日志、Agent 上下文和运行摘要均不得出现明文。
- Azure OpenAI、AWS Bedrock 及需要地域、部署、IAM 或业务空间参数的平台只展示接入状态,不得误标为只填 API Key 即可使用。
- 真实 Gateway 在执行边界按当前用户解析默认服务商、模型和密钥;密钥不会进入 `AgentExecutionRequest`。
## 用户与权限边界
- 使用 Django 内置认证、Session 和密码管理能力。
+45 -11
View File
@@ -4,11 +4,14 @@ moduleCode: engineering-baseline
moduleName: 工程基线
planDate: 2026-09-01
scope: fullstack
reviewStatus: pending
reviewedAt: null
replacedBy: null
---
# 开发计划:第一阶段工程基线(新功能)
> 执行状态:已于 2026-09-01 开始实施;步骤 1“配置与依赖基线”已完成代码和配置落盘,下一步为步骤 2“用户认证与数据归属设计”。依赖环境同步曾因网络下载长时间无进度而中断,恢复网络后需重新执行环境更新和 Ruff/pytest 验证。
> 执行状态:配置基线、用户资料、数据归属、Agent 运行记录、最小页面和 SDK 测试替身闭环已落地。当前进入第一阶段收口,重点是质量工具环境同步、页面人工验收和 PostgreSQL 集成验证。
## 任务概述
@@ -44,9 +47,19 @@ scope: fullstack
- 管理:通过 Django Admin 管理用户资料和 Agent 运行记录。
- 工程质量:增加统一日志、自动化测试、静态检查和迁移检查。
## 执行步骤
## 实施策略
### 步骤 1:配置与依赖基线
第一阶段采用“功能闭环驱动、模块边界承载”的纵向切片:
- 开发顺序由可运行、可验收的用户场景决定。
- `common`、`accounts`、`agent_runtime` 继续承担稳定的数据和代码归属。
- 每个切片同时完成必要的模型、服务、入口、页面和测试。
- 当前切片没有真实需求的字段、接口和抽象不提前实现。
- 每个切片通过自动验证和人工验收后,才进入下一个切片。
## 功能切片
### 切片 0:可启动的分环境工程骨架
- **输入材料**:[项目说明](../../README.md)、[架构设计](../architecture.md)、[实施路线图](../roadmap.md)。
- **实施内容**:
@@ -60,11 +73,14 @@ scope: fullstack
- `JobRadar/settings/` 分环境配置。
- `.env.example` 和更新后的 `.gitignore`。
- 更新后的依赖定义和开发说明。
- **当前状态**:已实现。
- **完成标志**:开发及测试配置可启动;生产配置缺少关键变量时明确失败;仓库不含真实密钥。
### 步骤 2:用户认证与数据归属设计
### 切片 1:用户可以安全登录并维护自己的资料
- **前置条件**:步骤 1 完成。
- **用户闭环**:管理员创建用户,用户登录、查看和修改自己的资料,无法访问其他用户数据。
- **涉及模块**:`common`、`accounts`、Django 认证与模板。
- **实施内容**:
- 保留 Django 内置 `User`,新增一对一用户资料模型。
- 建立用户私有模型的抽象基类、查询方法和服务层校验约定。
@@ -74,11 +90,15 @@ scope: fullstack
- `accounts` 应用的模型、服务、管理配置和迁移。
- `common` 公共归属模型或等效公共实现。
- 用户认证及跨用户隔离测试。
- **当前状态**:代码与基础自动化测试已实现,待两个真实用户的人工页面验收。
- **停止条件**:发现需要公开注册、自定义用户模型或复杂角色体系时停止并重新设计认证边界。
- **完成标志**:管理员可创建用户;普通用户只能读取和修改自己的资料及私有数据。
### 步骤 3:Agent 运行持久化基线
### 切片 2:管理员可以创建并审计最小 Agent Run
- **前置条件**:步骤 2 的用户归属边界已确定。
- **前置条件**:切片 1 的用户归属边界已确定。
- **用户闭环**:管理员创建测试 Run,系统记录状态、事件、工具调用和人工确认,Admin 可以审计完整过程。
- **涉及模块**:`common`、`agent_runtime`、Django Admin。
- **实施内容**:
- 建立 `AgentRun`、`AgentRunEvent`、`ToolCall` 和 `HumanApproval` 模型。
- 定义等待、运行、暂停、成功、失败和取消等运行状态。
@@ -88,11 +108,15 @@ scope: fullstack
- **期望产物**:
- `agent_runtime` 应用的模型、枚举、服务、管理配置和迁移。
- 状态转换、事件顺序、人工确认和用户归属测试。
- **当前状态**:模型、迁移、服务层、状态转换、工具调用、人工确认和 Admin 已实现;关键非法转换、工具幂等及跨用户拒绝已完成自动化测试,并发压力验证留待后续集成环境执行。
- **停止条件**:状态修改绕过服务层、事件可被普通入口改写或敏感参数进入摘要时停止交付。
- **完成标志**:最小 Agent Run 及其事件、工具调用和人工确认记录可持久化并可审计。
### 步骤 4:最小页面与管理后台
### 切片 3:用户可以查看自己的运行轨迹
- **前置条件**:步骤 2 和步骤 3 完成。
- **前置条件**:切片 1 和切片 2 完成。
- **用户闭环**:两个用户分别登录,只能看到自己的 Run,并能在详情页按顺序查看运行事件和工具摘要。
- **涉及模块**:`accounts`、`agent_runtime`、Django Templates。
- **实施内容**:
- 增加登录、退出和个人资料页面。
- 增加当前用户的 Agent Run 列表与详情页面。
@@ -101,11 +125,15 @@ scope: fullstack
- **期望产物**:
- Django URL、视图、表单和模板。
- 页面访问控制及基本响应测试。
- **当前状态**:页面、路由、登录退出、列表用户隔离和跨用户 404 测试已实现,待真实浏览器、响应式和可访问性验收。
- **停止条件**:页面需要前后端分离或出现第一阶段未定义的写操作时停止并补充接口设计。
- **完成标志**:两个普通用户登录后只能查看各自数据;管理员可在后台检查全部运行记录。
### 步骤 5:Agents SDK 最小接入
### 切片 4:测试替身可以驱动一次持久化运行
- **前置条件**:步骤 3 的持久化边界稳定。
- **前置条件**:切片 2 的持久化边界稳定。
- **用户闭环**:测试替身接收最小运行上下文,驱动 Run 从等待到成功或失败,并完整保存事件和脱敏结果。
- **涉及模块**:`agent_runtime` Gateway、运行服务和配置边界。
- **实施内容**:
- 建立服务端 SDK 配置读取入口和最小运行上下文。
- 建立 SDK 运行信息到本地 `AgentRun` 的适配边界。
@@ -114,11 +142,15 @@ scope: fullstack
- **期望产物**:
- Agent 运行配置、上下文和持久化适配代码。
- 无外部付费调用的单元测试。
- **当前状态**:Gateway 协议、Stub、服务端配置读取和持久化编排服务已实现;成功、失败、异常脱敏及缺少密钥的自动化测试已通过。真实 Agents SDK 运行入口仍属于第二阶段。
- **停止条件**:测试尝试真实付费调用、API Key 进入数据库或缺少 Key 导致非 Agent 页面失败时停止。
- **完成标志**:工程具备进入最小 Agent 闭环开发的稳定入口,但本阶段不执行真实岗位研究任务。
### 步骤 6:日志、质量检查与数据库兼容验证
### 切片 5:工程基线可以被完整验收
- **前置条件**:前五个步骤完成。
- **验收闭环**:在无真实密钥和付费调用的条件下完成 SQLite 自动验证、页面人工验收和 PostgreSQL 空库验证。
- **涉及模块**:全项目配置、测试、日志、迁移和文档。
- **实施内容**:
- 配置包含请求或运行关联标识的结构化日志。
- 对 API Key、密码、Cookie 和敏感工具参数执行脱敏。
@@ -129,6 +161,8 @@ scope: fullstack
- 日志配置、自动化测试和质量工具配置。
- SQLite 测试结果及 PostgreSQL 集成验证记录。
- 更新后的 README 和阶段状态。
- **当前状态**:请求关联标识、递归脱敏、SQLite 迁移检查和 19 项 Django 测试已完成;当前 Conda 环境尚未安装 pytest 与 Ruff,生产配置检查、页面人工验收和 PostgreSQL 验证待完成。
- **停止条件**:质量工具环境与依赖清单不一致、测试连接生产库或日志出现秘密时停止验收。
- **完成标志**:本计划“验收标准”全部满足,未执行项及原因有明确记录。
## 验收标准
+13 -4
View File
@@ -1,8 +1,14 @@
---
reviewStatus: pending
reviewedAt: null
replacedBy: null
---
# JobRadar 实施路线图
## 推进原则
项目按可运行成果分阶段实施。每个阶段由AI直接生成代码、迁移、测试和必要文档,但只实现当前阶段所需内容;完成自动验证和用户验收后,再扩大范围。
项目按可运行成果分阶段实施。阶段内部采用“功能闭环驱动、模块边界承载”的纵向切片:每个切片同时完成必要的数据、服务、页面和测试,形成可运行结果后再扩大范围;不先把所有模块建完,也不为了功能闭环破坏模块职责。
## 阶段一:工程基线
@@ -10,13 +16,16 @@
- [x] 拆分开发、测试和生产配置。
- [x] 将密钥及本地配置移出版本库。
- [ ] 接入Django用户认证和用户资料模型。
- [ ] 为用户私有业务数据建立统一归属边界。
- [x] 接入Django用户认证和用户资料模型。
- [x] 为用户私有业务数据建立统一归属边界。
- [ ] 开发与测试默认使用SQLite,并建立可切换PostgreSQL的迁移基线。
- [ ] 接入OpenAI Agents SDK。
- [ ] 建立Agent Run、运行事件、工具调用和人工确认模型。
- [x] 建立用户级模型服务商目录、API Key 加密存储、连接验证和运行时配置解析。
- [x] 建立Agent Run、运行事件、工具调用和人工确认模型。
- [ ] 增加统一日志、测试和代码质量工具。
当前状态:用户资料、数据归属、Agent 运行持久化、模型服务商配置页及 SDK 测试替身编排闭环已经落地;真实 Agents SDK 接入仍属于第二阶段。第一阶段剩余 PostgreSQL 集成验证、生产配置检查及人工页面验收。
验收条件:开发与生产配置隔离,用户可以安全登录,SQLite下迁移和测试通过,PostgreSQL空库迁移及核心模型测试通过,最小Agent运行数据可持久化。
详细实施范围、步骤和验收口径参见[第一阶段工程基线开发计划](engineering-baseline/dev-plan.md)。
+2
View File
@@ -11,6 +11,8 @@ dependencies:
- psycopg[binary]==3.3.5
- pillow==12.2.0
- openai-agents==0.22.0
# 页面保存的模型 API Key 必须可逆加密,禁止明文入库或自制加密算法。
- cryptography==50.0.1
# 测试与代码质量依赖统一固定版本,保证本地和持续集成结果一致。
- pytest==9.1.1
- pytest-django==4.14.0
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
# 保留全局 Django 模板目录,具体模板将随业务页面逐步增加。
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block title %}个人资料 · JobRadar{% endblock %}{% block content %}<h1>个人资料</h1><p>用户名:{{ user.username }}</p><form method="post">{% csrf_token %}{{ form.as_p }}<button type="submit">保存</button></form>{% endblock %}
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}API 服务商 · JobRadar{% endblock %}
{% block content %}
<div class="provider-layout{% if selected_provider %} has-drawer{% endif %}">
<section class="provider-main">
<header class="page-heading">
<div><h1>API 服务商</h1><p>连接模型服务,为不同 Agent 选择合适的模型</p></div>
<a class="primary-button" href="?provider=custom">+ 自定义服务商</a>
</header>
<div class="provider-tools">
<form class="provider-search" method="get">
<label class="sr-only" for="provider-search">搜索服务商</label>
<input id="provider-search" name="q" value="{{ query }}" placeholder="⌕ 搜索服务商">
<input type="hidden" name="category" value="{{ active_category }}">
</form>
<nav class="filter-tabs" aria-label="服务商分类">
{% for code, label in categories.items %}<a href="?category={{ code }}{% if query %}&amp;q={{ query|urlencode }}{% endif %}" {% if active_category == code %}aria-current="page"{% endif %}>{{ label }}</a>{% endfor %}
</nav>
<span class="security-note">▣ 密钥仅在服务端加密保存</span>
</div>
<div class="provider-summary"><strong>共 {{ provider_count }} 个内置服务商</strong><span>{{ connected_count }} 个已连接</span></div>
<div class="provider-grid">
{% for card in cards %}
<article class="provider-card{% if selected_provider.code == card.provider.code %} selected{% endif %}">
<a class="card-link" href="?provider={{ card.provider.code }}&amp;category={{ active_category }}{% if query %}&amp;q={{ query|urlencode }}{% endif %}" aria-label="配置 {{ card.provider.name }}"></a>
<div class="provider-card-head"><span class="provider-mark mark-{{ card.provider.category }}">{{ card.provider.mark }}</span><span><strong>{{ card.provider.name }}</strong><small>{{ card.provider.vendor }}</small></span><span class="card-menu" aria-hidden="true">⋮</span></div>
<div class="provider-tags"><span>{{ card.provider.protocol_label }}</span><span>{% if card.provider.key_only %}仅 API Key{% else %}需额外配置{% endif %}</span></div>
<p class="connection-status {% if card.config %}connected{% endif %}"><span aria-hidden="true">●</span>{% if card.config %}{{ card.config.get_status_display }}{% else %}未配置{% endif %}{% if card.config.is_default %}<em>默认</em>{% endif %}</p>
</article>
{% empty %}<div class="empty-panel"><h2>没有匹配的服务商</h2><p>请调整搜索词或分类条件。</p></div>{% endfor %}
</div>
</section>
{% if selected_provider %}
<aside class="provider-drawer" aria-labelledby="drawer-title">
<div class="drawer-header"><div><h2 id="drawer-title">配置{{ selected_provider.name }}</h2><p>系统已预置接口地址和协议</p></div><a class="drawer-close" href="{% url 'agent_runtime:provider-list' %}" aria-label="关闭">×</a></div>
<dl class="preset-details"><div><dt>接口协议</dt><dd>{{ selected_provider.protocol_label }}</dd></div><div><dt>接口地址</dt><dd>{% if selected_provider.base_url %}由系统预置{% else %}需要额外配置{% endif %}</dd></div></dl>
{% if selected_provider.key_only %}
<form class="provider-form" method="post" action="{% url 'agent_runtime:provider-configure' selected_provider.code %}?provider={{ selected_provider.code }}">
{% csrf_token %}
{% if provider_form.non_field_errors %}<div class="form-alert">{{ provider_form.non_field_errors }}</div>{% endif %}
<div class="field-row"><label for="{{ provider_form.api_key.id_for_label }}">API Key</label>{{ provider_form.api_key }}{% if selected_config %}<small>当前密钥:{{ selected_config.key_hint }};重新保存将覆盖旧密钥。</small>{% endif %}{{ provider_form.api_key.errors }}</div>
<div class="key-safety">▣ 密钥加密存储,保存后不可查看</div>
{% if selected_config %}<div class="connection-ok"><strong>✓ 已通过连接验证</strong><span>最近验证:{{ selected_config.last_verified_at|date:"Y-m-d H:i" }}</span></div>{% endif %}
<div class="field-row"><label for="{{ provider_form.model_id.id_for_label }}">模型选择</label>{{ provider_form.model_id }}<small>留空时自动使用服务商推荐模型。</small>{{ provider_form.model_id.errors }}</div>
<label class="checkbox-row">{{ provider_form.use_as_default }} <span>设为 Agent 默认服务商</span></label>
<details class="advanced-settings"><summary>高级设置(可选)</summary><p>接口地址和协议由系统预设,当前版本不允许用户覆盖。</p></details>
<div class="drawer-actions">{% if selected_config %}<button class="danger-button" form="disconnect-form" type="submit">移除配置</button>{% endif %}<a class="secondary-button" href="{% url 'agent_runtime:provider-list' %}">取消</a><button class="primary-button" type="submit">测试并保存</button></div>
</form>
{% if selected_config %}<form id="disconnect-form" method="post" action="{% url 'agent_runtime:provider-disconnect' selected_provider.code %}">{% csrf_token %}</form>{% endif %}
{% else %}
<div class="coming-soon"><span aria-hidden="true">◇</span><h3>需要额外配置</h3><p>{{ selected_provider.note }}</p><p>后续将根据该平台的地域、部署或 IAM 要求提供专用表单。</p></div>
<div class="drawer-actions"><a class="secondary-button" href="{% url 'agent_runtime:provider-list' %}">关闭</a></div>
{% endif %}
</aside>
{% endif %}
</div>
{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block title %}{{ run.title }} · JobRadar{% endblock %}{% block content %}<h1>{{ run.title }}</h1><p>状态:{{ run.get_status_display }}</p><h2>运行事件</h2><ol>{% for event in run.events.all %}<li><time>{{ event.occurred_at }}</time> {{ event.summary }}</li>{% empty %}<li>暂无事件</li>{% endfor %}</ol><h2>工具调用</h2>{% for call in run.tool_calls.all %}<p>{{ call.tool_name }}:{{ call.get_status_display }}</p>{% empty %}<p>暂无工具调用</p>{% endfor %}{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block title %}运行记录 · JobRadar{% endblock %}{% block content %}<h1>运行记录</h1>{% for run in page %}<article><h2><a href="{% url 'agent_runtime:run-detail' run.id %}">{{ run.title }}</a></h2><p>状态:{{ run.get_status_display }};创建于 <time>{{ run.created_at }}</time></p></article>{% empty %}<p>当前没有运行记录。第一阶段尚未开放真实 Agent 任务。</p>{% endfor %}{% if page.has_previous %}<a href="?page={{ page.previous_page_number }}">上一页</a>{% endif %}{% if page.has_next %}<a href="?page={{ page.next_page_number }}">下一页</a>{% endif %}{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}JobRadar{% endblock %}</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="{% if user.is_authenticated %}app-shell{% else %}public-shell{% endif %}">
<a class="skip" href="#main">跳到主要内容</a>
{% if user.is_authenticated %}
<aside class="sidebar" aria-label="主导航">
<a class="brand" href="{% url 'agent_runtime:run-list' %}">
<span class="brand-mark" aria-hidden="true">◎</span>
<span><strong>JobRadar</strong><small>职途雷达</small></span>
</a>
<nav class="side-nav">
<p class="nav-group">工作台</p>
<a href="{% url 'agent_runtime:run-list' %}"><span aria-hidden="true">◫</span> Agent 运行记录</a>
<p class="nav-group">设置</p>
<a href="{% url 'agent_runtime:provider-list' %}"><span aria-hidden="true">⚙</span> API 服务商</a>
<a href="{% url 'accounts:profile' %}"><span aria-hidden="true">○</span> 个人资料</a>
</nav>
<div class="sidebar-user">
<span class="avatar">{{ user.username|first|upper }}</span>
<span><strong>{{ user.username }}</strong><small>个人工作区</small></span>
<form method="post" action="{% url 'accounts:logout' %}">
{% csrf_token %}<button class="icon-button" title="退出登录">↗</button>
</form>
</div>
</aside>
{% else %}
<header class="public-header"><strong>JobRadar · 职途雷达</strong></header>
{% endif %}
<main id="main" class="main-content">
{% for message in messages %}<p class="message">{{ message }}</p>{% endfor %}
{% block content %}{% endblock %}
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block title %}登录 · JobRadar{% endblock %}{% block content %}<h1>登录</h1><form method="post">{% csrf_token %}{{ form.as_p }}{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}<button type="submit">登录</button></form>{% endblock %}