feat: work-form 无 Token 白名单与 leai_status 原样持久化

- Security: /public/leai-works/** 放行;Controller 使用 getCurrentUserIdOrNull;匿名仅按 remoteWorkId 定位作品

- saveWorkForm: 请求体 status 写入 leai_status(原样覆盖,不做区间收敛)

- 文档与编目/配音页相关调整

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-16 10:31:22 +08:00
parent b2ae6653d5
commit 5ae9233afc
6 changed files with 36 additions and 10 deletions

View File

@ -6,8 +6,8 @@
|----|------|
| Base URL | 部署域名 + **context-path `/api`**(见 `application.yml` `server.servlet.context-path` |
| 完整路径前缀 | `/api/public/leai-works` |
| 认证 | **需要登录**。请求头携带 `Authorization: Bearer <JWT>`;与 Web 端一致时可加 `X-Tenant-Code`、`X-Tenant-Id`(见前端 `request.ts` |
| 安全说明 | 该路径**不在** Spring Security 匿名白名单中,未带有效 Token 将返回 **401** |
| 认证 | **可选**。未带 Token 时接口仍可访问Spring Security 白名单 `/public/leai-works/**`),供外部系统对接;携带 `Authorization: Bearer <JWT>` 时,后端会校验作品归属当前用户。Web 端可继续`X-Tenant-Code`、`X-Tenant-Id`(见前端 `request.ts` |
| 安全说明 | 无 Token 时**仅凭路径中的 `remoteWorkId` 定位作品**,请依赖 ID 保密性并在网络层做访问控制;带 Token 时仍按用户维度校验。 |
---
@ -55,10 +55,11 @@ Content-Type: application/json
### 请求体JSON 对象)
- **不能为空**`{}` 会返回 **400**`请求体不能为空`)。
- 服务端从 body 中读取类数据:
1. **编目字段**(可选子集):仅处理以下键,其余顶层键若存在会被忽略(除 `pageList` 外):
- 服务端从 body 中读取类数据:
1. **编目字段**(可选子集):仅处理以下键,其余顶层键若存在会被忽略(除 `pageList`、`status` 外):
`author`、`title`、`subtitle`、`intro`、`tags`
2. **分页**`pageList` 为数组时,按乐读派 `pageList` 形态写入本库分页表。
2. **进度**`status`(整数)原样写入本库 `leai_status`,与 GET 返回的 `status` 一致(不做区间收敛)。
3. **分页**`pageList` 为数组时,按乐读派 `pageList` 形态写入本库分页表。
| 字段 | 类型 | 说明 |
|------|------|------|
@ -67,6 +68,7 @@ Content-Type: application/json
| `subtitle` | string | 副标题 |
| `intro` | string | 简介 |
| `tags` | string[] | 标签列表 |
| `status` | number | 乐读派创作进度(`leai_status`见下文「status 取值」 |
| `pageList` | object[] | 分页列表;元素字段见下表 |
`pageList` 元素:
@ -82,6 +84,7 @@ Content-Type: application/json
- 作品必须属于**当前登录用户**且 `remote_work_id` 匹配,否则 **404**`作品不存在或无权操作`)。
- 若本次请求更新了编目,且作品为草稿、且乐读派进度已 ≥「生成完成」,可能将发布状态从草稿置为未发布(见 `PublicLeaiWorkFormService`)。
- 若同时携带 `pageList` 且**每一页**均有非空 `audioUrl`,保存分页后会将 `leai_status` 置为 **5已配音**,覆盖本次请求中的 `status` 值。
- 若 `pageList` 非空,且**每一页**的 `audioUrl` 均为非空白字符串,则将 `leai_status` 更新为 **已配音5**
### 成功响应

View File

@ -24,7 +24,7 @@ public class PublicLeaiWorkController {
@GetMapping("/{remoteWorkId}/work-form")
@Operation(summary = "查询本库作品详情(编目/分页快照,不经乐读派 B2 GET")
public Result<Map<String, Object>> getWorkForm(@PathVariable String remoteWorkId) {
Long userId = SecurityUtil.getCurrentUserId();
Long userId = SecurityUtil.getCurrentUserIdOrNull();
return Result.success(publicLeaiWorkFormService.getWorkFormDetail(userId, remoteWorkId));
}
@ -33,7 +33,7 @@ public class PublicLeaiWorkController {
public Result<Void> saveWorkForm(
@PathVariable String remoteWorkId,
@RequestBody Map<String, Object> body) {
Long userId = SecurityUtil.getCurrentUserId();
Long userId = SecurityUtil.getCurrentUserIdOrNull();
publicLeaiWorkFormService.saveWorkForm(userId, remoteWorkId, body);
return Result.success();
}

View File

@ -36,7 +36,7 @@ public class PublicLeaiWorkFormService {
private final ILeaiSyncService leaiSyncService;
/**
* @param body 可含 authortitlesubtitleintrotagspageList乐读派 pageList 形态
* @param body 可含 authortitlesubtitleintrotagsstatus乐读派进度写入 leai_statuspageList乐读派 pageList 形态
*/
@Transactional(rollbackFor = Exception.class)
public void saveWorkForm(Long userId, String remoteWorkId, Map<String, Object> body) {
@ -70,6 +70,16 @@ public class PublicLeaiWorkFormService {
}
}
// status leai_status 原样覆盖不与库内旧值合并不做区间收敛先于 pageList若全页有配音下文仍会置为 DUBBED
if (body.containsKey("status")) {
int st = LeaiUtil.toInt(body.get("status"), 0);
LambdaUpdateWrapper<UgcWork> uw = new LambdaUpdateWrapper<>();
uw.eq(UgcWork::getId, work.getId())
.set(UgcWork::getLeaiStatus, st)
.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, uw);
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> pageList = (List<Map<String, Object>>) body.get("pageList");
if (pageList != null && !pageList.isEmpty()) {
@ -170,11 +180,16 @@ public class PublicLeaiWorkFormService {
return m;
}
/**
* 定位作品已登录时校验 {@code remoteWorkId} 属于当前用户 Token外部系统对接时仅按 {@code remoteWorkId} 查询请依赖 ID 保密性
*/
private UgcWork findOwnedWork(Long userId, String remoteWorkId) {
LambdaQueryWrapper<UgcWork> q = new LambdaQueryWrapper<>();
q.eq(UgcWork::getRemoteWorkId, remoteWorkId)
.eq(UgcWork::getUserId, userId)
.eq(UgcWork::getIsDeleted, 0);
if (userId != null) {
q.eq(UgcWork::getUserId, userId);
}
UgcWork work = ugcWorkMapper.selectOne(q);
if (work == null) {
throw new BusinessException(404, "作品不存在或无权操作");

View File

@ -58,6 +58,8 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/public/gallery", "/public/gallery/**").permitAll()
.requestMatchers(HttpMethod.GET, "/public/tags", "/public/tags/**").permitAll()
.requestMatchers(HttpMethod.GET, "/public/users/*/works").permitAll()
// 乐读派作品表单编目/分页快照支持外部系统无 Token 调用 Token 时仍校验作品归属
.requestMatchers("/public/leai-works/**").permitAll()
// 乐读派 Webhook 回调无用户上下文由乐读派服务端调用
.requestMatchers("/webhook/leai").permitAll()
// Knife4j 文档

View File

@ -456,6 +456,10 @@ function buildWorkFormPayload() {
text: p.text,
audioUrl: p.audioUrl || undefined,
}))
if (body.status < 4) {
body.status = 4
}
return body
}

View File

@ -269,7 +269,9 @@ async function saveFormToServer() {
payload.subtitle = form.value.subtitle.trim()
payload.intro = form.value.intro.trim()
payload.tags = [...selectedTags.value]
if (payload.status < 4) {
payload.status = 4
}
await saveLeaiWorkForm(id, payload)
workFormSnapshot.value = cloneWorkFormSnapshot(payload)