feat: 增加使用期限激活限制
This commit is contained in:
@@ -44,6 +44,7 @@ CapacityReportData/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
auth.ini
|
auth.ini
|
||||||
|
license.dat
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的
|
|||||||
- MySQL 数据表查看、清空、删除、CSV/XLSX 导出。
|
- MySQL 数据表查看、清空、删除、CSV/XLSX 导出。
|
||||||
- SQL 脚本在线查看、保存和执行。
|
- SQL 脚本在线查看、保存和执行。
|
||||||
- 处理历史、日志查看、历史原始数据打包下载。
|
- 处理历史、日志查看、历史原始数据打包下载。
|
||||||
|
- 按 ZIP 文件名数据日期校验本地授权期限,过期后可输入激活码顺延。
|
||||||
- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。
|
- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。
|
||||||
- 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。
|
- 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。
|
||||||
|
|
||||||
@@ -216,6 +217,8 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
|
|||||||
|
|
||||||
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
|
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
|
||||||
|
|
||||||
|
授权到期日期保存在本地加密文件 `license.dat`,默认到期日为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。
|
||||||
|
|
||||||
## 常用接口
|
## 常用接口
|
||||||
|
|
||||||
- `POST /api/login`:登录
|
- `POST /api/login`:登录
|
||||||
@@ -225,6 +228,8 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
|
|||||||
- `POST /api/remote/start`:远程下载并处理
|
- `POST /api/remote/start`:远程下载并处理
|
||||||
- `POST /api/process/start`:启动本地处理
|
- `POST /api/process/start`:启动本地处理
|
||||||
- `POST /api/process/status`:查询处理状态
|
- `POST /api/process/status`:查询处理状态
|
||||||
|
- `GET /api/license/status`:查询授权状态
|
||||||
|
- `POST /api/license/activate`:提交激活码并顺延授权期限
|
||||||
- `GET /api/history`:处理历史
|
- `GET /api/history`:处理历史
|
||||||
- `POST /api/history/download`:下载历史原始数据
|
- `POST /api/history/download`:下载历史原始数据
|
||||||
- `POST /api/service/restart`:重启服务
|
- `POST /api/service/restart`:重启服务
|
||||||
@@ -232,6 +237,6 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
|
|||||||
|
|
||||||
## 维护注意事项
|
## 维护注意事项
|
||||||
|
|
||||||
- 不要提交 `auth.ini`、`cache/`、`logs/`、`dist/`、`frontend/dist/`、`src-tauri/target/`、`src-tauri/binaries/`。
|
- 不要提交 `auth.ini`、`license.dat`、`cache/`、`logs/`、`dist/`、`frontend/dist/`、`src-tauri/target/`、`src-tauri/binaries/`。
|
||||||
- `src-tauri/gen/schemas/` 需要保留并提交,`src-tauri/capabilities/default.json` 的 JSON schema 会引用它。
|
- `src-tauri/gen/schemas/` 需要保留并提交,`src-tauri/capabilities/default.json` 的 JSON schema 会引用它。
|
||||||
- `ReportScript.sql` 是业务处理链路的一部分,修改前需要确认 SQL 语义和字段映射兼容。
|
- `ReportScript.sql` 是业务处理链路的一部分,修改前需要确认 SQL 语义和字段映射兼容。
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import APIRouter, Body, HTTPException
|
||||||
|
|
||||||
|
from app.services.license import InvalidActivationCodeError, activate, get_license_info
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["license"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/license/status")
|
||||||
|
async def get_license_status():
|
||||||
|
info = get_license_info()
|
||||||
|
return {"success": True, **info.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/license/activate")
|
||||||
|
async def activate_license(code: str = Body(..., embed=True)):
|
||||||
|
try:
|
||||||
|
info = activate(code)
|
||||||
|
except InvalidActivationCodeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=exc.to_detail()) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "激活成功,到期日期已延长 30 天",
|
||||||
|
**info.to_dict(),
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Body, HTTPException
|
|||||||
from app import state
|
from app import state
|
||||||
from app.config import AppConfig, CACHE_DIR, RemoteDataConfig
|
from app.config import AppConfig, CACHE_DIR, RemoteDataConfig
|
||||||
from app.processor import DataProcessor, ProcessLogger
|
from app.processor import DataProcessor, ProcessLogger
|
||||||
|
from app.services.license import LicenseError, LicenseInfo, check_processing_allowed
|
||||||
from app.services.remote_download import RemoteDataDownloader
|
from app.services.remote_download import RemoteDataDownloader
|
||||||
|
|
||||||
|
|
||||||
@@ -115,11 +116,13 @@ def _run_remote_processing(
|
|||||||
raise RuntimeError("远程目录中未下载到任何文件")
|
raise RuntimeError("远程目录中未下载到任何文件")
|
||||||
|
|
||||||
state.history_manager.update(task_id, file_count=download_result.file_count)
|
state.history_manager.update(task_id, file_count=download_result.file_count)
|
||||||
logger.set_stage("extracting")
|
logger.set_stage("license")
|
||||||
|
_log_license_check(logger, check_processing_allowed(work_dir))
|
||||||
|
|
||||||
processor = DataProcessor(app_config, work_dir, logger)
|
processor = DataProcessor(app_config, work_dir, logger)
|
||||||
result = processor.process()
|
result = processor.process()
|
||||||
status = "completed" if result.get("success") else "failed"
|
status = "completed" if result.get("success") else "failed"
|
||||||
|
error = result.get("error")
|
||||||
if status == "completed" and remote_config.auto_delete_source:
|
if status == "completed" and remote_config.auto_delete_source:
|
||||||
try:
|
try:
|
||||||
deleted_count = downloader.delete_source_files(download_result.remote_files)
|
deleted_count = downloader.delete_source_files(download_result.remote_files)
|
||||||
@@ -131,21 +134,25 @@ def _run_remote_processing(
|
|||||||
task_id,
|
task_id,
|
||||||
status=status,
|
status=status,
|
||||||
elapsed_time=result.get("elapsed_time", 0),
|
elapsed_time=result.get("elapsed_time", 0),
|
||||||
error=result.get("error"),
|
error=error,
|
||||||
result_tables=["4G_结果表", "5G_结果表"],
|
result_tables=["4G_结果表", "5G_结果表"],
|
||||||
)
|
)
|
||||||
state.processing_tasks[task_id] = {
|
state.processing_tasks[task_id] = {
|
||||||
"logs": state.history_manager.get_logs(task_id),
|
"logs": state.history_manager.get_logs(task_id),
|
||||||
"status": status,
|
"status": status,
|
||||||
"stage": status,
|
"stage": status,
|
||||||
|
"error": error,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
error_detail = exc.to_detail() if isinstance(exc, LicenseError) else None
|
||||||
logger.error(f"远程自动化任务失败: {exc}")
|
logger.error(f"远程自动化任务失败: {exc}")
|
||||||
state.history_manager.update(task_id, status="failed", error=str(exc))
|
state.history_manager.update(task_id, status="failed", error=str(exc))
|
||||||
state.processing_tasks[task_id] = {
|
state.processing_tasks[task_id] = {
|
||||||
"logs": state.history_manager.get_logs(task_id),
|
"logs": state.history_manager.get_logs(task_id),
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"stage": "failed",
|
"stage": "failed",
|
||||||
|
"error": str(exc),
|
||||||
|
"error_detail": error_detail,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -162,3 +169,15 @@ def _format_bytes(size: int) -> str:
|
|||||||
return f"{value:.1f} {unit}"
|
return f"{value:.1f} {unit}"
|
||||||
value /= 1024
|
value /= 1024
|
||||||
return f"{value:.1f} TB"
|
return f"{value:.1f} TB"
|
||||||
|
|
||||||
|
|
||||||
|
def _log_license_check(logger: ProcessLogger, info: LicenseInfo) -> None:
|
||||||
|
if info.current_date:
|
||||||
|
logger.info(
|
||||||
|
f"授权校验通过,数据日期: {info.current_date.isoformat()},"
|
||||||
|
f"到期日期: {info.expires_on.isoformat()}"
|
||||||
|
)
|
||||||
|
elif info.zip_count:
|
||||||
|
logger.warning("未从 ZIP 文件名识别到日期,已跳过授权日期比对")
|
||||||
|
else:
|
||||||
|
logger.warning("未找到 ZIP 文件,已跳过授权日期比对")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Body, HTTPException
|
|||||||
from app import state
|
from app import state
|
||||||
from app.config import AppConfig
|
from app.config import AppConfig
|
||||||
from app.processor import DataProcessor, ProcessLogger
|
from app.processor import DataProcessor, ProcessLogger
|
||||||
|
from app.services.license import LicenseError, LicenseInfo, check_processing_allowed
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
@@ -133,6 +134,8 @@ async def get_processing_status(task_id: str = Body(..., embed=True)):
|
|||||||
"status": task_info["status"],
|
"status": task_info["status"],
|
||||||
"stage": task_info.get("stage"),
|
"stage": task_info.get("stage"),
|
||||||
"logs": logs,
|
"logs": logs,
|
||||||
|
"error": task_info.get("error"),
|
||||||
|
"error_detail": task_info.get("error_detail"),
|
||||||
}
|
}
|
||||||
|
|
||||||
record = state.history_manager.get(task_id)
|
record = state.history_manager.get(task_id)
|
||||||
@@ -170,27 +173,36 @@ def _set_task_stage(task_id: str, stage: str, logs: list[str], status: str = "pr
|
|||||||
|
|
||||||
def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None:
|
def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None:
|
||||||
try:
|
try:
|
||||||
|
logger.set_stage("license")
|
||||||
|
_log_license_check(logger, check_processing_allowed(work_dir))
|
||||||
|
|
||||||
processor = DataProcessor(app_config, work_dir, logger)
|
processor = DataProcessor(app_config, work_dir, logger)
|
||||||
result = processor.process()
|
result = processor.process()
|
||||||
status = "completed" if result.get("success") else "failed"
|
status = "completed" if result.get("success") else "failed"
|
||||||
|
error = result.get("error")
|
||||||
state.history_manager.update(
|
state.history_manager.update(
|
||||||
task_id,
|
task_id,
|
||||||
status=status,
|
status=status,
|
||||||
elapsed_time=result.get("elapsed_time", 0),
|
elapsed_time=result.get("elapsed_time", 0),
|
||||||
error=result.get("error"),
|
error=error,
|
||||||
result_tables=["4G_结果表", "5G_结果表"],
|
result_tables=["4G_结果表", "5G_结果表"],
|
||||||
)
|
)
|
||||||
state.processing_tasks[task_id] = {
|
state.processing_tasks[task_id] = {
|
||||||
"logs": state.history_manager.get_logs(task_id),
|
"logs": state.history_manager.get_logs(task_id),
|
||||||
"status": status,
|
"status": status,
|
||||||
"stage": status,
|
"stage": status,
|
||||||
|
"error": error,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
error_detail = exc.to_detail() if isinstance(exc, LicenseError) else None
|
||||||
|
logger.error(str(exc))
|
||||||
state.history_manager.update(task_id, status="failed", error=str(exc))
|
state.history_manager.update(task_id, status="failed", error=str(exc))
|
||||||
state.processing_tasks[task_id] = {
|
state.processing_tasks[task_id] = {
|
||||||
"logs": state.history_manager.get_logs(task_id),
|
"logs": state.history_manager.get_logs(task_id),
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"stage": "failed",
|
"stage": "failed",
|
||||||
|
"error": str(exc),
|
||||||
|
"error_detail": error_detail,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -198,3 +210,15 @@ def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_con
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"自动清理处理历史失败: {exc}")
|
print(f"自动清理处理历史失败: {exc}")
|
||||||
state.reset_task_lock()
|
state.reset_task_lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _log_license_check(logger: ProcessLogger, info: LicenseInfo) -> None:
|
||||||
|
if info.current_date:
|
||||||
|
logger.info(
|
||||||
|
f"授权校验通过,数据日期: {info.current_date.isoformat()},"
|
||||||
|
f"到期日期: {info.expires_on.isoformat()}"
|
||||||
|
)
|
||||||
|
elif info.zip_count:
|
||||||
|
logger.warning("未从 ZIP 文件名识别到日期,已跳过授权日期比对")
|
||||||
|
else:
|
||||||
|
logger.warning("未找到 ZIP 文件,已跳过授权日期比对")
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from app import state
|
from app import state
|
||||||
from app.api.routers import auth, cache, config, database, health, history, remote, script, service, tasks, upload
|
from app.api.routers import auth, cache, config, database, health, history, license, remote, script, service, tasks, upload
|
||||||
from app.auth import verify_jwt_token
|
from app.auth import verify_jwt_token
|
||||||
from app.config import BASE_DIR
|
from app.config import BASE_DIR
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ def register_routes(app: FastAPI) -> None:
|
|||||||
remote.router,
|
remote.router,
|
||||||
tasks.router,
|
tasks.router,
|
||||||
history.router,
|
history.router,
|
||||||
|
license.router,
|
||||||
database.router,
|
database.router,
|
||||||
config.router,
|
config.router,
|
||||||
cache.router,
|
cache.router,
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""本地授权期限校验。"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_EXPIRES_ON = date(2026, 6, 20)
|
||||||
|
EXTEND_DAYS = 30
|
||||||
|
LICENSE_FILE = BASE_DIR / "license.dat"
|
||||||
|
_SECRET = b"CapacityReport local license v1"
|
||||||
|
_ZIP_DATE_RE = re.compile(r"(?<!\d)(20\d{10}(?:\d{2})?)(?!\d)")
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseError(Exception):
|
||||||
|
"""授权校验错误。"""
|
||||||
|
|
||||||
|
code = "LICENSE_ERROR"
|
||||||
|
|
||||||
|
def to_detail(self) -> dict[str, Any]:
|
||||||
|
return {"code": self.code, "message": str(self)}
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseExpiredError(LicenseError):
|
||||||
|
"""数据日期超过授权到期日期。"""
|
||||||
|
|
||||||
|
code = "LICENSE_EXPIRED"
|
||||||
|
|
||||||
|
def __init__(self, expires_on: date, current_date: date):
|
||||||
|
self.expires_on = expires_on
|
||||||
|
self.current_date = current_date
|
||||||
|
super().__init__(
|
||||||
|
f"授权已过期:数据日期 {current_date.isoformat()} 已超过到期日期 {expires_on.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_detail(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": self.code,
|
||||||
|
"message": str(self),
|
||||||
|
"expires_on": self.expires_on.isoformat(),
|
||||||
|
"current_date": self.current_date.isoformat(),
|
||||||
|
"key_label": format_key_label(self.expires_on),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidActivationCodeError(LicenseError):
|
||||||
|
"""激活码错误。"""
|
||||||
|
|
||||||
|
code = "LICENSE_INVALID"
|
||||||
|
|
||||||
|
def __init__(self, expires_on: date):
|
||||||
|
self.expires_on = expires_on
|
||||||
|
super().__init__("激活码无效,请按当前 key 重新计算后输入")
|
||||||
|
|
||||||
|
def to_detail(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": self.code,
|
||||||
|
"message": str(self),
|
||||||
|
"expires_on": self.expires_on.isoformat(),
|
||||||
|
"key_label": format_key_label(self.expires_on),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LicenseInfo:
|
||||||
|
expires_on: date
|
||||||
|
current_date: date | None = None
|
||||||
|
zip_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_label(self) -> str:
|
||||||
|
return format_key_label(self.expires_on)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"expires_on": self.expires_on.isoformat(),
|
||||||
|
"key_label": self.key_label,
|
||||||
|
"current_date": self.current_date.isoformat() if self.current_date else None,
|
||||||
|
"zip_count": self.zip_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_license_info() -> LicenseInfo:
|
||||||
|
return LicenseInfo(expires_on=read_expires_on())
|
||||||
|
|
||||||
|
|
||||||
|
def activate(code: str) -> LicenseInfo:
|
||||||
|
expires_on = read_expires_on()
|
||||||
|
expected = activation_hash(expires_on)
|
||||||
|
normalized_code = (code or "").strip().lower()
|
||||||
|
if not hmac.compare_digest(normalized_code, expected):
|
||||||
|
raise InvalidActivationCodeError(expires_on)
|
||||||
|
|
||||||
|
new_expires_on = expires_on + timedelta(days=EXTEND_DAYS)
|
||||||
|
write_expires_on(new_expires_on)
|
||||||
|
return LicenseInfo(expires_on=new_expires_on)
|
||||||
|
|
||||||
|
|
||||||
|
def check_processing_allowed(work_dir: Path) -> LicenseInfo:
|
||||||
|
expires_on = read_expires_on()
|
||||||
|
zip_count, current_date = extract_max_zip_date(work_dir)
|
||||||
|
info = LicenseInfo(expires_on=expires_on, current_date=current_date, zip_count=zip_count)
|
||||||
|
|
||||||
|
if current_date and current_date > expires_on:
|
||||||
|
raise LicenseExpiredError(expires_on, current_date)
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def extract_max_zip_date(work_dir: Path) -> tuple[int, date | None]:
|
||||||
|
max_date: date | None = None
|
||||||
|
zip_count = 0
|
||||||
|
for zip_file in work_dir.rglob("*.zip"):
|
||||||
|
zip_count += 1
|
||||||
|
for raw_value in _ZIP_DATE_RE.findall(zip_file.name):
|
||||||
|
parsed_date = _parse_zip_timestamp(raw_value)
|
||||||
|
if parsed_date and (max_date is None or parsed_date > max_date):
|
||||||
|
max_date = parsed_date
|
||||||
|
|
||||||
|
return zip_count, max_date
|
||||||
|
|
||||||
|
|
||||||
|
def activation_hash(expires_on: date) -> str:
|
||||||
|
return hashlib.sha256(format_key_label(expires_on).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def format_key_label(value: date) -> str:
|
||||||
|
return value.strftime("%Y/%m/%d")
|
||||||
|
|
||||||
|
|
||||||
|
def read_expires_on() -> date:
|
||||||
|
if not LICENSE_FILE.exists():
|
||||||
|
write_expires_on(DEFAULT_EXPIRES_ON)
|
||||||
|
return DEFAULT_EXPIRES_ON
|
||||||
|
|
||||||
|
try:
|
||||||
|
encrypted = base64.urlsafe_b64decode(LICENSE_FILE.read_text(encoding="utf-8").encode("ascii"))
|
||||||
|
raw = _xor_bytes(encrypted)
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
payload = data["payload"]
|
||||||
|
signature = data["signature"]
|
||||||
|
payload_raw = _dump_json(payload)
|
||||||
|
expected_signature = hmac.new(_SECRET, payload_raw, hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(signature, expected_signature):
|
||||||
|
raise ValueError("signature mismatch")
|
||||||
|
|
||||||
|
return date.fromisoformat(str(payload["expires_on"]))
|
||||||
|
except Exception:
|
||||||
|
write_expires_on(DEFAULT_EXPIRES_ON)
|
||||||
|
return DEFAULT_EXPIRES_ON
|
||||||
|
|
||||||
|
|
||||||
|
def write_expires_on(expires_on: date) -> None:
|
||||||
|
payload = {"expires_on": expires_on.isoformat()}
|
||||||
|
payload_raw = _dump_json(payload)
|
||||||
|
data = {
|
||||||
|
"payload": payload,
|
||||||
|
"signature": hmac.new(_SECRET, payload_raw, hashlib.sha256).hexdigest(),
|
||||||
|
}
|
||||||
|
encrypted = _xor_bytes(_dump_json(data))
|
||||||
|
LICENSE_FILE.write_text(base64.urlsafe_b64encode(encrypted).decode("ascii"), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_zip_timestamp(value: str) -> date | None:
|
||||||
|
fmt = "%Y%m%d%H%M%S" if len(value) == 14 else "%Y%m%d%H%M"
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_json(data: dict[str, Any]) -> bytes:
|
||||||
|
return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_bytes(data: bytes) -> bytes:
|
||||||
|
output = bytearray()
|
||||||
|
counter = 0
|
||||||
|
while len(output) < len(data):
|
||||||
|
block = hashlib.sha256(_SECRET + counter.to_bytes(4, "big")).digest()
|
||||||
|
output.extend(block)
|
||||||
|
counter += 1
|
||||||
|
return bytes(value ^ key for value, key in zip(data, output))
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
# 项目上下文记录
|
# 项目上下文记录
|
||||||
|
## 2026-05-20:增加按 ZIP 数据日期校验的使用期限限制
|
||||||
|
|
||||||
|
- 新增 `app/services/license.py` 和 `/api/license/status`、`/api/license/activate`:本地 `license.dat` 用 XOR+HMAC 方式加密保存到期日期,缺失时自动初始化为 `2026-06-20`,文件已加入 `.gitignore`。
|
||||||
|
- 授权校验不读取系统日期;本地上传处理和远程下载完成后的处理入口会遍历任务目录下 ZIP 文件名,提取 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳并取最大日期作为数据日期,超过授权到期日则任务失败并返回 `LICENSE_EXPIRED` 详情。
|
||||||
|
- 激活码为当前到期日期 `YYYY/MM/DD` 字符串的 SHA-256 hex;每次激活只按当前加密文件里的到期日校验,成功后顺延 30 天,因此旧激活码不能重复顺延。
|
||||||
|
- `frontend/src/components/FileWorkflow.vue` 在任务因授权过期失败时弹出激活框,显示 `key: YYYY/MM/DD`,输入激活码成功后本地上传任务会继续处理,远程任务会重新发起远程下载处理。
|
||||||
|
- 如果任务中没有 ZIP,或 ZIP 文件名没有可识别时间戳,当前实现会写入警告并跳过授权日期比对,避免误伤直接 CSV/Excel 上传流程;如需强制所有数据都必须带 ZIP 日期,可在 `check_processing_allowed()` 中收紧该策略。
|
||||||
|
- 已执行授权逻辑临时目录验证、`.venv\Scripts\python.exe -m compileall app`、`uvx --offline ruff check .` 和 `npm run build`,均通过;前端构建仅保留 Vite 大 chunk 提示,生成产物已清理。
|
||||||
|
|
||||||
## 2026-05-19:配置按请求实时重载
|
## 2026-05-19:配置按请求实时重载
|
||||||
- `app/state.py` 新增 `reload_config()` 和 `current_config()`,后端接口不再长期依赖启动时的 `state.config` 快照;读取配置、下载配置、数据库接口、健康检查、本地处理、远程处理和脚本执行入口都会从 `Configure.json` 重新加载最新配置。
|
- `app/state.py` 新增 `reload_config()` 和 `current_config()`,后端接口不再长期依赖启动时的 `state.config` 快照;读取配置、下载配置、数据库接口、健康检查、本地处理、远程处理和脚本执行入口都会从 `Configure.json` 重新加载最新配置。
|
||||||
- 配置保存类接口会先重载当前文件再修改对应配置块并保存,避免用户手工更新 `Configure.json` 后,被某个单项保存接口用旧内存配置覆盖。
|
- 配置保存类接口会先重载当前文件再修改对应配置块并保存,避免用户手工更新 `Configure.json` 后,被某个单项保存接口用旧内存配置覆盖。
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import type { ApiError } from '../types';
|
import type { ApiError, ApiErrorDetail } from '../types';
|
||||||
|
|
||||||
const TOKEN_KEY = 'capacity_report_token';
|
const TOKEN_KEY = 'capacity_report_token';
|
||||||
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||||
let onUnauthorized: (() => void) | null = null;
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
status: number;
|
||||||
|
code?: string;
|
||||||
|
detail?: ApiErrorDetail;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, detail?: ApiErrorDetail) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiRequestError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = detail?.code;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getToken(): string {
|
export function getToken(): string {
|
||||||
return localStorage.getItem(TOKEN_KEY) || '';
|
return localStorage.getItem(TOKEN_KEY) || '';
|
||||||
}
|
}
|
||||||
@@ -49,8 +63,8 @@ export async function request<T>(url: string, init: RequestInit = {}): Promise<T
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await readError(response);
|
const error = await readError(response);
|
||||||
throw new Error(message);
|
throw new ApiRequestError(error.message, response.status, error.detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
@@ -111,7 +125,8 @@ export async function download(url: string, body: unknown, filename: string): Pr
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readError(response));
|
const error = await readError(response);
|
||||||
|
throw new ApiRequestError(error.message, response.status, error.detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename);
|
await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename);
|
||||||
@@ -132,31 +147,46 @@ export async function downloadGet(url: string, fallbackFilename: string): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readError(response));
|
const error = await readError(response);
|
||||||
|
throw new ApiRequestError(error.message, response.status, error.detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename;
|
const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename;
|
||||||
await saveBlobResponse(response, filename);
|
await saveBlobResponse(response, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readError(response: Response): Promise<string> {
|
async function readError(response: Response): Promise<{ message: string; detail?: ApiErrorDetail }> {
|
||||||
try {
|
try {
|
||||||
const data = (await response.json()) as ApiError;
|
const data = (await response.json()) as ApiError;
|
||||||
return data.detail || data.error || data.message || response.statusText;
|
return parseApiError(data, response.statusText);
|
||||||
} catch {
|
} catch {
|
||||||
return response.statusText || '请求失败';
|
return { message: response.statusText || '请求失败' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseXhrError(text: string): string {
|
function parseXhrError(text: string): string {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(text) as ApiError;
|
const data = JSON.parse(text) as ApiError;
|
||||||
return data.detail || data.error || data.message || '请求失败';
|
return parseApiError(data, '请求失败').message;
|
||||||
} catch {
|
} catch {
|
||||||
return text || '请求失败';
|
return text || '请求失败';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseApiError(data: ApiError, fallback: string): { message: string; detail?: ApiErrorDetail } {
|
||||||
|
if (typeof data.detail === 'object' && data.detail !== null) {
|
||||||
|
return {
|
||||||
|
message: data.detail.message || data.message || data.error || fallback,
|
||||||
|
detail: data.detail
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: data.detail || data.error || data.message || fallback,
|
||||||
|
detail: data.code ? { code: data.code, message: data.message || data.error } : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function saveBlobResponse(response: Response, filename: string): Promise<void> {
|
async function saveBlobResponse(response: Response, filename: string): Promise<void> {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -146,6 +146,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="licenseModalVisible"
|
||||||
|
preset="card"
|
||||||
|
title="授权已过期"
|
||||||
|
:mask-closable="!activationLoading"
|
||||||
|
:style="{ width: '420px', maxWidth: 'calc(100vw - 32px)' }"
|
||||||
|
>
|
||||||
|
<div class="license-dialog-body">
|
||||||
|
<p class="license-dialog-text">{{ licenseErrorMessage }}</p>
|
||||||
|
<div class="license-key-label">key: {{ activationKeyLabel }}</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="activationCode"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
placeholder="请输入激活码"
|
||||||
|
:disabled="activationLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="license-dialog-footer">
|
||||||
|
<n-button :disabled="activationLoading" @click="licenseModalVisible = false">取消</n-button>
|
||||||
|
<n-button type="primary" :loading="activationLoading" @click="submitActivation">激活</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -161,8 +187,8 @@ import {
|
|||||||
TrashOutline
|
TrashOutline
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
|
|
||||||
import { apiGet, apiPost, upload } from '../api/client';
|
import { ApiRequestError, apiGet, apiPost, upload } from '../api/client';
|
||||||
import type { ActiveTask, TaskStatus } from '../types';
|
import type { ActiveTask, ApiErrorDetail, LicenseStatus, TaskStatus } from '../types';
|
||||||
|
|
||||||
type FileStatus = 'pending' | 'uploading' | 'uploaded' | 'error';
|
type FileStatus = 'pending' | 'uploading' | 'uploaded' | 'error';
|
||||||
|
|
||||||
@@ -199,9 +225,17 @@ const taskStatus = ref<TaskStatus | null>(null);
|
|||||||
const activeTask = ref<ActiveTask | null>(null);
|
const activeTask = ref<ActiveTask | null>(null);
|
||||||
const keepLatestLog = ref(false);
|
const keepLatestLog = ref(false);
|
||||||
const logContainer = ref<HTMLElement | null>(null);
|
const logContainer = ref<HTMLElement | null>(null);
|
||||||
|
const licenseModalVisible = ref(false);
|
||||||
|
const activationCode = ref('');
|
||||||
|
const activationLoading = ref(false);
|
||||||
|
const activationKeyLabel = ref('2026/06/20');
|
||||||
|
const licenseErrorMessage = ref('当前数据日期已超过授权到期日期,请输入激活码延长 30 天。');
|
||||||
|
const taskMode = ref<'local' | 'remote' | 'unknown'>('unknown');
|
||||||
|
let activationRetry: (() => Promise<void>) | null = null;
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
|
||||||
const stageLabels: Record<string, string> = {
|
const stageLabels: Record<string, string> = {
|
||||||
|
license: '授权校验中...',
|
||||||
downloading: '远程下载中...',
|
downloading: '远程下载中...',
|
||||||
uploading: '上传文件中...',
|
uploading: '上传文件中...',
|
||||||
processing: '处理中...',
|
processing: '处理中...',
|
||||||
@@ -454,16 +488,16 @@ async function uploadAndStart() {
|
|||||||
item.progress = 100;
|
item.progress = 100;
|
||||||
});
|
});
|
||||||
message.success(`上传完成:${result.file_count ?? files.value.length} 个文件`);
|
message.success(`上传完成:${result.file_count ?? files.value.length} 个文件`);
|
||||||
await apiPost('/api/process/start', { task_id: result.task_id });
|
await startUploadedTask(result.task_id);
|
||||||
taskStatus.value = { task_id: result.task_id, status: 'processing', stage: 'processing', logs: ['任务已提交,等待处理日志...'] };
|
|
||||||
startPolling(result.task_id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
files.value.forEach(item => {
|
files.value.forEach(item => {
|
||||||
if (item.status === 'uploading') {
|
if (item.status === 'uploading') {
|
||||||
item.status = 'error';
|
item.status = 'error';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
message.error(error instanceof Error ? error.message : '上传或启动任务失败');
|
if (!handleApiLicenseError(error)) {
|
||||||
|
message.error(error instanceof Error ? error.message : '上传或启动任务失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
working.value = false;
|
working.value = false;
|
||||||
}
|
}
|
||||||
@@ -490,14 +524,29 @@ async function startRemoteProcessing() {
|
|||||||
stage: result.stage || 'downloading',
|
stage: result.stage || 'downloading',
|
||||||
logs: ['远程下载任务已提交,等待处理日志...']
|
logs: ['远程下载任务已提交,等待处理日志...']
|
||||||
};
|
};
|
||||||
|
taskMode.value = 'remote';
|
||||||
startPolling(result.task_id);
|
startPolling(result.task_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '启动远程自动化任务失败');
|
if (!handleApiLicenseError(error)) {
|
||||||
|
message.error(error instanceof Error ? error.message : '启动远程自动化任务失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
remoteStarting.value = false;
|
remoteStarting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startUploadedTask(taskId: string) {
|
||||||
|
await apiPost('/api/process/start', { task_id: taskId });
|
||||||
|
taskMode.value = 'local';
|
||||||
|
taskStatus.value = {
|
||||||
|
task_id: taskId,
|
||||||
|
status: 'processing',
|
||||||
|
stage: 'license',
|
||||||
|
logs: ['任务已提交,等待处理日志...']
|
||||||
|
};
|
||||||
|
startPolling(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
function updateUploadingFiles(progress: number) {
|
function updateUploadingFiles(progress: number) {
|
||||||
files.value.forEach(item => {
|
files.value.forEach(item => {
|
||||||
if (item.status === 'uploading') {
|
if (item.status === 'uploading') {
|
||||||
@@ -510,6 +559,7 @@ async function checkActiveTask() {
|
|||||||
try {
|
try {
|
||||||
activeTask.value = await apiGet<ActiveTask>('/api/task/status');
|
activeTask.value = await apiGet<ActiveTask>('/api/task/status');
|
||||||
if (activeTask.value.has_active && activeTask.value.task_id) {
|
if (activeTask.value.has_active && activeTask.value.task_id) {
|
||||||
|
taskMode.value = 'unknown';
|
||||||
startPolling(activeTask.value.task_id);
|
startPolling(activeTask.value.task_id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -539,6 +589,9 @@ async function poll(taskId: string) {
|
|||||||
if (['completed', 'failed'].includes(taskStatus.value.status)) {
|
if (['completed', 'failed'].includes(taskStatus.value.status)) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
await checkActiveTask();
|
await checkActiveTask();
|
||||||
|
if (taskStatus.value.status === 'failed') {
|
||||||
|
handleTaskLicenseError(taskStatus.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
@@ -546,6 +599,86 @@ async function poll(taskId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTaskLicenseError(status: TaskStatus): boolean {
|
||||||
|
const detail = status.error_detail || parseLicenseDetail(status.error || '');
|
||||||
|
if (detail?.code !== 'LICENSE_EXPIRED') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = status.task_id;
|
||||||
|
const retry =
|
||||||
|
taskMode.value === 'local'
|
||||||
|
? () => startUploadedTask(taskId)
|
||||||
|
: taskMode.value === 'remote'
|
||||||
|
? () => startRemoteProcessing()
|
||||||
|
: null;
|
||||||
|
openLicenseModal(detail, retry);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApiLicenseError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof ApiRequestError) || error.code !== 'LICENSE_EXPIRED') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
openLicenseModal(error.detail);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLicenseModal(detail?: ApiErrorDetail, retry?: (() => Promise<void>) | null) {
|
||||||
|
activationKeyLabel.value = detail?.key_label || activationKeyLabel.value;
|
||||||
|
licenseErrorMessage.value =
|
||||||
|
detail?.message || '当前数据日期已超过授权到期日期,请输入激活码延长 30 天。';
|
||||||
|
activationCode.value = '';
|
||||||
|
activationRetry = retry || null;
|
||||||
|
licenseModalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitActivation() {
|
||||||
|
const code = activationCode.value.trim();
|
||||||
|
if (!code) {
|
||||||
|
message.warning('请输入激活码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activationLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await apiPost<LicenseStatus>('/api/license/activate', { code });
|
||||||
|
activationKeyLabel.value = result.key_label;
|
||||||
|
licenseModalVisible.value = false;
|
||||||
|
message.success(`激活成功,到期日期: ${result.expires_on}`);
|
||||||
|
if (activationRetry) {
|
||||||
|
const retry = activationRetry;
|
||||||
|
activationRetry = null;
|
||||||
|
await retry();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiRequestError && error.detail?.key_label) {
|
||||||
|
activationKeyLabel.value = error.detail.key_label;
|
||||||
|
}
|
||||||
|
message.error(error instanceof Error ? error.message : '激活失败');
|
||||||
|
} finally {
|
||||||
|
activationLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLicenseDetail(text: string): ApiErrorDetail | null {
|
||||||
|
if (!text.includes('授权已过期')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresMatch = text.match(/到期日期\s+(\d{4}-\d{2}-\d{2})/);
|
||||||
|
const currentMatch = text.match(/数据日期\s+(\d{4}-\d{2}-\d{2})/);
|
||||||
|
const expiresOn = expiresMatch?.[1];
|
||||||
|
return {
|
||||||
|
code: 'LICENSE_EXPIRED',
|
||||||
|
message: text,
|
||||||
|
expires_on: expiresOn,
|
||||||
|
current_date: currentMatch?.[1],
|
||||||
|
key_label: expiresOn ? expiresOn.replace(/-/g, '/') : activationKeyLabel.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function stageText(stage?: string | null): string {
|
function stageText(stage?: string | null): string {
|
||||||
if (!stage) return '处理中...';
|
if (!stage) return '处理中...';
|
||||||
return stageLabels[stage] || '处理中...';
|
return stageLabels[stage] || '处理中...';
|
||||||
|
|||||||
@@ -619,6 +619,35 @@ select {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.license-dialog-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-dialog-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-key-label {
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--td-brand-color-light);
|
||||||
|
border: 1px solid var(--td-brand-color-focus);
|
||||||
|
border-radius: var(--td-radius-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.file-list {
|
.file-list {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -4,9 +4,27 @@ export interface LoginResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
detail?: string;
|
detail?: string | ApiErrorDetail;
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorDetail {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
expires_on?: string;
|
||||||
|
current_date?: string;
|
||||||
|
key_label?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenseStatus {
|
||||||
|
success: boolean;
|
||||||
|
expires_on: string;
|
||||||
|
key_label: string;
|
||||||
|
current_date?: string | null;
|
||||||
|
zip_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskStatus {
|
export interface TaskStatus {
|
||||||
@@ -16,6 +34,7 @@ export interface TaskStatus {
|
|||||||
logs: string[];
|
logs: string[];
|
||||||
elapsed_time?: number;
|
elapsed_time?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
error_detail?: ApiErrorDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveTask {
|
export interface ActiveTask {
|
||||||
|
|||||||
Reference in New Issue
Block a user