Commit 1625ba48 by DaiJiezhang

Merge branch 'codex/integrate-authentication-to-master' into 'master'

集成认证、刷新重定向与本地 CORS 修复

See merge request !4
parents 27c7b6d1 d9d3df13
...@@ -32,6 +32,27 @@ ...@@ -32,6 +32,27 @@
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </dependency>
<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> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version> <version>${mybatis-plus.version}</version>
......
...@@ -2,6 +2,7 @@ package com.xyw.console.asset.controller; ...@@ -2,6 +2,7 @@ package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.*; import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.service.DeviceAssetService; import com.xyw.console.asset.service.DeviceAssetService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse; import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List; import java.util.List;
...@@ -15,26 +16,26 @@ import org.springframework.web.bind.annotation.*; ...@@ -15,26 +16,26 @@ import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/device-assets") @RequestMapping("/api/device-assets")
public class DeviceAssetController { public class DeviceAssetController {
private final DeviceAssetService service; private final DeviceAssetService service; private final PagePermissionService permissions;
/** 代码作用(白话):接收设备业务服务。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP Controller -> Service -> Mapper/文件服务。 */ /** 代码作用(白话):接收设备业务服务。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP Controller -> Service -> Mapper/文件服务。 */
public DeviceAssetController(DeviceAssetService service) { this.service=service; } public DeviceAssetController(DeviceAssetService service, PagePermissionService permissions) { this.service=service; this.permissions=permissions; }
/** 代码作用(白话):返回设备分页列表。关联文件:DeviceAssetPageQuery.java、DeviceAssetService.java。关联逻辑(调用链/数据流):GET 参数 -> page -> ApiResponse -> Vue 表格。 */ /** 代码作用(白话):返回设备分页列表。关联文件:DeviceAssetPageQuery.java、DeviceAssetService.java。关联逻辑(调用链/数据流):GET 参数 -> page -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<DeviceAssetPageResponse> page(@Valid DeviceAssetPageQuery query) { return ApiResponse.success(service.page(query)); } @GetMapping public ApiResponse<DeviceAssetPageResponse> page(@Valid DeviceAssetPageQuery query) { permissions.requireAdministrator(); return ApiResponse.success(service.page(query)); }
/** 代码作用(白话):创建设备及可选图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart POST -> create -> 文件/数据库 -> JSON 响应。 */ /** 代码作用(白话):创建设备及可选图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart POST -> create -> 文件/数据库 -> JSON 响应。 */
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> create(@Valid @ModelAttribute DeviceAssetSaveRequest request) { return ApiResponse.success("新增成功",service.create(request)); } @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> create(@Valid @ModelAttribute DeviceAssetSaveRequest request) { permissions.requireAdministrator(); return ApiResponse.success("新增成功",service.create(request)); }
/** 代码作用(白话):编辑有效设备并可替换或移除图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart PUT -> update -> 文件/数据库 -> JSON 响应。 */ /** 代码作用(白话):编辑有效设备并可替换或移除图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart PUT -> update -> 文件/数据库 -> JSON 响应。 */
@PutMapping(value="/{id}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> update(@PathVariable Long id,@Valid @ModelAttribute DeviceAssetSaveRequest request) { return ApiResponse.success("编辑成功",service.update(id,request)); } @PutMapping(value="/{id}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> update(@PathVariable Long id,@Valid @ModelAttribute DeviceAssetSaveRequest request) { permissions.requireAdministrator(); return ApiResponse.success("编辑成功",service.update(id,request)); }
/** 代码作用(白话):在没有有效引用时软删除设备。关联文件:DeviceAssetService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):DELETE -> 引用检查 -> deleteTime 更新 -> 前端刷新。 */ /** 代码作用(白话):在没有有效引用时软删除设备。关联文件:DeviceAssetService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):DELETE -> 引用检查 -> deleteTime 更新 -> 前端刷新。 */
@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.requireAdministrator(); service.softDelete(id); return ApiResponse.success("删除成功",null); }
/** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */ /** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */
@GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { return ApiResponse.success(service.searchCompanyPersons(keyword)); } @GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { permissions.requireAdministrator(); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
/** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */ /** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); } @GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { permissions.requireAdministrator(); Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); }
} }
package com.xyw.console.asset.controller; package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.*; import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.service.PhoneAssetService; import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse; import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
...@@ -8,14 +9,16 @@ import org.springframework.web.bind.annotation.*; ...@@ -8,14 +9,16 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/phone-assets") @RequestMapping("/api/phone-assets")
public class PhoneAssetController { public class PhoneAssetController {
private final PhoneAssetService service; private final PhoneAssetService service;
private final PagePermissionService permissions;
/** 代码作用(白话):接收手机号资产接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */ /** 代码作用(白话):接收手机号资产接口所需的业务服务。关联文件: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 表格。 */ /** 代码作用(白话):接收浏览器分页查询并返回统一 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 -> 弹窗。 */ /** 代码作用(白话):接收新增表单并创建手机号资产。关联文件: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 -> 列表刷新。 */ /** 代码作用(白话):接收编辑表单并更新允许修改的字段。关联文件: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 -> 列表刷新。 */ /** 代码作用(白话):软删除没有关联阻止的手机号资产。关联文件: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);}
} }
\ No newline at end of file
...@@ -7,6 +7,7 @@ import com.xyw.console.asset.dto.WecomAccountPageQuery; ...@@ -7,6 +7,7 @@ import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse; import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest; import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.service.WecomAccountService; import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse; import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List; import java.util.List;
...@@ -22,27 +23,29 @@ import org.springframework.web.bind.annotation.RestController; ...@@ -22,27 +23,29 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/wecom-accounts") @RequestMapping("/api/wecom-accounts")
public class WecomAccountController { public class WecomAccountController {
private final WecomAccountService service; 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. */ /** 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. */ /** Code purpose (plain language): saves a new enterprise WeChat asset. Related files: WecomAccountSaveRequest.java, WecomAccountService.java. Data flow: create dialog -> POST -> transaction -> response. */
@PostMapping @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. */ /** 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 @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. */ /** 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") @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. */ /** 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") @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. */ /** 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") @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)); }
} }
\ No newline at end of file
...@@ -18,4 +18,7 @@ public class SystemUserEntity extends AssetBaseEntity { ...@@ -18,4 +18,7 @@ public class SystemUserEntity extends AssetBaseEntity {
private String passwordHash; private String passwordHash;
private String roleCode; private String roleCode;
private String status; 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); }
}
/** Plain purpose: convert a saved permission JSON value into the same validated form used for a new account request. Related files: SystemUserAdminService.java, SystemUserEntity.java. Flow: stored permission JSON -> normalized map -> role-change comparison -> auth-version decision. */
public String normalizeStoredPermissions(String storedPermissions) {
if (storedPermissions == null || storedPermissions.isBlank()) return validatePermissions(Map.of());
try { return validatePermissions(objectMapper.readValue(storedPermissions, new TypeReference<>() {})); }
catch (Exception ignored) { return validatePermissions(Map.of()); }
}
/** 代码作用(白话):在资产接口执行服务端最低权限检查;关联文件: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("没有页面权限");
}
/** Plain purpose: stop non-administrators before an administrator-only controller reaches business data. Related files: DeviceAssetController.java, AuthTokenFilter.java. Flow: authenticated token principal -> administrator role check -> device controller or 403 response. */
public void requireAdministrator() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof AuthPrincipal principal) || !isAdministrator(principal.roleCode())) 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.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; import org.springframework.security.web.csrf.CsrfException;
@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,accessDeniedMessage(error)))).build(); }
/** Plain purpose: choose a safe client action for CSRF failures without exposing account or token details. Related files: auth-api-client.js, SecurityConfigTest.java. Flow: Spring access denial -> safe message -> JSON response -> browser refresh/retry guidance. */
String accessDeniedMessage(Exception error) { return error instanceof CsrfException ? "安全校验已失效,请刷新页面后重试" : "没有权限"; }
/** 代码作用(白话):生成 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)); }
}
...@@ -13,10 +13,12 @@ public class WebConfig implements WebMvcConfigurer { ...@@ -13,10 +13,12 @@ public class WebConfig implements WebMvcConfigurer {
* 关联逻辑(调用链/消息链/数据流):浏览器 fetch -> /api/** -> addCorsMappings() -> 后端控制器正常返回 JSON。 * 关联逻辑(调用链/消息链/数据流):浏览器 fetch -> /api/** -> addCorsMappings() -> 后端控制器正常返回 JSON。
*/ */
@Override @Override
/** 代码作用(白话):允许指定本地前端端口调用后端接口。CORS(浏览器限制不同端口互访的安全规则)需要列出 5175;否则浏览器会在密码校验前以 403 拒绝登录。关联文件:frontend/vite.config.js、SecurityConfig.java。关联逻辑(调用链/消息链/数据流):浏览器 5173/5175 -> /api/** -> addCorsMappings() -> 后端控制器。 */
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") registry.addMapping("/api/**")
.allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000", "http://localhost:5173", "http://127.0.0.1:5173") .allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000", "http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:5175", "http://127.0.0.1:5175")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .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.mockito.Mockito.verifyNoInteractions;
import static org.junit.jupiter.api.Assertions.assertThrows;
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 com.xyw.console.asset.dto.DeviceAssetPageQuery;
import com.xyw.console.asset.dto.DeviceAssetSaveRequest;
import com.xyw.console.auth.AuthPrincipal;
import com.xyw.console.auth.PagePermissionService;
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.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
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());
}
/** Plain purpose: prove a logged-in ordinary role cannot bypass the sidebar and call any device endpoint directly. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: finance principal -> controller administrator check -> access denial before device service or image read. */
@Test void blocksNonAdministratorsFromEveryDeviceEndpoint() {
DeviceAssetService service = mock(DeviceAssetService.class); PagePermissionService permissions = new PagePermissionService(new com.fasterxml.jackson.databind.ObjectMapper()); DeviceAssetController controller = new DeviceAssetController(service, permissions);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(new AuthPrincipal(3L, "finance", "FINANCE", 1), null));
try {
assertThrows(AccessDeniedException.class, () -> controller.page(new DeviceAssetPageQuery(1, 20, null, null, null, null)));
assertThrows(AccessDeniedException.class, () -> controller.create(new DeviceAssetSaveRequest()));
assertThrows(AccessDeniedException.class, () -> controller.update(1L, new DeviceAssetSaveRequest()));
assertThrows(AccessDeniedException.class, () -> controller.delete(1L));
assertThrows(AccessDeniedException.class, () -> controller.companyPersons("name"));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png"));
verifyNoInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
/** 代码作用(白话):集中创建带设备异常转换器的 MockMvc,保证控制器返回契约可被测试。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):HTTP 模拟请求 -> Controller -> Advice -> JSON 响应。*/
/** Plain purpose: create an administrator-authorized controller test harness without a live Spring Security filter chain. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: mock permission check -> controller endpoint -> mocked device service -> HTTP assertion. */
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service,mock(PagePermissionService.class))).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. ...@@ -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.entity.PhoneAssetEntity;
import com.xyw.console.asset.mapper.PhoneAssetMapper; import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.service.PhoneAssetService; import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.auth.PagePermissionService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
...@@ -27,7 +28,7 @@ class PhoneAssetControllerTest { ...@@ -27,7 +28,7 @@ class PhoneAssetControllerTest {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class); PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectCount(any())).thenReturn(0L); when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.insert(any(PhoneAssetEntity.class))).thenReturn(1); 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) mockMvc.perform(post("/api/phone-assets").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13812345678\",\"realNameOwner\":\"张三\"}")) .content("{\"phoneNumber\":\"13812345678\",\"realNameOwner\":\"张三\"}"))
...@@ -46,7 +47,7 @@ class PhoneAssetControllerTest { ...@@ -46,7 +47,7 @@ class PhoneAssetControllerTest {
when(mapper.selectOne(any())).thenReturn(entity); when(mapper.selectOne(any())).thenReturn(entity);
when(mapper.selectCount(any())).thenReturn(0L); when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1); 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) mockMvc.perform(put("/api/phone-assets/1").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13912345678\",\"realNameOwner\":\"李四\"}")) .content("{\"phoneNumber\":\"13912345678\",\"realNameOwner\":\"李四\"}"))
...@@ -63,7 +64,7 @@ class PhoneAssetControllerTest { ...@@ -63,7 +64,7 @@ class PhoneAssetControllerTest {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class); PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectOne(any())).thenReturn(activeEntity()); when(mapper.selectOne(any())).thenReturn(activeEntity());
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1); 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")) mockMvc.perform(delete("/api/phone-assets/1"))
.andExpect(status().isOk()); .andExpect(status().isOk());
......
...@@ -17,6 +17,7 @@ import com.xyw.console.asset.mapper.CompanyProfileMapper; ...@@ -17,6 +17,7 @@ import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper; import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper; import com.xyw.console.asset.mapper.WecomAccountMapper;
import com.xyw.console.asset.service.WecomAccountService; import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.auth.PagePermissionService;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
...@@ -66,7 +67,7 @@ class WecomAccountControllerTest { ...@@ -66,7 +67,7 @@ class WecomAccountControllerTest {
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device())); 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())); org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService( 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); } }
}
package com.xyw.console.auth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import com.xyw.console.auth.dto.SystemUserCreateRequest;
import com.xyw.console.auth.dto.SystemUserUpdateRequest;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
/** File purpose (plain language): verifies that account authorization changes revoke old login versions while existing role boundaries remain enforced. */
class SystemUserAdminServiceTest {
private SystemUserMapper users;
private SystemUserAdminService service;
/** Plain purpose: build a developer-authenticated service fixture before each account-security test. Related files: SystemUserAdminService.java, AuthTokenFilter.java. Flow: developer principal -> SecurityContext -> service role boundary -> mocked user update. */
@BeforeEach void setUp() {
users = mock(SystemUserMapper.class);
service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), mock(PasswordEncoder.class));
authenticate("DEVELOPER");
}
/** Plain purpose: remove the test principal so it cannot influence the next security test. Related files: AuthTokenFilter.java, SystemUserAdminService.java. Flow: test completion -> SecurityContext clear -> isolated next request. */
@AfterEach void tearDown() { SecurityContextHolder.clearContext(); }
/** Plain purpose: prove that promotion changes the account version used to reject its old token. Related files: SystemUserAdminService.java, AuthTokenFilter.java. Flow: role edit -> authVersion increment -> next old-token request rejected. */
@Test void promotionIncrementsAuthVersion() {
SystemUserEntity user = activeUser("FINANCE", 4, Map.of("phone-assets", "READ"));
when(users.selectOne(any())).thenReturn(user);
service.updateUser(9L, new SystemUserUpdateRequest("SUPER_ADMIN", "ACTIVE", Map.of("phone-assets", "READ")));
assertEquals(5, user.getAuthVersion());
verify(users).updateById(user);
}
/** Plain purpose: prove that disabling an account revokes its prior session even when the role stays the same. Related files: SystemUserAdminService.java, AuthTokenFilter.java. Flow: status edit -> authVersion increment -> disabled account cannot keep old session. */
@Test void statusChangeIncrementsAuthVersion() {
SystemUserEntity user = activeUser("FINANCE", 4, Map.of());
when(users.selectOne(any())).thenReturn(user);
service.updateUser(9L, new SystemUserUpdateRequest("FINANCE", "DISABLED", Map.of()));
assertEquals(5, user.getAuthVersion());
}
/** Plain purpose: prove that changed page permissions invalidate a token that still carries the previous authorization state. Related files: PagePermissionService.java, SystemUserAdminService.java. Flow: permission edit -> normalized comparison -> authVersion increment. */
@Test void permissionChangeIncrementsAuthVersion() {
SystemUserEntity user = activeUser("FINANCE", 4, Map.of("phone-assets", "READ"));
when(users.selectOne(any())).thenReturn(user);
service.updateUser(9L, new SystemUserUpdateRequest("FINANCE", "ACTIVE", Map.of("phone-assets", "EDIT")));
assertEquals(5, user.getAuthVersion());
}
/** Plain purpose: prove that saving unchanged authorization facts does not unnecessarily sign the account out. Related files: SystemUserAdminService.java, PagePermissionService.java. Flow: no-op edit -> equal normalized values -> retained authVersion. */
@Test void noOpUpdateRetainsAuthVersion() {
SystemUserEntity user = activeUser("FINANCE", 4, Map.of("phone-assets", "READ"));
when(users.selectOne(any())).thenReturn(user);
service.updateUser(9L, new SystemUserUpdateRequest("FINANCE", "ACTIVE", Map.of("phone-assets", "READ")));
assertEquals(4, user.getAuthVersion());
}
/** Plain purpose: prove that a fresh developer principal can still create an allowed non-developer account. Related files: SystemUserAdminService.java, SystemUserCreateRequest.java. Flow: developer principal -> create boundary -> mapper insert -> safe response. */
@Test void developerCanCreateSuperAdministrator() {
when(users.selectCount(any())).thenReturn(0L);
PasswordEncoder encoder = mock(PasswordEncoder.class);
when(encoder.encode(any())).thenReturn("hash");
service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), encoder);
service.createUser(new SystemUserCreateRequest("admin_two", "SUPER_ADMIN", "strong-password-123", Map.of()));
verify(users).insert(any(SystemUserEntity.class));
}
/** Plain purpose: prove that an ordinary role cannot manage accounts even if it calls the service directly. Related files: SystemUserAdminService.java, SystemUserAdminController.java. Flow: finance principal -> role boundary -> access denial -> no mapper write. */
@Test void nonAdministratorCannotCreateAccounts() {
authenticate("FINANCE");
assertThrows(AccessDeniedException.class, () -> service.createUser(new SystemUserCreateRequest("admin_two", "SUPER_ADMIN", "strong-password-123", Map.of())));
verifyNoInteractions(users);
}
/** Plain purpose: install a minimal signed-token equivalent principal for one service test. Related files: AuthPrincipal.java, AuthTokenFilter.java. Flow: role code -> SecurityContext principal -> SystemUserAdminService authorization branch. */
private void authenticate(String roleCode) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(new AuthPrincipal(1L, "tester", roleCode, 1), null)); }
/** Plain purpose: create a persisted account shape with a validated permission map and active credentials. Related files: SystemUserEntity.java, PagePermissionService.java. Flow: test fixture -> mapper select result -> authorization-state comparison. */
private SystemUserEntity activeUser(String roleCode, int authVersion, Map<String, String> permissions) {
SystemUserEntity user = new SystemUserEntity();
user.setId(9L); user.setUsername("managed_user"); user.setRoleCode(roleCode); user.setStatus("ACTIVE"); user.setPasswordHash("hash"); user.setDeleteTime(0L); user.setAuthVersion(authVersion);
user.setPagePermissions(new PagePermissionService(new ObjectMapper()).validatePermissions(permissions));
return user;
}
}
package com.xyw.console.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
/** File purpose (plain language): verifies that security rejections provide safe browser actions without leaking account or token details. */
class SecurityConfigTest {
/** Plain purpose: verify a missing or invalid CSRF token receives refresh-and-retry guidance rather than an indistinguishable role error. Related files: SecurityConfig.java, auth-api-client.js. Flow: CSRF filter denial -> safe message -> browser refresh/retry. */
@Test void csrfFailureUsesRefreshAndRetryMessage() {
SecurityConfig config = new SecurityConfig();
assertEquals("安全校验已失效,请刷新页面后重试", config.accessDeniedMessage(new InvalidCsrfTokenException(new DefaultCsrfToken("X-XSRF-TOKEN", "_csrf", "expected"), "actual")));
}
/** Plain purpose: verify a valid session without an allowed role keeps the generic denial message. Related files: SecurityConfig.java, PagePermissionService.java. Flow: controller role check -> access denial -> safe 403 response. */
@Test void authorizationFailureKeepsGenericMessage() {
SecurityConfig config = new SecurityConfig();
assertEquals("没有权限", config.accessDeniedMessage(new AccessDeniedException("internal detail")));
}
}
package com.xyw.console.config;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
/** File purpose (plain language): verifies local development frontends that use an approved port can call the backend API. */
class WebConfigTest {
/** Plain purpose: ensure the separate 5175 integration frontend is allowed to send login requests to the local backend. Related files: WebConfig.java, frontend/vite.config.js. Flow: browser origin -> CORS allowlist -> /api/auth/login -> authentication controller. */
@Test void allowsIntegrationFrontendOnPort5175() {
InspectableCorsRegistry registry = new InspectableCorsRegistry();
new WebConfig().addCorsMappings(registry);
CorsConfiguration configuration = registry.configurations().get("/api/**");
assertTrue(configuration.getAllowedOrigins().contains("http://127.0.0.1:5175"));
}
/** Plain purpose: expose Spring's protected test-only CORS mapping view without changing production configuration. Related files: WebConfig.java. Flow: WebConfig registration -> registry map -> assertion. */
private static final class InspectableCorsRegistry extends CorsRegistry {
/** Plain purpose: return the mappings registered by WebConfig for a direct allowlist assertion. Related files: WebConfigTest.java. Flow: addCorsMappings -> configurations -> expected local origin. */
java.util.Map<String, CorsConfiguration> configurations() { return getCorsConfigurations(); }
}
}
...@@ -4,9 +4,18 @@ ...@@ -4,9 +4,18 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>学有为资产管理台</title> <title>学有为资产管理台</title>
<style>
html, body, #app { min-height: 100%; margin: 0; }
.app-boot-splash { display: grid; min-height: 100vh; place-items: center; overflow: hidden; background: radial-gradient(circle at 20% 15%, rgba(92, 118, 173, .2), transparent 32%), radial-gradient(circle at 82% 78%, rgba(98, 77, 148, .18), transparent 28%), #0d1524; }
.app-boot-splash__mark { width: 72px; height: 72px; border: 1px solid rgba(226, 232, 240, .16); border-radius: 50%; box-shadow: 0 0 0 12px rgba(226, 232, 240, .025), 0 0 72px rgba(130, 150, 210, .16); }
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app">
<main class="app-boot-splash" aria-label="正在打开应用">
<div class="app-boot-splash__mark" aria-hidden="true"></div>
</main>
</div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>
/** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js、DeviceAssetView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */ import { authState, signOut } from './modules/auth/auth-store.js';
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>` };
/** 文件用途(白话):根据认证状态决定渲染登录页、网络重试页或已登录后台外壳。 */
export default {
/** 代码作用(白话):向页面模板提供认证状态、退出动作、网络重试和菜单权限判断。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):路由守卫恢复身份 -> authState -> 此组件决定网络提示、登录页或后台外壳。 */
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);
}
/** 代码作用(白话):在会话检查遇到网络问题时重新加载当前地址,让路由守卫重新请求当前用户而不先跳登录页。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):网络错误提示按钮 -> retryConnection() -> 浏览器刷新当前 Hash 地址 -> bootstrapAuth() -> 后台、登录页或再次提示。 */
function retryConnection() {
window.location.reload();
}
return { authState, logout, canVisit, isAdministrator, retryConnection };
},
template: `<main v-if="authState.connectionError" class="auth-network-error" role="alert"><section class="auth-network-error__panel"><h1>网络连接异常</h1><p>暂时无法确认登录状态,请检查网络后重试。</p><button type="button" @click="retryConnection">重新连接</button></section></main><main v-else-if="!authState.ready" class="auth-loading" role="status" aria-live="polite">正在验证登录状态…</main><RouterView v-else-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>`
};
...@@ -4,10 +4,13 @@ import 'element-plus/dist/index.css'; ...@@ -4,10 +4,13 @@ import 'element-plus/dist/index.css';
import App from './App.js'; import App from './App.js';
import router from './router/index.js'; import router from './router/index.js';
import './styles/app.css'; import './styles/app.css';
import './styles/auth-state.css';
/** /**
* 代码作用(白话):创建重构后的唯一 Vue 应用入口,把路由和界面组件挂到页面根节点。 * 代码作用(白话):创建重构后的唯一 Vue 应用入口,把路由和界面组件挂到页面根节点。
* 关联文件:frontend/index.html、frontend/src/App.js、frontend/src/router/index.js。 * 关联文件:frontend/index.html、frontend/src/App.js、frontend/src/router/index.js。
* 关联逻辑(调用链/数据流):index.html -> main.js -> createApp() -> router -> 静态参考页或重构占位页。 * 关联逻辑(调用链/数据流):index.html -> main.js -> createApp() -> router -> 静态参考页或重构占位页。
*/ */
createApp(App).use(router).use(ElementPlus).mount('#app'); const application = createApp(App).use(router).use(ElementPlus);
await router.isReady();
application.mount('#app');
import { reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { signIn } from './auth-store.js';
import blackHoleImage from '../../../img/black_hole.png';
const REMEMBERED_USERNAME_STORAGE_KEY = 'xyw-asset-console.remembered-username';
/** 代码作用(白话):提供唯一的账号密码登录界面,并只在 Jeddy 成功登录的当次显示专属欢迎提示。关联文件:auth-store.js、AuthController.java、app.css。关联逻辑(调用链/数据流):登录表单 -> Auth API -> Cookie/用户状态 -> 路由跳转。 */
export default {
/** 代码作用(白话):准备登录表单状态,并从浏览器中读取此前选择记住的账号。关联文件:app.css、auth-store.js。关联逻辑(调用链/数据流):浏览器本地存储 -> 表单账号 -> 用户提交登录。 */
setup() {
/** 代码作用(白话):安全读取浏览器本地保存的账号;浏览器禁用本地存储时不影响登录。关联文件:LoginView.js。关联逻辑(调用链/数据流):localStorage -> form.username -> 登录表单。 */
function getRememberedUsername() {
try {
return window.localStorage.getItem(REMEMBERED_USERNAME_STORAGE_KEY) || '';
} catch (_) {
return '';
}
}
const form = reactive({ username: getRememberedUsername(), password: '' });
const rememberUsername = ref(Boolean(form.username));
const submitting = ref(false);
/** 代码作用(白话):按复选框状态仅保存或删除账号名,绝不保存密码和 Token。关联文件:LoginView.js。关联逻辑(调用链/数据流):复选框/登录成功 -> localStorage -> 下次打开表单自动回填。 */
function syncRememberedUsername() {
try {
if (rememberUsername.value && form.username.trim()) {
window.localStorage.setItem(REMEMBERED_USERNAME_STORAGE_KEY, form.username.trim());
return;
}
window.localStorage.removeItem(REMEMBERED_USERNAME_STORAGE_KEY);
} catch (_) {
// 浏览器禁用本地存储时,登录主流程仍可继续。
}
}
/** 代码作用(白话):提交现有账号密码数据、按需记住账号,并在完成后清除页面内存中的密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 submit -> signIn -> 账号本地存储/Jeddy 欢迎提示或通用错误 -> 资产总览。 */
async function submit() {
submitting.value = true;
try {
const user = await signIn(form);
syncRememberedUsername();
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 { blackHoleImage, form, rememberUsername, submitting, submit, syncRememberedUsername };
},
template: `
<main class="login-page" :style="{ '--login-background-image': 'url(' + blackHoleImage + ')' }">
<div class="login-page__atmosphere" aria-hidden="true"></div>
<section class="login-panel" aria-labelledby="login-title">
<header class="login-brand">
<div class="login-brand__mark" aria-hidden="true"><span></span><span></span><span></span></div>
<div>
<h1 id="login-title">学有为资产管理系统</h1>
</div>
</header>
<el-form class="login-form" @submit.prevent="submit">
<el-form-item class="login-form__item">
<el-input v-model="form.username" class="login-form__input" placeholder="请输入账号" autocomplete="username" aria-label="账号" />
</el-form-item>
<el-form-item class="login-form__item">
<div class="login-password-field">
<el-input v-model="form.password" class="login-form__input" placeholder="请输入密码" type="password" show-password autocomplete="current-password" aria-label="密码" @keyup.enter="submit" />
</div>
</el-form-item>
<el-checkbox v-model="rememberUsername" class="login-form__remember" @change="syncRememberedUsername">记住我的账号</el-checkbox>
<el-button type="primary" native-type="submit" :loading="submitting" class="login-submit">{{ submitting ? '正在登录' : '登录' }}</el-button>
</el-form>
</section>
</main>
`
};
/** 代码作用(白话):读取同站 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]) } : {}; }
/** Plain purpose: detect multipart form data so the browser can create its required boundary header. Related files: device-api-client.js, DeviceAssetController.java. Flow: device form -> FormData -> shared request -> browser multipart header -> controller binding. */
function isFormDataBody(body) { return typeof FormData !== 'undefined' && body instanceof FormData; }
/** 代码作用(白话):把请求失败原因和 HTTP 状态一起交给调用方,便于首次会话检查区分未登录和网络异常。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):fetch 失败响应 -> createRequestError() -> bootstrapAuth() -> 登录重定向或网络提示。 */
function createRequestError(message, status) { const error = new Error(message); error.status = status; return error; }
/** Plain purpose: notify the application that the server rejected the current session and it must return to login. Related files: auth-store.js, router/index.js. Flow: protected API 401 -> browser event -> cleared auth state -> login route. */
function notifyExpiredSession() { if (typeof window !== 'undefined') window.dispatchEvent(new Event('xyw-auth-expired')); }
/** 代码作用(白话):首次写操作前请求 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') return csrfHeader(); const existing = csrfHeader(); if (Object.keys(existing).length) return existing; const response = await fetch('/api/auth/csrf', { credentials: 'include' }); if (!response.ok) throw new Error('安全校验初始化失败'); const refreshed = csrfHeader(); if (!Object.keys(refreshed).length) throw new Error('安全校验初始化失败'); return refreshed; }
/** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */
export async function request(path, options = {}) { const { headers: suppliedHeaders = {}, body, ...requestOptions } = options; const method = (requestOptions.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const headers = { ...csrf, ...suppliedHeaders }; if (!isFormDataBody(body) && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; const response = await fetch(path, { ...requestOptions, body, credentials: 'include', headers }); const payload = await response.json().catch(() => ({})); if (response.status === 401 && path !== '/api/auth/login' && path !== '/api/auth/me') notifyExpiredSession(); if (!response.ok || payload.code !== 200) throw createRequestError(payload.message || '请求失败', response.ok ? payload.code : response.status); 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, connectionError: false });
/** Plain purpose: remove a browser identity whose server-side session is no longer valid and return to the public login route. Related files: auth-api-client.js, router/index.js. Flow: protected API 401 -> browser event -> authState clear -> login route. */
export function expireSession() { authState.user = null; authState.connectionError = false; authState.ready = true; if (typeof window !== 'undefined') window.location.hash = '#/login'; }
/** Plain purpose: receive a shared API-session-expired event without making auth-api-client and auth-store import each other. Related files: auth-api-client.js, auth-store.js. Flow: request helper -> browser event -> expireSession -> login route. */
if (typeof window !== 'undefined') window.addEventListener('xyw-auth-expired', expireSession);
/** 代码作用(白话):向后端恢复一次当前会话,并区分未登录和网络无法连接两种结果。关联文件:auth-api-client.js、router/index.js、App.js。关联逻辑(调用链/数据流):路由守卫 -> /api/auth/me -> 401 交给路由跳登录,网络错误写入 connectionError -> App 显示重试页。 */
export async function bootstrapAuth() { const timeoutMs = typeof window !== 'undefined' && window.__XYW_AUTH_CHECK_TIMEOUT_MS ? window.__XYW_AUTH_CHECK_TIMEOUT_MS : 8000; let timeoutId; authState.connectionError = false; try { authState.user = await Promise.race([getCurrentUser(), new Promise((_, reject) => { timeoutId = window.setTimeout(() => reject(new Error('SESSION_CHECK_TIMEOUT')), timeoutMs); })]); } catch (error) { authState.user = null; authState.connectionError = error.status !== 401; } finally { if (timeoutId) window.clearTimeout(timeoutId); authState.ready = true; } return authState.user; }
/** 代码作用(白话):完成登录并保存安全用户资料供当前页面使用。关联文件:LoginView.js。关联逻辑(调用链/数据流):表单 -> login API -> authState -> 跳转。 */
export async function signIn(credentials) { authState.connectionError = false; 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.connectionError = false; authState.ready = true; } }
/** 文件用途(白话):统一调用设备资产接口,并将非成功响应变为页面可显示的错误。 */ import { request } from '../auth/auth-api-client.js';
async function request(path, options = {}) {
/** 代码作用(白话):发送一次设备接口请求并提取统一响应数据。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):页面动作 -> request -> ApiResponse -> 页面状态或错误提示。 */ /** File purpose (plain language): sends device requests through the shared cookie and CSRF-safe authentication helper. */
const response = await fetch(path, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '设备资产请求失败');
return payload.data;
}
/** 代码作用(白话):按筛选条件读取设备列表。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):筛选器 -> GET -> 分页响应 -> 表格。 */ /** 代码作用(白话):按筛选条件读取设备列表。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):筛选器 -> GET -> 分页响应 -> 表格。 */
export function listDeviceAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key,value); }); return request('/api/device-assets?' + params); } export function listDeviceAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key,value); }); return request('/api/device-assets?' + params); }
/** 代码作用(白话):用 multipart 表单新建设备。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 文件/设备写入 -> 响应。 */ /** 代码作用(白话):用 multipart 表单新建设备。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 文件/设备写入 -> 响应。 */
......
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 { ElMessage, ElMessageBox } from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn.mjs'; 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'; import { createPhoneAsset, deletePhoneAsset, listPhoneAssets, updatePhoneAsset } from './phone-api-client.js';
/** /**
...@@ -22,6 +23,8 @@ export default { ...@@ -22,6 +23,8 @@ export default {
const total = ref(0); const total = ref(0);
const dialogVisible = ref(false); const dialogVisible = ref(false);
const editingId = ref(null); 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' }); const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: 'ALL' });
let searchTimer = null; let searchTimer = null;
let latestRequest = 0; let latestRequest = 0;
...@@ -257,6 +260,7 @@ export default { ...@@ -257,6 +260,7 @@ export default {
changePage, changePage,
changePageSize, changePageSize,
changeStatus, changeStatus,
canEdit,
confirmDelete, confirmDelete,
dialogVisible, dialogVisible,
editingId, editingId,
...@@ -285,9 +289,9 @@ export default { ...@@ -285,9 +289,9 @@ export default {
}, },
template: ` template: `
<el-config-provider :locale="elementLocale"><section class="phone-asset-page phone-asset-list-page"> <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__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-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 class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required> <el-form-item class="phone-asset-modal__form-row" label="手机号" required>
...@@ -315,4 +319,4 @@ export default { ...@@ -315,4 +319,4 @@ export default {
</el-dialog> </el-dialog>
</section></el-config-provider> </section></el-config-provider>
` `
}; };
\ No newline at end of file
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/** /**
* 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字。 * 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字。
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。 * 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误。 * 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误。
*/ */
async function request(path, options = {}) { 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(); const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败'); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败');
return payload.data; 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 { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchPhoneAssets } from './wecom-api-client.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. */ /** File purpose (plain language): renders the enterprise WeChat asset list and its creation dialog with reusable asset searches. */
...@@ -13,6 +14,8 @@ export default { ...@@ -13,6 +14,8 @@ export default {
const total = ref(0); const total = ref(0);
const companyOptions = ref([]); const companyOptions = ref([]);
const ownerOptions = ref([]); const ownerOptions = ref([]);
/** 代码作用(白话):把企微页面的有效权限转换为新增按钮和弹窗是否可用;关联文件:auth-store.js、WecomAccountController.java。关联逻辑(调用链/数据流):认证资料 -> EDIT 判断 -> 写操作控件 -> 后端 EDIT 校验。 */
const canEdit = computed(() => authState.user?.pagePermissions?.['reference-wecom'] === 'EDIT');
let searchTimer; 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 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 }); const form = reactive({ wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null });
...@@ -69,14 +72,14 @@ export default { ...@@ -69,14 +72,14 @@ export default {
function formatRelation(name, id) { return id === null || id === undefined ? '—' : `${name || '—'}(ID:${id})`; } function formatRelation(name, id) { return id === null || id === undefined ? '—' : `${name || '—'}(ID:${id})`; }
onMounted(loadPage); 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: ` template: `
<section class="phone-asset-list-page wecom-account-page"> <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__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> <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> <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>
</section> </section>
` `
}; };
\ No newline at end of file
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/** File purpose (plain language): centralizes enterprise WeChat asset requests and turns API envelopes into usable data. */ /** 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. */ /** 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 = {}) { 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(); const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '企业微信资产请求失败'); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '企业微信资产请求失败');
return payload.data; return payload.data;
...@@ -25,4 +27,4 @@ export function searchCompanyProfiles(keyword) { return request(`/api/wecom-acco ...@@ -25,4 +27,4 @@ export function searchCompanyProfiles(keyword) { return request(`/api/wecom-acco
export function searchPhoneAssets(keyword) { return request(`/api/wecom-accounts/lookups/phone-assets?keyword=${encodeURIComponent(keyword || '')}`); } export function searchPhoneAssets(keyword) { return request(`/api/wecom-accounts/lookups/phone-assets?keyword=${encodeURIComponent(keyword || '')}`); }
/** Code purpose (plain language): searches optional WeCom owners. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */ /** Code purpose (plain language): searches optional WeCom owners. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyPersons(keyword) { return request(`/api/wecom-accounts/lookups/company-persons?keyword=${encodeURIComponent(keyword || '')}`); } export function searchCompanyPersons(keyword) { return request(`/api/wecom-accounts/lookups/company-persons?keyword=${encodeURIComponent(keyword || '')}`); }
\ No newline at end of file
...@@ -3,6 +3,9 @@ import LegacyReferenceView from '../reference/LegacyReferenceView.js'; ...@@ -3,6 +3,9 @@ import LegacyReferenceView from '../reference/LegacyReferenceView.js';
import PhoneAssetView from '../modules/phone/PhoneAssetView.js'; import PhoneAssetView from '../modules/phone/PhoneAssetView.js';
import WecomAccountView from '../modules/wecom/WecomAccountView.js'; import WecomAccountView from '../modules/wecom/WecomAccountView.js';
import DeviceAssetView from '../modules/device/DeviceAssetView.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,21 @@ const router = createRouter({ ...@@ -22,14 +25,21 @@ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{ path: '/', redirect: '/overview' }, { path: '/', redirect: '/overview' },
{ path: '/overview', component: createPlaceholderView('资产总览') }, { path: '/login', component: LoginView, meta: { public: true } },
{ path: '/phone-assets', component: PhoneAssetView }, { path: '/overview', component: createPlaceholderView('资产总览'), meta: { page: 'overview' } },
{ path: '/device-assets', component: DeviceAssetView }, { path: '/phone-assets', component: PhoneAssetView, meta: { page: 'phone-assets' } },
{ path: '/domain', component: createPlaceholderView('域名资料') }, { path: '/device-assets', component: DeviceAssetView, meta: { administratorOnly: true } },
{ path: '/alerts', component: createPlaceholderView('提醒中心') }, { path: '/domain', component: createPlaceholderView('域名资料'), meta: { page: 'domain' } },
{ path: '/reference/wecom', component: WecomAccountView }, { 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' } } { path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } }
] ]
}); });
/** 代码作用(白话):在进入业务路由前恢复 Cookie 会话,未登录用户一律进入新建的登录路由。关联文件:auth-store.js、LoginView.js。关联逻辑(调用链/数据流):Hash 地址 -> 路由守卫 -> /api/auth/me -> 业务页或 /login。 */
/** 代码作用(白话):在已登录后继续按逐页权限和管理角色校验目标路由,避免只隐藏菜单就能手输地址绕过;关联文件:App.js、PagePermissionService.java。关联逻辑(调用链/数据流):路由地址 -> authState 有效权限 -> 放行或返回总览。 */
/** 代码作用(白话):集中决定首轮认证后的目标页面;网络异常时保留当前地址交给错误页,其他结果再执行公开页、登录页和权限判断。关联文件:auth-store.js、auth-api-client.js、App.js。关联逻辑(调用链/数据流):Hash 地址 -> bootstrapAuth() -> connectionError 或 user -> 当前地址、/login、/overview 或业务页。 */
router.beforeEach(async to => { if (!authState.ready) await bootstrapAuth(); if (authState.connectionError) return true; 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; export default router;
/* 文件用途(白话):集中定义认证结果未确定或网络不可用时的独立页面外观,不与登录密码输入框样式混在一起。 */
/* 代码作用(白话):在极少数应用已挂载但路由仍未完成的时序中,保持中性背景而不向视觉界面显示认证提示文字。关联文件:App.js、main.js、index.html。关联逻辑(调用链/数据流):HTML 启动底图 -> Vue 挂载前等待路由 -> 此样式作为 App 认证分支的视觉兜底 -> 实际路由页面。 */
.auth-loading {
min-height: 100vh;
font-size: 0;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
}
/* 代码作用(白话):在认证服务不可连接时展示独立的重试页面,避免错误地显示后台或跳转登录页。关联文件:App.js、auth-store.js、router/index.js。关联逻辑(调用链/数据流):会话请求网络失败 -> connectionError=true -> App 网络异常分支 -> 用户重新连接。 */
.auth-network-error {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
color: #e8eefc;
background: radial-gradient(circle at 20% 15%, rgba(92, 118, 173, .2), transparent 32%), radial-gradient(circle at 82% 78%, rgba(98, 77, 148, .18), transparent 28%), #0d1524;
}
.auth-network-error__panel {
width: min(420px, 100%);
padding: 34px;
border: 1px solid rgba(226, 232, 240, .12);
border-radius: 16px;
text-align: center;
background: rgba(18, 28, 48, .72);
box-shadow: 0 22px 60px rgba(0, 0, 0, .22);
}
.auth-network-error__panel h1 { margin: 0; font-size: 22px; }
.auth-network-error__panel p { margin: 14px 0 24px; color: rgba(226, 232, 240, .72); line-height: 1.7; }
.auth-network-error__panel button { min-width: 112px; height: 38px; border: 0; border-radius: 7px; color: #fff; background: #5b7ed0; cursor: pointer; }
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 }) => {
/** Plain purpose: add the browser-visible CSRF Cookie used by the shared request helper in either local Playwright port. Related files: auth-api-client.js, SecurityConfig.java. Flow: test context Cookie -> X-XSRF-TOKEN header -> protected request fixture. */
await page.context().addCookies([{ name: 'XSRF-TOKEN', value: 'playwright-csrf', domain: '127.0.0.1', path: '/' }]);
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 = [ const referencePages = [
{ path: '#/reference/phone', title: '手机号卡旧界面参考' } { path: '#/reference/phone', title: '手机号卡旧界面参考' }
......
import { expect, test } from '@playwright/test'; import { expect, test } from './authenticated-test.js';
/** /**
* 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表。 * 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表。
...@@ -125,4 +125,4 @@ test('窄窗口下弹窗不超出屏幕且页面没有横向滚动', async ({ pa ...@@ -125,4 +125,4 @@ test('窄窗口下弹窗不超出屏幕且页面没有横向滚动', async ({ pa
expect(bounds.x).toBeGreaterThanOrEqual(0); expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(320); expect(bounds.x + bounds.width).toBeLessThanOrEqual(320);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}); });
\ No newline at end of file
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. */ /** 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 }) => { test('creates an enterprise WeChat asset and exposes the required form', async ({ page }) => {
...@@ -26,4 +26,4 @@ test('external number links to the related enterprise WeChat asset filter', asyn ...@@ -26,4 +26,4 @@ test('external number links to the related enterprise WeChat asset filter', asyn
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
const link = page.getByRole('link', { name: '外部号码' }); const link = page.getByRole('link', { name: '外部号码' });
await expect(link).toHaveAttribute('href', '#/reference/wecom?phoneAssetId=9'); await expect(link).toHaveAttribute('href', '#/reference/wecom?phoneAssetId=9');
}); });
\ No newline at end of file
## Context
`origin/master` at `27c7b6d` contains device, phone, and WeCom management but not the authentication module. The source chain is `1cd0087`, `9d5953f`, `3f7c655`, `52071f0`, and `7a452f3`; `52071f0` changes only a historical OpenSpec document and is intentionally excluded under the approved documentation policy.
### Scope-file purpose notes
| Files | Purpose |
|---|---|
| `backend/src/main/java/com/xyw/console/auth/**` and `config/{SecurityConfig,WebConfig}.java` | Login, token/session validation, authorization, system-user administration, and HTTP security policy. |
| `backend/src/main/resources/db/migration/V1__system_user_auth_permissions.sql` | Adds permission/session fields required by authentication. |
| `backend/src/main/java/com/xyw/console/asset/**` changed files | Applies authentication and CSRF checks to existing asset APIs. |
| `frontend/src/modules/auth/**`, `router/index.js`, `main.js`, `App.js`, `index.html` | Login UI, browser state, first-load route decision, and neutral startup display. |
| Changed device, phone, WeCom, system-user frontend files and test files | Keep protected workspaces compatible and prove regressions are avoided. |
| `openspec/changes/integrate-authentication-to-master/**` | Sole planning record for this integration. |
## Goals / Non-Goals
**Goals:**
- Use a neutral first paint while the router validates the session.
- Send unauthenticated visitors to login without showing a protected shell.
- Keep a network failure at the requested URL with a retry path.
- Preserve asset management workflows for permitted users.
**Non-Goals:**
- Do not push, merge to `master`, deploy, or run a database migration without a further user approval.
- Do not include historical source-branch OpenSpec records.
- Do not redesign unrelated asset features.
## Decisions
1. Integrate four runtime commits on a new branch based on `origin/master`; omit `52071f0` because it affects only excluded historical documentation.
2. The router owns the initial current-user decision. The API client handles later expired sessions, avoiding competing redirects.
3. `index.html` provides a neutral boot screen and `main.js` waits for router readiness, avoiding the dashboard-to-login flash.
4. Backend and frontend are a paired release. A database without production data lowers data-risk now, but the inserted administrator's password hash, role, and page permissions must match backend rules before release.
5. Every new or modified method, object method, arrow function, and business callback in scope retains a plain-language comment describing its purpose, related files, and call/data flow.
## Risks / Trade-offs
- [Administrator cannot log in] -> verify password hash, administrator role, and page permissions against the implemented backend before release.
- [Asset regression] -> retain current `master` behavior and run device, phone, WeCom, and frontend regression suites.
- [Refresh flash returns] -> preserve the neutral boot contract and run the dedicated browser checks.
- [Session/CSRF mismatch] -> test authenticated and rejected writes in backend and browser suites.
- [Future production migration] -> approve migration execution separately; application rollback must not blindly delete user/role/permission data.
## Migration Plan
1. Use the isolated integration branch and verify its test evidence.
2. Before any release, insert a verified administrator using the backend-compatible password hash, role, and permissions.
3. Review and run the database migration only after explicit approval.
4. Deploy matching backend and frontend artifacts together.
5. If authentication or core asset behavior fails, roll back application artifacts together; preserve database data for review.
## Open Questions
1. Which exact administrator username, password hash process, role, and page permissions will be inserted before the first release?
2. When the branch is reviewed, do you approve a remote push and a new MR? Those actions remain out of scope for the current execution.
## Why
Authentication is a dependent feature chain outside `master`. The refresh-only fix cannot work on its own because `master` lacks the login, session, permission, and database code it needs.
## What Changes
- Integrate the authentication foundation, session/CSRF correction, login UI, and refresh redirect fix on a branch based on current `master`.
- Retain current device, phone, and WeCom asset behavior while adding authentication and page permissions.
- Exclude historical source-branch OpenSpec records; retain only this integration plan.
- **BREAKING**: management pages require a valid authenticated session after release.
## Capabilities
### New Capabilities
- `authentication-master-integration`: safe integration, verification, and release readiness for the full authentication feature.
### Modified Capabilities
<!-- None. -->
## Impact
- Backend authentication, authorization, CSRF protection, system-user data, and the related migration.
- Frontend login, startup routing, protected asset workspaces, and refresh behavior.
- Release readiness requires a valid administrator account and matching frontend/backend artifacts.
## ADDED Requirements
### Requirement: Complete runtime authentication integration
The integration SHALL include the authentication foundation, session/CSRF correction, login experience, and refresh redirect fix as one feature branch based on current `master`. Historical source-branch OpenSpec records MUST NOT be present in the final integration diff.
#### Scenario: Integration branch is prepared
- **WHEN** maintainers inspect the integration branch
- **THEN** it contains the required runtime code and tests but excludes historical source-branch OpenSpec records
### Requirement: Safe initial routing
The frontend SHALL show an identity-neutral startup state until initial session validation completes. The router MUST own the initial current-user route decision. An unauthenticated visitor MUST NOT see a protected workspace before reaching login.
#### Scenario: Unauthenticated protected refresh
- **WHEN** an unauthenticated visitor refreshes a protected URL
- **THEN** the neutral boot state is shown before the login route, without a dashboard shell or dashboard-shaped placeholder
#### Scenario: Initial network failure
- **WHEN** initial current-user validation fails from a network error rather than a 401 response
- **THEN** the browser remains on the requested URL and provides a retry state
### Requirement: Protected asset compatibility
Permitted authenticated users SHALL retain their device, phone, WeCom, and system-user workflows. Unauthenticated or unauthorized protected writes MUST be rejected by the backend security policy.
#### Scenario: Permitted protected write
- **WHEN** a permitted signed-in user performs an allowed asset write with valid CSRF data
- **THEN** the backend accepts the operation
#### Scenario: Rejected protected write
- **WHEN** a visitor lacks a valid session or required permission for a protected write
- **THEN** the backend rejects the operation and the frontend does not report success
## 1. Integration preparation
- [x] 1.1 Confirm `origin/master` base `27c7b6d` and source commits `1cd0087`, `9d5953f`, `3f7c655`, `52071f0`, and `7a452f3`.
- [x] 1.2 Confirm that the database has no production data, no release is scheduled, and the first administrator will be inserted directly into the database before release.
- [x] 1.3 Create an isolated branch from current `master` without changing source branches or the user's dirty worktree.
- [x] 1.4 Exclude historical source-branch OpenSpec records; retain only this integration plan.
## 2. Runtime integration
- [x] 2.1 Apply `1cd0087`, `9d5953f`, `3f7c655`, and `7a452f3` in dependency order.
- [x] 2.2 Omit `52071f0` because it changes only historical documentation excluded by task 1.4.
- [x] 2.3 Confirm no code conflicts occurred with current device, phone, or WeCom changes.
- [x] 2.4 Retain the required beginner comments for new or modified backend and frontend methods/callbacks.
## 3. Verification
- [x] 3.1 Run the backend test suite.
- [x] 3.2 Run the frontend production build.
- [x] 3.3 Run the dedicated login and refresh browser tests.
- [x] 3.4 Run the complete frontend browser regression suite for device, phone, WeCom, and legacy pages.
- [x] 3.5 Review the final staged diff for accidental source documents, secrets, generated files, and omitted runtime files.
## 4. Deferred release actions
- [ ] 4.1 Verify the first inserted administrator's password hash, role, and page permissions against the backend implementation.
- [ ] 4.2 Obtain explicit approval before pushing this branch, creating an MR, merging to `master`, deploying, or running the database migration.
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