Commit 1cd0087e by DaiJiezhang

feat: add authenticated asset management

parent 028e5bb6
......@@ -32,6 +32,27 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
......
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
......@@ -8,14 +9,16 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/phone-assets")
public class PhoneAssetController {
private final PhoneAssetService service;
private final PagePermissionService permissions;
/** 代码作用(白话):接收手机号资产接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */
public PhoneAssetController(PhoneAssetService service){this.service=service;}
/** 代码作用(白话):接收手机号业务服务和页面权限服务。关联文件:PagePermissionService.java、PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 请求 -> READ/EDIT 校验 -> 原有业务服务。 */
public PhoneAssetController(PhoneAssetService service, PagePermissionService permissions){this.service=service;this.permissions=permissions;}
/** 代码作用(白话):接收浏览器分页查询并返回统一 JSON。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):GET /api/phone-assets -> Service -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){return ApiResponse.success(service.page(query));}
@GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){permissions.require(PagePermissionService.PHONE,"READ");return ApiResponse.success(service.page(query));}
/** 代码作用(白话):接收新增表单并创建手机号资产。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):POST -> DTO -> Service.create -> ApiResponse -> 弹窗。 */
@PostMapping public ApiResponse<PhoneAssetResponse> create(@Valid @RequestBody PhoneAssetSaveRequest request){return ApiResponse.success("新增成功",service.create(request));}
@PostMapping public ApiResponse<PhoneAssetResponse> create(@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");return ApiResponse.success("新增成功",service.create(request));}
/** 代码作用(白话):接收编辑表单并更新允许修改的字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):PUT -> Service.update -> ApiResponse -> 列表刷新。 */
@PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){return ApiResponse.success("编辑成功",service.update(id,request));}
@PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");return ApiResponse.success("编辑成功",service.update(id,request));}
/** 代码作用(白话):软删除没有关联阻止的手机号资产。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):DELETE -> Service.softDelete -> ApiResponse -> 列表刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){service.softDelete(id);return ApiResponse.success("删除成功",null);}
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){permissions.require(PagePermissionService.PHONE,"EDIT");service.softDelete(id);return ApiResponse.success("删除成功",null);}
}
......@@ -7,6 +7,7 @@ import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
......@@ -22,27 +23,29 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/wecom-accounts")
public class WecomAccountController {
private final WecomAccountService service;
private final PagePermissionService permissions;
/** Code purpose (plain language): receives the service that coordinates WeCom assets and referenced assets. Related files: WecomAccountService.java. Data flow: HTTP controller -> service -> mappers. */
public WecomAccountController(WecomAccountService service) { this.service = service; }
/** 代码作用(白话):接收企微业务服务和页面权限服务。关联文件:PagePermissionService.java、WecomAccountService.java。关联逻辑(调用链/数据流):HTTP 请求 -> READ/EDIT 校验 -> 原有企微业务服务。 */
public WecomAccountController(WecomAccountService service, PagePermissionService permissions) { this.service = service; this.permissions = permissions; }
/** Code purpose (plain language): saves a new enterprise WeChat asset. Related files: WecomAccountSaveRequest.java, WecomAccountService.java. Data flow: create dialog -> POST -> transaction -> response. */
@PostMapping
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { return ApiResponse.success("新增成功", service.create(request)); }
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { permissions.require(PagePermissionService.WECOM, "EDIT"); return ApiResponse.success("新增成功", service.create(request)); }
/** Code purpose (plain language): returns the paged enterprise WeChat asset list and optional registration-phone filter. Related files: WecomAccountPageQuery.java, WecomAccountService.java. Data flow: list query -> GET -> service.page -> table. */
@GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { return ApiResponse.success(service.page(query)); }
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.page(query)); }
/** Code purpose (plain language): searches registration subjects by company name or short name. Related files: CompanyProfileLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-profiles")
public ApiResponse<List<CompanyProfileLookupResponse>> companyProfiles(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyProfiles(keyword)); }
public ApiResponse<List<CompanyProfileLookupResponse>> companyProfiles(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchCompanyProfiles(keyword)); }
/** Code purpose (plain language): searches reusable registration phone assets. Related files: PhoneAssetLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/phone-assets")
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchPhoneAssets(keyword)); }
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchPhoneAssets(keyword)); }
/** Code purpose (plain language): searches optional WeCom owners from company people. Related files: CompanyPersonLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-persons")
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyPersons(keyword)); }
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
}
......@@ -18,4 +18,7 @@ public class SystemUserEntity extends AssetBaseEntity {
private String passwordHash;
private String roleCode;
private String status;
private String pagePermissions;
private LocalDateTime passwordUpdatedAt;
private Integer authVersion;
}
package com.xyw.console.auth;
import com.xyw.console.auth.dto.CurrentUserResponse; import com.xyw.console.auth.dto.LoginRequest; import com.xyw.console.common.ApiResponse;
import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/api/auth")
public class AuthController {
private final AuthService service;
/** 代码作用(白话):接收认证业务服务;关联文件:AuthService.java。关联逻辑(调用链/数据流):浏览器认证请求 -> Controller -> Service。 */
public AuthController(AuthService service) { this.service=service; }
/** 代码作用(白话):接收登录表单并写入安全 Cookie;关联文件:LoginRequest.java、LoginView.js。关联逻辑(调用链/数据流):用户名密码 -> AuthService -> Set-Cookie/当前用户。 */
@PostMapping("/login") public ApiResponse<CurrentUserResponse> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) { return ApiResponse.success(service.login(request,response)); }
/** 代码作用(白话):返回当前登录身份及页面权限;关联文件:AuthService.java、auth-store.js。关联逻辑(调用链/数据流):Cookie -> Filter -> me -> 菜单路由。 */
@GetMapping("/me") public ApiResponse<CurrentUserResponse> me() { return ApiResponse.success(service.me()); }
/** 代码作用(白话):让浏览器取得写操作所需的 CSRF 校验值;关联文件:auth-api-client.js、SecurityConfig.java。关联逻辑(调用链/数据流):应用启动/提交前 -> csrf 接口 -> XSRF-TOKEN Cookie 和请求头。 */
@GetMapping("/csrf") public ApiResponse<String> csrf(CsrfToken token) { return ApiResponse.success(token.getToken()); }
/** 代码作用(白话):清除登录 Cookie 结束当前浏览器会话;关联文件:AuthService.java、App.js。关联逻辑(调用链/数据流):登出 -> Controller -> 过期 Cookie。 */
@PostMapping("/logout") public ApiResponse<Void> logout(HttpServletResponse response) { service.logout(response); return ApiResponse.success(null); }
}
package com.xyw.console.auth;
import com.xyw.console.common.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 代码作用(白话):把认证与账号管理的预期失败统一转成前端可读取的 JSON;关联文件:AuthController.java、system-user-api-client.js。关联逻辑(调用链/数据流):Service 抛出规则异常 -> 本处理器 -> ApiResponse -> 提示消息。 */
@RestControllerAdvice
public class AuthExceptionHandler {
/** 代码作用(白话):隐藏登录失败细节并返回统一未认证状态;关联文件:AuthService.java、LoginView.js。关联逻辑(调用链/数据流):密码校验失败 -> 401 JSON -> 登录页通用错误提示。 */
@ExceptionHandler(BadCredentialsException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResponse<Void> badCredentials(BadCredentialsException error) { return ApiResponse.error(401, "账号或密码错误"); }
/** 代码作用(白话):把账号、角色和密码规则失败返回为明确的 400 提示;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):规则校验失败 -> 400 JSON -> 表单提示。 */
@ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> invalidArgument(IllegalArgumentException error) { return ApiResponse.error(400, error.getMessage()); }
}
package com.xyw.console.auth;
/**
* 代码作用(白话):保存已通过令牌验证的最小用户身份,避免把密码哈希放进 Spring Security 上下文。
* 关联文件:AuthTokenFilter.java、PagePermissionService.java。
* 关联逻辑(调用链/数据流):Cookie -> AuthTokenFilter -> AuthPrincipal -> Controller/权限服务。
*/
public record AuthPrincipal(Long userId, String username, String roleCode, int authVersion) {}
package com.xyw.console.auth;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import com.xyw.console.auth.dto.CurrentUserResponse;
import com.xyw.console.auth.dto.LoginRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final SystemUserMapper users; private final PasswordEncoder encoder; private final AuthTokenService tokens; private final PagePermissionService permissions;
/** 代码作用(白话):接收认证依赖;关联文件:AuthController.java、SystemUserMapper.java。关联逻辑(调用链/数据流):Controller -> Service -> 用户表/令牌/权限。 */
public AuthService(SystemUserMapper users, PasswordEncoder encoder, AuthTokenService tokens, PagePermissionService permissions) { this.users = users; this.encoder = encoder; this.tokens = tokens; this.permissions = permissions; }
/** 代码作用(白话):验证账号密码并签发登录 Cookie;关联文件:LoginRequest.java、AuthTokenService.java。关联逻辑(调用链/数据流):登录表单 -> 用户表/BCrypt -> Cookie -> 当前用户响应。 */
public CurrentUserResponse login(LoginRequest request, HttpServletResponse response) { SystemUserEntity user = users.selectOne(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getUsername, request.username()).eq(SystemUserEntity::getDeleteTime, 0L)); if (user == null || !"ACTIVE".equals(user.getStatus()) || user.getPasswordHash() == null || !encoder.matches(request.password(), user.getPasswordHash())) throw new BadCredentialsException("账号或密码错误"); tokens.issue(new AuthPrincipal(user.getId(), user.getUsername(), user.getRoleCode(), user.getAuthVersion() == null ? 1 : user.getAuthVersion()), response); return responseOf(user); }
/** 代码作用(白话):把当前令牌身份重新读取为最新菜单资料;关联文件:AuthTokenFilter.java、App.js。关联逻辑(调用链/数据流):Cookie -> SecurityContext -> 用户表 -> 菜单权限响应。 */
public CurrentUserResponse me() { Object value = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (!(value instanceof AuthPrincipal principal)) throw new BadCredentialsException("登录已失效"); SystemUserEntity user = users.selectById(principal.userId()); if (user == null || !"ACTIVE".equals(user.getStatus())) throw new BadCredentialsException("登录已失效"); return responseOf(user); }
/** 代码作用(白话):让浏览器删除登录 Cookie;关联文件:AuthController.java。关联逻辑(调用链/数据流):登出按钮 -> Service -> 过期 Cookie。 */
public void logout(HttpServletResponse response) { tokens.clear(response); }
/** 代码作用(白话):将用户实体转换为绝不包含密码哈希的当前登录响应;关联文件:CurrentUserResponse.java、auth-store.js。关联逻辑(调用链/数据流):用户表 -> 计算有效权限 -> 前端会话状态。 */
private CurrentUserResponse responseOf(SystemUserEntity user) { return new CurrentUserResponse(user.getId(), user.getUsername(), user.getRoleCode(), permissions.effectivePermissions(user)); }
}
package com.xyw.console.auth;
import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.util.Collections;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class AuthTokenFilter extends OncePerRequestFilter {
private final AuthTokenService tokens; private final SystemUserMapper users; private final PagePermissionService permissions;
/** 代码作用(白话):接收令牌、用户表和权限服务;关联文件:SecurityConfig.java。关联逻辑(调用链/数据流):Security Filter Chain -> 本过滤器 -> 用户表/权限。 */
public AuthTokenFilter(AuthTokenService tokens, SystemUserMapper users, PagePermissionService permissions) { this.tokens=tokens; this.users=users; this.permissions=permissions; }
/** 代码作用(白话):跳过登录和 CSRF 令牌接口,避免公开的初始化请求被自身拦截;关联文件:AuthController.java。关联逻辑(调用链/数据流):/api/auth/login 或 /api/auth/csrf -> Controller,不进入验签。 */
@Override protected boolean shouldNotFilter(HttpServletRequest request) { return "/api/auth/login".equals(request.getRequestURI()) || "/api/auth/csrf".equals(request.getRequestURI()); }
/** 代码作用(白话):将有效 Cookie 转成 Spring Security 身份并二次核验账号状态和版本;关联文件:AuthTokenService.java、SystemUserEntity.java。关联逻辑(调用链/数据流):请求 Cookie -> JWT -> 用户表 -> SecurityContext -> Controller。 */
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { String token=tokens.extract(request.getCookies()); if (token != null) { AuthPrincipal principal=tokens.parse(token); SystemUserEntity user=users.selectById(principal.userId()); if (user != null && "ACTIVE".equals(user.getStatus()) && user.getAuthVersion()!=null && user.getAuthVersion()==principal.authVersion()) { var authentication=new UsernamePasswordAuthenticationToken(principal, null, Collections.singleton(new SimpleGrantedAuthority("ROLE_"+principal.roleCode()))); authentication.setDetails(permissions.effectivePermissions(user)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception ignored) { SecurityContextHolder.clearContext(); } chain.doFilter(request,response); }
}
package com.xyw.console.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
@Service
public class AuthTokenService {
private static final String COOKIE_NAME = "XYW_SESSION";
private final String secret;
private final Duration lifetime;
private final boolean cookieSecure;
/** 代码作用(白话):读取部署密钥和会话时长;关联文件:application.yml。关联逻辑(调用链/数据流):环境变量 -> 配置 -> JWT 签发/验签。 */
public AuthTokenService(@Value("${xyw.auth.jwt-secret:}") String secret, @Value("${xyw.auth.session-hours:8}") long sessionHours, @Value("${xyw.auth.cookie-secure:false}") boolean cookieSecure) { this.secret = secret; this.lifetime = Duration.ofHours(sessionHours); this.cookieSecure = cookieSecure; }
/** 代码作用(白话):为已登录用户签发仅浏览器自动携带的 HttpOnly Cookie;关联文件:AuthService.java。关联逻辑(调用链/数据流):登录成功 -> JWT -> Set-Cookie -> 后续过滤器。 */
public void issue(AuthPrincipal principal, HttpServletResponse response) {
Instant now = Instant.now();
String token = Jwts.builder().subject(principal.userId().toString()).claim("username", principal.username()).claim("role", principal.roleCode()).claim("version", principal.authVersion()).issuedAt(Date.from(now)).expiration(Date.from(now.plus(lifetime))).signWith(key()).compact();
response.addHeader("Set-Cookie", ResponseCookie.from(COOKIE_NAME, token).httpOnly(true).secure(cookieSecure).sameSite("Lax").path("/").maxAge(lifetime).build().toString());
}
/** 代码作用(白话):解析并验签 Cookie 中的短期登录票据;关联文件:AuthTokenFilter.java。关联逻辑(调用链/数据流):请求 Cookie -> JWT claims -> 数据库二次核验。 */
public AuthPrincipal parse(String token) {
Claims claims = Jwts.parser().verifyWith((javax.crypto.SecretKey) key()).build().parseSignedClaims(token).getPayload();
return new AuthPrincipal(Long.valueOf(claims.getSubject()), claims.get("username", String.class), claims.get("role", String.class), claims.get("version", Integer.class));
}
/** 代码作用(白话):清空浏览器登录 Cookie;关联文件:AuthService.java。关联逻辑(调用链/数据流):登出 -> 过期 Cookie -> 下次请求无身份。 */
public void clear(HttpServletResponse response) { response.addHeader("Set-Cookie", ResponseCookie.from(COOKIE_NAME, "").httpOnly(true).secure(cookieSecure).sameSite("Lax").path("/").maxAge(Duration.ZERO).build().toString()); }
/** 代码作用(白话):从请求 Cookie 取出登录票据;关联文件:AuthTokenFilter.java。关联逻辑(调用链/数据流):HTTP 请求 -> Cookie 数组 -> JWT 文本。 */
public String extract(Cookie[] cookies) { if (cookies != null) for (Cookie cookie : cookies) if (COOKIE_NAME.equals(cookie.getName())) return cookie.getValue(); return null; }
/** 代码作用(白话):验证密钥足够安全,避免使用默认或过短密钥签发票据;关联文件:application.yml。关联逻辑(调用链/数据流):配置 -> HMAC Key -> JWT 签名。 */
private Key key() { if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) throw new IllegalStateException("认证签名密钥未配置或长度不足"); return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); }
}
package com.xyw.console.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xyw.console.asset.entity.SystemUserEntity;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Service
public class PagePermissionService {
public static final String OVERVIEW = "overview";
public static final String DOMAIN = "domain";
public static final String WECOM = "reference-wecom";
public static final String PHONE = "phone-assets";
public static final String ALERTS = "alerts";
private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号资产", ALERTS, "提醒中心");
private final ObjectMapper objectMapper;
/** 代码作用(白话):接收 JSON 工具以读取数据库权限映射;关联文件:SystemUserEntity.java。关联逻辑(调用链/数据流):page_permissions JSON -> 权限 Map -> Controller 判定。 */
public PagePermissionService(ObjectMapper objectMapper) { this.objectMapper = objectMapper; }
/** 代码作用(白话):生成当前账号的有效五页权限,开发者和超级管理员固定全编辑;关联文件:AuthService.java、App.js。关联逻辑(调用链/数据流):用户表 -> 有效权限 -> /api/auth/me -> 菜单和路由。 */
public Map<String, String> effectivePermissions(SystemUserEntity user) {
Map<String, String> result = new LinkedHashMap<>();
PAGES.keySet().forEach(key -> result.put(key, isAdministrator(user.getRoleCode()) ? "EDIT" : "NONE"));
if (!isAdministrator(user.getRoleCode()) && user.getPagePermissions() != null) try { Map<String, String> saved = objectMapper.readValue(user.getPagePermissions(), new TypeReference<>() {}); saved.forEach((key, value) -> { if (PAGES.containsKey(key) && validLevel(value)) result.put(key, value); }); } catch (Exception ignored) { }
return result;
}
/** 代码作用(白话):校验权限面板提交内容,只接受已注册页面和三档权限;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):表单 Map -> 校验/标准化 -> JSON 入库。 */
public String validatePermissions(Map<String, String> input) {
Map<String, String> normalized = new LinkedHashMap<>();
if (input != null) input.forEach((key, value) -> { if (!PAGES.containsKey(key) || !validLevel(value)) throw new IllegalArgumentException("页面权限配置无效"); normalized.put(key, value); });
try { return objectMapper.writeValueAsString(normalized); } catch (Exception error) { throw new IllegalStateException("页面权限无法保存", error); }
}
/** 代码作用(白话):在资产接口执行服务端最低权限检查;关联文件:PhoneAssetController.java、WecomAccountController.java。关联逻辑(调用链/数据流):Controller -> 当前认证身份 -> READ/EDIT 决定 403 或继续业务服务。 */
public void require(String pageKey, String minimum) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof AuthPrincipal principal)) throw new AccessDeniedException("没有页面权限");
String permission = authentication.getDetails() instanceof Map<?, ?> map ? String.valueOf(map.get(pageKey)) : "NONE";
if (!("EDIT".equals(permission) || ("READ".equals(permission) && "READ".equals(minimum)))) throw new AccessDeniedException("没有页面权限");
}
/** 代码作用(白话):判断角色是否固定拥有全部编辑权限;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):role_code -> 全量权限或逐页权限。 */
public boolean isAdministrator(String roleCode) { return "DEVELOPER".equals(roleCode) || "SUPER_ADMIN".equals(roleCode); }
/** 代码作用(白话):限制权限值为三档,避免未知值意外放行;关联文件:UserPermissionView.js。关联逻辑(调用链/数据流):前端单选值 -> 后端验证 -> 持久化。 */
private boolean validLevel(String value) { return "NONE".equals(value) || "READ".equals(value) || "EDIT".equals(value); }
}
package com.xyw.console.auth;
import com.xyw.console.auth.dto.PasswordResetRequest;
import com.xyw.console.auth.dto.SystemUserCreateRequest;
import com.xyw.console.auth.dto.SystemUserResponse;
import com.xyw.console.auth.dto.SystemUserUpdateRequest;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/system-users")
public class SystemUserAdminController {
private final SystemUserAdminService service;
/** 代码作用(白话):接收账号管理业务服务;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):管理页面请求 -> Controller -> 角色边界/用户表。 */
public SystemUserAdminController(SystemUserAdminService service) { this.service = service; }
/** 代码作用(白话):返回隐藏 Jeddy 后的账号列表;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):设置页面 -> GET -> Service 查询 -> 安全列表。 */
@GetMapping public ApiResponse<List<SystemUserResponse>> list() { return ApiResponse.success(service.listVisibleUsers()); }
/** 代码作用(白话):创建账号并由服务端执行创建者角色规则;关联文件:SystemUserCreateRequest.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):创建表单 -> POST -> 账号表。 */
@PostMapping public ApiResponse<SystemUserResponse> create(@Valid @RequestBody SystemUserCreateRequest request) { return ApiResponse.success(service.createUser(request)); }
/** 代码作用(白话):编辑账号资料和页面权限;关联文件:SystemUserUpdateRequest.java、PagePermissionService.java。关联逻辑(调用链/数据流):权限面板 -> PUT -> 用户表。 */
@PutMapping("/{id}") public ApiResponse<SystemUserResponse> update(@PathVariable Long id, @Valid @RequestBody SystemUserUpdateRequest request) { return ApiResponse.success(service.updateUser(id, request)); }
/** 代码作用(白话):仅开发者可重置非开发者密码;关联文件:PasswordResetRequest.java、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):开发者表单 -> PUT password -> BCrypt/触发器。 */
@PutMapping("/{id}/password") public ApiResponse<Void> resetPassword(@PathVariable Long id, @Valid @RequestBody PasswordResetRequest request) { service.resetPassword(id, request); return ApiResponse.success(null); }
}
package com.xyw.console.auth;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import com.xyw.console.auth.dto.PasswordResetRequest;
import com.xyw.console.auth.dto.SystemUserCreateRequest;
import com.xyw.console.auth.dto.SystemUserResponse;
import com.xyw.console.auth.dto.SystemUserUpdateRequest;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class SystemUserAdminService {
private final SystemUserMapper users; private final PagePermissionService permissions; private final PasswordEncoder encoder;
/** 代码作用(白话):接收账号表、页面权限和密码哈希依赖;关联文件:SystemUserAdminController.java、SystemUserMapper.java。关联逻辑(调用链/数据流):管理接口 -> 本服务 -> 用户表/权限 JSON/BCrypt。 */
public SystemUserAdminService(SystemUserMapper users, PagePermissionService permissions, PasswordEncoder encoder) { this.users = users; this.permissions = permissions; this.encoder = encoder; }
/** 代码作用(白话):查询管理者可见的非开发者账号,任何响应都不返回哈希;关联文件:SystemUserAdminController.java、UserPermissionView.js。关联逻辑(调用链/数据流):账号页面 -> GET users -> as_system_user(role != DEVELOPER) -> 列表。 */
public List<SystemUserResponse> listVisibleUsers() { assertAdministrator(); return users.selectList(new LambdaQueryWrapper<SystemUserEntity>().ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L).orderByDesc(SystemUserEntity::getId)).stream().map(this::responseOf).toList(); }
/** 代码作用(白话):按当前创建者的角色边界新增非开发者账号;关联文件:SystemUserCreateRequest.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):创建表单 -> 创建者校验 -> 密码/禁用规则 -> as_system_user。 */
public SystemUserResponse createUser(SystemUserCreateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); if (users.selectCount(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getUsername, request.username()).eq(SystemUserEntity::getDeleteTime, 0L)) > 0) throw new IllegalArgumentException("用户名已存在"); SystemUserEntity user = new SystemUserEntity(); user.setUsername(request.username()); user.setRoleCode(request.roleCode()); user.setPagePermissions(permissions.validatePermissions(request.pagePermissions())); user.setDeleteTime(0L); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); user.setAuthVersion(1); if ("SUPER_ADMIN".equals(actor)) { user.setStatus("DISABLED"); user.setPasswordHash(null); } else { requireValidPassword(request.password(), request.username()); user.setStatus("ACTIVE"); user.setPasswordHash(encoder.encode(request.password())); } users.insert(user); return responseOf(user); }
/** 代码作用(白话):更新非开发者账号的角色、启停和逐页权限,禁止绕过 Jeddy;关联文件:SystemUserUpdateRequest.java、PagePermissionService.java。关联逻辑(调用链/数据流):编辑面板 -> 角色边界 -> 用户表更新 -> 下次请求按新权限判定。 */
public SystemUserResponse updateUser(Long id, SystemUserUpdateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); SystemUserEntity user = findManageableUser(id); user.setRoleCode(request.roleCode()); user.setStatus(request.status()); if (!("ACTIVE".equals(user.getStatus()) || "DISABLED".equals(user.getStatus()))) throw new IllegalArgumentException("账号状态无效"); if ("ACTIVE".equals(user.getStatus()) && user.getPasswordHash() == null) throw new IllegalArgumentException("请由开发者先设置密码后再启用账号"); user.setPagePermissions(permissions.validatePermissions(request.pagePermissions())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); return responseOf(user); }
/** 代码作用(白话):仅允许开发者为非开发者账号设置密码,并交给数据库触发器记录时间及失效旧会话;关联文件:PasswordResetRequest.java、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):开发者表单 -> BCrypt hash -> UPDATE password_hash -> trigger(auth_version)。 */
public void resetPassword(Long id, PasswordResetRequest request) { if (!"DEVELOPER".equals(currentRole())) throw new AccessDeniedException("只有开发者可以修改密码"); SystemUserEntity user = findManageableUser(id); requireValidPassword(request.password(), user.getUsername()); user.setPasswordHash(encoder.encode(request.password())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); }
/** 代码作用(白话):确认当前会话属于开发者或超级管理员;关联文件:AuthTokenFilter.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):Cookie 身份 -> SecurityContext -> 管理接口准入。 */
private void assertAdministrator() { if (!permissions.isAdministrator(currentRole())) throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):读取过滤器已经核验过的当前角色;关联文件:AuthPrincipal.java、AuthTokenFilter.java。关联逻辑(调用链/数据流):JWT -> SecurityContext principal -> 创建/修改边界。 */
private String currentRole() { Object principal = SecurityContextHolder.getContext().getAuthentication() == null ? null : SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (!(principal instanceof AuthPrincipal user)) throw new AccessDeniedException("没有账号管理权限"); return user.roleCode(); }
/** 代码作用(白话):按角色边界阻止创建开发者以及普通角色创建账号;关联文件:SystemUserCreateRequest.java、SystemUserUpdateRequest.java。关联逻辑(调用链/数据流):当前角色 + 目标角色 -> 允许或 403。 */
private void assertCreatableRole(String actor, String roleCode) { if ("DEVELOPER".equals(roleCode)) throw new AccessDeniedException("开发者账号仅允许固定账号 Jeddy"); if (!("SUPER_ADMIN".equals(roleCode) || "FINANCE".equals(roleCode) || "HR".equals(roleCode) || "OPERATIONS".equals(roleCode))) throw new IllegalArgumentException("角色无效"); if ("DEVELOPER".equals(actor) || "SUPER_ADMIN".equals(actor)) return; throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):按固定查询条件取得可管理账号,避免 Jeddy 通过 URL 或接口细节泄露;关联文件:SystemUserMapper.java、SystemUserResponse.java。关联逻辑(调用链/数据流):账号 id -> 非 DEVELOPER 查询 -> 编辑/重置。 */
private SystemUserEntity findManageableUser(Long id) { SystemUserEntity user = users.selectOne(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getId, id).ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L)); if (user == null) throw new IllegalArgumentException("账号不存在"); return user; }
/** 代码作用(白话):验证密码长度和用户名相似度,避免弱密码直接进入数据库;关联文件:PasswordResetRequest.java、SystemUserCreateRequest.java。关联逻辑(调用链/数据流):密码输入 -> 规则校验 -> BCrypt 哈希。 */
private void requireValidPassword(String password, String username) { if (password == null || password.length() < 12 || password.length() > 72 || password.toLowerCase().contains(username.toLowerCase())) throw new IllegalArgumentException("密码需为 12-72 位且不能包含用户名"); }
/** 代码作用(白话):将实体转为安全响应,并统一展开固定管理员的有效权限;关联文件:SystemUserResponse.java、PagePermissionService.java。关联逻辑(调用链/数据流):实体 -> 权限计算 -> 前端列表。 */
private SystemUserResponse responseOf(SystemUserEntity user) { return new SystemUserResponse(user.getId(), user.getUsername(), user.getRoleCode(), user.getStatus(), permissions.effectivePermissions(user), user.getPasswordUpdatedAt()); }
}
package com.xyw.console.auth.dto;
import java.util.Map;
/** 代码作用(白话):返回前端菜单所需的安全身份,不返回密码或哈希;关联文件:AuthService.java、auth-store.js。关联逻辑(调用链/数据流):用户表 -> 安全响应 -> 菜单/路由。 */
public record CurrentUserResponse(Long id, String username, String roleCode, Map<String, String> pagePermissions) {}
package com.xyw.console.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/** 代码作用(白话):限定登录请求只接收合法账号名和暂存密码;关联文件:AuthController.java。关联逻辑(调用链/数据流):登录表单 -> DTO 校验 -> AuthService。 */
public record LoginRequest(@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{3,64}") String username, @NotBlank String password) {}
package com.xyw.console.auth.dto;
import jakarta.validation.constraints.NotBlank;
/** 代码作用(白话):只接收开发者为他人设置的新密码,密码仅在本次请求中使用;关联文件:SystemUserAdminController.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):重置密码表单 -> DTO -> BCrypt 哈希 -> 数据库触发器失效旧会话。 */
public record PasswordResetRequest(@NotBlank String password) {}
package com.xyw.console.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import java.util.Map;
/** 代码作用(白话):接收新账号的基础信息,不把密码哈希暴露给浏览器;关联文件:SystemUserAdminController.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):账号创建表单 -> DTO 校验 -> 管理服务 -> as_system_user。 */
public record SystemUserCreateRequest(
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{3,64}") String username,
@NotBlank String roleCode,
String password,
Map<String, String> pagePermissions) {}
package com.xyw.console.auth.dto;
import java.time.LocalDateTime;
import java.util.Map;
/** 代码作用(白话):向管理页面返回可展示的账号资料,明确不含 password_hash;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):用户表实体 -> 安全响应 DTO -> 账号列表/编辑面板。 */
public record SystemUserResponse(Long id, String username, String roleCode, String status, Map<String, String> pagePermissions, LocalDateTime passwordUpdatedAt) {}
package com.xyw.console.auth.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
/** 代码作用(白话):接收非开发者账号的角色、启停和页面权限编辑结果;关联文件:SystemUserAdminController.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):权限面板 -> DTO -> 管理服务 -> 用户表。 */
public record SystemUserUpdateRequest(@NotBlank String roleCode, @NotBlank String status, Map<String, String> pagePermissions) {}
package com.xyw.console.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.xyw.console.auth.AuthTokenFilter; import com.xyw.console.common.ApiResponse;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration @EnableWebSecurity
public class SecurityConfig {
/** 代码作用(白话):配置无状态认证、CSRF 和统一 401/403 响应;关联文件:AuthTokenFilter.java、WebConfig.java。关联逻辑(调用链/数据流):浏览器请求 -> Security Filter Chain -> Cookie 验签 -> Controller。 */
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http, AuthTokenFilter filter, ObjectMapper mapper) throws Exception { return http.httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable).csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).ignoringRequestMatchers("/api/auth/login")).cors(cors -> {}).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.OPTIONS,"/**").permitAll().requestMatchers("/api/auth/login", "/api/auth/csrf").permitAll().anyRequest().authenticated()).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class).exceptionHandling(errors -> errors.authenticationEntryPoint((request,response,error)->write(mapper,response,401,"请先登录")).accessDeniedHandler((request,response,error)->write(mapper,response,403,"没有权限"))).build(); }
/** 代码作用(白话):生成 BCrypt 编码器,确保密码只存不可逆哈希;关联文件:AuthService.java。关联逻辑(调用链/数据流):开发者设密码/登录验证 -> BCrypt。 */
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
/** 代码作用(白话):把安全层错误也保持为现有 code/message/data 格式;关联文件:ApiResponse.java、前端 API 客户端。关联逻辑(调用链/数据流):安全拒绝 -> JSON 响应 -> 页面错误提示。 */
private void write(ObjectMapper mapper, HttpServletResponse response, int status, String message) throws java.io.IOException { response.setStatus(status); response.setContentType("application/json;charset=UTF-8"); mapper.writeValue(response.getWriter(), ApiResponse.error(status,message)); }
}
......@@ -17,6 +17,7 @@ public class WebConfig implements WebMvcConfigurer {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000", "http://localhost:5173", "http://127.0.0.1:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*");
.allowedHeaders("*")
.allowCredentials(true);
}
}
-- 代码作用(白话):在不写入任何账号密码或 Jeddy 哈希的前提下,为现有账号表补齐页面权限和会话失效字段。
-- 关联文件:SystemUserEntity.java、AuthTokenFilter.java、SystemUserAdminService.java。
-- 关联逻辑(调用链/数据流):数据库字段/触发器 -> 认证过滤器校验 auth_version -> 登录 Cookie 立即失效。
-- 本脚本在部署前的只读核对确认字段不存在后执行一次;MySQL 不支持 ADD COLUMN IF NOT EXISTS。
ALTER TABLE as_system_user
ADD COLUMN page_permissions JSON NULL COMMENT '页面权限映射:NONE/READ/EDIT',
ADD COLUMN password_updated_at DATETIME NULL COMMENT '密码哈希最后更新时间',
ADD COLUMN auth_version INT NOT NULL DEFAULT 1 COMMENT '登录凭证版本号';
CREATE UNIQUE INDEX uk_as_system_user_username ON as_system_user (username);
DROP TRIGGER IF EXISTS trg_as_system_user_password_changed;
DELIMITER $$
CREATE TRIGGER trg_as_system_user_password_changed
BEFORE UPDATE ON as_system_user
FOR EACH ROW
BEGIN
IF NOT (NEW.password_hash <=> OLD.password_hash) THEN
SET NEW.password_updated_at = CURRENT_TIMESTAMP;
SET NEW.auth_version = COALESCE(OLD.auth_version, 1) + 1;
END IF;
END$$
DELIMITER ;
-- 回滚说明:仅在确认没有依赖新字段和唯一索引后,先 DROP TRIGGER,再 DROP INDEX,最后 DROP COLUMN;
-- 绝不可在未备份 as_system_user 或存在新账号数据时直接执行回滚,也不得在此脚本写入 Jeddy 哈希。
package com.xyw.console.asset.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.xyw.console.asset.dto.DeviceAssetPageResponse;
import com.xyw.console.asset.dto.DeviceAssetResponse;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.asset.service.DeviceAssetService;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/** 文件用途注释:验证设备资产 HTTP 接口可正确绑定查询、multipart 表单和删除异常,不依赖真实数据库或文件目录。*/
class DeviceAssetControllerTest {
/** 代码作用(白话):验证列表接口将服务层的设备与人员名称数据按统一 JSON 返回。关联文件:DeviceAssetController.java、DeviceAssetService.java。关联逻辑(调用链/数据流):GET -> Controller.page -> Service.page -> ApiResponse -> 前端表格。*/
@Test void returnsPagedDeviceAssets() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
mockMvc(service).perform(get("/api/device-assets")).andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)).andExpect(jsonPath("$.data.records[0].deviceName").value("测试电脑")).andExpect(jsonPath("$.data.records[0].userPersonName").value("张三"));
}
/** 代码作用(白话):验证新增接口接收 multipart 字段和图片并把保存请求交给服务层。关联文件:DeviceAssetController.java、DeviceAssetSaveRequest.java。关联逻辑(调用链/数据流):表单/图片 -> @ModelAttribute -> Service.create -> 成功 JSON。*/
@Test void createsDeviceAssetThroughMultipartEndpoint() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.create(any())).thenReturn(response());MockMultipartFile image=new MockMultipartFile("imageAttachment1","device.png","image/png",new byte[]{1,2,3});
mockMvc(service).perform(multipart("/api/device-assets").file(image).param("deviceName","测试电脑").param("userUsageStatus","使用中").param("assetRelationStatus","未关联"))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)).andExpect(jsonPath("$.data.id").value(1));verify(service).create(any());
}
/** 代码作用(白话):验证删除被关联资产阻断时,接口返回 400 和可读错误信息。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):DELETE -> Service.softDelete 异常 -> Advice -> 400 JSON。*/
@Test void returnsBadRequestWhenDeleteIsBlocked() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);doThrow(new DeviceAssetValidationException("设备仍被手机号码资产引用,不能删除")).when(service).softDelete(1L);
mockMvc(service).perform(delete("/api/device-assets/1")).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(400));
}
/** Plain purpose: verify the edit endpoint binds multipart fields and delegates to the update service. Related files: DeviceAssetController.java, DeviceAssetSaveRequest.java. Flow: multipart PUT -> model binding -> service.update -> success JSON. */
@Test void updatesDeviceAssetThroughMultipartEndpoint() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.update(any(),any())).thenReturn(response());MockMultipartFile image=new MockMultipartFile("imageAttachment2","edited.png","image/png",new byte[]{1,2,3});
mockMvc(service).perform(multipart(HttpMethod.PUT,"/api/device-assets/{id}",1L).file(image).param("deviceName","\u6d4b\u8bd5\u7535\u8111").param("userUsageStatus","\u4f7f\u7528\u4e2d").param("assetRelationStatus","\u672a\u5173\u8054"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.imageAttachment1Url").value("/api/device-assets/files/image.png"));verify(service).update(org.mockito.ArgumentMatchers.eq(1L),any());
}
/** Plain purpose: verify image bytes are served only through the controlled opaque identifier endpoint. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: GET file identifier -> service resource -> response bytes. */
@Test void readsImageThroughControlledEndpoint() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.findImage("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{7,8}) { @Override public String getFilename(){return "opaque.png";} });
mockMvc(service).perform(get("/api/device-assets/files/opaque.png")).andExpect(status().isOk()).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{7,8}));
}
/** Plain purpose: assert the public list response has URLs but never deleteTime or a disk path field. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: service response -> ApiResponse JSON -> browser. */
@Test void hidesDeleteTimeAndPhysicalImagePath() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
mockMvc(service).perform(get("/api/device-assets")).andExpect(status().isOk()).andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist()).andExpect(jsonPath("$.data.records[0].imageAttachment1Url").value("/api/device-assets/files/image.png")).andExpect(jsonPath("$.data.records[0].imageAttachment1").doesNotExist());
}
/** 代码作用(白话):集中创建带设备异常转换器的 MockMvc,保证控制器返回契约可被测试。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):HTTP 模拟请求 -> Controller -> Advice -> JSON 响应。*/
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service)).setControllerAdvice(new DeviceAssetExceptionHandler()).build();}
/** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):服务层响应 -> ApiResponse -> 页面展示。*/
private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,9L,"张三","使用中","未关联",null,null);}
}
......@@ -11,6 +11,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.auth.PagePermissionService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
......@@ -27,7 +28,7 @@ class PhoneAssetControllerTest {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.insert(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper), mock(PagePermissionService.class))).build();
mockMvc.perform(post("/api/phone-assets").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13812345678\",\"realNameOwner\":\"张三\"}"))
......@@ -46,7 +47,7 @@ class PhoneAssetControllerTest {
when(mapper.selectOne(any())).thenReturn(entity);
when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper), mock(PagePermissionService.class))).build();
mockMvc.perform(put("/api/phone-assets/1").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13912345678\",\"realNameOwner\":\"李四\"}"))
......@@ -63,7 +64,7 @@ class PhoneAssetControllerTest {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectOne(any())).thenReturn(activeEntity());
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper), mock(PagePermissionService.class))).build();
mockMvc.perform(delete("/api/phone-assets/1"))
.andExpect(status().isOk());
......
......@@ -17,6 +17,7 @@ import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.auth.PagePermissionService;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
......@@ -66,7 +67,7 @@ class WecomAccountControllerTest {
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device()));
org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper));
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper), org.mockito.Mockito.mock(PagePermissionService.class));
}
/**
......
package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
/** 文件用途(白话):验证设备图片的格式、大小、路径约束和失败清理,不依赖真实上传目录。 */
class DeviceAssetFileStorageServiceTest {
/** 代码作用(白话):验证合法 PNG 会存为不透明标识,且失败清理后不能再次读取。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):图片 -> store -> 标识 -> cleanupNewFile -> 受控读取失败。 */
@Test void storesAndCleansOnlyNewImage(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String identifier=storage.store(new MockMultipartFile("image","one.png","image/png",png()));
assertTrue(Files.exists(directory.resolve(identifier)));storage.cleanupNewFile(identifier);assertThrows(DeviceAssetValidationException.class,()->storage.resolve(identifier));
}
/** 代码作用(白话):验证允许的 JPG、PNG、GIF 都会经过真实图片解码后保存。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):三种 MultipartFile -> validateImage -> store -> 受控文件目录。*/
@Test void storesEverySupportedImageFormat(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
assertTrue(Files.exists(directory.resolve(storage.store(new MockMultipartFile("image","one.jpg","image/jpeg",image("jpg"))))));
assertTrue(Files.exists(directory.resolve(storage.store(new MockMultipartFile("image","two.png","image/png",image("png"))))));
assertTrue(Files.exists(directory.resolve(storage.store(new MockMultipartFile("image","three.gif","image/gif",image("gif"))))));
}
/** 代码作用(白话):验证损坏、超限和不支持类型会在保存前被拒绝。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):multipart 文件 -> validateImage -> 400 业务异常。 */
@Test void rejectsCorruptedOversizedAndUnsupportedImages(@TempDir Path directory) {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.png","image/png",new byte[]{1,2,3})));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.txt","text/plain",png())));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","large.png","image/png",new byte[20*1024*1024+1])));
}
/** 代码作用(白话):验证文件标识不能离开设备图片根目录。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL 标识 -> resolve -> 根目录校验。 */
@Test void rejectsPathTraversalIdentifier(@TempDir Path directory) { DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.resolve("../secret.png")); }
/** 代码作用(白话):在测试中生成 ImageIO 必然可解码的 PNG 字节。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> PNG 字节 -> MockMultipartFile -> 服务校验。 */
private byte[] png() { try { ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
/** 代码作用(白话):按指定格式生成可被 ImageIO 解码的小图片,避免测试数据本身失真。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> 指定格式字节 -> MockMultipartFile -> 上传格式校验。*/
private byte[] image(String format) { try { ByteArrayOutputStream output=new ByteArrayOutputStream();if(!ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_RGB),format,output)){throw new IllegalStateException("测试运行环境不支持图片格式:"+format);}return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
}
/** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js、DeviceAssetView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */
export default { template: `<div class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink to="/overview">总览</RouterLink><RouterLink to="/domain">域名资料</RouterLink><RouterLink to="/reference/wecom">企微资料</RouterLink><RouterLink to="/phone-assets">手机号资产</RouterLink><RouterLink to="/device-assets">设备资产管理</RouterLink><RouterLink to="/alerts">提醒中心</RouterLink></nav></aside><main class="content"><RouterView /></main></div>` };
import { authState, signOut } from './modules/auth/auth-store.js';
/** 代码作用(白话):提供已登录后台外壳,让登录路由不显示侧栏。关联文件:router/index.js、LoginView.js。关联逻辑(调用链/数据流):当前 Hash 路由 -> 登录页或菜单壳 -> RouterView。 */
export default { setup() { /** 代码作用(白话):退出当前会话并无论后端 Cookie 清理是否报错都跳回登录路由。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):退出按钮 -> POST logout -> 清空前端身份 -> finally 跳转 /login。 */ async function logout() { try { await signOut(); } finally { window.location.hash = '#/login'; } } /** 代码作用(白话):判断当前用户能否看见某个受控菜单,编辑权限自然包含只读菜单;关联文件:router/index.js、PagePermissionService.java。关联逻辑(调用链/数据流):/api/auth/me 权限 -> 侧栏菜单 -> 路由守卫。 */ function canVisit(page) { return authState.user?.pagePermissions?.[page] && authState.user.pagePermissions[page] !== 'NONE'; } /** 代码作用(白话):判断当前用户是否可以使用设置下的账号与权限功能;关联文件:UserPermissionView.js、SystemUserAdminService.java。关联逻辑(调用链/数据流):角色码 -> 菜单展示 -> 后端二次授权。 */ function isAdministrator() { return ['DEVELOPER', 'SUPER_ADMIN'].includes(authState.user?.roleCode); } return { authState, logout, canVisit, isAdministrator }; }, template: `<RouterView v-if="$route.path === '/login'" /><div v-else class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink v-if="canVisit('overview')" to="/overview">总览</RouterLink><RouterLink v-if="canVisit('domain')" to="/domain">域名资料</RouterLink><RouterLink v-if="canVisit('reference-wecom')" to="/reference/wecom">企微资料</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号资产</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>` };
import { reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { signIn } from './auth-store.js';
/** 代码作用(白话):提供唯一的账号密码登录界面,并只在 Jeddy 成功登录的当次显示专属欢迎提示。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> Auth API -> Cookie/用户状态 -> 路由跳转。 */
export default { setup() { const form = reactive({ username: '', password: '' }); const submitting = ref(false); /** 代码作用(白话):提交登录并清空内存中的密码字段,避免页面长期保留密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 -> signIn -> Jeddy 提示/目标路由。 */ async function submit() { submitting.value = true; try { const user = await signIn(form); form.password = ''; if (user.username === 'Jeddy' && user.roleCode === 'DEVELOPER') ElMessage.success('🎉 欢迎系统开发者-BOSS:Jeddy 上线'); window.location.hash = '#/overview'; } catch (_) { ElMessage.error('账号或密码错误,请重试'); } finally { submitting.value = false; } } return { form, submitting, submit }; }, template: `<main class="login-page"><section class="login-card" aria-labelledby="login-title"><p class="eyebrow">XYW CONSOLE</p><h1 id="login-title">登录资产后台</h1><p>请输入由开发者设置的账号和密码。</p><el-form @submit.prevent="submit"><el-form-item label="用户名"><el-input v-model="form.username" autocomplete="username" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item><el-button type="primary" native-type="submit" :loading="submitting" class="login-submit">登录</el-button></el-form></section></main>` };
let csrfReady = false;
/** 代码作用(白话):读取同站 Cookie 中的 CSRF 值,供会改变数据的请求放入校验头;关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):csrf 接口写 Cookie -> 此方法读取 Cookie -> POST/PUT/DELETE 请求头。 */
function csrfHeader() { const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/); return match ? { 'X-XSRF-TOKEN': decodeURIComponent(match[1]) } : {}; }
/** 代码作用(白话):首次写操作前请求 CSRF Cookie,登录接口已由后端单独豁免。关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):前端写请求 -> /api/auth/csrf -> XSRF-TOKEN Cookie -> 安全层校验。 */
export async function csrfHeadersFor(path, method) { if (method === 'GET' || path === '/api/auth/login' || csrfReady) return csrfHeader(); const response = await fetch('/api/auth/csrf', { credentials: 'include' }); if (!response.ok) throw new Error('安全校验初始化失败'); csrfReady = true; return csrfHeader(); }
/** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */
export async function request(path, options = {}) { const method = (options.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrf, ...(options.headers || {}) }, ...options }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '请求失败'); return payload.data; }
/** 代码作用(白话):提交账号密码以建立 HttpOnly 会话。关联文件:LoginView.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> POST login -> Set-Cookie -> 当前用户。 */
export function login(credentials) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(credentials) }); }
/** 代码作用(白话):恢复浏览器已有会话并获取菜单权限。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):应用启动 -> GET me -> 路由/菜单状态。 */
export function getCurrentUser() { return request('/api/auth/me'); }
/** 代码作用(白话):请求后端清除当前浏览器的登录会话。关联文件:App.js、AuthController.java。关联逻辑(调用链/数据流):登出按钮 -> POST logout -> Cookie 过期 -> 登录页。 */
export function logout() { return request('/api/auth/logout', { method: 'POST' }); }
import { reactive } from 'vue/dist/vue.esm-bundler.js';
import { getCurrentUser, login, logout } from './auth-api-client.js';
/** 代码作用(白话):保存内存中的安全用户资料,页面刷新后只从后端 Cookie 恢复而不写入浏览器存储。关联文件:router/index.js、App.js。关联逻辑(调用链/数据流):Auth API -> authState -> 路由守卫/菜单。 */
export const authState = reactive({ ready: false, user: null });
/** 代码作用(白话):向后端恢复一次当前会话,失败时清除内存身份。关联文件:auth-api-client.js。关联逻辑(调用链/数据流):路由守卫 -> /api/auth/me -> authState。 */
export async function bootstrapAuth() { try { authState.user = await getCurrentUser(); } catch (_) { authState.user = null; } finally { authState.ready = true; } return authState.user; }
/** 代码作用(白话):完成登录并保存安全用户资料供当前页面使用。关联文件:LoginView.js。关联逻辑(调用链/数据流):表单 -> login API -> authState -> 跳转。 */
export async function signIn(credentials) { authState.user = await login(credentials); authState.ready = true; return authState.user; }
/** 代码作用(白话):尝试登出并无论结果都清空本地身份。关联文件:App.js。关联逻辑(调用链/数据流):登出 -> API -> authState 清空 -> 登录路由。 */
export async function signOut() { try { await logout(); } finally { authState.user = null; authState.ready = true; } }
import { nextTick, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { computed, nextTick, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage, ElMessageBox } from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn.mjs';
import { authState } from '../auth/auth-store.js';
import { createPhoneAsset, deletePhoneAsset, listPhoneAssets, updatePhoneAsset } from './phone-api-client.js';
/**
......@@ -22,6 +23,8 @@ export default {
const total = ref(0);
const dialogVisible = ref(false);
const editingId = ref(null);
/** 代码作用(白话):把当前手机号资产权限转换为是否展示写操作;关联文件:auth-store.js、PhoneAssetController.java。关联逻辑(调用链/数据流):/api/auth/me -> EDIT 判断 -> 新增/编辑/删除控件 -> 后端二次校验。 */
const canEdit = computed(() => authState.user?.pagePermissions?.['phone-assets'] === 'EDIT');
const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: 'ALL' });
let searchTimer = null;
let latestRequest = 0;
......@@ -257,6 +260,7 @@ export default {
changePage,
changePageSize,
changeStatus,
canEdit,
confirmDelete,
dialogVisible,
editingId,
......@@ -285,9 +289,9 @@ export default {
},
template: `
<el-config-provider :locale="elementLocale"><section class="phone-asset-page phone-asset-list-page">
<header class="phone-asset-list-page__header"><h2>手机号资产</h2><el-button class="phone-asset-list-page__add" type="primary" @click="openCreate">新增手机号资产</el-button></header>
<header class="phone-asset-list-page__header"><h2>手机号资产</h2><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增手机号资产</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search" aria-label="筛选手机号资产"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.phoneNumber" maxlength="11" inputmode="numeric" placeholder="手机号前3位、后4位或完整号码" @input="limitSearchPhone" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.iccid" maxlength="20" placeholder="请输入 ICCID" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.realNameOwner" placeholder="请输入实名人" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-select v-model="filters.disposalStatus" placeholder="使用状态:" clearable @change="changeStatus" @clear="restoreAllDisposalStatuses"><template #prefix>使用状态:</template><el-option label="全部" value="ALL" /><el-option label="正常" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="注销" value="已注销" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="phoneNumber" label="手机号" min-width="150" show-overflow-tooltip /><el-table-column label="号码类型" min-width="120"><template #default="{ row }"><a v-if="row.numberType === 'EXTERNAL' && row.sourceAssetType === 'WECOM'" class="phone-asset-list-page__external-link" :href="'#/reference/wecom?phoneAssetId=' + row.id">外部号码</a><span v-else>{{ row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码' }}</span></template></el-table-column><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="220" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><el-table-column prop="managementType" label="管理模式" min-width="120" show-overflow-tooltip /><el-table-column label="使用状态" min-width="130"><template #default="{ row }"><span class="phone-asset-list-page__status"><i :class="['phone-asset-list-page__status-dot', row.disposalStatus]"></i>{{ formatDisposalStatus(row.disposalStatus) }}</span></template></el-table-column><el-table-column prop="deviceId" label="关联设备(ID)" min-width="150" show-overflow-tooltip /><el-table-column label="操作" width="120"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="sizes, prev, pager, next, jumper" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize" /></footer></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="phoneNumber" label="手机号" min-width="150" show-overflow-tooltip /><el-table-column label="号码类型" min-width="120"><template #default="{ row }"><a v-if="row.numberType === 'EXTERNAL' && row.sourceAssetType === 'WECOM'" class="phone-asset-list-page__external-link" :href="'#/reference/wecom?phoneAssetId=' + row.id">外部号码</a><span v-else>{{ row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码' }}</span></template></el-table-column><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="220" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><el-table-column prop="managementType" label="管理模式" min-width="120" show-overflow-tooltip /><el-table-column label="使用状态" min-width="130"><template #default="{ row }"><span class="phone-asset-list-page__status"><i :class="['phone-asset-list-page__status-dot', row.disposalStatus]"></i>{{ formatDisposalStatus(row.disposalStatus) }}</span></template></el-table-column><el-table-column prop="deviceId" label="关联设备(ID)" min-width="150" show-overflow-tooltip /><el-table-column v-if="canEdit" label="操作" width="120"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="sizes, prev, pager, next, jumper" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize" /></footer></section>
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll">
<el-form class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required>
......
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/**
* 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字。
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误。
*/
async function request(path, options = {}) {
const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
const headers = await csrfHeadersFor(path, (options.method || 'GET').toUpperCase());
const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers }, ...options });
const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败');
return payload.data;
......
import { computed, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createSystemUser, listSystemUsers, resetSystemUserPassword, updateSystemUser } from './system-user-api-client.js';
const pages = [{ key: 'overview', label: '总览' }, { key: 'domain', label: '域名资料' }, { key: 'reference-wecom', label: '企微资料' }, { key: 'phone-assets', label: '手机号资产' }, { key: 'alerts', label: '提醒中心' }];
const roles = [{ value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'FINANCE', label: '财务' }, { value: 'HR', label: '人事' }, { value: 'OPERATIONS', label: '运营' }];
/** 代码作用(白话):创建五页均无权限的编辑表单初始值;关联文件:PagePermissionService.java、UserPermissionView.js。关联逻辑(调用链/数据流):新增/编辑打开 -> 本函数 -> 表单权限单选 -> JSON 提交。 */
function blankForm() { return { username: '', roleCode: 'FINANCE', status: 'ACTIVE', password: '', pagePermissions: Object.fromEntries(pages.map(page => [page.key, 'NONE'])) }; }
/** 代码作用(白话):提供账号创建、角色编辑、密码重置和逐页权限面板;关联文件:system-user-api-client.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):设置路由 -> 页面操作 -> 管理 API -> 刷新列表。 */
export default { setup() { const users = ref([]); const loading = ref(false); const dialogOpen = ref(false); const passwordOpen = ref(false); const editingId = ref(null); const target = ref(null); const form = reactive(blankForm()); const password = ref(''); const isDeveloper = computed(() => authState.user?.roleCode === 'DEVELOPER'); const isAdministratorRole = computed(() => form.roleCode === 'SUPER_ADMIN');
/** 代码作用(白话):读取当前可管理账号列表,后端已过滤固定开发者 Jeddy;关联文件:system-user-api-client.js、SystemUserAdminService.java。关联逻辑(调用链/数据流):页面进入/保存后 -> GET users -> 表格。 */
async function load() { loading.value = true; try { users.value = await listSystemUsers(); } catch (error) { ElMessage.error(error.message || '账号列表加载失败'); } finally { loading.value = false; } }
/** 代码作用(白话):打开新增账号面板,超级管理员不会看到密码输入;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):新增按钮 -> 空表单 -> 创建提交。 */
function openCreate() { Object.assign(form, blankForm()); editingId.value = null; dialogOpen.value = true; }
/** 代码作用(白话):把选中账号复制到编辑面板,避免直接改动表格对象;关联文件:SystemUserResponse.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):编辑按钮 -> 本地副本 -> PUT 更新。 */
function openEdit(user) { Object.assign(form, blankForm(), { username: user.username, roleCode: user.roleCode, status: user.status, pagePermissions: { ...user.pagePermissions } }); editingId.value = user.id; dialogOpen.value = true; }
/** 代码作用(白话):创建或更新账号,并把超级管理员创建的账号交给后端固定为禁用无密码;关联文件:SystemUserAdminService.java、system-user-api-client.js。关联逻辑(调用链/数据流):表单 -> POST/PUT -> 用户表 -> 列表刷新。 */
async function save() { try { if (editingId.value) await updateSystemUser(editingId.value, { roleCode: form.roleCode, status: form.status, pagePermissions: form.pagePermissions }); else await createSystemUser({ username: form.username, roleCode: form.roleCode, password: isDeveloper.value ? form.password : null, pagePermissions: form.pagePermissions }); ElMessage.success(editingId.value ? '账号已更新' : '账号已创建'); dialogOpen.value = false; await load(); } catch (error) { ElMessage.error(error.message || '保存失败'); } }
/** 代码作用(白话):打开仅开发者可见的密码设置面板;关联文件:SystemUserAdminController.java、PasswordResetRequest.java。关联逻辑(调用链/数据流):开发者点击设置密码 -> 输入暂存 -> 安全接口。 */
function openPassword(user) { target.value = user; password.value = ''; passwordOpen.value = true; }
/** 代码作用(白话):把新密码提交给后端并立即从页面内存清除;关联文件:system-user-api-client.js、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):新密码 -> BCrypt -> 数据库触发器 -> 旧会话失效。 */
async function savePassword() { try { await resetSystemUserPassword(target.value.id, password.value); password.value = ''; passwordOpen.value = false; ElMessage.success('密码已更新,旧会话已失效'); await load(); } catch (error) { ElMessage.error(error.message || '密码更新失败'); } }
load(); return { users, loading, dialogOpen, passwordOpen, form, pages, roles, editingId, isDeveloper, isAdministratorRole, target, password, load, openCreate, openEdit, save, openPassword, savePassword }; }, template: `<section class="user-permission-page"><header class="page-header"><div><p class="eyebrow">设置 / 账号与权限</p><h2>账号与权限</h2><p>开发者账号 Jeddy 不会在此页面显示;逐页权限由此面板独立配置。</p></div><el-button type="primary" @click="openCreate">新增账号</el-button></header><section class="reference-card"><el-table :data="users" v-loading="loading"><el-table-column prop="username" label="用户名" /><el-table-column prop="roleCode" label="角色" /><el-table-column prop="status" label="状态" /><el-table-column label="操作" width="210"><template #default="scope"><el-button link @click="openEdit(scope.row)">编辑权限</el-button><el-button v-if="isDeveloper" link type="primary" @click="openPassword(scope.row)">设置密码</el-button></template></el-table-column></el-table></section><el-dialog v-model="dialogOpen" :title="editingId ? '编辑账号与权限' : '新增账号'" width="680px"><el-form label-width="112px"><el-form-item label="用户名" v-if="!editingId"><el-input v-model="form.username" /></el-form-item><el-form-item label="角色"><el-select v-model="form.roleCode"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item><el-form-item label="账号状态" v-if="editingId"><el-select v-model="form.status"><el-option label="启用" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item><el-form-item label="初始密码" v-if="!editingId && isDeveloper"><el-input v-model="form.password" type="password" show-password /><small>12-72 位,且不能包含用户名。</small></el-form-item><el-alert v-if="!editingId && !isDeveloper" title="超级管理员创建的账号会保持禁用且不设密码,需由开发者设置密码后再启用。" type="info" :closable="false" /><el-divider>页面权限</el-divider><div v-if="isAdministratorRole" class="permission-hint">超级管理员固定拥有全部页面的编辑权限。</div><el-form-item v-for="page in pages" :key="page.key" :label="page.label"><el-radio-group v-model="form.pagePermissions[page.key]" :disabled="isAdministratorRole"><el-radio value="NONE">无权限</el-radio><el-radio value="READ">只读</el-radio><el-radio value="EDIT">编辑</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogOpen=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog><el-dialog v-model="passwordOpen" title="设置账号密码" width="420px"><p>仅开发者可以设置密码:{{ target?.username }}</p><el-input v-model="password" type="password" show-password placeholder="12-72 位且不含用户名" /><template #footer><el-button @click="passwordOpen=false">取消</el-button><el-button type="primary" @click="savePassword">确认设置</el-button></template></el-dialog></section>` };
import { request } from '../auth/auth-api-client.js';
/** 代码作用(白话):集中发送账号管理请求,并复用认证模块的 Cookie 与 CSRF 保护;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):设置页面操作 -> request -> 账号管理 API -> 安全响应。 */
export function listSystemUsers() { return request('/api/system-users'); }
/** 代码作用(白话):提交创建账号资料;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):新增账号面板 -> POST -> as_system_user。 */
export function createSystemUser(form) { return request('/api/system-users', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交角色、启停和逐页权限修改;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):编辑面板 -> PUT -> as_system_user。 */
export function updateSystemUser(id, form) { return request(`/api/system-users/${id}`, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):仅供开发者提交非开发者账号的新密码;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):密码面板 -> PUT password -> BCrypt/数据库触发器。 */
export function resetSystemUserPassword(id, password) { return request(`/api/system-users/${id}/password`, { method: 'PUT', body: JSON.stringify({ password }) }); }
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { computed, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchPhoneAssets } from './wecom-api-client.js';
/** File purpose (plain language): renders the enterprise WeChat asset list and its creation dialog with reusable asset searches. */
......@@ -13,6 +14,8 @@ export default {
const total = ref(0);
const companyOptions = ref([]);
const ownerOptions = ref([]);
/** 代码作用(白话):把企微页面的有效权限转换为新增按钮和弹窗是否可用;关联文件:auth-store.js、WecomAccountController.java。关联逻辑(调用链/数据流):认证资料 -> EDIT 判断 -> 写操作控件 -> 后端 EDIT 校验。 */
const canEdit = computed(() => authState.user?.pagePermissions?.['reference-wecom'] === 'EDIT');
let searchTimer;
const filters = reactive({ page: 1, size: 20, keyword: '', wecomAccount: '', phoneAssetId: new URLSearchParams(window.location.hash.split('?')[1] || '').get('phoneAssetId') || '', companyProfileId: 'ALL', realNameOwnerStatus: 'ALL' });
const form = reactive({ wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null });
......@@ -69,11 +72,11 @@ export default {
function formatRelation(name, id) { return id === null || id === undefined ? '—' : `${name || '—'}(ID:${id})`; }
onMounted(loadPage);
return { changePage, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
return { canEdit, changePage, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
},
template: `
<section class="phone-asset-list-page wecom-account-page">
<header class="phone-asset-list-page__header"><div><h2>企业微信资产</h2><p class="wecom-account-page__eyebrow">WECOM ACCOUNTS</p></div><el-button class="phone-asset-list-page__add" type="primary" @click="openCreate">新增企业微信资产</el-button></header>
<header class="phone-asset-list-page__header"><div><h2>企业微信资产</h2><p class="wecom-account-page__eyebrow">WECOM ACCOUNTS</p></div><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增企业微信资产</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="企微名称或手机号" clearable @input="scheduleSearch" @clear="scheduleSearch" /><el-select v-model="filters.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="注册主体:" @change="submitSearch" @clear="restoreAllCompanyProfiles"><template #prefix>注册主体:</template><el-option label="全部" value="ALL" /><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select><el-select v-model="filters.realNameOwnerStatus" clearable placeholder="实名状态:" @change="submitSearch" @clear="restoreAllRealNameStatuses"><template #prefix>实名状态:</template><el-option label="全部" value="ALL" /><el-option label="在职" value="在职" /><el-option label="离职" value="离职" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid wecom-account-page__grid"><el-table-column prop="id" label="企业微信资产 ID" min-width="140" /><el-table-column prop="wecomName" label="企微名称" min-width="150" show-overflow-tooltip /><el-table-column prop="wecomAlias" label="企微别名" min-width="180" show-overflow-tooltip /><el-table-column prop="wecomAccount" label="企微账号" min-width="160" show-overflow-tooltip /><el-table-column label="注册主体" min-width="180"><template #default="{ row }">{{ formatRelation(row.companyProfileName, row.companyProfileId) }}</template></el-table-column><el-table-column label="注册手机号" min-width="180"><template #default="{ row }">{{ formatRelation(row.phoneNumber, row.phoneAssetId) }}</template></el-table-column><el-table-column label="关联方式" min-width="120"><template #default="{ row }">{{ row.phoneLinkMode === 'CREATED' ? '新建号码' : '已有号码' }}</template></el-table-column><el-table-column prop="realNameOwner" label="实名人" min-width="120" /><el-table-column prop="realNameOwnerStatus" label="实名状态" min-width="110" /><el-table-column prop="gender" label="性别" min-width="90" /><el-table-column label="企微号归属人" min-width="180"><template #default="{ row }">{{ formatRelation(row.operatorPersonName, row.operatorPersonId) }}</template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="prev, pager, next" :current-page="filters.page" :page-size="filters.size" :total="total" @current-change="changePage" /></footer></section>
<el-dialog v-model="dialogVisible" title="新增企业微信资产" width="640px" :close-on-click-modal="false"><el-form label-position="top" @submit.prevent="submitCreate"><el-row :gutter="16"><el-col :span="12"><el-form-item label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item></el-col><el-col :span="12"><el-form-item label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item></el-col><el-col :span="12"><el-form-item label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item></el-col><el-col :span="12"><el-form-item label="注册手机号" required><el-autocomplete v-model="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" placeholder="输入或选择手机号" style="width:100%" /></el-form-item></el-col><el-col :span="12"><el-form-item label="注册主体"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="企微号归属人"><el-select v-model="form.operatorPersonId" filterable remote clearable :remote-method="loadOwners" placeholder="输入人员姓名" style="width:100%"><el-option v-for="item in ownerOptions" :key="item.id" :label="item.personName" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="实名人"><el-input v-model="form.realNameOwner" /></el-form-item></el-col><el-col :span="12"><el-form-item label="实名状态"><el-radio-group v-model="form.realNameOwnerStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item></el-col><el-col :span="12"><el-form-item label="性别"><el-radio-group v-model="form.gender"><el-radio value="男">男</el-radio><el-radio value="女">女</el-radio></el-radio-group></el-form-item></el-col></el-row></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreate">保存</el-button></template></el-dialog>
......
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/** File purpose (plain language): centralizes enterprise WeChat asset requests and turns API envelopes into usable data. */
/** Code purpose (plain language): sends an API request and exposes either its data or readable error. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: view action -> request -> ApiResponse -> view state. */
async function request(path, options = {}) {
const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
const headers = await csrfHeadersFor(path, (options.method || 'GET').toUpperCase());
const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers }, ...options });
const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '企业微信资产请求失败');
return payload.data;
......
......@@ -3,6 +3,9 @@ import LegacyReferenceView from '../reference/LegacyReferenceView.js';
import PhoneAssetView from '../modules/phone/PhoneAssetView.js';
import WecomAccountView from '../modules/wecom/WecomAccountView.js';
import DeviceAssetView from '../modules/device/DeviceAssetView.js';
import LoginView from '../modules/auth/LoginView.js';
import UserPermissionView from '../modules/system-user/UserPermissionView.js';
import { authState, bootstrapAuth } from '../modules/auth/auth-store.js';
/**
* 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。
......@@ -22,14 +25,20 @@ const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', redirect: '/overview' },
{ path: '/overview', component: createPlaceholderView('资产总览') },
{ path: '/phone-assets', component: PhoneAssetView },
{ path: '/device-assets', component: DeviceAssetView },
{ path: '/domain', component: createPlaceholderView('域名资料') },
{ path: '/alerts', component: createPlaceholderView('提醒中心') },
{ path: '/reference/wecom', component: WecomAccountView },
{ path: '/login', component: LoginView, meta: { public: true } },
{ path: '/overview', component: createPlaceholderView('资产总览'), meta: { page: 'overview' } },
{ path: '/phone-assets', component: PhoneAssetView, meta: { page: 'phone-assets' } },
{ path: '/device-assets', component: DeviceAssetView, meta: { administratorOnly: true } },
{ path: '/domain', component: createPlaceholderView('域名资料'), meta: { page: 'domain' } },
{ path: '/alerts', component: createPlaceholderView('提醒中心'), meta: { page: 'alerts' } },
{ path: '/reference/wecom', component: WecomAccountView, meta: { page: 'reference-wecom' } },
{ path: '/settings/users-permissions', component: UserPermissionView, meta: { administratorOnly: true } },
{ path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } }
]
});
/** 代码作用(白话):在进入业务路由前恢复 Cookie 会话,未登录用户一律进入新建的登录路由。关联文件:auth-store.js、LoginView.js。关联逻辑(调用链/数据流):Hash 地址 -> 路由守卫 -> /api/auth/me -> 业务页或 /login。 */
/** 代码作用(白话):在已登录后继续按逐页权限和管理角色校验目标路由,避免只隐藏菜单就能手输地址绕过;关联文件:App.js、PagePermissionService.java。关联逻辑(调用链/数据流):路由地址 -> authState 有效权限 -> 放行或返回总览。 */
router.beforeEach(async to => { if (!authState.ready) await bootstrapAuth(); if (to.meta.public) return authState.user ? '/overview' : true; if (!authState.user) return '/login'; const isAdministrator = ['DEVELOPER', 'SUPER_ADMIN'].includes(authState.user.roleCode); if (to.meta.administratorOnly && !isAdministrator) return '/overview'; if (to.meta.page && authState.user.pagePermissions?.[to.meta.page] === 'NONE' && to.path !== '/overview') return '/overview'; return true; });
export default router;
:root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: linear-gradient(135deg, #132338, #28537d); }
.login-card { width: min(420px, 100%); padding: 32px; border-radius: 16px; background: #fff; box-shadow: 0 24px 70px rgba(0,0,0,.24); }
.login-card h1 { margin: 0 0 8px; }.login-card > p:not(.eyebrow) { margin: 0 0 24px; color: #64748b; }.login-submit { width: 100%; }.logout-button { width: 100%; margin-top: 24px; padding: 10px 12px; border: 1px solid #496b8c; border-radius: 8px; color: #cbd5e1; background: transparent; cursor: pointer; }.logout-button:hover { color:#fff; background:#1f3b59; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); }
.sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; }
.sidebar h1 { margin: 0 0 32px; font-size: 20px; }
.sidebar nav { display: grid; gap: 8px; }
.sidebar a { border-radius: 8px; color: #cbd5e1; padding: 10px 12px; text-decoration: none; }
.sidebar a.router-link-active, .sidebar a:hover { background: #1f3b59; color: white; }
.sidebar .settings-link { margin-top: 16px; border-top: 1px solid #385673; border-radius: 0; padding-top: 18px; }
.content { min-width: 0; padding: 40px; }
.user-permission-page { max-width: 1180px; margin: 0 auto; }.user-permission-page small { display:block; margin-top:6px; color:#64748b; }.permission-hint { margin:-4px 0 16px; color:#64748b; font-size:13px; }
.eyebrow { margin: 0 0 8px; color: #5f8fcb; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.reference-page, .placeholder { max-width: 1180px; margin: 0 auto; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
......
import { expect, test as base } from '@playwright/test';
/** File purpose (plain language): supplies the same developer login fixture to every asset browser test without changing production authentication code. */
export const test = base;
/** Plain purpose: register deterministic authentication responses before a test opens a protected route. Related files: auth-store.js, router/index.js, and all files in frontend/tests. Data flow: test setup -> mocked current-user/CSRF requests -> route guard -> protected page. */
test.beforeEach(async ({ page }) => {
const user = {
id: 1,
username: 'playwright-asset-admin',
roleCode: 'DEVELOPER',
pagePermissions: {
overview: 'EDIT',
domain: 'EDIT',
'reference-wecom': 'EDIT',
'phone-assets': 'EDIT',
alerts: 'EDIT'
}
};
/** Plain purpose: return an administrator identity to the route guard. Related files: auth-store.js and router/index.js. Data flow: GET /api/auth/me -> mocked envelope -> auth state -> allowed route. */
await page.route('**/api/auth/me', async route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: 'success', data: user })
}));
/** Plain purpose: make protected test requests receive a successful CSRF preflight. Related files: api-client.js and all asset API clients. Data flow: API client -> CSRF request -> mocked success -> test-owned business request mock. */
await page.route('**/api/auth/csrf', async route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: 'success', data: null })
}));
});
export { expect };
import { expect, test } from '@playwright/test';
import { expect, test } from './authenticated-test.js';
const referencePages = [
{ path: '#/reference/phone', title: '手机号卡旧界面参考' }
......
import { expect, test } from '@playwright/test';
import { expect, test } from './authenticated-test.js';
/**
* 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表。
......
import { expect, test } from '@playwright/test';
import { expect, test } from './authenticated-test.js';
/** File purpose (plain language): checks the enterprise WeChat page's create flow, external-number navigation, and phone filter. */
test('creates an enterprise WeChat asset and exposes the required form', async ({ page }) => {
......
## Context
The project already maps `as_asset_device` through `AssetDeviceEntity` and `AssetDeviceMapper`, but it has no device Controller, Service, DTOs, page, image upload path, route, or menu. The table keeps a device name, two optional image attachment fields, a logical `user_person_id`, two text status fields, and the common `delete_time` soft-delete marker.
Enterprise WeChat asset work is currently in progress in the same repository. Its files are already dirty, including the enterprise WeChat Controller, Service, DTOs, frontend module, CSS, and tests. Device management must therefore be independently buildable and defer the two shared frontend registrations until final integration.
There is no existing upload service. This change stores original image files locally and records controlled, application-relative file identifiers in `image_attachment_1` and `image_attachment_2`. The browser renders a fixed-size preview of the original image; it does not generate or store a second thumbnail file.
## Goals / Non-Goals
**Goals:**
- Provide a complete, safe device-asset CRUD workflow over existing `as_asset_device` rows.
- Support up to two JPG, PNG, or GIF images per device, with a 20 MB maximum for each image.
- Provide searchable company-person selection and readable person names in the list response.
- Keep active device names unique, apply approved dropdown values, and protect referenced devices from deletion.
- Add a dedicated Device Asset Management page, route, and menu without changing enterprise WeChat business behavior.
- Allow parallel implementation by isolating device work to new files and assigning final shared-file integration to one owner.
**Non-Goals:**
- Do not modify database schema, run migrations, or directly operate the database.
- Do not add device selection to the enterprise WeChat creation form; that is a later change.
- Do not generate physical thumbnail files, convert image formats, or add an object-storage dependency.
- Do not permanently remove original image files during normal device soft deletion.
- Do not introduce authentication or a general-purpose file-management module.
## Decisions
### Device API and data contract
Use `/api/device-assets` for list, creation, update, and deletion. `GET` accepts `page`, `size`, `deviceName`, `userPersonId`, `userUsageStatus`, and `assetRelationStatus`; it filters `delete_time = 0` and orders by descending ID. `POST` and `PUT /{id}` accept multipart form data so device fields and both optional image files are saved together. `DELETE /{id}` performs a soft deletion.
The list and detail response return `id`, device fields, original-image access URLs, `userPersonId`, `userPersonName`, and audit times, but never return `deleteTime` or the physical storage path. This follows the current asset API envelope and pagination shape.
### Validation and reference handling
`deviceName` is required and is unique among active records. Save operations reject values outside the fixed approved status lists. A selected `userPersonId` must resolve to a non-deleted company person; it can be omitted.
Before a device is soft-deleted, the service checks active `as_phone_asset`, `as_wecom_account`, `as_wechat_account`, and `as_douyin_account` rows for the device ID. If any exists, deletion fails with a readable list of reference sources. This preserves logical association integrity because the database intentionally has no foreign keys.
### Image storage and access
The server validates extension and image content for JPG, PNG, and GIF and limits each file to 20 MB. It saves each original file beneath a configurable local root, defaulting to `./uploads/device-assets`, with a generated opaque file identifier. Attachment database columns store only that identifier.
An application endpoint resolves an identifier to a file only after constraining it to the upload root; response DTOs expose the endpoint URL rather than a disk path. On update, omitted image parts preserve the current image; an explicit remove flag clears its database reference. A successful replacement saves the new file before updating the row, while failed requests clean up newly written temporary files. Soft deletion keeps files for recoverability.
Browser-sized preview was selected over server-generated thumbnail files because it adds no image-processing dependency or duplicate storage. The trade-off is that a list may download larger original files; lazy image loading will reduce initial page work.
### Frontend behavior
`DeviceAssetView` provides filters, a paged table, a create/edit dialog, remote company-person search, the two status dropdowns, two image selectors, inline browser previews, and a delete confirmation. Each slot supports retain, replace, or explicit removal during edit. The page sets `loading` and `saving` states and surfaces API errors through the existing Element Plus message pattern.
Use a dedicated `device-asset.css`, imported by the device module, rather than editing the currently modified shared stylesheet. The final integration adds the `设备资产管理` navigation item and maps `#/device-assets` to the new view.
### Parallel ownership and integration
Device work owns only newly added `DeviceAsset*` backend files, device frontend module files, device CSS, and device tests. It reuses but does not edit `AssetDeviceEntity`, `AssetDeviceMapper`, or enterprise WeChat code. Enterprise WeChat work retains ownership of all current dirty files. A single integration owner changes `App.js` and `router/index.js` after both feature branches are ready.
This ownership model avoids merge conflicts. The device branch is developed in a separate Git worktree from the current dirty enterprise-WeChat worktree; final integration runs all relevant tests after both changes are present.
## Risks / Trade-offs
- [Original files can be 20 MB] → Lazy-load list previews, display a loading state, and never generate duplicate thumbnail files.
- [Local disk is not shared across multiple application instances] → Keep the upload root configurable and document that a future multi-instance deployment must move to shared/object storage.
- [Soft deletion retains image files] → Retention is deliberate for recovery; any physical cleanup must be a separately authorized maintenance workflow.
- [Database has no foreign keys] → Service-level active-reference checks block deletion, and tests cover each referencing asset type.
- [User-provided file extension can be misleading] → Validate both allowed type and decodable image content; serve files only through opaque identifiers constrained to the upload root.
- [Shared menu and router files are collision points] → Make their two-line integration a separately owned final commit only.
## Migration Plan
1. Create the device feature branch/worktree from the agreed base without resetting or stashing the current enterprise-WeChat working tree.
2. Implement and test only device-owned new files; do not change enterprise-WeChat files, shared CSS, or shared navigation during this phase.
3. Configure the multipart request maximum to accommodate two 20 MB files plus form metadata, without changing database schema.
4. Deploy with a writable device upload directory and verify that its access endpoint can read only files under the configured root.
5. Run device API tests, frontend build, device Playwright tests, then the existing phone and enterprise-WeChat smoke tests after final integration.
6. Roll back code by reverting the device feature and route/menu integration commits. Existing rows and retained files remain recoverable; no database rollback is required.
## Open Questions
None. The approved status values, per-image 20 MB limit, browser-sized previews, local storage, deletion protection, route, menu label, and deferred enterprise-WeChat selection are all fixed for this change.
## Why
`as_asset_device` already has an Entity and Mapper, but the asset console cannot browse, create, edit, upload images for, or safely delete device assets. Completing this closed management loop is needed before later work can let enterprise WeChat assets choose a device.
## What Changes
- Add a device-asset REST API for paged listing, creation, editing, image upload and protected soft deletion against `as_asset_device`.
- Add a Device Asset Management workspace at `#/device-assets`, including filters, paged table, creation/edit dialog, two-image upload and browser-sized thumbnail preview.
- Limit each uploaded JPG, PNG, or GIF image to 20 MB. Store only original files locally; the list renders a fixed-size browser preview and does not create a second thumbnail file.
- Add fixed dropdown options for user usage status (`使用中`, `闲置`, `维修中`, `停用`) and asset relation status (`已关联`, `未关联`, `待确认`).
- Resolve `user_person_id` to a readable company-person name and prohibit deletion while an active phone, enterprise WeChat, WeChat, or Douyin asset still references the device.
- Add the `设备资产管理` menu item and the `#/device-assets` route as a final, isolated integration change so this work can proceed in parallel with enterprise WeChat asset work.
## Capabilities
### New Capabilities
- `device-asset-api`: Provides safe device-asset CRUD, filtered pagination, image attachment handling, company-person lookup, and reference-protected deletion.
- `device-asset-workspace`: Provides the Device Asset Management Vue workspace, its filters, forms, image previews, and menu/route entry.
### Modified Capabilities
- None.
## Impact
- Backend: adds device-specific Controller, Service, request/response DTOs, local file-storage support, and API tests; reuses the existing `AssetDeviceEntity`, `AssetDeviceMapper`, `CompanyPersonMapper`, and referencing asset Mappers without changing database schema.
- Frontend: adds an isolated device module, API client, dedicated CSS, and Playwright coverage. Only the final integration changes `frontend/src/App.js` and `frontend/src/router/index.js`.
- API and files: introduces `/api/device-assets` CRUD, company-person lookup, and controlled image-file access endpoints. Images are stored under a configurable local directory, defaulting to `./uploads/device-assets`; each image is limited to 20 MB.
- Database: no DDL, migration, or direct database operation is included. Existing `delete_time` semantics remain the soft-delete mechanism.
## ADDED Requirements
### Requirement: Paged active device list
The system SHALL provide `GET /api/device-assets` with validated `page` and `size` parameters and optional `deviceName`, `userPersonId`, `userUsageStatus`, and `assetRelationStatus` filters. It MUST return only rows whose `delete_time` is `0`, ordered by descending ID, in the existing `records`, `total`, `page`, and `size` pagination shape.
#### Scenario: Filtered active-device page
- **WHEN** a user requests page 1 with `deviceName` and an approved status filter
- **THEN** the response contains only matching non-deleted device records and their pagination metadata
### Requirement: Readable device response and company-person lookup
The system SHALL return each device's ID, device name, two image access URLs when present, `userPersonId`, resolved `userPersonName`, both status fields, and creation and update times. It MUST NOT return `deleteTime` or a physical file path. The system SHALL provide a read-only company-person lookup endpoint that returns active matching person IDs and names for the device form.
#### Scenario: Missing or deleted device user
- **WHEN** a device has no user person or its referenced person is not active
- **THEN** the device record retains `userPersonId` when present and returns a null user-person name without failing the page
### Requirement: Device creation and update
The system SHALL provide multipart `POST /api/device-assets` and `PUT /api/device-assets/{id}` endpoints. Creation and update MUST require a nonblank device name, reject duplicate active device names, accept only the approved status values, and require an active company person when `userPersonId` is supplied. Update MUST preserve an existing image when no replacement or explicit removal is supplied.
#### Scenario: Create a device with valid approved values
- **WHEN** a user submits a unique device name, optional active user person, and approved usage and relation statuses
- **THEN** the system creates an active device record with creation and update timestamps and returns its readable response
#### Scenario: Reject invalid status or duplicate name
- **WHEN** a user submits an unsupported status value or a device name already used by an active device
- **THEN** the system rejects the request without writing a new or changed device row
### Requirement: Device image attachment handling
The system SHALL accept at most two optional device images, one for each attachment slot. Each file MUST be JPG, PNG, or GIF, MUST be decodable as that image type, and MUST not exceed 20 MB. The system MUST store original files under a configurable local root and expose them only through an opaque application file URL; it MUST NOT return local disk paths or create separate thumbnail files.
#### Scenario: Display image by browser-sized preview
- **WHEN** a device response contains an image access URL
- **THEN** the client can load the original image through the controlled URL and render it in a fixed-size preview without requesting a separately generated thumbnail
#### Scenario: Replace or remove an image while editing
- **WHEN** a user updates one image slot with a valid replacement or an explicit remove flag
- **THEN** the system respectively records the new opaque identifier or clears that slot while leaving the other slot unchanged
### Requirement: Reference-protected device deletion
The system SHALL provide `DELETE /api/device-assets/{id}` as a soft delete. Before deletion it MUST check active phone, enterprise WeChat, WeChat, and Douyin assets for the target device ID. If any active reference exists, it MUST reject deletion and identify the referencing asset types; otherwise it MUST update `delete_time` and `update_time` without physically deleting stored image files.
#### Scenario: Reject deletion of a referenced device
- **WHEN** an active phone or account asset references the requested device ID
- **THEN** the system returns a readable failure and the device remains active
#### Scenario: Soft-delete an unreferenced device
- **WHEN** no active supported asset references the requested device ID
- **THEN** the system marks the device deleted and it no longer appears in the device list
## ADDED Requirements
### Requirement: Device Asset Management route and menu
The frontend SHALL provide a Device Asset Management workspace at `#/device-assets` and a sidebar menu item labelled `设备资产管理` that opens it. The route and menu integration MUST preserve the existing enterprise-WeChat route and page behavior.
#### Scenario: Open Device Asset Management
- **WHEN** a user selects `设备资产管理` from the sidebar
- **THEN** the application navigates to `#/device-assets` and renders the device list workspace
### Requirement: Device list filters and pagination
The workspace SHALL render device name, user person, usage status, relation status, and audit-time columns with filters for name, user person, usage status, and relation status. It MUST expose pagination and show loading, empty, and request-error states.
#### Scenario: Reset a filtered device list
- **WHEN** a user clears the filters through the reset control
- **THEN** the workspace requests the first unfiltered page and displays its returned records
### Requirement: Device create and edit form
The workspace SHALL provide create and edit dialogs with a required device name, remote company-person selector, the approved usage-status dropdown, and the approved relation-status dropdown. It MUST prevent duplicate save submissions while a request is pending and show validation or API failures to the user.
#### Scenario: Submit a valid device form
- **WHEN** a user completes a valid create or edit dialog and selects save
- **THEN** the workspace submits multipart form data, closes the dialog after success, and refreshes the list
### Requirement: Two-image browser preview
The workspace SHALL allow each device form to select up to two JPG, PNG, or GIF files, each no larger than 20 MB. It MUST render fixed-size previews of existing and newly selected original images, allow an existing slot to be retained, replaced, or marked for removal, and allow a user to open the original image preview.
#### Scenario: Reject an oversized or unsupported image before save
- **WHEN** a user selects an image larger than 20 MB or outside the supported formats
- **THEN** the workspace displays an error and does not include that file in the save request
### Requirement: Protected delete interaction
The workspace SHALL require delete confirmation and refresh the list after a successful soft delete. If the API reports active references, it MUST display the returned reason and keep the device row visible.
#### Scenario: Attempt to delete a referenced device
- **WHEN** a user confirms deletion of a device that is still referenced
- **THEN** the workspace shows the API's reference warning and does not remove the row from the table
## Why
当前资产后台已经预留 `as_system_user` 用户表映射,但没有登录校验、账号管理或页面权限控制;任何访问者都能直接进入现有路由和接口。现在需要让开发者与超级管理员能够安全创建和维护业务账号,并让财务、人事、运营按被授予的页面读写权限使用系统。
## What Changes
- 新增账号密码登录、登录态校验与登出能力,未登录用户只能访问登录页和登录接口。
- 接入现有 `as_system_user` 作为唯一账号表:保存用户名、不可逆密码哈希、角色、启停状态和逐页权限配置;不保存明文密码。
- 定义五种固定角色:开发者、超级管理员、财务、人事、运营;唯一开发者固定账号为 `Jeddy`,且不出现在任何前端列表或下拉选项中。
- 新增账号与权限管理能力:开发者可创建所有角色并管理全部密码;超级管理员只能创建财务、人事、运营与其他超级管理员,且不能创建或修改任何密码;其他角色不能创建账号。
- 新增独立的“设置 / 账号与权限”菜单项及路由页,以图标加文字呈现;页面包含账号列表、创建/编辑账号、启停和逐页面板的“只读/编辑/无权限”勾选。
- 将菜单展示、前端路由守卫与后端接口授权统一建立在同一份服务端权限结果上,避免只隐藏前端入口而仍可调用接口。
- 所有密码创建、重置和修改均只由开发者执行;`Jeddy` 的初始密码哈希由受控数据库操作人工写入。登录成功后仅对 `Jeddy` 弹出“🎉 欢迎系统开发者-BOSS:Jeddy 上线”,其他账号不展示欢迎提示。
## Capabilities
### New Capabilities
- `system-user-authentication`: 以 `as_system_user` 为账号来源的安全登录、会话校验、登出和开发者专属登录欢迎提示能力。
- `system-user-administration`: 按创建者角色限制的账号查询、创建、编辑、启停与仅开发者可执行的密码重置能力,并隐藏开发者账号。
- `page-permission-management`: 服务端维护的逐页“无权限/只读/编辑”配置,以及设置入口、菜单过滤、路由和接口授权能力。
### Modified Capabilities
- 无现有 OpenSpec 主规格需要修改;现有资产接口的实现将增加统一授权校验,但其业务响应字段和资产数据规则不改变。
## Impact
- 后端:新增认证/账号/权限模块、Spring Security 依赖与安全配置、`as_system_user` 查询和写入逻辑、现有 `/api/**` 的授权拦截,以及相应单元和接口测试。
- 数据库:复用 `as_system_user` 的账号字段;需先核对线上表的实际列和索引,必要时通过可回滚迁移补充权限 JSON、密码更新时间和版本字段。不会存储明文密码。
- 前端:新增登录页、认证状态、请求鉴权头、路由守卫、设置菜单和独立账号与权限管理页;`Jeddy` 登录后仅在当前登录成功流程展示专属欢迎提示;现有资产页根据服务端返回的页面权限显示或禁用编辑操作。
- 运维:新增仅部署环境持有的令牌签名密钥;`Jeddy` 的初始密码哈希由受控数据库操作维护,密钥、明文密码和哈希均不提交到仓库。
## ADDED Requirements
### Requirement: 每个非开发者账号拥有逐页三档权限
系统 SHALL 为每个非开发者账号保存首期五个注册页面(总览、域名资料、企微资料、手机号资产、提醒中心)的权限值,且值 MUST 仅为 `NONE``READ``EDIT``EDIT` MUST 包含 `READ`;未配置和未知页面 MUST 按 `NONE` 处理。超级管理员对上述五页固定为 `EDIT`
#### Scenario: 管理员配置页面权限
- **WHEN** 有权管理员在独立页面权限面板为可管理账号勾选各页面的只读或编辑权限并保存
- **THEN** 系统校验页面键和值后持久化权限映射并返回最新有效权限
#### Scenario: 新页面没有被配置
- **WHEN** 系统发布了一个尚未存在于账号权限映射中的页面
- **THEN** 非开发者用户不能访问该页面,直到有权管理员明确授予权限
### Requirement: 菜单、路由和接口必须使用服务端权限结论
系统 MUST 从当前用户接口取得有效页面权限来决定菜单可见性和路由访问,并 MUST 在后端对每个页面关联接口执行同等或更严格的最低权限校验。
#### Scenario: 只读用户进入资产页
- **WHEN** 用户对手机号资产页只有 `READ` 权限
- **THEN** 菜单和路由允许查看列表,但新增、编辑和删除操作不显示或禁用,且后端拒绝对应写请求
#### Scenario: 用户绕过菜单访问无权限地址
- **WHEN** 用户手动输入没有权限的 Hash 路由或直接请求对应接口
- **THEN** 前端显示无权页且后端返回 403,不泄露业务数据
### Requirement: 设置入口仅向账户管理员显示
系统 SHALL 在主菜单的“设置”分组内以图标和文字显示“账号与权限”入口,且 MUST 只向开发者和超级管理员显示并允许访问。
#### Scenario: 超级管理员打开设置
- **WHEN** 超级管理员登录系统
- **THEN** 主菜单显示带设置图标的账号与权限入口并可跳转至独立管理路由
#### Scenario: 运营用户查看菜单
- **WHEN** 运营用户登录系统
- **THEN** 主菜单不显示账号与权限入口且直接访问该路由会被拒绝
## ADDED Requirements
### Requirement: 开发者和超级管理员按边界创建账号
系统 MUST 只允许开发者和超级管理员创建账号。开发者 SHALL 能创建所有角色并设置非开发者账号密码;超级管理员 SHALL 只能创建超级管理员、财务、人事和运营账号,且新账号 MUST 以 `DISABLED` 状态等待开发者设置密码;财务、人事和运营 MUST 被拒绝创建账号。
#### Scenario: 开发者创建任意角色
- **WHEN** 开发者提交合法的新账号、角色与密码
- **THEN** 系统创建该账号并返回不含密码的账号资料
#### Scenario: 超级管理员创建业务账号
- **WHEN** 超级管理员提交财务、人事、运营或超级管理员账号且请求不含密码
- **THEN** 系统创建 `DISABLED` 账号并等待开发者后续设置密码和启用
#### Scenario: 超级管理员尝试创建开发者
- **WHEN** 超级管理员提交角色为 `DEVELOPER` 的创建请求
- **THEN** 系统返回 403 且不创建账号
#### Scenario: 业务角色尝试创建账号
- **WHEN** 财务、人事或运营用户调用账号创建接口
- **THEN** 系统返回 403 且不写入 `as_system_user`
### Requirement: 开发者账号不得在前端出现
系统 MUST 从所有面向浏览器的账号列表、查询结果、详情、筛选项和角色选项中排除 `DEVELOPER` 账号;任何浏览器请求开发者账号 ID 的管理接口 MUST 返回不可见或无权结果。
#### Scenario: 开发者查看账号列表
- **WHEN** 开发者打开账号与权限页面
- **THEN** 列表中不出现任何开发者账号,也不提供开发者角色的创建或编辑选项
### Requirement: 管理员可维护可管理账号但密码仅开发者可改
开发者或超级管理员 SHALL 能查询、编辑、启用和禁用其可管理账号;仅开发者 SHALL 能设置或重置非开发者账号密码。系统 MUST 禁止禁用最后一个可用的开发者或超级管理员账号。
#### Scenario: 禁用业务账号
- **WHEN** 有权管理员禁用财务、人事或运营账号
- **THEN** 系统将状态改为 `DISABLED`、撤销该账号的现有登录态并使其无法再次登录
#### Scenario: 尝试禁用最后一个管理账号
- **WHEN** 操作会导致系统不存在可用开发者或超级管理员
- **THEN** 系统拒绝操作并说明需要保留至少一个可用管理账号
## ADDED Requirements
### Requirement: 用户必须通过受保护登录进入系统
系统 SHALL 提供账号密码登录、当前登录人查询和登出接口,并 SHALL 拒绝未认证用户访问任何受保护的 `/api/**` 接口。认证失败响应 MUST 不透露用户名是否存在、账号是否被禁用或密码是否错误。
#### Scenario: 有效账号成功登录
- **WHEN** 状态为 `ACTIVE` 的用户提交正确用户名和密码
- **THEN** 系统返回当前用户的安全资料与页面权限,并写入短期 HttpOnly 登录 Cookie
#### Scenario: 无效凭据无法判断原因
- **WHEN** 用户名不存在、密码错误或账号被禁用
- **THEN** 系统返回相同的认证失败响应且不创建登录 Cookie
#### Scenario: 未登录请求资产接口
- **WHEN** 浏览器未携带有效登录凭证而请求任一受保护资产接口
- **THEN** 系统返回 401 且不返回资产数据
### Requirement: 密码仅以不可逆哈希保存且仅开发者可维护
系统 MUST 使用 BCrypt 保存密码哈希,且 MUST NOT 在数据库、API 响应、日志、审计文本或前端持久化存储中保存或回显明文密码。只有 `DEVELOPER` 可以通过系统接口设置或重置非开发者账号密码;固定开发者 `Jeddy` 的密码哈希只允许通过受控数据库操作维护。系统 MUST NOT 提供任何账号的自助改密接口或页面。
#### Scenario: 开发者设置业务账号密码
- **WHEN** 开发者提交可管理非开发者账号的新密码和确认密码
- **THEN** `as_system_user.password_hash` 保存 BCrypt 哈希且任何响应均不包含提交的密码
#### Scenario: 超级管理员尝试提交密码
- **WHEN** 超级管理员在创建或编辑账号请求中提交密码字段,或调用密码重置接口
- **THEN** 系统返回 403 且不写入或修改任何密码哈希
#### Scenario: 用户尝试自助改密
- **WHEN** 任意已登录用户访问自助改密路由或调用自助改密接口
- **THEN** 系统不提供该路由或接口,并拒绝对应请求
### Requirement: 密码重置和禁用必须即时撤销旧会话
系统 MUST 在开发者设置/重置密码、受控直接更新数据库哈希或禁用账号时递增 `auth_version`,并在每个受保护请求验证令牌版本和账号状态。数据库 MUST 提供仅在 `password_hash` 实际变化时自动更新 `password_updated_at` 并递增 `auth_version` 的触发器。
#### Scenario: 已重置密码的旧浏览器继续访问
- **WHEN** 开发者重置业务账号密码后,该账号先前浏览器携带旧登录 Cookie 请求接口
- **THEN** 系统拒绝该请求并要求重新登录
#### Scenario: 手工更新 Jeddy 的密码哈希
- **WHEN** 受控数据库操作将 `Jeddy``password_hash` 更新为新的 BCrypt 哈希
- **THEN** 数据库触发器自动更新 `password_updated_at`、递增 `auth_version`,且 `Jeddy` 的旧登录 Cookie 随后被拒绝
### Requirement: 开发者登录显示专属欢迎提示
系统 SHALL 仅在固定账号 `Jeddy``DEVELOPER` 角色成功登录的当次前端登录流程中显示“🎉 欢迎系统开发者-BOSS:Jeddy 上线”。系统 MUST NOT 为其他账号显示该提示。
#### Scenario: Jeddy 登录成功
- **WHEN** `Jeddy` 使用有效开发者凭据完成登录
- **THEN** 前端在登录成功后显示一次专属欢迎提示并进入系统
#### Scenario: 其他账号登录成功
- **WHEN** 超级管理员、财务、人事或运营账号完成登录
- **THEN** 前端进入系统且不显示开发者专属欢迎提示
## Context
当前手机号资产页面的筛选栏、列表列和新增/编辑表单直接展示后端字段的旧业务名称:`cardType` 显示为“卡类型”,`realNameOwner` 显示为“实名归属”,`managementType` 显示为“管理方式”,`disposalStatus` 显示为“处置状态”,`deviceId` 显示为“关联设备 ID”。
从已配置的选项可知,`cardType` 实际保存的是运营商;“正常使用、闲置、停机、已注销”描述的是当前状态而非处置动作;`deviceId` 仅保存数值 ID,尚未验证或读取设备名称。本次仅改用户可见文本和表单帮助说明,不能改动接口键、DTO 或数据库列,以保护已有记录和调用方兼容。
## Goals / Non-Goals
**Goals:**
- 让每个字段名称对应一个独立且可理解的业务维度:运营商、实名登记、管理模式、使用状态和设备关联。
- 在列表与表单中使用同一套名称,并在表单中说明容易误解字段的录入含义。
- 维持现有请求体、响应体与表结构完全不变。
**Non-Goals:**
- 不重命名 JavaScript、接口、DTO 或数据库字段。
- 不调整下拉选项、校验规则、筛选条件、数据迁移或历史记录。
- 不接入设备选择器、设备名称回显或设备存在性校验。
## Decisions
### 1. 保持内部键不变,只替换展示名称
展示名称与接口字段分离:页面继续绑定 `cardType``realNameOwner``managementType``disposalStatus``deviceId`,但将标签分别显示为“运营商、实名主体、管理模式、使用状态、关联设备(ID)”。
- 术语(白话解释):接口字段是浏览器和后端交换数据时使用的固定键名;展示名称是页面上给人看的文字。这里要分开,是因为后端键名已被现有数据和代码使用,而人的理解需要更准确的词。若直接改接口字段,会让已有请求、测试或数据映射失效。
- 备选方案:同时改接口和数据库字段。未采用,因为本次问题是业务文案歧义,不值得引入跨前后端及数据迁移风险。
### 2. 以现有枚举值反推正确业务名称
“移动、联通、电信、广电、虚拟运营商”统一称为“运营商”;“自有、租用、代运营”统一称为“管理模式”;“正常使用、闲置、停机、已注销”统一称为“使用状态”。“实名主体”用于记录与号码实名登记一致的个人或单位名称。
- 术语(白话解释):枚举值是下拉框中预先限定的一组可选值。这里用现有选项判断字段含义,能够避免名称和实际可选值不一致;否则用户仍可能把“卡类型”理解成实体 SIM/eSIM 类型,把“处置状态”理解成审批结果。
- 备选方案:继续保留旧名称并只增加提示。未采用,因为表格中的旧列名仍会造成长期误读。
### 3. 明确设备字段当前只记录 ID
页面标签使用“关联设备(ID)”,表单帮助文字说明“填写设备资产记录的数字 ID;当前不校验是否存在”。
- 术语(白话解释):ID 是系统给一条记录的唯一编号。这里明确显示 ID,是为了避免用户误以为可以输入设备名称;不说明会导致保存后找不到关联设备的预期落差。
- 备选方案:改为设备下拉选择。未采用,因为需要设备查询接口和存在性校验,超出单纯字段释义的范围。
## Risks / Trade-offs
- [旧标签可能已经被少量用户记住] → 在表单帮助文字中保留可理解的定义,不改变已存数据和值。
- [“实名主体”可能被理解为仅企业主体] → 帮助文字明确“个人或单位名称”,并以测试覆盖标签和说明。
- [测试通过文字定位页面元素] → 更新页面测试中的可访问名称,避免真实页面文案变更后测试产生误报。
## Migration Plan
1. 更新列表列名、表单标签、占位文字与帮助说明,并同步更新对应页面测试。
2. 在本地打开 `http://localhost:5173/asset/#/phone-assets`,核对列表、新增和编辑弹窗的名称一致性。
3. 回滚时仅还原前端展示文本和测试断言;不存在数据、接口或数据库迁移。
## Open Questions
- 无。当前字段的选项和值足以确定本次仅展示层的命名修正。
## Why
手机号资产页当前的“卡类型、实名归属、管理方式、处置状态、关联设备 ID”等名称没有明确区分运营商、实名登记、资产管理和当前使用状态。录入人员容易根据字面误填,后续筛选与资产盘点也难以得到一致的数据。
现在页面、接口字段和数据表已经形成可用闭环,因此应先只校正前端的业务名称与填写提示,消除理解偏差,同时保持已上线的字段名和数据兼容。
## What Changes
-`cardType` 的展示名称从“卡类型”调整为“运营商”,并明确其值用于记录移动、联通、电信、广电或虚拟运营商。
-`realNameOwner` 的展示名称调整为“实名主体”,说明其记录与该号码实名登记一致的个人或单位名称。
-`managementType` 的展示名称调整为“管理模式”,使“自有、租用、代运营”作为同一管理维度的选项。
-`disposalStatus` 的展示名称调整为“使用状态”,使“正常使用、闲置、停机、已注销”表达资产当前状态而非处理动作。
-`deviceId` 的展示名称调整为“关联设备(ID)”,并在字段旁说明当前只保存设备记录的 ID;本次不新增设备校验或选择器。
- 在新增和编辑表单中补充简短帮助说明;列表列名与表单标签保持一致。
## Capabilities
### New Capabilities
- 无。
### Modified Capabilities
- `phone-asset-workspace`: 统一手机号资产列表和表单的业务字段名称,并为易混淆字段提供填写含义。
## Impact
- 受影响前端文件为 `frontend/src/modules/phone/PhoneAssetView.js` 与其页面测试 `frontend/tests/phone-asset.spec.js`
- 不修改 `/api/phone-assets` 请求或响应字段、DTO、数据库表、已有资产记录、路由、权限或依赖。
- 页面内部和接口继续使用现有字段名 `cardType``realNameOwner``managementType``disposalStatus``deviceId`,仅改变用户可见名称与辅助文案,因此不产生接口兼容风险。
## MODIFIED Requirements
### Requirement: Use confirmed form controls
The workspace SHALL restrict the phone input to 11 digits after trimming whitespace and removing a leading `+86`. It SHALL display `cardType` as “运营商”, `realNameOwner` as “实名主体”, `managementType` as “管理模式”, `disposalStatus` as “使用状态”, and `deviceId` as “关联设备(ID)” consistently in the list and create/edit form. The form SHALL explain that the real-name subject is the individual or organization registered to the phone number, and that the device field accepts only an asset-record ID. Management type SHALL display empty for null and disposal status is required with initial value `正常使用`.
#### Scenario: User views a record in the list
- **WHEN** the workspace renders phone-asset records
- **THEN** it shows the columns “运营商、实名主体、管理模式、使用状态、关联设备(ID)” and does not expose the prior ambiguous labels for those fields
#### Scenario: User creates or edits a record
- **WHEN** a user opens the create or edit form
- **THEN** the field labels match the list names and the form explains the real-name subject and device-ID meanings without changing the submitted property names
#### Scenario: User saves confirmed dropdown values
- **WHEN** a user creates or edits using existing dropdown values
- **THEN** the workspace submits the existing `cardType`, `managementType`, and `disposalStatus` properties unchanged while the visible labels remain “运营商、管理模式、使用状态”
## 1. 页面字段命名
- [ ] 1.1 修改 `frontend/src/modules/phone/PhoneAssetView.js`(文件用途:承载手机号资产的筛选、列表、新增与编辑页面;关联逻辑:路由进入页面 → 表单/表格渲染 → `phone-api-client.js` 请求),将列表列名、表单标签、占位文字改为“运营商、实名主体、管理模式、使用状态、关联设备(ID)”。
- [ ] 1.2 在同一表单中为“实名主体”和“关联设备(ID)”增加简短帮助说明,分别明确“手机号实名登记的个人或单位名称”和“设备资产记录的数字 ID,当前不校验是否存在”。
- [ ] 1.3 保持 `cardType``realNameOwner``managementType``disposalStatus``deviceId` 的响应式表单属性及提交数据不变;不修改 `phone-api-client.js`(文件用途:封装手机号资产 HTTP 请求;关联逻辑:View → `/api/phone-assets` → Controller)或任何后端文件。
- [ ] 1.4 注释门禁:本组不新增或调整 JavaScript 方法;检查 `PhoneAssetView.js` 内已有方法的新手注释仍覆盖代码作用、关联文件和调用链/数据流。若实现时新增或改动任何方法,必须先补齐该方法的三项新手注释。
## 2. 自动化验证
- [ ] 2.1 修改 `frontend/tests/phone-asset.spec.js`(文件用途:验证手机号资产页的新增弹窗与输入行为;关联逻辑:浏览器页面 → 前端表单 → 被拦截的手机号资产接口),将按可访问名称断言的旧标签替换为新标签,并新增列表列和表单说明的断言。
- [ ] 2.2 注释门禁:本组不新增或调整测试辅助方法;检查现有测试方法注释仍说明代码作用、关联文件和调用链/数据流。若实现时新增或改动任何测试方法,必须先补齐该方法的三项新手注释。
- [ ] 2.3 运行手机号资产 Playwright 用例,并在本地打开 `http://localhost:5173/asset/#/phone-assets`,核对筛选栏、列表、新增弹窗和编辑弹窗的文字一致,且保存请求仍使用原有字段键。
## 3. 兼容性确认
- [ ] 3.1 对比修改前后的网络请求体与列表响应,确认接口、DTO、数据库和配置均未变化;本次没有新增、重命名或删除业务文件。
## Context
The frontend identifies the current user through `GET /api/auth/me`, which reads the current database row. Protected backend writes use the role stored in the signed login token. `SystemUserAdminService.updateUser` currently changes role, status, and page permissions without increasing `auth_version`; therefore an old token can retain a lower role while the UI renders the newly promoted role.
`phone-api-client.js`, `wecom-api-client.js`, and `system-user-api-client.js` use the shared authentication request helper. `device-api-client.js` uses a separate raw `fetch`, so it neither requests the CSRF cookie nor sends the `X-XSRF-TOKEN` header. Spring Security maps both a missing CSRF token and role denial to a generic 403 response, which conceals the cause during diagnosis.
## Goals / Non-Goals
**Goals:**
- Make a role, status, or page-permission change invalidate the account's existing login token before its new authorization state is shown as usable.
- Preserve the existing fixed roles and server-side authorization checks; a developer remains the highest-privilege role but must reauthenticate after an authorization change.
- Use one authenticated request path for device GET, multipart create/update, and delete operations, including cookies and CSRF headers for every write.
- Return a safe, actionable distinction between expired authentication, CSRF failure, and ordinary authorization denial.
**Non-Goals:**
- Do not add a database migration, create more developer accounts, weaken CSRF, or change device asset fields, upload validation, or response DTOs.
- Do not add self-service role changes or browser-stored login tokens.
- Do not modify the enterprise-WeChat or phone asset business rules.
## Decisions
### 1. Invalidate on every authorization-state mutation
`SystemUserAdminService.updateUser` will compare the persisted and requested role, status, and normalized page permissions. If any effective authorization value changes, it will increment `auth_version` in the same database update. `AuthTokenFilter` already rejects a token whose version differs from the database value, so the next request with that old cookie returns 401 and the user must sign in again.
`auth_version`(登录票据版本号:服务端用来立即作废旧登录票据的整数) is used because the role embedded in a signed JWT cannot safely be altered in-place. Relying only on `/api/auth/me` would keep the UI and API authorization sources inconsistent; rereading the role from the database on each request would reduce this mismatch but would not reliably revoke sessions after status or page-permission changes.
### 2. Reuse the existing authenticated request helper for multipart safely
Exported `request` in `auth-api-client.js` will remain the single browser request entry point. It will preserve caller headers, request CSRF only for non-GET non-login methods, and omit `Content-Type` when the body is `FormData` so the browser can supply the multipart boundary.
`device-api-client.js` will call this helper for list, lookup, create, update, delete, and image access requests as appropriate. The alternative of disabling CSRF for device endpoints is rejected because it would permit forged writes from a third-party page that can use a user's login cookie.
### 3. Keep safe 401/403 diagnostic classes
The security error handler will retain generic authorization wording for ordinary role/page denial, return a session-expired/login-required message for an invalidated token, and return a refresh-and-retry message for missing or invalid CSRF input. It will not reveal account existence, target roles, permission maps, token values, or internal exception details.
CSRF(跨站请求伪造防护:要求浏览器把服务端发出的随机值同时放在 Cookie 和请求头中) remains mandatory for all non-login writes. Without it, another website could submit a write using the user's ambient login Cookie.
### 4. Test the real failure paths before declaring the fix
Backend tests will prove that a promoted user token becomes invalid, a fresh developer token can manage users, and a non-developer token remains denied. Browser tests will verify that the first device write initializes CSRF, sends cookies and the token header, preserves multipart boundaries, and shows the distinct safe error messages.
## Risks / Trade-offs
- [An administrator changes their own authorization state] -> Their next request becomes 401 by design; the frontend redirects to login with a clear reauthentication message.
- [A change increments `auth_version` unnecessarily] -> Compare the persisted role, status, and normalized permission JSON before incrementing; no-op edits retain the current session.
- [Setting JSON content type for multipart] -> Detect `FormData` and let the browser add the boundary; otherwise uploads would reach the server as malformed data.
- [Detailed 403 messages disclose security state] -> Restrict messages to three generic client actions: login again, refresh/retry, or no permission.
- [Concurrent account updates] -> Use the existing single-row update path and increment from the persisted version; the current project has no optimistic-lock column, so concurrent administrator updates remain outside this targeted fix.
## Migration Plan
1. Deploy the backend and frontend together; no data migration or schema change is required.
2. Existing sessions continue until a managed account's authorization state changes. That change invalidates its previous session at the next protected request.
3. Verify a device create, multipart device update, phone write, enterprise-WeChat write, and system-user write using a fresh developer login.
4. Roll back by reverting the application changes. Existing `auth_version` values and asset data remain intact; already-invalidated sessions simply need a new login.
## Open Questions
None. Reauthentication after a role, status, or page-permission change is the selected security behavior.
## Why
An account promoted to `DEVELOPER` can receive full permissions from `/api/auth/me` while its already-issued login token still contains the prior role. The UI then identifies the user as an administrator but protected writes, such as `POST /api/system-users`, return 403. Separately, device-asset writes bypass the existing CSRF protection flow and are rejected regardless of the user's role.
## What Changes
- Invalidate an existing login session whenever a managed account's role, enabled status, or page permissions change. The next protected request must require a fresh login so the token role and database role cannot diverge.
- Keep the current rule that `DEVELOPER` and `SUPER_ADMIN` are administrators; do not relax role checks or allow creation of extra developer accounts.
- Route every device-asset write request through the shared authenticated request helper so it includes cookies and the CSRF header, including multipart `POST` and `PUT` requests.
- Make 403 verification distinguish an authorization failure from a missing/invalid CSRF token in automated tests and developer diagnostics, without exposing sensitive account information.
## Capabilities
### New Capabilities
- `authorization-session-consistency`: Keeps the role and permissions used by a protected API request consistent with the currently effective account state.
- `authenticated-device-writes`: Makes device-asset create, update, and delete requests use the same authenticated CSRF-safe request flow as the existing asset modules.
### Modified Capabilities
- None. The related authentication and device specifications are still in unarchived changes rather than the repository's main OpenSpec specification set; this change records the corrective requirements as standalone capabilities.
## Impact
- Backend: `SystemUserAdminService`, authentication-token filtering, and focused authorization tests. No database schema change or migration is required because `auth_version` already exists.
- Frontend: the shared auth request utility, device API client, and browser tests. The page layout, role names, and asset data contract remain unchanged.
- Security: CSRF(跨站请求伪造防护:阻止第三方网站借用登录 Cookie 发起写操作) continues to protect every non-login write. Not preserving it would make the 403 disappear at the cost of allowing forged writes.
## ADDED Requirements
### Requirement: Device write requests use the shared authenticated CSRF flow
The browser SHALL send device-asset create, update, and delete requests through the shared authenticated request helper. It MUST include login cookies and obtain/send the CSRF header for each protected non-login write. Multipart create and update requests MUST allow the browser to set the multipart boundary and MUST NOT force a JSON content type.
#### Scenario: Create a device with an image
- **WHEN** an authenticated administrator submits a device create form with a valid image
- **THEN** the client obtains CSRF input when needed, sends the login cookie and CSRF header with multipart form data, and the backend receives a valid device create request
#### Scenario: Update a device with multipart data
- **WHEN** an authenticated administrator edits a device name, image, or remove-image flag
- **THEN** the client sends the protected multipart update without overriding the browser-generated multipart boundary
#### Scenario: Missing device CSRF input
- **WHEN** a device write is attempted without a valid CSRF token
- **THEN** the backend rejects it before asset or file mutation and the browser shows the safe refresh-and-retry message
### Requirement: Device read contract remains compatible
The device list, company-person lookup, and controlled opaque image access SHALL retain their existing request paths and response shapes. This change MUST NOT expose physical file paths, disable reference-protected deletion, or relax the existing image validation rules.
#### Scenario: Read device list after security fix
- **WHEN** an authenticated authorized user loads the device asset page
- **THEN** the client receives the existing paged list shape and renders it without a write-oriented CSRF requirement
## ADDED Requirements
### Requirement: Authorization-state changes revoke stale login sessions
The system SHALL increment `auth_version` whenever a managed account's effective role, enabled status, or validated page-permission map changes. A token whose embedded version differs from the current account version MUST be rejected before a protected controller or service method executes.
#### Scenario: Promoted account uses an old token
- **WHEN** an active account is changed from a non-administrator role to `DEVELOPER` or `SUPER_ADMIN` while it still holds an earlier login token
- **THEN** its next protected request is rejected as requiring a fresh login, and a new login receives a token with the current role
#### Scenario: Permission map changes without a role change
- **WHEN** an administrator changes an account's validated page permissions or enabled status
- **THEN** the prior token is rejected on its next protected request and the account's next login uses the new effective permissions and status
#### Scenario: No-op account edit
- **WHEN** an administrator saves an account with the same effective role, status, and validated page permissions
- **THEN** the account's `auth_version` is unchanged and its current session remains valid
### Requirement: Protected write failures provide safe actionable categories
The system SHALL preserve generic authorization denial for a valid authenticated user without the required role or page permission. It SHALL return a login-required response for an invalidated or absent session, and a refresh-and-retry response for a missing or invalid CSRF token. Responses MUST NOT disclose token values, account existence, roles, or permission maps.
#### Scenario: Valid non-administrator creates an account
- **WHEN** a valid non-administrator token submits `POST /api/system-users`
- **THEN** the system returns a generic authorization-denied response and does not write an account row
#### Scenario: Browser omits CSRF input
- **WHEN** an authenticated browser sends a protected non-login write without a valid CSRF token
- **THEN** the system returns a safe refresh-and-retry response and does not execute the controller business method
## 0. Scope, ownership, and file-purpose annotations
| File | File purpose (plain language) | Ownership / collision boundary |
|---|---|---|
| `backend/src/main/java/com/xyw/console/auth/SystemUserAdminService.java` | Changes an account and invalidates its old login only when effective authorization changes. | Authentication change; coordinate with current account-permission work. |
| `backend/src/main/java/com/xyw/console/config/SecurityConfig.java` | Maps security-layer authentication, CSRF, and authorization failures to safe browser responses. | Authentication change; do not relax CSRF or global protection. |
| `backend/src/test/java/com/xyw/console/auth/SystemUserAdminServiceTest.java` | Proves version changes and account-management role boundaries without a live database. | New focused test file. |
| `backend/src/test/java/com/xyw/console/config/SecurityConfigTest.java` | Proves safe response categories for expired sessions, CSRF failures, and ordinary permission denial. | New focused test file. |
| `frontend/src/modules/auth/auth-api-client.js` | Is the shared browser helper that sends cookies, fetches CSRF input, and preserves multipart requests. | Authentication change; all API clients depend on it. |
| `frontend/src/modules/device/device-api-client.js` | Sends device list, lookup, CRUD, and image requests through the shared authenticated helper. | Device integration only; do not change device view behavior. |
| `frontend/tests/auth-session-consistency.spec.js` | Exercises stale-session, CSRF, and multipart browser request behavior. | New focused browser test. |
| `frontend/tests/device-asset.spec.js` | Extends device workflow tests with real request-header and multipart-boundary assertions. | Device integration test; preserve existing scenarios. |
- [ ] 0.1 Confirm the active authentication and device working-tree changes, preserve unrelated edits, and designate one integration owner for the four shared files above.
- [ ] 0.2 Add a plain-language file-purpose annotation to each new test file. For every method, callback, or helper added or changed in the scoped files, add a beginner comment describing its purpose, related files, and request-to-security-to-response data flow.
- [ ] 0.3 Do not run a database migration or modify `as_system_user` schema; verify `auth_version` is already readable and writable before implementation.
## 1. Authorization-session consistency
- [ ] 1.1 Add focused helpers in `SystemUserAdminService` that compare persisted and requested role, status, and normalized page permissions, and calculate the next `auth_version` safely from the persisted value.
- [ ] 1.2 Update `SystemUserAdminService.updateUser` so a true authorization-state change increments `auth_version` in the same row update, while a no-op update does not invalidate the current session.
- [ ] 1.3 Keep existing developer/super-administrator creation boundaries unchanged; a fresh `DEVELOPER` token can manage accounts, a valid non-administrator token remains denied, and no additional developer account can be created.
- [ ] 1.4 Update `SecurityConfig` to return safe distinct messages for invalidated/absent login sessions, missing-or-invalid CSRF input, and valid-session authorization denial, without returning account, role, permission, token, or exception details.
## 2. Shared device write authentication
- [ ] 2.1 Update `auth-api-client.js` request handling to preserve caller headers, send credentials, initialize CSRF for protected writes, and omit a forced JSON `Content-Type` when the request body is `FormData`.
- [ ] 2.2 Refactor every `device-api-client.js` operation to use the shared authenticated request helper; retain existing URLs, query parameter behavior, and API response shape.
- [ ] 2.3 Verify device multipart create and update retain the browser-generated boundary, and device delete includes the CSRF header and cookie.
- [ ] 2.4 Ensure a 401 session-invalid response clears local authentication state and leads the user to sign in again; do not silently retry a write that might repeat a user action.
## 3. Focused verification
- [ ] 3.1 Add `SystemUserAdminServiceTest` coverage for role promotion, status change, permission change, no-op update, developer management success, and non-administrator denial. Annotate every test method and business callback.
- [ ] 3.2 Add `SecurityConfigTest` coverage for 401 expired-session handling, CSRF 403 handling, ordinary authorization 403 handling, and the absence of sensitive details. Annotate every test method and callback.
- [ ] 3.3 Add browser coverage for the first device write obtaining CSRF, multipart request header/boundary behavior, device delete CSRF behavior, and the safe refresh-and-retry message. Annotate every test method and route callback.
- [ ] 3.4 Run `mvn -q test`, `npm run build`, focused authentication/device Playwright tests, and the existing full frontend Playwright suite. Diagnose a failing check before changing code.
- [ ] 3.5 Record changed files, no-database-impact confirmation, security behavior, executed verification, and any unexecuted check in the completion handoff.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment