# Phase 2 开发日志 — 仪表盘真数据 + 用户管理 + Excel 批量导入

> 《军事科技》栏目内部工作台 · Phase 2
> 撰写日期:2026-05-15
> 状态:**Task 1 后端 CRUD 完成，冒烟实测 6/6 通过；Task 2 进行中**

---

## 一、本 Phase 完成时间

- **开始日期**:2026-05-15
- **Task 1 后端 CRUD**:2026-05-15 完成，冒烟 6/6 全绿
- **Task 2 Excel 批量导入**:进行中
- **Task 3 前端**:待开始
- **预计 Phase 2 整体收官**:待定

---

## 二、本 Phase 做了什么

### Task 1 — 后端 CRUD（2026-05-15）

1. **models**：`episode.py`（软引用+快照）、`yearly_target.py`（year UNIQUE）
2. **schemas**：`episode.py`、`yearly_target.py`、`user_admin.py`、`auth.py` 顺手改 ConfigDict
3. **API**：`users.py`（管理员 CRUD）、`episodes.py`（增删改查）、`yearly_targets.py`（增删改查，新增 DELETE）
4. **路由注册**：`main.py` 注册新路由

---

## 三、Task 1 冒烟实测记录（2026-05-15 Cline 自跑）

使用 `backend/scripts/smoke_test.py`（幂等版可重复跑），结果：

| 步骤 | 接口 | 预期 | 实际 | 结果 |
|------|------|------|------|------|
| 1 | POST /api/users 新建 zebian | 201 | 201 | ✅ ID=4 |
| 2 | POST /api/yearly_targets 新建 2026 | 201 | 201 | ✅ ID=2 |
| 3 | POST /api/yearly_targets 重复 2026 | 409 | 409 | ✅ 正确拦截 |
| 4 | POST /api/episodes editor_id=null | 201 | 201 | ✅ ID=2 |
| 5 | GET /api/episodes 列表 | 200 | 200 | ✅ |
| 6 | PATCH /api/yearly_targets 尝试改 year | 200（year 不变） | 200 | ✅ year 仍为 2026 |

**6/6 全部通过。**

---

## 四、业务红线验证记录

| 红线 | 代码位置 | 验证结果 |
|------|----------|----------|
| 收视颜色只对 audience_share | `episode.py` 注释 | ✅ 未实现颜色逻辑，仅存字段 |
| 年度目标只增不改 year UNIQUE | `yearly_targets.py` create_target + `yearly_target.py` UNIQUE | ✅ 冲突报 409 |
| year 字段禁止 UPDATE 修改 | `yearly_targets.py` update_target pop("year") | ✅ 冒烟 Step 6 验证 |
| editor_id 可空+editor_name_snapshot 必填 | `episode.py` Field 设计 | ✅ 冒烟 Step 4 editor_id=null 成功 |
| 角色枚举全拼音 | `UserRole` Enum + CHECK 约束 | ✅ 三角色全是拼音 |
| ON CONFLICT DO NOTHING（非 UPDATE） | `yearly_targets.py` 注释 | ✅ 只增不改 |
| password_hash 明文传输开发配置 | `security.py` + `main.py` https_only=False | ⚠️ Phase 5 部署前必改 |

---

## 五、踩过的坑（Phase 2 内部教训）

### 坑 1：API 文件漏了模型类导入（一类 bug）

**现象**：Swagger UI 实测 `POST /api/yearly_targets` 报 500，`NameError: name 'YearlyTarget' is not defined`

**原因**：`api/yearly_targets.py` 和 `api/episodes.py` 用了 `Episode` / `YearlyTarget` 类但顶部漏了 import 语句

**排查**：同步检查 `api/users.py`、`api/episodes.py`、`api/yearly_targets.py` 三文件，发现 episodes.py 也漏了

**修复**：
- `episodes.py` 加 `from app.models.episode import Episode`
- `yearly_targets.py` 加 `from app.models.yearly_target import YearlyTarget`
- `users.py` 删掉未使用的 `verify_password` 导入

**教训**：新建 API 文件后，**第一件事检查所有模型类是否已 import**，与 Schema import 同等优先级。Task 1 完工报告前未做最小冒烟测试，导致这类问题漏到实测阶段才发现。以后每个 Task Act 完成后必须自跑冒烟，不依赖制片人发现。

