Skip to main content

tdm_server_rust/web/
editor_controller.rs

1//! 在线翻译编辑器接口 (Editor Controller)
2//!
3//! 路由前缀 `/api/editor`,对接 `membertb` 登录态(写操作要求登录)。
4//!
5//! | 方法 | 路径 | 说明 |
6//! |------|------|------|
7//! | POST | `/episodes/{episodeId}/pages/import-provider` | 从图源导入生成页面 |
8//! | GET  | `/episodes/{episodeId}/workspace` | 获取编辑器工作区 |
9//! | GET  | `/pages?episodeId=` | 查询页面列表 |
10//! | GET  | `/units?pageId=` | 查询某页标记单元 |
11//! | PUT  | `/units` | 整页替换式保存标记单元 |
12//! | POST | `/pages/{pageId}/translation-completion` | 标记页面翻译完成 |
13//! | DELETE | `/pages/{pageId}/translation-completion` | 取消页面翻译完成 |
14//! | POST | `/episodes/{episodeId}/workflow/complete` | 整话确认,回写流程 |
15
16use crate::utils::query_deserialize::de_i32;
17use crate::{
18    app::AppState,
19    common::result::ResultBody,
20    common::AppJson,
21    entity::editor::{
22        CompleteEpisodeWorkflowRequest, EditorPageInfo, EditorUnitInfo,
23        EditorUploadCredentialsRequest, EditorUploadCredentialsResponse, EditorWorkspace,
24        ImportProviderResult, RegisterEditorPagesRequest, SaveEditorUnitsRequest,
25    },
26    entity::oss::OssCredential,
27    error::{ApiResult, AppError},
28    middleware::AuthMember,
29    service::{
30        editor_import_service::EditorImportService, editor_service::EditorService,
31        editor_source_archive_service::EditorSourceArchiveService,
32    },
33};
34use axum::{
35    body::Body,
36    extract::{Extension, Path, Query, State},
37    http::{header, HeaderValue, StatusCode},
38    response::{IntoResponse, Response},
39    routing::{get, post},
40    Router,
41};
42use serde::Deserialize;
43
44/// 页面列表查询参数
45#[derive(Debug, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct PagesQuery {
48    /// 话数 ID
49    #[serde(deserialize_with = "de_i32")]
50    pub episode_id: i32,
51}
52
53/// 标记单元查询参数
54#[derive(Debug, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct UnitsQuery {
57    /// 页面 ID
58    pub page_id: i64,
59}
60
61/// 图源导入查询参数
62#[derive(Debug, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct ImportProviderQuery {
65    /// 是否强制重建(删除旧页面/标点后重新导入)
66    #[serde(default)]
67    pub force: bool,
68}
69
70/// 编辑器路由(挂载于 `/api/editor`)
71pub fn routes() -> Router<AppState> {
72    Router::new()
73        .route(
74            "/episodes/:episodeId/pages/import-provider",
75            post(import_provider),
76        )
77        .route(
78            "/episodes/:episodeId/pages/upload-credentials",
79            post(prepare_upload_credentials),
80        )
81        .route("/episodes/:episodeId/pages/register", post(register_pages))
82        .route(
83            "/episodes/:episodeId/source-archive",
84            get(get_source_archive),
85        )
86        .route(
87            "/episodes/:episodeId/source-archive/file",
88            get(download_source_archive_file),
89        )
90        .route("/episodes/:episodeId/workspace", get(get_workspace))
91        .route("/pages", get(list_pages))
92        .route("/units", get(list_units).put(save_units))
93        .route(
94            "/pages/:pageId/translation-completion",
95            post(set_translation_completion).delete(clear_translation_completion),
96        )
97        .route(
98            "/episodes/:episodeId/workflow/complete",
99            post(complete_workflow),
100        )
101}
102
103/// 从图源导入生成页面(幂等,force 重建)
104#[tracing::instrument(skip_all, level = "info")]
105pub async fn import_provider(
106    State(state): State<AppState>,
107    Extension(AuthMember(member)): Extension<AuthMember>,
108    Path(episode_id): Path<i32>,
109    Query(q): Query<ImportProviderQuery>,
110) -> ApiResult<ResultBody<ImportProviderResult>> {
111    require_member(&member)?;
112    let result = EditorImportService::import_provider_pages(&state, episode_id, q.force).await?;
113    Ok(ResultBody::success_data(result))
114}
115
116/// 为前端图源解压直传准备批量上传凭证
117#[tracing::instrument(skip_all, level = "info")]
118pub async fn prepare_upload_credentials(
119    State(state): State<AppState>,
120    Extension(AuthMember(member)): Extension<AuthMember>,
121    Path(episode_id): Path<i32>,
122    AppJson(body): AppJson<EditorUploadCredentialsRequest>,
123) -> ApiResult<ResultBody<EditorUploadCredentialsResponse>> {
124    require_member(&member)?;
125    let data =
126        EditorImportService::prepare_upload_credentials(&state, episode_id, body.files).await?;
127    Ok(ResultBody::success_data(data))
128}
129
130/// 注册前端已直传到图片 OSS 的页面(force 重建)
131#[tracing::instrument(skip_all, level = "info")]
132pub async fn register_pages(
133    State(state): State<AppState>,
134    Extension(AuthMember(member)): Extension<AuthMember>,
135    Path(episode_id): Path<i32>,
136    AppJson(body): AppJson<RegisterEditorPagesRequest>,
137) -> ApiResult<ResultBody<ImportProviderResult>> {
138    require_member(&member)?;
139    let result = EditorImportService::register_pages(&state, episode_id, body).await?;
140    Ok(ResultBody::success_data(result))
141}
142
143/// 获取图源压缩包下载凭证(新模型现打包,按页面集 hash 缓存复用)
144#[tracing::instrument(skip_all, level = "info")]
145pub async fn get_source_archive(
146    State(state): State<AppState>,
147    Path(episode_id): Path<i32>,
148) -> ApiResult<ResultBody<OssCredential>> {
149    let data = EditorSourceArchiveService::get_or_build_archive(&state, episode_id).await?;
150    Ok(ResultBody::success_data(data))
151}
152
153/// 跳转到 COS/CDN 图源压缩包地址,避免后端服务器中转文件流量。
154#[tracing::instrument(skip_all, level = "info")]
155pub async fn download_source_archive_file(
156    State(state): State<AppState>,
157    Path(episode_id): Path<i32>,
158) -> ApiResult<Response> {
159    let data = EditorSourceArchiveService::get_or_build_archive(&state, episode_id).await?;
160    let mut headers = axum::http::HeaderMap::new();
161    headers.insert(
162        header::LOCATION,
163        HeaderValue::from_str(&data.presigned_url)
164            .map_err(|e| AppError::Internal(format!("生成 COS 跳转地址失败: {e}")))?,
165    );
166    Ok((StatusCode::TEMPORARY_REDIRECT, headers, Body::empty()).into_response())
167}
168
169/// 获取编辑器工作区
170#[tracing::instrument(skip_all, level = "info")]
171pub async fn get_workspace(
172    State(state): State<AppState>,
173    Path(episode_id): Path<i32>,
174) -> ApiResult<ResultBody<EditorWorkspace>> {
175    let data = EditorService::get_workspace(&state, episode_id).await?;
176    Ok(ResultBody::success_data(data))
177}
178
179/// 查询页面列表
180#[tracing::instrument(skip_all, level = "info")]
181pub async fn list_pages(
182    State(state): State<AppState>,
183    Query(q): Query<PagesQuery>,
184) -> ApiResult<ResultBody<Vec<EditorPageInfo>>> {
185    let data = EditorService::list_pages(&state, q.episode_id).await?;
186    Ok(ResultBody::success_data(data))
187}
188
189/// 查询某页标记单元
190#[tracing::instrument(skip_all, level = "info")]
191pub async fn list_units(
192    State(state): State<AppState>,
193    Query(q): Query<UnitsQuery>,
194) -> ApiResult<ResultBody<Vec<EditorUnitInfo>>> {
195    let data = EditorService::list_units(&state, q.page_id).await?;
196    Ok(ResultBody::success_data(data))
197}
198
199/// 整页替换式保存标记单元
200#[tracing::instrument(skip_all, level = "info")]
201pub async fn save_units(
202    State(state): State<AppState>,
203    Extension(AuthMember(member)): Extension<AuthMember>,
204    AppJson(body): AppJson<SaveEditorUnitsRequest>,
205) -> ApiResult<ResultBody<usize>> {
206    let member_id = require_member(&member)?;
207    let saved = EditorService::save_units(&state, body, member_id).await?;
208    Ok(ResultBody::success_data(saved))
209}
210
211/// 标记页面翻译完成
212#[tracing::instrument(skip_all, level = "info")]
213pub async fn set_translation_completion(
214    State(state): State<AppState>,
215    Extension(AuthMember(member)): Extension<AuthMember>,
216    Path(page_id): Path<i64>,
217) -> ApiResult<ResultBody<()>> {
218    require_member(&member)?;
219    EditorService::set_page_translation_completion(&state, page_id, true).await?;
220    Ok(ResultBody::success())
221}
222
223/// 取消页面翻译完成
224#[tracing::instrument(skip_all, level = "info")]
225pub async fn clear_translation_completion(
226    State(state): State<AppState>,
227    Extension(AuthMember(member)): Extension<AuthMember>,
228    Path(page_id): Path<i64>,
229) -> ApiResult<ResultBody<()>> {
230    require_member(&member)?;
231    EditorService::set_page_translation_completion(&state, page_id, false).await?;
232    Ok(ResultBody::success())
233}
234
235/// 整话确认(回写流程并失效缓存)
236#[tracing::instrument(skip_all, level = "info")]
237pub async fn complete_workflow(
238    State(state): State<AppState>,
239    Extension(AuthMember(member)): Extension<AuthMember>,
240    Path(episode_id): Path<i32>,
241    AppJson(body): AppJson<CompleteEpisodeWorkflowRequest>,
242) -> ApiResult<ResultBody<()>> {
243    let member_id = require_member(&member)?;
244    EditorService::complete_episode_workflow(&state, episode_id, &body.role, member_id).await?;
245    Ok(ResultBody::success())
246}
247
248/// 写操作要求登录,返回当前组员 ID
249fn require_member(member: &Option<crate::entity::member::Member>) -> ApiResult<i32> {
250    member
251        .as_ref()
252        .map(|m| m.id)
253        .ok_or_else(|| AppError::login_expired("请登录后再操作编辑器喵……"))
254}