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:
+72
-7
@@ -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())
|
||||
|
||||
+33
@@ -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());
|
||||
}
|
||||
}
|
||||
+77
@@ -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));
|
||||
}
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+33
@@ -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 对象 key(client 桶,tutorial/ 前缀) */
|
||||
private String objectKey;
|
||||
|
||||
/** 文件字节数(0=未知,历史种子行) */
|
||||
private Long fileSize;
|
||||
|
||||
/** 上传时间,由数据库 CURRENT_TIMESTAMP 兜底 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+227
@@ -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 对象 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'
|
||||
);
|
||||
@@ -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-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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user