### 坑 2：冒烟脚本 emoji + 中文导致 Windows GBK 崩溃

**现象**：`UnicodeEncodeError: 'gbk' codec can't encode character '\u274c'`

**修复**：将 `smoke_test.py` 全部改英文 + ASCII 字符（`✅`→`[OK]`，`❌`→`[FAIL]`）

### 坑 3：数据不幂等导致重跑失败

**现象**：第二次跑冒烟，Step 1/2/4 全部 409（数据已存在）

**修复**：改为幂等版脚本，先清理同名数据再创建

### 坑 4：pip 默认路由到系统 Python 3.14，绕过项目锁定的 3.12

**现象**：`pip install pandas openpyxl` 后报错，或运行时报 `ModuleNotFoundError: No module named 'pandas'`（已确认装过）

**原因**：系统 PATH 中 python 命令指向 Microsoft Store 的 Python 3.14，而项目要求 Python 3.12，导致装到了错误的解释器。`pip show pandas` 发现路径在 AppData/Local/Microsoft/WindowsApps 而非项目 venv。

**修复**：
- 确认项目使用 `python -m pip install ...`（明确调项目锁定的 3.12）
- 或激活 venv 后 `& .\venv\Scripts\Activate.ps1` 再 pip install
- 装完 `pip show pandas` 核对路径包含 `venv` 或项目目录

**教训**：**任何 pip install 必须验证装到了哪个 Python 解释器**。先用 `where python` 确认 PATH 优先级，用 `python -c "import sys; print(sys.executable)"` 确认实际调用的解释器，再执行安装。

---

### 坑 5：002b SQL 表达式索引使用 EXTRACT(...)::int 语法不被 PostgreSQL 接受

**现象**：`CREATE INDEX idx_episodes_broadcast_date ON episodes (EXTRACT(year FROM broadcast_date)::int);` 在 pgAdmin / psql 中执行报错：`ERROR: syntax error at or near "::"`

**原因**：PostgreSQL 的 `EXTRACT()` 返回 `double precision` 类型，`::int` 是标准 cast 语法，但某些 PostgreSQL 版本或客户端对表达式索引内 cast 有限制。SQL 在 commit 前未本机干跑直接进库，翻车后才发现。

**修复**：改为 `date_part('year', broadcast_date)` 语法，等效且兼容性更好：
```sql
CREATE INDEX idx_episodes_broadcast_date ON episodes (date_part('year', broadcast_date));
```

**教训**：**SQL 文件 commit 前必须在本机干跑**。即使是看似简单的 DDL，本机跑一遍能提前发现语法兼容性问题，避免入库后才发现翻车。Task 1 NameError（坑 1）与本坑同源，都是"没自测就 commit"。

---

### 坑 6：Gitea OAuth refresh token 过期导致 git push 反复 401

**现象**：`git push` 多次返回 401 Unauthorized，Git 凭证管理器反复弹框，输入正确账密后仍然失败。

**原因**：Gitea OAuth access token 有效期到期，refresh token 也已失效，导致 Git 凭证无法续期。多次 401 后需重新完成 OAuth 认证流程刷新 token。

**修复**：
- 删掉旧 token：Git Bash 执行 `git credential reject` + 重置 credential helper
- 重新触发 GitHub/Gitea OAuth 流程获取新 access token
- 验证：`git push` 成功后确认 remote URL 使用新的 token

**教训**：**push 不成功不算完工**。本次 Task 2 期间 push 已全部恢复正常，之前的 git push 条目从"认证失败，需制片人手动"已转为✅完成。

---

## 六、纪律警告（Phase 2 Task 2 期间立）

### 纪律 1："push 待制片人手动处理"不算完工

**背景**：Task 1 期间 MiniMax 多次以"push 待手动处理"作为完工结尾。本 chat 期间明确规矩。

**教训**：push 是 Git 工作流最后一环，未 push 就不是真正的完工。Git commit 只代表本地变更保存，push 成功才代表远程仓库同步完成。在任务报告中以"push 待手动处理"结尾会误导协作者认为工作已完成。

**落地规矩**：任何任务的完工报告必须包含"git push 成功"状态才算闭环。若 push 遇到认证问题：
- 优先排查 token 状态（坑 6 经验）
- 不得以"待手动处理"为由跳过 push 环节
- push 失败时用红字标注"⚠️ push 待解决"，不写入"完工"状态

