# Phase 1：凭据槽位 API 客户端 - Context

**Gathered**: 2026-05-08
**Status**: Ready for planning
**Source**: 用户在 `/gsd-plan-phase 1` 调用时提供的内联约束（PRD 快速通道）

<domain>
## Phase 边界

本 phase 是 Milestone v1.0 前端集成的**起点**，纯逻辑层（无 UI）：
- 新建 `lib/api/credential-slot.ts`：封装 GET / PUT 两个调用 + 类型 + 后端→前端适配器
- 从 `lib/api/index.ts` 导出新模块
- `tsc --noEmit` 通过（项目用 `npm run lint` 跑）

**不负责**（留给后续 phase）：
- Phase 2：RBAC 模块声明（`lib/permissions.ts` 加 `credential-slot` key）+ `/ai-model` 页面入口控件
- Phase 3：编辑对话框组件 + Sonner toast 反馈

**联动**：
- 消费 qy_lty 后端 Milestone v1.0 已锁定的 API 契约（Phase 2 落地，commit `46d72b8` 起前后端互引修改记录已建立）
- 当前 phase 是纯 API client 层；执行测试可走 mock，**不需要**后端 dev server 真实运行

</domain>

<decisions>
## 实现决策（锁定）

### 接口契约（从后端 milestone v1.0 镜像，**不变**）

**GET `/api/v1/admin/credential-slot/`** — admin token 鉴权
- 响应壳层（经过 `StandardResponseMiddleware`）：`{ success, code, message, data }`
- `data` 字段：`{ app_id: string, access_token: string, updated_at: string }`
- 关键：**`access_token` 在响应中已是脱敏掩码**（末 4 位明文，前面 `*`）

**PUT `/api/v1/admin/credential-slot/`** — admin token 鉴权
- 请求体：`{ app_id: string, access_token: string }` —— 全字段覆写，**明文**提交
- 响应壳层与 GET 同；`data.access_token` 也是脱敏后返回（不会回显运营提交的明文）

**错误响应**（同样标准壳层）：
- 401：无 Authorization 头 / 无效 token
- 403：携普通 user token（非 admin）

### 文件路径与命名

- **新建**：`lib/api/credential-slot.ts`（单文件，沿用其他 lib/api/ 模块的扁平结构）
- **修改**：`lib/api/index.ts`（增加导出）
- **不动**：`lib/api/client.ts`（拦截器已就位）、`lib/api/adapters.ts`（mapBackend* 约定参考）、`lib/api/types.ts`（共享类型集合）

### 类型定义

**前端类型（camelCase，导出供页面/组件 import）**：

```typescript
// 后端响应中的"凭据槽位"，access_token 已是脱敏掩码
export interface CredentialSlot {
  appId: string
  accessTokenMasked: string  // 后端返回的脱敏字符串，前端命名上明确"已脱敏"
  updatedAt: string  // ISO 8601 时间字符串
}

// PUT 请求体，access_token 是明文（运营录入态）
export interface CredentialSlotUpdatePayload {
  appId: string
  accessToken: string  // 明文，提交后后端覆写
}

// 后端响应原始 dict（snake_case，仅 adapter 内部用）
interface BackendCredentialSlot {
  app_id: string
  access_token: string
  updated_at: string
}
```

**关键决策**：
- 前端类型用 `accessTokenMasked` 名字（不是 `accessToken`），明确语义"这是脱敏后的字符串"，让后续 phase 写对话框时不会误把它当真值回写 PUT
- 提交载荷类型用 `accessToken`（无 Masked 后缀），明确"这是明文，将覆写后端"
- 这两个类型故意命名不同，让 TypeScript 编译期就能捕捉"把脱敏字符串当真值回写"的 bug

### 适配器（沿用 mapBackend* 约定）

**后端→前端**（GET 响应反向）：
```typescript
function mapBackendCredentialSlot(raw: BackendCredentialSlot): CredentialSlot {
  return {
    appId: raw.app_id,
    accessTokenMasked: raw.access_token,
    updatedAt: raw.updated_at,
  }
}
```

