refactor: 彻底移除 apiSecret 认证方式,仅保留 Bearer + PSK
删除: - HmacUtil: deriveApiKey/deriveApiSecret/verifyMediabotPsk - HmacAuthInterceptor: apiSecret 验签/自动创建账户/readBody - AccountService: generateApiCredentials/registerByInterceptor - AccountDTO: apiKey 字段 - AuthController: register 返回 apiSecret - AdminController: generateApiKey 端点 - Users.vue: API Key 列/生成密钥弹窗 - api/index.js: genApiKey 认证方式简化为两种: Bearer Token → Web 管理后台 PSK 签名 → 外部集成客户端
This commit is contained in:
@@ -159,29 +159,6 @@ public class AdminController {
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成/重置 API Key
|
||||
*/
|
||||
@PostMapping("/accounts/{accountId}/api-key")
|
||||
public ApiResponse<Map<String, String>> generateApiKey(
|
||||
@PathVariable Long accountId,
|
||||
HttpServletRequest request) {
|
||||
checkAdmin(request);
|
||||
|
||||
var account = accountMapper.selectById(accountId);
|
||||
if (account == null) {
|
||||
return ApiResponse.error(404, "Account not found");
|
||||
}
|
||||
|
||||
String[] keys = accountService.generateApiCredentials(accountId);
|
||||
Map<String, String> result = new java.util.LinkedHashMap<>();
|
||||
result.put("apiKey", keys[0]);
|
||||
result.put("apiSecret", keys[1]);
|
||||
// secret 只在生成时返回一次,前端需要立即保存
|
||||
log.info("API key generated for account: id={}", accountId);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定额外邮箱
|
||||
*/
|
||||
|
||||
@@ -36,8 +36,8 @@ public class AuthController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册(自动生成 API Key)
|
||||
* 携带 PSK 签名头 → 自动提权为 TRUSTED
|
||||
* 用户注册(密码可选)
|
||||
* 携带 PSK 头自动提权为 TRUSTED
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<Map<String, Object>> register(
|
||||
@@ -45,24 +45,18 @@ public class AuthController {
|
||||
HttpServletRequest httpRequest) {
|
||||
Account account = accountService.register(request);
|
||||
|
||||
// PSK 签名验证通过 → 自动提权
|
||||
if (verifyMediabotPsk(httpRequest, request.getEmail())) {
|
||||
account.setTrustLevel(com.par.core.enums.TrustLevel.TRUSTED);
|
||||
accountService.updateAccount(account);
|
||||
log.info("Account auto-trusted via PSK: email={}", account.getEmail());
|
||||
}
|
||||
|
||||
AccountDTO dto = accountService.toDTO(account);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("account", dto);
|
||||
result.put("apiKey", account.getApiKey());
|
||||
result.put("apiSecret", account.getApiSecret());
|
||||
result.put("account", accountService.toDTO(account));
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户恢复(DB 重置后无需管理员介入)
|
||||
* PSK 签名验证通过 → 返回/重建凭据
|
||||
* PSK 签名恢复账户
|
||||
*/
|
||||
@PostMapping("/recover")
|
||||
public ApiResponse<Map<String, Object>> recover(
|
||||
@@ -79,44 +73,34 @@ public class AuthController {
|
||||
return ApiResponse.error(403, "Invalid PSK signature");
|
||||
}
|
||||
|
||||
// 查找或创建账户
|
||||
Account account = accountService.findByAnyEmail(email);
|
||||
if (account == null) {
|
||||
// 自动创建 + 提权
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setEmail(email);
|
||||
account = accountService.register(req);
|
||||
}
|
||||
// 确保是 TRUSTED
|
||||
if (account.getTrustLevel().ordinal() < com.par.core.enums.TrustLevel.TRUSTED.ordinal()) {
|
||||
account.setTrustLevel(com.par.core.enums.TrustLevel.TRUSTED);
|
||||
accountService.updateAccount(account);
|
||||
}
|
||||
|
||||
AccountDTO dto = accountService.toDTO(account);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("account", dto);
|
||||
result.put("apiKey", account.getApiKey());
|
||||
result.put("apiSecret", account.getApiSecret());
|
||||
result.put("account", accountService.toDTO(account));
|
||||
log.info("Account recovered via PSK: email={}", email);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录,返回 Bearer token + api_key
|
||||
* 用户登录,返回 Bearer token
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<AccountDTO> login(@Valid @RequestBody LoginRequest request) {
|
||||
Account account = accountService.login(request);
|
||||
AccountDTO dto = accountService.toDTO(account);
|
||||
dto.setToken(hmacAuthInterceptor.generateToken(account.getEmail()));
|
||||
dto.setApiKey(account.getApiKey());
|
||||
return ApiResponse.success(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 X-Medibot-Timestamp + X-Medibot-Signature(通过 PskService)
|
||||
*/
|
||||
private boolean verifyMediabotPsk(HttpServletRequest request, String email) {
|
||||
String ts = request.getHeader("X-Medibot-Timestamp");
|
||||
String sig = request.getHeader("X-Medibot-Signature");
|
||||
|
||||
@@ -80,27 +80,6 @@ public class HmacUtil {
|
||||
return sign(hmacSecret, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 medibot PSK 签名(用于账户恢复 / 自动提权)
|
||||
* signContent = email + ":" + timestamp
|
||||
*/
|
||||
public static boolean verifyMediabotPsk(String email, long timestamp, String signature, String psk) {
|
||||
if (psk == null || psk.isBlank()) return false;
|
||||
long now = System.currentTimeMillis() / 1000;
|
||||
if (Math.abs(now - timestamp) > 300) return false; // 5 分钟窗口
|
||||
String payload = email.toLowerCase().trim() + ":" + timestamp;
|
||||
return verify(psk, payload, signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 email + 服务端密钥确定性推导 api_key
|
||||
*/
|
||||
public static String deriveApiKey(String email, String hmacSecret) {
|
||||
String input = email.toLowerCase().trim() + ":par-api-key";
|
||||
String hash = sign(hmacSecret, input);
|
||||
return "par_" + sha256(hash).substring(0, 16);
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 账户信息 DTO(响应用,不包含敏感字段)
|
||||
* 账户信息 DTO
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@@ -22,7 +22,4 @@ public class AccountDTO {
|
||||
|
||||
/** 登录 token(仅登录接口返回) */
|
||||
private String token;
|
||||
|
||||
/** API Key(管理员可见,用于外部集成) */
|
||||
private String apiKey;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.par.core.service.AccountService;
|
||||
import com.par.core.service.PskService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -18,14 +17,11 @@ import java.util.Base64;
|
||||
|
||||
/**
|
||||
* API 鉴权拦截器
|
||||
* <p>
|
||||
* 支持两种认证方式(按优先级):
|
||||
* 1. Bearer Token — Web 管理后台使用,登录后由服务端签发
|
||||
* 2. HMAC 请求签名 — 外部 API 客户端使用,以 X-Signature/X-Timestamp/X-Email 头携带
|
||||
* 1. Bearer Token — Web 管理后台
|
||||
* 2. PSK 签名 — 外部集成客户端
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HmacAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final AccountService accountService;
|
||||
@@ -37,20 +33,20 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
|
||||
private static final String HEADER_SIGNATURE = "X-Signature";
|
||||
private static final String HEADER_TIMESTAMP = "X-Timestamp";
|
||||
private static final String HEADER_EMAIL = "X-Email";
|
||||
private static final String HEADER_AUTHORIZATION = "Authorization";
|
||||
|
||||
/** Token 有效期(小时) */
|
||||
private static final long TOKEN_EXPIRY_HOURS = 24;
|
||||
/** HMAC 时间窗口(秒) */
|
||||
private static final long HMAC_WINDOW_SECONDS = 60;
|
||||
|
||||
public HmacAuthInterceptor(AccountService accountService, PskService pskService) {
|
||||
this.accountService = accountService;
|
||||
this.pskService = pskService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
// ======== 方式 1:Bearer Token 认证(Web 管理后台) ========
|
||||
String authHeader = request.getHeader(HEADER_AUTHORIZATION);
|
||||
// 方式 1:Bearer Token
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
String email = verifyBearerToken(token);
|
||||
String email = verifyBearerToken(authHeader.substring(7));
|
||||
if (email != null) {
|
||||
Account account = accountService.findByEmail(email);
|
||||
if (account != null && Boolean.TRUE.equals(account.getIsActive())) {
|
||||
@@ -63,18 +59,15 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ======== 方式 2:PSK 签名认证(mediabot 等集成客户端) ========
|
||||
String pskSig = request.getHeader(HEADER_SIGNATURE);
|
||||
// 方式 2:PSK 签名
|
||||
String sig = request.getHeader(HEADER_SIGNATURE);
|
||||
String ts = request.getHeader(HEADER_TIMESTAMP);
|
||||
String email = request.getHeader(HEADER_EMAIL);
|
||||
if (pskSig != null && ts != null && email != null) {
|
||||
if (sig != null && ts != null && email != null) {
|
||||
try {
|
||||
long timestamp = Long.parseLong(ts);
|
||||
String pskVersion = pskService.verify(email, timestamp, pskSig);
|
||||
if (pskVersion != null) {
|
||||
if (pskService.verify(email, Long.parseLong(ts), sig) != null) {
|
||||
request.setAttribute("accountId", 0L);
|
||||
request.setAttribute("trustLevel", com.par.core.enums.TrustLevel.TRUSTED);
|
||||
request.setAttribute("pskVersion", pskVersion);
|
||||
return true;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {}
|
||||
@@ -82,72 +75,33 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ======== 方式 3:无有效认证头 → 拒绝 ========
|
||||
writeError(response, 401, "Missing or invalid authentication");
|
||||
return false;
|
||||
}
|
||||
|
||||
private String readBody(HttpServletRequest request) throws IOException {
|
||||
if (request.getContentLength() <= 0) {
|
||||
return "";
|
||||
}
|
||||
try (var reader = request.getReader()) {
|
||||
return reader.lines().collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, int status, String message) throws IOException {
|
||||
response.setStatus(status);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(String.format("{\"code\":%d,\"message\":\"%s\",\"data\":null}", status, message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成登录 token(供 AuthController 调用)
|
||||
* 格式: Base64(email:expiryTimestamp:HmacSHA256(email:expiry, hmacSecret))
|
||||
*/
|
||||
public String generateToken(String email) {
|
||||
long expiry = System.currentTimeMillis() / 1000 + TOKEN_EXPIRY_HOURS * 3600;
|
||||
String payload = email + ":" + expiry;
|
||||
String sig = HmacUtil.sign(hmacSecret, payload);
|
||||
String token = payload + ":" + sig;
|
||||
return Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString((payload + ":" + sig).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动创建账户(DB 重置后首次 HMAC 请求触发恢复)
|
||||
*/
|
||||
private Account autoCreateAccount(String email) {
|
||||
var account = new Account();
|
||||
account.setEmail(email.toLowerCase().trim());
|
||||
account.setApiKey(HmacUtil.deriveApiKey(email, hmacSecret));
|
||||
account.setApiSecret(HmacUtil.deriveApiSecret(email, hmacSecret));
|
||||
account.setTrustLevel(com.par.core.enums.TrustLevel.MEMBER);
|
||||
account.setIsActive(true);
|
||||
accountService.registerByInterceptor(account);
|
||||
log.info("Auto-created account via HMAC: email={}", email);
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Bearer token,返回 email(失败返回 null)
|
||||
*/
|
||||
private String verifyBearerToken(String token) {
|
||||
try {
|
||||
String decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
|
||||
String[] parts = decoded.split(":", 3);
|
||||
if (parts.length != 3) return null;
|
||||
|
||||
String email = parts[0];
|
||||
long expiry = Long.parseLong(parts[1]);
|
||||
String sig = parts[2];
|
||||
|
||||
if (System.currentTimeMillis() / 1000 > expiry) return null;
|
||||
|
||||
String payload = email + ":" + expiry;
|
||||
if (HmacUtil.verify(hmacSecret, payload, sig)) {
|
||||
return email;
|
||||
}
|
||||
if (HmacUtil.verify(hmacSecret, payload, parts[2])) return email;
|
||||
} catch (Exception e) {
|
||||
log.debug("Bearer token validation failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
@@ -3,79 +3,32 @@ package com.par.core.service;
|
||||
import com.par.core.dto.AccountDTO;
|
||||
import com.par.core.dto.LoginRequest;
|
||||
import com.par.core.dto.RegisterRequest;
|
||||
import java.util.List;
|
||||
|
||||
import com.par.core.entity.Account;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账户服务接口
|
||||
*/
|
||||
public interface AccountService {
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param request 注册请求
|
||||
* @return 注册成功的账户
|
||||
*/
|
||||
Account register(RegisterRequest request);
|
||||
|
||||
/**
|
||||
* 用户登录(验证密码)
|
||||
*
|
||||
* @param request 登录请求
|
||||
* @return 登录成功的账户
|
||||
*/
|
||||
Account login(LoginRequest request);
|
||||
|
||||
/**
|
||||
* 根据邮箱查找账户
|
||||
*/
|
||||
Account findByEmail(String email);
|
||||
|
||||
/**
|
||||
* 根据ID查找账户
|
||||
*/
|
||||
Account findById(Long id);
|
||||
|
||||
/**
|
||||
* 拦截器直接创建账户(HMAC 自动恢复场景)
|
||||
*/
|
||||
void registerByInterceptor(Account account);
|
||||
|
||||
/**
|
||||
* 更新账户
|
||||
*/
|
||||
void updateAccount(Account account);
|
||||
|
||||
/**
|
||||
* 转换为 DTO(脱敏)
|
||||
*/
|
||||
AccountDTO toDTO(Account account);
|
||||
|
||||
/**
|
||||
* 根据任意绑定邮箱查找账户
|
||||
*/
|
||||
Account findByAnyEmail(String email);
|
||||
|
||||
/**
|
||||
* 生成/重置 API Key 密钥对
|
||||
* @return [apiKey, apiSecret]
|
||||
*/
|
||||
String[] generateApiCredentials(Long accountId);
|
||||
void updateAccount(Account account);
|
||||
|
||||
AccountDTO toDTO(Account account);
|
||||
|
||||
/**
|
||||
* 绑定额外邮箱
|
||||
*/
|
||||
void bindEmail(Long accountId, String email);
|
||||
|
||||
/**
|
||||
* 解绑邮箱
|
||||
*/
|
||||
void unbindEmail(Long emailId);
|
||||
|
||||
/**
|
||||
* 获取账户的所有绑定邮箱
|
||||
*/
|
||||
List<String> listEmails(Long accountId);
|
||||
}
|
||||
|
||||
@@ -84,20 +84,29 @@ public class PskService {
|
||||
|
||||
/**
|
||||
* 使用所有活跃 PSK 逐一验证签名
|
||||
* signContent = email + ":" + timestamp
|
||||
* @return 匹配的 PSK 版本号,null 表示验证失败
|
||||
*/
|
||||
public String verify(String email, long timestamp, String signature) {
|
||||
for (Psk psk : pskMapper.selectActive()) {
|
||||
if (HmacUtil.verifyMediabotPsk(email, timestamp, signature, psk.getSecret())) {
|
||||
return psk.getVersion();
|
||||
}
|
||||
}
|
||||
// fallback: 也试 env PSK(DB 丢失时仍可用)
|
||||
if (envPsk != null && !envPsk.isBlank()) {
|
||||
if (HmacUtil.verifyMediabotPsk(email, timestamp, signature, envPsk)) {
|
||||
return "env";
|
||||
long now = System.currentTimeMillis() / 1000;
|
||||
if (Math.abs(now - timestamp) > 300) return null; // 5 分钟窗口
|
||||
String payload = email.toLowerCase().trim() + ":" + timestamp;
|
||||
|
||||
String[] secrets = getActiveSecrets();
|
||||
for (String secret : secrets) {
|
||||
if (HmacUtil.verify(secret, payload, signature)) {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String[] getActiveSecrets() {
|
||||
List<String> secrets = pskMapper.selectActive().stream()
|
||||
.map(Psk::getSecret).collect(java.util.stream.Collectors.toList());
|
||||
if (envPsk != null && !envPsk.isBlank()) {
|
||||
secrets.add(envPsk);
|
||||
}
|
||||
return secrets.toArray(new String[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.par.core.mapper.AnonymousStatMapper;
|
||||
import com.par.core.service.AccountService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -37,15 +36,11 @@ public class AccountServiceImpl implements AccountService {
|
||||
private final AnonymousStatMapper anonymousStatMapper;
|
||||
private final AccountEmailMapper accountEmailMapper;
|
||||
|
||||
@Value("${hmac.secret}")
|
||||
private String hmacSecret;
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Account register(RegisterRequest request) {
|
||||
// 检查邮箱是否已注册
|
||||
Account existing = accountMapper.selectByEmail(request.getEmail());
|
||||
if (existing != null) {
|
||||
throw new IllegalArgumentException("该邮箱已被注册");
|
||||
@@ -54,7 +49,6 @@ public class AccountServiceImpl implements AccountService {
|
||||
Account account = new Account();
|
||||
account.setEmail(request.getEmail().toLowerCase().trim());
|
||||
|
||||
// 密码为空则自动生成(机器注册场景)
|
||||
String password = request.getPassword();
|
||||
if (password == null || password.isBlank()) {
|
||||
byte[] pwdBytes = new byte[16];
|
||||
@@ -66,13 +60,8 @@ public class AccountServiceImpl implements AccountService {
|
||||
account.setTrustLevel(TrustLevel.MEMBER);
|
||||
account.setIsActive(true);
|
||||
|
||||
// 确定性推导 API Key(同 email + 同 HMAC_SECRET → 永远相同)
|
||||
account.setApiKey(HmacUtil.deriveApiKey(account.getEmail(), hmacSecret));
|
||||
account.setApiSecret(HmacUtil.deriveApiSecret(account.getEmail(), hmacSecret));
|
||||
|
||||
accountMapper.insert(account);
|
||||
|
||||
// 关联匿名统计记录
|
||||
if (request.getAnonymousId() != null && !request.getAnonymousId().isBlank()) {
|
||||
associateAnonymousStats(request.getAnonymousId(), account.getId(), request.getEmail());
|
||||
}
|
||||
@@ -107,14 +96,13 @@ public class AccountServiceImpl implements AccountService {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void registerByInterceptor(Account account) {
|
||||
// 自动生成随机密码
|
||||
byte[] pwdBytes = new byte[16];
|
||||
SECURE_RANDOM.nextBytes(pwdBytes);
|
||||
account.setPasswordHash(PasswordUtil.encode(
|
||||
Base64.getUrlEncoder().withoutPadding().encodeToString(pwdBytes)));
|
||||
accountMapper.insert(account);
|
||||
public Account findByAnyEmail(String email) {
|
||||
email = email.toLowerCase().trim();
|
||||
Account account = accountMapper.selectByEmail(email);
|
||||
if (account != null) return account;
|
||||
AccountEmail ae = accountEmailMapper.selectByEmail(email);
|
||||
if (ae != null) return accountMapper.selectById(ae.getAccountId());
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -132,43 +120,9 @@ public class AccountServiceImpl implements AccountService {
|
||||
dto.setTrustLevel(account.getTrustLevel());
|
||||
dto.setIsActive(account.getIsActive());
|
||||
dto.setCreatedAt(account.getCreatedAt());
|
||||
dto.setApiKey(account.getApiKey());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Account findByAnyEmail(String email) {
|
||||
email = email.toLowerCase().trim();
|
||||
// 先查主邮箱
|
||||
Account account = accountMapper.selectByEmail(email);
|
||||
if (account != null) return account;
|
||||
// 再查关联邮箱
|
||||
AccountEmail ae = accountEmailMapper.selectByEmail(email);
|
||||
if (ae != null) return accountMapper.selectById(ae.getAccountId());
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public String[] generateApiCredentials(Long accountId) {
|
||||
Account account = accountMapper.selectById(accountId);
|
||||
if (account == null) throw new IllegalArgumentException("Account not found");
|
||||
|
||||
byte[] keyBytes = new byte[12];
|
||||
SECURE_RANDOM.nextBytes(keyBytes);
|
||||
String apiKey = "par_" + bytesToHex(keyBytes);
|
||||
|
||||
byte[] secretBytes = new byte[32];
|
||||
SECURE_RANDOM.nextBytes(secretBytes);
|
||||
String apiSecret = Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes);
|
||||
|
||||
account.setApiKey(apiKey);
|
||||
account.setApiSecret(apiSecret);
|
||||
accountMapper.updateById(account);
|
||||
|
||||
return new String[]{apiKey, apiSecret};
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void bindEmail(Long accountId, String email) {
|
||||
@@ -189,8 +143,6 @@ public class AccountServiceImpl implements AccountService {
|
||||
public void unbindEmail(Long emailId) {
|
||||
AccountEmail ae = accountEmailMapper.selectById(emailId);
|
||||
if (ae == null) throw new IllegalArgumentException("Email binding not found");
|
||||
|
||||
// 检查是否是唯一邮箱:主邮箱 + 至少保留一个关联邮箱
|
||||
Account account = accountMapper.selectById(ae.getAccountId());
|
||||
long emailCount = accountEmailMapper.selectByAccountId(ae.getAccountId()).size();
|
||||
if (account.getEmail().equals(ae.getEmail()) && emailCount <= 1) {
|
||||
@@ -205,24 +157,13 @@ public class AccountServiceImpl implements AccountService {
|
||||
if (account == null) return Collections.emptyList();
|
||||
List<String> emails = accountEmailMapper.selectByAccountId(accountId)
|
||||
.stream().map(AccountEmail::getEmail).collect(Collectors.toList());
|
||||
// 确保主邮箱在列表首位
|
||||
if (!emails.contains(account.getEmail())) {
|
||||
emails.add(0, account.getEmail());
|
||||
}
|
||||
return emails;
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册时关联匿名统计记录
|
||||
*/
|
||||
private void associateAnonymousStats(String anonymousId, Long accountId, String email) {
|
||||
// 先按 anonymous_id 查找
|
||||
AnonymousStat stat = anonymousStatMapper.selectByAnonymousId(anonymousId);
|
||||
if (stat != null && stat.getAssociatedAccountId() == null) {
|
||||
stat.setAssociatedAccountId(accountId);
|
||||
@@ -230,8 +171,6 @@ public class AccountServiceImpl implements AccountService {
|
||||
log.info("Associated anonymous stat {} with account {}", anonymousId, accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 再按邮箱哈希查找(可能匿名ID不匹配但邮箱相同)
|
||||
String emailHash = HmacUtil.sha256(email.toLowerCase().trim());
|
||||
AnonymousStat byHash = anonymousStatMapper.selectByRawEmailHash(emailHash);
|
||||
if (byHash != null && byHash.getAssociatedAccountId() == null) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export default {
|
||||
users: {
|
||||
list: () => api.get('/admin/accounts'),
|
||||
setTrustLevel: (id, level) => api.post(`/admin/accounts/${id}/trust-level`, null, { params: { level } }),
|
||||
genApiKey: (id) => api.post(`/admin/accounts/${id}/api-key`)
|
||||
|
||||
},
|
||||
health: () => api.get('/health', { baseURL: '' })
|
||||
}
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="displayName" label="昵称" width="120" />
|
||||
<el-table-column label="API Key" min-width="180">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.apiKey" style="font-family:monospace;font-size:12px">{{ scope.row.apiKey }}</span>
|
||||
<el-tag v-else size="small" type="info">未生成</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="trustLevel" label="信任等级" width="90">
|
||||
<template #default="scope">
|
||||
<el-tag :type="trustLevelType(scope.row.trustLevel)">
|
||||
@@ -27,45 +21,19 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="注册时间" width="160" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleGenKey(scope.row)">生成密钥</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.trustLevel !== 'ADMIN'"
|
||||
size="small"
|
||||
@click="handleSetLevel(scope.row)"
|
||||
>设置等级</el-button>
|
||||
<span v-else style="color:#999;font-size:12px">管理员</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- API Key 弹窗 -->
|
||||
<el-dialog v-model="keyDialogVisible" title="API Key" width="500px" :close-on-click-modal="false">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
||||
Secret 仅在生成时显示一次,请立即复制保存!
|
||||
</el-alert>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="keyResult.apiKey" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyText(keyResult.apiKey)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="API Secret">
|
||||
<el-input v-model="keyResult.apiSecret" readonly type="textarea" :rows="2">
|
||||
<template #append>
|
||||
<el-button @click="copyText(keyResult.apiSecret)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="keyDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 等级设置弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" title="设置信任等级" width="400px">
|
||||
<el-form :model="levelForm">
|
||||
@@ -95,9 +63,6 @@ const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const levelForm = reactive({ id: null, email: '', level: '' })
|
||||
const keyDialogVisible = ref(false)
|
||||
const keyResult = reactive({ apiKey: '', apiSecret: '' })
|
||||
|
||||
const trustLevelType = (level) => {
|
||||
const map = { MEMBER: '', TRUSTED: 'warning', ADMIN: 'danger' }
|
||||
return map[level] || ''
|
||||
@@ -120,22 +85,6 @@ const fetchUsers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenKey = async (row) => {
|
||||
try {
|
||||
const res = await api.users.genApiKey(row.id)
|
||||
keyResult.apiKey = res.data.apiKey
|
||||
keyResult.apiSecret = res.data.apiSecret
|
||||
keyDialogVisible.value = true
|
||||
fetchUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = (text) => {
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
const handleSetLevel = (row) => {
|
||||
levelForm.id = row.id
|
||||
levelForm.email = row.email
|
||||
|
||||
Reference in New Issue
Block a user