# UAT 接入说明

更新日期：2026-09-10。**UAT 读写接入已完成并通过真实联调**，当前服务运行于 `uat` 模式。

**永久边界：生产组织（`00DC80000071iuEMAQ`）永不开放写入。** 本应用不存在任何生产写路径（代码审计见《验收与演示说明》#1），即使未来误配置，后端也会在唯一写路径（`lib/adapters/store.js`）拒绝。

## 1. 当前状态

| 项 | 状态 |
| --- | --- |
| 数据模式 | `uat`（本地库仍为记录源，保存同步真实写入 UAT 测试对象） |
| UAT 认证 | ✅ `sospdomain@sf-auto.com.uat`（组织 `00DC5000000kicLMAQ`，实例 CHN5S，refreshToken 自动续期） |
| UAT 写入开关 | ✅ 开启（`SOSP_UAT_WRITE=true`，且每次写入前核验组织身份） |
| 生产访问 | 仅允许只读查询（默认禁用，见 §6）；无任何生产写路径 |

## 2. 实现概要（2026-09-10 集成）

- **适配器** `lib/adapters/sf-uat.js`：唯一的 UAT Salesforce 通道。execFile + 固定子命令 + SOQL 转义；每次写入前 `sf org display` 核验组织 ID 在 `SOSP_UAT_ORG_IDS` 允许列表内；失败一律抛错，由 store 层拒绝保存并保留草稿，绝不回退生产或伪装本地写入。
- **对象映射**（依据 2026-09-10 只读 schema 调查）：
  - 拜访日志 → `Visit_Plan__c`（组织真实在用对象）：`Opty__c` 关联机会、`Visit_Content__c` 转写与六项字段、`ActivityDateTime__c` 活动时间、`logType__c`/`visitCategory__c`/`Visit_Status__c` 按组织选项列表取值；
  - 幂等与清理凭据 → `Visit_Plan__c.Source_Id__c`（唯一外部 ID）= `SOSP-DEMO-<clientToken>`；超时先按外部 ID 对账再重试，绝不盲目重写；
  - 机会关联 → 每个本地机会首次保存时按需在 UAT 创建 `SOSP-DEMO-<oppId>` 测试机会（挂 `SOSP-DEMO-<oppId>` 测试客户），SF Id 留痕于本地 `opportunities.sf_opportunity_id`；
  - 阶段推进 → 仅允许对本 Demo 创建的测试机会执行，阶段值白名单来自组织真实选项列表，推进后回读核对。
- **组织侧限制（实测踩坑）**：`OppAccStatus`/`OpportunityTrigger` 触发器要求机会必须带 `AccountId`、省份字段非空（受限选项列表取值如「广东」），机会挂载客户必须「审批通过」，`level3Province__c` 本用户无写权限——测试数据创建时已按上述口径补齐。
- **凭证隔离**：服务用户 `sosp-assistant` 使用独立 `HOME=/var/lib/sosp-assistant`，其 `.sfdx/`（0700/0600）只含 UAT 认证与解密 key，看不到生产认证；服务进程沙箱（ProtectHome 等）不受影响。

## 3. 服务端配置（/etc/systemd/system/sosp-assistant.service）

```ini
Environment=SOSP_MODE=uat
Environment=SOSP_UAT_ORG_IDS=00DC5000000kicLMAQ
Environment=SOSP_UAT_WRITE=true
Environment=SF_UAT_TARGET_ORG=sospdomain@sf-auto.com.uat
```

改动后：`systemctl daemon-reload && systemctl restart sosp-assistant`。回退 mock：`SOSP_MODE` 改回 `mock` 并重启，本地数据不受影响。

## 4. 验证（全部通过，2026-09-10）

| 验证 | 方式 | 结果 |
| --- | --- | --- |
| 回归 | `npm run test:store` / `test:uat` / `test:ai-validate` | ✅ mock 行为不变，uat 开关关闭时写入仍拒绝 |
| UAT 同步逻辑 | `npm run test:uat-sync`（7 场景，假 sf 替身，隔离） | ✅ 开关关闭/允许列表空/身份不符均拒绝且本地零残留；正常路径本地留痕；幂等对账不重复创建；非 Demo 机会拒绝推进 |
| 真实 UAT 往返 | `scripts/verify-uat-roundtrip.js`（需 `SOSP_UAT_RT_ALLOW=1`） | ✅ 11/11：建客户→建机会→写日志→SOQL 回读→幂等重试→阶段推进回读→全量清理并回读确认 |
| 应用真实保存路径 | 以服务用户身份调用 `store.saveVisitLog` → 真实 UAT | ✅ `uat_state=written`，UAT 回读内容一致，两端清理干净 |
| 线上服务 | `GET /api/session` | ✅ 徽章 `UAT 联调（真实写入）`，`writeTarget=uat` |

## 5. 失败与安全行为（实测）

| 场景 | 行为 |
| --- | --- |
| UAT 写入开关关闭 | 保存返回 403「UAT 写入未启用」；草稿保留（test:uat 覆盖） |
| 组织身份核验不过 / 允许列表为空 / 认证过期 | 拒绝写入，本地零残留，绝不回退生产或伪装本地保存为 UAT 写入（test:uat-sync 覆盖） |
| 远端超时 | 本地不落库；重试按 `Source_Id__c` 对账，不产生重复记录 |
| 阶段推进非 Demo 机会 | 拒绝（UAT_FORBIDDEN），真实业务数据不可触碰 |
| 客户端伪造 mode/org 参数 | 服务端忽略（test:security 覆盖） |

## 6. production-readonly（可选，当前关闭）

`SOSP_ENABLE_SF_READONLY=false` 保持关闭。开启方式见 git 历史版本本节；生产组织 ID `00DC80000071iuEMAQ` 任何情况下禁止写入。

## 7. 已知边界

- UAT 写入仅限：`Visit_Plan__c`（白名单字段）、`SOSP-DEMO-` 前缀的测试 Opportunity/Account、阶段推进（仅测试机会）。清理仅针对可证明由本 Demo 创建的记录（外部 ID / 前缀 + 本地留痕双重确认）。
- UAT 中已有的真实业务记录（1 万+ 机会、真实拜访计划等）只读不写。
- 提醒（stagnation）动作仍为 Demo 本地行为，不写 UAT。