---

### 纪律 2：SQL / 代码 commit 前必须本机干跑

**背景**：Task 1 NameError（坑 1）+ Task 2 002b SQL 语法错（坑 5）同源，都是 commit 前没自测直接进库。

**教训**：任何变更（SQL 文件、后端代码、前端代码）在 commit 前必须经过本机验证。对于 SQL 文件，本机用 `psql -U postgres -d milsci_dev -f backend/sql/00X_xxx.sql` 干跑；对于 Python 代码，用 `python -m pytest` 或冒烟脚本验证。发现错误当场修，不把问题带进 commit。

**落地规矩**：
- SQL 文件：commit 前用 psql 在本机 dev 数据库干跑，确认无语法错误
- Python 代码：至少跑一遍冒烟测试或单元测试，确认基本功能正常
- 禁止用"这只是小改/语法简单/理论上没问题"为由跳过自测
- 养成习惯：**commit 前自测，发现问题当场修，比入库后发现 cost 低一个数量级**

---

## 七、遗留事项

### 7.1 Phase 2 内部待完成

- [x] **Task 1 后端 CRUD**：✅ 冒烟 6/6 全绿
- [x] **git push**：✅ 已恢复正常（坑 6 已解决）
- [ ] **Task 2**：Excel 批量导入（收视数据 + 排期模板）进行中
- [ ] **Task 3**：前端仪表盘 + 录入界面 + 用户管理界面

### 7.2 跨 Phase 遗留

#### Phase 4 或后续 Task 处理

- **PATCH /api/users 接口 password 相关权限**：当前 `UserUpdate` Schema 允许改 `is_active`、`role`、`display_name`、`profile_text`，但：
  - `password_hash` **不允许**通过 API 修改（当前设计）
  - 能否改自己密码？不能（当前设计）
  - 改别人密码是否强制对方下次登录改密？未实现
  - 建议 Phase 4+ 新增独立 `/api/users/change-password` 接口，设计时拉 Op

#### Phase 5 部署时处理（与 SECRET_KEY 并列）

- **传输安全**：当前 `password` 在请求体明文传输，仅 HTTPS 环境下可接受。Phase 5 部署到腾讯云后：
  - 改 `same_site="strict"` + `https_only=True`
  - 确认 HTTPS 开启，强制全站 TLS
  - 考虑 API 请求体密码加密或单独 `/api/auth/change-password` 接口

- **SECRET_KEY 占位符**：`.env` 当前是 `change-me-to-a-random-string-in-production`，**Phase 5 部署前必须改**为真实随机值。生成命令：
  ```python
  python -c "import secrets; print(secrets.token_urlsafe(32))"
  ```

---

## 八、Git commit 记录

| commit | 内容 |
|--------|------|
| 143601c | docs: 合入第十三章 Opus task brief 节制原则，版本升至 v5 |
| 33bee0c | feat: 后端 CRUD 完成（episodes + yearly_targets + users 管理 API） |
| 63c031a | docs: Task 1 完工记录，Swagger UI 验收清单，跨 Phase 遗留事项 |
| 4f7c92d | fix: 修复 episodes.py + yearly_targets.py 模型导入缺失，删 users.py 残留 verify_password，增 DELETE 接口（幂等冒烟后确认 6/6 全绿） |
| f82a71b | docs: phase2_log 追加 Task 2 进行中坑与纪律警告 |

---

---

## 九、Task 2 完工记录（2026-05-19 commit 442d52f）

### 9.1 冒烟结果（8 步全绿）

| Step | 描述 | 预期 | 实际 | 结果 |
|------|------|------|------|------|
| 1 | GET /api/imports/template | 200 xlsx | 200 xlsx (5595 bytes) | ✅ |
| 2 | POST /api/imports/episodes 合法行 | 200 success=1 | 200 success=1 failed=0 | ✅ |
| 3 | POST 部分失败（1ok+2bad） | 200 errors=2 | 200 errors=2 row_number=[3,4] | ✅ |
| 4 | GET /api/imports/errors/{batch_id} | 200 xlsx | 200 xlsx (5181 bytes) | ✅ |
| 5 | POST 2024 无年度目标 | 400 | 400 含"年度目标"+"2024" | ✅ |
| 6 | POST 重复期次（库内重复） | 数据库约束触发 | failed_count=1 UniqueViolation | ✅ |
| 7 | POST 未知编导"李不存在"软落地 | 200 GET id 确认 null | 200 id=22 editor_id=null | ✅ |
| 8 | GET /api/episodes/{真实id} 验收 | 200 editor_id=null | 200 ✅ | ✅ |

