提交登录修改
This commit is contained in:
@@ -31,7 +31,7 @@ public class SecurityConfig {
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setExposedHeaders(List.of("Content-Disposition"));
|
||||
configuration.setAllowCredentials(false);
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.nanri.aiimage.modules.admin.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||
import com.nanri.aiimage.modules.admin.service.AdminUserService;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin")
|
||||
@Tag(name = "管理后台-用户", description = "用户列表、创建、更新、删除")
|
||||
public class AdminUserController {
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final AdminUserService adminUserService;
|
||||
|
||||
@GetMapping("/users")
|
||||
@Operation(summary = "分页查询用户列表")
|
||||
public ApiResponse<AdminUserListVo> listUsers(HttpServletRequest request,
|
||||
@RequestParam(required = false, defaultValue = "1") Integer page,
|
||||
@RequestParam(name = "page_size", required = false, defaultValue = "15") Integer pageSize,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(name = "search", required = false) String search,
|
||||
@RequestParam(name = "created_by_id", required = false) Long createdById,
|
||||
@RequestParam(name = "admin_id", required = false) Long adminId) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
String kw = (username == null || username.isBlank()) ? search : username;
|
||||
Long filterCreatedBy = createdById != null ? createdById : adminId;
|
||||
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy);
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
|
||||
@PostMapping("/user")
|
||||
@Operation(summary = "创建用户")
|
||||
public ApiResponse<Void> createUser(HttpServletRequest request,
|
||||
@RequestBody AdminUserCreateRequest body) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.createUser(currentUser, body);
|
||||
return ApiResponse.success("用户创建成功", null);
|
||||
}
|
||||
|
||||
@PutMapping("/user/{uid}")
|
||||
@Operation(summary = "更新用户")
|
||||
public ApiResponse<Void> updateUser(HttpServletRequest request,
|
||||
@PathVariable Long uid,
|
||||
@RequestBody AdminUserUpdateRequest body) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.updateUser(currentUser, uid, body);
|
||||
return ApiResponse.success("更新成功", null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/user/{uid}")
|
||||
@Operation(summary = "删除用户")
|
||||
public ApiResponse<Void> deleteUser(HttpServletRequest request,
|
||||
@PathVariable Long uid) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.deleteUser(currentUser, uid);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.admin.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserCreateRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
private String role;
|
||||
private Long createdById;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.admin.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserUpdateRequest {
|
||||
private String password;
|
||||
private String role;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AdminBriefVo {
|
||||
private Long id;
|
||||
private String username;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserItemVo {
|
||||
private Long id;
|
||||
private String username;
|
||||
@JsonProperty("is_admin")
|
||||
private boolean admin;
|
||||
private String role;
|
||||
@JsonProperty("created_by_id")
|
||||
private Long createdById;
|
||||
@JsonProperty("creator_username")
|
||||
private String creatorUsername;
|
||||
@JsonProperty("created_at")
|
||||
private String createdAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class AdminUserListVo {
|
||||
private List<AdminUserItemVo> items;
|
||||
private Long total;
|
||||
private Integer page;
|
||||
@JsonProperty("page_size")
|
||||
private Integer pageSize;
|
||||
@JsonProperty("current_user_role")
|
||||
private String currentUserRole;
|
||||
private List<AdminBriefVo> admins;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.nanri.aiimage.modules.admin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminBriefVo;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserItemVo;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserService {
|
||||
|
||||
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final WerkzeugPasswordEncoder passwordEncoder;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
|
||||
String username, Long createdById) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
int safePage = page == null || page < 1 ? 1 : page;
|
||||
int safeSize = pageSize == null ? 15 : pageSize;
|
||||
if (safeSize < 5) safeSize = 5;
|
||||
if (safeSize > 50) safeSize = 50;
|
||||
|
||||
String kw = username == null ? "" : username.trim();
|
||||
Long filterCreatedBy = "super_admin".equals(role) ? createdById : null;
|
||||
|
||||
LambdaQueryWrapper<AdminUserEntity> query = new LambdaQueryWrapper<>();
|
||||
if ("super_admin".equals(role)) {
|
||||
if (!kw.isEmpty()) {
|
||||
query.like(AdminUserEntity::getUsername, kw);
|
||||
}
|
||||
if (filterCreatedBy != null) {
|
||||
query.eq(AdminUserEntity::getCreatedById, filterCreatedBy);
|
||||
}
|
||||
} else {
|
||||
Long adminId = currentUser.getId();
|
||||
query.and(w -> w
|
||||
.eq(AdminUserEntity::getId, adminId)
|
||||
.or(inner -> inner
|
||||
.eq(AdminUserEntity::getRole, "normal")
|
||||
.eq(AdminUserEntity::getCreatedById, adminId)));
|
||||
if (!kw.isEmpty()) {
|
||||
query.like(AdminUserEntity::getUsername, kw);
|
||||
}
|
||||
}
|
||||
query.orderByAsc(AdminUserEntity::getId);
|
||||
|
||||
Long total = adminUserMapper.selectCount(query);
|
||||
int offset = (safePage - 1) * safeSize;
|
||||
query.last("LIMIT " + safeSize + " OFFSET " + offset);
|
||||
List<AdminUserEntity> rows = adminUserMapper.selectList(query);
|
||||
|
||||
Map<Long, String> creatorMap = loadCreatorMap(rows);
|
||||
|
||||
List<AdminUserItemVo> items = rows.stream().map(r -> toItem(r, creatorMap)).toList();
|
||||
List<AdminBriefVo> admins = "super_admin".equals(role)
|
||||
? adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)).stream()
|
||||
.map(u -> new AdminBriefVo(u.getId(), u.getUsername()))
|
||||
.toList()
|
||||
: List.of();
|
||||
|
||||
AdminUserListVo vo = new AdminUserListVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total == null ? 0L : total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
vo.setCurrentUserRole(role);
|
||||
vo.setAdmins(admins);
|
||||
return vo;
|
||||
}
|
||||
|
||||
public void createUser(AdminUserEntity currentUser, AdminUserCreateRequest request) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
String username = request.getUsername() == null ? "" : request.getUsername().trim();
|
||||
String password = request.getPassword() == null ? "" : request.getPassword();
|
||||
String wantRole = request.getRole() == null ? "normal" : request.getRole().trim();
|
||||
if (wantRole.isEmpty()) wantRole = "normal";
|
||||
if (!"admin".equals(wantRole) && !"normal".equals(wantRole)) {
|
||||
wantRole = "normal";
|
||||
}
|
||||
if ("admin".equals(role) && "admin".equals(wantRole)) {
|
||||
throw new BusinessException("仅超级管理员可创建管理员");
|
||||
}
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
throw new BusinessException("用户名和密码不能为空");
|
||||
}
|
||||
if (username.length() < 2) {
|
||||
throw new BusinessException("用户名至少2个字符");
|
||||
}
|
||||
if (password.length() < 6) {
|
||||
throw new BusinessException("密码至少6个字符");
|
||||
}
|
||||
|
||||
Long wantCreatedBy;
|
||||
if ("admin".equals(wantRole)) {
|
||||
wantCreatedBy = currentUser.getId();
|
||||
} else if ("super_admin".equals(role)) {
|
||||
wantCreatedBy = request.getCreatedById();
|
||||
if (wantCreatedBy == null) {
|
||||
AdminUserEntity firstAdmin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)
|
||||
.last("LIMIT 1"));
|
||||
wantCreatedBy = firstAdmin != null ? firstAdmin.getId() : currentUser.getId();
|
||||
}
|
||||
} else {
|
||||
wantCreatedBy = currentUser.getId();
|
||||
}
|
||||
|
||||
int isAdmin = ("super_admin".equals(wantRole) || "admin".equals(wantRole)) ? 1 : 0;
|
||||
AdminUserEntity entity = new AdminUserEntity();
|
||||
entity.setUsername(username);
|
||||
entity.setPasswordHash(passwordEncoder.hash(password));
|
||||
entity.setIsAdmin(isAdmin);
|
||||
entity.setRole(wantRole);
|
||||
entity.setCreatedById(wantCreatedBy);
|
||||
try {
|
||||
adminUserMapper.insert(entity);
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw new BusinessException("用户名已存在");
|
||||
}
|
||||
}
|
||||
|
||||
public void updateUser(AdminUserEntity currentUser, Long uid, AdminUserUpdateRequest request) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
String password = request.getPassword();
|
||||
String wantRole = request.getRole() == null ? null : request.getRole().trim();
|
||||
|
||||
if ((password == null || password.isEmpty()) && (wantRole == null || wantRole.isEmpty())) {
|
||||
throw new BusinessException("请提供要修改的内容");
|
||||
}
|
||||
|
||||
AdminUserEntity target = adminUserMapper.selectById(uid);
|
||||
if (target == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
String targetRole = target.getRole() == null ? "" : target.getRole();
|
||||
|
||||
if ("admin".equals(role)) {
|
||||
if (!"normal".equals(targetRole) || !currentUser.getId().equals(target.getCreatedById())) {
|
||||
throw new BusinessException(403, "只能编辑自己创建的普通用户");
|
||||
}
|
||||
wantRole = null;
|
||||
} else {
|
||||
if ("super_admin".equals(targetRole)) {
|
||||
throw new BusinessException("不能修改超级管理员");
|
||||
}
|
||||
if ("super_admin".equals(wantRole)) {
|
||||
throw new BusinessException("不能将用户设为超级管理员");
|
||||
}
|
||||
if (wantRole != null && !"admin".equals(wantRole) && !"normal".equals(wantRole) && !wantRole.isEmpty()) {
|
||||
wantRole = null;
|
||||
}
|
||||
}
|
||||
|
||||
LambdaUpdateWrapper<AdminUserEntity> update = new LambdaUpdateWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getId, uid);
|
||||
boolean dirty = false;
|
||||
if (password != null && !password.isEmpty()) {
|
||||
if (password.length() < 6) {
|
||||
throw new BusinessException("密码至少6个字符");
|
||||
}
|
||||
update.set(AdminUserEntity::getPasswordHash, passwordEncoder.hash(password));
|
||||
dirty = true;
|
||||
}
|
||||
if (wantRole != null && !wantRole.isEmpty()) {
|
||||
int isAdmin = "admin".equals(wantRole) ? 1 : 0;
|
||||
update.set(AdminUserEntity::getIsAdmin, isAdmin);
|
||||
update.set(AdminUserEntity::getRole, wantRole);
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
adminUserMapper.update(null, update);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteUser(AdminUserEntity currentUser, Long uid) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
if (currentUser.getId().equals(uid)) {
|
||||
throw new BusinessException("不能删除当前登录账号");
|
||||
}
|
||||
AdminUserEntity target = adminUserMapper.selectById(uid);
|
||||
if (target == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
String targetRole = target.getRole() == null ? "" : target.getRole();
|
||||
if ("super_admin".equals(targetRole)) {
|
||||
throw new BusinessException("不能删除超级管理员");
|
||||
}
|
||||
if ("admin".equals(role)) {
|
||||
if (!"normal".equals(targetRole) || !currentUser.getId().equals(target.getCreatedById())) {
|
||||
throw new BusinessException(403, "只能删除自己创建的普通用户");
|
||||
}
|
||||
}
|
||||
int affected = adminUserMapper.deleteById(uid);
|
||||
if (affected == 0) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, String> loadCreatorMap(List<AdminUserEntity> rows) {
|
||||
Set<Long> creatorIds = new LinkedHashSet<>();
|
||||
for (AdminUserEntity r : rows) {
|
||||
if (r.getCreatedById() != null && r.getCreatedById() > 0) {
|
||||
creatorIds.add(r.getCreatedById());
|
||||
}
|
||||
}
|
||||
if (creatorIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<AdminUserEntity> creators = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.in(AdminUserEntity::getId, new ArrayList<>(creatorIds)));
|
||||
Map<Long, String> map = new HashMap<>(creators.size() * 2);
|
||||
for (AdminUserEntity c : creators) {
|
||||
map.put(c.getId(), c.getUsername() == null ? "" : c.getUsername());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private AdminUserItemVo toItem(AdminUserEntity entity, Map<Long, String> creatorMap) {
|
||||
AdminUserItemVo vo = new AdminUserItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setUsername(entity.getUsername());
|
||||
vo.setAdmin(entity.getIsAdmin() != null && entity.getIsAdmin() == 1);
|
||||
vo.setRole(entity.getRole() == null || entity.getRole().isEmpty() ? "normal" : entity.getRole());
|
||||
vo.setCreatedById(entity.getCreatedById());
|
||||
vo.setCreatorUsername(creatorMap.getOrDefault(entity.getCreatedById(), ""));
|
||||
LocalDateTime createdAt = entity.getCreatedAt();
|
||||
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.nanri.aiimage.modules.admin.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.service.JwtService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AdminAuthSupport {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
/** 解析当前请求的用户;token 缺失或无效抛 401。 */
|
||||
public AdminUserEntity requireUser(HttpServletRequest request) {
|
||||
String token = resolveToken(request);
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BusinessException(401, "未登录");
|
||||
}
|
||||
Claims claims = jwtService.parse(token);
|
||||
if (claims == null) {
|
||||
throw new BusinessException(401, "登录已过期,请重新登录");
|
||||
}
|
||||
Long userId;
|
||||
try {
|
||||
userId = Long.parseLong(claims.getSubject());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException(401, "登录态无效");
|
||||
}
|
||||
AdminUserEntity user = adminUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(401, "用户不存在");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */
|
||||
public AdminUserEntity requireAdmin(HttpServletRequest request) {
|
||||
AdminUserEntity user = requireUser(request);
|
||||
String role = currentRole(user);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 计算当前用户的管理角色:super_admin / admin / null。 */
|
||||
public String currentRole(AdminUserEntity user) {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
String storedRole = user.getRole() == null ? "" : user.getRole().trim().toLowerCase();
|
||||
if ("super_admin".equals(storedRole)) {
|
||||
return "super_admin";
|
||||
}
|
||||
if ("admin".equals(storedRole)) {
|
||||
return resolveAdminRole(user);
|
||||
}
|
||||
boolean isAdminFlag = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
if (isAdminFlag) {
|
||||
return resolveAdminRole(user);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 数据库里 role='admin' 中 id 最小者视作超级管理员(与 Python 行为一致)。 */
|
||||
private String resolveAdminRole(AdminUserEntity user) {
|
||||
AdminUserEntity superAdmin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)
|
||||
.last("LIMIT 1"));
|
||||
if (superAdmin != null && superAdmin.getId().equals(user.getId())) {
|
||||
return "super_admin";
|
||||
}
|
||||
return "admin";
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String t = authHeader.substring(7).trim();
|
||||
if (!t.isEmpty()) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
String cookieName = authProperties.getCookieName();
|
||||
for (Cookie cookie : cookies) {
|
||||
if (cookieName.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,10 @@ public class AppearancePatentCozeClient {
|
||||
parameters.put("title_list", titles);
|
||||
parameters.put("url_list", urls);
|
||||
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, titles, urls));
|
||||
parameters.put("prompt", prompt == null ? "" : prompt);
|
||||
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
parameters.put("prompt", prompt);
|
||||
}
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
parameters.put("api_key", apiKey.trim());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.auth.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "aiimage.auth")
|
||||
public class AuthProperties {
|
||||
|
||||
private String jwtSecret = "please-change-this-secret-please-rotate-at-least-32-bytes";
|
||||
private long jwtTtlHours = 168L;
|
||||
private String cookieName = "aiimage_token";
|
||||
private boolean cookieSecure = false;
|
||||
private String cookieSameSite = "Lax";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.nanri.aiimage.modules.auth.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
|
||||
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
|
||||
import com.nanri.aiimage.modules.auth.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "登录认证", description = "桌面端登录与会话校验")
|
||||
public class LoginController {
|
||||
|
||||
private static final String DEVICE_ID_HEADER = "X-Device-Id";
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "用户名密码登录")
|
||||
public ResponseEntity<ApiResponse<LoginResultVo>> login(@RequestBody LoginRequest request) {
|
||||
LoginResultVo result = authService.login(request);
|
||||
ResponseCookie cookie = authService.buildAuthCookie(result.getToken());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success("登录成功", result));
|
||||
}
|
||||
|
||||
@GetMapping("/check_login")
|
||||
@Operation(summary = "校验登录态")
|
||||
public ResponseEntity<ApiResponse<LoginResultVo>> checkLogin(
|
||||
HttpServletRequest httpRequest,
|
||||
@RequestHeader(value = DEVICE_ID_HEADER, required = false) String deviceIdHeader) {
|
||||
String token = resolveToken(httpRequest);
|
||||
LoginResultVo result = authService.checkLogin(token, deviceIdHeader);
|
||||
ResponseCookie cookie = authService.buildAuthCookie(result.getToken());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
@Operation(summary = "登出")
|
||||
public ResponseEntity<ApiResponse<Void>> logout() {
|
||||
ResponseCookie cookie = authService.buildClearCookie();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success("已登出", null));
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7).trim();
|
||||
}
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
String cookieName = authService.cookieName();
|
||||
for (Cookie cookie : cookies) {
|
||||
if (cookieName.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.auth.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface LoginUserMapper extends BaseMapper<LoginUserEntity> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.auth.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
|
||||
private String username;
|
||||
private String password;
|
||||
private String deviceId;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.nanri.aiimage.modules.auth.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("users")
|
||||
public class LoginUserEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String username;
|
||||
@TableField("password_hash")
|
||||
private String passwordHash;
|
||||
private String machine;
|
||||
@TableField("is_admin")
|
||||
private Integer isAdmin;
|
||||
private String role;
|
||||
@TableField("created_by_id")
|
||||
private Long createdById;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.auth.model.vo;
|
||||
|
||||
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class LoginResultVo {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String role;
|
||||
private Boolean isAdmin;
|
||||
private String deviceId;
|
||||
private String token;
|
||||
private Long expiresIn;
|
||||
private List<PermissionMenuItemVo> appColumns;
|
||||
private List<PermissionMenuItemVo> adminColumns;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.nanri.aiimage.modules.auth.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import com.nanri.aiimage.modules.auth.mapper.LoginUserMapper;
|
||||
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
|
||||
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
|
||||
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthService {
|
||||
|
||||
private final LoginUserMapper loginUserMapper;
|
||||
private final WerkzeugPasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final PermissionMenuService permissionMenuService;
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
public LoginResultVo login(LoginRequest request) {
|
||||
String username = trim(request.getUsername());
|
||||
String password = request.getPassword() == null ? "" : request.getPassword();
|
||||
String deviceId = trim(request.getDeviceId());
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
throw new BusinessException("请输入用户名和密码");
|
||||
}
|
||||
if (deviceId.isEmpty()) {
|
||||
throw new BusinessException("缺少设备ID,请在桌面端打开");
|
||||
}
|
||||
|
||||
LoginUserEntity user = loginUserMapper.selectOne(new LambdaQueryWrapper<LoginUserEntity>()
|
||||
.eq(LoginUserEntity::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
if (user == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
throw new BusinessException("用户名或密码错误");
|
||||
}
|
||||
|
||||
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
|
||||
if (stored.isEmpty()) {
|
||||
loginUserMapper.update(null, new LambdaUpdateWrapper<LoginUserEntity>()
|
||||
.eq(LoginUserEntity::getId, user.getId())
|
||||
.set(LoginUserEntity::getMachine, deviceId));
|
||||
stored = deviceId;
|
||||
log.info("[auth] first-login bind userId={} device={}", user.getId(), deviceId);
|
||||
} else if (!stored.equals(deviceId)) {
|
||||
if (isAdmin) {
|
||||
log.warn("[auth] device mismatch but admin bypass userId={} stored={} current={}",
|
||||
user.getId(), stored, deviceId);
|
||||
} else {
|
||||
log.warn("[auth] device mismatch reject userId={} stored={} current={}",
|
||||
user.getId(), stored, deviceId);
|
||||
throw new BusinessException("当前设备与首次登录设备不一致,请在原设备上登录");
|
||||
}
|
||||
} else {
|
||||
log.info("[auth] device match userId={} isAdmin={} device={}",
|
||||
user.getId(), isAdmin, deviceId);
|
||||
}
|
||||
|
||||
return buildResult(user, stored, isAdmin);
|
||||
}
|
||||
|
||||
public LoginResultVo checkLogin(String token, String currentDeviceId) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BusinessException(401, "未登录");
|
||||
}
|
||||
Claims claims = jwtService.parse(token);
|
||||
if (claims == null) {
|
||||
throw new BusinessException(401, "登录已过期,请重新登录");
|
||||
}
|
||||
Long userId;
|
||||
try {
|
||||
userId = Long.parseLong(claims.getSubject());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException(401, "登录态无效");
|
||||
}
|
||||
LoginUserEntity user = loginUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(401, "用户不存在");
|
||||
}
|
||||
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
|
||||
String device = trim(currentDeviceId);
|
||||
if (device.isEmpty()) {
|
||||
// 没传设备 ID 时,回落到 token 内 deviceId
|
||||
Object claimDevice = claims.get("deviceId");
|
||||
device = claimDevice == null ? "" : claimDevice.toString().trim();
|
||||
}
|
||||
if (!stored.isEmpty() && !device.isEmpty() && !stored.equals(device)) {
|
||||
if (isAdmin) {
|
||||
log.warn("[auth] check_login device mismatch but admin bypass userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
} else {
|
||||
log.warn("[auth] check_login device mismatch reject userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
throw new BusinessException(401, "当前设备与首次登录设备不一致");
|
||||
}
|
||||
}
|
||||
return buildResult(user, stored.isEmpty() ? device : stored, isAdmin);
|
||||
}
|
||||
|
||||
public ResponseCookie buildAuthCookie(String token) {
|
||||
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(authProperties.getCookieName(), token)
|
||||
.httpOnly(true)
|
||||
.path("/")
|
||||
.maxAge(Duration.ofSeconds(jwtService.ttlSeconds()))
|
||||
.sameSite(authProperties.getCookieSameSite());
|
||||
if (authProperties.isCookieSecure()) {
|
||||
builder.secure(true);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public ResponseCookie buildClearCookie() {
|
||||
return ResponseCookie.from(authProperties.getCookieName(), "")
|
||||
.httpOnly(true)
|
||||
.path("/")
|
||||
.maxAge(Duration.ZERO)
|
||||
.sameSite(authProperties.getCookieSameSite())
|
||||
.secure(authProperties.isCookieSecure())
|
||||
.build();
|
||||
}
|
||||
|
||||
public String cookieName() {
|
||||
return authProperties.getCookieName();
|
||||
}
|
||||
|
||||
private LoginResultVo buildResult(LoginUserEntity user, String deviceId, boolean isAdmin) {
|
||||
String token = jwtService.issue(user.getId(), user.getUsername(), deviceId);
|
||||
LoginResultVo vo = new LoginResultVo();
|
||||
vo.setUserId(user.getId());
|
||||
vo.setUsername(user.getUsername());
|
||||
vo.setRole(user.getRole());
|
||||
vo.setIsAdmin(isAdmin);
|
||||
vo.setDeviceId(deviceId);
|
||||
vo.setToken(token);
|
||||
vo.setExpiresIn(jwtService.ttlSeconds());
|
||||
vo.setAppColumns(permissionMenuService.getUserColumnPermissions(user.getId(), PermissionMenuService.MENU_TYPE_APP));
|
||||
vo.setAdminColumns(permissionMenuService.getUserColumnPermissions(user.getId(), PermissionMenuService.MENU_TYPE_ADMIN));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.nanri.aiimage.modules.auth.service;
|
||||
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JwtService {
|
||||
|
||||
private final AuthProperties props;
|
||||
|
||||
private SecretKey signingKey() {
|
||||
byte[] keyBytes = props.getJwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||
if (keyBytes.length < 32) {
|
||||
byte[] padded = new byte[32];
|
||||
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
|
||||
keyBytes = padded;
|
||||
}
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
|
||||
public String issue(Long userId, String username, String deviceId) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plus(Duration.ofHours(props.getJwtTtlHours()));
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.claim("username", username)
|
||||
.claim("deviceId", deviceId)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(exp))
|
||||
.signWith(signingKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
try {
|
||||
return Jwts.parser()
|
||||
.verifyWith(signingKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.debug("[auth] jwt parse failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public long ttlSeconds() {
|
||||
return Duration.ofHours(props.getJwtTtlHours()).toSeconds();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.nanri.aiimage.modules.auth.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* 兼容 Werkzeug `generate_password_hash` 的 PBKDF2-HMAC-SHA256 输出格式。
|
||||
* 形如:pbkdf2:sha256:iterations$salt$hexdigest(Werkzeug 默认 600000 轮、salt 16 位)。
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class WerkzeugPasswordEncoder {
|
||||
|
||||
private static final int DK_BITS = 32 * 8;
|
||||
private static final int DEFAULT_ITERATIONS = 600_000;
|
||||
private static final int SALT_LENGTH = 16;
|
||||
private static final String SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
public String hash(String rawPassword) {
|
||||
if (rawPassword == null) {
|
||||
throw new IllegalArgumentException("password cannot be null");
|
||||
}
|
||||
String salt = generateSalt();
|
||||
try {
|
||||
byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(),
|
||||
salt.getBytes(StandardCharsets.UTF_8), DEFAULT_ITERATIONS, DK_BITS);
|
||||
String hex = HexFormat.of().formatHex(derived);
|
||||
return "pbkdf2:sha256:" + DEFAULT_ITERATIONS + "$" + salt + "$" + hex;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("生成密码摘要失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean matches(String rawPassword, String storedHash) {
|
||||
if (rawPassword == null || storedHash == null || storedHash.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int firstSep = storedHash.indexOf('$');
|
||||
int secondSep = storedHash.indexOf('$', firstSep + 1);
|
||||
if (firstSep < 0 || secondSep < 0) {
|
||||
return false;
|
||||
}
|
||||
String method = storedHash.substring(0, firstSep);
|
||||
String salt = storedHash.substring(firstSep + 1, secondSep);
|
||||
String expectedHex = storedHash.substring(secondSep + 1);
|
||||
if (!method.startsWith("pbkdf2:sha256")) {
|
||||
log.warn("[auth] unsupported password hash scheme: {}", method);
|
||||
return false;
|
||||
}
|
||||
String[] parts = method.split(":");
|
||||
int iterations = parts.length >= 3 ? Integer.parseInt(parts[2]) : 600_000;
|
||||
byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(), salt.getBytes(StandardCharsets.UTF_8), iterations, DK_BITS);
|
||||
String actualHex = HexFormat.of().formatHex(derived);
|
||||
return constantTimeEquals(actualHex, expectedHex);
|
||||
} catch (Exception e) {
|
||||
log.warn("[auth] verify password failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pbkdf2Sha256(char[] password, byte[] salt, int iterations, int keyBits) throws Exception {
|
||||
PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBits);
|
||||
try {
|
||||
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
|
||||
} finally {
|
||||
spec.clearPassword();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean constantTimeEquals(String a, String b) {
|
||||
if (a == null || b == null || a.length() != b.length()) {
|
||||
return false;
|
||||
}
|
||||
int diff = 0;
|
||||
for (int i = 0; i < a.length(); i++) {
|
||||
diff |= a.charAt(i) ^ b.charAt(i);
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
private static String generateSalt() {
|
||||
StringBuilder sb = new StringBuilder(SALT_LENGTH);
|
||||
for (int i = 0; i < SALT_LENGTH; i++) {
|
||||
sb.append(SALT_ALPHABET.charAt(RANDOM.nextInt(SALT_ALPHABET.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryListVo;
|
||||
import com.nanri.aiimage.modules.imagehistory.service.ImageHistoryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin")
|
||||
@Tag(name = "管理后台-生成历史", description = "管理员查看所有用户的生成历史")
|
||||
public class ImageHistoryController {
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final ImageHistoryService imageHistoryService;
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "分页查询所有用户生成历史")
|
||||
public ApiResponse<ImageHistoryListVo> listHistory(HttpServletRequest request,
|
||||
@RequestParam(required = false, defaultValue = "1") Integer page,
|
||||
@RequestParam(name = "page_size", required = false, defaultValue = "15") Integer pageSize,
|
||||
@RequestParam(name = "user_id", required = false) Long userId,
|
||||
@RequestParam(name = "time_start", required = false) String timeStart,
|
||||
@RequestParam(name = "time_end", required = false) String timeEnd) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
ImageHistoryListVo vo = imageHistoryService.listHistory(page, pageSize, userId, timeStart, timeEnd);
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.entity.ImageHistoryEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ImageHistoryMapper extends BaseMapper<ImageHistoryEntity> {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("image_history")
|
||||
public class ImageHistoryEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
@TableField("panel_type")
|
||||
private String panelType;
|
||||
/** JSON 字段,按字符串读出后由 service 解析。 */
|
||||
@TableField("original_urls")
|
||||
private String originalUrls;
|
||||
private String params;
|
||||
@TableField("result_urls")
|
||||
private String resultUrls;
|
||||
@TableField("long_image_url")
|
||||
private String longImageUrl;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class ImageHistoryItemVo {
|
||||
private Long id;
|
||||
@JsonProperty("user_id")
|
||||
private Long userId;
|
||||
private String username;
|
||||
@JsonProperty("created_at")
|
||||
private String createdAt;
|
||||
@JsonProperty("panel_type")
|
||||
private String panelType;
|
||||
@JsonProperty("original_urls")
|
||||
private List<String> originalUrls;
|
||||
private Map<String, Object> params;
|
||||
@JsonProperty("result_urls")
|
||||
private List<String> resultUrls;
|
||||
@JsonProperty("long_image_url")
|
||||
private String longImageUrl;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ImageHistoryListVo {
|
||||
private List<ImageHistoryItemVo> items;
|
||||
private Long total;
|
||||
private Integer page;
|
||||
@JsonProperty("page_size")
|
||||
private Integer pageSize;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.imagehistory.mapper.ImageHistoryMapper;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.entity.ImageHistoryEntity;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryItemVo;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryListVo;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImageHistoryService {
|
||||
|
||||
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
private static final DateTimeFormatter ISO_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final DateTimeFormatter ISO_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
private static final TypeReference<List<String>> LIST_STRING_REF = new TypeReference<>() {
|
||||
};
|
||||
private static final TypeReference<Map<String, Object>> MAP_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final ImageHistoryMapper imageHistoryMapper;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ImageHistoryListVo listHistory(Integer page, Integer pageSize, Long userId,
|
||||
String timeStart, String timeEnd) {
|
||||
int safePage = page == null || page < 1 ? 1 : page;
|
||||
int safeSize = pageSize == null ? 15 : pageSize;
|
||||
if (safeSize < 10) safeSize = 10;
|
||||
if (safeSize > 50) safeSize = 50;
|
||||
|
||||
LambdaQueryWrapper<ImageHistoryEntity> query = new LambdaQueryWrapper<>();
|
||||
if (userId != null && userId > 0) {
|
||||
query.eq(ImageHistoryEntity::getUserId, userId);
|
||||
}
|
||||
LocalDateTime startDt = parseDateTime(timeStart, false);
|
||||
if (startDt != null) {
|
||||
query.ge(ImageHistoryEntity::getCreatedAt, startDt);
|
||||
}
|
||||
LocalDateTime endDt = parseDateTime(timeEnd, true);
|
||||
if (endDt != null) {
|
||||
query.le(ImageHistoryEntity::getCreatedAt, endDt);
|
||||
}
|
||||
|
||||
Long total = imageHistoryMapper.selectCount(query);
|
||||
query.orderByDesc(ImageHistoryEntity::getCreatedAt);
|
||||
int offset = (safePage - 1) * safeSize;
|
||||
query.last("LIMIT " + safeSize + " OFFSET " + offset);
|
||||
List<ImageHistoryEntity> rows = imageHistoryMapper.selectList(query);
|
||||
|
||||
Map<Long, String> usernameMap = loadUsernameMap(rows);
|
||||
|
||||
List<ImageHistoryItemVo> items = new ArrayList<>(rows.size());
|
||||
for (ImageHistoryEntity r : rows) {
|
||||
items.add(toItem(r, usernameMap));
|
||||
}
|
||||
|
||||
ImageHistoryListVo vo = new ImageHistoryListVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total == null ? 0L : total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Map<Long, String> loadUsernameMap(List<ImageHistoryEntity> rows) {
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
for (ImageHistoryEntity r : rows) {
|
||||
if (r.getUserId() != null && r.getUserId() > 0) {
|
||||
userIds.add(r.getUserId());
|
||||
}
|
||||
}
|
||||
if (userIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<AdminUserEntity> users = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.in(AdminUserEntity::getId, new ArrayList<>(userIds)));
|
||||
Map<Long, String> map = new HashMap<>(users.size() * 2);
|
||||
for (AdminUserEntity u : users) {
|
||||
map.put(u.getId(), u.getUsername() == null ? "" : u.getUsername());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private ImageHistoryItemVo toItem(ImageHistoryEntity entity, Map<Long, String> usernameMap) {
|
||||
ImageHistoryItemVo vo = new ImageHistoryItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setUserId(entity.getUserId());
|
||||
String username = usernameMap.get(entity.getUserId());
|
||||
vo.setUsername(username == null || username.isEmpty() ? "-" : username);
|
||||
LocalDateTime createdAt = entity.getCreatedAt();
|
||||
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
|
||||
vo.setPanelType(entity.getPanelType() == null ? "" : entity.getPanelType());
|
||||
vo.setOriginalUrls(parseList(entity.getOriginalUrls()));
|
||||
vo.setParams(parseMap(entity.getParams()));
|
||||
vo.setResultUrls(parseList(entity.getResultUrls()));
|
||||
String longImageUrl = entity.getLongImageUrl();
|
||||
vo.setLongImageUrl(longImageUrl == null || longImageUrl.trim().isEmpty() ? null : longImageUrl.trim());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private List<String> parseList(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
List<String> v = objectMapper.readValue(json, LIST_STRING_REF);
|
||||
return v == null ? Collections.emptyList() : v;
|
||||
} catch (Exception e) {
|
||||
log.warn("[image-history] 解析 JSON 列表失败: {}", e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseMap(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
Map<String, Object> v = objectMapper.readValue(json, MAP_REF);
|
||||
return v == null ? Collections.emptyMap() : v;
|
||||
} catch (Exception e) {
|
||||
log.warn("[image-history] 解析 JSON 对象失败: {}", e.getMessage());
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime parseDateTime(String text, boolean endOfDay) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
String t = text.trim();
|
||||
if (t.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (t.length() <= 10) {
|
||||
LocalDateTime base = java.time.LocalDate.parse(t, ISO_DATE_FORMATTER).atStartOfDay();
|
||||
return endOfDay ? base.withHour(23).withMinute(59).withSecond(59) : base;
|
||||
}
|
||||
return LocalDateTime.parse(t.replace('T', ' '), ISO_DATETIME_FORMATTER);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("时间格式不正确: " + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("users")
|
||||
public class AdminUserEntity {
|
||||
@@ -13,7 +15,14 @@ public class AdminUserEntity {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String username;
|
||||
@TableField("password_hash")
|
||||
private String passwordHash;
|
||||
@TableField("is_admin")
|
||||
private Integer isAdmin;
|
||||
private String role;
|
||||
@TableField("created_by_id")
|
||||
private Long createdById;
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
private String machine;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.permission.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -9,9 +10,14 @@ public class PermissionMenuItemVo {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
@JsonProperty("column_key")
|
||||
private String columnKey;
|
||||
@JsonProperty("menu_type")
|
||||
private String menuType;
|
||||
@JsonProperty("route_path")
|
||||
private String routePath;
|
||||
@JsonProperty("sort_order")
|
||||
private Integer sortOrder;
|
||||
@JsonProperty("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -355,9 +355,14 @@ public class SimilarAsinCozeClient {
|
||||
List<String> urls = rows.stream().map(this::primaryImageUrl).toList();
|
||||
List<List<String>> urlLists = rows.stream().map(this::imageUrls).toList();
|
||||
|
||||
List<Map<String, Object>> items = buildItemObjects(asins, titles, skus, urls, urlLists);
|
||||
logCozeItemsDiff(rows, items);
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("items", buildItemObjects(asins, titles, skus, urls, urlLists));
|
||||
parameters.put("prompt", prompt == null ? "" : prompt);
|
||||
parameters.put("items", items);
|
||||
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
parameters.put("prompt", prompt);
|
||||
}
|
||||
// legacy flag:当线上 coze 工作流回退到老契约时,把环境变量
|
||||
// AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=true 即可重新塞 api_key。
|
||||
boolean includeLegacyApiKey = properties.isCozeIncludeLegacyApiKey();
|
||||
@@ -367,6 +372,51 @@ public class SimilarAsinCozeClient {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印每一行 row 在送入 Coze 前后的关键字段,便于排查 Python -> Java -> Coze 的字段是否被改写。
|
||||
* 对照点:asin / url / urls(size+样例) / title / sku;以及组装到 Coze 后的 url / target_urls。
|
||||
*/
|
||||
private void logCozeItemsDiff(List<SimilarAsinResultRowDto> rows, List<Map<String, Object>> items) {
|
||||
if (rows == null || items == null) {
|
||||
return;
|
||||
}
|
||||
int size = Math.min(rows.size(), items.size());
|
||||
log.info("[similar-asin] coze items diff start batchSize={}", size);
|
||||
for (int i = 0; i < size; i++) {
|
||||
SimilarAsinResultRowDto row = rows.get(i);
|
||||
Map<String, Object> item = items.get(i);
|
||||
if (row == null || item == null) {
|
||||
continue;
|
||||
}
|
||||
List<String> rowUrls = safeUrls(row.getUrls());
|
||||
String rowUrl = safeText(row.getUrl());
|
||||
Object outUrl = item.get("url");
|
||||
Object outTargetUrls = item.get("target_urls");
|
||||
int outTargetSize = (outTargetUrls instanceof List<?> list) ? list.size() : 0;
|
||||
boolean urlMatch = String.valueOf(outUrl == null ? "" : outUrl).equals(rowUrl);
|
||||
boolean targetMatch = (outTargetUrls instanceof List<?> outList)
|
||||
&& outList.size() == rowUrls.size()
|
||||
&& outList.equals(rowUrls);
|
||||
log.info("[similar-asin] coze item idx={} asin={} title={} sku={} rowUrl={} rowUrlsSize={} rowUrlsHead={} rowUrlsTail={} outUrl={} outTargetSize={} outTargetHead={} outTargetTail={} urlMatch={} targetUrlsMatch={}",
|
||||
i,
|
||||
safeText(row.getAsin()),
|
||||
abbreviate(safeText(row.getTitle()), 80),
|
||||
safeText(row.getSku()),
|
||||
abbreviate(rowUrl, 200),
|
||||
rowUrls.size(),
|
||||
rowUrls.isEmpty() ? "" : abbreviate(rowUrls.get(0), 200),
|
||||
rowUrls.size() <= 1 ? "" : abbreviate(rowUrls.get(rowUrls.size() - 1), 200),
|
||||
abbreviate(String.valueOf(outUrl == null ? "" : outUrl), 200),
|
||||
outTargetSize,
|
||||
(outTargetUrls instanceof List<?> headList && !headList.isEmpty())
|
||||
? abbreviate(String.valueOf(headList.get(0)), 200) : "",
|
||||
(outTargetUrls instanceof List<?> tailList && tailList.size() > 1)
|
||||
? abbreviate(String.valueOf(tailList.get(tailList.size() - 1)), 200) : "",
|
||||
urlMatch,
|
||||
targetMatch);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> maskCozeRequestBody(Map<String, Object> body) {
|
||||
Map<String, Object> masked = new LinkedHashMap<>(body);
|
||||
|
||||
@@ -9,14 +9,11 @@ import lombok.Data;
|
||||
import java.lang.reflect.Array;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测单行商品数据")
|
||||
public class SimilarAsinResultRowDto {
|
||||
private static final int MAX_IMAGE_URLS = 24;
|
||||
|
||||
@Schema(description = "来源文件 key。多文件回传时用于避免行结果串到别的文件。", example = "uploads/20260426/similar_asin_17.xlsx")
|
||||
private String sourceFileKey;
|
||||
@@ -47,10 +44,10 @@ public class SimilarAsinResultRowDto {
|
||||
@Schema(description = "商品价格。来自 Excel 解析或 Python 回传。", example = "12.29")
|
||||
private String price;
|
||||
|
||||
@Schema(description = "兼容旧链路的首个图片 URL。新链路允许 url 传字符串或数组;数组会同步写入 urls。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
@Schema(description = "商品主图 URL。Python 端解析的代表图。允许传字符串或数组(数组取首个非空),与 urls 互不影响。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "商品图片 URL 列表,最多保留 24 个非空地址。请求中也可以直接把 url 传成数组。", example = "[\"https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg\"]")
|
||||
@Schema(description = "同类商品图 URL 列表。Python 端原样回传,后端不再去重/截断/与 url 互写。", example = "[\"https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg\"]")
|
||||
private List<String> urls = new ArrayList<>();
|
||||
|
||||
@Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
|
||||
@@ -125,11 +122,7 @@ public class SimilarAsinResultRowDto {
|
||||
private String puzzleImg2;
|
||||
|
||||
public String getUrl() {
|
||||
if (url != null && !url.isBlank()) {
|
||||
return url;
|
||||
}
|
||||
List<String> normalized = getUrls();
|
||||
return normalized.isEmpty() ? "" : normalized.get(0);
|
||||
return url == null ? "" : url;
|
||||
}
|
||||
|
||||
@JsonSetter("url")
|
||||
@@ -140,20 +133,24 @@ public class SimilarAsinResultRowDto {
|
||||
"image", "img", "pic", "picture", "link", "imageLink", "image_link"
|
||||
})
|
||||
public void setUrl(Object value) {
|
||||
List<String> normalized = normalizeUrls(value);
|
||||
this.urls = normalized;
|
||||
// 仅写 url 字段;不再回写 urls,遵循"url 主图 / urls 同类图"语义。
|
||||
// 兼容历史输入:若传入数组/集合,仅取第一个非空值作为 url,不影响 urls。
|
||||
if (value == null) {
|
||||
this.url = "";
|
||||
return;
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
this.url = text.trim();
|
||||
return;
|
||||
}
|
||||
List<String> normalized = new ArrayList<>();
|
||||
appendUrls(normalized, value);
|
||||
this.url = normalized.isEmpty() ? "" : normalized.get(0);
|
||||
}
|
||||
|
||||
public List<String> getUrls() {
|
||||
List<String> normalized = normalizeUrls(urls);
|
||||
if (url != null && !url.isBlank() && normalized.stream().noneMatch(url.trim()::equals)) {
|
||||
List<String> combined = new ArrayList<>(normalized.size() + 1);
|
||||
combined.add(url.trim());
|
||||
combined.addAll(normalized);
|
||||
return limitUrls(combined);
|
||||
}
|
||||
return normalized;
|
||||
// 直接返回 Python 回传的 urls,不再把 url 强推到首位、不去重、不截断。
|
||||
return cleanUrls(urls);
|
||||
}
|
||||
|
||||
@JsonSetter("urls")
|
||||
@@ -166,19 +163,18 @@ public class SimilarAsinResultRowDto {
|
||||
"图片链接", "商品图片", "商品主图", "主图", "主图链接"
|
||||
})
|
||||
public void setUrls(Object urls) {
|
||||
List<String> normalized = normalizeUrls(urls);
|
||||
// 仅写 urls 字段;不再回写 url,避免 url/urls 交叉污染。
|
||||
List<String> normalized = new ArrayList<>();
|
||||
appendUrls(normalized, urls);
|
||||
this.urls = normalized;
|
||||
this.url = normalized.isEmpty() ? "" : normalized.get(0);
|
||||
}
|
||||
|
||||
public boolean hasImageUrl() {
|
||||
return !getUrls().isEmpty();
|
||||
}
|
||||
|
||||
private static List<String> normalizeUrls(Object value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
appendUrls(result, value);
|
||||
return limitUrls(result);
|
||||
// url(主图)和 urls(同类商品图)任一存在即可作为可送 Coze 的素材。
|
||||
if (url != null && !url.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
return !cleanUrls(urls).isEmpty();
|
||||
}
|
||||
|
||||
private static void appendUrls(List<String> result, Object value) {
|
||||
@@ -214,19 +210,21 @@ public class SimilarAsinResultRowDto {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> limitUrls(List<String> values) {
|
||||
Set<String> deduplicated = new LinkedHashSet<>();
|
||||
if (values != null) {
|
||||
for (String value : values) {
|
||||
if (value == null || value.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
deduplicated.add(value.trim());
|
||||
if (deduplicated.size() >= MAX_IMAGE_URLS) {
|
||||
break;
|
||||
}
|
||||
private static List<String> cleanUrls(List<String> values) {
|
||||
// 仅做空值过滤与 trim,不去重、不截断,原样保留 Python 回传顺序与数量。
|
||||
if (values == null || values.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<String> result = new ArrayList<>(values.size());
|
||||
for (String value : values) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (!trimmed.isBlank()) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(deduplicated);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,11 +1301,56 @@ public class SimilarAsinTaskService {
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
logPythonInboundRows(rows);
|
||||
return rows;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印 Python 端回传给 Java 的每一行 row 关键字段,确认 url(主图)/ urls(同类商品图)/ title / sku
|
||||
* 是否按预期到达。该日志与 SimilarAsinCozeClient 的 coze items diff 日志成对,
|
||||
* 便于排查"Python 回传了什么、Java 又把什么发到 Coze"。
|
||||
*/
|
||||
private void logPythonInboundRows(List<SimilarAsinResultRowDto> rows) {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.info("[similar-asin] python inbound start size={}", rows.size());
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
SimilarAsinResultRowDto row = rows.get(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String url = row.getUrl();
|
||||
List<String> urls = row.getUrls();
|
||||
log.info("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} url={} urlsSize={} urlsHead={} urlsTail={}",
|
||||
i,
|
||||
normalize(row.getGroupKey()),
|
||||
normalize(row.getRowToken()),
|
||||
normalize(row.getId()),
|
||||
normalize(row.getAsin()),
|
||||
normalize(row.getCountry()),
|
||||
abbreviateForLog(row.getTitle(), 80),
|
||||
normalize(row.getSku()),
|
||||
abbreviateForLog(url, 200),
|
||||
urls == null ? 0 : urls.size(),
|
||||
urls == null || urls.isEmpty() ? "" : abbreviateForLog(urls.get(0), 200),
|
||||
urls == null || urls.size() <= 1 ? "" : abbreviateForLog(urls.get(urls.size() - 1), 200));
|
||||
}
|
||||
}
|
||||
|
||||
private String abbreviateForLog(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (maxLength <= 0 || trimmed.length() <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private int allRowCount(FileTaskEntity task) {
|
||||
try {
|
||||
SimilarAsinParsedPayloadDto payload = readParsedPayload(task);
|
||||
@@ -1634,13 +1679,8 @@ public class SimilarAsinTaskService {
|
||||
if (parsedRow == null) {
|
||||
return row;
|
||||
}
|
||||
if (normalize(row.getUrl()).isBlank()) {
|
||||
String parsedUrl = firstNonBlank(parsedRow.getUrl(), readValueByHeader(parsedRow,
|
||||
"url", "rul", "image", "img", "pic", "picture", "link"));
|
||||
if (!normalize(parsedUrl).isBlank()) {
|
||||
row.setUrl(parsedUrl);
|
||||
}
|
||||
}
|
||||
// 不再回填 url:Python 端会同时回传 url(主图)与 urls(同类商品图),
|
||||
// 缺失场景应在 Python 侧定位,Java 不再合成新的 url 字段以避免覆盖原始数据。
|
||||
if (normalize(row.getTitle()).isBlank()) {
|
||||
row.setTitle(parsedRow.getTitle());
|
||||
}
|
||||
|
||||
@@ -58,11 +58,13 @@ public class SimilarAsinImageEmbedder {
|
||||
static final int DOWNLOAD_TIMEOUT_SECONDS = 5;
|
||||
static final int DOWNLOAD_MAX_RETRY = 1;
|
||||
static final int DOWNLOAD_POOL_SIZE = 8;
|
||||
public static final float IMAGE_ROW_HEIGHT_POINTS = 150f;
|
||||
public static final int IMAGE_COL_WIDTH_CHARS = 30;
|
||||
static final int TARGET_LONG_EDGE_PX = 300;
|
||||
static final float JPEG_QUALITY = 0.85f;
|
||||
static final int MAX_THUMB_SIZE_BYTES = 40 * 1024;
|
||||
// B 方案:单元格放大到 Excel 上限附近(255 char ≈ 1785 px / 409.5 pt ≈ 546 px),保持 16:9 显示空间。
|
||||
public static final float IMAGE_ROW_HEIGHT_POINTS = 409f;
|
||||
public static final int IMAGE_COL_WIDTH_CHARS = 80;
|
||||
// 源图长边 1280 px 给 Excel 缩放/打印留余量;q=0.75 + 300KB 出口硬上限主动控制单图体积。
|
||||
static final int TARGET_LONG_EDGE_PX = 1280;
|
||||
static final float JPEG_QUALITY = 0.75f;
|
||||
static final int MAX_THUMB_SIZE_BYTES = 300 * 1024;
|
||||
static final int MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024;
|
||||
static final int MAX_DECODE_PIXELS = 6000 * 6000;
|
||||
|
||||
@@ -150,6 +152,9 @@ public class SimilarAsinImageEmbedder {
|
||||
anchor.setRow2(rowIdx + 1);
|
||||
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
|
||||
patriarch.createPicture(anchor, picIdx);
|
||||
// POI 已经把缩略图字节复制进 picture pool(B 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。
|
||||
// 同 URL 行内重复出现概率较低,且 cache 仍承担同任务跨行去重收益(首次 miss 时已经 put 过)。
|
||||
taskImageCache.remove(trimmedUrl);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
|
||||
row.createCell(colIdx).setCellValue(trimmedUrl);
|
||||
@@ -440,8 +445,10 @@ public class SimilarAsinImageEmbedder {
|
||||
}
|
||||
|
||||
/**
|
||||
* resize 出口字节硬上限保护:worst case 6000 行 × 3 列 × 40KB × 2 副本 = 1440MB;
|
||||
* 因此线上限制建议结合 `taskImageCache` 命中率与单任务行数评估,超出预算需缩小 MAX_THUMB_SIZE_BYTES 或限制行数。
|
||||
* resize 出口字节硬上限保护:B 方案下单图最坏 ~300KB;POI picture pool 在 SXSSFWorkbook.dispose() 前不会 spill 到临时文件,
|
||||
* 因此 N 行 × 3 列 × 300KB 全量驻留堆。配合 -Xmx2048M:≈1500 行 × 3 列 ≈ 1.32GB picture pool,已逼近安全水位;
|
||||
* embed() 成功后会立刻 taskImageCache.remove() 释放 A 副本,B 副本(picture pool)仍随 workbook 生命周期驻留。
|
||||
* 超过 ~1500 行需要降低 MAX_THUMB_SIZE_BYTES 或调小 TARGET_LONG_EDGE_PX,必要时再考虑拆任务。
|
||||
*/
|
||||
public static class ResizeOversizeException extends ResizeException {
|
||||
private final String url;
|
||||
|
||||
@@ -179,6 +179,12 @@ aiimage:
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
auth:
|
||||
jwt-secret: ${AIIMAGE_JWT_SECRET:please-change-this-secret-please-rotate-at-least-32-bytes}
|
||||
jwt-ttl-hours: ${AIIMAGE_AUTH_JWT_TTL_HOURS:168}
|
||||
cookie-name: ${AIIMAGE_AUTH_COOKIE_NAME:aiimage_token}
|
||||
cookie-secure: ${AIIMAGE_AUTH_COOKIE_SECURE:false}
|
||||
cookie-same-site: ${AIIMAGE_AUTH_COOKIE_SAME_SITE:Lax}
|
||||
ziniao:
|
||||
enabled: ${AIIMAGE_ZINIAO_ENABLED:false}
|
||||
base-url: ${AIIMAGE_ZINIAO_BASE_URL:https://sbappstoreapi.ziniao.com/openapi-router}
|
||||
|
||||
Reference in New Issue
Block a user