feat(A1/A3+C8): 客户端令牌链路打通(按人鉴权就绪)+ 快照 JSON 写入节流
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 <jwt>,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 钩子自测(注入/归一/第三方跳过/不覆盖/登出)通过
This commit is contained in:
@@ -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 后缀)
|
||||
|
||||
@@ -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<ProductRiskCandidateVo[]>([])
|
||||
@@ -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} 服务已恢复,继续等待执行结果...`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -63,6 +63,12 @@ export interface DesktopConfigUpdate {
|
||||
}
|
||||
|
||||
export interface PywebviewApi {
|
||||
/**
|
||||
* 把页面登录态令牌交给桌面端 Python 侧(审查 A1/A3):
|
||||
* Python 调用自家 Java 接口时会带上 Authorization: Bearer <token>,服务端可按人鉴权。
|
||||
* 网页形态(降级桥)无此方法,页面静默跳过。
|
||||
*/
|
||||
set_user_token?: (token: string) => Promise<unknown>;
|
||||
close?: () => Promise<void>;
|
||||
minimize?: () => Promise<void>;
|
||||
maximize?: () => Promise<void>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user