diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java
index 0beb8357..4739cb3f 100644
--- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java
@@ -63,7 +63,20 @@ public class QueryAsinTaskService {
private static final int RESULT_SUCCESS = 1;
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
+ /**
+ * 整档快照 JSON 的写入节流间隔(2026-09 审查 C8)。
+ *
+ *
分片回传很频繁时逐次重写整份文档是 O(n²) 序列化开销;读端已改为**以
+ * biz_task_result_item 行为准、JSON 仅作兜底**,因此允许 JSON 短时滞后。
+ * 终态(结果文件生成/结束)走 force 立即写,保证历史详情最终一致。
+ */
+ private static final long SNAPSHOT_JSON_WRITE_THROTTLE_MILLIS = 30_000L;
+
private final FileTaskMapper fileTaskMapper;
+
+ /** taskId → 上次整档 JSON 写入时间(仅本进程节流;跨实例由终态强制写兜底) */
+ private final java.util.concurrent.ConcurrentHashMap snapshotJsonWrittenAt =
+ new java.util.concurrent.ConcurrentHashMap<>();
private final FileResultMapper fileResultMapper;
private final QueryAsinResolveService queryAsinResolveService;
private final QueryAsinExcelAssemblyService excelAssemblyService;
@@ -619,9 +632,11 @@ public class QueryAsinTaskService {
private Map> buildSnapshotMap(Map taskMap) {
Map> out = new LinkedHashMap<>();
for (Map.Entry entry : taskMap.entrySet()) {
- List snapshots = parseTaskSnapshots(entry.getValue().getResultJson());
+ // 以行表为准(增量真源),JSON 仅作兜底(历史任务的 JSON 是唯一副本)
+ List snapshots =
+ taskResultItemService.listResultSnapshots(entry.getKey(), MODULE_TYPE, QueryAsinResultItemVo.class);
if (snapshots.isEmpty()) {
- snapshots = taskResultItemService.listResultSnapshots(entry.getKey(), MODULE_TYPE, QueryAsinResultItemVo.class);
+ snapshots = parseTaskSnapshots(entry.getValue().getResultJson());
}
out.put(entry.getKey(), indexSnapshotByResultId(snapshots));
}
@@ -722,7 +737,12 @@ public class QueryAsinTaskService {
}
private List buildSnapshotFromDb(FileTaskEntity task, List rows) {
- List existing = parseTaskSnapshots(task.getResultJson());
+ // 同上:行表优先,JSON 兜底
+ List existing =
+ taskResultItemService.listResultSnapshots(task.getId(), MODULE_TYPE, QueryAsinResultItemVo.class);
+ if (existing.isEmpty()) {
+ existing = parseTaskSnapshots(task.getResultJson());
+ }
Map snapshotByResultId = indexSnapshotByResultId(existing);
List list = new ArrayList<>();
for (FileResultEntity row : rows) {
@@ -962,7 +982,8 @@ public class QueryAsinTaskService {
}
}
- persistSnapshotJson(task, snapshots);
+ // 终态:强制重写整档 JSON,保证历史详情与结果文件口径一致
+ persistSnapshotJson(task, snapshots, true);
fileTaskMapper.updateById(task);
taskCacheService.deleteTaskCache(task.getId());
}
@@ -1008,7 +1029,7 @@ public class QueryAsinTaskService {
}
updateResultFileColumns(successRowIds, filename, objectKey, fileSize, rowCount);
updateTaskStatusFromRows(task, rows);
- persistSnapshotJson(task, buildSnapshotFromDb(task, rows));
+ persistSnapshotJson(task, buildSnapshotFromDb(task, rows), true);
fileTaskMapper.updateById(task);
} finally {
FileUtil.del(xlsx);
@@ -1090,14 +1111,37 @@ public class QueryAsinTaskService {
}
private void persistSnapshotJson(FileTaskEntity task, List snapshots) {
+ persistSnapshotJson(task, snapshots, false);
+ }
+
+ /**
+ * @param force true=立即重写整档 JSON(终态路径用);false=按节流窗口决定是否重写
+ */
+ private void persistSnapshotJson(FileTaskEntity task, List snapshots, boolean force) {
try {
- task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
+ // 行表始终同步(增量 upsert,内容未变时自身会跳过)
syncSnapshotTables(task, snapshots);
+ if (!force && !snapshotJsonWriteDue(task)) {
+ return;
+ }
+ task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
+ if (task.getId() != null) {
+ snapshotJsonWrittenAt.put(task.getId(), System.currentTimeMillis());
+ }
} catch (Exception ex) {
throw new BusinessException("查询ASIN任务快照保存失败");
}
}
+ private boolean snapshotJsonWriteDue(FileTaskEntity task) {
+ if (task == null || task.getId() == null) {
+ return true;
+ }
+ Long lastWrittenAt = snapshotJsonWrittenAt.get(task.getId());
+ return lastWrittenAt == null
+ || System.currentTimeMillis() - lastWrittenAt >= SNAPSHOT_JSON_WRITE_THROTTLE_MILLIS;
+ }
+
private void syncSnapshotTables(FileTaskEntity task, List snapshots) {
List safe = snapshots == null ? List.of() : snapshots;
taskResultItemService.replaceTaskSnapshots(task.getId(), MODULE_TYPE, safe, new TaskResultItemService.SnapshotKeyResolver() {
diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java
index 7103267f..e2429574 100644
--- a/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java
+++ b/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java
@@ -29,9 +29,15 @@ class AdminApiGuardFilterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private AdminApiGuardFilter newFilter(AdminAuthSupport adminAuthSupport, boolean enabled, String exemptPrefixes) {
+ return newFilter(adminAuthSupport, enabled, exemptPrefixes, false);
+ }
+
+ private AdminApiGuardFilter newFilter(AdminAuthSupport adminAuthSupport, boolean enabled, String exemptPrefixes,
+ boolean userToolGuardEnabled) {
AdminApiGuardFilter filter = new AdminApiGuardFilter(adminAuthSupport, objectMapper);
ReflectionTestUtils.setField(filter, "enabled", enabled);
ReflectionTestUtils.setField(filter, "exemptPrefixes", exemptPrefixes);
+ ReflectionTestUtils.setField(filter, "userToolGuardEnabled", userToolGuardEnabled);
return filter;
}
@@ -251,4 +257,82 @@ class AdminApiGuardFilterTest {
}
verify(authSupport, never()).requireUserOrInternal(any());
}
-}
+ // ---- 用户态工具前缀(A1/A3):开关默认关闭,打开后按"用户令牌或内部令牌"鉴权 ----
+
+ @Test
+ void userToolPrefixesAreOpenWhileGuardDisabled() throws Exception {
+ AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
+ AdminApiGuardFilter filter = newFilter(authSupport, true, "", false);
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/patrol-delete/tasks/submit");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ verify(authSupport, never()).requireUserOrInternal(any());
+ assertThat(chain.getRequest()).isNotNull();
+ }
+
+ @Test
+ void enabledUserToolGuardRejectsAnonymous() throws Exception {
+ AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
+ when(authSupport.requireUserOrInternal(any())).thenThrow(new BusinessException(401, "未登录"));
+ AdminApiGuardFilter filter = newFilter(authSupport, true, "", true);
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/patrol-delete/tasks/submit");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(chain.getRequest()).isNull();
+ assertThat(response.getStatus()).isEqualTo(200);
+ JsonNode body = objectMapper.readTree(response.getContentAsString());
+ assertThat(body.path("success").asBoolean()).isFalse();
+ assertThat(body.path("code").asInt()).isEqualTo(401);
+ }
+
+ @Test
+ void enabledUserToolGuardAcceptsUserToken() throws Exception {
+ AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
+ when(authSupport.requireUserOrInternal(any())).thenReturn(new AdminUserEntity());
+ AdminApiGuardFilter filter = newFilter(authSupport, true, "", true);
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/withdraw/tasks/submit");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(chain.getRequest()).isNotNull();
+ verify(authSupport).requireUserOrInternal(any());
+ }
+
+ @Test
+ void enabledUserToolGuardStillLetsTrustedInternalTokenThrough() throws Exception {
+ AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
+ when(authSupport.isTrustedInternalToken(any())).thenReturn(true);
+ AdminApiGuardFilter filter = newFilter(authSupport, true, "", true);
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/collect-data/tasks/submit");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ verify(authSupport, never()).requireUserOrInternal(any());
+ assertThat(chain.getRequest()).isNotNull();
+ }
+
+ @Test
+ void enabledUserToolGuardCoversZiniaoPrefixAsSecondLayer() throws Exception {
+ AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
+ when(authSupport.requireUserOrInternal(any())).thenThrow(new BusinessException(401, "未登录"));
+ AdminApiGuardFilter filter = newFilter(authSupport, true, "", true);
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/ziniao/staff");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ MockFilterChain chain = new MockFilterChain();
+
+ filter.doFilter(request, response, chain);
+
+ assertThat(chain.getRequest()).isNull();
+ assertThat(response.getContentAsString()).contains("401");
+ }
+}
\ No newline at end of file
diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinSnapshotJsonThrottleTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinSnapshotJsonThrottleTest.java
new file mode 100644
index 00000000..f28a5953
--- /dev/null
+++ b/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinSnapshotJsonThrottleTest.java
@@ -0,0 +1,95 @@
+package com.nanri.aiimage.modules.queryasin.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinResultItemVo;
+import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
+import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
+import com.nanri.aiimage.modules.task.service.TaskResultItemService;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 快照 JSON 整档写入的节流语义(2026-09 审查 C8)。
+ *
+ * 读端已改为"以 biz_task_result_item 行为准、JSON 兜底",因此允许 JSON 短时滞后:
+ * 节流窗口内只同步行表、不重写整档 JSON;终态路径传 force=true 立即重写。
+ * 本测试直接钉住这两条契约(其余依赖与本次语义无关,传 null/mock)。
+ */
+class QueryAsinSnapshotJsonThrottleTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final TaskResultItemService taskResultItemService = mock(TaskResultItemService.class);
+ private final TaskProgressSnapshotService taskProgressSnapshotService = mock(TaskProgressSnapshotService.class);
+
+ private QueryAsinTaskService service() {
+ // 构造顺序:mapper/resultMapper/resolve/excel/cache/oss/resultDownloadResolver/ziniaoSwitch/
+ // objectMapper/pressure/jobService/resultItemService/snapshot/lock/lightAssembler
+ return new QueryAsinTaskService(
+ null, null, null, null, null, null, null, null,
+ objectMapper, null, null, taskResultItemService, taskProgressSnapshotService, null, null);
+ }
+
+ private static void persist(QueryAsinTaskService service, FileTaskEntity task,
+ List snapshots, boolean force) throws Exception {
+ Method method = QueryAsinTaskService.class.getDeclaredMethod(
+ "persistSnapshotJson", FileTaskEntity.class, List.class, boolean.class);
+ method.setAccessible(true);
+ method.invoke(service, task, snapshots, force);
+ }
+
+ private static QueryAsinResultItemVo item(long resultId, String shopName) {
+ QueryAsinResultItemVo vo = new QueryAsinResultItemVo();
+ vo.setResultId(resultId);
+ vo.setShopName(shopName);
+ vo.setSuccess(true);
+ return vo;
+ }
+
+ @Test
+ void firstWriteGoesThroughThenThrottledUntilForced() throws Exception {
+ QueryAsinTaskService service = service();
+ FileTaskEntity task = new FileTaskEntity();
+ task.setId(9001L);
+
+ List first = List.of(item(1L, "店铺A"));
+ persist(service, task, first, false);
+ String afterFirst = task.getResultJson();
+ assertTrue(afterFirst != null && afterFirst.contains("店铺A"), "首次写入立即生效(无历史写入时间)");
+
+ // 节流窗口内:行表仍同步,但 JSON 不重写(新数据要等窗口结束或 force)
+ List second = List.of(item(1L, "店铺A"), item(2L, "店铺B"));
+ persist(service, task, second, false);
+ assertEquals(afterFirst, task.getResultJson(), "窗口内不重写整档 JSON");
+ verify(taskResultItemService, times(2))
+ .replaceTaskSnapshots(anyLong(), anyString(), any(), any());
+
+ // 终态:force 立即重写
+ persist(service, task, second, true);
+ assertTrue(task.getResultJson().contains("店铺B"), "force 立即重写整档 JSON");
+ }
+
+ @Test
+ void emptySnapshotStillSyncsRowTable() throws Exception {
+ QueryAsinTaskService service = service();
+ FileTaskEntity task = new FileTaskEntity();
+ task.setId(9002L);
+
+ persist(service, task, new ArrayList<>(), false);
+
+ verify(taskResultItemService).replaceTaskSnapshots(anyLong(), anyString(), any(), any());
+ assertEquals("[]", task.getResultJson(), "空快照序列化为空数组");
+ }
+}
diff --git a/frontend-vue/src/main.ts b/frontend-vue/src/main.ts
index 3c47bf7c..14daf2fe 100644
--- a/frontend-vue/src/main.ts
+++ b/frontend-vue/src/main.ts
@@ -5,6 +5,10 @@ import App from '@/App.vue'
import router from '@/router'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import { ensureApiSecretsLoaded, loadApiSecrets } from '@/shared/utils/api-secret-store'
+import { installUserTokenSync } from '@/shared/auth/user-token-sync.ts'
+
+// 登录态令牌同步给桌面端 Python(A1/A3):无桥环境静默跳过
+installUserTokenSync()
/**
* 数富AI 前端统一入口(SPA,URL 无 .html 后缀)
diff --git a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue
index 37c80a43..4eac2a6e 100644
--- a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue
+++ b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue
@@ -186,6 +186,7 @@ import { formatDateTime } from '@/shared/utils/datetime'
import { runBatchDelete } from '@/shared/utils/batch-delete'
import { isRecordMissingError } from '@/shared/utils/task-queue-state.ts'
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
+import { getPollingProgressBatch } from '@/shared/api/task-progress-polling.ts'
const shopInput = ref('')
const candidates = ref([])
@@ -752,7 +753,10 @@ async function refreshTaskBatch() {
const ids = pollingTaskIds.value.filter((id) => id > 0)
if (!ids.length) return
try {
- const batch = await getProductRiskTaskProgressBatch(ids)
+ // F5:轮询只读 task.id/status(轻量白名单覆盖),走 light 端点减小响应体,异常回退重型 batch
+ const batch = await getPollingProgressBatch('productRisk', ids, {
+ fallback: () => getProductRiskTaskProgressBatch(ids),
+ })
let changed = false
const nextSnapshots = { ...taskSnapshots.value }
for (const d of batch.items || []) {
@@ -835,7 +839,10 @@ async function waitForTaskTerminal(taskId: number) {
while (true) {
if (disposed) return 'STOPPED'
try {
- const batch = await getProductRiskTaskProgressBatch([taskId])
+ // F5:等待终态的紧循环只读 status,走 light 端点(异常回退重型 batch)
+ const batch = await getPollingProgressBatch('productRisk', [taskId], {
+ fallback: () => getProductRiskTaskProgressBatch([taskId]),
+ })
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`
}
diff --git a/frontend-vue/src/shared/auth/user-token-bridge.ts b/frontend-vue/src/shared/auth/user-token-bridge.ts
new file mode 100644
index 00000000..fb87e5ec
--- /dev/null
+++ b/frontend-vue/src/shared/auth/user-token-bridge.ts
@@ -0,0 +1,56 @@
+/**
+ * 登录态令牌 → 桌面端 Python 的同步(全维度审查 A1/A3)。
+ *
+ * 桌面端页面登录后令牌只存在 localStorage 里,Python 侧的采集/回传调用不带身份,
+ * 服务端只能匿名放行(内部令牌硬编码在客户端源码里,等同公开)。
+ * 这里把**变化过**的令牌推给桌面桥,Python 侧据此给自家 Java 请求带上 Authorization 头,
+ * 服务端即可按登录用户鉴权。
+ *
+ * 本模块刻意不 import 任何桥/运行时模块(纯逻辑,可在 Node 下单测);
+ * 真实推送目标由 user-token-sync.ts 在应用启动时注入。
+ */
+
+/** 推送目标(真实实现走桌面桥;测试可注入替身) */
+export interface TokenPushTarget {
+ push: (value: string) => void
+}
+
+/** 上次推送给桥的值;null 表示尚未推送过(首次空值也推一次,便于清空上一轮会话) */
+let lastPushed: string | null = null
+
+/** 当前生效的推送目标(由 user-token-sync.ts 安装) */
+let installedTarget: TokenPushTarget | null = null
+
+/** 安装/替换推送目标;传 null 表示当前没有可用的桥。 */
+export function setUserTokenPushTarget(target: TokenPushTarget | null): void {
+ installedTarget = target
+}
+
+/**
+ * 把登录态令牌同步给 Python(值变化才推送)。返回是否真的推送了。
+ *
+ * @param target 省略时用已安装的目标;显式传 null 表示"没有可用的桥"(不推送)
+ */
+export function syncUserTokenToBridge(token: string, target?: TokenPushTarget | null): boolean {
+ const value = (token || '').trim()
+ if (value === lastPushed) {
+ return false
+ }
+ const resolved = target === undefined ? installedTarget : target
+ if (!resolved) {
+ return false
+ }
+ lastPushed = value
+ try {
+ resolved.push(value)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/** 仅供测试:重置状态 */
+export function resetUserTokenBridgeStateForTest(): void {
+ lastPushed = null
+ installedTarget = null
+}
diff --git a/frontend-vue/src/shared/auth/user-token-sync.ts b/frontend-vue/src/shared/auth/user-token-sync.ts
new file mode 100644
index 00000000..8c9eb4bd
--- /dev/null
+++ b/frontend-vue/src/shared/auth/user-token-sync.ts
@@ -0,0 +1,60 @@
+import { getPywebviewApi } from '@/shared/bridges/pywebview'
+import { setUserTokenPushTarget, syncUserTokenToBridge } from '@/shared/auth/user-token-bridge'
+
+/**
+ * 把登录态令牌持续同步给桌面端 Python(A1/A3)。
+ *
+ * 触发点:应用启动(桥可能未就绪,监听 pywebviewready 后补推)、localStorage 变化(登录/登出)、
+ * 以及每 60 秒兜底轮询(同标签页内 localStorage 变化不触发 storage 事件)。
+ * 网页形态(无桥)静默跳过。
+ */
+
+const AUTH_TOKEN_KEY = 'aiimage_auth_token'
+const POLL_INTERVAL_MS = 60_000
+
+let installed = false
+
+function readToken(): string {
+ try {
+ return window.localStorage.getItem(AUTH_TOKEN_KEY) || ''
+ } catch {
+ return ''
+ }
+}
+
+function pushNow(): void {
+ syncUserTokenToBridge(readToken())
+}
+
+export function installUserTokenSync(): void {
+ if (installed || typeof window === 'undefined') {
+ return
+ }
+ installed = true
+
+ const apply = () => {
+ const api = getPywebviewApi()
+ setUserTokenPushTarget(
+ api && typeof api.set_user_token === 'function'
+ ? {
+ push: (value: string) => {
+ void Promise.resolve(api.set_user_token?.(value)).catch(() => {
+ /* 桥调用失败不影响页面 */
+ })
+ },
+ }
+ : null,
+ )
+ pushNow()
+ }
+
+ // 桥可能晚于脚本就绪:先尝试一次,再在 ready 事件与轮询里补
+ apply()
+ window.addEventListener('pywebviewready', apply)
+ window.addEventListener('storage', (event) => {
+ if (!event.key || event.key === AUTH_TOKEN_KEY) {
+ pushNow()
+ }
+ })
+ window.setInterval(pushNow, POLL_INTERVAL_MS)
+}
diff --git a/frontend-vue/src/shared/bridges/pywebview.ts b/frontend-vue/src/shared/bridges/pywebview.ts
index c94449b2..f1c4812b 100644
--- a/frontend-vue/src/shared/bridges/pywebview.ts
+++ b/frontend-vue/src/shared/bridges/pywebview.ts
@@ -63,6 +63,12 @@ export interface DesktopConfigUpdate {
}
export interface PywebviewApi {
+ /**
+ * 把页面登录态令牌交给桌面端 Python 侧(审查 A1/A3):
+ * Python 调用自家 Java 接口时会带上 Authorization: Bearer ,服务端可按人鉴权。
+ * 网页形态(降级桥)无此方法,页面静默跳过。
+ */
+ set_user_token?: (token: string) => Promise;
close?: () => Promise;
minimize?: () => Promise;
maximize?: () => Promise;
diff --git a/frontend-vue/src/shared/client-changelog.ts b/frontend-vue/src/shared/client-changelog.ts
index a5ed0a24..3da71f9d 100644
--- a/frontend-vue/src/shared/client-changelog.ts
+++ b/frontend-vue/src/shared/client-changelog.ts
@@ -24,6 +24,13 @@ export interface ClientChangelogEntry {
/** 更新日志数据(新版本在前;发版时在数组最前追加一条) */
export const CLIENT_CHANGELOG: ClientChangelogEntry[] = [
+ {
+ version: '4.0.14',
+ date: '2026-09-14',
+ items: [
+ '登录后任务会带上你的账号身份,服务端校验更严格',
+ ],
+ },
{
version: '4.0.13',
date: '2026-09-14',
diff --git a/frontend-vue/tests/user-token-bridge.test.ts b/frontend-vue/tests/user-token-bridge.test.ts
new file mode 100644
index 00000000..c982d388
--- /dev/null
+++ b/frontend-vue/tests/user-token-bridge.test.ts
@@ -0,0 +1,51 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { resetUserTokenBridgeStateForTest, syncUserTokenToBridge } from '../src/shared/auth/user-token-bridge.ts'
+import type { TokenPushTarget } from '../src/shared/auth/user-token-bridge.ts'
+
+function collector() {
+ const pushed: string[] = []
+ return {
+ target: { push: (value: string) => pushed.push(value) } as TokenPushTarget,
+ pushed,
+ }
+}
+
+test('令牌变化时推送,重复值不重复推送', () => {
+ resetUserTokenBridgeStateForTest()
+ const { target, pushed } = collector()
+
+ assert.equal(syncUserTokenToBridge('abc', target), true)
+ assert.equal(syncUserTokenToBridge('abc', target), false, '同值不重复推送')
+ assert.equal(syncUserTokenToBridge(' def ', target), true, '去空白后不同则推送')
+ assert.deepEqual(pushed, ['abc', 'def'])
+})
+
+test('登出推空串以清空 Python 侧令牌', () => {
+ resetUserTokenBridgeStateForTest()
+ const { target, pushed } = collector()
+
+ syncUserTokenToBridge('abc', target)
+ assert.equal(syncUserTokenToBridge('', target), true)
+ assert.deepEqual(pushed, ['abc', ''])
+})
+
+test('没有可用的桥时返回 false 且不记录状态(后续有桥仍会推送)', () => {
+ resetUserTokenBridgeStateForTest()
+ const { target, pushed } = collector()
+
+ assert.equal(syncUserTokenToBridge('abc', null), false, '无桥不推送')
+ assert.equal(syncUserTokenToBridge('abc', target), true, '之后有桥仍推送')
+ assert.deepEqual(pushed, ['abc'])
+})
+
+test('推送目标抛异常时返回 false', () => {
+ resetUserTokenBridgeStateForTest()
+ const throwing: TokenPushTarget = {
+ push: () => {
+ throw new Error('bridge down')
+ },
+ }
+
+ assert.equal(syncUserTokenToBridge('abc', throwing), false)
+})