**smoke 脚本：`backend/scripts/import_smoke.py`（幂等版，可重复跑）**

---

### 9.2 坑 7：pandas 2.x 不接受 raw bytes（BytesIO 包装解决）

**发现时间**：2026-05-19 第一轮冒烟

**现象**：`pd.read_excel(file_content)` 传入 bytes，pandas 2.x 报错或读不出数据。

**原因**：pandas 2.x 要求 file-like object，不再直接接受 bytes。

**修复**：`excel_service.py` 中用 `io.BytesIO(file_content)` 包装后传给 `pd.read_excel`。

**commit**：442d52f

---

### 坑 8：openpyxl add_named_style 误用（4ca24dc 原代码遗留）

**发现时间**：2026-05-19 冒烟 Step 4 下载失败行 Excel

**现象**：`generate_error_excel()` 调用 `wb.add_named_style("error_header")` 触发 `TypeError: Only NamedStyle instances can be added`，导致 GET /api/imports/errors/{batch_id} 500。

**来源**：经 git show 4ca24dc 确认，该 bug **在 4ca24dc 原代码中已存在**，非修坑引入。4ca24dc 自检只发现了 pandas bytes 问题，未发现此第二个 bug。

**修复**：删除 `wb.add_named_style("error_header")` 这一行（该样式未实际使用）。

**教训**：commit 前自测要覆盖**所有**接口路径，不能只测主要流程。Step 1-3 通过不等于 Step 4 也通。

---

### 9.3 业务红线澄清：Step 6 重复期次拦截的两套机制

**两套拦截路径，各管各的事，不能混为一谈：**

1. **文件内重复 → check_duplicates() 整体 409**
   - 检测 Excel 内部是否有重复的 (year, episode_number)
   - 由 `ExcelService.check_duplicates()` 在入库前调用
   - 有重复则整体拒绝，整文件不入库，返回 HTTP 409

2. **库内重复 → 数据库 UNIQUE 约束 单行失败，其他行入库**
   - 检测 Excel 中某行与数据库已有记录重复
   - 由 PostgreSQL `idx_episodes_year_number` 唯一约束触发
   - 重复行报错进入 `errors[]`，其他行继续入库，返回 HTTP 200

**Step 6 实测的是第二套**（库内重复）：Excel 写入 episode_number=99（与 Step 2 已入库的重复），数据库约束抛出 `UniqueViolation`，整行失败，success=0 failed=1。这两套机制分管文件内重复和库内重复，职责清晰。

---

### 9.4 纪律 3：启动期三件套不能证明接口业务路径通

**背景**：Task 2 开发期间，启动期（imports.py + excel_service.py + schemas/imports.py）写完后只跑了 GET 接口自测，POST/PATCH/DELETE 接口靠理论推导认为没问题就直接 commit。实际冒烟时发现 Step 2/3/4/5/6/7 全是 POST，全部出问题。

**教训**：启动期三件套（models/schemas/API 文件 + 路由注册 + 依赖注入）只能证明**文件层面**没有导入错误、路由能匹配、类型能解析。**不能**证明接口的业务路径（读写数据库、事务提交、错误处理）正常。任何 POST/PUT/PATCH/DELETE 接口 commit 前必须发真请求验证业务路径。

**落地规矩**：
- 所有变更（SQL 文件、后端代码、前端代码）在 commit 前必须经过本机验证
- POST/PUT/PATCH/DELETE 接口必须用真请求实测，不能靠代码逻辑推导
- 发现错误当场修，不把问题带进 commit

---

### 9.5 遗留 bug：smoke 脚本 Step 7 写错（已改正）

**发现时间**：2026-05-19 冒烟 Step 7 第一版

**现象**：GET /api/episodes/300 返回 404，"在库中不存在"。

