From c55c4a140bf259c5940ef2dbe59e44bea4aa14cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 14 Sep 2026 10:20:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(A1/A3+C8):=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E9=93=BE=E8=B7=AF=E6=89=93=E9=80=9A=EF=BC=88?= =?UTF-8?q?=E6=8C=89=E4=BA=BA=E9=89=B4=E6=9D=83=E5=B0=B1=E7=BB=AA=EF=BC=89?= =?UTF-8?q?+=20=E5=BF=AB=E7=85=A7=20JSON=20=E5=86=99=E5=85=A5=E8=8A=82?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1/A3 客户端令牌链路(服务端守卫已能按 JWT 鉴权,缺口在客户端不带身份) - 前端:新增 user-token-bridge(纯逻辑,值变化才推送)+ user-token-sync(启动安装, pywebviewready/storage/60s 轮询补推),main.ts 接入;桥接口补 set_user_token - 客户端:新增 crawler_core/user_token.py(持有 + 对自家 Java 端点注入 Authorization: Bearer ,Session.request 包装,第三方域名不注入、已有头不覆盖、 登出清空、SHUFUAI_DISABLE_USER_TOKEN_HOOK 可关);main.py 暴露桥方法并在启动安装钩子 - 服务端:守卫契约测试补 5 个用户态前缀用例(开关关闭放行 / 打开后匿名 401 / 用户令牌通过 / 内部令牌仍放行 / /api/ziniao 第二层) - 翻开关的前置条件(客户端铺开后置 aiimage.security.user-tool-guard-enabled=true)写入报告 C8 快照 JSON 写入节流 - 读端改以 biz_task_result_item 行为准、JSON 仅兜底(历史任务 JSON 仍是唯一副本时可用) - 整档 JSON 改 30s 节流写,终态路径 force 立即写;新增 2 个契约测试钉住语义 验证:mvn test 2888 全绿;npm test 741 全绿;user_token 钩子自测(注入/归一/第三方跳过/不覆盖/登出)通过 --- .../service/QueryAsinTaskService.java | 56 +++++++++-- .../config/AdminApiGuardFilterTest.java | 86 ++++++++++++++++- .../QueryAsinSnapshotJsonThrottleTest.java | 95 +++++++++++++++++++ frontend-vue/src/main.ts | 4 + .../brand/components/BrandProductRiskTab.vue | 11 ++- .../src/shared/auth/user-token-bridge.ts | 56 +++++++++++ .../src/shared/auth/user-token-sync.ts | 60 ++++++++++++ frontend-vue/src/shared/bridges/pywebview.ts | 6 ++ frontend-vue/src/shared/client-changelog.ts | 7 ++ frontend-vue/tests/user-token-bridge.test.ts | 51 ++++++++++ 10 files changed, 423 insertions(+), 9 deletions(-) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinSnapshotJsonThrottleTest.java create mode 100644 frontend-vue/src/shared/auth/user-token-bridge.ts create mode 100644 frontend-vue/src/shared/auth/user-token-sync.ts create mode 100644 frontend-vue/tests/user-token-bridge.test.ts 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) +})