**前端→后端**（PUT 请求体正向）：
```typescript
function toBackendUpdatePayload(payload: CredentialSlotUpdatePayload): { app_id: string; access_token: string } {
  return {
    app_id: payload.appId,
    access_token: payload.accessToken,
    // 不带 updated_at —— 仓库现有约定（researcher 实测 ai-models.ts/outfits.ts 全部仅传业务字段）
    // 后端 auto_now=True 自动维护
  }
}
```

### API 函数签名

**关键修正（researcher 实测）**：
- `apiClient` 响应拦截器**不解包**（仅 `console.log` 后透传 response）
- 仓库现有模块（`ai-models.ts` / `outfits.ts` 等）统一约定：调用方手写 `const data = response.data?.data || response.data` 兼容"标准壳层"与"裸响应"两种形态
- 路径**不含 `/api` 前缀**（`API_BASE_URL` 已吃掉 `/api`），写 `/v1/admin/credential-slot/`

```typescript
export async function getCredentialSlot(): Promise<CredentialSlot> {
  const response = await apiClient.get('/v1/admin/credential-slot/')
  const raw = response.data?.data || response.data  // 兼容标准壳层与裸响应
  return mapBackendCredentialSlot(raw)
}

export async function updateCredentialSlot(payload: CredentialSlotUpdatePayload): Promise<CredentialSlot> {
  const body = toBackendUpdatePayload(payload)
  const response = await apiClient.put('/v1/admin/credential-slot/', body)
  const raw = response.data?.data || response.data
  return mapBackendCredentialSlot(raw)
}
```

**1:1 模板**：`lib/api/ai-models.ts` L1-85（`getAiModel(id)` 单资源 GET 形态最贴近）、L65-73（`updateAiModel` PATCH body 构造，仅传业务字段不带 `updated_at`）。Planner 应把这两段作为照抄起点。

### `lib/api/index.ts` 导出

追加：
```typescript
export {
  getCredentialSlot,
  updateCredentialSlot,
  type CredentialSlot,
  type CredentialSlotUpdatePayload,
} from './credential-slot'
```

如 `index.ts` 不存在或导出风格不同，按仓库现有约定（researcher 必须看 `lib/api/index.ts` 当前内容）。

### Lint + 类型检查（researcher 修正：必须两条独立命令）

- `npm run lint` 实际**只跑 `next lint`（ESLint）**，**不跑 tsc**（researcher 实测）
- `next.config.mjs` 的 `typescript.ignoreBuildErrors: true` 仅影响 `next build`，**不**影响独立 `tsc`
- Phase 1 完成态需**两条**独立命令都退出码 0：
  - `npm run lint` —— ESLint 检查
  - `npx tsc --noEmit` —— TypeScript 类型检查（独立运行，**不**省略）

### 修改记录

`qy-lty-admin/docs/修改记录.md` 顶部追加一条 Phase 1 条目：
- 文件路径：`lib/api/credential-slot.ts`、`lib/api/index.ts`
- 修改类型：新增
- 跨项目联动：「无 — Phase 1 是纯 API client 层落地，调用的后端接口由 qy_lty 后端 Milestone v1.0 Phase 2 提供（commit `46d72b8` 已建立前后端互引）；Phase 1 不引入新代码契约，无需再次互引。前端 UI 集成（Phase 2 + 3）完成前不会真正调到后端。」

### Claude's Discretion

- PUT body 是否携带 `updated_at` 字段 — 看 axios 拦截器是否对 PUT body 做过滤
- TypeScript 类型放在 `lib/api/credential-slot.ts` 内部还是抽到 `lib/api/types.ts` — 看现有约定
- 是否给 `getCredentialSlot()` / `updateCredentialSlot()` 加 JSDoc 注释（中文）— 推荐加

</decisions>

<canonical_refs>
## Canonical References

**下游 agent 必读**：

### 项目宪法
- `qy-lty-admin/CLAUDE.md` — 沟通语言（中文）+ 修改记录强制规则 + 跨项目联动 + 包管理器警告（不混用 npm/pnpm/yarn）
- `qy-lty-admin/.planning/PROJECT.md` — Milestone v1.0「本期 Milestone」段、关键约束（特别是「留空保留旧值」语义会影响 Phase 3，但 Phase 1 仅 API client 层不涉及）
- `qy-lty-admin/.planning/REQUIREMENTS.md` — Active 段 CRED-FE-01 完整描述
- `qy-lty-admin/.planning/ROADMAP.md` — Phase 1 详情段（Goal、Success Criteria 4 条）