**原因**：smoke 脚本**把 episode_number 当 id 传**给 GET /api/episodes/{id}。后端 `GET /api/episodes/{id}` 接收的是数据库自增主键 id，不是业务期号 episode_number。episode_number=300 的记录入库后真实 id 是 14（或列表查询返回的其他值），不是 300。

**改正**：先从 `GET /api/episodes?limit=100` 列表中筛选 episode_number=301 的记录，取其真实 id，再用该真实 id 调用 `GET /api/episodes/{real_id}`。确保 GET /api/episodes/{id} 路径被实际验收。

**教训**：smoke 测试中查到新记录后，应先从查询结果中提取真实 id，再进行后续验证，不能假设 episode_number 等于数据库 id。

---

### 9.6 Task 2 收尾清单（还未做的四件小尾巴）

- [ ] **补录 2024 真值年度目标**：Step 5 冒烟用到 2024 年，当前库中无 2024 目标，补录后 Step 5 才能真正通过
- [ ] **Windows python 默认 PATH 改指向 3.12**：坑 4 未完全解决，`py` 命令仍可能路由到系统 Python 3.14，需在系统 PATH 中显式优先 3.12
- [ ] **backups/ 进 .gitignore**：虽然 .gitignore 有 `*.log` 等模式，但 `backups/` 目录显式加入更稳，避免样本数据误传
- [ ] **MiniMax 自检报告里 schema 类名对照 Plan 自查**：`ImportError`/`ImportResult` vs `UploadTemplateResponse`/`ImportResultResponse` 等命名差异对着 Plan 确认是否一致

---

## 十、Git commit 记录（2026-05-19 追加）

| commit | 内容 |
|--------|------|
| 442d52f | feat: 完善批量导入逻辑，8步冒烟全绿 |

*本日志由 MiniMax M2.7(Cline Act 模式)于 2026-05-15 持续追加，2026-05-19 追加 Task 2 完工坑与红线澄清，2026-05-20 追加 Task 2 收尾记录。*

---

## 十一、Task 2 收尾完工记录(2026-05-20)

### 11.1 Git 安全卫生:token 从 URL 剥离

**背景**:`.git/config` 里 origin 远程 URL 含明文 token(`cline-tps-dashboard`),
存在被 Cline 误贴日志、截图泄露、备份带出等风险。

**改动**:
- `git remote set-url origin` 改成干净 URL(去掉 `user:token@`)
- token 改由 Windows Git Credential Manager (manager) 加密存储
- 两次 fetch 静默通过,push 走 GCM 透明工作
- 旧 token `cline-tps-dashboard` 继续使用,不吊销

**纪律备注**:以后任何凭证(密码、API key、token)都不许写进 URL、不许写进 .py 文件、
不许写进任何会被 commit 的文件。

### 11.2 小尾巴 1:补录 2024 真值

**操作**:制片人手动 psql 直接 INSERT(绕过 API,因 API 权限收紧待办未做)。

**当前 yearly_targets 真值**:
- 2024: base=0.6448 / stretch=0.8290 (issued_date=2024-01-01, 补录说明在 notes)
- 2025: base=0.6448 / stretch=0.8989
- 2026: base=0.6448 / stretch=0.8989

### 11.3 小尾巴 3:backups/ 进 .gitignore

**commit**:20c0a1d(chore: backups/ 进 .gitignore)
**push**:已成功

### 11.4 小尾巴 4:schema 类名自查通过

类名与 Task 2 Plan 一致,无漂移。`ImportError` / `ImportResult` 命名正确。

发现 2 个隐患,**暂不处理,记为待办**:
- 隐患 A:`ImportError` 与 Python 内置同名,当前未撞,未来若在导入逻辑里 raise 才会冲突
- 隐患 B:`ExcelService.import_episodes()` 返回 dict 而非 Pydantic 对象,
  API 层手动构造 `ImportResult`,缺一次 Pydantic 校验层。8 步冒烟已验证业务正确,
  仅代码洁癖问题

### 11.5 小尾巴 2:Python PATH 根治坑 4

