diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java index 591f9edc..ff79c06d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java @@ -40,6 +40,8 @@ public class OssStorageService { private static final String SHOP_DATA_CRAWL_PREFIX = "result/shop_data_crawl/"; private static final String DIGITAL_HUMAN_PREFIX = "digital-human/versions/"; private static final String SOFTWARE_VERSION_PREFIX = "nanri-image/versions/"; + /** 教程安装包对象键前缀(公开 client 桶,工具台「立即下载教程」直链来源)。 */ + private static final String TUTORIAL_PREFIX = "tutorial/"; private static final String LEGACY_MINIO_ENDPOINT = "http://47.110.241.161:9000"; /** readObjectBytes 兜底读取上限(20MB):防止大对象一次性读入内存导致 OOM。 */ private static final long DEFAULT_READ_MAX_BYTES = 20L * 1024 * 1024; @@ -118,12 +120,13 @@ public class OssStorageService { if (objectKey == null || !objectKey.startsWith(SOFTWARE_VERSION_PREFIX)) { throw new IllegalArgumentException("software version objectKey must start with " + SOFTWARE_VERSION_PREFIX); } - String bucket = softwareVersionBucket(); + String bucket = publicClientBucket(); uploadFile(file, bucket, objectKey); return getPublicUrl(objectKey, bucket); } - private String softwareVersionBucket() { + /** 公开 client 桶(软件版本包与教程包共用):默认 client,可用 aiimage.oss.software-version-bucket 覆盖。 */ + private String publicClientBucket() { String configured = ossProperties.getSoftwareVersionBucket(); return (configured == null || configured.isBlank()) ? "client" : configured; } @@ -139,7 +142,7 @@ public class OssStorageService { try { return buildClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.PUT) - .bucket(softwareVersionBucket()) + .bucket(publicClientBucket()) .object(objectKey) .expiry(Math.max(60, expirySeconds), TimeUnit.SECONDS) .build()); @@ -155,7 +158,7 @@ public class OssStorageService { } try { var stat = buildClient().statObject(StatObjectArgs.builder() - .bucket(softwareVersionBucket()) + .bucket(publicClientBucket()) .object(objectKey) .build()); return stat.size(); @@ -174,7 +177,7 @@ public class OssStorageService { if (objectKey == null || !objectKey.startsWith(SOFTWARE_VERSION_PREFIX)) { throw new IllegalArgumentException("software version objectKey must start with " + SOFTWARE_VERSION_PREFIX); } - return getPublicUrl(objectKey, softwareVersionBucket()); + return getPublicUrl(objectKey, publicClientBucket()); } /** 删除软件版本对象(client 桶);确认环节发现超限等异常时清理残留,避免公共桶悬挂大对象。 */ @@ -184,7 +187,7 @@ public class OssStorageService { } try { buildClient().removeObject(RemoveObjectArgs.builder() - .bucket(softwareVersionBucket()) + .bucket(publicClientBucket()) .object(objectKey) .build()); } catch (Exception ex) { @@ -192,6 +195,68 @@ public class OssStorageService { } } + /** + * 为教程安装包签发浏览器直传用的预签名 PUT URL(公开 client 桶 tutorial/ 前缀)。 + * 签名 host 与反代入口一致,浏览器对返回的 URL 直接 PUT 即可落对象。 + */ + public String presignTutorialUpload(String objectKey, int expirySeconds) { + requireTutorialKey(objectKey); + try { + return buildClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + .method(Method.PUT) + .bucket(publicClientBucket()) + .object(objectKey) + .expiry(Math.max(60, expirySeconds), TimeUnit.SECONDS) + .build()); + } catch (Exception ex) { + throw storageFailure("presign", objectKey, ex); + } + } + + /** 返回教程对象的字节数;对象不存在返回 -1(直传完成后由服务端核对大小是否超限)。 */ + public long tutorialObjectSize(String objectKey) { + requireTutorialKey(objectKey); + try { + var stat = buildClient().statObject(StatObjectArgs.builder() + .bucket(publicClientBucket()) + .object(objectKey) + .build()); + return stat.size(); + } catch (ErrorResponseException ex) { + if (isNotFound(ex)) { + return -1L; + } + throw storageFailure("stat", objectKey, ex); + } catch (Exception ex) { + throw storageFailure("stat", objectKey, ex); + } + } + + /** 生成教程对象的公开下载 URL(与历史直链同构:https://oss.aishufu.top/client/tutorial/...)。 */ + public String getTutorialDownloadUrl(String objectKey) { + requireTutorialKey(objectKey); + return getPublicUrl(objectKey, publicClientBucket()); + } + + /** 删除教程对象(client 桶);后台删除记录时清理无引用对象。 */ + public void removeTutorialObject(String objectKey) { + requireTutorialKey(objectKey); + try { + buildClient().removeObject(RemoveObjectArgs.builder() + .bucket(publicClientBucket()) + .object(objectKey) + .build()); + } catch (Exception ex) { + throw storageFailure("delete", objectKey, ex); + } + } + + private void requireTutorialKey(String objectKey) { + if (objectKey == null || !objectKey.startsWith(TUTORIAL_PREFIX)) { + throw new IllegalArgumentException("tutorial objectKey must start with " + TUTORIAL_PREFIX); + } + } + public String uploadText(String objectKey, String content) { if (objectKey == null || objectKey.isBlank()) { throw new IllegalArgumentException("objectKey must not be blank"); @@ -590,7 +655,7 @@ public class OssStorageService { private List configuredBuckets() { return Stream.of(ossProperties.getBucket(), imageVideoBucket(), digitalHumanBucket(), templateBucket(), - softwareVersionBucket(), shopDataBucket()) + publicClientBucket(), shopDataBucket()) .filter(Objects::nonNull) .map(String::trim) .filter(bucket -> !bucket.isBlank()) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/controller/PublicTutorialController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/controller/PublicTutorialController.java new file mode 100644 index 00000000..a31fc787 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/controller/PublicTutorialController.java @@ -0,0 +1,33 @@ +package com.nanri.aiimage.modules.tutorial.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.tutorial.service.TutorialPackageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 教程包公开查询接口:工具台首页「立即下载教程」在页面加载时取最新上传包 + * (无记录时返回空字段,前端回退到历史固定直链)。 + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/tutorial") +@Tag(name = "教程包公开接口", description = "工具台下载入口读取最新教程包") +public class PublicTutorialController { + + private final TutorialPackageService tutorialPackageService; + + @GetMapping("/latest") + @Operation(summary = "最新教程包", description = "返回最新上传的教程压缩包信息(文件名/大小/公开直链/上传时间)") + public ApiResponse> latestTutorial() { + return ApiResponse.success(tutorialPackageService.latestTutorialPackage()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/controller/TutorialAdminController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/controller/TutorialAdminController.java new file mode 100644 index 00000000..44b47091 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/controller/TutorialAdminController.java @@ -0,0 +1,77 @@ +package com.nanri.aiimage.modules.tutorial.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.tutorial.service.TutorialPackageService; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 教程安装包管理(后台「教程管理」页):列表、直传预签名、确认落库、删除。 + *

工具台首页「立即下载教程」读公开接口 /api/tutorial/latest,取最新上传记录。 + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin") +@Tag(name = "教程包管理", description = "工具台下载入口的教程压缩包管理:列表与上传(浏览器直传 MinIO)") +public class TutorialAdminController { + + private final TutorialPackageService tutorialPackageService; + private final AdminAuthSupport adminAuthSupport; + + @GetMapping("/tutorials") + @Operation(summary = "教程包列表", description = "按上传时间倒序返回全部教程包(第一条即工具台下载的最新包)") + public ApiResponse> listTutorials(HttpServletRequest request) { + AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); + log.info("[tutorial] 管理端查询教程包列表 operator={}", operator.getUsername()); + return ApiResponse.success(Map.of("items", tutorialPackageService.listTutorialPackages())); + } + + @PostMapping("/tutorial/presign") + @Operation(summary = "签发教程包直传预签名", description = "返回浏览器直传 MinIO client 桶的 PUT 预签名地址;直传完成后调用 /tutorial/confirm 校验落库") + public ApiResponse> presignTutorial( + HttpServletRequest request, + @Parameter(description = "上传的 zip 文件名") @RequestParam(value = "file_name", required = false) String fileName) { + AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); + log.info("[tutorial] 管理端签发教程包直传预签名 operator={} fileName={}", operator.getUsername(), fileName); + return ApiResponse.success(tutorialPackageService.presignTutorialPackage(fileName)); + } + + @PostMapping("/tutorial/confirm") + @Operation(summary = "确认教程包直传完成", description = "服务端核对 client 桶对象存在与大小并落库,返回新记录行") + public ApiResponse> confirmTutorial( + HttpServletRequest request, + @Parameter(description = "预签名返回的对象 key") @RequestParam(value = "object_key", required = false) String objectKey, + @Parameter(description = "上传的 zip 文件名") @RequestParam(value = "file_name", required = false) String fileName) { + AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); + log.info("[tutorial] 管理端确认教程包直传完成 operator={} objectKey={} fileName={}", + operator.getUsername(), objectKey, fileName); + return ApiResponse.success("上传成功", Map.of("item", tutorialPackageService.confirmTutorialPackage(objectKey, fileName))); + } + + @PostMapping("/tutorial/delete") + @Operation(summary = "批量删除教程包", description = "按 id 删除记录;关联 MinIO 包体仅当无其它记录引用时清除") + public ApiResponse> deleteTutorials( + HttpServletRequest request, + @Parameter(description = "要删除的教程包记录 id 列表") @RequestBody List ids) { + AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); + log.info("[tutorial] 管理端删除教程包 operator={} ids={}", operator.getUsername(), ids); + int deleted = tutorialPackageService.deleteTutorialPackages(ids); + return ApiResponse.success("删除成功", Map.of("deleted", deleted)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/mapper/TutorialPackageMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/mapper/TutorialPackageMapper.java new file mode 100644 index 00000000..86b971d2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/mapper/TutorialPackageMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.tutorial.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.tutorial.model.entity.TutorialPackageEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TutorialPackageMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/model/entity/TutorialPackageEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/model/entity/TutorialPackageEntity.java new file mode 100644 index 00000000..e3bcda42 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/model/entity/TutorialPackageEntity.java @@ -0,0 +1,33 @@ +package com.nanri.aiimage.modules.tutorial.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; + +/** + * 教程客户端安装包记录(工具台首页「立即下载教程」的数据源)。 + *

包体放公开 client 桶 tutorial/ 前缀;下载 URL 由服务端按当前公开端点实时拼装, + * 不入库,避免端点配置变更后历史行 URL 失效。 + */ +@Data +@TableName("biz_tutorial_package") +public class TutorialPackageEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 原始文件名(展示用) */ + private String fileName; + + /** MinIO 对象 key(client 桶,tutorial/ 前缀) */ + private String objectKey; + + /** 文件字节数(0=未知,历史种子行) */ + private Long fileSize; + + /** 上传时间,由数据库 CURRENT_TIMESTAMP 兜底 */ + private LocalDateTime createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/service/TutorialPackageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/service/TutorialPackageService.java new file mode 100644 index 00000000..e5672931 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/tutorial/service/TutorialPackageService.java @@ -0,0 +1,227 @@ +package com.nanri.aiimage.modules.tutorial.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.tutorial.mapper.TutorialPackageMapper; +import com.nanri.aiimage.modules.tutorial.model.entity.TutorialPackageEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 教程安装包管理:后台「教程管理」上传(浏览器直传 MinIO),工具台首页「立即下载教程」 + * 按最新上传记录下载(公开接口 /api/tutorial/latest)。 + *

对象存公开 client 桶 tutorial/ 前缀;每次上传生成带时间戳的新 key, + * 规避下载域按 URL 缓存导致"上传后仍下到旧包"。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TutorialPackageService { + + /** 与软件版本包一致的对象键前缀(client 桶公开路径) */ + public static final String OBJECT_KEY_PREFIX = "tutorial/"; + + /** 教程包与软件版本包同量级,沿用 512MB 上限 */ + private static final long MAX_UPLOAD_BYTES = 512L * 1024 * 1024; + + /** 直传预签名有效期:3 分钟足够数百 MB 级压缩包上传 */ + private static final int PRESIGN_EXPIRY_SECONDS = 180; + + private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private static final DateTimeFormatter OBJECT_NAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** 对齐软件版本包 key 的安全化规则:保留中文/字母/数字/._-,其余替换为 _ */ + private static final Pattern UNSAFE_KEY_CHARS = + Pattern.compile("[^\\w.\\-]", Pattern.UNICODE_CHARACTER_CLASS); + + private final TutorialPackageMapper tutorialPackageMapper; + private final OssStorageService ossStorageService; + + /** 教程包列表:按上传时间倒序(第一条即工具台下载的"最新")。 */ + public List> listTutorialPackages() { + List entities = tutorialPackageMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(TutorialPackageEntity::getCreatedAt) + .orderByDesc(TutorialPackageEntity::getId)); + log.info("[tutorial] 查询教程包列表 count={}", entities.size()); + List> items = new ArrayList<>(entities.size()); + for (TutorialPackageEntity entity : entities) { + items.add(toItemMap(entity)); + } + return items; + } + + /** 最新教程包(工具台下载入口);无记录时返回空字段,前端回退到历史固定直链。 */ + public Map latestTutorialPackage() { + TutorialPackageEntity entity = tutorialPackageMapper.selectOne( + new LambdaQueryWrapper() + .orderByDesc(TutorialPackageEntity::getCreatedAt) + .orderByDesc(TutorialPackageEntity::getId) + .last("LIMIT 1")); + Map result = new LinkedHashMap<>(); + if (entity == null) { + log.info("[tutorial] 查询最新教程包:无记录,前端将回退固定直链"); + result.put("file_name", null); + result.put("file_url", null); + result.put("file_size", null); + result.put("created_at", null); + return result; + } + Map item = toItemMap(entity); + log.info("[tutorial] 查询最新教程包 id={} fileName={} objectKey={}", + entity.getId(), entity.getFileName(), entity.getObjectKey()); + result.put("file_name", item.get("file_name")); + result.put("file_url", item.get("file_url")); + result.put("file_size", item.get("file_size")); + result.put("created_at", item.get("created_at")); + return result; + } + + /** + * 签发教程包直传预签名:按 时间戳-原文件名 生成新对象 key(每次上传互不覆盖), + * 浏览器直传到 MinIO 后再调 {@link #confirmTutorialPackage} 由服务端核对落库。 + */ + public Map presignTutorialPackage(String fileName) { + String normalizedName = requireZipFileName(fileName); + String objectKey = OBJECT_KEY_PREFIX + OBJECT_NAME_FORMATTER.format(LocalDateTime.now()) + + "-" + safeFileName(normalizedName); + log.info("[tutorial] 签发教程包直传预签名 fileName={} objectKey={} expirySeconds={}", + normalizedName, objectKey, PRESIGN_EXPIRY_SECONDS); + String uploadUrl = ossStorageService.presignTutorialUpload(objectKey, PRESIGN_EXPIRY_SECONDS); + Map result = new LinkedHashMap<>(); + result.put("object_key", objectKey); + result.put("upload_url", uploadUrl); + result.put("file_url", ossStorageService.getTutorialDownloadUrl(objectKey)); + return result; + } + + /** 直传完成后确认落库:校验对象存在且不超限,新 key 不与其它记录冲突,写入记录并返回新行。 */ + public Map confirmTutorialPackage(String objectKey, String fileName) { + if (objectKey == null || !objectKey.startsWith(OBJECT_KEY_PREFIX)) { + throw new BusinessException("非法的对象路径,请重新上传"); + } + String normalizedName = requireZipFileName(fileName); + long size = ossStorageService.tutorialObjectSize(objectKey); + if (size < 0) { + log.warn("[tutorial] 直传确认失败:对象不存在 objectKey={}", objectKey); + throw new BusinessException("对象存储中未找到上传的压缩包,请重新上传"); + } + if (size > MAX_UPLOAD_BYTES) { + log.warn("[tutorial] 直传对象超过大小上限,清理残留 objectKey={} bytes={}", objectKey, size); + ossStorageService.removeTutorialObject(objectKey); + throw new BusinessException("文件超过允许的大小限制"); + } + Long duplicated = tutorialPackageMapper.selectCount(new LambdaQueryWrapper() + .eq(TutorialPackageEntity::getObjectKey, objectKey)); + if (duplicated != null && duplicated > 0) { + log.warn("[tutorial] 直传确认失败:对象已被记录,重复提交 objectKey={}", objectKey); + throw new BusinessException("该压缩包已登记,请勿重复提交"); + } + + TutorialPackageEntity entity = new TutorialPackageEntity(); + entity.setFileName(normalizedName); + entity.setObjectKey(objectKey); + entity.setFileSize(size); + tutorialPackageMapper.insert(entity); + log.info("[tutorial] 教程包登记成功 id={} fileName={} objectKey={} bytes={}", + entity.getId(), normalizedName, objectKey, size); + TutorialPackageEntity saved = tutorialPackageMapper.selectById(entity.getId()); + return toItemMap(saved == null ? entity : saved); + } + + /** + * 按 id 批量删除教程包记录;对应 MinIO 对象仅在删除后无任何剩余记录引用时才清除 + * (防止同一对象被多行引用时误删仍在用的包)。 + */ + public int deleteTutorialPackages(List ids) { + List distinctIds = ids == null ? List.of() : ids.stream() + .filter(id -> id != null).distinct().toList(); + if (distinctIds.isEmpty()) { + throw new BusinessException("请选择要删除的教程包"); + } + List targets = tutorialPackageMapper.selectBatchIds(distinctIds); + int deleted = 0; + for (Long id : distinctIds) { + deleted += tutorialPackageMapper.deleteById(id); + } + Set touchedKeys = new LinkedHashSet<>(); + for (TutorialPackageEntity target : targets) { + if (target.getObjectKey() != null && !target.getObjectKey().isBlank()) { + touchedKeys.add(target.getObjectKey()); + } + } + int removedObjects = 0; + for (String objectKey : touchedKeys) { + Long remain = tutorialPackageMapper.selectCount(new LambdaQueryWrapper() + .eq(TutorialPackageEntity::getObjectKey, objectKey)); + if (remain == null || remain <= 0) { + removedObjects += removeTutorialObjectQuietly(objectKey) ? 1 : 0; + } + } + log.info("[tutorial] 批量删除教程包 ids={} deleted={} 清理关联文件数={}", + distinctIds, deleted, removedObjects); + return deleted; + } + + private boolean removeTutorialObjectQuietly(String objectKey) { + try { + if (!objectKey.startsWith(OBJECT_KEY_PREFIX)) { + return false; + } + ossStorageService.removeTutorialObject(objectKey); + return true; + } catch (RuntimeException ex) { + log.warn("[tutorial] 清理教程包对象失败,保留对象 objectKey={} err={}", objectKey, ex.getMessage()); + return false; + } + } + + private String requireZipFileName(String fileName) { + String normalized = fileName == null ? "" : fileName.trim(); + if (normalized.isEmpty()) { + throw new BusinessException("请选择要上传的 zip 压缩包"); + } + if (!normalized.toLowerCase(Locale.ROOT).endsWith(".zip")) { + throw new BusinessException("仅支持 .zip 格式"); + } + return normalized; + } + + /** 文件名 → 安全对象名片段:去掉路径分隔,只保留中文/字母/数字/._-,空则兜底 tutorial.zip。 */ + private String safeFileName(String fileName) { + String base = fileName.replace('\\', '/'); + int slash = base.lastIndexOf('/'); + if (slash >= 0) { + base = base.substring(slash + 1); + } + String replaced = UNSAFE_KEY_CHARS.matcher(base).replaceAll("_"); + return replaced.isBlank() || ".".equals(replaced) || "..".equals(replaced) ? "tutorial.zip" : replaced; + } + + private Map toItemMap(TutorialPackageEntity entity) { + Map item = new LinkedHashMap<>(); + item.put("id", entity.getId()); + item.put("file_name", entity.getFileName() == null ? "" : entity.getFileName()); + item.put("object_key", entity.getObjectKey() == null ? "" : entity.getObjectKey()); + item.put("file_size", entity.getFileSize() == null ? 0L : entity.getFileSize()); + item.put("file_url", entity.getObjectKey() == null || entity.getObjectKey().isBlank() + ? "" : ossStorageService.getTutorialDownloadUrl(entity.getObjectKey())); + item.put("created_at", entity.getCreatedAt() == null + ? "" : entity.getCreatedAt().format(CREATED_AT_FORMATTER)); + return item; + } +} diff --git a/backend-java/src/main/resources/db/V119__tutorial_package.sql b/backend-java/src/main/resources/db/V119__tutorial_package.sql new file mode 100644 index 00000000..2beee584 --- /dev/null +++ b/backend-java/src/main/resources/db/V119__tutorial_package.sql @@ -0,0 +1,28 @@ +-- V119: 教程客户端安装包管理(后台「教程管理」上传;工具台首页「立即下载教程」按最新上传下载) +-- 包体存公开 client 桶 tutorial/ 前缀(oss.aishufu.top 直链);本表只存对象 key 与元信息, +-- 下载 URL 由服务端按当前公开端点实时拼装(避免历史 URL 与端点配置漂移)。 +-- 每次上传生成带时间戳的新对象 key,规避下载域缓存按 URL 命中旧包,保证"以最新上传为主"。 + +CREATE TABLE IF NOT EXISTS `biz_tutorial_package` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `file_name` VARCHAR(255) NOT NULL COMMENT '原始文件名(展示用)', + `object_key` VARCHAR(512) NOT NULL COMMENT 'MinIO 对象 key(client 桶,tutorial/ 前缀)', + `file_size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数(0=未知,历史种子行)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', + PRIMARY KEY (`id`), + KEY `idx_tutorial_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教程客户端安装包(工具台下载入口)'; + +-- 种子行:指向 scripts/upload_tutorial_zip.py 历史上传的固定对象,保证上线后「最新」仍指向当前可用包 +INSERT INTO `biz_tutorial_package` (`file_name`, `object_key`, `file_size`) +SELECT '数富AI-教学客户端.zip', 'tutorial/数富AI-教学客户端.zip', 0 +WHERE NOT EXISTS (SELECT 1 FROM `biz_tutorial_package`); + +-- 后台菜单:教程管理(挂在「记录与版本」分组下;幂等,仅当 column_key 不存在时插入) +INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id) +SELECT '教程管理', 'admin_tutorial', 'admin', 'records/tutorial', 82, parent.id +FROM columns parent +WHERE parent.column_key = 'admin_group_record' + AND NOT EXISTS ( + SELECT 1 FROM columns WHERE column_key = 'admin_tutorial' + ); diff --git a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue index be985b8a..57b7e9f9 100644 --- a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue @@ -273,6 +273,7 @@ import { sanitizeCountryCodes, } from "@/shared/country-options"; import { formatDateTime } from '@/shared/utils/datetime' +import { useHistoryPolling } from '@/shared/composables/useHistoryPolling' const MAX_TRANSIENT_ERRORS = 30; /** 任务终态后等待结果文件(Java 侧异步生成)的最大轮次,12 × 10s ≈ 2 分钟 */ @@ -302,7 +303,6 @@ const queuePushResult = ref(""); const queuePayloadText = ref(""); const activeTaskId = ref(null); const queueWorkerRunning = ref(false); -let historyPollTimer: number | null = null; const timers = createCategorizedTimers("patrol-delete"); const matchedRunnableItems = computed(() => @@ -399,6 +399,13 @@ function taskGroupKey(item: PatrolDeleteHistoryItem) { let disposed = false; +const historyPolling = useHistoryPolling({ + timers, + isDisposed: () => disposed, + shouldPoll: () => hasQueueWork.value, + refresh: () => refreshActiveTaskProgress(), +}) + function sleep(ms: number) { if (disposed) return Promise.resolve(); return timers.sleep("queue-wait", ms); @@ -793,27 +800,6 @@ async function refreshActiveTaskProgress(taskIds?: number[]) { return batch.missingTaskIds || []; } -function startHistoryPolling() { - stopHistoryPolling(); - if (disposed || !hasQueueWork.value) return; - const run = async () => { - historyPollTimer = null; - if (disposed || !hasQueueWork.value) return; - await refreshActiveTaskProgress(); - if (!disposed && hasQueueWork.value) { - historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2); - } - }; - historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2); -} - -function stopHistoryPolling() { - if (historyPollTimer) { - timers.clearTimer("history-poll", historyPollTimer); - historyPollTimer = null; - } -} - function onSelectionChange(rows: PatrolDeleteCandidateVo[]) { selectedCandidates.value = rows; } @@ -1060,7 +1046,7 @@ async function processQueue() { if (disposed || queueWorkerRunning.value) return; queueWorkerRunning.value = true; pushing.value = true; - startHistoryPolling(); + historyPolling.start(); try { const api = getPywebviewApi(); @@ -1175,7 +1161,7 @@ async function processQueue() { } finally { queueWorkerRunning.value = false; pushing.value = false; - stopHistoryPolling(); + historyPolling.stop(); saveQueueState(); } } @@ -1260,7 +1246,7 @@ onMounted(async () => { onUnmounted(() => { disposed = true; - stopHistoryPolling(); + historyPolling.stop(); clearSleepTimers(); timers.clearScope(); }); diff --git a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue index a919c8c6..d8985333 100644 --- a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue @@ -215,6 +215,7 @@ import { passGuard } from "@/shared/dispatch-guard-ui"; import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue"; import { useZiniaoVersion } from "@/shared/utils/ziniao-version"; import { formatDateTime } from '@/shared/utils/datetime' +import { useHistoryPolling } from '@/shared/composables/useHistoryPolling' const MAX_TRANSIENT_ERRORS = 30; const ziniaoVersion = useZiniaoVersion(); @@ -241,7 +242,6 @@ const activeQueueItem = ref(null); const queueWorkerRunning = ref(false); const autoQueueEnabled = ref(false); const taskStartTimes = ref>({}); -let historyPollTimer: number | null = null; const timers = createCategorizedTimers("query-asin"); const matchedRunnableItems = computed(() => @@ -327,6 +327,13 @@ function historyItemKey(item: QueryAsinHistoryItem) { let disposed = false; +const historyPolling = useHistoryPolling({ + timers, + isDisposed: () => disposed, + shouldPoll: () => hasQueueWork.value, + refresh: () => refreshActiveTaskProgress(), +}) + function sleep(ms: number) { if (disposed) return Promise.resolve(); return timers.sleep("queue-wait", ms); @@ -597,7 +604,7 @@ function resetQueueWorkerIfIdle() { queueWorkerRunning.value = false; pushing.value = false; autoQueueEnabled.value = false; - stopHistoryPolling(); + historyPolling.stop(); saveQueueState(); } @@ -710,27 +717,6 @@ async function refreshActiveTaskProgress(taskIds?: number[]) { } } -function startHistoryPolling() { - stopHistoryPolling(); - if (disposed || !hasQueueWork.value) return; - const run = async () => { - historyPollTimer = null; - if (disposed || !hasQueueWork.value) return; - await refreshActiveTaskProgress(); - if (!disposed && hasQueueWork.value) { - historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2); - } - }; - historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2); -} - -function stopHistoryPolling() { - if (historyPollTimer) { - timers.clearTimer("history-poll", historyPollTimer); - historyPollTimer = null; - } -} - function onSelectionChange(rows: QueryAsinCandidateVo[]) { selectedCandidates.value = rows; } @@ -894,7 +880,7 @@ async function processQueue() { if (disposed || queueWorkerRunning.value) return; queueWorkerRunning.value = true; pushing.value = true; - startHistoryPolling(); + historyPolling.start(); try { const api = getPywebviewApi(); @@ -1004,7 +990,7 @@ async function processQueue() { } finally { queueWorkerRunning.value = false; pushing.value = false; - stopHistoryPolling(); + historyPolling.stop(); saveQueueState(); } } @@ -1087,7 +1073,7 @@ onMounted(async () => { onUnmounted(() => { disposed = true; - stopHistoryPolling(); + historyPolling.stop(); clearSleepTimers(); timers.clearScope(); }); diff --git a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue index 19ab198a..1df364dc 100644 --- a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue @@ -255,6 +255,7 @@ import { passGuard } from "@/shared/dispatch-guard-ui"; import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue"; import { useZiniaoVersion } from "@/shared/utils/ziniao-version"; import { formatDateTime } from '@/shared/utils/datetime' +import { useHistoryPolling } from '@/shared/composables/useHistoryPolling' const MAX_TRANSIENT_ERRORS = 30; const ziniaoVersion = useZiniaoVersion(); @@ -292,7 +293,6 @@ const activeQueueItem = ref(null); const queueWorkerRunning = ref(false); const autoQueueEnabled = ref(false); const taskStartTimes = ref>({}); -let historyPollTimer: number | null = null; const timers = createCategorizedTimers("withdraw"); const matchedRunnableItems = computed(() => @@ -458,6 +458,13 @@ function mergeQueueBatches( let disposed = false; +const historyPolling = useHistoryPolling({ + timers, + isDisposed: () => disposed, + shouldPoll: () => hasQueueWork.value, + refresh: () => refreshActiveTaskProgress(), +}) + function sleep(ms: number) { if (disposed) return Promise.resolve(); return timers.sleep("queue-wait", ms); @@ -741,7 +748,7 @@ function resetQueueWorkerIfIdle() { queueWorkerRunning.value = false; pushing.value = false; autoQueueEnabled.value = false; - stopHistoryPolling(); + historyPolling.stop(); saveQueueState(); } @@ -838,27 +845,6 @@ async function refreshActiveTaskProgress(taskIds?: number[]) { } } -function startHistoryPolling() { - stopHistoryPolling(); - if (disposed || !hasQueueWork.value) return; - const run = async () => { - historyPollTimer = null; - if (disposed || !hasQueueWork.value) return; - await refreshActiveTaskProgress(); - if (!disposed && hasQueueWork.value) { - historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2); - } - }; - historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2); -} - -function stopHistoryPolling() { - if (historyPollTimer) { - timers.clearTimer("history-poll", historyPollTimer); - historyPollTimer = null; - } -} - function onSelectionChange(rows: WithdrawCandidateVo[]) { selectedCandidates.value = rows; } @@ -1033,7 +1019,7 @@ async function processQueue() { if (disposed || queueWorkerRunning.value) return; queueWorkerRunning.value = true; pushing.value = true; - startHistoryPolling(); + historyPolling.start(); try { const api = getPywebviewApi(); @@ -1136,7 +1122,7 @@ async function processQueue() { } finally { queueWorkerRunning.value = false; pushing.value = false; - stopHistoryPolling(); + historyPolling.stop(); saveQueueState(); } } @@ -1220,7 +1206,7 @@ onMounted(async () => { onUnmounted(() => { disposed = true; - stopHistoryPolling(); + historyPolling.stop(); clearSleepTimers(); timers.clearScope(); }); diff --git a/frontend-vue/src/shared/composables/useHistoryPolling.ts b/frontend-vue/src/shared/composables/useHistoryPolling.ts new file mode 100644 index 00000000..7ed60104 --- /dev/null +++ b/frontend-vue/src/shared/composables/useHistoryPolling.ts @@ -0,0 +1,50 @@ +import { getTaskPollIntervalMs } from '../task-progress-config.ts' +import type { CategorizedTimers } from '../utils/categorized-timers.ts' + +export interface HistoryPollingOptions { + /** 页面自己的 categorized-timers 实例(决定定时器清理 scope)。 */ + timers: CategorizedTimers + /** 组件是否已卸载。 */ + isDisposed: () => boolean + /** 是否还有队列工作需要跟踪。 */ + shouldPoll: () => boolean + /** 每轮拉取活动任务进度(返回值被忽略,允许各页返回不同结果)。 */ + refresh: () => Promise + /** 轮询间隔;默认 getTaskPollIntervalMs() 的 2 倍(历史刷新不需要主任务那么勤)。 */ + intervalMs?: () => number +} + +/** + * 历史任务进度轮询:队列有工作时按较慢的固定间隔拉取活动任务进度。 + * 与主任务轮询(useTaskProgressLoop)并存,但节奏更慢、只管历史列表刷新。 + * + * 定时器统一注册到传入的 categorized-timers(category 固定为 'history-poll'), + * 由页面在卸载时 clearScope 兜底清理。 + */ +export function useHistoryPolling(options: HistoryPollingOptions) { + const intervalMs = options.intervalMs ?? (() => getTaskPollIntervalMs() * 2) + let timer: number | null = null + + function stop() { + if (timer != null) { + options.timers.clearTimer('history-poll', timer) + timer = null + } + } + + function start() { + stop() + if (options.isDisposed() || !options.shouldPoll()) return + const run = async () => { + timer = null + if (options.isDisposed() || !options.shouldPoll()) return + await options.refresh() + if (!options.isDisposed() && options.shouldPoll()) { + timer = options.timers.setTimeout('history-poll', run, intervalMs()) + } + } + timer = options.timers.setTimeout('history-poll', run, intervalMs()) + } + + return { start, stop } +} diff --git a/frontend-vue/src/shared/utils/categorized-timers.ts b/frontend-vue/src/shared/utils/categorized-timers.ts index 77a465da..625cdc45 100644 --- a/frontend-vue/src/shared/utils/categorized-timers.ts +++ b/frontend-vue/src/shared/utils/categorized-timers.ts @@ -105,6 +105,8 @@ export function createCategorizedTimers(scope: string) { } } +export type CategorizedTimers = ReturnType + export function getCategorizedTimerStats() { return Array.from(timerBuckets.entries()).map(([category, bucket]) => ({ category, diff --git a/frontend-vue/tests/history-polling.test.ts b/frontend-vue/tests/history-polling.test.ts new file mode 100644 index 00000000..909e8666 --- /dev/null +++ b/frontend-vue/tests/history-polling.test.ts @@ -0,0 +1,119 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useHistoryPolling } from '../src/shared/composables/useHistoryPolling.ts' +import type { CategorizedTimers } from '../src/shared/utils/categorized-timers.ts' + +function fakeTimers() { + const scheduled: Array<{ category: string; id: number; delay: number; run: () => void }> = [] + const cleared: number[] = [] + let seq = 0 + const timers = { + setTimeout(category: string, handler: () => void, delayMs: number) { + const id = ++seq + scheduled.push({ category, id, delay: delayMs, run: handler }) + return id + }, + clearTimer(_category: string, id: number | null | undefined) { + if (id != null) cleared.push(id) + }, + } + return { timers: timers as unknown as CategorizedTimers, scheduled, cleared } +} + +test('shouldPoll 为 false 时不排定时器', () => { + const { timers, scheduled } = fakeTimers() + const p = useHistoryPolling({ + timers, + isDisposed: () => false, + shouldPoll: () => false, + refresh: async () => {}, + intervalMs: () => 40, + }) + + p.start() + + assert.equal(scheduled.length, 0) +}) + +test('按传入间隔排定 history-poll,触发后刷新并续排', async () => { + const { timers, scheduled } = fakeTimers() + let calls = 0 + const p = useHistoryPolling({ + timers, + isDisposed: () => false, + shouldPoll: () => true, + refresh: async () => { + calls += 1 + }, + intervalMs: () => 40, + }) + + p.start() + assert.equal(scheduled.length, 1) + assert.equal(scheduled[0].category, 'history-poll') + assert.equal(scheduled[0].delay, 40) + + await scheduled[0].run() + + assert.equal(calls, 1) + assert.equal(scheduled.length, 2) + assert.equal(scheduled[1].delay, 40) +}) + +test('stop 清除已排定的定时器', () => { + const { timers, scheduled, cleared } = fakeTimers() + const p = useHistoryPolling({ + timers, + isDisposed: () => false, + shouldPoll: () => true, + refresh: async () => {}, + intervalMs: () => 40, + }) + + p.start() + p.stop() + + assert.equal(scheduled.length, 1) + assert.deepEqual(cleared, [scheduled[0].id]) +}) + +test('已卸载时触发不再刷新也不续排', async () => { + const { timers, scheduled } = fakeTimers() + let disposed = false + let calls = 0 + const p = useHistoryPolling({ + timers, + isDisposed: () => disposed, + shouldPoll: () => true, + refresh: async () => { + calls += 1 + }, + intervalMs: () => 40, + }) + + p.start() + disposed = true + await scheduled[0].run() + + assert.equal(calls, 0) + assert.equal(scheduled.length, 1) +}) + +test('刷新后队列已空则不再续排', async () => { + const { timers, scheduled } = fakeTimers() + let work = true + const p = useHistoryPolling({ + timers, + isDisposed: () => false, + shouldPoll: () => work, + refresh: async () => { + work = false + }, + intervalMs: () => 40, + }) + + p.start() + await scheduled[0].run() + + assert.equal(scheduled.length, 1) +})