diff --git a/admin-frontend-vue/src/api/device-logs.ts b/admin-frontend-vue/src/api/device-logs.ts new file mode 100644 index 00000000..558069d9 --- /dev/null +++ b/admin-frontend-vue/src/api/device-logs.ts @@ -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 { + const query: Record = { 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(data) +} + +/** 查看日志尾部内容:GET /api/admin/device-logs/content */ +export async function fetchDeviceLogContent(fileId: number, maxBytes?: number): Promise { + const query: Record = { fileId } + if (maxBytes) query.maxBytes = maxBytes + const { data } = await http.get('/api/admin/device-logs/content', { params: query }) + return unwrap(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 { + const { data } = await http.delete(`/api/admin/device-logs/${id}`) + unwrap(data) +} + +/** 采集配置(全局默认 + 终端覆盖):GET /api/admin/device-logs/config */ +export async function fetchDeviceLogConfig(keyword?: string): Promise { + const { data } = await http.get('/api/admin/device-logs/config', { + params: keyword ? { keyword } : undefined, + }) + return unwrap(data) +} + +/** 最近上报过的终端(覆盖选择用):GET /api/admin/device-logs/devices */ +export async function fetchDeviceLogDevices(): Promise { + const { data } = await http.get('/api/admin/device-logs/devices') + return unwrap(data) +} + +/** 设置全局采集模式:PUT /api/admin/device-logs/config/global */ +export async function updateDeviceLogGlobalMode(mode: string): Promise { + const { data } = await http.put('/api/admin/device-logs/config/global', undefined, { params: { mode } }) + unwrap(data) +} + +/** 设置/更新终端覆盖:PUT /api/admin/device-logs/config/device */ +export async function updateDeviceLogOverride( + source: string, + deviceId: string, + deviceName: string | null, + mode: string, +): Promise { + const { data } = await http.put('/api/admin/device-logs/config/device', undefined, { + params: { source, deviceId, deviceName: deviceName || undefined, mode }, + }) + unwrap(data) +} + +/** 删除终端覆盖(回落到全局默认):DELETE /api/admin/device-logs/config/device/{id} */ +export async function deleteDeviceLogOverride(id: number): Promise { + const { data } = await http.delete(`/api/admin/device-logs/config/device/${id}`) + unwrap(data) +} diff --git a/admin-frontend-vue/src/pages/records/DeviceLogsPage.vue b/admin-frontend-vue/src/pages/records/DeviceLogsPage.vue new file mode 100644 index 00000000..7feca43d --- /dev/null +++ b/admin-frontend-vue/src/pages/records/DeviceLogsPage.vue @@ -0,0 +1,719 @@ + + + + + diff --git a/admin-frontend-vue/src/router/routes.ts b/admin-frontend-vue/src/router/routes.ts index d7b70302..e13e9576 100644 --- a/admin-frontend-vue/src/router/routes.ts +++ b/admin-frontend-vue/src/router/routes.ts @@ -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') }, diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/DeviceLogOssProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/DeviceLogOssProperties.java new file mode 100644 index 00000000..c5353804 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/DeviceLogOssProperties.java @@ -0,0 +1,38 @@ +package com.nanri.aiimage.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 设备日志对象存储配置:指向主机B 独立部署的 MinIO 实例(非业务 MinIO)。 + * + *

日志体积大、只保留 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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java index 34a4026d..c4eb4235 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java @@ -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 { } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java index b6a79145..ffaeb181 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java @@ -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 发出, * 代理不可用时自动回退直连;留空则全部直连。 diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/controller/KdFlowController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/controller/KdFlowController.java new file mode 100644 index 00000000..28feaaea --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/controller/KdFlowController.java @@ -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; + +/** + * 工作台「开店流程」模块访问密码校验(公开接口,密码本身就是凭据,不额外要求登录态)。 + * + *

客户端只在用户点开「开店流程」分组时调用一次;返回体只给 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> verify(@RequestBody(required = false) Map 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)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/mapper/AppConfigMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/mapper/AppConfigMapper.java new file mode 100644 index 00000000..62c55456 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/mapper/AppConfigMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/model/entity/AppConfigEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/model/entity/AppConfigEntity.java new file mode 100644 index 00000000..10fb30f7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/model/entity/AppConfigEntity.java @@ -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)。 + *

只放这类低价值、需要"改一行即生效"的口令,不放密钥类敏感配置。 + */ +@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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/service/KdFlowService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/service/KdFlowService.java new file mode 100644 index 00000000..f006fe96 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appconfig/service/KdFlowService.java @@ -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; + +/** + * 工作台「开店流程」模块访问密码的服务端校验。 + * + *

此前密码写死在客户端源码(KD_FLOW_PASSWORD),改密码必须重新打包装包发给全部用户; + * 改由服务端比对后,改密码只需 UPDATE app_config 一行(key = kd_flow_password)。 + * + *

不缓存:调用频次极低(用户点一次分组头一次),且改密码后应立即生效。 + */ +@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() + .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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/controller/AdminDeviceLogController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/controller/AdminDeviceLogController.java new file mode 100644 index 00000000..086f53a0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/controller/AdminDeviceLogController.java @@ -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 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 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> 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> config(HttpServletRequest request, + @RequestParam(required = false) String keyword) { + requireSuperAdmin(request); + Map data = new LinkedHashMap<>(); + data.put("globalMode", deviceLogConfigService.globalMode()); + List overrides = deviceLogConfigService.listOverrides(keyword); + data.put("overrides", overrides); + return ApiResponse.success(data); + } + + @GetMapping("/devices") + @Operation(summary = "最近上报的终端列表(覆盖选择用)") + public ApiResponse>> devices(HttpServletRequest request) { + requireSuperAdmin(request); + return ApiResponse.success(deviceLogService.recentDevices()); + } + + @PutMapping("/config/global") + @Operation(summary = "设置全局采集模式", description = "mode=full(全量)/ selected(精选)") + public ApiResponse> 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> 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 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, "仅超级管理员可访问日志管理"); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/controller/InternalDeviceLogController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/controller/InternalDeviceLogController.java new file mode 100644 index 00000000..bd3f7106 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/controller/InternalDeviceLogController.java @@ -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> 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 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> 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> config( + HttpServletRequest request, + @RequestParam("source") String source, + @RequestParam("deviceId") String deviceId) { + requireInternal(request, "配置拉取"); + DeviceLogConfigService.EffectiveConfig config = deviceLogConfigService.resolve(source, deviceId); + Map 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, "未授权"); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/mapper/DeviceLogConfigMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/mapper/DeviceLogConfigMapper.java new file mode 100644 index 00000000..c308a4ad --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/mapper/DeviceLogConfigMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/mapper/DeviceLogFileMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/mapper/DeviceLogFileMapper.java new file mode 100644 index 00000000..3bc3f583 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/mapper/DeviceLogFileMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/entity/DeviceLogConfigEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/entity/DeviceLogConfigEntity.java new file mode 100644 index 00000000..58a6bf40 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/entity/DeviceLogConfigEntity.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/entity/DeviceLogFileEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/entity/DeviceLogFileEntity.java new file mode 100644 index 00000000..bc5a2639 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/entity/DeviceLogFileEntity.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogContentVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogContentVo.java new file mode 100644 index 00000000..19f6b6fc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogContentVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogFileVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogFileVo.java new file mode 100644 index 00000000..63263a40 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogFileVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogPageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogPageVo.java new file mode 100644 index 00000000..5d71f804 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/model/vo/DeviceLogPageVo.java @@ -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 items; + private long total; + private long page; + private long pageSize; + /** 服务端保留天数(前端提示「日志仅保留 N 天」)。 */ + private int retentionDays; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogConfigService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogConfigService.java new file mode 100644 index 00000000..f99c6b25 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogConfigService.java @@ -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; + +/** + * 日志采集配置:全局默认 + 终端覆盖(超管在后台「日志管理」调整)。 + * + *

上报端(桌面客户端 / 麦象)定期拉取生效配置:全量上传目录内全部日志; + * 精选模式只上传关键日志(排除清单见 {@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> 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 exclude) { + } + + public EffectiveConfig resolve(String source, String deviceId) { + DeviceLogConfigEntity override = deviceLogConfigMapper.selectOne( + new LambdaQueryWrapper() + .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 selectedExcludes(String mode, String source) { + if (!MODE_SELECTED.equals(mode)) { + return List.of(); + } + List 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 listOverrides(String keyword) { + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .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() + .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() + .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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogService.java new file mode 100644 index 00000000..92734763 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogService.java @@ -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; + +/** + * 设备日志:增量片段接收(桌面客户端 / 麦象采集机上报)与后台查询。 + * + *

存储模型:一个「来源+设备+文件名+日期」一行元数据;内容以 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() + .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 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> wrapperBuilder = countOnly -> { + LambdaQueryWrapper 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 rows = total == 0 ? List.of() + : deviceLogFileMapper.selectList(wrapperBuilder.apply(false) + .orderByDesc(DeviceLogFileEntity::getLastUploadAt) + .orderByDesc(DeviceLogFileEntity::getId) + .last("limit " + offset + "," + safeSize)); + + Map usernameOf = resolveUsernames(rows); + List 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> recentDevices() { + LocalDate minDate = LocalDate.now().minusDays(properties.retentionDaysOrDefault() - 1L); + return deviceLogFileMapper.selectMaps(new QueryWrapper() + .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 partKeys = storage.listParts(prefix); + + Deque 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 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 partKeys = storage.listParts(objectKeyPrefix(row)); + List 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() + .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 resolveUsernames(List rows) { + List uids = rows.stream() + .map(DeviceLogFileEntity::getUid) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (uids.isEmpty()) { + return Map.of(); + } + List users = adminUserMapper.selectBatchIds(uids); + Map map = new java.util.HashMap<>(); + for (AdminUserEntity user : users) { + map.put(user.getId(), user.getUsername()); + } + return map; + } + + private DeviceLogFileVo toVo(DeviceLogFileEntity row, Map 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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/storage/DeviceLogStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/storage/DeviceLogStorageService.java new file mode 100644 index 00000000..46335366 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/devicelog/storage/DeviceLogStorageService.java @@ -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)。 + * + *

只存 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 listParts(String prefix) { + MinioClient c = requireClient(); + List keys = new ArrayList<>(); + try { + Iterable> results = c.listObjects(ListObjectsArgs.builder() + .bucket(bucket()) + .prefix(prefix) + .recursive(true) + .build()); + for (Result 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 deleteParts(List objectKeys) { + if (objectKeys == null || objectKeys.isEmpty()) { + return List.of(); + } + MinioClient c = requireClient(); + List targets = objectKeys.stream().map(DeleteObject::new).toList(); + List failed = new ArrayList<>(); + try { + Iterable> results = c.removeObjects(RemoveObjectsArgs.builder() + .bucket(bucket()).objects(targets).build()); + for (Result 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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java index f0cbe58b..6c3eb9bf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java @@ -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; } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/UserSecretModule.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/UserSecretModule.java index 0059e1a9..4f4bd49b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/UserSecretModule.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/UserSecretModule.java @@ -41,16 +41,12 @@ public enum UserSecretModule { return required; } - /** 检测目标:LLM 主机 + 模型(仅 LLM 类模块;代理模块没有 LLM 目标)。 */ - public LlmTarget resolveLlmTarget(AppearancePatentProperties appearancePatentProperties, - SimilarAsinProperties similarAsinProperties) { + /** 检测目标主机:各 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) { - } } diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 156c0a9d..64b39cf9 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -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} diff --git a/backend-java/src/main/resources/db/V127__device_log.sql b/backend-java/src/main/resources/db/V127__device_log.sql new file mode 100644 index 00000000..c2a58522 --- /dev/null +++ b/backend-java/src/main/resources/db/V127__device_log.sql @@ -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' + ); diff --git a/backend-java/src/main/resources/db/V128__app_config.sql b/backend-java/src/main/resources/db/V128__app_config.sql new file mode 100644 index 00000000..c40bbc76 --- /dev/null +++ b/backend-java/src/main/resources/db/V128__app_config.sql @@ -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', '工作台「开店流程」模块访问密码(服务端校验,改这里即生效)'); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appconfig/service/KdFlowServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appconfig/service/KdFlowServiceTest.java new file mode 100644 index 00000000..77428e81 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appconfig/service/KdFlowServiceTest.java @@ -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; + +/** + * 开店流程访问密码服务端校验单测。 + * + *

重点覆盖"宁可锁死也不放行":配置缺失/为空一律判失败,避免 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(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogServiceTest.java new file mode 100644 index 00000000..ba42e19a --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/devicelog/service/DeviceLogServiceTest.java @@ -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 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(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java index f6de7064..6219e46e 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java @@ -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()); diff --git a/frontend-vue/src/shared/client-changelog.ts b/frontend-vue/src/shared/client-changelog.ts index 7ce6075b..36353538 100644 --- a/frontend-vue/src/shared/client-changelog.ts +++ b/frontend-vue/src/shared/client-changelog.ts @@ -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', diff --git a/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue b/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue index db05c971..0ff0ac5f 100644 --- a/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue +++ b/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue @@ -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) {