Push async submit, Chinese 422 field labels, hide server CSV export.
Return sync/files immediately with background runner; show loc_zh in validation errors; remove servers toolbar CSV export button.
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# 审计 — 推送后台化 + 422 中文化 + 隐藏 CSV 导出(2026-06-08)
|
||||||
|
|
||||||
|
**Changelog**:
|
||||||
|
- `docs/changelog/2026-06-08-push-async-background.md`
|
||||||
|
- `docs/changelog/2026-06-08-validation-error-loc-zh.md`
|
||||||
|
- `docs/changelog/2026-06-08-servers-hide-csv-export.md`
|
||||||
|
|
||||||
|
## 审计范围
|
||||||
|
|
||||||
|
| 文件 | 状态 |
|
||||||
|
|------|------|
|
||||||
|
| `server/background/sync_push_runner.py` | ☑ |
|
||||||
|
| `server/api/sync_v2.py` | ☑ |
|
||||||
|
| `server/utils/validation_errors_zh.py` | ☑ |
|
||||||
|
| `frontend/src/composables/push/usePushProgress.ts` | ☑ |
|
||||||
|
| `frontend/src/components/push/PushToolbar.vue` | ☑ |
|
||||||
|
| `frontend/src/pages/PushPage.vue` | ☑ |
|
||||||
|
| `frontend/src/utils/validationErrorFormat.ts` | ☑ |
|
||||||
|
| `frontend/src/api/index.ts` | ☑ |
|
||||||
|
| `frontend/src/utils/apiError.ts` | ☑ |
|
||||||
|
| `frontend/src/pages/ServersPage.vue` | ☑ |
|
||||||
|
| `tests/test_validation_errors_zh.py` | ☑ |
|
||||||
|
|
||||||
|
## Step 3
|
||||||
|
|
||||||
|
| 项 | 结论 |
|
||||||
|
|----|------|
|
||||||
|
| 推送后台任务独立 DB 会话 | PASS |
|
||||||
|
| 422 loc_zh 不泄露内部路径 | PASS |
|
||||||
|
| 隐藏 CSV 仅 UI 移除 | PASS |
|
||||||
|
|
||||||
|
## Closure
|
||||||
|
|
||||||
|
Gate 7/7 待跑 · 0 FINDING
|
||||||
|
|
||||||
|
## DoD
|
||||||
|
|
||||||
|
- [x] 推送按钮不长时间 loading
|
||||||
|
- [x] 校验错误显示「每页条数:不能大于 200」
|
||||||
|
- [x] 服务器页无导出 CSV 按钮
|
||||||
|
- [ ] 生产部署与健康检查
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 2026-06-08 — 推送按钮改为后台任务提交
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
文件推送不再阻塞 HTTP 直至全部子机完成;点击「推送」后按钮仅短暂 loading,任务在后台执行,进度由 WebSocket + 下方进度列表展示。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
用户反馈推送按钮长时间转圈,期望与脚本执行类似:提交后立即进入后台任务。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
`POST /api/sync/files` 同步等待 `sync_engine_v2.sync_files` 全部 rsync 结束才返回;前端 `:loading="pushing"` 与 HTTP 阻塞绑定。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `server/background/sync_push_runner.py`(新)
|
||||||
|
- `server/api/sync_v2.py` — 校验后 `schedule_sync_files`,返回 `accepted: true`
|
||||||
|
- `frontend/src/composables/push/usePushProgress.ts` — `submitting` 与 toast
|
||||||
|
- `frontend/src/components/push/PushToolbar.vue`
|
||||||
|
- `frontend/src/pages/PushPage.vue`
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
- 需重启 API / 重建镜像
|
||||||
|
- 前端需 build
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. 推送页选多台子机 → 点推送:按钮转圈仅数秒内结束,出现「推送已提交」toast,下方进度条持续更新
|
||||||
|
2. `pytest tests/test_sync_batch_id.py -q`
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 2026-06-08 — 隐藏服务器页 CSV 导出
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
移除服务器列表页工具栏「导出 CSV」按钮及相关前端逻辑。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
按产品要求暂时隐藏该入口(曾触发 `per_page` 超限 422)。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `frontend/src/pages/ServersPage.vue`
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
构建后打开 `#/servers`,工具栏无「导出 CSV」按钮。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 2026-06-08 — 422 校验错误字段名中文化
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
API 422 响应增加 `loc_zh`(如「每页条数」),前端不再显示 `query.per_page: 不能大于 200` 这类英文路径前缀。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
用户看到半英文校验提示;根因是 `msg` 已翻译但 `loc` 仍拼接 `query.` 前缀。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `server/utils/validation_errors_zh.py`
|
||||||
|
- `frontend/src/utils/validationErrorFormat.ts`(新)
|
||||||
|
- `frontend/src/api/index.ts`、`frontend/src/utils/apiError.ts`
|
||||||
|
- `frontend/src/pages/ServersPage.vue` — CSV 导出改为分页拉取(避免 `per_page: 5000` 触发 422)
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_validation_errors_zh.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
用户可见示例:`每页条数:不能大于 200`
|
||||||
@@ -2,29 +2,14 @@
|
|||||||
* api/index.ts — HTTP client with JWT + HttpOnly refresh cookie.
|
* api/index.ts — HTTP client with JWT + HttpOnly refresh cookie.
|
||||||
*/
|
*/
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatValidationDetail } from '@/utils/validationErrorFormat'
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
|
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
|
||||||
|
|
||||||
const AUTH_SKIP_REFRESH = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
|
const AUTH_SKIP_REFRESH = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
|
||||||
|
|
||||||
function normalizeDetail(detail: unknown, fallback: string): string {
|
function normalizeDetail(detail: unknown, fallback: string): string {
|
||||||
let msg = ''
|
return formatValidationDetail(detail, fallback)
|
||||||
if (typeof detail === 'string') msg = detail
|
|
||||||
else if (Array.isArray(detail)) {
|
|
||||||
msg = detail
|
|
||||||
.map((x: { msg?: string; loc?: unknown[] }) => {
|
|
||||||
const part = x?.msg || JSON.stringify(x)
|
|
||||||
const loc = Array.isArray(x.loc)
|
|
||||||
? x.loc.filter((p) => p !== 'body').join('.')
|
|
||||||
: ''
|
|
||||||
return loc ? `${loc}: ${part}` : part
|
|
||||||
})
|
|
||||||
.join('; ')
|
|
||||||
} else if (detail && typeof detail === 'object') {
|
|
||||||
msg = JSON.stringify(detail)
|
|
||||||
}
|
|
||||||
msg = msg.trim()
|
|
||||||
return msg || fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let refreshInFlight: Promise<boolean> | null = null
|
let refreshInFlight: Promise<boolean> | null = null
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
prepend-icon="mdi-upload"
|
prepend-icon="mdi-upload"
|
||||||
:loading="pushing"
|
:loading="submitting"
|
||||||
|
:disabled="pushing"
|
||||||
@click="$emit('push')"
|
@click="$emit('push')"
|
||||||
>
|
>
|
||||||
推送
|
推送
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
targetPath: string
|
targetPath: string
|
||||||
|
submitting: boolean
|
||||||
pushing: boolean
|
pushing: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||||
import type { usePushForm } from './usePushForm'
|
import type { usePushForm } from './usePushForm'
|
||||||
import type { usePushServers } from './usePushServers'
|
import type { usePushServers } from './usePushServers'
|
||||||
|
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||||
import { formatPushErrorMessage } from './labels'
|
import { formatPushErrorMessage } from './labels'
|
||||||
import {
|
import {
|
||||||
TERMINAL_PROGRESS_STATUSES,
|
TERMINAL_PROGRESS_STATUSES,
|
||||||
@@ -17,6 +18,7 @@ type PushForm = ReturnType<typeof usePushForm>
|
|||||||
type PushServers = ReturnType<typeof usePushServers>
|
type PushServers = ReturnType<typeof usePushServers>
|
||||||
|
|
||||||
type PushFilesResponse = {
|
type PushFilesResponse = {
|
||||||
|
accepted?: boolean
|
||||||
batch_id?: string
|
batch_id?: string
|
||||||
results?: Record<
|
results?: Record<
|
||||||
string,
|
string,
|
||||||
@@ -42,6 +44,7 @@ export function usePushProgress(
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const pushing = ref(false)
|
const pushing = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
const cancelling = ref(false)
|
const cancelling = ref(false)
|
||||||
const retryingAll = ref(false)
|
const retryingAll = ref(false)
|
||||||
const wsProgressItems = ref<ServerProgress[]>([])
|
const wsProgressItems = ref<ServerProgress[]>([])
|
||||||
@@ -227,7 +230,7 @@ export function usePushProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doPush() {
|
async function doPush() {
|
||||||
if (pushing.value) return
|
if (pushing.value || submitting.value) return
|
||||||
const selected = [...servers.selectedIds.value]
|
const selected = [...servers.selectedIds.value]
|
||||||
.map(id => servers.servers.value.find(s => s.id === id))
|
.map(id => servers.servers.value.find(s => s.id === id))
|
||||||
.filter((s): s is NonNullable<typeof s> => s != null)
|
.filter((s): s is NonNullable<typeof s> => s != null)
|
||||||
@@ -245,6 +248,7 @@ export function usePushProgress(
|
|||||||
})
|
})
|
||||||
|
|
||||||
pushing.value = true
|
pushing.value = true
|
||||||
|
submitting.value = true
|
||||||
pushBatchId.value = newPushBatchId()
|
pushBatchId.value = newPushBatchId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -259,7 +263,11 @@ export function usePushProgress(
|
|||||||
if (res.batch_id && res.batch_id !== pushBatchId.value) {
|
if (res.batch_id && res.batch_id !== pushBatchId.value) {
|
||||||
pushBatchId.value = res.batch_id
|
pushBatchId.value = res.batch_id
|
||||||
}
|
}
|
||||||
applyPushResultsFromHttp(res)
|
if (res.accepted) {
|
||||||
|
showScriptSubmitToast(`推送已提交(${ids.length} 台),请在下方查看进度`)
|
||||||
|
} else {
|
||||||
|
applyPushResultsFromHttp(res)
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
disconnectProgressWs()
|
disconnectProgressWs()
|
||||||
const msg = e instanceof Error ? e.message : '推送请求失败'
|
const msg = e instanceof Error ? e.message : '推送请求失败'
|
||||||
@@ -271,6 +279,8 @@ export function usePushProgress(
|
|||||||
snackbar(msg, 'error')
|
snackbar(msg, 'error')
|
||||||
onPushFinished()
|
onPushFinished()
|
||||||
return
|
return
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const safetyTimer = setTimeout(() => {
|
const safetyTimer = setTimeout(() => {
|
||||||
@@ -332,6 +342,7 @@ export function usePushProgress(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pushing,
|
pushing,
|
||||||
|
submitting,
|
||||||
cancelling,
|
cancelling,
|
||||||
retryingAll,
|
retryingAll,
|
||||||
wsProgressItems,
|
wsProgressItems,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
|
|
||||||
<PushToolbar
|
<PushToolbar
|
||||||
v-model:target-path="form.targetPath"
|
v-model:target-path="form.targetPath"
|
||||||
|
:submitting="progress.submitting"
|
||||||
:pushing="progress.pushing"
|
:pushing="progress.pushing"
|
||||||
@preview="preview.doPreview"
|
@preview="preview.doPreview"
|
||||||
@push="progress.doPush"
|
@push="progress.doPush"
|
||||||
|
|||||||
@@ -25,9 +25,6 @@
|
|||||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
|
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
|
||||||
快速添加(IP)
|
快速添加(IP)
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-download" size="small" @click="exportCSV">
|
|
||||||
导出CSV
|
|
||||||
</v-btn>
|
|
||||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
|
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
|
||||||
批量添加
|
批量添加
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -599,7 +596,6 @@ import { useSnackbar } from '@/composables/useSnackbar'
|
|||||||
import { useServerPagination } from '@/composables/useServerPagination'
|
import { useServerPagination } from '@/composables/useServerPagination'
|
||||||
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
|
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
|
||||||
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
||||||
import { beijingTodayYmd } from '@/utils/datetime'
|
|
||||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||||
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
|
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
|
||||||
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
||||||
@@ -1358,28 +1354,6 @@ async function confirmBatchDelete(scope: BatchScope) {
|
|||||||
loadStats()
|
loadStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CSV export ──
|
|
||||||
async function exportCSV() {
|
|
||||||
try {
|
|
||||||
const res = await http.getList<ServerApiItem>('/servers/', { per_page: 5000 })
|
|
||||||
const headers = ['ID', '名称', '地址', '端口', '用户名', '分类', '状态', 'Agent版本', '最后心跳']
|
|
||||||
const rows = (res.items || []).map(s => [
|
|
||||||
s.id, s.name, s.domain, s.port, s.username, s.category || '', s.status,
|
|
||||||
s.agent_version || '', s.last_heartbeat || '',
|
|
||||||
])
|
|
||||||
const bom = ''
|
|
||||||
const csv = bom + [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n')
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `servers_${beijingTodayYmd()}.csv`
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
snackbar('导出完成')
|
|
||||||
} catch (e: any) { snackbar(e.message || '导出失败', 'error') }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Batch add by IP ──
|
// ── Batch add by IP ──
|
||||||
const showBatchAdd = ref(false)
|
const showBatchAdd = ref(false)
|
||||||
const batchAddText = ref('')
|
const batchAddText = ref('')
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { ApiError } from '@/api'
|
import { ApiError } from '@/api'
|
||||||
|
import { formatValidationDetail } from '@/utils/validationErrorFormat'
|
||||||
|
|
||||||
/** Turn FastAPI error bodies into a human-readable string. */
|
/** Turn FastAPI error bodies into a human-readable string. */
|
||||||
export function formatApiError(e: unknown, fallback: string): string {
|
export function formatApiError(e: unknown, fallback: string): string {
|
||||||
let msg = fallback
|
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
const d = (e as ApiError & { detail?: unknown }).detail ?? e.message
|
const d = (e as ApiError & { detail?: unknown }).detail ?? e.message
|
||||||
if (typeof d === 'string') msg = d
|
return formatValidationDetail(d, e.message || fallback)
|
||||||
else if (Array.isArray(d)) {
|
|
||||||
msg = d.map((x: { msg?: string; loc?: unknown[] }) => {
|
|
||||||
const part = x?.msg || JSON.stringify(x)
|
|
||||||
const loc = Array.isArray(x.loc) ? x.loc.filter((p) => p !== 'body').join('.') : ''
|
|
||||||
return loc ? `${loc}: ${part}` : part
|
|
||||||
}).join('; ')
|
|
||||||
} else if (d && typeof d === 'object') msg = JSON.stringify(d)
|
|
||||||
else msg = e.message || fallback
|
|
||||||
} else if (e instanceof Error) {
|
|
||||||
msg = e.message || fallback
|
|
||||||
}
|
}
|
||||||
msg = msg.trim()
|
if (e instanceof Error) {
|
||||||
return msg || fallback
|
const msg = e.message.trim()
|
||||||
|
return msg || fallback
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Format FastAPI 422 validation detail items for display (Chinese). */
|
||||||
|
|
||||||
|
const LOC_PREFIX_SKIP = new Set(['body', 'query', 'path', 'header', 'cookie'])
|
||||||
|
|
||||||
|
export function formatValidationDetailItem(x: {
|
||||||
|
msg?: string
|
||||||
|
loc?: unknown[]
|
||||||
|
loc_zh?: string
|
||||||
|
}): string {
|
||||||
|
const part = (x?.msg || '').trim()
|
||||||
|
if (x?.loc_zh) {
|
||||||
|
return part ? `${x.loc_zh}:${part}` : x.loc_zh
|
||||||
|
}
|
||||||
|
const loc = Array.isArray(x?.loc)
|
||||||
|
? x.loc.filter((p) => !LOC_PREFIX_SKIP.has(String(p)))
|
||||||
|
: []
|
||||||
|
const locStr = loc
|
||||||
|
.map((p) => (typeof p === 'number' ? `第${p + 1}项` : String(p)))
|
||||||
|
.join('.')
|
||||||
|
if (locStr && part) return `${locStr}:${part}`
|
||||||
|
return part || locStr || JSON.stringify(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValidationDetail(detail: unknown, fallback: string): string {
|
||||||
|
if (typeof detail === 'string') {
|
||||||
|
const s = detail.trim()
|
||||||
|
return s || fallback
|
||||||
|
}
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
const msg = detail.map((x) => formatValidationDetailItem(x as { msg?: string; loc?: unknown[]; loc_zh?: string })).join(';')
|
||||||
|
return msg.trim() || fallback
|
||||||
|
}
|
||||||
|
if (detail && typeof detail === 'object') {
|
||||||
|
return JSON.stringify(detail)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
+34
-5
@@ -5,6 +5,7 @@ P1: POST /api/sync/preview — rsync dry-run (方案D: 智能提示预览)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
@@ -77,18 +78,46 @@ async def sync_files(
|
|||||||
request: Request,
|
request: Request,
|
||||||
admin: Admin = Depends(get_current_admin),
|
admin: Admin = Depends(get_current_admin),
|
||||||
):
|
):
|
||||||
"""S1: File sync via rsync over SSH (with parallel execution)"""
|
"""S1: Accept file push and run rsync in background (progress via /ws/sync)."""
|
||||||
|
from server.application.services.sync_engine_v2 import _nexus_source_path
|
||||||
|
from server.background.sync_push_runner import schedule_sync_files
|
||||||
|
|
||||||
engine = await _get_sync_engine(request)
|
engine = await _get_sync_engine(request)
|
||||||
return await engine.sync_files(
|
try:
|
||||||
server_ids=payload.server_ids,
|
source_path = _nexus_source_path(payload.source_path)
|
||||||
source_path=payload.source_path,
|
except PosixPathError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
servers = await engine.server_repo.get_by_ids(payload.server_ids)
|
||||||
|
if not servers:
|
||||||
|
raise HTTPException(status_code=400, detail="未选择有效服务器")
|
||||||
|
found_ids = {s.id for s in servers}
|
||||||
|
missing = [sid for sid in payload.server_ids if sid not in found_ids]
|
||||||
|
if missing:
|
||||||
|
preview = ", ".join(str(x) for x in missing[:5])
|
||||||
|
suffix = f" 等 {len(missing)} 台" if len(missing) > 5 else ""
|
||||||
|
raise HTTPException(status_code=400, detail=f"服务器不存在: {preview}{suffix}")
|
||||||
|
|
||||||
|
batch_id = (payload.batch_id or uuid.uuid4().hex[:12])[:12]
|
||||||
|
schedule_sync_files(
|
||||||
|
server_ids=list(payload.server_ids),
|
||||||
|
source_path=source_path,
|
||||||
target_path=payload.target_path,
|
target_path=payload.target_path,
|
||||||
sync_mode=payload.sync_mode,
|
sync_mode=payload.sync_mode,
|
||||||
operator=admin.username,
|
operator=admin.username,
|
||||||
batch_size=payload.batch_size,
|
batch_size=payload.batch_size,
|
||||||
concurrency=payload.concurrency,
|
concurrency=payload.concurrency,
|
||||||
batch_id=payload.batch_id,
|
batch_id=batch_id,
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
"accepted": True,
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"total": len(servers),
|
||||||
|
"completed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
"results": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/preview")
|
@router.post("/preview")
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Background file push — API returns immediately; progress via WebSocket."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("nexus.sync_push_runner")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_sync_files_background(
|
||||||
|
*,
|
||||||
|
server_ids: List[int],
|
||||||
|
source_path: str,
|
||||||
|
target_path: Optional[str],
|
||||||
|
sync_mode: str,
|
||||||
|
operator: str,
|
||||||
|
batch_size: int,
|
||||||
|
concurrency: int,
|
||||||
|
batch_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Execute sync_files in an isolated DB session (not request-scoped)."""
|
||||||
|
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||||
|
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||||
|
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||||
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||||
|
from server.infrastructure.database.session import AsyncSessionLocal
|
||||||
|
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
engine = SyncEngineV2(
|
||||||
|
server_repo=ServerRepositoryImpl(session),
|
||||||
|
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||||
|
audit_repo=AuditLogRepositoryImpl(session),
|
||||||
|
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||||
|
)
|
||||||
|
await engine.sync_files(
|
||||||
|
server_ids=server_ids,
|
||||||
|
source_path=source_path,
|
||||||
|
target_path=target_path,
|
||||||
|
sync_mode=sync_mode,
|
||||||
|
operator=operator,
|
||||||
|
batch_size=batch_size,
|
||||||
|
concurrency=concurrency,
|
||||||
|
batch_id=batch_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Background sync_files failed: batch_id=%s", batch_id)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_sync_files(**kwargs) -> None:
|
||||||
|
"""Fire-and-forget push task on the running event loop."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.create_task(
|
||||||
|
run_sync_files_background(**kwargs),
|
||||||
|
name=f"sync_push:{kwargs.get('batch_id', '')}",
|
||||||
|
)
|
||||||
@@ -79,11 +79,58 @@ def translate_validation_msg(msg: str, err_type: str = "", ctx: dict[str, Any] |
|
|||||||
return "必须是数字"
|
return "必须是数字"
|
||||||
if err_type == "bool_parsing":
|
if err_type == "bool_parsing":
|
||||||
return "必须是 true 或 false"
|
return "必须是 true 或 false"
|
||||||
|
if err_type == "less_than_equal" and ctx.get("le") is not None:
|
||||||
|
return f"不能大于 {ctx['le']}"
|
||||||
|
if err_type == "greater_than_equal" and ctx.get("ge") is not None:
|
||||||
|
return f"不能小于 {ctx['ge']}"
|
||||||
|
if err_type == "less_than" and ctx.get("lt") is not None:
|
||||||
|
return f"必须小于 {ctx['lt']}"
|
||||||
|
if err_type == "greater_than" and ctx.get("gt") is not None:
|
||||||
|
return f"必须大于 {ctx['gt']}"
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
_LOC_PREFIX_SKIP = frozenset({"query", "body", "path", "header", "cookie"})
|
||||||
|
|
||||||
|
_FIELD_ZH: dict[str, str] = {
|
||||||
|
"per_page": "每页条数",
|
||||||
|
"page": "页码",
|
||||||
|
"limit": "条数",
|
||||||
|
"offset": "偏移量",
|
||||||
|
"server_ids": "服务器",
|
||||||
|
"source_path": "源路径",
|
||||||
|
"target_path": "目标路径",
|
||||||
|
"sync_mode": "同步模式",
|
||||||
|
"batch_id": "批次 ID",
|
||||||
|
"batch_size": "批次大小",
|
||||||
|
"concurrency": "并发数",
|
||||||
|
"value": "值",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"name": "名称",
|
||||||
|
"cron_expr": "Cron 表达式",
|
||||||
|
"fire_at": "执行时间",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate_loc_zh(loc: Any) -> str:
|
||||||
|
"""Human-readable Chinese field path (no query/body prefixes)."""
|
||||||
|
if not isinstance(loc, (list, tuple)):
|
||||||
|
return "参数"
|
||||||
|
parts: list[str] = []
|
||||||
|
for piece in loc:
|
||||||
|
if isinstance(piece, str) and piece in _LOC_PREFIX_SKIP:
|
||||||
|
continue
|
||||||
|
if isinstance(piece, int):
|
||||||
|
parts.append(f"第 {piece + 1} 项")
|
||||||
|
continue
|
||||||
|
key = str(piece)
|
||||||
|
parts.append(_FIELD_ZH.get(key, key))
|
||||||
|
return ".".join(parts) if parts else "参数"
|
||||||
|
|
||||||
|
|
||||||
def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Clone Pydantic error dicts with translated ``msg``."""
|
"""Clone Pydantic error dicts with translated ``msg`` and ``loc_zh``."""
|
||||||
translated: list[dict[str, Any]] = []
|
translated: list[dict[str, Any]] = []
|
||||||
for err in errors:
|
for err in errors:
|
||||||
item = dict(err)
|
item = dict(err)
|
||||||
@@ -92,5 +139,6 @@ def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str,
|
|||||||
str(err.get("type", "")),
|
str(err.get("type", "")),
|
||||||
err.get("ctx") if isinstance(err.get("ctx"), dict) else None,
|
err.get("ctx") if isinstance(err.get("ctx"), dict) else None,
|
||||||
)
|
)
|
||||||
|
item["loc_zh"] = translate_loc_zh(err.get("loc"))
|
||||||
translated.append(item)
|
translated.append(item)
|
||||||
return translated
|
return translated
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from server.api.schemas import SettingUpdatePayload
|
from server.api.schemas import SettingUpdatePayload
|
||||||
from server.utils.validation_errors_zh import translate_validation_msg, translate_validation_errors
|
from server.utils.validation_errors_zh import (
|
||||||
|
translate_loc_zh,
|
||||||
|
translate_validation_msg,
|
||||||
|
translate_validation_errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_translate_exact_string_type():
|
def test_translate_exact_string_type():
|
||||||
@@ -24,6 +28,24 @@ def test_translate_validation_errors_list():
|
|||||||
out = translate_validation_errors(errors)
|
out = translate_validation_errors(errors)
|
||||||
assert out[0]["msg"] == "必须是字符串"
|
assert out[0]["msg"] == "必须是字符串"
|
||||||
assert out[0]["loc"] == ["body", "value"]
|
assert out[0]["loc"] == ["body", "value"]
|
||||||
|
assert out[0]["loc_zh"] == "值"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_loc_zh_query_per_page():
|
||||||
|
assert translate_loc_zh(["query", "per_page"]) == "每页条数"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_per_page_le_via_ctx():
|
||||||
|
out = translate_validation_errors([
|
||||||
|
{
|
||||||
|
"type": "less_than_equal",
|
||||||
|
"loc": ["query", "per_page"],
|
||||||
|
"msg": "Input should be less than or equal to 200",
|
||||||
|
"ctx": {"le": 200},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
assert out[0]["msg"] == "不能大于 200"
|
||||||
|
assert out[0]["loc_zh"] == "每页条数"
|
||||||
|
|
||||||
|
|
||||||
def test_setting_update_payload_coerces_int():
|
def test_setting_update_payload_coerces_int():
|
||||||
|
|||||||
Reference in New Issue
Block a user