feat(密钥管理): 后台分组过滤+欠费状态识别
- 后台密钥列表:主管只看本组子账户(created_by_id=自己),超管全量可按创建人筛选;行 VO 新增 createdById/createdByUsername - 代理提取接口欠费(message=余额不足)识别为 insufficient_balance,不再静默回退直连 - 上游 LLM 网关欠费(code=insufficient_user_quota/预扣费额度失败)同样归为欠费并透传额度明细 - 前端:桌面设置面板显示"欠费",admin 后台单格药丸显示"欠费"、新增所属管理员列与超管筛选
This commit is contained in:
@@ -19,6 +19,8 @@ export interface AdminUserSecretModule {
|
|||||||
export interface AdminUserSecretRow {
|
export interface AdminUserSecretRow {
|
||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
|
createdById: number | null
|
||||||
|
createdByUsername: string
|
||||||
similarAsin: AdminUserSecretModule
|
similarAsin: AdminUserSecretModule
|
||||||
appearancePatent: AdminUserSecretModule
|
appearancePatent: AdminUserSecretModule
|
||||||
proxy: AdminUserSecretModule
|
proxy: AdminUserSecretModule
|
||||||
@@ -34,9 +36,17 @@ export interface AdminUserSecretPage {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 管理员下拉项(超管筛选用)。 */
|
||||||
|
export interface AdminOption {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSecretQuery {
|
export interface UserSecretQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
checkStatus?: string
|
checkStatus?: string
|
||||||
|
/** 按创建人筛选(仅超管生效)。 */
|
||||||
|
createdById?: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
import { fetchUserList } from '@/api/users'
|
||||||
import {
|
import {
|
||||||
checkUserSecret,
|
checkUserSecret,
|
||||||
deleteUserSecret,
|
deleteUserSecret,
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
type AdminUserSecretRow,
|
type AdminUserSecretRow,
|
||||||
} from '@/api/user-secrets'
|
} from '@/api/user-secrets'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<AdminUserSecretRow[]>([])
|
const rows = ref<AdminUserSecretRow[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -18,6 +21,9 @@ const page = ref(1)
|
|||||||
const pageSize = ref(15)
|
const pageSize = ref(15)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
|
const createdByIdFilter = ref<number | null>(null)
|
||||||
|
/** 管理员下拉(仅超管可见/加载)。 */
|
||||||
|
const adminOptions = ref<Array<{ id: number; username: string }>>([])
|
||||||
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
||||||
const checkingId = ref<number | null>(null)
|
const checkingId = ref<number | null>(null)
|
||||||
|
|
||||||
@@ -48,7 +54,7 @@ function rowStatusMeta(status: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单格状态药丸:未配置时统一灰色。 */
|
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
|
||||||
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
||||||
if (!module || !module.exists) {
|
if (!module || !module.exists) {
|
||||||
return { label: '未配置', tone: 'is-unknown' }
|
return { label: '未配置', tone: 'is-unknown' }
|
||||||
@@ -57,6 +63,9 @@ function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
|||||||
case 'passed':
|
case 'passed':
|
||||||
return { label: '通过', tone: 'is-allowed' }
|
return { label: '通过', tone: 'is-allowed' }
|
||||||
case 'failed':
|
case 'failed':
|
||||||
|
if (module.checkCode === 'insufficient_balance') {
|
||||||
|
return { label: '欠费', tone: 'is-warn' }
|
||||||
|
}
|
||||||
return { label: '失败', tone: 'is-blocked' }
|
return { label: '失败', tone: 'is-blocked' }
|
||||||
case 'error':
|
case 'error':
|
||||||
return { label: '无法判定', tone: 'is-warn' }
|
return { label: '无法判定', tone: 'is-warn' }
|
||||||
@@ -82,12 +91,23 @@ function rowStatusTooltip(row: AdminUserSecretRow) {
|
|||||||
return parts.join(';') || '暂无检测记录'
|
return parts.join(';') || '暂无检测记录'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAdminOptions() {
|
||||||
|
if (!session.isSuperAdmin) return
|
||||||
|
try {
|
||||||
|
const result = await fetchUserList({ page: 1, pageSize: 999, role: 'admin' })
|
||||||
|
adminOptions.value = result.admins || []
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[user-secrets] 管理员下拉加载失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchUserSecretList({
|
const result = await fetchUserSecretList({
|
||||||
keyword: keyword.value.trim() || undefined,
|
keyword: keyword.value.trim() || undefined,
|
||||||
checkStatus: statusFilter.value || undefined,
|
checkStatus: statusFilter.value || undefined,
|
||||||
|
createdById: createdByIdFilter.value || undefined,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
})
|
})
|
||||||
@@ -108,6 +128,7 @@ function search() {
|
|||||||
function reset() {
|
function reset() {
|
||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
statusFilter.value = ''
|
statusFilter.value = ''
|
||||||
|
createdByIdFilter.value = null
|
||||||
page.value = 1
|
page.value = 1
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
@@ -162,7 +183,10 @@ function changeSize(size: number) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => {
|
||||||
|
loadAdminOptions()
|
||||||
|
load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -183,6 +207,13 @@ onMounted(load)
|
|||||||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" v-if="session.isSuperAdmin" style="min-width: 170px">
|
||||||
|
<label>所属管理员</label>
|
||||||
|
<select v-model="createdByIdFilter">
|
||||||
|
<option :value="null">全部管理员</option>
|
||||||
|
<option v-for="admin in adminOptions" :key="admin.id" :value="admin.id">{{ admin.username }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label> </label>
|
<label> </label>
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
@@ -198,6 +229,7 @@ onMounted(load)
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="width: 58px">序号</th>
|
<th style="width: 58px">序号</th>
|
||||||
<th style="width: 260px">用户</th>
|
<th style="width: 260px">用户</th>
|
||||||
|
<th style="width: 130px">所属管理员</th>
|
||||||
<th style="width: 230px">货源查询密钥</th>
|
<th style="width: 230px">货源查询密钥</th>
|
||||||
<th style="width: 230px">外观专利密钥</th>
|
<th style="width: 230px">外观专利密钥</th>
|
||||||
<th style="width: 230px">代理设置</th>
|
<th style="width: 230px">代理设置</th>
|
||||||
@@ -212,6 +244,9 @@ onMounted(load)
|
|||||||
<td>
|
<td>
|
||||||
<span class="user-name">{{ row.username || '—' }}</span>
|
<span class="user-name">{{ row.username || '—' }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="creator-name">{{ row.createdByUsername || (row.createdById ? `UID ${row.createdById}` : '—') }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="module-cell">
|
<div class="module-cell">
|
||||||
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
||||||
@@ -258,10 +293,10 @@ onMounted(load)
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="7" class="empty-tip">加载中...</td>
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">{{ keyword || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
<td colspan="8" class="empty-tip">{{ keyword || statusFilter || createdByIdFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -408,7 +443,7 @@ h3 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
min-width: 1310px;
|
min-width: 1440px;
|
||||||
}
|
}
|
||||||
.secrets-table-scroll th,
|
.secrets-table-scroll th,
|
||||||
.secrets-table-scroll td {
|
.secrets-table-scroll td {
|
||||||
@@ -443,6 +478,10 @@ h3 {
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.creator-name {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
.module-cell {
|
.module-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+37
@@ -44,6 +44,7 @@ public class JikipProxyClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从提取链接取一个代理地址(http://ip:port);未配置或失败返回 null,由调用方回退直连。
|
* 从提取链接取一个代理地址(http://ip:port);未配置或失败返回 null,由调用方回退直连。
|
||||||
|
* 供应商欠费(message 含"余额不足")抛 InsufficientBalanceException 供调用方给出明确提示。
|
||||||
*/
|
*/
|
||||||
public String fetchProxyUrl() {
|
public String fetchProxyUrl() {
|
||||||
String extractUrl = normalize(properties.getCheckProxyExtractUrl());
|
String extractUrl = normalize(properties.getCheckProxyExtractUrl());
|
||||||
@@ -57,17 +58,53 @@ public class JikipProxyClient {
|
|||||||
.body(String.class);
|
.body(String.class);
|
||||||
String proxyUrl = parseProxyUrl(body);
|
String proxyUrl = parseProxyUrl(body);
|
||||||
if (proxyUrl == null) {
|
if (proxyUrl == null) {
|
||||||
|
if (isInsufficientBalance(body)) {
|
||||||
|
log.warn("[user-secret][proxy] 代理提取接口欠费,响应前 200 字={}", abbreviate(body, 200));
|
||||||
|
throw new InsufficientBalanceException();
|
||||||
|
}
|
||||||
log.warn("[user-secret][proxy] 提取接口返回中未找到 ip:port,响应前 200 字={}", abbreviate(body, 200));
|
log.warn("[user-secret][proxy] 提取接口返回中未找到 ip:port,响应前 200 字={}", abbreviate(body, 200));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
log.info("[user-secret][proxy] 代理提取成功 proxy={}", proxyUrl);
|
log.info("[user-secret][proxy] 代理提取成功 proxy={}", proxyUrl);
|
||||||
return proxyUrl;
|
return proxyUrl;
|
||||||
|
} catch (InsufficientBalanceException ex) {
|
||||||
|
throw ex;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[user-secret][proxy] 代理提取失败 err={}", ex.getMessage());
|
log.warn("[user-secret][proxy] 代理提取失败 err={}", ex.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提取接口响应指明欠费(如 {"code":-1,"message":"余额不足"})。 */
|
||||||
|
private boolean isInsufficientBalance(String body) {
|
||||||
|
String text = normalize(body);
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 先看 JSON 的 message/code 字段,再看任意文本兜底(防字段改名)。
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(text);
|
||||||
|
String message = text(root.get("message"));
|
||||||
|
if (message != null && message.contains("余额不足")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Double code = root.get("code") == null ? null : root.get("code").asDouble(Double.NaN);
|
||||||
|
if (code != null && !code.isNaN() && code == -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 非 JSON 走文本兜底
|
||||||
|
}
|
||||||
|
return text.contains("余额不足");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 代理服务供应商欠费。 */
|
||||||
|
public static class InsufficientBalanceException extends RuntimeException {
|
||||||
|
public InsufficientBalanceException() {
|
||||||
|
super("代理服务余额不足");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 余量查询:GET {balanceUrl}?id={planId}&userId={userId},返回 surplus/balance。 */
|
/** 余量查询:GET {balanceUrl}?id={planId}&userId={userId},返回 surplus/balance。 */
|
||||||
public UserApiSecretBalanceVo fetchBalance() {
|
public UserApiSecretBalanceVo fetchBalance() {
|
||||||
UserApiSecretBalanceVo vo = new UserApiSecretBalanceVo();
|
UserApiSecretBalanceVo vo = new UserApiSecretBalanceVo();
|
||||||
|
|||||||
+6
-3
@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.usersecret.controller;
|
|||||||
import com.nanri.aiimage.common.api.ApiResponse;
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
import com.nanri.aiimage.config.UserSecretProperties;
|
import com.nanri.aiimage.config.UserSecretProperties;
|
||||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||||
|
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||||
@@ -38,20 +39,22 @@ public class AdminUserApiSecretController {
|
|||||||
private final AdminAuthSupport adminAuthSupport;
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "分页查询用户密钥", description = "一行一用户;keyword 匹配用户名,checkStatus 按行级状态筛选。")
|
@Operation(summary = "分页查询用户密钥", description = "一行一用户;keyword 匹配用户名,checkStatus 按行级状态筛选。主管只能看自己名下子账户,超管看全量并可按创建人筛选。")
|
||||||
public ApiResponse<AdminUserSecretPageVo> page(
|
public ApiResponse<AdminUserSecretPageVo> page(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@Parameter(description = "关键字:用户名") @RequestParam(required = false) String keyword,
|
@Parameter(description = "关键字:用户名") @RequestParam(required = false) String keyword,
|
||||||
@Parameter(description = "行级状态筛选:passed/failed/incomplete/error/unknown") @RequestParam(required = false) String checkStatus,
|
@Parameter(description = "行级状态筛选:passed/failed/incomplete/error/unknown") @RequestParam(required = false) String checkStatus,
|
||||||
|
@Parameter(description = "按创建人筛选(仅超管生效)") @RequestParam(name = "created_by_id", required = false) Long createdById,
|
||||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
||||||
adminAuthSupport.requireAdmin(request);
|
AdminUserEntity operator = adminAuthSupport.requireAdmin(request);
|
||||||
AdminUserSecretQuery query = new AdminUserSecretQuery();
|
AdminUserSecretQuery query = new AdminUserSecretQuery();
|
||||||
query.setKeyword(keyword);
|
query.setKeyword(keyword);
|
||||||
query.setCheckStatus(checkStatus);
|
query.setCheckStatus(checkStatus);
|
||||||
|
query.setCreatedById(createdById);
|
||||||
query.setPage(page);
|
query.setPage(page);
|
||||||
query.setPageSize(pageSize);
|
query.setPageSize(pageSize);
|
||||||
return ApiResponse.success(userApiSecretService.adminPage(query));
|
return ApiResponse.success(userApiSecretService.adminPage(operator, query));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{userId}/check")
|
@PostMapping("/{userId}/check")
|
||||||
|
|||||||
+3
@@ -13,6 +13,9 @@ public class AdminUserSecretQuery {
|
|||||||
@Schema(description = "行级状态筛选:passed/failed/incomplete/error/unknown")
|
@Schema(description = "行级状态筛选:passed/failed/incomplete/error/unknown")
|
||||||
private String checkStatus;
|
private String checkStatus;
|
||||||
|
|
||||||
|
@Schema(description = "按创建人筛选(仅超管生效;主管强制为本组)")
|
||||||
|
private Long createdById;
|
||||||
|
|
||||||
@Schema(description = "页码,从 1 开始")
|
@Schema(description = "页码,从 1 开始")
|
||||||
private Long page = 1L;
|
private Long page = 1L;
|
||||||
|
|
||||||
|
|||||||
+6
@@ -15,6 +15,12 @@ public class AdminUserSecretRowVo {
|
|||||||
@Schema(description = "用户名")
|
@Schema(description = "用户名")
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "所属管理员(创建人)ID")
|
||||||
|
private Long createdById;
|
||||||
|
|
||||||
|
@Schema(description = "所属管理员用户名")
|
||||||
|
private String createdByUsername;
|
||||||
|
|
||||||
@Schema(description = "货源查询密钥")
|
@Schema(description = "货源查询密钥")
|
||||||
private AdminUserSecretModuleVo similarAsin;
|
private AdminUserSecretModuleVo similarAsin;
|
||||||
|
|
||||||
|
|||||||
+47
-1
@@ -45,6 +45,10 @@ public class UserApiSecretCheckService {
|
|||||||
public static final String CODE_SERVER_ERROR = "server_error";
|
public static final String CODE_SERVER_ERROR = "server_error";
|
||||||
public static final String CODE_NETWORK_ERROR = "network_error";
|
public static final String CODE_NETWORK_ERROR = "network_error";
|
||||||
public static final String CODE_PROVIDER_ERROR = "provider_error";
|
public static final String CODE_PROVIDER_ERROR = "provider_error";
|
||||||
|
public static final String CODE_INSUFFICIENT_BALANCE = "insufficient_balance";
|
||||||
|
|
||||||
|
/** 供应商欠费提示文案(前后端都按 code 识别展示)。 */
|
||||||
|
public static final String INSUFFICIENT_BALANCE_MESSAGE = "代理服务商余额不足,请充值后重试";
|
||||||
|
|
||||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||||
private static final int READ_TIMEOUT_MILLIS = 15_000;
|
private static final int READ_TIMEOUT_MILLIS = 15_000;
|
||||||
@@ -66,7 +70,13 @@ public class UserApiSecretCheckService {
|
|||||||
if (module == UserSecretModule.PROXY) {
|
if (module == UserSecretModule.PROXY) {
|
||||||
return probeProxy(plainApiKey);
|
return probeProxy(plainApiKey);
|
||||||
}
|
}
|
||||||
String proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
|
String proxyUrl;
|
||||||
|
try {
|
||||||
|
proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
|
||||||
|
} catch (JikipProxyClient.InsufficientBalanceException ex) {
|
||||||
|
log.warn("[user-secret][check] 代理提取接口欠费 module={},按余额不足处理", module.key());
|
||||||
|
return new CheckOutcome(STATUS_FAILED, CODE_INSUFFICIENT_BALANCE, INSUFFICIENT_BALANCE_MESSAGE, null, false);
|
||||||
|
}
|
||||||
if (proxyUrl != null) {
|
if (proxyUrl != null) {
|
||||||
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
||||||
if (CODE_NETWORK_ERROR.equals(viaProxy.code())) {
|
if (CODE_NETWORK_ERROR.equals(viaProxy.code())) {
|
||||||
@@ -155,6 +165,12 @@ public class UserApiSecretCheckService {
|
|||||||
/** 按 HTTP 状态码与响应体分类检测结果(package-private 供单测覆盖分类矩阵)。 */
|
/** 按 HTTP 状态码与响应体分类检测结果(package-private 供单测覆盖分类矩阵)。 */
|
||||||
CheckOutcome classify(int statusCode, String body, int latencyMs, boolean viaProxy) {
|
CheckOutcome classify(int statusCode, String body, int latencyMs, boolean viaProxy) {
|
||||||
String responseBody = body == null ? "" : body;
|
String responseBody = body == null ? "" : body;
|
||||||
|
// 上游网关欠费(任意 HTTP 状态):形如 {"code":"insufficient_user_quota","message":"预扣费额度失败,用户剩余额度:¥0.42,需要预扣费额度:¥0.51"}
|
||||||
|
String quotaMessage = extractInsufficientQuotaMessage(statusCode, responseBody);
|
||||||
|
if (quotaMessage != null) {
|
||||||
|
return new CheckOutcome(STATUS_FAILED, CODE_INSUFFICIENT_BALANCE,
|
||||||
|
"预扣费额度失败:" + quotaMessage, latencyMs, viaProxy);
|
||||||
|
}
|
||||||
if (statusCode >= 200 && statusCode < 300) {
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
JsonNode root = parseJson(body);
|
JsonNode root = parseJson(body);
|
||||||
if (root != null) {
|
if (root != null) {
|
||||||
@@ -187,6 +203,36 @@ public class UserApiSecretCheckService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别上游欠费报文并提取 message(含剩余/需要的额度明细)。
|
||||||
|
* 匹配任一条件:code=insufficient_user_quota;message 同时含"预扣费"与"额度"。
|
||||||
|
*/
|
||||||
|
private String extractInsufficientQuotaMessage(int statusCode, String body) {
|
||||||
|
String normalized = body == null ? "" : body.trim();
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (bodyContainsQuotaSignal(normalized)) {
|
||||||
|
JsonNode root = parseJson(normalized);
|
||||||
|
if (root != null) {
|
||||||
|
String message = text(root.get("message"));
|
||||||
|
if (message != null && !message.isBlank()) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return abbreviate(normalized, 200);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean bodyContainsQuotaSignal(String body) {
|
||||||
|
// 关键字直查(code 与 message 字段都可能变名,双信号兜底)。
|
||||||
|
if (body.contains("insufficient_user_quota")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return body.contains("预扣费") && body.contains("额度");
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> buildCheckBody(String model) {
|
private Map<String, Object> buildCheckBody(String model) {
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
body.put("model", model);
|
body.put("model", model);
|
||||||
|
|||||||
+39
-22
@@ -3,10 +3,10 @@ package com.nanri.aiimage.modules.usersecret.service;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||||
|
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
||||||
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
|
||||||
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||||
@@ -68,6 +68,7 @@ public class UserApiSecretService {
|
|||||||
private final UserApiSecretCheckService checkService;
|
private final UserApiSecretCheckService checkService;
|
||||||
private final JikipProxyClient jikipProxyClient;
|
private final JikipProxyClient jikipProxyClient;
|
||||||
private final AdminUserMapper adminUserMapper;
|
private final AdminUserMapper adminUserMapper;
|
||||||
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
|
|
||||||
/** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */
|
/** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */
|
||||||
public UserApiSecretBundleVo bundle(Long userId) {
|
public UserApiSecretBundleVo bundle(Long userId) {
|
||||||
@@ -199,20 +200,45 @@ public class UserApiSecretService {
|
|||||||
* 先按关键字圈定用户,再全量聚合、行级状态筛选、按最近更新时间倒序后内存分页
|
* 先按关键字圈定用户,再全量聚合、行级状态筛选、按最近更新时间倒序后内存分页
|
||||||
* (当前规模为用户数×3,内存聚合成本可控;量级上来后可改为 GROUP BY 下推)。
|
* (当前规模为用户数×3,内存聚合成本可控;量级上来后可改为 GROUP BY 下推)。
|
||||||
*/
|
*/
|
||||||
public AdminUserSecretPageVo adminPage(AdminUserSecretQuery query) {
|
/** 后台分页查询:主管(admin)只能看自己名下子账户(created_by_id=自己),超管看全量可按创建人筛选。 */
|
||||||
|
public AdminUserSecretPageVo adminPage(AdminUserEntity operator, AdminUserSecretQuery query) {
|
||||||
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : query;
|
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : query;
|
||||||
long page = safeQuery.getPage() == null || safeQuery.getPage() < 1 ? 1L : safeQuery.getPage();
|
long page = safeQuery.getPage() == null || safeQuery.getPage() < 1 ? 1L : safeQuery.getPage();
|
||||||
long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1
|
long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1
|
||||||
? 15L : Math.min(safeQuery.getPageSize(), 100L);
|
? 15L : Math.min(safeQuery.getPageSize(), 100L);
|
||||||
|
|
||||||
|
// 组隔离:主管强制锁定本组;超管可按 created_by_id 筛选(0 或空 = 全部)。
|
||||||
|
boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator));
|
||||||
|
Long scopedCreatedById;
|
||||||
|
if (superAdmin) {
|
||||||
|
Long filter = safeQuery.getCreatedById();
|
||||||
|
scopedCreatedById = filter != null && filter > 0 ? filter : null;
|
||||||
|
} else {
|
||||||
|
scopedCreatedById = operator.getId();
|
||||||
|
}
|
||||||
|
|
||||||
LambdaQueryWrapper<UserApiSecretEntity> wrapper = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<UserApiSecretEntity> wrapper = new LambdaQueryWrapper<>();
|
||||||
String keyword = normalize(safeQuery.getKeyword());
|
String keyword = normalize(safeQuery.getKeyword());
|
||||||
|
List<Long> allowedUserIds = null;
|
||||||
|
// 用户存在性/归属先按 users 表圈定:keyword 匹配 + 组隔离前置过滤(无密钥记录的用户本来就不在聚合表里)。
|
||||||
|
LambdaQueryWrapper<AdminUserEntity> userWrapper = new LambdaQueryWrapper<>();
|
||||||
if (!keyword.isEmpty()) {
|
if (!keyword.isEmpty()) {
|
||||||
List<Long> userIds = resolveUserIdsByKeyword(keyword);
|
userWrapper.like(AdminUserEntity::getUsername, keyword);
|
||||||
if (userIds.isEmpty()) {
|
}
|
||||||
|
if (scopedCreatedById != null) {
|
||||||
|
userWrapper.eq(AdminUserEntity::getCreatedById, scopedCreatedById);
|
||||||
|
}
|
||||||
|
if (!keyword.isEmpty() || scopedCreatedById != null) {
|
||||||
|
allowedUserIds = adminUserMapper.selectList(userWrapper).stream()
|
||||||
|
.map(AdminUserEntity::getId)
|
||||||
|
.filter(id -> id != null)
|
||||||
|
.toList();
|
||||||
|
if (allowedUserIds.isEmpty()) {
|
||||||
|
log.info("[user-secret] 后台密钥列表无匹配用户 keyword={} createdById={} operatorId={} role={}",
|
||||||
|
keyword, scopedCreatedById, operator.getId(), superAdmin ? "super_admin" : "admin");
|
||||||
return emptyPage(page, pageSize);
|
return emptyPage(page, pageSize);
|
||||||
}
|
}
|
||||||
wrapper.in(UserApiSecretEntity::getUserId, userIds);
|
wrapper.in(UserApiSecretEntity::getUserId, allowedUserIds);
|
||||||
}
|
}
|
||||||
List<UserApiSecretEntity> rows = userApiSecretMapper.selectList(wrapper);
|
List<UserApiSecretEntity> rows = userApiSecretMapper.selectList(wrapper);
|
||||||
|
|
||||||
@@ -244,8 +270,8 @@ public class UserApiSecretService {
|
|||||||
vo.setTotal(total);
|
vo.setTotal(total);
|
||||||
vo.setPage(page);
|
vo.setPage(page);
|
||||||
vo.setPageSize(pageSize);
|
vo.setPageSize(pageSize);
|
||||||
log.info("[user-secret] 后台密钥列表 keyword={} statusFilter={} 聚合用户数={} 本页返回={}",
|
log.info("[user-secret] 后台密钥列表 keyword={} statusFilter={} createdById={} role={} 聚合用户数={} 本页返回={}",
|
||||||
keyword, statusFilter, total, vo.getItems().size());
|
keyword, statusFilter, scopedCreatedById, superAdmin ? "super_admin" : "admin", total, vo.getItems().size());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +412,11 @@ public class UserApiSecretService {
|
|||||||
vo.setUpdatedAt(latestUpdatedAt(modules));
|
vo.setUpdatedAt(latestUpdatedAt(modules));
|
||||||
AdminUserEntity user = adminUserMapper.selectById(userId);
|
AdminUserEntity user = adminUserMapper.selectById(userId);
|
||||||
vo.setUsername(user == null ? "" : user.getUsername());
|
vo.setUsername(user == null ? "" : user.getUsername());
|
||||||
|
if (user != null && user.getCreatedById() != null) {
|
||||||
|
vo.setCreatedById(user.getCreatedById());
|
||||||
|
AdminUserEntity creator = adminUserMapper.selectById(user.getCreatedById());
|
||||||
|
vo.setCreatedByUsername(creator == null ? "" : creator.getUsername());
|
||||||
|
}
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,20 +527,6 @@ public class UserApiSecretService {
|
|||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关键字圈定用户:仅按用户名模糊匹配(页面不提供 UID 搜索)。 */
|
|
||||||
private List<Long> resolveUserIdsByKeyword(String keyword) {
|
|
||||||
Set<Long> userIds = new LinkedHashSet<>();
|
|
||||||
List<AdminUserEntity> matched = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
|
||||||
.like(AdminUserEntity::getUsername, keyword)
|
|
||||||
.last("limit 200"));
|
|
||||||
for (AdminUserEntity user : matched) {
|
|
||||||
if (user.getId() != null) {
|
|
||||||
userIds.add(user.getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new ArrayList<>(userIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private AdminUserSecretModuleVo toModuleVo(UserSecretModule module, UserApiSecretEntity row) {
|
private AdminUserSecretModuleVo toModuleVo(UserSecretModule module, UserApiSecretEntity row) {
|
||||||
AdminUserSecretModuleVo vo = new AdminUserSecretModuleVo();
|
AdminUserSecretModuleVo vo = new AdminUserSecretModuleVo();
|
||||||
vo.setModuleKey(module.key());
|
vo.setModuleKey(module.key());
|
||||||
|
|||||||
+49
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/** 检测结果分类矩阵:passed=放行,failed=拦截,error=无法判定(放行但警示)。 */
|
/** 检测结果分类矩阵:passed=放行,failed=拦截,error=无法判定(放行但警示)。 */
|
||||||
class UserApiSecretCheckServiceTest {
|
class UserApiSecretCheckServiceTest {
|
||||||
@@ -47,6 +48,54 @@ class UserApiSecretCheckServiceTest {
|
|||||||
assertThat(outcome.message()).contains("quota exceeded");
|
assertThat(outcome.message()).contains("quota exceeded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void classifyInsufficientUserQuotaIsBalanceShortage() {
|
||||||
|
String body = "{\"code\":\"insufficient_user_quota\",\"message\":\"预扣费额度失败,用户剩余额度:¥0.420000,需要预扣费额度:¥0.510000\"}";
|
||||||
|
UserApiSecretCheckService.CheckOutcome outcome = service.classify(200, body, 100, false);
|
||||||
|
|
||||||
|
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||||
|
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE);
|
||||||
|
assertThat(outcome.message()).contains("剩余额度:¥0.420000");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void classifyInsufficientUserQuotaOn400AlsoDetected() {
|
||||||
|
String body = "{\"code\":\"insufficient_user_quota\",\"message\":\"预扣费额度失败,用户剩余额度:¥0.42\"}";
|
||||||
|
UserApiSecretCheckService.CheckOutcome outcome = service.classify(400, body, 100, true);
|
||||||
|
|
||||||
|
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||||
|
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE);
|
||||||
|
assertThat(outcome.viaProxy()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void probeFailsWithInsufficientBalanceWhenProviderOverdrawn() {
|
||||||
|
JikipProxyClient jikip = mock(JikipProxyClient.class);
|
||||||
|
UserApiSecretCheckService probeService = new UserApiSecretCheckService(
|
||||||
|
new AppearancePatentProperties(), new SimilarAsinProperties(), jikip, new ObjectMapper());
|
||||||
|
when(jikip.isExtractConfigured()).thenReturn(true);
|
||||||
|
when(jikip.fetchProxyUrl()).thenThrow(new JikipProxyClient.InsufficientBalanceException());
|
||||||
|
|
||||||
|
UserApiSecretCheckService.CheckOutcome outcome = probeService.probe(
|
||||||
|
com.nanri.aiimage.modules.usersecret.support.UserSecretModule.SIMILAR_ASIN, "sk-test");
|
||||||
|
|
||||||
|
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||||
|
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE);
|
||||||
|
assertThat(outcome.message()).contains("余额不足");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jikipBalanceDetectionRecognizesOverdrawnPayload() throws Exception {
|
||||||
|
com.nanri.aiimage.config.UserSecretProperties props = new com.nanri.aiimage.config.UserSecretProperties();
|
||||||
|
JikipProxyClient client = new JikipProxyClient(props, new ObjectMapper());
|
||||||
|
java.lang.reflect.Method method = JikipProxyClient.class.getDeclaredMethod("isInsufficientBalance", String.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
|
||||||
|
assertThat((Boolean) method.invoke(client, "{\"code\":-1,\"data\":null,\"status\":200,\"message\":\"余额不足\"}")).isTrue();
|
||||||
|
assertThat((Boolean) method.invoke(client, "{\"data\":{\"ip\":\"1.2.3.4\",\"port\":8080}}")).isFalse();
|
||||||
|
assertThat((Boolean) method.invoke(client, "")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void classifyInvalidKeyOn401() {
|
void classifyInvalidKeyOn401() {
|
||||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(401, "unauthorized", 45, true);
|
UserApiSecretCheckService.CheckOutcome outcome = service.classify(401, "unauthorized", 45, true);
|
||||||
|
|||||||
+33
-2
@@ -1,6 +1,7 @@
|
|||||||
package com.nanri.aiimage.modules.usersecret.service;
|
package com.nanri.aiimage.modules.usersecret.service;
|
||||||
|
|
||||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||||
|
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||||
@@ -30,6 +31,7 @@ class UserApiSecretServiceTest {
|
|||||||
private final UserApiSecretCheckService checkService = mock(UserApiSecretCheckService.class);
|
private final UserApiSecretCheckService checkService = mock(UserApiSecretCheckService.class);
|
||||||
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
|
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
|
||||||
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
|
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
|
||||||
|
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
|
||||||
|
|
||||||
private UserApiSecretService newService() {
|
private UserApiSecretService newService() {
|
||||||
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
|
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
|
||||||
@@ -37,7 +39,9 @@ class UserApiSecretServiceTest {
|
|||||||
String value = inv.getArgument(0, String.class);
|
String value = inv.getArgument(0, String.class);
|
||||||
return value.startsWith("enc:") ? value.substring(4) : value;
|
return value.startsWith("enc:") ? value.substring(4) : value;
|
||||||
});
|
});
|
||||||
return new UserApiSecretService(mapper, crypto, checkService, jikipProxyClient, adminUserMapper);
|
// 默认按主管(admin)判定;超管用例里单独改打桩。
|
||||||
|
when(adminAuthSupport.currentRole(any())).thenReturn("admin");
|
||||||
|
return new UserApiSecretService(mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -206,6 +210,8 @@ class UserApiSecretServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void adminPageAggregatesOneRowPerUserWithProxyMasked() {
|
void adminPageAggregatesOneRowPerUserWithProxyMasked() {
|
||||||
UserApiSecretService service = newService();
|
UserApiSecretService service = newService();
|
||||||
|
// 非限定查询:无 keyword 无组过滤时不触发 users 表圈定查询。
|
||||||
|
when(adminUserMapper.selectList(any())).thenReturn(List.of());
|
||||||
when(mapper.selectList(any())).thenReturn(List.of(
|
when(mapper.selectList(any())).thenReturn(List.of(
|
||||||
row(1L, "similar-asin", "enc:sk-sa-1234", "passed"),
|
row(1L, "similar-asin", "enc:sk-sa-1234", "passed"),
|
||||||
row(1L, "appearance-patent", "enc:sk-ap-5678", "passed"),
|
row(1L, "appearance-patent", "enc:sk-ap-5678", "passed"),
|
||||||
@@ -214,18 +220,43 @@ class UserApiSecretServiceTest {
|
|||||||
user.setId(1L);
|
user.setId(1L);
|
||||||
user.setUsername("张三");
|
user.setUsername("张三");
|
||||||
when(adminUserMapper.selectById(1L)).thenReturn(user);
|
when(adminUserMapper.selectById(1L)).thenReturn(user);
|
||||||
|
AdminUserEntity creator = new AdminUserEntity();
|
||||||
|
creator.setId(99L);
|
||||||
|
creator.setUsername("主管甲");
|
||||||
|
when(adminUserMapper.selectById(99L)).thenReturn(creator);
|
||||||
|
user.setCreatedById(99L);
|
||||||
|
|
||||||
var page = service.adminPage(new AdminUserSecretQuery());
|
AdminUserEntity operator = new AdminUserEntity();
|
||||||
|
operator.setId(88L);
|
||||||
|
when(adminAuthSupport.currentRole(operator)).thenReturn("super_admin");
|
||||||
|
var page = service.adminPage(operator, new AdminUserSecretQuery());
|
||||||
|
|
||||||
assertThat(page.getItems()).hasSize(1);
|
assertThat(page.getItems()).hasSize(1);
|
||||||
var rowVo = page.getItems().get(0);
|
var rowVo = page.getItems().get(0);
|
||||||
assertThat(rowVo.getUsername()).isEqualTo("张三");
|
assertThat(rowVo.getUsername()).isEqualTo("张三");
|
||||||
|
assertThat(rowVo.getCreatedById()).isEqualTo(99L);
|
||||||
|
assertThat(rowVo.getCreatedByUsername()).isEqualTo("主管甲");
|
||||||
assertThat(rowVo.getStatus()).isEqualTo("failed");
|
assertThat(rowVo.getStatus()).isEqualTo("failed");
|
||||||
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
|
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
|
||||||
assertThat(rowVo.getProxy().getExists()).isTrue();
|
assertThat(rowVo.getProxy().getExists()).isTrue();
|
||||||
assertThat(rowVo.getProxy().getMasked()).isEqualTo("http://***@1.2.3.4:8080");
|
assertThat(rowVo.getProxy().getMasked()).isEqualTo("http://***@1.2.3.4:8080");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminPageLocksAdminToOwnGroupUsers() {
|
||||||
|
UserApiSecretService service = newService();
|
||||||
|
// 主管无论传什么 createdById,都强制锁定为本组(created_by_id=88L)。
|
||||||
|
AdminUserEntity operator = new AdminUserEntity();
|
||||||
|
operator.setId(88L);
|
||||||
|
when(adminUserMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.adminPage(operator, new AdminUserSecretQuery());
|
||||||
|
|
||||||
|
// 主管模式:先圈定 users 表(组过滤),密钥表因无匹配行不再查询。
|
||||||
|
verify(adminUserMapper).selectList(any());
|
||||||
|
verify(mapper, never()).selectList(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void adminClearByUserDeletesAllRowsOfUser() {
|
void adminClearByUserDeletesAllRowsOfUser() {
|
||||||
UserApiSecretService service = newService();
|
UserApiSecretService service = newService();
|
||||||
|
|||||||
@@ -202,7 +202,9 @@ async function runProxyCheck() {
|
|||||||
proxyCheckResult.value = await checkApiSecret('proxy', dirty ? inputValue : undefined)
|
proxyCheckResult.value = await checkApiSecret('proxy', dirty ? inputValue : undefined)
|
||||||
const result = proxyCheckResult.value
|
const result = proxyCheckResult.value
|
||||||
if (result.checkStatus === 'failed') {
|
if (result.checkStatus === 'failed') {
|
||||||
ElMessage.warning(result.checkMessage || '代理不可用')
|
ElMessage.warning(
|
||||||
|
result.checkCode === 'insufficient_balance' ? '代理服务商余额不足,请充值或联系管理员' : (result.checkMessage || '代理不可用'),
|
||||||
|
)
|
||||||
} else if (result.checkStatus === 'error') {
|
} else if (result.checkStatus === 'error') {
|
||||||
ElMessage.warning(result.checkMessage || '暂时无法判定代理可用性')
|
ElMessage.warning(result.checkMessage || '暂时无法判定代理可用性')
|
||||||
} else {
|
} else {
|
||||||
@@ -291,6 +293,7 @@ function statusTextOf(moduleKey: string) {
|
|||||||
const latency = state.result.checkLatencyMs != null ? `(${state.result.checkLatencyMs}ms)` : ''
|
const latency = state.result.checkLatencyMs != null ? `(${state.result.checkLatencyMs}ms)` : ''
|
||||||
const suffix = state.result.viaProxy === true ? ' · 经代理' : ''
|
const suffix = state.result.viaProxy === true ? ' · 经代理' : ''
|
||||||
if (state.result.checkStatus === 'passed') return `检测通过${latency}${suffix}`
|
if (state.result.checkStatus === 'passed') return `检测通过${latency}${suffix}`
|
||||||
|
if (state.result.checkCode === 'insufficient_balance') return `欠费:${state.result.checkMessage || '代理服务商余额不足'}${suffix}`
|
||||||
return `${state.result.checkMessage || '检测失败'}${suffix}`
|
return `${state.result.checkMessage || '检测失败'}${suffix}`
|
||||||
}
|
}
|
||||||
if (state.error) return state.error
|
if (state.error) return state.error
|
||||||
|
|||||||
Reference in New Issue
Block a user