feat(设备日志/后台配置/密钥检测): 补齐已上线未提交的设备日志与app_config,并合入检测模型解耦
三部分均已部署到双节点(当前线上 JAR 009efc3e),本次补齐仓库状态,避免"已上线未提交" 在后续最小构建比对里被误判。 - 设备日志管理:modules/devicelog + DeviceLogOssProperties(主机B 独立 MinIO,仅内网) + V127 device_log_file/device_log_config + admin-vue 日志管理页;客户端/麦象按 offset 增量上报(X-Internal-Token),查询仅超管 - 后台通用配置:modules/appconfig + V128 app_config 键值表;工作台「开店流程」密码改服务端 校验(POST /api/kd-flow/verify),改密码只需 UPDATE 该行、客户端无需重新发版 - 密钥检测模型解耦:新增 aiimage.user-secret.check-model(env AIIMAGE_USER_SECRET_CHECK_MODEL), 默认 doubao-seed-2-0-lite-260215 —— 同系列 mini 在中继分组下无可用渠道(503 model_not_found), 实测 lite 可路由;UserSecretModule 去掉 LlmTarget 改 resolveLlmHost,探测日志带 model= - 前端:密钥面板「检测配置密钥」按钮不再折行;client-changelog 补 4.0.19/4.0.20/4.0.21 条目 注:application.yml 与 PropertiesConfig 同时承载上述多个部分,故未按功能拆分提交
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
/** 日志文件行(桌面客户端 / 麦象采集机上报)。 */
|
||||
export interface DeviceLogFileRow {
|
||||
id: number
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
username: string | null
|
||||
uid: number | null
|
||||
fileName: string
|
||||
logDate: string
|
||||
uploadedBytes: number
|
||||
partCount: number
|
||||
lastUploadAt: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogPage {
|
||||
items: DeviceLogFileRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 云端日志保留天数(页面提示用)。 */
|
||||
retentionDays: number
|
||||
}
|
||||
|
||||
export interface DeviceLogContent {
|
||||
fileId: number
|
||||
fileName: string
|
||||
content: string
|
||||
totalBytes: number
|
||||
shownBytes: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface DeviceLogDevice {
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
lastUploadAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogOverride {
|
||||
id: number
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
mode: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogConfigData {
|
||||
globalMode: string
|
||||
overrides: DeviceLogOverride[]
|
||||
}
|
||||
|
||||
export interface DeviceLogQuery {
|
||||
source?: string
|
||||
keyword?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询日志文件列表:GET /api/admin/device-logs/files */
|
||||
export async function fetchDeviceLogFiles(params: DeviceLogQuery): Promise<DeviceLogPage> {
|
||||
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||
if (params.source) query.source = params.source
|
||||
if (params.keyword) query.keyword = params.keyword
|
||||
if (params.startDate) query.startDate = params.startDate
|
||||
if (params.endDate) query.endDate = params.endDate
|
||||
const { data } = await http.get('/api/admin/device-logs/files', { params: query })
|
||||
return unwrap<DeviceLogPage>(data)
|
||||
}
|
||||
|
||||
/** 查看日志尾部内容:GET /api/admin/device-logs/content */
|
||||
export async function fetchDeviceLogContent(fileId: number, maxBytes?: number): Promise<DeviceLogContent> {
|
||||
const query: Record<string, number> = { fileId }
|
||||
if (maxBytes) query.maxBytes = maxBytes
|
||||
const { data } = await http.get('/api/admin/device-logs/content', { params: query })
|
||||
return unwrap<DeviceLogContent>(data)
|
||||
}
|
||||
|
||||
/** 完整日志下载地址(同域 cookie 鉴权,直接给 a[href] 或 window.open 用)。 */
|
||||
export function deviceLogDownloadUrl(fileId: number): string {
|
||||
return `/api/admin/device-logs/download?fileId=${fileId}`
|
||||
}
|
||||
|
||||
/** 删除日志文件(片段与元数据,不可恢复):DELETE /api/admin/device-logs/{id} */
|
||||
export async function deleteDeviceLogFile(id: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/device-logs/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 采集配置(全局默认 + 终端覆盖):GET /api/admin/device-logs/config */
|
||||
export async function fetchDeviceLogConfig(keyword?: string): Promise<DeviceLogConfigData> {
|
||||
const { data } = await http.get('/api/admin/device-logs/config', {
|
||||
params: keyword ? { keyword } : undefined,
|
||||
})
|
||||
return unwrap<DeviceLogConfigData>(data)
|
||||
}
|
||||
|
||||
/** 最近上报过的终端(覆盖选择用):GET /api/admin/device-logs/devices */
|
||||
export async function fetchDeviceLogDevices(): Promise<DeviceLogDevice[]> {
|
||||
const { data } = await http.get('/api/admin/device-logs/devices')
|
||||
return unwrap<DeviceLogDevice[]>(data)
|
||||
}
|
||||
|
||||
/** 设置全局采集模式:PUT /api/admin/device-logs/config/global */
|
||||
export async function updateDeviceLogGlobalMode(mode: string): Promise<void> {
|
||||
const { data } = await http.put('/api/admin/device-logs/config/global', undefined, { params: { mode } })
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 设置/更新终端覆盖:PUT /api/admin/device-logs/config/device */
|
||||
export async function updateDeviceLogOverride(
|
||||
source: string,
|
||||
deviceId: string,
|
||||
deviceName: string | null,
|
||||
mode: string,
|
||||
): Promise<void> {
|
||||
const { data } = await http.put('/api/admin/device-logs/config/device', undefined, {
|
||||
params: { source, deviceId, deviceName: deviceName || undefined, mode },
|
||||
})
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 删除终端覆盖(回落到全局默认):DELETE /api/admin/device-logs/config/device/{id} */
|
||||
export async function deleteDeviceLogOverride(id: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/device-logs/config/device/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
<script setup lang="ts">
|
||||
/** 日志管理页:桌面客户端 / 麦象采集机上报的日志浏览(仅超管)。
|
||||
* 支持来源/日期/关键字筛选,尾部内容查看(自动滚到底、可向前加载更早内容)、
|
||||
* 完整下载、删除;「采集配置」可调全量/精选模式(全局默认 + 终端覆盖)。 */
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import {
|
||||
deleteDeviceLogFile,
|
||||
deleteDeviceLogOverride,
|
||||
deviceLogDownloadUrl,
|
||||
fetchDeviceLogConfig,
|
||||
fetchDeviceLogContent,
|
||||
fetchDeviceLogDevices,
|
||||
fetchDeviceLogFiles,
|
||||
updateDeviceLogGlobalMode,
|
||||
updateDeviceLogOverride,
|
||||
type DeviceLogContent,
|
||||
type DeviceLogDevice,
|
||||
type DeviceLogFileRow,
|
||||
type DeviceLogOverride,
|
||||
} from '@/api/device-logs'
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: '', label: '全部来源' },
|
||||
{ value: 'client', label: '桌面客户端' },
|
||||
{ value: 'maixiang', label: '麦象采集机' },
|
||||
]
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === 'client') return '桌面客户端'
|
||||
if (source === 'maixiang') return '麦象采集机'
|
||||
return source || '—'
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let value = bytes / 1024
|
||||
let unitIndex = 0
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<DeviceLogFileRow[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const sourceFilter = ref('')
|
||||
const keyword = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const retentionDays = ref(7)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchDeviceLogFiles({
|
||||
source: sourceFilter.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
rows.value = result?.items || []
|
||||
total.value = Number(result?.total || 0)
|
||||
if (result?.retentionDays) retentionDays.value = result.retentionDays
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '日志列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
sourceFilter.value = ''
|
||||
keyword.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 内容查看
|
||||
|
||||
const TAIL_STEP = 256 * 1024
|
||||
const TAIL_MAX = 8 * 1024 * 1024
|
||||
|
||||
const viewerVisible = ref(false)
|
||||
const viewerLoading = ref(false)
|
||||
const viewerFile = ref<DeviceLogFileRow | null>(null)
|
||||
const viewerContent = ref<DeviceLogContent | null>(null)
|
||||
const viewerMaxBytes = ref(TAIL_STEP)
|
||||
const viewerPre = ref<HTMLElement | null>(null)
|
||||
|
||||
async function openViewer(row: DeviceLogFileRow) {
|
||||
viewerFile.value = row
|
||||
viewerMaxBytes.value = TAIL_STEP
|
||||
viewerContent.value = null
|
||||
viewerVisible.value = true
|
||||
await loadContent(true)
|
||||
}
|
||||
|
||||
async function loadContent(scrollToBottom: boolean) {
|
||||
if (!viewerFile.value) return
|
||||
viewerLoading.value = true
|
||||
try {
|
||||
viewerContent.value = await fetchDeviceLogContent(viewerFile.value.id, viewerMaxBytes.value)
|
||||
if (scrollToBottom) {
|
||||
await nextTick()
|
||||
if (viewerPre.value) viewerPre.value.scrollTop = viewerPre.value.scrollHeight
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '日志内容加载失败')
|
||||
} finally {
|
||||
viewerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
viewerMaxBytes.value = Math.min(viewerMaxBytes.value * 2, TAIL_MAX)
|
||||
loadContent(false)
|
||||
}
|
||||
|
||||
async function remove(row: DeviceLogFileRow) {
|
||||
if (!window.confirm(`确认删除「${row.fileName}」(${row.deviceName || row.deviceId})的云端日志?删除后不可恢复。`)) return
|
||||
try {
|
||||
await deleteDeviceLogFile(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 采集配置
|
||||
|
||||
const configVisible = ref(false)
|
||||
const configLoading = ref(false)
|
||||
const globalMode = ref('full')
|
||||
const overrides = ref<DeviceLogOverride[]>([])
|
||||
const devices = ref<DeviceLogDevice[]>([])
|
||||
const newOverrideKey = ref('')
|
||||
const newOverrideMode = ref('selected')
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ value: 'full', label: '全量采集' },
|
||||
{ value: 'selected', label: '精选采集' },
|
||||
]
|
||||
|
||||
function modeLabel(mode: string): string {
|
||||
return mode === 'selected' ? '精选' : '全量'
|
||||
}
|
||||
|
||||
async function openConfig() {
|
||||
configVisible.value = true
|
||||
await loadConfig()
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
configLoading.value = true
|
||||
try {
|
||||
const data = await fetchDeviceLogConfig()
|
||||
globalMode.value = data.globalMode || 'full'
|
||||
overrides.value = data.overrides || []
|
||||
devices.value = (await fetchDeviceLogDevices()) || []
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '采集配置加载失败')
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGlobalMode(mode: string) {
|
||||
if (mode === globalMode.value) return
|
||||
try {
|
||||
await updateDeviceLogGlobalMode(mode)
|
||||
globalMode.value = mode
|
||||
ElMessage.success(`全局采集模式已切换为「${modeLabel(mode)}」`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function addOverride() {
|
||||
if (!newOverrideKey.value) {
|
||||
ElMessage.warning('请先选择终端')
|
||||
return
|
||||
}
|
||||
const [source, deviceId] = newOverrideKey.value.split('|')
|
||||
const device = devices.value.find((item) => item.source === source && item.deviceId === deviceId)
|
||||
try {
|
||||
await updateDeviceLogOverride(source, deviceId, device?.deviceName || null, newOverrideMode.value)
|
||||
ElMessage.success('终端覆盖已保存')
|
||||
newOverrideKey.value = ''
|
||||
await loadConfig()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOverride(row: DeviceLogOverride) {
|
||||
const who = row.deviceName || row.deviceId
|
||||
if (!window.confirm(`确认删除终端「${who}」的采集覆盖(回落到全局默认)?`)) return
|
||||
try {
|
||||
await deleteDeviceLogOverride(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await loadConfig()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-logs-view">
|
||||
<section class="panel-box">
|
||||
<div class="logs-head">
|
||||
<h3>日志文件列表</h3>
|
||||
<div class="logs-head-tools">
|
||||
<span class="retention-tip">云端仅保留 {{ retentionDays }} 天</span>
|
||||
<button class="btn btn-ghost" type="button" @click="openConfig">采集配置</button>
|
||||
<button class="btn" type="button" @click="load">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row logs-filter-row">
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>来源</label>
|
||||
<select v-model="sourceFilter">
|
||||
<option v-for="option in SOURCE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 220px">
|
||||
<label>设备 / 文件名</label>
|
||||
<input v-model="keyword" type="text" placeholder="模糊搜索设备名、设备ID、文件名" @keyup.enter="search" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>日志日期(起)</label>
|
||||
<input v-model="startDate" type="date" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>日志日期(止)</label>
|
||||
<input v-model="endDate" type="date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<div class="filter-actions">
|
||||
<button class="btn" type="button" @click="search">查询</button>
|
||||
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 110px">来源</th>
|
||||
<th style="width: 230px">设备</th>
|
||||
<th style="width: 130px">用户</th>
|
||||
<th style="width: 230px">文件名</th>
|
||||
<th style="width: 110px">日志日期</th>
|
||||
<th style="width: 100px">已收大小</th>
|
||||
<th style="width: 80px">片段数</th>
|
||||
<th style="width: 170px">最后更新</th>
|
||||
<th style="width: 190px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>
|
||||
<span class="source-pill" :class="row.source === 'maixiang' ? 'is-maixiang' : 'is-client'">
|
||||
{{ sourceLabel(row.source) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="device-name" :title="row.deviceId">{{ row.deviceName || '—' }}</span>
|
||||
<span class="device-id" :title="row.deviceId">{{ row.deviceId }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.username" class="user-name">{{ row.username }}</span>
|
||||
<span v-else-if="row.uid" class="user-name">UID {{ row.uid }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
|
||||
</td>
|
||||
<td>{{ row.logDate }}</td>
|
||||
<td>{{ formatBytes(row.uploadedBytes) }}</td>
|
||||
<td>{{ row.partCount ?? 0 }}</td>
|
||||
<td>{{ row.lastUploadAt ? formatDateTime(row.lastUploadAt) : '—' }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openViewer(row)">查看</button>
|
||||
<a class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(row.id)" download>下载</a>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="9" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="9" class="empty-tip">
|
||||
{{ keyword || sourceFilter || startDate || endDate ? '暂无匹配日志' : '暂无日志上报(客户端/采集机上报后自动出现在这里)' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="viewerVisible" :title="viewerFile ? `${viewerFile.fileName}(${viewerFile.deviceName || viewerFile.deviceId})` : '日志内容'" width="900px" top="5vh">
|
||||
<div class="viewer-toolbar">
|
||||
<span class="viewer-meta">
|
||||
云端已收 {{ formatBytes(viewerContent?.totalBytes ?? viewerFile?.uploadedBytes) }}
|
||||
<template v-if="viewerContent"> · 当前展示 {{ formatBytes(viewerContent.shownBytes) }}</template>
|
||||
<template v-if="viewerContent?.truncated"> · 更早内容未加载</template>
|
||||
</span>
|
||||
<span class="viewer-actions">
|
||||
<button class="btn btn-sm btn-ghost" type="button" :disabled="viewerLoading" @click="loadContent(true)">刷新</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
type="button"
|
||||
:disabled="viewerLoading || !viewerContent?.truncated || viewerMaxBytes >= TAIL_MAX"
|
||||
@click="loadMore"
|
||||
>加载更早</button>
|
||||
<a v-if="viewerFile" class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(viewerFile.id)" download>下载完整日志</a>
|
||||
</span>
|
||||
</div>
|
||||
<pre ref="viewerPre" class="log-pre">{{ viewerLoading && !viewerContent ? '加载中...' : (viewerContent?.content || '(暂无内容)') }}</pre>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="configVisible" title="采集配置" width="720px">
|
||||
<div class="config-block">
|
||||
<h4>全局默认模式</h4>
|
||||
<p class="config-desc">对未单独配置的终端生效。全量=上传日志目录内全部文件;精选=排除低价值大日志(客户端排除 pywebview;麦象排除 kk-browser / 控制台 / 测试日志)。终端在下一次上报周期(约 1 分钟内)跟随新配置。</p>
|
||||
<div class="mode-switch">
|
||||
<button
|
||||
v-for="option in MODE_OPTIONS"
|
||||
:key="option.value"
|
||||
class="mode-btn"
|
||||
:class="{ 'is-active': globalMode === option.value }"
|
||||
type="button"
|
||||
@click="saveGlobalMode(option.value)"
|
||||
>{{ option.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-block">
|
||||
<h4>终端覆盖</h4>
|
||||
<div class="override-add">
|
||||
<select v-model="newOverrideKey" class="override-select">
|
||||
<option value="">选择终端(最近上报的设备)</option>
|
||||
<option v-for="device in devices" :key="`${device.source}|${device.deviceId}`" :value="`${device.source}|${device.deviceId}`">
|
||||
{{ sourceLabel(device.source) }} · {{ device.deviceName || device.deviceId }}({{ device.deviceId }})
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="newOverrideMode">
|
||||
<option v-for="option in MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<button class="btn btn-sm" type="button" @click="addOverride">添加/更新覆盖</button>
|
||||
</div>
|
||||
<table class="override-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px">来源</th>
|
||||
<th>设备</th>
|
||||
<th style="width: 80px">模式</th>
|
||||
<th style="width: 160px">更新时间</th>
|
||||
<th style="width: 90px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in overrides" :key="row.id">
|
||||
<td>{{ sourceLabel(row.source) }}</td>
|
||||
<td>
|
||||
<span class="device-name">{{ row.deviceName || row.deviceId }}</span>
|
||||
<span class="device-id">{{ row.deviceId }}</span>
|
||||
</td>
|
||||
<td>{{ modeLabel(row.mode) }}</td>
|
||||
<td>{{ row.updatedAt ? formatDateTime(row.updatedAt) : '—' }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeOverride(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!overrides.length">
|
||||
<td colspan="5" class="empty-tip">{{ configLoading ? '加载中...' : '暂无终端覆盖(全部跟随全局默认)' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="configVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 沿用「记录与版本」系列的旧后台面板视觉语言。 */
|
||||
.device-logs-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.logs-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.retention-tip {
|
||||
color: #8598ab;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.logs-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: #ffffff;
|
||||
border-color: #c7d7e5;
|
||||
color: #4f78a5;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12.5px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.logs-table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
color: #5b6f83;
|
||||
font-weight: 650;
|
||||
font-size: 12.5px;
|
||||
border-bottom: 1px solid #dbe6f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eaf1f7;
|
||||
vertical-align: top;
|
||||
}
|
||||
.source-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.source-pill.is-client {
|
||||
background: #e8f1fb;
|
||||
color: #37618f;
|
||||
}
|
||||
.source-pill.is-maixiang {
|
||||
background: #eef7ec;
|
||||
color: #3f7a42;
|
||||
}
|
||||
.device-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
.device-id {
|
||||
display: block;
|
||||
color: #8598ab;
|
||||
font-size: 11.5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-name {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
}
|
||||
.dim {
|
||||
color: #9db0c2;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: none;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 26px 0;
|
||||
text-align: center;
|
||||
color: #8598ab;
|
||||
}
|
||||
.dl-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
.viewer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.viewer-meta {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.viewer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.log-pre {
|
||||
max-height: 62vh;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 10px;
|
||||
background: #0f1c29;
|
||||
color: #d7e4f1;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
}
|
||||
.config-block {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.config-block h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13.5px;
|
||||
color: #24384d;
|
||||
}
|
||||
.config-desc {
|
||||
margin: 0 0 10px;
|
||||
color: #66798d;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.mode-btn {
|
||||
min-height: 38px;
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 9px;
|
||||
background: #ffffff;
|
||||
color: #4f78a5;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode-btn.is-active {
|
||||
border-color: #4f78a5;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.override-add {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.override-add select {
|
||||
min-height: 38px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
}
|
||||
.override-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.override-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,7 @@ export const adminPages: AdminPageDef[] = [
|
||||
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
|
||||
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
|
||||
{ path: 'records/tutorial', menuKey: 'admin_tutorial', title: '教程管理', load: () => import('@/pages/records/RecordsTutorialPage.vue') },
|
||||
{ path: 'records/device-logs', menuKey: 'admin_device_logs', title: '日志管理', load: () => import('@/pages/records/DeviceLogsPage.vue') },
|
||||
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
|
||||
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
|
||||
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 设备日志对象存储配置:指向主机B 独立部署的 MinIO 实例(非业务 MinIO)。
|
||||
*
|
||||
* <p>日志体积大、只保留 7 天,独立实例便于单独设生命周期规则与容量管理,
|
||||
* 不挤占业务桶(nanri-ai-images 等)。endpoint 为空时上报接口直接失败,
|
||||
* 不做静默回退(避免日志悄悄落到其他存储上而无人知情)。
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "aiimage.device-log-oss")
|
||||
public class DeviceLogOssProperties {
|
||||
|
||||
private String endpoint;
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
private String bucket;
|
||||
|
||||
/**
|
||||
* 日志保留天数:查询侧按此过滤(早于今天的 N-1 天不展示),
|
||||
* 对象过期由 MinIO 桶生命周期规则在部署时同步设置(两侧口径保持一致)。
|
||||
*/
|
||||
private Integer retentionDays;
|
||||
|
||||
public boolean configured() {
|
||||
return endpoint != null && !endpoint.isBlank()
|
||||
&& accessKeyId != null && !accessKeyId.isBlank()
|
||||
&& accessKeySecret != null && !accessKeySecret.isBlank()
|
||||
&& bucket != null && !bucket.isBlank();
|
||||
}
|
||||
|
||||
public int retentionDaysOrDefault() {
|
||||
return retentionDays == null || retentionDays < 1 ? 7 : retentionDays;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class, NotificationProperties.class})
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class, NotificationProperties.class, DeviceLogOssProperties.class})
|
||||
public class PropertiesConfig {
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ public class UserSecretProperties {
|
||||
/** 单轮巡检时间预算(分钟),超时中断本轮。 */
|
||||
private int checkBudgetMinutes = 20;
|
||||
|
||||
/**
|
||||
* 检测请求使用的 LLM 模型:独立于业务任务模型(业务用 gemini-3.8-flash 等),
|
||||
* 选便宜的可用模型,只验证密钥有效性与链路连通,降低每次检测与巡检的成本。
|
||||
* 用 lite 而非 mini:mini 在中继分组下无可用渠道(503 model_not_found),实测 lite 可路由。
|
||||
*/
|
||||
private String checkModel = "doubao-seed-2-0-lite-260215";
|
||||
|
||||
/**
|
||||
* 检测出口代理提取链接(选配):配置后检测请求优先经该代理 IP 发出,
|
||||
* 代理不可用时自动回退直连;留空则全部直连。
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.nanri.aiimage.modules.appconfig.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.appconfig.service.KdFlowService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作台「开店流程」模块访问密码校验(公开接口,密码本身就是凭据,不额外要求登录态)。
|
||||
*
|
||||
* <p>客户端只在用户点开「开店流程」分组时调用一次;返回体只给 ok 与中文提示,
|
||||
* 不回显服务端配置的密码。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "开店流程访问校验", description = "工作台「开店流程」模块访问密码的服务端校验")
|
||||
public class KdFlowController {
|
||||
|
||||
private final KdFlowService kdFlowService;
|
||||
|
||||
@PostMapping("/api/kd-flow/verify")
|
||||
@Operation(summary = "校验开店流程访问密码",
|
||||
description = "密码存 app_config.kd_flow_password;改密码只需 UPDATE 该行,客户端无需重新发布")
|
||||
public ApiResponse<Map<String, Object>> verify(@RequestBody(required = false) Map<String, String> body,
|
||||
HttpServletRequest request) {
|
||||
String input = body == null ? null : body.get("password");
|
||||
boolean ok = kdFlowService.matches(input);
|
||||
// 只记输入长度与结果,绝不回显密码本身
|
||||
log.info("[开店流程] 校验请求 remoteAddr={} 输入为空={} 结果={}",
|
||||
request.getRemoteAddr(), input == null || input.isBlank(), ok ? "通过" : "拒绝");
|
||||
if (!ok) {
|
||||
return ApiResponse.fail("密码错误");
|
||||
}
|
||||
return ApiResponse.success("验证通过", Map.of("ok", true));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.appconfig.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.appconfig.model.entity.AppConfigEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AppConfigMapper extends BaseMapper<AppConfigEntity> {
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.appconfig.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 通用应用配置(键值)。首个用途:工作台「开店流程」模块访问密码(key = kd_flow_password)。
|
||||
* <p>只放这类低价值、需要"改一行即生效"的口令,不放密钥类敏感配置。
|
||||
*/
|
||||
@Data
|
||||
@TableName("app_config")
|
||||
public class AppConfigEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 配置键(唯一) */
|
||||
private String configKey;
|
||||
|
||||
/** 配置值 */
|
||||
private String configValue;
|
||||
|
||||
/** 说明 */
|
||||
private String remark;
|
||||
|
||||
/** 更新时间,由数据库 CURRENT_TIMESTAMP 维护 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.nanri.aiimage.modules.appconfig.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.modules.appconfig.mapper.AppConfigMapper;
|
||||
import com.nanri.aiimage.modules.appconfig.model.entity.AppConfigEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 工作台「开店流程」模块访问密码的服务端校验。
|
||||
*
|
||||
* <p>此前密码写死在客户端源码(KD_FLOW_PASSWORD),改密码必须重新打包装包发给全部用户;
|
||||
* 改由服务端比对后,改密码只需 UPDATE app_config 一行(key = kd_flow_password)。
|
||||
*
|
||||
* <p>不缓存:调用频次极低(用户点一次分组头一次),且改密码后应立即生效。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KdFlowService {
|
||||
|
||||
/** app_config 中存放开店流程访问密码的键名 */
|
||||
public static final String PASSWORD_KEY = "kd_flow_password";
|
||||
|
||||
private final AppConfigMapper appConfigMapper;
|
||||
|
||||
/** 读取服务端配置的密码;未配置返回 null。 */
|
||||
public String configuredPassword() {
|
||||
AppConfigEntity row = appConfigMapper.selectOne(new LambdaQueryWrapper<AppConfigEntity>()
|
||||
.eq(AppConfigEntity::getConfigKey, PASSWORD_KEY)
|
||||
.last("LIMIT 1"));
|
||||
return row == null ? null : row.getConfigValue();
|
||||
}
|
||||
|
||||
/** 校验用户输入的密码。未配置密码时一律判失败(宁可锁死也不放行)。 */
|
||||
public boolean matches(String input) {
|
||||
String expect = configuredPassword();
|
||||
if (expect == null || expect.isBlank()) {
|
||||
log.warn("[开店流程] app_config 未配置 {},本次校验一律判失败", PASSWORD_KEY);
|
||||
return false;
|
||||
}
|
||||
String actual = input == null ? "" : input.trim();
|
||||
boolean ok = expect.equals(actual);
|
||||
log.info("[开店流程] 服务端校验 输入长度={} 结果={}", actual.length(), ok ? "通过" : "不通过");
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.nanri.aiimage.modules.devicelog.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.common.security.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.devicelog.model.entity.DeviceLogConfigEntity;
|
||||
import com.nanri.aiimage.modules.devicelog.model.entity.DeviceLogFileEntity;
|
||||
import com.nanri.aiimage.modules.devicelog.model.vo.DeviceLogContentVo;
|
||||
import com.nanri.aiimage.modules.devicelog.model.vo.DeviceLogPageVo;
|
||||
import com.nanri.aiimage.modules.devicelog.service.DeviceLogConfigService;
|
||||
import com.nanri.aiimage.modules.devicelog.service.DeviceLogService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 后台「日志管理」:桌面客户端与麦象采集机的日志浏览(仅超管)。
|
||||
* 列表/内容/下载/删除 + 采集配置(全局默认与终端覆盖)。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@RequestMapping("/api/admin/device-logs")
|
||||
@Tag(name = "日志管理(后台)", description = "设备日志列表、内容查看、下载与采集配置(仅超管)。")
|
||||
public class AdminDeviceLogController {
|
||||
|
||||
private final DeviceLogService deviceLogService;
|
||||
private final DeviceLogConfigService deviceLogConfigService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping("/files")
|
||||
@Operation(summary = "日志文件分页列表",
|
||||
description = "source=client/maixiang;keyword 模糊匹配设备名/设备ID/文件名;日期为闭区间(早于保留窗口自动收紧)。")
|
||||
public ApiResponse<DeviceLogPageVo> files(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "来源") @RequestParam(required = false) String source,
|
||||
@Parameter(description = "关键字(设备/文件名)") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "起始日期(含)") @RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(含)") @RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(defaultValue = "1") Long page,
|
||||
@RequestParam(defaultValue = "20") Long pageSize) {
|
||||
requireSuperAdmin(request);
|
||||
return ApiResponse.success(deviceLogService.page(source, keyword, startDate, endDate, page, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/content")
|
||||
@Operation(summary = "查看日志尾部内容", description = "默认取最后 256KB(自最早行边界起);truncated=true 时可用更大 maxBytes 再取。")
|
||||
public ApiResponse<DeviceLogContentVo> content(
|
||||
HttpServletRequest request,
|
||||
@RequestParam Long fileId,
|
||||
@Parameter(description = "期望返回的明文字节数(16KB ~ 8MB)") @RequestParam(required = false) Long maxBytes) {
|
||||
requireSuperAdmin(request);
|
||||
return ApiResponse.success(deviceLogService.readTail(fileId, maxBytes));
|
||||
}
|
||||
|
||||
@GetMapping("/download")
|
||||
@Operation(summary = "下载完整日志(按偏移拼接解压)")
|
||||
public void download(HttpServletRequest request, HttpServletResponse response,
|
||||
@RequestParam Long fileId) throws IOException {
|
||||
requireSuperAdmin(request);
|
||||
DeviceLogFileEntity row = deviceLogService.requireFile(fileId);
|
||||
String downloadName = row.getFileName().replace('/', '_').replace('\\', '_');
|
||||
response.setContentType("text/plain;charset=UTF-8");
|
||||
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''"
|
||||
+ URLEncoder.encode(downloadName, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
deviceLogService.streamDownload(fileId, response.getOutputStream());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除日志文件(片段与元数据,不可恢复)")
|
||||
public ApiResponse<Map<String, Object>> delete(HttpServletRequest request,
|
||||
@PathVariable Long id) {
|
||||
requireSuperAdmin(request);
|
||||
int deletedParts = deviceLogService.deleteFile(id);
|
||||
return ApiResponse.success("已删除", Map.of("deletedParts", deletedParts));
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
@Operation(summary = "采集配置:全局默认 + 终端覆盖列表")
|
||||
public ApiResponse<Map<String, Object>> config(HttpServletRequest request,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
requireSuperAdmin(request);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("globalMode", deviceLogConfigService.globalMode());
|
||||
List<DeviceLogConfigEntity> overrides = deviceLogConfigService.listOverrides(keyword);
|
||||
data.put("overrides", overrides);
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
@GetMapping("/devices")
|
||||
@Operation(summary = "最近上报的终端列表(覆盖选择用)")
|
||||
public ApiResponse<List<Map<String, Object>>> devices(HttpServletRequest request) {
|
||||
requireSuperAdmin(request);
|
||||
return ApiResponse.success(deviceLogService.recentDevices());
|
||||
}
|
||||
|
||||
@PutMapping("/config/global")
|
||||
@Operation(summary = "设置全局采集模式", description = "mode=full(全量)/ selected(精选)")
|
||||
public ApiResponse<Map<String, Object>> updateGlobal(HttpServletRequest request,
|
||||
@RequestParam String mode) {
|
||||
requireSuperAdmin(request);
|
||||
deviceLogConfigService.setGlobalMode(mode);
|
||||
return ApiResponse.success(Map.of("globalMode", deviceLogConfigService.globalMode()));
|
||||
}
|
||||
|
||||
@PutMapping("/config/device")
|
||||
@Operation(summary = "设置/更新终端采集模式覆盖")
|
||||
public ApiResponse<Map<String, Object>> updateDevice(HttpServletRequest request,
|
||||
@RequestParam String source,
|
||||
@RequestParam String deviceId,
|
||||
@RequestParam(required = false) String deviceName,
|
||||
@RequestParam String mode) {
|
||||
requireSuperAdmin(request);
|
||||
DeviceLogConfigEntity row = deviceLogConfigService.upsertOverride(source, deviceId, deviceName, mode);
|
||||
return ApiResponse.success(Map.of("id", row.getId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/config/device/{id}")
|
||||
@Operation(summary = "删除终端覆盖(回落到全局默认)")
|
||||
public ApiResponse<Boolean> deleteOverride(HttpServletRequest request, @PathVariable Long id) {
|
||||
requireSuperAdmin(request);
|
||||
return ApiResponse.success("已删除", deviceLogConfigService.deleteOverride(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志可能含账号/代理等敏感信息,这里比常规后台更严:仅超管(requireAdmin 不含)。
|
||||
*/
|
||||
private void requireSuperAdmin(HttpServletRequest request) {
|
||||
AdminUserEntity user = adminAuthSupport.requireAdmin(request);
|
||||
if (!"super_admin".equals(adminAuthSupport.currentRole(user))) {
|
||||
log.warn("[device-log] 非超管访问日志管理被拒 userId={} username={}",
|
||||
user.getId(), user.getUsername());
|
||||
throw new BusinessException(403, "仅超级管理员可访问日志管理");
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.nanri.aiimage.modules.devicelog.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.security.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.devicelog.service.DeviceLogConfigService;
|
||||
import com.nanri.aiimage.modules.devicelog.service.DeviceLogService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设备日志上报(桌面客户端 / 麦象采集机):增量片段上传、进度对齐、采集配置拉取。
|
||||
* 仅内部令牌(X-Internal-Token)可调。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@RequestMapping("/api/internal/device-logs")
|
||||
@Tag(name = "内部接口", description = "服务间调用(X-Internal-Token 鉴权)。")
|
||||
public class InternalDeviceLogController {
|
||||
|
||||
private final DeviceLogService deviceLogService;
|
||||
private final DeviceLogConfigService deviceLogConfigService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "上报日志增量片段",
|
||||
description = "multipart:元数据字段 + file(gzip 片段)。offset 必须等于服务端已收字节数;"
|
||||
+ "重复片段幂等跳过(skipped=true);偏移不连续返回 code=409,调用方应从 uploadedBytes 重读。")
|
||||
public ApiResponse<Map<String, Object>> upload(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("source") String source,
|
||||
@RequestParam("deviceId") String deviceId,
|
||||
@RequestParam(value = "deviceName", required = false) String deviceName,
|
||||
@RequestParam(value = "uid", required = false) Long uid,
|
||||
@RequestParam("fileName") String fileName,
|
||||
@RequestParam("logDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate logDate,
|
||||
@RequestParam("offset") long offset,
|
||||
@RequestParam("plainBytes") long plainBytes,
|
||||
@RequestParam("file") MultipartFile file) throws IOException {
|
||||
requireInternal(request, "日志上报");
|
||||
if (file == null || file.isEmpty()) {
|
||||
return ApiResponse.fail("file 片段为空");
|
||||
}
|
||||
DeviceLogService.PartResult result = deviceLogService.recordPart(source, deviceId, deviceName, uid,
|
||||
fileName, logDate, offset, plainBytes, file.getBytes());
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("uploadedBytes", result.uploadedBytes());
|
||||
data.put("partCount", result.partCount());
|
||||
data.put("accepted", result.accepted());
|
||||
data.put("skipped", result.skipped());
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
@GetMapping("/state")
|
||||
@Operation(summary = "查询某文件服务端已收进度", description = "客户端本地进度丢失/被拒后从此对齐。")
|
||||
public ApiResponse<Map<String, Object>> state(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("source") String source,
|
||||
@RequestParam("deviceId") String deviceId,
|
||||
@RequestParam("fileName") String fileName,
|
||||
@RequestParam("logDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate logDate) {
|
||||
requireInternal(request, "进度查询");
|
||||
return ApiResponse.success(deviceLogService.state(source, deviceId, fileName, logDate));
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
@Operation(summary = "拉取生效的采集配置", description = "终端覆盖 > 全局默认;返回 mode 与精选模式排除清单(glob)。")
|
||||
public ApiResponse<Map<String, Object>> config(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("source") String source,
|
||||
@RequestParam("deviceId") String deviceId) {
|
||||
requireInternal(request, "配置拉取");
|
||||
DeviceLogConfigService.EffectiveConfig config = deviceLogConfigService.resolve(source, deviceId);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("mode", config.mode());
|
||||
data.put("exclude", config.exclude());
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
private void requireInternal(HttpServletRequest request, String scene) {
|
||||
if (!adminAuthSupport.isTrustedInternalToken(request)) {
|
||||
log.warn("[device-log] 拒绝未携带可信内部令牌的{}请求 remoteAddr={}", scene, request.getRemoteAddr());
|
||||
throw new BusinessException(401, "未授权");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.devicelog.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.devicelog.model.entity.DeviceLogConfigEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface DeviceLogConfigMapper extends BaseMapper<DeviceLogConfigEntity> {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.devicelog.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.devicelog.model.entity.DeviceLogFileEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface DeviceLogFileMapper extends BaseMapper<DeviceLogFileEntity> {
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.devicelog.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 日志采集配置:scope=global 为全局默认(source/device_id 存空串占位);
|
||||
* scope=device 为终端级覆盖(按来源+设备精确命中,优先于全局)。
|
||||
*/
|
||||
@Data
|
||||
@TableName("device_log_config")
|
||||
public class DeviceLogConfigEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
/** global / device。 */
|
||||
private String scope;
|
||||
/** 对应 device_log_file.source(global 行存空串)。 */
|
||||
private String source;
|
||||
/** 对应 device_log_file.device_id(global 行存空串)。 */
|
||||
private String deviceId;
|
||||
/** 覆盖行记录的设备展示名(列表展示用)。 */
|
||||
private String deviceName;
|
||||
/** full(全量)/ selected(精选)。 */
|
||||
private String mode;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.devicelog.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备日志文件元数据(对象内容存独立 MinIO,本表只存索引与进度)。
|
||||
* 一个「来源 + 设备 + 文件名 + 日志日期」一行,uploadedBytes/partCount 随增量上报推进。
|
||||
*/
|
||||
@Data
|
||||
@TableName("device_log_file")
|
||||
public class DeviceLogFileEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
/** 来源:client(桌面客户端)/ maixiang(麦象采集机)。 */
|
||||
private String source;
|
||||
private String deviceId;
|
||||
/** 展示名:客户端登录用户名或机器名。 */
|
||||
private String deviceName;
|
||||
/** 桌面客户端当前登录用户 id(users.id),maixiang 上报为空。 */
|
||||
private Long uid;
|
||||
/** 日志文件名(客户端可能含子目录,如 API/2026_09_15.log)。 */
|
||||
private String fileName;
|
||||
private LocalDate logDate;
|
||||
/** 已上传的明文字节数(客户端增量断点由此对齐)。 */
|
||||
private Long uploadedBytes;
|
||||
private Integer partCount;
|
||||
private LocalDateTime lastUploadAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.devicelog.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 日志内容(按尾部截取)。 */
|
||||
@Data
|
||||
public class DeviceLogContentVo {
|
||||
|
||||
private Long fileId;
|
||||
private String fileName;
|
||||
/** 已解压的日志文本(自最早行边界起,保证不截出半行)。 */
|
||||
private String content;
|
||||
/** 服务端已收到的日志总字节数。 */
|
||||
private long totalBytes;
|
||||
/** 本次实际返回的字节数。 */
|
||||
private long shownBytes;
|
||||
/** true=内容被截断(更早的历史未返回,可加大 maxBytes 再取)。 */
|
||||
private boolean truncated;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.nanri.aiimage.modules.devicelog.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 日志文件列表行。 */
|
||||
@Data
|
||||
public class DeviceLogFileVo {
|
||||
|
||||
private Long id;
|
||||
private String source;
|
||||
private String deviceId;
|
||||
private String deviceName;
|
||||
/** 关联用户展示名(users.username;解析不到时为空)。 */
|
||||
private String username;
|
||||
private Long uid;
|
||||
private String fileName;
|
||||
private LocalDate logDate;
|
||||
private Long uploadedBytes;
|
||||
private Integer partCount;
|
||||
private LocalDateTime lastUploadAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.devicelog.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 日志文件分页结果。 */
|
||||
@Data
|
||||
public class DeviceLogPageVo {
|
||||
|
||||
private List<DeviceLogFileVo> items;
|
||||
private long total;
|
||||
private long page;
|
||||
private long pageSize;
|
||||
/** 服务端保留天数(前端提示「日志仅保留 N 天」)。 */
|
||||
private int retentionDays;
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.nanri.aiimage.modules.devicelog.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.devicelog.mapper.DeviceLogConfigMapper;
|
||||
import com.nanri.aiimage.modules.devicelog.model.entity.DeviceLogConfigEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 日志采集配置:全局默认 + 终端覆盖(超管在后台「日志管理」调整)。
|
||||
*
|
||||
* <p>上报端(桌面客户端 / 麦象)定期拉取生效配置:全量上传目录内全部日志;
|
||||
* 精选模式只上传关键日志(排除清单见 {@link #selectedExcludes},随配置一起下发,
|
||||
* 调整清单无需发客户端版本)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceLogConfigService {
|
||||
|
||||
public static final String MODE_FULL = "full";
|
||||
public static final String MODE_SELECTED = "selected";
|
||||
|
||||
/** 精选模式排除清单(glob,按来源;相对日志目录的文件名)。 */
|
||||
private static final Map<String, List<String>> SELECTED_EXCLUDES = Map.of(
|
||||
"client", List.of("pywebview.log"),
|
||||
"maixiang", List.of("kk-browser.log*", "*_console.log", "test*.log"));
|
||||
|
||||
private final DeviceLogConfigMapper deviceLogConfigMapper;
|
||||
|
||||
/** 生效配置(终端覆盖 > 全局默认 > 兜底全量)。 */
|
||||
public record EffectiveConfig(String mode, List<String> exclude) {
|
||||
}
|
||||
|
||||
public EffectiveConfig resolve(String source, String deviceId) {
|
||||
DeviceLogConfigEntity override = deviceLogConfigMapper.selectOne(
|
||||
new LambdaQueryWrapper<DeviceLogConfigEntity>()
|
||||
.eq(DeviceLogConfigEntity::getScope, "device")
|
||||
.eq(DeviceLogConfigEntity::getSource, source)
|
||||
.eq(DeviceLogConfigEntity::getDeviceId, deviceId)
|
||||
.last("limit 1"));
|
||||
String mode = override != null ? override.getMode() : globalMode();
|
||||
return new EffectiveConfig(mode, selectedExcludes(mode, source));
|
||||
}
|
||||
|
||||
public String globalMode() {
|
||||
DeviceLogConfigEntity global = findGlobal();
|
||||
return global == null ? MODE_FULL : global.getMode();
|
||||
}
|
||||
|
||||
public List<String> selectedExcludes(String mode, String source) {
|
||||
if (!MODE_SELECTED.equals(mode)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> excludes = SELECTED_EXCLUDES.get(source);
|
||||
if (excludes == null) {
|
||||
log.warn("[device-log] 来源 {} 无精选排除清单,精选模式将等价全量", source);
|
||||
return List.of();
|
||||
}
|
||||
return new ArrayList<>(excludes);
|
||||
}
|
||||
|
||||
public void setGlobalMode(String mode) {
|
||||
String safeMode = normalizeMode(mode);
|
||||
DeviceLogConfigEntity global = findGlobal();
|
||||
if (global == null) {
|
||||
global = new DeviceLogConfigEntity();
|
||||
global.setScope("global");
|
||||
global.setSource("");
|
||||
global.setDeviceId("");
|
||||
global.setMode(safeMode);
|
||||
deviceLogConfigMapper.insert(global);
|
||||
log.info("[device-log] 全局采集模式初始化 mode={}", safeMode);
|
||||
return;
|
||||
}
|
||||
if (safeMode.equals(global.getMode())) {
|
||||
return;
|
||||
}
|
||||
deviceLogConfigMapper.updateById(withMode(global, safeMode));
|
||||
log.info("[device-log] 全局采集模式更新 {} → {}", global.getMode(), safeMode);
|
||||
}
|
||||
|
||||
public List<DeviceLogConfigEntity> listOverrides(String keyword) {
|
||||
LambdaQueryWrapper<DeviceLogConfigEntity> qw = new LambdaQueryWrapper<DeviceLogConfigEntity>()
|
||||
.eq(DeviceLogConfigEntity::getScope, "device");
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
String kw = keyword.trim();
|
||||
qw.and(w -> w.like(DeviceLogConfigEntity::getDeviceId, kw)
|
||||
.or().like(DeviceLogConfigEntity::getDeviceName, kw));
|
||||
}
|
||||
return deviceLogConfigMapper.selectList(qw
|
||||
.orderByDesc(DeviceLogConfigEntity::getUpdatedAt)
|
||||
.last("limit 500"));
|
||||
}
|
||||
|
||||
public DeviceLogConfigEntity upsertOverride(String source, String deviceId, String deviceName, String mode) {
|
||||
String safeSource = requireText(source, "source", 32);
|
||||
String safeDeviceId = requireText(deviceId, "deviceId", 128);
|
||||
String safeMode = normalizeMode(mode);
|
||||
DeviceLogConfigEntity row = deviceLogConfigMapper.selectOne(
|
||||
new LambdaQueryWrapper<DeviceLogConfigEntity>()
|
||||
.eq(DeviceLogConfigEntity::getScope, "device")
|
||||
.eq(DeviceLogConfigEntity::getSource, safeSource)
|
||||
.eq(DeviceLogConfigEntity::getDeviceId, safeDeviceId)
|
||||
.last("limit 1"));
|
||||
if (row == null) {
|
||||
row = new DeviceLogConfigEntity();
|
||||
row.setScope("device");
|
||||
row.setSource(safeSource);
|
||||
row.setDeviceId(safeDeviceId);
|
||||
row.setDeviceName(deviceName);
|
||||
row.setMode(safeMode);
|
||||
deviceLogConfigMapper.insert(row);
|
||||
log.info("[device-log] 新增终端覆盖 source={} device={} mode={}", safeSource, safeDeviceId, safeMode);
|
||||
return row;
|
||||
}
|
||||
DeviceLogConfigEntity update = withMode(row, safeMode);
|
||||
if (deviceName != null && !deviceName.isBlank()) {
|
||||
update.setDeviceName(deviceName);
|
||||
}
|
||||
deviceLogConfigMapper.updateById(update);
|
||||
log.info("[device-log] 终端覆盖更新 source={} device={} mode={}", safeSource, safeDeviceId, safeMode);
|
||||
return update;
|
||||
}
|
||||
|
||||
public boolean deleteOverride(Long id) {
|
||||
if (id == null) {
|
||||
throw new BusinessException(400, "id 不能为空");
|
||||
}
|
||||
int deleted = deviceLogConfigMapper.deleteById(id);
|
||||
log.info("[device-log] 终端覆盖删除 id={} deleted={}", id, deleted);
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
private DeviceLogConfigEntity findGlobal() {
|
||||
return deviceLogConfigMapper.selectOne(new LambdaQueryWrapper<DeviceLogConfigEntity>()
|
||||
.eq(DeviceLogConfigEntity::getScope, "global")
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private static DeviceLogConfigEntity withMode(DeviceLogConfigEntity row, String mode) {
|
||||
DeviceLogConfigEntity update = new DeviceLogConfigEntity();
|
||||
update.setId(row.getId());
|
||||
update.setMode(mode);
|
||||
return update;
|
||||
}
|
||||
|
||||
private static String normalizeMode(String mode) {
|
||||
String trimmed = mode == null ? "" : mode.trim().toLowerCase();
|
||||
if (!MODE_FULL.equals(trimmed) && !MODE_SELECTED.equals(trimmed)) {
|
||||
throw new BusinessException(400, "mode 只支持 full / selected");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field, int maxLength) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty() || trimmed.length() > maxLength) {
|
||||
throw new BusinessException(400, field + " 非法");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
package com.nanri.aiimage.modules.devicelog.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.config.DeviceLogOssProperties;
|
||||
import com.nanri.aiimage.modules.devicelog.mapper.DeviceLogFileMapper;
|
||||
import com.nanri.aiimage.modules.devicelog.model.entity.DeviceLogFileEntity;
|
||||
import com.nanri.aiimage.modules.devicelog.model.vo.DeviceLogContentVo;
|
||||
import com.nanri.aiimage.modules.devicelog.model.vo.DeviceLogFileVo;
|
||||
import com.nanri.aiimage.modules.devicelog.model.vo.DeviceLogPageVo;
|
||||
import com.nanri.aiimage.modules.devicelog.storage.DeviceLogStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/**
|
||||
* 设备日志:增量片段接收(桌面客户端 / 麦象采集机上报)与后台查询。
|
||||
*
|
||||
* <p>存储模型:一个「来源+设备+文件名+日期」一行元数据;内容以 gzip 片段对象按
|
||||
* 「起始偏移」命名存独立 MinIO(device-logs/…/{offset}.log.gz)。客户端按本地
|
||||
* uploadedBytes 断点续传,服务端条件推进偏移;查看/下载时按偏移顺序拼接解压。
|
||||
* 片段 key 带偏移(而非序号),重复上报与乱序重试都会覆盖同一对象,天然幂等。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceLogService {
|
||||
|
||||
/** 单片段明文上限(客户端按 2MB 切片,此处留防线余量)。 */
|
||||
private static final long MAX_PART_PLAIN_BYTES = 8L * 1024 * 1024;
|
||||
private static final long MAX_PART_GZIP_BYTES = 8L * 1024 * 1024;
|
||||
|
||||
private static final long DEFAULT_TAIL_BYTES = 256L * 1024;
|
||||
private static final long MIN_TAIL_BYTES = 16L * 1024;
|
||||
private static final long MAX_TAIL_BYTES = 8L * 1024 * 1024;
|
||||
|
||||
private static final long MAX_PAGE_SIZE = 100L;
|
||||
|
||||
/** 来源白名单形态:小写字母开头,长度 ≤32(client / maixiang / 未来新来源)。 */
|
||||
private static final Pattern SOURCE_PATTERN = Pattern.compile("^[a-z][a-z0-9_-]{0,31}$");
|
||||
/** 对象 key 段落清洗:路径分隔符、通配符、控制字符一律换成下划线。 */
|
||||
private static final Pattern UNSAFE_SEGMENT = Pattern.compile("[\\\\/:*?\"<>|\\x00-\\x1F]+");
|
||||
|
||||
private final DeviceLogFileMapper deviceLogFileMapper;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final DeviceLogStorageService storage;
|
||||
private final DeviceLogOssProperties properties;
|
||||
|
||||
/** 上报处理结果。 */
|
||||
public record PartResult(long uploadedBytes, int partCount, boolean accepted, boolean skipped) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 上报
|
||||
|
||||
/**
|
||||
* 接收一个增量片段。
|
||||
*
|
||||
* @param offset 客户端认为的已上传明文偏移(必须等于服务端记录值才能追加)
|
||||
* @param plainBytes 本片段解压后的明文字节数(客户端告知;服务端只存 gzip 不解压)
|
||||
*/
|
||||
public PartResult recordPart(String source, String deviceId, String deviceName, Long uid,
|
||||
String fileName, LocalDate logDate, long offset, long plainBytes,
|
||||
byte[] gzipBytes) {
|
||||
String safeSource = normalizeSource(source);
|
||||
String safeDeviceId = requireSegment(deviceId, "deviceId", 128);
|
||||
String safeFileName = requireSegment(fileName, "fileName", 255);
|
||||
if (logDate == null) {
|
||||
throw new BusinessException(400, "logDate 不能为空");
|
||||
}
|
||||
if (offset < 0) {
|
||||
throw new BusinessException(400, "offset 非法");
|
||||
}
|
||||
if (plainBytes <= 0 || plainBytes > MAX_PART_PLAIN_BYTES) {
|
||||
throw new BusinessException(400, "plainBytes 非法(1 ~ " + MAX_PART_PLAIN_BYTES + ")");
|
||||
}
|
||||
if (gzipBytes == null || gzipBytes.length == 0 || gzipBytes.length > MAX_PART_GZIP_BYTES) {
|
||||
throw new BusinessException(400, "片段内容为空或超过上限");
|
||||
}
|
||||
if (!storage.enabled()) {
|
||||
log.error("[device-log] 上报被拒:对象存储未就绪 source={} device={} file={}", safeSource, safeDeviceId, safeFileName);
|
||||
throw new BusinessException(503, "日志存储未就绪,请稍后重试");
|
||||
}
|
||||
|
||||
DeviceLogFileEntity row = findOrCreate(safeSource, safeDeviceId, safeFileName, logDate, deviceName, uid);
|
||||
long current = row.getUploadedBytes() == null ? 0L : row.getUploadedBytes();
|
||||
|
||||
if (offset < current) {
|
||||
// 重试/重复上报:内容已收过,幂等跳过(返回服务端权威进度供客户端对齐)
|
||||
log.info("[device-log] 片段重复,幂等跳过 source={} device={} file={} date={} offset={} current={}",
|
||||
safeSource, safeDeviceId, safeFileName, logDate, offset, current);
|
||||
return new PartResult(current, nvl(row.getPartCount()), false, true);
|
||||
}
|
||||
if (offset > current) {
|
||||
// 出现空洞(客户端本地进度领先于服务端):拒绝,让客户端从服务端进度重读
|
||||
log.warn("[device-log] 片段偏移不连续 source={} device={} file={} date={} offset={} current={}",
|
||||
safeSource, safeDeviceId, safeFileName, logDate, offset, current);
|
||||
throw new BusinessException(409, "偏移不连续,请从 uploadedBytes=" + current + " 重新读取");
|
||||
}
|
||||
|
||||
String objectKey = objectKeyPrefix(safeSource, safeDeviceId, logDate, safeFileName)
|
||||
+ String.format("%012d.log.gz", offset);
|
||||
storage.putPart(objectKey, gzipBytes);
|
||||
|
||||
// 条件推进(uploaded_bytes 与读取时一致才更新;双节点并发时另一方以 0 行影响放弃,以库中值为准)
|
||||
int updated = deviceLogFileMapper.update(null, new LambdaUpdateWrapper<DeviceLogFileEntity>()
|
||||
.eq(DeviceLogFileEntity::getId, row.getId())
|
||||
.eq(DeviceLogFileEntity::getUploadedBytes, current)
|
||||
.set(DeviceLogFileEntity::getUploadedBytes, offset + plainBytes)
|
||||
.setSql("part_count = part_count + 1")
|
||||
.set(DeviceLogFileEntity::getLastUploadAt, LocalDateTime.now())
|
||||
.set(deviceName != null && !deviceName.isBlank(), DeviceLogFileEntity::getDeviceName, deviceName)
|
||||
.set(uid != null, DeviceLogFileEntity::getUid, uid));
|
||||
if (updated <= 0) {
|
||||
DeviceLogFileEntity latest = deviceLogFileMapper.selectById(row.getId());
|
||||
long latestBytes = latest == null || latest.getUploadedBytes() == null ? current : latest.getUploadedBytes();
|
||||
log.warn("[device-log] 并发推进冲突,以库中值为准 id={} offset={} 库中={}", row.getId(), offset, latestBytes);
|
||||
return new PartResult(latestBytes, latest == null ? 0 : nvl(latest.getPartCount()), false, true);
|
||||
}
|
||||
|
||||
long after = offset + plainBytes;
|
||||
log.info("[device-log] 已收片段 source={} device={} file={} date={} offset={} +{}B → {}B key={}",
|
||||
safeSource, safeDeviceId, safeFileName, logDate, offset, plainBytes, after, objectKey);
|
||||
return new PartResult(after, nvl(row.getPartCount()) + 1, true, false);
|
||||
}
|
||||
|
||||
/** 客户端进度对齐:返回服务端已持有的偏移与片段数。 */
|
||||
public Map<String, Object> state(String source, String deviceId, String fileName, LocalDate logDate) {
|
||||
String safeSource = normalizeSource(source);
|
||||
DeviceLogFileEntity row = find(safeSource, requireSegment(deviceId, "deviceId", 128),
|
||||
requireSegment(fileName, "fileName", 255), logDate);
|
||||
return Map.of(
|
||||
"exists", row != null,
|
||||
"uploadedBytes", row == null || row.getUploadedBytes() == null ? 0L : row.getUploadedBytes(),
|
||||
"parts", row == null ? 0 : nvl(row.getPartCount()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 查询
|
||||
|
||||
public DeviceLogPageVo page(String source, String keyword, LocalDate startDate, LocalDate endDate,
|
||||
Long pageParam, Long pageSizeParam) {
|
||||
int retentionDays = properties.retentionDaysOrDefault();
|
||||
LocalDate minDate = LocalDate.now().minusDays(retentionDays - 1L);
|
||||
LocalDate from = startDate == null || startDate.isBefore(minDate) ? minDate : startDate;
|
||||
long safePage = pageParam == null || pageParam < 1 ? 1L : pageParam;
|
||||
long safeSize = pageSizeParam == null || pageSizeParam < 1
|
||||
? 20L : Math.min(pageSizeParam, MAX_PAGE_SIZE);
|
||||
|
||||
Function<Boolean, LambdaQueryWrapper<DeviceLogFileEntity>> wrapperBuilder = countOnly -> {
|
||||
LambdaQueryWrapper<DeviceLogFileEntity> qw = new LambdaQueryWrapper<>();
|
||||
if (source != null && !source.isBlank()) {
|
||||
qw.eq(DeviceLogFileEntity::getSource, source.trim());
|
||||
}
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
String kw = keyword.trim();
|
||||
qw.and(w -> w.like(DeviceLogFileEntity::getDeviceName, kw)
|
||||
.or().like(DeviceLogFileEntity::getDeviceId, kw)
|
||||
.or().like(DeviceLogFileEntity::getFileName, kw));
|
||||
}
|
||||
qw.ge(DeviceLogFileEntity::getLogDate, from);
|
||||
if (endDate != null) {
|
||||
qw.le(DeviceLogFileEntity::getLogDate, endDate);
|
||||
}
|
||||
return qw;
|
||||
};
|
||||
|
||||
Long totalValue = deviceLogFileMapper.selectCount(wrapperBuilder.apply(true));
|
||||
long total = totalValue == null ? 0L : totalValue;
|
||||
long offset = Math.max(0L, (safePage - 1) * safeSize);
|
||||
List<DeviceLogFileEntity> rows = total == 0 ? List.of()
|
||||
: deviceLogFileMapper.selectList(wrapperBuilder.apply(false)
|
||||
.orderByDesc(DeviceLogFileEntity::getLastUploadAt)
|
||||
.orderByDesc(DeviceLogFileEntity::getId)
|
||||
.last("limit " + offset + "," + safeSize));
|
||||
|
||||
Map<Long, String> usernameOf = resolveUsernames(rows);
|
||||
List<DeviceLogFileVo> items = new ArrayList<>(rows.size());
|
||||
for (DeviceLogFileEntity row : rows) {
|
||||
items.add(toVo(row, usernameOf));
|
||||
}
|
||||
|
||||
DeviceLogPageVo vo = new DeviceLogPageVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
vo.setRetentionDays(retentionDays);
|
||||
log.info("[device-log] 列表查询 source={} keyword={} 起={} 止={} page={} size={} 命中={}",
|
||||
source, keyword, from, endDate, safePage, safeSize, total);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 最近上报过的终端(来源+设备去重,供后台配置终端覆盖时选择)。 */
|
||||
public List<Map<String, Object>> recentDevices() {
|
||||
LocalDate minDate = LocalDate.now().minusDays(properties.retentionDaysOrDefault() - 1L);
|
||||
return deviceLogFileMapper.selectMaps(new QueryWrapper<DeviceLogFileEntity>()
|
||||
.select("source",
|
||||
"device_id AS deviceId",
|
||||
"MAX(device_name) AS deviceName",
|
||||
"MAX(last_upload_at) AS lastUploadAt")
|
||||
.ge("log_date", minDate)
|
||||
.groupBy("source", "device_id")
|
||||
.orderByDesc("lastUploadAt")
|
||||
.last("limit 200"));
|
||||
}
|
||||
|
||||
/** 尾部内容:从最新片段往前读,尽量凑满 maxBytes(自最早行边界起截取,不出现半行)。 */
|
||||
public DeviceLogContentVo readTail(Long fileId, Long maxBytesParam) {
|
||||
DeviceLogFileEntity row = requireFile(fileId);
|
||||
long maxBytes = maxBytesParam == null ? DEFAULT_TAIL_BYTES
|
||||
: Math.max(MIN_TAIL_BYTES, Math.min(maxBytesParam, MAX_TAIL_BYTES));
|
||||
String prefix = objectKeyPrefix(row);
|
||||
List<String> partKeys = storage.listParts(prefix);
|
||||
|
||||
Deque<byte[]> chunks = new ArrayDeque<>();
|
||||
long acc = 0;
|
||||
int idx = partKeys.size() - 1;
|
||||
for (; idx >= 0 && acc < maxBytes; idx--) {
|
||||
byte[] plain;
|
||||
try {
|
||||
plain = gunzip(storage.readPartBytes(partKeys.get(idx)));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[device-log] 片段读取/解压失败,跳过 key={} err={}", partKeys.get(idx), ex.getMessage());
|
||||
continue;
|
||||
}
|
||||
chunks.addFirst(plain);
|
||||
acc += plain.length;
|
||||
}
|
||||
boolean truncated = idx >= 0;
|
||||
|
||||
ByteArrayOutputStream merged = new ByteArrayOutputStream((int) Math.min(acc, Integer.MAX_VALUE));
|
||||
for (byte[] chunk : chunks) {
|
||||
merged.write(chunk, 0, chunk.length);
|
||||
}
|
||||
byte[] bytes = merged.toByteArray();
|
||||
if (bytes.length > maxBytes) {
|
||||
int cut = (int) (bytes.length - maxBytes);
|
||||
int nl = indexOfNewline(bytes, cut);
|
||||
// 从行边界开始截(不留半行);但若这样会切掉全部内容(超长行),退回按字节截
|
||||
if (nl >= 0 && nl + 1 < bytes.length) {
|
||||
cut = nl + 1;
|
||||
}
|
||||
bytes = Arrays.copyOfRange(bytes, cut, bytes.length);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
DeviceLogContentVo vo = new DeviceLogContentVo();
|
||||
vo.setFileId(row.getId());
|
||||
vo.setFileName(row.getFileName());
|
||||
vo.setContent(new String(bytes, StandardCharsets.UTF_8));
|
||||
vo.setTotalBytes(row.getUploadedBytes() == null ? 0L : row.getUploadedBytes());
|
||||
vo.setShownBytes(bytes.length);
|
||||
vo.setTruncated(truncated);
|
||||
log.info("[device-log] 内容查看 id={} file={} 总大小={}B 返回={}B 截断={}",
|
||||
row.getId(), row.getFileName(), vo.getTotalBytes(), bytes.length, truncated);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 按偏移顺序流式拼接全部片段(解压后写响应,无整文件内存占用)。 */
|
||||
public void streamDownload(Long fileId, OutputStream out) throws IOException {
|
||||
DeviceLogFileEntity row = requireFile(fileId);
|
||||
List<String> partKeys = storage.listParts(objectKeyPrefix(row));
|
||||
if (partKeys.isEmpty()) {
|
||||
throw new BusinessException(404, "该日志暂无内容");
|
||||
}
|
||||
for (String key : partKeys) {
|
||||
try (InputStream raw = storage.openPartStream(key);
|
||||
GZIPInputStream gz = new GZIPInputStream(raw)) {
|
||||
gz.transferTo(out);
|
||||
}
|
||||
}
|
||||
out.flush();
|
||||
log.info("[device-log] 下载拼接完成 id={} file={} 片段数={}", row.getId(), row.getFileName(), partKeys.size());
|
||||
}
|
||||
|
||||
/** 删除日志文件(片段对象 + 元数据行)。返回删除的对象数。 */
|
||||
public int deleteFile(Long fileId) {
|
||||
DeviceLogFileEntity row = requireFile(fileId);
|
||||
List<String> partKeys = storage.listParts(objectKeyPrefix(row));
|
||||
List<String> failed = storage.deleteParts(partKeys);
|
||||
deviceLogFileMapper.deleteById(fileId);
|
||||
log.info("[device-log] 删除日志 id={} file={} 片段总数={} 删除失败={}",
|
||||
row.getId(), row.getFileName(), partKeys.size(), failed.size());
|
||||
return partKeys.size() - failed.size();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 内部
|
||||
|
||||
/** 按 id 取日志文件行(不存在抛 404)。 */
|
||||
public DeviceLogFileEntity requireFile(Long fileId) {
|
||||
if (fileId == null) {
|
||||
throw new BusinessException(400, "fileId 不能为空");
|
||||
}
|
||||
DeviceLogFileEntity row = deviceLogFileMapper.selectById(fileId);
|
||||
if (row == null) {
|
||||
throw new BusinessException(404, "日志文件不存在或已清理");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private DeviceLogFileEntity findOrCreate(String source, String deviceId, String fileName,
|
||||
LocalDate logDate, String deviceName, Long uid) {
|
||||
DeviceLogFileEntity row = find(source, deviceId, fileName, logDate);
|
||||
if (row != null) {
|
||||
return row;
|
||||
}
|
||||
DeviceLogFileEntity entity = new DeviceLogFileEntity();
|
||||
entity.setSource(source);
|
||||
entity.setDeviceId(deviceId);
|
||||
entity.setFileName(fileName);
|
||||
entity.setLogDate(logDate);
|
||||
entity.setDeviceName(deviceName);
|
||||
entity.setUid(uid);
|
||||
entity.setUploadedBytes(0L);
|
||||
entity.setPartCount(0);
|
||||
try {
|
||||
deviceLogFileMapper.insert(entity);
|
||||
log.info("[device-log] 登记新日志文件 id={} source={} device={} file={} date={}",
|
||||
entity.getId(), source, deviceId, fileName, logDate);
|
||||
return entity;
|
||||
} catch (Exception ex) {
|
||||
// 双节点并发首传同一文件:唯一键冲突后复用已有行
|
||||
DeviceLogFileEntity existing = find(source, deviceId, fileName, logDate);
|
||||
if (existing != null) {
|
||||
log.info("[device-log] 并发登记同一文件,复用已有行 id={} file={}", existing.getId(), fileName);
|
||||
return existing;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private DeviceLogFileEntity find(String source, String deviceId, String fileName, LocalDate logDate) {
|
||||
if (logDate == null) {
|
||||
return null;
|
||||
}
|
||||
return deviceLogFileMapper.selectOne(new LambdaQueryWrapper<DeviceLogFileEntity>()
|
||||
.eq(DeviceLogFileEntity::getSource, source)
|
||||
.eq(DeviceLogFileEntity::getDeviceId, deviceId)
|
||||
.eq(DeviceLogFileEntity::getFileName, fileName)
|
||||
.eq(DeviceLogFileEntity::getLogDate, logDate)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private String objectKeyPrefix(DeviceLogFileEntity row) {
|
||||
return objectKeyPrefix(row.getSource(), row.getDeviceId(), row.getLogDate(), row.getFileName());
|
||||
}
|
||||
|
||||
private String objectKeyPrefix(String source, String deviceId, LocalDate logDate, String fileName) {
|
||||
return String.format("device-logs/%s/%s/%s/%s/",
|
||||
safeSegment(source), safeSegment(deviceId), logDate, safeSegment(fileName));
|
||||
}
|
||||
|
||||
private Map<Long, String> resolveUsernames(List<DeviceLogFileEntity> rows) {
|
||||
List<Long> uids = rows.stream()
|
||||
.map(DeviceLogFileEntity::getUid)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (uids.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<AdminUserEntity> users = adminUserMapper.selectBatchIds(uids);
|
||||
Map<Long, String> map = new java.util.HashMap<>();
|
||||
for (AdminUserEntity user : users) {
|
||||
map.put(user.getId(), user.getUsername());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private DeviceLogFileVo toVo(DeviceLogFileEntity row, Map<Long, String> usernameOf) {
|
||||
DeviceLogFileVo vo = new DeviceLogFileVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setSource(row.getSource());
|
||||
vo.setDeviceId(row.getDeviceId());
|
||||
vo.setDeviceName(row.getDeviceName());
|
||||
vo.setUid(row.getUid());
|
||||
vo.setUsername(row.getUid() == null ? null : usernameOf.get(row.getUid()));
|
||||
vo.setFileName(row.getFileName());
|
||||
vo.setLogDate(row.getLogDate());
|
||||
vo.setUploadedBytes(row.getUploadedBytes());
|
||||
vo.setPartCount(row.getPartCount());
|
||||
vo.setLastUploadAt(row.getLastUploadAt());
|
||||
vo.setCreatedAt(row.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String normalizeSource(String source) {
|
||||
String trimmed = source == null ? "" : source.trim();
|
||||
if (!SOURCE_PATTERN.matcher(trimmed).matches()) {
|
||||
log.warn("[device-log] 非法来源被拒 source={}", source);
|
||||
throw new BusinessException(400, "source 非法(小写字母开头,数字/下划线/中划线,≤32)");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private String requireSegment(String value, String field, int maxLength) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new BusinessException(400, field + " 不能为空");
|
||||
}
|
||||
if (trimmed.length() > maxLength) {
|
||||
throw new BusinessException(400, field + " 超长(>" + maxLength + ")");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 清洗对象 key 段落:路径分隔符等换成下划线,剔除「.」「..」防穿越。 */
|
||||
private String safeSegment(String value) {
|
||||
String cleaned = UNSAFE_SEGMENT.matcher(value == null ? "" : value.trim()).replaceAll("_");
|
||||
if (cleaned.isEmpty() || cleaned.equals(".") || cleaned.equals("..")) {
|
||||
return "_";
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static byte[] gunzip(byte[] gz) throws IOException {
|
||||
try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(gz));
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(64, gz.length * 4))) {
|
||||
in.transferTo(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static int indexOfNewline(byte[] bytes, int from) {
|
||||
for (int i = Math.max(0, from); i < bytes.length; i++) {
|
||||
if (bytes[i] == '\n') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int nvl(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.nanri.aiimage.modules.devicelog.storage;
|
||||
|
||||
import com.nanri.aiimage.config.DeviceLogOssProperties;
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.GetObjectResponse;
|
||||
import io.minio.ListObjectsArgs;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectsArgs;
|
||||
import io.minio.Result;
|
||||
import io.minio.messages.DeleteError;
|
||||
import io.minio.messages.DeleteObject;
|
||||
import io.minio.messages.Item;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备日志的独立对象存储客户端(主机B 自建 MinIO,非业务 OSS)。
|
||||
*
|
||||
* <p>只存 gzip 片段原文,不做重压缩;桶的 7 天过期规则在部署时由运维用 mc 配置,
|
||||
* 本类不负责生命周期管理。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceLogStorageService {
|
||||
|
||||
private final DeviceLogOssProperties properties;
|
||||
|
||||
private volatile MinioClient client;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
if (!properties.configured()) {
|
||||
log.warn("[device-log] aiimage.device-log-oss 未配置(endpoint/凭据/桶),"
|
||||
+ "日志上报与管理接口将不可用;生产必须通过 AIIMAGE_DEVICE_LOG_OSS_* 环境变量注入");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
MinioClient built = MinioClient.builder()
|
||||
.endpoint(properties.getEndpoint())
|
||||
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
|
||||
.build();
|
||||
// 收紧超时:日志接口不能被慢存储拖挂(默认读超时 5 分钟)
|
||||
built.setTimeout(10_000, 60_000, 60_000);
|
||||
boolean exists = built.bucketExists(BucketExistsArgs.builder()
|
||||
.bucket(properties.getBucket()).build());
|
||||
if (!exists) {
|
||||
built.makeBucket(MakeBucketArgs.builder().bucket(properties.getBucket()).build());
|
||||
log.info("[device-log] 已创建日志桶 bucket={}", properties.getBucket());
|
||||
}
|
||||
this.client = built;
|
||||
log.info("[device-log] 日志对象存储已就绪 endpoint={} bucket={} 保留天数={}",
|
||||
properties.getEndpoint(), properties.getBucket(), properties.retentionDaysOrDefault());
|
||||
} catch (Exception ex) {
|
||||
log.error("[device-log] 日志对象存储初始化失败 endpoint={} bucket={},日志功能不可用: {}",
|
||||
properties.getEndpoint(), properties.getBucket(), ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return client != null;
|
||||
}
|
||||
|
||||
public String bucket() {
|
||||
return properties.getBucket();
|
||||
}
|
||||
|
||||
/** 写入一个 gzip 片段(同 key 覆盖写,幂等)。 */
|
||||
public void putPart(String objectKey, byte[] gzipBytes) {
|
||||
MinioClient c = requireClient();
|
||||
try (ByteArrayInputStream in = new ByteArrayInputStream(gzipBytes)) {
|
||||
c.putObject(PutObjectArgs.builder()
|
||||
.bucket(bucket())
|
||||
.object(objectKey)
|
||||
.stream(in, gzipBytes.length, -1)
|
||||
.contentType("application/gzip")
|
||||
.build());
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("写日志对象失败 key=" + objectKey + " err=" + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 列出前缀下全部对象 key(按 key 升序;key 内的 offset 为零填充,字典序即偏移序)。 */
|
||||
public List<String> listParts(String prefix) {
|
||||
MinioClient c = requireClient();
|
||||
List<String> keys = new ArrayList<>();
|
||||
try {
|
||||
Iterable<Result<Item>> results = c.listObjects(ListObjectsArgs.builder()
|
||||
.bucket(bucket())
|
||||
.prefix(prefix)
|
||||
.recursive(true)
|
||||
.build());
|
||||
for (Result<Item> result : results) {
|
||||
keys.add(result.get().objectName());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("列日志对象失败 prefix=" + prefix + " err=" + ex.getMessage(), ex);
|
||||
}
|
||||
keys.sort(String::compareTo);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** 读取一个 gzip 片段的原始字节(未解压)。 */
|
||||
public byte[] readPartBytes(String objectKey) {
|
||||
MinioClient c = requireClient();
|
||||
try (GetObjectResponse response = c.getObject(GetObjectArgs.builder()
|
||||
.bucket(bucket()).object(objectKey).build())) {
|
||||
return response.readAllBytes();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("读日志对象失败 key=" + objectKey + " err=" + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开一个 gzip 片段的流(调用方负责关闭;下载拼接时避免整段进内存)。 */
|
||||
public InputStream openPartStream(String objectKey) {
|
||||
MinioClient c = requireClient();
|
||||
try {
|
||||
return c.getObject(GetObjectArgs.builder()
|
||||
.bucket(bucket()).object(objectKey).build());
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("打开日志对象流失败 key=" + objectKey + " err=" + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量删除对象;返回删除失败的 key 列表。 */
|
||||
public List<String> deleteParts(List<String> objectKeys) {
|
||||
if (objectKeys == null || objectKeys.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
MinioClient c = requireClient();
|
||||
List<DeleteObject> targets = objectKeys.stream().map(DeleteObject::new).toList();
|
||||
List<String> failed = new ArrayList<>();
|
||||
try {
|
||||
Iterable<Result<DeleteError>> results = c.removeObjects(RemoveObjectsArgs.builder()
|
||||
.bucket(bucket()).objects(targets).build());
|
||||
for (Result<DeleteError> result : results) {
|
||||
DeleteError error = result.get();
|
||||
failed.add(error.objectName());
|
||||
log.warn("[device-log] 删除对象失败 key={} err={}", error.objectName(), error.message());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("批量删除日志对象失败 err=" + ex.getMessage(), ex);
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** 探测连通性(上传接口的错误提示用)。 */
|
||||
public boolean ping() {
|
||||
if (client == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return client.bucketExists(BucketExistsArgs.builder().bucket(bucket()).build());
|
||||
} catch (Exception ex) {
|
||||
log.warn("[device-log] 存储连通性探测失败: {}", ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private MinioClient requireClient() {
|
||||
MinioClient c = client;
|
||||
if (c == null) {
|
||||
throw new IllegalStateException("日志对象存储未配置或初始化失败");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
+12
-7
@@ -6,6 +6,7 @@ import com.nanri.aiimage.common.util.SecretMasking;
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.config.UserSecretProperties;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -29,6 +30,8 @@ import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 密钥连通性探测:调一次 LLM /v1/chat/completions,能访问通(2xx 且返回 choices)即通过。
|
||||
* 检测模型独立配置(aiimage.user-secret.check-model,默认便宜模型 doubao-seed-2-0-lite-260215),
|
||||
* 与业务任务模型(gemini-3.8-flash 等)无关。
|
||||
* 无副作用(落库由 UserApiSecretService 负责)、不重试;
|
||||
* 出口默认直连,配置了提取链接时优先经代理、代理网络不可达自动回退直连。
|
||||
*/
|
||||
@@ -75,6 +78,7 @@ public class UserApiSecretCheckService {
|
||||
|
||||
private final AppearancePatentProperties appearancePatentProperties;
|
||||
private final SimilarAsinProperties similarAsinProperties;
|
||||
private final UserSecretProperties userSecretProperties;
|
||||
private final JikipProxyClient jikipProxyClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -294,8 +298,9 @@ public class UserApiSecretCheckService {
|
||||
}
|
||||
|
||||
private CheckOutcome probeOnce(UserSecretModule module, String plainApiKey, String proxyUrl, boolean viaProxy) {
|
||||
UserSecretModule.LlmTarget target = module.resolveLlmTarget(appearancePatentProperties, similarAsinProperties);
|
||||
String url = joinUrl(target.host(), "/v1/chat/completions");
|
||||
String host = module.resolveLlmHost(appearancePatentProperties, similarAsinProperties);
|
||||
String model = userSecretProperties.getCheckModel();
|
||||
String url = joinUrl(host, "/v1/chat/completions");
|
||||
String key = stripBearer(plainApiKey);
|
||||
long startMillis = System.currentTimeMillis();
|
||||
String viaText = viaProxy ? "经代理" : "直连";
|
||||
@@ -307,20 +312,20 @@ public class UserApiSecretCheckService {
|
||||
headers.setContentType(APPLICATION_JSON_UTF8);
|
||||
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
|
||||
})
|
||||
.body(buildCheckBody(target.model()))
|
||||
.body(buildCheckBody(model))
|
||||
.exchange((request, response) -> new StatusAndBody(
|
||||
response.getStatusCode().value(),
|
||||
readResponseBodyBounded(response.getBody())));
|
||||
long latency = System.currentTimeMillis() - startMillis;
|
||||
CheckOutcome outcome = classify(statusAndBody.statusCode(), statusAndBody.body(), (int) latency, viaProxy);
|
||||
log.info("[user-secret][check] {}探测完成 module={} status={} code={} httpStatus={} latency={}ms",
|
||||
viaText, module.key(), outcome.status(), outcome.code(), statusAndBody.statusCode(), latency);
|
||||
log.info("[user-secret][check] {}探测完成 module={} model={} status={} code={} httpStatus={} latency={}ms",
|
||||
viaText, module.key(), model, outcome.status(), outcome.code(), statusAndBody.statusCode(), latency);
|
||||
return outcome;
|
||||
} catch (Exception ex) {
|
||||
long latency = System.currentTimeMillis() - startMillis;
|
||||
CheckOutcome outcome = classifyTransportFailure(ex, (int) latency, viaProxy);
|
||||
log.warn("[user-secret][check] {}探测异常 module={} latency={}ms code={} err={}",
|
||||
viaText, module.key(), latency, outcome.code(), ex.getMessage());
|
||||
log.warn("[user-secret][check] {}探测异常 module={} model={} latency={}ms code={} err={}",
|
||||
viaText, module.key(), model, latency, outcome.code(), ex.getMessage());
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-11
@@ -41,16 +41,12 @@ public enum UserSecretModule {
|
||||
return required;
|
||||
}
|
||||
|
||||
/** 检测目标:LLM 主机 + 模型(仅 LLM 类模块;代理模块没有 LLM 目标)。 */
|
||||
public LlmTarget resolveLlmTarget(AppearancePatentProperties appearancePatentProperties,
|
||||
/** 检测目标主机:各 LLM 模块自己的 API 主机(检测模型由 aiimage.user-secret.check-model 统一指定)。 */
|
||||
public String resolveLlmHost(AppearancePatentProperties appearancePatentProperties,
|
||||
SimilarAsinProperties similarAsinProperties) {
|
||||
return switch (this) {
|
||||
case APPEARANCE_PATENT -> new LlmTarget(
|
||||
appearancePatentProperties.getLlmHost(),
|
||||
appearancePatentProperties.getTitleModel());
|
||||
case SIMILAR_ASIN -> new LlmTarget(
|
||||
similarAsinProperties.getLlmHost(),
|
||||
similarAsinProperties.getLlmCategoryModel());
|
||||
case APPEARANCE_PATENT -> appearancePatentProperties.getLlmHost();
|
||||
case SIMILAR_ASIN -> similarAsinProperties.getLlmHost();
|
||||
case PROXY -> throw new IllegalStateException("代理模块没有 LLM 检测目标");
|
||||
};
|
||||
}
|
||||
@@ -72,7 +68,4 @@ public enum UserSecretModule {
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public record LlmTarget(String host, String model) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,15 @@ aiimage:
|
||||
shop-data-bucket: ${AIIMAGE_OSS_SHOP_DATA_BUCKET:shufu-shop-data}
|
||||
access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:}
|
||||
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:}
|
||||
# 设备日志对象存储:主机B 独立 MinIO(与业务 OSS 隔离,日志只保留 7 天)。
|
||||
# endpoint 未配置时日志上报/管理接口不可用(宁可失败也不静默落到业务桶)。
|
||||
# 桶的 7 天过期规则由运维用 mc 配置(与 retention-days 口径一致)。
|
||||
device-log-oss:
|
||||
endpoint: ${AIIMAGE_DEVICE_LOG_OSS_ENDPOINT:}
|
||||
bucket: ${AIIMAGE_DEVICE_LOG_OSS_BUCKET:device-logs}
|
||||
access-key-id: ${AIIMAGE_DEVICE_LOG_OSS_ACCESS_KEY_ID:}
|
||||
access-key-secret: ${AIIMAGE_DEVICE_LOG_OSS_ACCESS_KEY_SECRET:}
|
||||
retention-days: ${AIIMAGE_DEVICE_LOG_RETENTION_DAYS:7}
|
||||
transient-storage:
|
||||
enabled: ${AIIMAGE_TRANSIENT_STORAGE_ENABLED:true}
|
||||
endpoint: ${AIIMAGE_TRANSIENT_STORAGE_ENDPOINT:http://121.196.149.225:9000}
|
||||
@@ -334,6 +343,8 @@ aiimage:
|
||||
check-cron: ${AIIMAGE_USER_SECRET_CHECK_CRON:0 30 4 * * *}
|
||||
check-max-rows: ${AIIMAGE_USER_SECRET_CHECK_MAX_ROWS:500}
|
||||
check-budget-minutes: ${AIIMAGE_USER_SECRET_CHECK_BUDGET_MINUTES:20}
|
||||
# 检测模型:密钥检测专用便宜模型(业务任务模型各自独立配置)
|
||||
check-model: ${AIIMAGE_USER_SECRET_CHECK_MODEL:doubao-seed-2-0-lite-260215}
|
||||
# 检测出口代理提取链接:留空=直连;配置后检测优先经代理、失败回退直连
|
||||
check-proxy-extract-url: ${AIIMAGE_USER_SECRET_CHECK_PROXY_EXTRACT_URL:}
|
||||
jikip-balance-url: ${AIIMAGE_USER_SECRET_JIKIP_BALANCE_URL:https://api.jikip.com/find-balance}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- V127: 设备日志管理(桌面客户端 / 麦象采集机日志同步到云端,后台「日志管理」排查)
|
||||
--
|
||||
-- 存储模型:内容以 gzip 增量片段存主机B 独立 MinIO(device-logs/ 前缀,桶 7 天生命周期
|
||||
-- 由运维 mc 配置);本表只存文件级元数据与上传进度(uploaded_bytes 供客户端断点续传对齐)。
|
||||
-- 采集配置:device_log_config 存全局默认与终端覆盖(full 全量 / selected 精选)。
|
||||
--
|
||||
-- 幂等:建表 IF NOT EXISTS;菜单/种子行仅当不存在时插入。重复执行安全。
|
||||
-- 回滚:DROP TABLE device_log_file; DROP TABLE device_log_config; DELETE FROM columns WHERE column_key='admin_device_logs';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `device_log_file` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`source` VARCHAR(32) NOT NULL COMMENT '来源:client(桌面客户端)/ maixiang(麦象采集机)',
|
||||
`device_id` VARCHAR(128) NOT NULL COMMENT '设备标识(客户端硬件指纹 / 麦象主机名)',
|
||||
`device_name` VARCHAR(255) NULL COMMENT '展示名:客户端登录用户名或机器名',
|
||||
`uid` BIGINT NULL COMMENT '桌面客户端当前登录用户 users.id(可空)',
|
||||
`file_name` VARCHAR(255) NOT NULL COMMENT '日志文件名(可含子目录,如 API/2026_09_15.log)',
|
||||
`log_date` DATE NOT NULL COMMENT '日志归属日期',
|
||||
`uploaded_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT '已上传明文字节数(客户端断点)',
|
||||
`part_count` INT NOT NULL DEFAULT 0 COMMENT '已收片段数',
|
||||
`last_upload_at` DATETIME NULL COMMENT '最近一次收到片段的时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_device_log_file` (`source`, `device_id`, `file_name`, `log_date`),
|
||||
KEY `idx_device_log_last_upload` (`last_upload_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备日志文件元数据(内容在主机B 独立 MinIO,保留7天)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `device_log_config` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`scope` VARCHAR(16) NOT NULL COMMENT 'global(全局默认)/ device(终端覆盖)',
|
||||
`source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '终端覆盖的来源(global 行存空串)',
|
||||
`device_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '终端覆盖的设备(global 行存空串)',
|
||||
`device_name` VARCHAR(255) NULL COMMENT '覆盖行设备展示名(列表用)',
|
||||
`mode` VARCHAR(16) NOT NULL COMMENT 'full(全量)/ selected(精选)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_device_log_config` (`scope`, `source`, `device_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日志采集配置(全局默认 + 终端覆盖)';
|
||||
|
||||
-- 全局默认:full(全量采集);后台可在「日志管理 → 采集配置」修改
|
||||
INSERT INTO `device_log_config` (`scope`, `source`, `device_id`, `mode`)
|
||||
SELECT 'global', '', '', 'full'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `device_log_config` WHERE `scope` = 'global');
|
||||
|
||||
-- 后台菜单:日志管理(挂在「记录与版本」分组下;幂等,仅当 column_key 不存在时插入)
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
|
||||
SELECT '日志管理', 'admin_device_logs', 'admin', 'records/device-logs', 83, parent.id
|
||||
FROM columns parent
|
||||
WHERE parent.column_key = 'admin_group_record'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = 'admin_device_logs'
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- V128: 通用配置表 app_config(首个用途:数富AI 工作台「开店流程」访问密码改服务端校验)
|
||||
--
|
||||
-- 背景:工作台源码里写死了开店流程访问密码(KD_FLOW_PASSWORD),改一次密码就得重新打包装包发给
|
||||
-- 全部用户。改为工作台把用户输入的密码发到 POST /api/kd-flow/verify 由服务端比对,密码存本表,
|
||||
-- 改密码只需 UPDATE 一行数据,客户端无需重新发布。
|
||||
--
|
||||
-- 与"账号密码"的区别:工作台登录用的是 users 表里后台分配的账号(POST /login 校验),
|
||||
-- 客户端源码里不存在任何写死的登录账号;本表只放这类低价值的模块访问口令。
|
||||
--
|
||||
-- 幂等:建表 IF NOT EXISTS;种子行靠 config_key 唯一键 + INSERT IGNORE,重复执行不会覆盖已改过的值。
|
||||
-- 回滚:DROP TABLE app_config;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_config` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`config_key` VARCHAR(64) NOT NULL COMMENT '配置键(唯一)',
|
||||
`config_value` VARCHAR(512) NOT NULL COMMENT '配置值(明文,仅放低价值口令,勿放密钥)',
|
||||
`remark` VARCHAR(255) NULL COMMENT '说明',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_app_config_key` (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通用应用配置(键值)';
|
||||
|
||||
INSERT IGNORE INTO `app_config` (`config_key`, `config_value`, `remark`)
|
||||
VALUES ('kd_flow_password', 'hjx6688', '工作台「开店流程」模块访问密码(服务端校验,改这里即生效)');
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.nanri.aiimage.modules.appconfig.service;
|
||||
|
||||
import com.nanri.aiimage.modules.appconfig.mapper.AppConfigMapper;
|
||||
import com.nanri.aiimage.modules.appconfig.model.entity.AppConfigEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 开店流程访问密码服务端校验单测。
|
||||
*
|
||||
* <p>重点覆盖"宁可锁死也不放行":配置缺失/为空一律判失败,避免 app_config 还没落库时
|
||||
* 出现"空密码即可通过"。
|
||||
*/
|
||||
class KdFlowServiceTest {
|
||||
|
||||
private final AppConfigMapper mapper = mock(AppConfigMapper.class);
|
||||
|
||||
private KdFlowService serviceWith(String configured) {
|
||||
when(mapper.selectOne(any())).thenReturn(row(configured));
|
||||
return new KdFlowService(mapper);
|
||||
}
|
||||
|
||||
private static AppConfigEntity row(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
AppConfigEntity e = new AppConfigEntity();
|
||||
e.setConfigKey(KdFlowService.PASSWORD_KEY);
|
||||
e.setConfigValue(value);
|
||||
return e;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 密码正确时通过() {
|
||||
assertThat(serviceWith("hjx6688").matches("hjx6688")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 密码错误时不通过() {
|
||||
assertThat(serviceWith("hjx6688").matches("hjx6699")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 输入首尾空格按去掉后比对() {
|
||||
assertThat(serviceWith("hjx6688").matches(" hjx6688 ")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 输入为空或null时不通过() {
|
||||
KdFlowService s = serviceWith("hjx6688");
|
||||
assertThat(s.matches(null)).isFalse();
|
||||
assertThat(s.matches("")).isFalse();
|
||||
assertThat(s.matches(" ")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 服务端未配置该键时一律不通过() {
|
||||
assertThat(serviceWith(null).matches("hjx6688")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 服务端配置为空串时一律不通过() {
|
||||
assertThat(serviceWith("").matches("")).isFalse();
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package com.nanri.aiimage.modules.devicelog.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.config.DeviceLogOssProperties;
|
||||
import com.nanri.aiimage.modules.devicelog.mapper.DeviceLogFileMapper;
|
||||
import com.nanri.aiimage.modules.devicelog.model.entity.DeviceLogFileEntity;
|
||||
import com.nanri.aiimage.modules.devicelog.model.vo.DeviceLogContentVo;
|
||||
import com.nanri.aiimage.modules.devicelog.storage.DeviceLogStorageService;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class DeviceLogServiceTest {
|
||||
|
||||
private static final LocalDate LOG_DATE = LocalDate.of(2026, 9, 15);
|
||||
|
||||
/** 单测无 Spring 上下文,需手动注册实体 TableInfo 供 LambdaWrapper 解析列名。 */
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
DeviceLogFileEntity.class);
|
||||
}
|
||||
|
||||
private final DeviceLogFileMapper mapper = mock(DeviceLogFileMapper.class);
|
||||
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
|
||||
private final DeviceLogStorageService storage = mock(DeviceLogStorageService.class);
|
||||
private final DeviceLogOssProperties properties = new DeviceLogOssProperties();
|
||||
|
||||
private DeviceLogService service() {
|
||||
properties.setRetentionDays(7);
|
||||
when(storage.enabled()).thenReturn(true);
|
||||
return new DeviceLogService(mapper, adminUserMapper, storage, properties);
|
||||
}
|
||||
|
||||
private static DeviceLogFileEntity row(long uploadedBytes, int parts) {
|
||||
DeviceLogFileEntity row = new DeviceLogFileEntity();
|
||||
row.setId(1L);
|
||||
row.setSource("client");
|
||||
row.setDeviceId("dev-1");
|
||||
row.setFileName("2026_09_15.log");
|
||||
row.setLogDate(LOG_DATE);
|
||||
row.setUploadedBytes(uploadedBytes);
|
||||
row.setPartCount(parts);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static byte[] gzip(String text) throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try (GZIPOutputStream gz = new GZIPOutputStream(out)) {
|
||||
gz.write(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstPartAppendsAndAdvances() throws Exception {
|
||||
when(mapper.selectOne(any())).thenReturn(row(0L, 0));
|
||||
when(mapper.update(isNull(), any())).thenReturn(1);
|
||||
byte[] payload = gzip("line-1\n");
|
||||
|
||||
DeviceLogService.PartResult result = service().recordPart("client", "dev-1", "PC-A", 7L,
|
||||
"2026_09_15.log", LOG_DATE, 0L, 7L, payload);
|
||||
|
||||
assertThat(result.accepted()).isTrue();
|
||||
assertThat(result.skipped()).isFalse();
|
||||
assertThat(result.uploadedBytes()).isEqualTo(7L);
|
||||
assertThat(result.partCount()).isEqualTo(1);
|
||||
verify(storage).putPart(
|
||||
contains("device-logs/client/dev-1/2026-09-15/2026_09_15.log/000000000000.log.gz"),
|
||||
eq(payload));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateOffsetIsIdempotentlySkipped() throws Exception {
|
||||
when(mapper.selectOne(any())).thenReturn(row(500L, 3));
|
||||
|
||||
DeviceLogService.PartResult result = service().recordPart("client", "dev-1", null, null,
|
||||
"2026_09_15.log", LOG_DATE, 100L, 7L, gzip("old\n"));
|
||||
|
||||
assertThat(result.accepted()).isFalse();
|
||||
assertThat(result.skipped()).isTrue();
|
||||
assertThat(result.uploadedBytes()).isEqualTo(500L);
|
||||
verify(storage, never()).putPart(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gapOffsetIsRejectedWith409() throws Exception {
|
||||
when(mapper.selectOne(any())).thenReturn(row(100L, 1));
|
||||
|
||||
assertThatThrownBy(() -> service().recordPart("client", "dev-1", null, null,
|
||||
"2026_09_15.log", LOG_DATE, 500L, 7L, gzip("jump\n")))
|
||||
.isInstanceOfSatisfying(BusinessException.class, ex -> {
|
||||
assertThat(ex.getCode()).isEqualTo(409);
|
||||
assertThat(ex.getMessage()).contains("偏移不连续");
|
||||
});
|
||||
verify(storage, never()).putPart(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstUploadRegistersRow() throws Exception {
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
when(mapper.insert(any(DeviceLogFileEntity.class))).thenReturn(1);
|
||||
when(mapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
service().recordPart("maixiang", "host-9", "host-9", null,
|
||||
"kk-browser.log", LOG_DATE, 0L, 10L, gzip("boot\n"));
|
||||
|
||||
ArgumentCaptor<DeviceLogFileEntity> captor = ArgumentCaptor.forClass(DeviceLogFileEntity.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
DeviceLogFileEntity inserted = captor.getValue();
|
||||
assertThat(inserted.getSource()).isEqualTo("maixiang");
|
||||
assertThat(inserted.getDeviceId()).isEqualTo("host-9");
|
||||
assertThat(inserted.getFileName()).isEqualTo("kk-browser.log");
|
||||
assertThat(inserted.getLogDate()).isEqualTo(LOG_DATE);
|
||||
assertThat(inserted.getUploadedBytes()).isZero();
|
||||
assertThat(inserted.getPartCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidSource() throws Exception {
|
||||
assertThatThrownBy(() -> service().recordPart("BAD SOURCE!", "dev-1", null, null,
|
||||
"a.log", LOG_DATE, 0L, 10L, gzip("x\n")))
|
||||
.isInstanceOf(BusinessException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedPart() throws Exception {
|
||||
assertThatThrownBy(() -> service().recordPart("client", "dev-1", null, null,
|
||||
"a.log", LOG_DATE, 0L, 100L * 1024 * 1024, gzip("x\n")))
|
||||
.isInstanceOf(BusinessException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readTailMergesPartsAndTrimsToLineBoundary() throws Exception {
|
||||
String partA = "A".repeat(10000) + "\n";
|
||||
String partB = "B".repeat(10000) + "\n";
|
||||
when(mapper.selectById(1L)).thenReturn(row(partA.length() + partB.length(), 2));
|
||||
when(storage.listParts(any())).thenReturn(List.of(
|
||||
"device-logs/client/dev-1/2026-09-15/2026_09_15.log/000000000000.log.gz",
|
||||
"device-logs/client/dev-1/2026-09-15/2026_09_15.log/000000000010001.log.gz"));
|
||||
when(storage.readPartBytes(contains("000000000000"))).thenReturn(gzip(partA));
|
||||
when(storage.readPartBytes(contains("000000010001"))).thenReturn(gzip(partB));
|
||||
|
||||
DeviceLogContentVo vo = service().readTail(1L, 16L * 1024L);
|
||||
|
||||
// 两段合计超过 16KB:裁到最近行边界,只留较新的 B 段,且不留半行
|
||||
assertThat(vo.getContent()).isEqualTo(partB);
|
||||
assertThat(vo.getShownBytes()).isEqualTo(partB.length());
|
||||
assertThat(vo.isTruncated()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void readTailReturnsWholeContentWhenUnderLimit() throws Exception {
|
||||
when(mapper.selectById(1L)).thenReturn(row(10L, 2));
|
||||
when(storage.listParts(any())).thenReturn(List.of(
|
||||
"device-logs/client/dev-1/2026-09-15/2026_09_15.log/000000000000.log.gz",
|
||||
"device-logs/client/dev-1/2026-09-15/2026_09_15.log/000000000005.log.gz"));
|
||||
when(storage.readPartBytes(contains("000000000000"))).thenReturn(gzip("AAAA\n"));
|
||||
when(storage.readPartBytes(contains("000000000005"))).thenReturn(gzip("BBBB\n"));
|
||||
|
||||
DeviceLogContentVo vo = service().readTail(1L, 16L * 1024L);
|
||||
|
||||
assertThat(vo.getContent()).isEqualTo("AAAA\nBBBB\n");
|
||||
assertThat(vo.isTruncated()).isFalse();
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -16,6 +16,7 @@ class UserApiSecretCheckServiceTest {
|
||||
private final UserApiSecretCheckService service = new UserApiSecretCheckService(
|
||||
new AppearancePatentProperties(),
|
||||
new SimilarAsinProperties(),
|
||||
new com.nanri.aiimage.config.UserSecretProperties(),
|
||||
mock(JikipProxyClient.class),
|
||||
new ObjectMapper());
|
||||
|
||||
@@ -72,7 +73,8 @@ class UserApiSecretCheckServiceTest {
|
||||
void probeFailsWithInsufficientBalanceWhenProviderOverdrawn() {
|
||||
JikipProxyClient jikip = mock(JikipProxyClient.class);
|
||||
UserApiSecretCheckService probeService = new UserApiSecretCheckService(
|
||||
new AppearancePatentProperties(), new SimilarAsinProperties(), jikip, new ObjectMapper());
|
||||
new AppearancePatentProperties(), new SimilarAsinProperties(),
|
||||
new com.nanri.aiimage.config.UserSecretProperties(), jikip, new ObjectMapper());
|
||||
when(jikip.isExtractConfigured()).thenReturn(true);
|
||||
when(jikip.fetchProxyUrl()).thenThrow(new JikipProxyClient.InsufficientBalanceException());
|
||||
|
||||
|
||||
@@ -24,6 +24,27 @@ export interface ClientChangelogEntry {
|
||||
|
||||
/** 更新日志数据(新版本在前;发版时在数组最前追加一条) */
|
||||
export const CLIENT_CHANGELOG: ClientChangelogEntry[] = [
|
||||
{
|
||||
version: '4.0.21',
|
||||
date: '2026-09-15',
|
||||
items: [
|
||||
'新增设备标识文件:客户端程序目录下会生成 code.txt,遇到问题时把文件里的标识发给客服,可快速查询本机日志',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '4.0.20',
|
||||
date: '2026-09-15',
|
||||
items: [
|
||||
'新增运行日志自动同步:本地日志会(压缩后)上传到云端后台,遇到问题客服可远程排查,不用再手动发日志文件(云端保留 7 天)',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '4.0.19',
|
||||
date: '2026-09-15',
|
||||
items: [
|
||||
'修复:变体采集提交任务时报「未配置 MinIO 凭据」导致无法开始采集的问题',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '4.0.18',
|
||||
date: '2026-09-15',
|
||||
|
||||
@@ -626,6 +626,8 @@ defineExpose({ saveAll, reload })
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-btn:hover:not(:disabled) {
|
||||
|
||||
Reference in New Issue
Block a user