refactor(品牌工具页): 历史轮询抽为 useHistoryPolling

QueryAsin / Withdraw / PatrolDelete 三页逐字相同的 startHistoryPolling /
stopHistoryPolling 收敛为 shared/composables/useHistoryPolling:定时器走各页
categorized-timers(category 固定 history-poll),间隔默认主轮询的 2 倍。
categorized-timers 补 CategorizedTimers 类型导出;补注入式假定时器单测 5 例。

净减约 40 行;vue-tsc 构建与 695 个前端单测通过。
This commit is contained in:
2026-09-13 23:22:49 +08:00
parent 882ccdac12
commit dc6e8924a9
13 changed files with 685 additions and 84 deletions
@@ -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<String> configuredBuckets() {
return Stream.of(ossProperties.getBucket(), imageVideoBucket(), digitalHumanBucket(), templateBucket(),
softwareVersionBucket(), shopDataBucket())
publicClientBucket(), shopDataBucket())
.filter(Objects::nonNull)
.map(String::trim)
.filter(bucket -> !bucket.isBlank())
@@ -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<Map<String, Object>> latestTutorial() {
return ApiResponse.success(tutorialPackageService.latestTutorialPackage());
}
}
@@ -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;
/**
* 教程安装包管理(后台「教程管理」页):列表、直传预签名、确认落库、删除。
* <p>工具台首页「立即下载教程」读公开接口 /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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> deleteTutorials(
HttpServletRequest request,
@Parameter(description = "要删除的教程包记录 id 列表") @RequestBody List<Long> 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));
}
}
@@ -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<TutorialPackageEntity> {
}
@@ -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;
/**
* 教程客户端安装包记录(工具台首页「立即下载教程」的数据源)。
* <p>包体放公开 client 桶 tutorial/ 前缀;下载 URL 由服务端按当前公开端点实时拼装,
* 不入库,避免端点配置变更后历史行 URL 失效。
*/
@Data
@TableName("biz_tutorial_package")
public class TutorialPackageEntity {
@TableId(type = IdType.AUTO)
private Long id;
/** 原始文件名(展示用) */
private String fileName;
/** MinIO 对象 keyclient 桶,tutorial/ 前缀) */
private String objectKey;
/** 文件字节数(0=未知,历史种子行) */
private Long fileSize;
/** 上传时间,由数据库 CURRENT_TIMESTAMP 兜底 */
private LocalDateTime createdAt;
}
@@ -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)。
* <p>对象存公开 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<Map<String, Object>> listTutorialPackages() {
List<TutorialPackageEntity> entities = tutorialPackageMapper.selectList(
new LambdaQueryWrapper<TutorialPackageEntity>()
.orderByDesc(TutorialPackageEntity::getCreatedAt)
.orderByDesc(TutorialPackageEntity::getId));
log.info("[tutorial] 查询教程包列表 count={}", entities.size());
List<Map<String, Object>> items = new ArrayList<>(entities.size());
for (TutorialPackageEntity entity : entities) {
items.add(toItemMap(entity));
}
return items;
}
/** 最新教程包(工具台下载入口);无记录时返回空字段,前端回退到历史固定直链。 */
public Map<String, Object> latestTutorialPackage() {
TutorialPackageEntity entity = tutorialPackageMapper.selectOne(
new LambdaQueryWrapper<TutorialPackageEntity>()
.orderByDesc(TutorialPackageEntity::getCreatedAt)
.orderByDesc(TutorialPackageEntity::getId)
.last("LIMIT 1"));
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<TutorialPackageEntity>()
.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<Long> ids) {
List<Long> distinctIds = ids == null ? List.of() : ids.stream()
.filter(id -> id != null).distinct().toList();
if (distinctIds.isEmpty()) {
throw new BusinessException("请选择要删除的教程包");
}
List<TutorialPackageEntity> targets = tutorialPackageMapper.selectBatchIds(distinctIds);
int deleted = 0;
for (Long id : distinctIds) {
deleted += tutorialPackageMapper.deleteById(id);
}
Set<String> 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<TutorialPackageEntity>()
.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<String, Object> toItemMap(TutorialPackageEntity entity) {
Map<String, Object> 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;
}
}
@@ -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 对象 keyclient 桶,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'
);
@@ -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<number | null>(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();
});
@@ -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<QueryAsinShopQueueItem | null>(null);
const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
const taskStartTimes = ref<Record<number, string>>({});
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();
});
@@ -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<WithdrawTaskBatchQueueItem | null>(null);
const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
const taskStartTimes = ref<Record<number, string>>({});
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();
});
@@ -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<unknown>
/** 轮询间隔;默认 getTaskPollIntervalMs() 的 2 倍(历史刷新不需要主任务那么勤)。 */
intervalMs?: () => number
}
/**
* 历史任务进度轮询:队列有工作时按较慢的固定间隔拉取活动任务进度。
* 与主任务轮询(useTaskProgressLoop)并存,但节奏更慢、只管历史列表刷新。
*
* 定时器统一注册到传入的 categorized-timerscategory 固定为 '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 }
}
@@ -105,6 +105,8 @@ export function createCategorizedTimers(scope: string) {
}
}
export type CategorizedTimers = ReturnType<typeof createCategorizedTimers>
export function getCategorizedTimerStats() {
return Array.from(timerBuckets.entries()).map(([category, bucket]) => ({
category,
+119
View File
@@ -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)
})