**改动**:从系统 PATH 删除 `C:\Python314\Scripts\` 和 `C:\Python314\` 两行。
用户 PATH 中 Python 3.12 自动顶上来。

**验证**:
- `where.exe python` 第一行变成 3.12 路径
- `python --version` = Python 3.12.10
- `sys.executable` 指向 3.12

**遗留**:E:\tps-dashboard\backend\ 下没有 venv,Python 依赖装在系统 3.12 全局
site-packages 里。**记入待办**,Phase 4 部署前或 Task 3 启动前评估是否补 venv。

### 11.6 待办清单(Task 3 启动前或后续 Phase 处理)

| 待办 | 触发时机 | 优先级 |
|---|---|---|
| `yearly_targets` 写权限收紧到 `zhipianren` + `zebian`,`biandao` 只读 | Task 3 启动前 | 高(业务红线) |
| backend/ 补 venv,把全局依赖搬进去 | Task 3 启动前评估 | 中 |
| 隐患 A:`ImportError` 同名冲突预防 | 真要 raise 时再说 | 低 |
| 隐患 B:`ExcelService` 返回 Pydantic 化 | 代码洁癖,有空时做 | 低 |

### 11.7 Task 2 完整 commit 链(2026-05-19 → 2026-05-20)

| commit | 内容 |
|---|---|
| 442d52f | feat: 完善批量导入逻辑,8步冒烟全绿(2026-05-19) |
| 6de8111 | docs: Task 2 完工记录,坑7/8/纪律3/Q1-Q4 写入(2026-05-19) |
| 20c0a1d | chore: backups/ 进 .gitignore(Task 2 收尾)(2026-05-20) |

**Task 2 完整收尾,Phase 2 Task 2 状态:✅ 完工。**

---

## 第十二章 yearly_targets 写权限收紧（2026-05-20）

**状态:✅ 完工。冒烟 12/12 全绿。**

### 12.1 任务来源

task brief 明确要求收紧 yearly_targets 表的写权限，是业务红线。

### 12.2 改动文件

| 文件 | 改动内容 |
|------|----------|
| `backend/app/api/yearly_targets.py` | 5处 `require_role` 参数修正 |
| `backend/app/schemas/yearly_target.py` | `YearlyTargetUpdate` 改为部分字段可选（支持 PATCH 部分更新） |
| `backend/scripts/smoke_yearly_targets_auth.py` | 新建，12步幂等冒烟脚本 |
| `README.md` | 新增"角色权限表"节 |
| `.clinerules` 5.5 | 新增 yearly_targets 写权限收紧说明 |
| `logs/phase2_log.md` | 本章记录 |

### 12.3 权限矩阵最终状态

| 接口 | zhipianren | zebian | biandao |
|------|-----------|--------|---------|
| GET 列表/详情 | ✅ 200 | ✅ 200 | ✅ 200 |
| POST 新增 | ✅ 201 | ✅ 201 | ❌ 403 |
| PATCH 修改 | ✅ 200 | ✅ 200 | ❌ 403 |
| DELETE 删除 | ✅ 204 | ❌ 403 | ❌ 403 |

### 12.4 GET 权限修改说明

GET 权限修改原本不在 task brief 范围，是 Plan 阶段自查发现并扩大的范围。

Opus 审 Plan 时同意纳入，理由：权限矩阵整体校正一次做完更合理。

此条在 Opus 审方案 v2 中明确记录，phase2_log 本章也写入此说明，不得糊涂带过。

### 12.5 冒烟实测记录

`backend/scripts/smoke_yearly_targets_auth.py`，12步结果：

| 步骤 | 场景 | 预期 | 结果 |
|------|------|------|------|
| 1 | zhipianren POST | 201 | ✅ |
| 2 | zebian POST | 201 | ✅ |
| 3 | biandao POST | 403 | ✅ |
| 4 | 未登录 POST | 401 | ✅ |
| 5 | zhipianren PATCH | 200 | ✅ |
| 6 | zebian PATCH | 200 | ✅ |
| 7 | biandao PATCH | 403 | ✅ |
| 8 | zhipianren DELETE | 204 | ✅ |
| 9 | zebian DELETE | 403 | ✅ |
| 10 | biandao DELETE | 403 | ✅ |
| 11 | biandao GET 列表 | 200 | ✅ |
| 12 | zhipianren GET 列表 | 200 | ✅ |

**12/12 全部通过。**

### 12.6 测试账号保留

冒烟脚本末尾显式打印保留账号（供后续手动清理）：
- `test_zebian_smoke` (zebian)
- `test_biandao_smoke` (biandao)
