通过 VITE_AUTO_FILL_TEST 环境变量控制,在 .env.test 中启用, 使测试环境构建后登录框也能自动填充测试账号,方便测试人员使用。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
384 lines
14 KiB
Markdown
384 lines
14 KiB
Markdown
# 企业同步创作数据 — 核心三步
|
||
|
||
> 基于《AI绘本创作系统 企业后端集成指南 V4.0》,提炼企业同步乐读派创作数据的核心实现路径。
|
||
>
|
||
> 三层防线共用同一套同步判断规则,区别仅在于触发方式和时机。
|
||
|
||
---
|
||
|
||
## 前置知识:5步状态机与同步判断规则
|
||
|
||
### 状态定义
|
||
|
||
| 数值 | 状态名 | 含义 |
|
||
|------|--------|------|
|
||
| -1 | FAILED | 创作失败(异常状态) |
|
||
| 1 | PENDING | 已提交,排队等待 |
|
||
| 2 | PROCESSING | AI创作中(进度变化) |
|
||
| 3 | COMPLETED | 图片完成,待编目 |
|
||
| 4 | CATALOGED | 编目完成,待配音 |
|
||
| 5 | DUBBED | 配音完成(乐读派终态) |
|
||
|
||
正常流转:`1 → 2 → 3 → 4 → 5`,严格单向递增,不可回退。
|
||
|
||
### 统一同步判断规则(三步通用)
|
||
|
||
```
|
||
if (本地无记录) → INSERT(新作品)
|
||
if (remote_status == -1) → 强制UPDATE(失败通知,无条件处理)
|
||
if (remote_status == 2) → 强制UPDATE(进度变化,无条件更新)
|
||
if (remote_status > local_status) → 全量UPDATE(状态前进)
|
||
其他 → SKIP(旧数据/重复,忽略)
|
||
```
|
||
|
||
> **重点**:PROCESSING(2) 和 FAILED(-1) 是特殊状态,**不参与数值大小比较**,收到即强制更新。
|
||
|
||
---
|
||
|
||
## 第一步:Webhook 回调推送(实时主通道)
|
||
|
||
### 作用
|
||
|
||
乐读派后端在作品状态变更时,**主动** POST 推送到企业配置的 Webhook URL,实现秒级数据同步。
|
||
|
||
### 触发时机
|
||
|
||
每次 status 发生变化时自动推送,包括:
|
||
- 状态前进:1→2、2→3、3→4、4→5
|
||
- 创作失败:any→-1
|
||
|
||
### 两种事件类型
|
||
|
||
| 事件 | 触发时机 | 频率 | 说明 |
|
||
|------|----------|------|------|
|
||
| `work.status_changed` | 状态变更 | 每次状态变更1次 | 核心事件,携带完整作品数据(含 page_list) |
|
||
| `work.progress` | PROCESSING 阶段进度变化 | 多次/作品 | 进度里程碑推送(10%/30%/50%/70%/90%) |
|
||
|
||
### Webhook 请求格式
|
||
|
||
```
|
||
POST {企业webhook_url}
|
||
Content-Type: application/json
|
||
X-Webhook-Id: evt_190368671438289
|
||
X-Webhook-Event: work.status_changed
|
||
X-Webhook-Timestamp: 1712000000000
|
||
X-Webhook-Signature: HMAC-SHA256=a3f8c2d1...
|
||
```
|
||
|
||
### 企业处理逻辑(Java 伪代码)
|
||
|
||
```java
|
||
@Transactional
|
||
public void handleWebhook(JSONObject data) {
|
||
String workId = data.getString("work_id");
|
||
int remoteStatus = data.getIntValue("status");
|
||
|
||
// 1. 幂等去重(用 event_id,防止重复处理)
|
||
if (processedEvents.contains(eventId)) return;
|
||
|
||
// 2. 查本地记录(建议 SELECT ... FOR UPDATE 行锁)
|
||
WorkRecord local = db.selectForUpdate(workId);
|
||
|
||
// 3. 新作品 → 直接入库
|
||
if (local == null) {
|
||
db.insert(buildRecord(data));
|
||
return;
|
||
}
|
||
|
||
// 4. FAILED(-1) → 强制更新,无条件
|
||
if (remoteStatus == -1) {
|
||
local.setStatus(-1);
|
||
local.setFailReason(data.getString("fail_reason"));
|
||
db.update(local);
|
||
return;
|
||
}
|
||
|
||
// 5. PROCESSING(2) → 强制更新进度,无条件
|
||
if (remoteStatus == 2) {
|
||
local.setProgress(data.getIntValue("progress"));
|
||
local.setProgressMessage(data.getString("progress_message"));
|
||
db.update(local);
|
||
return;
|
||
}
|
||
|
||
// 6. 状态前进 → 全量覆盖
|
||
if (remoteStatus > local.getStatus()) {
|
||
updateAllFields(local, data); // 更新 title/author/page_list 等
|
||
db.update(local);
|
||
return;
|
||
}
|
||
|
||
// 7. 旧数据/重复 → 忽略
|
||
log.info("skip: remote={} <= local={}", remoteStatus, local.getStatus());
|
||
}
|
||
```
|
||
|
||
### 各状态下企业应保存的关键数据
|
||
|
||
| 状态 | 企业需保存的字段 | 用途 |
|
||
|------|--------------|------|
|
||
| 1 PENDING | work_id, phone, org_id, style, original_image_url | 创建本地记录,关联企业用户 |
|
||
| 2 PROCESSING | progress, progress_message | 显示创作进度(可选) |
|
||
| 3 COMPLETED | title, pages, page_list(含 image_url) | 作品图片已生成,可预览展示 |
|
||
| 4 CATALOGED | title, author, subtitle, intro, tags | 用户填写的编目信息 |
|
||
| 5 DUBBED | page_list(含 audio_url) | 配音URL已填充,作品完整可用 |
|
||
| -1 FAILED | fail_reason | 记录失败原因,通知用户 |
|
||
|
||
### 签名验证(必须实现)
|
||
|
||
```
|
||
签名体 = "{X-Webhook-Id}.{X-Webhook-Timestamp}.{请求body原文}"
|
||
期望签名 = HMAC-SHA256(签名体, app_secret).toHex()
|
||
```
|
||
|
||
安全检查清单:
|
||
- **时间窗口**:`|当前时间 - X-Webhook-Timestamp| ≤ 5分钟`,防重放
|
||
- **幂等去重**:用 `X-Webhook-Id` 记录已处理事件
|
||
- **常量时间比较**:用 `MessageDigest.isEqual()` 而非 `.equals()`,防时序攻击
|
||
- **原始body**:签名时使用 HTTP body 原文,不要反序列化再序列化
|
||
|
||
### 重试策略
|
||
|
||
首次发送 + 5次重试 = 共6次尝试。
|
||
|
||
延迟间隔:`10s → 30s → 2min → 10min → 30min → 30min`
|
||
|
||
全部失败后需通过**第二步 B3 批量拉取**兜底。
|
||
|
||
---
|
||
|
||
## 第二步:B3 批量查询兜底(定时对账)
|
||
|
||
### 作用
|
||
|
||
Webhook 可能因网络问题、企业服务宕机等原因丢失。B3 定时批量拉取作为兜底通道,确保数据**最终一致**。
|
||
|
||
### 接口信息
|
||
|
||
```
|
||
GET /api/v1/query/works?orgId=ORG001&updatedAfter=2026-04-05T00:00:00
|
||
认证方式:HMAC-SHA256 签名
|
||
```
|
||
|
||
返回指定时间之后有变更的所有作品列表。
|
||
|
||
### 建议配置
|
||
|
||
| 配置项 | 推荐值 | 说明 |
|
||
|--------|--------|------|
|
||
| 对账频率 | 每 30 分钟 | 不低于 15 分钟,避免对 API 造成查询压力 |
|
||
| 查询范围 | 最近 2 小时 | 覆盖 2 个对账周期,防止边界遗漏 |
|
||
|
||
### 企业实现伪代码(Java)
|
||
|
||
```java
|
||
@Scheduled(fixedRate = 30 * 60 * 1000) // 每30分钟执行
|
||
public void reconcile() {
|
||
// 查询最近2小时内有变更的作品
|
||
String since = twoHoursAgo.format(ISO_FORMAT);
|
||
List<RemoteWork> remoteList = callB3(since);
|
||
|
||
for (RemoteWork remote : remoteList) {
|
||
WorkRecord local = db.get(remote.workId);
|
||
|
||
// 同步判断规则与 Webhook 完全一致
|
||
if (local == null
|
||
|| remote.status > local.getStatus()
|
||
|| remote.status == 2
|
||
|| remote.status == -1) {
|
||
db.upsert(remote);
|
||
}
|
||
}
|
||
lastSyncTime = now();
|
||
}
|
||
```
|
||
|
||
### B3 调用签名示例
|
||
|
||
```java
|
||
Map<String, String> params = new LinkedHashMap<>();
|
||
params.put("orgId", ORG_ID);
|
||
params.put("updatedAfter", "2026-04-05T00:00:00");
|
||
|
||
// 生成 HMAC 签名头(4个 Header)
|
||
Map<String, String> headers = buildHmacHeaders(params, ORG_ID, APP_SECRET);
|
||
|
||
String url = API_URL + "/api/v1/query/works?orgId=" + ORG_ID
|
||
+ "&updatedAfter=" + URLEncoder.encode("2026-04-05T00:00:00", "UTF-8");
|
||
|
||
// 注意:传 URI 对象,避免 RestTemplate 双重编码
|
||
restTemplate.getForObject(URI.create(url), String.class);
|
||
```
|
||
|
||
### 并发安全(Webhook + B3 同时触发)
|
||
|
||
推荐两种方案防止同一作品被并发更新:
|
||
|
||
```sql
|
||
-- 方案1: 行锁(推荐,简单可靠)
|
||
SELECT * FROM enterprise_work WHERE work_id = ? FOR UPDATE;
|
||
-- 然后按同步规则判断和更新
|
||
|
||
-- 方案2: CAS 乐观锁(无锁,适合高并发)
|
||
UPDATE enterprise_work
|
||
SET status = #{remoteStatus}, title = #{title}, page_list = #{pageList}
|
||
WHERE work_id = #{workId}
|
||
AND (status < #{remoteStatus} OR #{remoteStatus} = 2 OR #{remoteStatus} = -1);
|
||
-- rows=0 表示被其他线程抢先更新了,安全忽略
|
||
```
|
||
|
||
---
|
||
|
||
## 第三步:详情页进入时强制 B2 拉取(用户触发兜底)
|
||
|
||
### 作用
|
||
|
||
当用户点击进入作品详情页,且本地状态尚未完成(`status < 3`),企业**强制调用 B2 单条查询**拉取最新数据,确保用户看到的是最新状态。
|
||
|
||
### 为什么需要这一步
|
||
|
||
- status 1/2 期间状态变化最快(排队 → 创作中 → 完成),是 Webhook 丢失的**高风险窗口**
|
||
- B3 对账有 30 分钟延迟,用户可能在此期间打开详情页
|
||
- 用户主动触发,**即时补偿**,体验最佳
|
||
|
||
### 触发条件
|
||
|
||
```
|
||
用户点击作品详情页 && local_status < 3
|
||
```
|
||
|
||
`status >= 3` 后变化由用户主动操作驱动(编目/配音),Webhook + B3 已足够覆盖,无需额外拉取。
|
||
|
||
### 接口信息
|
||
|
||
```
|
||
GET /api/v1/query/work/{workId}?orgId=ORG001
|
||
认证方式:HMAC-SHA256 签名 或 Session Token
|
||
```
|
||
|
||
### 企业实现伪代码(Java)
|
||
|
||
```java
|
||
/**
|
||
* 用户点击作品详情页时调用
|
||
*/
|
||
public WorkRecord getWorkDetail(String workId) {
|
||
WorkRecord local = db.get(workId);
|
||
|
||
// 本地状态 < 3(PENDING 或 PROCESSING),强制从乐读派拉取最新
|
||
if (local != null && local.getStatus() < 3) {
|
||
RemoteWork remote = callB2(workId);
|
||
|
||
// 同步判断规则与 Webhook、B3 完全一致
|
||
if (remote.status > local.getStatus()
|
||
|| remote.status == 2
|
||
|| remote.status == -1) {
|
||
updateAllFields(local, remote);
|
||
db.update(local);
|
||
}
|
||
}
|
||
|
||
// 返回最新的本地记录,渲染详情页
|
||
return db.get(workId);
|
||
}
|
||
```
|
||
|
||
### 前端配合(可选)
|
||
|
||
如果企业有自建 C 端,可在作品列表页面根据 status 做路由跳转:
|
||
|
||
```javascript
|
||
switch (work.status) {
|
||
case 1: case 2: // 排队/创作中
|
||
navigate('/creating/' + workId); // → 进度等待页
|
||
break;
|
||
case 3: // 图片完成
|
||
navigate('/catalog/' + workId); // → 编目编辑页(强制)
|
||
break;
|
||
case 4: // 编目完成
|
||
navigate('/dubbing/' + workId); // → 配音编辑页(强制)
|
||
break;
|
||
case 5: // 配音完成(终态)
|
||
navigate('/reader/' + workId); // → 阅读页
|
||
break;
|
||
case -1: // 失败
|
||
showError(work.failReason);
|
||
break;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三步协同总览
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ 企业同步三层防线 │
|
||
├──────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 第一步 Webhook 推送 秒级实时 ← 主通道(可能丢失) │
|
||
│ ↓ 漏网之鱼 │
|
||
│ 第二步 B3 定时对账 30分钟兜底 ← 保障通道(高可靠) │
|
||
│ ↓ 用户等不及 │
|
||
│ 第三步 B2 详情页强制拉取 用户触发 ← 即时补偿(status<3时) │
|
||
│ │
|
||
├──────────────────────────────────────────────────────────────────┤
|
||
│ 三层共用统一同步规则: │
|
||
│ · remote > local → 全量更新 │
|
||
│ · remote == 2 (进度) → 强制更新 │
|
||
│ · remote == -1 (失败) → 强制更新 │
|
||
│ · 其他 → 忽略 │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 数据流对比
|
||
|
||
| 维度 | 第一步 Webhook | 第二步 B3 对账 | 第三步 B2 强制拉取 |
|
||
|------|-------------|------------|---------------|
|
||
| 触发方式 | 乐读派主动推送 | 企业定时轮询 | 用户打开详情页 |
|
||
| 实时性 | 秒级 | 30分钟级 | 即时 |
|
||
| 可靠性 | 可能丢失 | 高可靠 | 高可靠 |
|
||
| 触发条件 | 状态变更时自动 | 定时任务 | `local_status < 3` |
|
||
| 数据范围 | 单条作品 | 时间范围内批量 | 单条作品 |
|
||
| 认证方式 | 签名验证(被动接收) | HMAC 签名(主动请求) | HMAC/Token(主动请求) |
|
||
|
||
---
|
||
|
||
## 企业推荐表结构
|
||
|
||
```sql
|
||
CREATE TABLE `enterprise_work` (
|
||
`work_id` VARCHAR(32) PRIMARY KEY,
|
||
`status` INT NOT NULL DEFAULT 0 COMMENT '状态: 乐读派1-5, 企业>=6',
|
||
`progress` INT DEFAULT 0 COMMENT '创作进度0-100',
|
||
`title` VARCHAR(200),
|
||
`author` VARCHAR(50),
|
||
`tags` JSON,
|
||
`page_list` JSON COMMENT '页面数据(含image_url/audio_url)',
|
||
`original_image_url` VARCHAR(500) COMMENT '用户原创作品图片URL',
|
||
`phone` VARCHAR(20) COMMENT '用户手机号(关联企业用户)',
|
||
`fail_reason` VARCHAR(500),
|
||
`webhook_event_id` VARCHAR(64) COMMENT '最近一次webhook事件ID(幂等去重)',
|
||
`synced_at` DATETIME COMMENT '最近同步时间',
|
||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
INDEX `idx_status` (`status`),
|
||
INDEX `idx_phone` (`phone`)
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 管理后台配置项
|
||
|
||
企业需在乐读派管理后台「机构管理」中配置以下 2 项:
|
||
|
||
| 配置项 | 填写内容 | 说明 |
|
||
|--------|--------|------|
|
||
| Webhook URL | `https://你的域名/webhook/leai` | 接收作品状态变更推送(第一步) |
|
||
| 认证回调URL | `https://你的域名/leai-auth` | H5 token 失效后跳回重新认证 |
|
||
|
||
---
|
||
|
||
> 完整接口规范、签名算法、代码示例请参考《AI绘本创作系统 企业后端集成指南 V4.0》及随附的 `enterprise-demo/java-demo/` 目录。
|