### lib/api/ 现有模块（必读，1:1 模板候选）
- `qy-lty-admin/lib/api/client.ts` — Axios 实例 + 请求/响应拦截器（**关键**：必须确认 response 拦截器是否已经解包 `data.data`）
- `qy-lty-admin/lib/api/types.ts` — 共享类型集合（`ApiResponse<T>` 形态）
- `qy-lty-admin/lib/api/adapters.ts` — `mapBackend*` 工具函数集合
- `qy-lty-admin/lib/api/outfits.ts` — 业务模块的 CRUD 范式（候选模板）
- `qy-lty-admin/lib/api/ai-models.ts` — AI 模型管理 API（与本 phase 语义最贴近，Phase 2 入口控件就在 `/ai-model` 页面）
- `qy-lty-admin/lib/api/songs.ts` — 另一个 CRUD 模块参考
- `qy-lty-admin/lib/api/index.ts` — 模块导出汇总点

### 后端契约
- `../qy_lty/aiapp/views.py` — `CredentialSlotAdminView`（GET 响应实际形状 + PUT 请求体格式）
- `../qy_lty/aiapp/serializers.py` — `CredentialSlotSerializer`（字段集 / read_only_fields）
- `../qy_lty/.planning/phases/02-admin-rest/02-VERIFICATION.md` — 后端 Phase 2 端到端验收记录（含真实响应样本）

### 修改记录
- `qy-lty-admin/docs/修改记录.md` — 头部「修改格式说明」+ 已存在的 Phase 2 互引条目作模板

</canonical_refs>

<specifics>
## 具体要点（Success Criteria 显式化）

| # | 验证点 | 检查方式 |
|---|--------|----------|
| 1 | `lib/api/credential-slot.ts` 导出 2 函数 + 2 类型 | `grep "export.*getCredentialSlot\|updateCredentialSlot\|CredentialSlot\|CredentialSlotUpdatePayload" lib/api/credential-slot.ts` 命中 ≥4 次 |
| 2 | 两函数走 `apiClient` + 路径 `/v1/admin/credential-slot/` | grep `apiClient.get.*/v1/admin/credential-slot/` + `apiClient.put.*/v1/admin/credential-slot/` 各 1 命中 |
| 3 | adapter `mapBackendCredentialSlot` 把 snake → camel | 单元测试式 import 调用：`mapBackendCredentialSlot({app_id:'a', access_token:'b', updated_at:'c'})` 返回 `{appId:'a', accessTokenMasked:'b', updatedAt:'c'}` |
| 4 | `lib/api/index.ts` 导出新模块 | grep `export.*credential-slot` 命中 |
| 5 | `npm run lint` / `tsc --noEmit` 退出码 0 | shell 退出码检查（注：项目 next.config.mjs 有 `typescript.ignoreBuildErrors: true`，**这是仅构建期忽略**，`tsc --noEmit` 仍会真实报错） |
| 6 | 类型契约：从 component 文件 import 应能解析 | 写一个临时 tsx 文件 import 这些符号，跑 tsc --noEmit 不报错 |
| 7 | 修改记录顶部追加 Phase 1 条目 | grep `qy-lty-admin/docs/修改记录.md` 顶部出现 `[2026-05-08] Phase 1 (前端) 凭据槽位 API 客户端` 字样 |

</specifics>

<deferred>
## 推迟事项（不在 Phase 1 范围）

- **`/ai-model` 页面入口控件 / RBAC 模块声明** — Phase 2
- **编辑对话框组件 + Sonner toast 反馈** — Phase 3
- **跨项目互引修改记录** — 后端 Phase 2 已建立互引（commit `46d72b8`），前端 Phase 1 不需要再次互引；Phase 2 + 3 引入实质 UI 集成时再考虑（如确实需要）
- **Cypress / Playwright E2E 测试** — 项目当前无 E2E 框架；Phase 1 只验证 tsc + adapter 单元行为
- **真实后端 dev server 联调** — Phase 1 完成不要求；Phase 2/3 集成后端到端再做

</deferred>

---

*Phase: 01-credential-slot-api*
*Context gathered: 2026-05-08 via inline PRD（用户在 /gsd-plan-phase 1 调用时提供完整约束）*
