Commit 2857e4ea by DaiJiezhang

feat: 公司档案工作台与登录安全加固

本提交是 codex 已完成、此前一直留在工作区未提交的改动,本次原样归档:

- 公司档案:后端 Controller / Service / 4 个 DTO,前端 company-profile 模块与路由注册,配套单测与 e2e
- 登录安全:新增 LoginAttemptGuard / InMemoryLoginAttemptGuard / LoginThrottledException 失败限流,AuthService 与异常处理相应调整
- 企微资产:注册主体选择、弹窗滚动复位,相关 Service 与测试更新
- 账号权限:SystemUserAdmin 相关调整与登录锁定解除入口
- openspec 变更记录 4 份,.gitignore 补充两条本地产物忽略

未经改动地提交,作者本人未复核;如需拆分或回退请以此提交为界。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent c6787499
...@@ -31,5 +31,7 @@ backend/.mvn/jvm.config ...@@ -31,5 +31,7 @@ backend/.mvn/jvm.config
.idea/ .idea/
.vite/ .vite/
frontend/.playwright-cli/ frontend/.playwright-cli/
frontend/output/
frontend/test-results/ frontend/test-results/
test-results/ test-results/
openspec/changes/integrate-authentication-to-master/
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.CompanyProfilePageQuery;
import com.xyw.console.asset.dto.CompanyProfilePageResponse;
import com.xyw.console.asset.dto.CompanyProfileResponse;
import com.xyw.console.asset.dto.CompanyProfileSaveRequest;
import com.xyw.console.asset.service.CompanyProfileService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 文件用途(白话):提供公司档案分页和新增接口,并在每次操作前检查页面权限。 */
@RestController
@RequestMapping("/api/company-profiles")
public class CompanyProfileController {
private final CompanyProfileService service;
private final PagePermissionService permissions;
/**
* 代码作用(白话):接收公司档案查询服务和页面权限服务,供 HTTP 请求统一调用。
* 关联文件:CompanyProfileService.java、PagePermissionService.java。
* 关联逻辑(调用链/数据流):HTTP Controller -> READ 权限校验 -> Service -> Mapper。
*/
public CompanyProfileController(CompanyProfileService service, PagePermissionService permissions) {
this.service = service;
this.permissions = permissions;
}
/**
* 代码作用(白话):接收公司档案分页搜索参数,校验查看权限后返回统一 JSON 数据。
* 关联文件:CompanyProfilePageQuery.java、CompanyProfileService.java、company-profile-api-client.js。
* 关联逻辑(调用链/数据流):GET /api/company-profiles -> 权限校验 -> Service.page -> ApiResponse -> Vue 表格。
*/
@GetMapping
public ApiResponse<CompanyProfilePageResponse> page(@Valid CompanyProfilePageQuery query) {
permissions.require(PagePermissionService.COMPANY_PROFILE, "READ");
return ApiResponse.success(service.page(query));
}
/**
* 代码作用(白话):接收新增公司档案的表单,在确认编辑权限后交给业务服务写入。
* 关联文件:CompanyProfileSaveRequest.java、CompanyProfileService.java、CompanyProfileView.js。
* 关联逻辑(调用链/数据流):新增弹窗 -> POST /api/company-profiles -> EDIT 校验 -> Service.create -> JSON。
*/
@PostMapping
public ApiResponse<CompanyProfileResponse> create(@Valid @RequestBody CompanyProfileSaveRequest request) {
permissions.require(PagePermissionService.COMPANY_PROFILE, "EDIT");
return ApiResponse.success("新增成功", service.create(request));
}
}
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收公司档案只读列表的分页和关键词参数,不承载任何新增或编辑数据。 */
public record CompanyProfilePageQuery(
@Min(1) Integer page,
@Min(1) @Max(100) Integer size,
String keyword) {
/**
* 代码作用(白话):在浏览器未传页码时给公司档案列表提供安全的第一页默认值。
* 关联文件:CompanyProfileController.java、CompanyProfileService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedPage -> MyBatis-Plus 分页查询。
*/
public int resolvedPage() {
return page == null ? 1 : page;
}
/**
* 代码作用(白话):限制每次读取的公司档案数量,避免一次请求返回过多数据。
* 关联文件:CompanyProfileService.java、CompanyProfileView.js。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedSize -> Mapper limit -> 前端分页器。
*/
public int resolvedSize() {
return size == null ? 20 : size;
}
}
package com.xyw.console.asset.dto;
import java.util.List;
/** 文件用途(白话):把公司档案记录、总数和当前分页信息一起返回给只读列表页面。 */
public record CompanyProfilePageResponse(
List<CompanyProfileResponse> records,
long total,
int page,
int size) {
}
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
/** 文件用途(白话):定义公司档案页面允许展示的字段,不暴露内部 ID 和软删除标志。 */
public record CompanyProfileResponse(
String companyName,
String shortName,
String unifiedSocialCreditCode,
String address,
String contactName,
String contactValue,
LocalDateTime createTime,
LocalDateTime updateTime) {
}
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
/**
* 文件用途(白话):定义新增公司档案时浏览器可以提交的业务字段,隔离数据库内部字段和审计字段。
* 关联文件:CompanyProfileController.java、CompanyProfileService.java、CompanyProfileView.js。
* 关联逻辑(调用链/数据流):新增弹窗表单 -> POST JSON -> 本 DTO 校验 -> Service 创建实体。
*/
public record CompanyProfileSaveRequest(
@NotBlank(message = "公司名称不能为空") String companyName,
String shortName,
String unifiedSocialCreditCode,
String address,
String contactName,
String contactValue) {
}
package com.xyw.console.asset.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.CompanyProfilePageQuery;
import com.xyw.console.asset.dto.CompanyProfilePageResponse;
import com.xyw.console.asset.dto.CompanyProfileResponse;
import com.xyw.console.asset.dto.CompanyProfileSaveRequest;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.mapper.CompanyProfileMapper;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Service;
/** 文件用途(白话):负责读取和新增有效公司档案,并转换成页面可展示且不含内部 ID 的数据。 */
@Service
public class CompanyProfileService {
private final CompanyProfileMapper mapper;
/**
* 代码作用(白话):接收公司档案表的数据库访问入口,供只读列表查询使用。
* 关联文件:CompanyProfileMapper.java、CompanyProfileController.java。
* 关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_company_profile。
*/
public CompanyProfileService(CompanyProfileMapper mapper) {
this.mapper = mapper;
}
/**
* 代码作用(白话):按页读取符合关键词的有效公司档案,并删除页面不应看到的内部字段。
* 关联文件:CompanyProfilePageQuery.java、CompanyProfileResponse.java、CompanyProfileView.js。
* 关联逻辑(调用链/数据流):GET 参数 -> Query -> Mapper 分页 -> Response -> 公司档案表格。
*/
public CompanyProfilePageResponse page(CompanyProfilePageQuery query) {
Page<CompanyProfileEntity> page = mapper.selectPage(
new Page<>(query.resolvedPage(), query.resolvedSize()), activeQuery(query));
List<CompanyProfileResponse> records = page.getRecords().stream().map(this::toResponse).toList();
return new CompanyProfilePageResponse(records, page.getTotal(), query.resolvedPage(), query.resolvedSize());
}
/**
* 代码作用(白话):将新增表单中的业务字段写入公司档案,并由服务端统一初始化审计字段。
* 关联文件:CompanyProfileSaveRequest.java、CompanyProfileMapper.java、CompanyProfileController.java。
* 关联逻辑(调用链/数据流):POST 表单 -> Controller.create -> Service.create -> Mapper.insert -> Response。
*/
public CompanyProfileResponse create(CompanyProfileSaveRequest request) {
CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setCompanyName(normalizeRequiredText(request.companyName(), "公司名称不能为空"));
entity.setShortName(normalizeOptionalText(request.shortName()));
entity.setUnifiedSocialCreditCode(normalizeOptionalText(request.unifiedSocialCreditCode()));
entity.setAddress(normalizeOptionalText(request.address()));
entity.setContactName(normalizeOptionalText(request.contactName()));
entity.setContactValue(normalizeOptionalText(request.contactValue()));
LocalDateTime now = LocalDateTime.now();
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setDeleteTime(0L);
if (mapper.insert(entity) != 1) {
throw new IllegalStateException("公司档案新增失败");
}
return toResponse(entity);
}
/**
* 代码作用(白话):组合“未删除”和关键词条件,保证公司档案页面只读取仍有效的数据。
* 关联文件:CompanyProfileEntity.java、CompanyProfilePageQuery.java。
* 关联逻辑(调用链/数据流):页面关键词 -> 查询条件 -> as_company_profile SQL where/order by。
*/
private LambdaQueryWrapper<CompanyProfileEntity> activeQuery(CompanyProfilePageQuery query) {
LambdaQueryWrapper<CompanyProfileEntity> wrapper = new LambdaQueryWrapper<CompanyProfileEntity>()
.eq(CompanyProfileEntity::getDeleteTime, 0L);
applyKeywordFilter(wrapper, query.keyword());
return wrapper.orderByDesc(CompanyProfileEntity::getId);
}
/**
* 代码作用(白话):把一个关键词匹配到公司档案可搜索的六个文本字段中的任意一个。
* 关联文件:CompanyProfileView.js、CompanyProfileEntity.java。
* 关联逻辑(调用链/数据流):搜索框文本 -> OR 查询条件 -> Mapper 返回匹配记录。
*/
private void applyKeywordFilter(LambdaQueryWrapper<CompanyProfileEntity> wrapper, String keyword) {
if (!hasText(keyword)) {
return;
}
wrapper.and(condition -> condition
.like(CompanyProfileEntity::getCompanyName, keyword)
.or().like(CompanyProfileEntity::getShortName, keyword)
.or().like(CompanyProfileEntity::getUnifiedSocialCreditCode, keyword)
.or().like(CompanyProfileEntity::getAddress, keyword)
.or().like(CompanyProfileEntity::getContactName, keyword)
.or().like(CompanyProfileEntity::getContactValue, keyword));
}
/**
* 代码作用(白话):判断搜索框是否真的输入了内容,避免空字符串意外改变查询结果。
* 关联文件:CompanyProfilePageQuery.java、CompanyProfileService.java。
* 关联逻辑(调用链/数据流):请求参数 -> hasText -> 是否追加关键词条件。
*/
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
/**
* 代码作用(白话):去掉必填文本两端空格,并阻止空白值绕过 HTTP 层校验直接写入数据库。
* 关联文件:CompanyProfileSaveRequest.java、CompanyProfileController.java。
* 关联逻辑(调用链/数据流):浏览器公司名称 -> DTO 校验/本方法 -> Entity.companyName -> 数据库。
*/
private String normalizeRequiredText(String value, String errorMessage) {
String normalized = normalizeOptionalText(value);
if (!hasText(normalized)) {
throw new IllegalArgumentException(errorMessage);
}
return normalized;
}
/**
* 代码作用(白话):清理可选文本两端空格,并将空白内容统一为 null,避免数据库保存无意义空格。
* 关联文件:CompanyProfileSaveRequest.java、CompanyProfileEntity.java。
* 关联逻辑(调用链/数据流):弹窗可选字段 -> 本方法 -> Entity 字段 -> Mapper.insert。
*/
private String normalizeOptionalText(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isEmpty() ? null : normalized;
}
/**
* 代码作用(白话):从实体挑选公司档案页面允许展示的字段,隐藏内部 ID 和软删除标志。
* 关联文件:CompanyProfileEntity.java、CompanyProfileResponse.java。
* 关联逻辑(调用链/数据流):数据库实体 -> Response DTO -> ApiResponse -> Vue 表格。
*/
private CompanyProfileResponse toResponse(CompanyProfileEntity entity) {
return new CompanyProfileResponse(
entity.getCompanyName(),
entity.getShortName(),
entity.getUnifiedSocialCreditCode(),
entity.getAddress(),
entity.getContactName(),
entity.getContactValue(),
entity.getCreateTime(),
entity.getUpdateTime());
}
}
...@@ -62,9 +62,10 @@ public class WecomAccountService { ...@@ -62,9 +62,10 @@ public class WecomAccountService {
* 关联文件:WecomAccountController.java、WecomAccountPageQuery.java、WecomAccountResponse.java。 * 关联文件:WecomAccountController.java、WecomAccountPageQuery.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):GET 请求 -> page -> WecomAccountMapper + 关联 Mapper -> 分页 DTO -> Vue 表格。 * 关联逻辑(调用链/数据流):GET 请求 -> page -> WecomAccountMapper + 关联 Mapper -> 分页 DTO -> Vue 表格。
*/ */
/** 代码作用(白话):创建企业微信资产并关联已有手机号或自动创建外部号码。关联文件:WecomAccountController.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):POST 表单 -> create -> 手机号查询或新增 -> 企微插入。 */ /** 代码作用(白话):创建企业微信资产前先验证注册主体,再关联已有手机号或自动创建外部号码。关联文件:WecomAccountController.java、CompanyProfileEntity.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):POST 表单 -> 注册主体校验 -> 手机号查询或新增 -> 企微插入。 */
@Transactional @Transactional
public WecomAccountResponse create(WecomAccountSaveRequest request) { public WecomAccountResponse create(WecomAccountSaveRequest request) {
requireActiveCompanyProfile(request.companyProfileId());
String phoneNumber = normalizePhoneNumber(request.phoneNumber()); String phoneNumber = normalizePhoneNumber(request.phoneNumber());
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).eq(PhoneAssetEntity::getPhoneNumber, phoneNumber)); PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).eq(PhoneAssetEntity::getPhoneNumber, phoneNumber));
boolean created = phone == null; boolean created = phone == null;
...@@ -80,6 +81,22 @@ public class WecomAccountService { ...@@ -80,6 +81,22 @@ public class WecomAccountService {
return companyProfileMapper.selectList(query.orderByDesc(CompanyProfileEntity::getId)).stream().limit(20).map(item -> new CompanyProfileLookupResponse(item.getId(), item.getCompanyName(), item.getShortName())).toList(); return companyProfileMapper.selectList(query.orderByDesc(CompanyProfileEntity::getId)).stream().limit(20).map(item -> new CompanyProfileLookupResponse(item.getId(), item.getCompanyName(), item.getShortName())).toList();
} }
/**
* 代码作用(白话):在注册主体 ID 已提供时确认它引用的是一条仍有效的公司档案,阻止伪造或已删除的关联 ID 写入,同时保留旧接口允许为空的兼容行为。
* 关联文件:CompanyProfileMapper.java、WecomAccountSaveRequest.java、AuthExceptionHandler.java。
* 关联逻辑(调用链/数据流):company_profile_id -> Mapper 查询有效记录 -> 参数错误或继续创建企微。
*/
private void requireActiveCompanyProfile(Long companyProfileId) {
if (companyProfileId == null) {
return;
}
if (companyProfileMapper.selectOne(new LambdaQueryWrapper<CompanyProfileEntity>()
.eq(CompanyProfileEntity::getId, companyProfileId)
.eq(CompanyProfileEntity::getDeleteTime, 0L)) == null) {
throw new IllegalArgumentException("注册主体不存在或已删除");
}
}
/** Code purpose (plain language): finds active phone assets which may be reused as registration phones. Related files: WecomAccountController.java, PhoneAssetEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */ /** Code purpose (plain language): finds active phone assets which may be reused as registration phones. Related files: WecomAccountController.java, PhoneAssetEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */
public List<PhoneAssetLookupResponse> searchPhoneAssets(String keyword) { public List<PhoneAssetLookupResponse> searchPhoneAssets(String keyword) {
LambdaQueryWrapper<PhoneAssetEntity> query = new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).like(hasText(keyword), PhoneAssetEntity::getPhoneNumber, keyword).orderByDesc(PhoneAssetEntity::getId); LambdaQueryWrapper<PhoneAssetEntity> query = new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).like(hasText(keyword), PhoneAssetEntity::getPhoneNumber, keyword).orderByDesc(PhoneAssetEntity::getId);
...@@ -162,7 +179,7 @@ public class WecomAccountService { ...@@ -162,7 +179,7 @@ public class WecomAccountService {
} }
/** /**
* 代码作用(白话):批量读取公司档案名称,供公司档案 ID 在页面显示为名称(ID) * 代码作用(白话):批量读取公司档案简称,简称为空时回退公司全称,供企微注册主体显示可读名称
* 关联文件:CompanyProfileEntity.java、CompanyProfileMapper.java、WecomAccountResponse.java。 * 关联文件:CompanyProfileEntity.java、CompanyProfileMapper.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):companyProfileId 集合 -> as_company_profile -> companyProfileName。 * 关联逻辑(调用链/数据流):companyProfileId 集合 -> as_company_profile -> companyProfileName。
*/ */
...@@ -172,7 +189,8 @@ public class WecomAccountService { ...@@ -172,7 +189,8 @@ public class WecomAccountService {
} }
return toNameMap(companyProfileMapper.selectList(new LambdaQueryWrapper<CompanyProfileEntity>() return toNameMap(companyProfileMapper.selectList(new LambdaQueryWrapper<CompanyProfileEntity>()
.in(CompanyProfileEntity::getId, ids).eq(CompanyProfileEntity::getDeleteTime, 0L)), .in(CompanyProfileEntity::getId, ids).eq(CompanyProfileEntity::getDeleteTime, 0L)),
CompanyProfileEntity::getId, CompanyProfileEntity::getCompanyName); CompanyProfileEntity::getId,
item -> hasText(item.getShortName()) ? item.getShortName() : item.getCompanyName());
} }
/** /**
......
package com.xyw.console.auth; package com.xyw.console.auth;
import com.xyw.console.common.ApiResponse; import com.xyw.console.common.ApiResponse;
import java.time.Duration;
import java.time.Instant;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
...@@ -16,4 +18,15 @@ public class AuthExceptionHandler { ...@@ -16,4 +18,15 @@ public class AuthExceptionHandler {
/** 代码作用(白话):把账号、角色和密码规则失败返回为明确的 400 提示;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):规则校验失败 -> 400 JSON -> 表单提示。 */ /** 代码作用(白话):把账号、角色和密码规则失败返回为明确的 400 提示;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):规则校验失败 -> 400 JSON -> 表单提示。 */
@ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> invalidArgument(IllegalArgumentException error) { return ApiResponse.error(400, error.getMessage()); } public ApiResponse<Void> invalidArgument(IllegalArgumentException error) { return ApiResponse.error(400, error.getMessage()); }
/**
* 代码作用(白话):账号名连续登录失败达到上限时返回 429,让登录页提示稍后重试而不是继续提示密码错误。
* 关联文件:LoginAttemptGuard.java、LoginView.js。
* 关联逻辑(调用链/数据流):assertAllowed 抛出 -> 本处理器换算剩余小时 -> 429 JSON -> 登录页提示。
*
* 安全要点:提示语对"账号存在"和"账号不存在"必须完全一致,否则锁定提示本身会重新泄露账号是否存在。
*/
@ExceptionHandler(LoginThrottledException.class) @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public ApiResponse<Void> loginThrottled(LoginThrottledException error) { return ApiResponse.error(429, "登录失败次数过多,账号已被临时锁定,请约 " + remainingHours(error.lockedUntil()) + " 小时后重试或联系管理员解锁"); }
/** 代码作用(白话):把锁定截止时间换算成向上取整的剩余小时数,避免提示"0 小时后重试"。关联文件:LoginAttemptGuard.java。关联逻辑(调用链/数据流):锁定截止时间 -> 剩余时长 -> 提示文案。 */
private long remainingHours(Instant lockedUntil) { return lockedUntil == null ? 24 : Math.max(1, (long) Math.ceil(Math.max(0, Duration.between(Instant.now(), lockedUntil).toMinutes()) / 60.0)); }
} }
...@@ -6,6 +6,8 @@ import com.xyw.console.asset.mapper.SystemUserMapper; ...@@ -6,6 +6,8 @@ import com.xyw.console.asset.mapper.SystemUserMapper;
import com.xyw.console.auth.dto.CurrentUserResponse; import com.xyw.console.auth.dto.CurrentUserResponse;
import com.xyw.console.auth.dto.LoginRequest; import com.xyw.console.auth.dto.LoginRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
...@@ -13,11 +15,43 @@ import org.springframework.stereotype.Service; ...@@ -13,11 +15,43 @@ import org.springframework.stereotype.Service;
@Service @Service
public class AuthService { public class AuthService {
private final SystemUserMapper users; private final PasswordEncoder encoder; private final AuthTokenService tokens; private final PagePermissionService permissions; private final SystemUserMapper users; private final PasswordEncoder encoder; private final AuthTokenService tokens; private final PagePermissionService permissions; private final LoginAttemptGuard loginAttempts;
/** 代码作用(白话):接收认证依赖;关联文件:AuthController.java、SystemUserMapper.java。关联逻辑(调用链/数据流):Controller -> Service -> 用户表/令牌/权限。 */ /** 代码作用(白话):账号不存在时用来占位比对的哈希,唯一用途是让失败路径与正常路径耗时一致。关联文件:AuthService.java。关联逻辑(调用链/数据流):启动时生成 -> 未知账号登录 -> BCrypt 比对 -> 统一耗时。 */
public AuthService(SystemUserMapper users, PasswordEncoder encoder, AuthTokenService tokens, PagePermissionService permissions) { this.users = users; this.encoder = encoder; this.tokens = tokens; this.permissions = permissions; } private final String timingEqualizerHash;
/** 代码作用(白话):验证账号密码并签发登录 Cookie;关联文件:LoginRequest.java、AuthTokenService.java。关联逻辑(调用链/数据流):登录表单 -> 用户表/BCrypt -> Cookie -> 当前用户响应。 */ /** 代码作用(白话):所有登录失败路径统一补齐到的最小耗时,用于消除各失败分支之间的时间差。关联文件:application.yml。关联逻辑(调用链/数据流):配置 -> 失败路径补时 -> 统一响应耗时。 */
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); } private final long failureMinNanos;
/** 代码作用(白话):接收认证依赖,并在启动时生成一份占位哈希;关联文件:AuthController.java、SystemUserMapper.java、LoginAttemptGuard.java。关联逻辑(调用链/数据流):Controller -> Service -> 用户表/令牌/权限/失败计数。 */
public AuthService(SystemUserMapper users, PasswordEncoder encoder, AuthTokenService tokens, PagePermissionService permissions, LoginAttemptGuard loginAttempts, @Value("${xyw.auth.login-failure-min-millis:400}") long failureMinMillis) { this.users = users; this.encoder = encoder; this.tokens = tokens; this.permissions = permissions; this.loginAttempts = loginAttempts; this.failureMinNanos = TimeUnit.MILLISECONDS.toNanos(failureMinMillis); this.timingEqualizerHash = encoder.encode("xyw-login-timing-equalizer"); }
/**
* 代码作用(白话):先确认账号名未被锁定,再验证账号密码并签发登录 Cookie。
* 关联文件:LoginRequest.java、AuthTokenService.java、LoginAttemptGuard.java。
* 关联逻辑(调用链/数据流):登录表单 -> 失败计数校验 -> 用户表/BCrypt -> Cookie -> 当前用户响应。
*
* 安全要点一:无论账号是否存在、是否停用、是否已设密码,都必须先把 passwordMatches 计算完再进入判断;
* 若把 encoder.matches 直接写进 if 条件,短路求值会跳过 BCrypt,使"账号不存在"比"账号存在"快十几倍。
*
* 安全要点二:仅靠"都跑一次 BCrypt"并不足够。数据库中的存量密码哈希可能是较低强度(cost 10)生成的,
* 而占位哈希用当前配置强度(cost 12)生成,两者比对耗时相差数倍,时序差异会被放大而不是消除。
* 因此所有失败分支最后统一补齐到 failureMinNanos,使"账号不存在""账号停用""密码错误"耗时一致。
*/
public CurrentUserResponse login(LoginRequest request, HttpServletResponse response) {
loginAttempts.assertAllowed(request.username());
long startNanos = System.nanoTime();
SystemUserEntity user = users.selectOne(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getUsername, request.username()).eq(SystemUserEntity::getDeleteTime, 0L));
String storedHash = user != null && user.getPasswordHash() != null ? user.getPasswordHash() : timingEqualizerHash;
boolean passwordMatches = encoder.matches(request.password(), storedHash);
if (user == null || !"ACTIVE".equals(user.getStatus()) || user.getPasswordHash() == null || !passwordMatches) { loginAttempts.recordFailure(request.username()); padFailureDuration(startNanos); throw new BadCredentialsException("账号或密码错误"); }
loginAttempts.recordSuccess(request.username());
tokens.issue(new AuthPrincipal(user.getId(), user.getUsername(), user.getRoleCode(), user.getAuthVersion() == null ? 1 : user.getAuthVersion()), response);
return responseOf(user);
}
/** 代码作用(白话):把本次失败的处理时间补齐到统一下限,让各失败原因在响应耗时上无法区分。关联文件:application.yml、AuthServiceTest.java。关联逻辑(调用链/数据流):失败分支 -> 计算已耗时 -> 补足剩余时间 -> 抛出统一错误。 */
private void padFailureDuration(long startNanos) {
long remaining = failureMinNanos - (System.nanoTime() - startNanos);
if (remaining <= 0) return;
try { TimeUnit.NANOSECONDS.sleep(remaining); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
}
/** 代码作用(白话):把当前令牌身份重新读取为最新菜单资料;关联文件:AuthTokenFilter.java、App.js。关联逻辑(调用链/数据流):Cookie -> SecurityContext -> 用户表 -> 菜单权限响应。 */ /** 代码作用(白话):把当前令牌身份重新读取为最新菜单资料;关联文件: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); } 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。 */ /** 代码作用(白话):让浏览器删除登录 Cookie;关联文件:AuthController.java。关联逻辑(调用链/数据流):登出按钮 -> Service -> 过期 Cookie。 */
......
package com.xyw.console.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* 文件用途(白话):在单机内存中统计每个账号名的连续登录失败次数,达到 20 次后锁定 24 小时,并输出可追溯的锁定审计日志。
*
* 关联文件:LoginAttemptGuard.java、AuthService.java、SystemUserAdminService.java。
* 关联逻辑(调用链/数据流):登录请求 -> assertAllowed() -> 密码比对 -> recordFailure()/recordSuccess() -> 锁定状态 -> 管理页解锁。
*
* 安全取舍说明:计数键只用账号名而不用来源 IP,因为 X-Forwarded-For 可被伪造,用 IP 做主键等于没做限流;
* 代价是他人可故意输错密码把某个账号锁住,因此必须保留管理端解锁入口(见 unlock)。
*/
@Component
public class InMemoryLoginAttemptGuard implements LoginAttemptGuard {
private static final Logger log = LoggerFactory.getLogger(InMemoryLoginAttemptGuard.class);
/** 连续失败达到该次数即锁定。 */
private static final int THRESHOLD = 20;
/** 锁定持续时长。 */
private static final Duration LOCK_DURATION = Duration.ofHours(24);
/** 距上次失败超过该时长则重新计数,避免历史零星失败被无限累加。 */
private static final Duration FAILURE_WINDOW = Duration.ofHours(24);
/** 记录条目上限,防止攻击者用大量随机账号名撑爆内存。 */
private static final int MAX_ENTRIES = 50_000;
private final Map<String, Attempt> attempts = new ConcurrentHashMap<>();
private final Clock clock;
/** 代码作用(白话):容器启动时使用系统时钟。关联文件:SecurityConfig.java。关联逻辑(调用链/数据流):Spring 扫描 -> 本组件 -> AuthService 注入。 */
public InMemoryLoginAttemptGuard() { this(Clock.systemDefaultZone()); }
/** 代码作用(白话):允许测试注入可控时钟,从而验证 24 小时锁定到期行为而无需真实等待。关联文件:InMemoryLoginAttemptGuardTest.java。关联逻辑(调用链/数据流):测试时钟 -> 锁定判定 -> 断言。 */
InMemoryLoginAttemptGuard(Clock clock) { this.clock = clock; }
/** 代码作用(白话):锁定期内直接拒绝尝试,且不再累加计数,避免锁定时间被无限延长而永远解不开。关联文件:AuthService.java、AuthExceptionHandler.java。关联逻辑(调用链/数据流):登录入口 -> 锁定判定 -> 抛出 429 异常或放行。 */
@Override public void assertAllowed(String username) {
Attempt attempt = attempts.get(key(username));
Instant now = clock.instant();
if (attempt != null && attempt.lockedUntil() != null && attempt.lockedUntil().isAfter(now)) throw new LoginThrottledException(attempt.lockedUntil());
}
/** 代码作用(白话):原子累加失败次数并在达到阈值时写入锁定截止时间;超过记录上限时先清理过期条目。关联文件:AuthService.java。关联逻辑(调用链/数据流):密码比对失败 -> compute 原子更新 -> 仅本次新触发锁定时记审计日志。 */
@Override public void recordFailure(String username) {
if (attempts.size() >= MAX_ENTRIES) evictStaleEntries();
String key = key(username);
Instant now = clock.instant();
// 只在本次调用真正触发锁定时记一条审计日志;ConcurrentHashMap.compute 对同一键原子执行,标记不会被并发写乱
boolean[] justLocked = { false };
Attempt updated = attempts.compute(key, (ignored, current) -> {
// 锁定期内不再累加,防止持续尝试把锁定时间不断推后,导致被恶意锁号的账号永远解不开
if (current != null && current.lockedUntil() != null && current.lockedUntil().isAfter(now)) return current;
// 锁定已到期,或距上次失败超出统计窗口,都必须重新从 1 计数;否则解锁后第一次输错就会立即再锁 24 小时
boolean lockExpired = current != null && current.lockedUntil() != null;
boolean restart = current == null || lockExpired || current.lastFailureAt().plus(FAILURE_WINDOW).isBefore(now);
int failures = restart ? 1 : current.failures() + 1;
Instant lockedUntil = failures >= THRESHOLD ? now.plus(LOCK_DURATION) : null;
justLocked[0] = lockedUntil != null;
return new Attempt(failures, lockedUntil, now);
});
if (justLocked[0]) log.warn("LOGIN_ACCOUNT_LOCKED username={} failures={} lockedUntil={}", key, updated.failures(), updated.lockedUntil());
}
/** 代码作用(白话):登录成功后清空该账号名的失败记录。关联文件:AuthService.java。关联逻辑(调用链/数据流):密码比对通过 -> 移除计数 -> 下次失败重新从 1 开始。 */
@Override public void recordSuccess(String username) { attempts.remove(key(username)); }
/** 代码作用(白话):返回当前仍在锁定期内的账号名,按锁定时间倒序供管理页展示。关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):管理页查询 -> 过滤未过期锁定 -> 列表响应。 */
@Override public List<LockedAccount> lockedAccounts() {
Instant now = clock.instant();
List<LockedAccount> locked = new ArrayList<>();
attempts.forEach((username, attempt) -> { if (attempt.lockedUntil() != null && attempt.lockedUntil().isAfter(now)) locked.add(new LockedAccount(username, attempt.failures(), attempt.lockedUntil(), attempt.lastFailureAt())); });
locked.sort(Comparator.comparing(LockedAccount::lockedUntil).reversed());
return locked;
}
/** 代码作用(白话):手动解除锁定并记录审计日志,返回该账号名此前是否确实存在记录。关联文件:SystemUserAdminController.java。关联逻辑(调用链/数据流):开发者点击解锁 -> 移除计数 -> 审计日志 -> 账号立即可登录。 */
@Override public boolean unlock(String username) {
boolean existed = attempts.remove(key(username)) != null;
if (existed) log.info("LOGIN_ACCOUNT_UNLOCKED username={}", key(username));
return existed;
}
/** 代码作用(白话):把账号名归一化为小写并去除首尾空白,避免改变大小写就绕过限流。关联文件:AuthService.java。关联逻辑(调用链/数据流):登录账号名 -> 归一化 -> 计数键。 */
private String key(String username) { return username == null ? "" : username.trim().toLowerCase(Locale.ROOT); }
/** 代码作用(白话):清理已超出统计窗口且未处于锁定期的条目,为新的失败记录腾出空间。关联文件:InMemoryLoginAttemptGuardTest.java。关联逻辑(调用链/数据流):条目数达上限 -> 移除过期记录 -> 继续计数。 */
private void evictStaleEntries() {
Instant now = clock.instant();
attempts.entrySet().removeIf(entry -> {
Attempt attempt = entry.getValue();
boolean stillLocked = attempt.lockedUntil() != null && attempt.lockedUntil().isAfter(now);
return !stillLocked && attempt.lastFailureAt().plus(FAILURE_WINDOW).isBefore(now);
});
}
/** 代码作用(白话):保存一个账号名的失败次数、锁定截止时间和最后失败时间;使用不可变记录以配合原子更新。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):compute 原子替换 -> 锁定判定/列表展示。 */
private record Attempt(int failures, Instant lockedUntil, Instant lastFailureAt) {}
}
package com.xyw.console.auth;
import java.time.Instant;
import java.util.List;
/**
* 代码作用(白话):约定登录失败次数的记录与拦截行为,让业务代码不依赖具体存储方式。
* 关联文件:InMemoryLoginAttemptGuard.java、AuthService.java、SystemUserAdminService.java。
* 关联逻辑(调用链/数据流):登录请求 -> assertAllowed() -> 密码比对 -> recordFailure()/recordSuccess()。
*
* 扩展点说明:当前为单机内存实现。后端若改为多副本部署,内存计数会被轮流打不同实例绕过,
* 届时新增一个基于 Redis 的实现类替换即可,AuthService 无需改动。
*/
public interface LoginAttemptGuard {
/** 代码作用(白话):在比对密码前确认该账号名当前是否被锁定,被锁定则抛出异常。关联文件:AuthService.java。关联逻辑(调用链/数据流):登录入口 -> 锁定状态 -> LoginThrottledException 或放行。 */
void assertAllowed(String username);
/** 代码作用(白话):记录一次登录失败,达到阈值时写入锁定截止时间。关联文件:AuthService.java。关联逻辑(调用链/数据流):密码比对失败 -> 计数累加 -> 达阈值锁定。 */
void recordFailure(String username);
/** 代码作用(白话):登录成功后立即清零该账号名的失败计数。关联文件:AuthService.java。关联逻辑(调用链/数据流):密码比对通过 -> 清除计数 -> 不影响后续登录。 */
void recordSuccess(String username);
/** 代码作用(白话):列出当前处于锁定状态的账号名,供管理页展示。关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):管理页查询 -> 锁定列表 -> 表格展示。 */
List<LockedAccount> lockedAccounts();
/** 代码作用(白话):由开发者手动解除某个账号名的锁定,避免 24 小时锁定被用来恶意锁号。关联文件:SystemUserAdminController.java。关联逻辑(调用链/数据流):解锁按钮 -> 清除计数 -> 该账号立即可再次登录。 */
boolean unlock(String username);
/** 代码作用(白话):描述一条锁定记录的展示字段,不包含任何密码或令牌信息。关联文件:UserPermissionView.js。关联逻辑(调用链/数据流):内存计数 -> 本记录 -> 管理页表格。 */
record LockedAccount(String username, int failures, Instant lockedUntil, Instant lastFailureAt) {}
}
package com.xyw.console.auth;
import java.time.Instant;
/**
* 代码作用(白话):表示当前账号名因连续登录失败被暂时拒绝尝试,用来和"密码错误"区分开。
* 关联文件:LoginAttemptGuard.java、AuthExceptionHandler.java、LoginView.js。
* 关联逻辑(调用链/数据流):连续失败达阈值 -> assertAllowed() 抛出本异常 -> 429 JSON -> 登录页提示稍后重试。
*/
public class LoginThrottledException extends RuntimeException {
private final transient Instant lockedUntil;
/** 代码作用(白话):记录锁定截止时间,供上层换算还需等待多久;关联文件:AuthExceptionHandler.java。关联逻辑(调用链/数据流):锁定状态 -> 异常 -> 响应提示。 */
public LoginThrottledException(Instant lockedUntil) { super("登录尝试过于频繁"); this.lockedUntil = lockedUntil; }
public Instant lockedUntil() { return lockedUntil; }
}
...@@ -16,14 +16,15 @@ public class PagePermissionService { ...@@ -16,14 +16,15 @@ public class PagePermissionService {
public static final String DOMAIN = "domain"; public static final String DOMAIN = "domain";
public static final String WECOM = "reference-wecom"; public static final String WECOM = "reference-wecom";
public static final String PHONE = "phone-assets"; public static final String PHONE = "phone-assets";
public static final String COMPANY_PROFILE = "company-profile";
public static final String ALERTS = "alerts"; public static final String ALERTS = "alerts";
private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号资产", ALERTS, "提醒中心"); private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号资产", COMPANY_PROFILE, "公司档案", ALERTS, "提醒中心");
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** 代码作用(白话):接收 JSON 工具以读取数据库权限映射;关联文件:SystemUserEntity.java。关联逻辑(调用链/数据流):page_permissions JSON -> 权限 Map -> Controller 判定。 */ /** 代码作用(白话):接收 JSON 工具以读取数据库权限映射;关联文件:SystemUserEntity.java。关联逻辑(调用链/数据流):page_permissions JSON -> 权限 Map -> Controller 判定。 */
public PagePermissionService(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } public PagePermissionService(ObjectMapper objectMapper) { this.objectMapper = objectMapper; }
/** 代码作用(白话):生成当前账号的有效五页权限,开发者和超级管理员固定全编辑;关联文件:AuthService.java、App.js。关联逻辑(调用链/数据流):用户表 -> 有效权限 -> /api/auth/me -> 菜单和路由。 */ /** 代码作用(白话):生成当前账号的有效页面权限,开发者和超级管理员固定全编辑;关联文件:AuthService.java、App.js、CompanyProfileController.java。关联逻辑(调用链/数据流):用户表 -> 有效权限 -> /api/auth/me -> 菜单、路由和接口校验。 */
public Map<String, String> effectivePermissions(SystemUserEntity user) { public Map<String, String> effectivePermissions(SystemUserEntity user) {
Map<String, String> result = new LinkedHashMap<>(); Map<String, String> result = new LinkedHashMap<>();
PAGES.keySet().forEach(key -> result.put(key, isAdministrator(user.getRoleCode()) ? "EDIT" : "NONE")); PAGES.keySet().forEach(key -> result.put(key, isAdministrator(user.getRoleCode()) ? "EDIT" : "NONE"));
...@@ -31,7 +32,7 @@ public class PagePermissionService { ...@@ -31,7 +32,7 @@ public class PagePermissionService {
return result; return result;
} }
/** 代码作用(白话):校验权限面板提交内容,只接受已注册页面和三档权限;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):表单 Map -> 校验/标准化 -> JSON 入库。 */ /** 代码作用(白话):校验权限面板提交内容,只接受已注册页面和三档权限;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):表单 Map -> 校验/标准化 -> JSON 入库 -> 菜单和接口权限。 */
public String validatePermissions(Map<String, String> input) { public String validatePermissions(Map<String, String> input) {
Map<String, String> normalized = new LinkedHashMap<>(); 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); }); if (input != null) input.forEach((key, value) -> { if (!PAGES.containsKey(key) || !validLevel(value)) throw new IllegalArgumentException("页面权限配置无效"); normalized.put(key, value); });
...@@ -45,7 +46,7 @@ public class PagePermissionService { ...@@ -45,7 +46,7 @@ public class PagePermissionService {
catch (Exception ignored) { return validatePermissions(Map.of()); } catch (Exception ignored) { return validatePermissions(Map.of()); }
} }
/** 代码作用(白话):在资产接口执行服务端最低权限检查;关联文件:PhoneAssetController.java、WecomAccountController.java。关联逻辑(调用链/数据流):Controller -> 当前认证身份 -> READ/EDIT 决定 403 或继续业务服务。 */ /** 代码作用(白话):在资产接口执行服务端最低权限检查;关联文件:PhoneAssetController.java、WecomAccountController.java、CompanyProfileController.java。关联逻辑(调用链/数据流):Controller -> 当前认证身份 -> READ/EDIT 决定 403 或继续业务服务。 */
public void require(String pageKey, String minimum) { public void require(String pageKey, String minimum) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof AuthPrincipal principal)) throw new AccessDeniedException("没有页面权限"); if (authentication == null || !(authentication.getPrincipal() instanceof AuthPrincipal principal)) throw new AccessDeniedException("没有页面权限");
......
...@@ -23,4 +23,8 @@ public class SystemUserAdminController { ...@@ -23,4 +23,8 @@ public class SystemUserAdminController {
@PutMapping("/{id}") public ApiResponse<SystemUserResponse> update(@PathVariable Long id, @Valid @RequestBody SystemUserUpdateRequest request) { return ApiResponse.success(service.updateUser(id, request)); } @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/触发器。 */ /** 代码作用(白话):仅开发者可重置非开发者密码;关联文件: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); } @PutMapping("/{id}/password") public ApiResponse<Void> resetPassword(@PathVariable Long id, @Valid @RequestBody PasswordResetRequest request) { service.resetPassword(id, request); return ApiResponse.success(null); }
/** 代码作用(白话):返回当前因连续登录失败被锁定的账号名列表;关联文件:LoginAttemptGuard.java、UserPermissionView.js。关联逻辑(调用链/数据流):管理页 -> GET locked-accounts -> 内存锁定状态 -> 表格展示。 */
@GetMapping("/locked-accounts") public ApiResponse<List<LoginAttemptGuard.LockedAccount>> lockedAccounts() { return ApiResponse.success(service.listLockedAccounts()); }
/** 代码作用(白话):由管理员解除指定账号名的登录锁定,避免 24 小时锁定被用来恶意锁号;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):解锁按钮 -> DELETE -> 清除失败计数 -> 该账号立即可登录。 */
@DeleteMapping("/locked-accounts/{username}") public ApiResponse<Void> unlockAccount(@PathVariable String username) { service.unlockAccount(username); return ApiResponse.success(null); }
} }
...@@ -16,9 +16,25 @@ import org.springframework.stereotype.Service; ...@@ -16,9 +16,25 @@ import org.springframework.stereotype.Service;
@Service @Service
public class SystemUserAdminService { public class SystemUserAdminService {
private final SystemUserMapper users; private final PagePermissionService permissions; private final PasswordEncoder encoder; private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(SystemUserAdminService.class);
/** 代码作用(白话):接收账号表、页面权限和密码哈希依赖;关联文件:SystemUserAdminController.java、SystemUserMapper.java。关联逻辑(调用链/数据流):管理接口 -> 本服务 -> 用户表/权限 JSON/BCrypt。 */ private final SystemUserMapper users; private final PagePermissionService permissions; private final PasswordEncoder encoder; private final LoginAttemptGuard loginAttempts;
public SystemUserAdminService(SystemUserMapper users, PagePermissionService permissions, PasswordEncoder encoder) { this.users = users; this.permissions = permissions; this.encoder = encoder; } /** 代码作用(白话):接收账号表、页面权限、密码哈希和登录失败计数依赖;关联文件:SystemUserAdminController.java、LoginAttemptGuard.java。关联逻辑(调用链/数据流):管理接口 -> 本服务 -> 用户表/权限 JSON/BCrypt/锁定状态。 */
public SystemUserAdminService(SystemUserMapper users, PagePermissionService permissions, PasswordEncoder encoder, LoginAttemptGuard loginAttempts) { this.users = users; this.permissions = permissions; this.encoder = encoder; this.loginAttempts = loginAttempts; }
/** 代码作用(白话):列出当前被登录失败锁定的账号名,供管理页判断是否需要人工解锁。关联文件:LoginAttemptGuard.java、UserPermissionView.js。关联逻辑(调用链/数据流):管理页 -> GET locked-accounts -> 内存锁定状态 -> 表格。 */
public List<LoginAttemptGuard.LockedAccount> listLockedAccounts() { assertAdministrator(); return loginAttempts.lockedAccounts(); }
/**
* 代码作用(白话):由管理员手动解除某个账号名的登录锁定,并记录谁解锁了谁。
* 关联文件:LoginAttemptGuard.java、SystemUserAdminController.java。
* 关联逻辑(调用链/数据流):解锁按钮 -> 管理员校验 -> 清除失败计数 -> 审计日志 -> 该账号立即可登录。
*
* 权限说明:允许开发者和超级管理员解锁,而不是只允许开发者;否则唯一的开发者账号被锁定后将无人可解。
*/
public void unlockAccount(String username) {
assertAdministrator();
if (username == null || username.isBlank()) throw new IllegalArgumentException("账号名不能为空");
boolean existed = loginAttempts.unlock(username);
log.info("LOGIN_UNLOCK_BY_ADMIN operator={} target={} hadRecord={}", currentUsername(), username, existed);
}
/** 代码作用(白话):查询管理者可见的非开发者账号,任何响应都不返回哈希;关联文件:SystemUserAdminController.java、UserPermissionView.js。关联逻辑(调用链/数据流):账号页面 -> GET users -> as_system_user(role != DEVELOPER) -> 列表。 */ /** 代码作用(白话):查询管理者可见的非开发者账号,任何响应都不返回哈希;关联文件:SystemUserAdminController.java、UserPermissionView.js。关联逻辑(调用链/数据流):账号页面 -> GET users -> as_system_user(role != DEVELOPER) -> 列表。 */
public List<SystemUserResponse> listVisibleUsers() { assertAdministrator(); return users.selectList(new LambdaQueryWrapper<SystemUserEntity>().ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L).orderByDesc(SystemUserEntity::getId)).stream().map(this::responseOf).toList(); } public List<SystemUserResponse> listVisibleUsers() { assertAdministrator(); return users.selectList(new LambdaQueryWrapper<SystemUserEntity>().ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L).orderByDesc(SystemUserEntity::getId)).stream().map(this::responseOf).toList(); }
/** 代码作用(白话):按当前创建者的角色边界新增非开发者账号;关联文件:SystemUserCreateRequest.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):创建表单 -> 创建者校验 -> 密码/禁用规则 -> as_system_user。 */ /** 代码作用(白话):按当前创建者的角色边界新增非开发者账号;关联文件:SystemUserCreateRequest.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):创建表单 -> 创建者校验 -> 密码/禁用规则 -> as_system_user。 */
...@@ -37,6 +53,8 @@ public class SystemUserAdminService { ...@@ -37,6 +53,8 @@ public class SystemUserAdminService {
private void assertAdministrator() { if (!permissions.isAdministrator(currentRole())) throw new AccessDeniedException("没有账号管理权限"); } private void assertAdministrator() { if (!permissions.isAdministrator(currentRole())) throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):读取过滤器已经核验过的当前角色;关联文件:AuthPrincipal.java、AuthTokenFilter.java。关联逻辑(调用链/数据流):JWT -> SecurityContext principal -> 创建/修改边界。 */ /** 代码作用(白话):读取过滤器已经核验过的当前角色;关联文件:AuthPrincipal.java、AuthTokenFilter.java。关联逻辑(调用链/数据流):JWT -> SecurityContext principal -> 创建/修改边界。 */
private String currentRole() { Object principal = SecurityContextHolder.getContext().getAuthentication() == null ? null : SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (!(principal instanceof AuthPrincipal user)) throw new AccessDeniedException("没有账号管理权限"); return user.roleCode(); } private String currentRole() { Object principal = SecurityContextHolder.getContext().getAuthentication() == null ? null : SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (!(principal instanceof AuthPrincipal user)) throw new AccessDeniedException("没有账号管理权限"); return user.roleCode(); }
/** 代码作用(白话):读取当前操作者的账号名,仅用于写入解锁审计日志。关联文件:AuthPrincipal.java。关联逻辑(调用链/数据流):SecurityContext -> 操作人账号名 -> 审计日志。 */
private String currentUsername() { Object principal = SecurityContextHolder.getContext().getAuthentication() == null ? null : SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return principal instanceof AuthPrincipal user ? user.username() : "unknown"; }
/** 代码作用(白话):按角色边界阻止创建开发者以及普通角色创建账号;关联文件:SystemUserCreateRequest.java、SystemUserUpdateRequest.java。关联逻辑(调用链/数据流):当前角色 + 目标角色 -> 允许或 403。 */ /** 代码作用(白话):按角色边界阻止创建开发者以及普通角色创建账号;关联文件:SystemUserCreateRequest.java、SystemUserUpdateRequest.java。关联逻辑(调用链/数据流):当前角色 + 目标角色 -> 允许或 403。 */
private void assertCreatableRole(String actor, String roleCode) { if ("DEVELOPER".equals(roleCode)) throw new AccessDeniedException("开发者账号仅允许固定账号 Jeddy"); if (!("SUPER_ADMIN".equals(roleCode) || "FINANCE".equals(roleCode) || "HR".equals(roleCode) || "OPERATIONS".equals(roleCode))) throw new IllegalArgumentException("角色无效"); if ("DEVELOPER".equals(actor) || "SUPER_ADMIN".equals(actor)) return; throw new AccessDeniedException("没有账号管理权限"); } private void assertCreatableRole(String actor, String roleCode) { if ("DEVELOPER".equals(roleCode)) throw new AccessDeniedException("开发者账号仅允许固定账号 Jeddy"); if (!("SUPER_ADMIN".equals(roleCode) || "FINANCE".equals(roleCode) || "HR".equals(roleCode) || "OPERATIONS".equals(roleCode))) throw new IllegalArgumentException("角色无效"); if ("DEVELOPER".equals(actor) || "SUPER_ADMIN".equals(actor)) return; throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):按固定查询条件取得可管理账号,避免 Jeddy 通过 URL 或接口细节泄露;关联文件:SystemUserMapper.java、SystemUserResponse.java。关联逻辑(调用链/数据流):账号 id -> 非 DEVELOPER 查询 -> 编辑/重置。 */ /** 代码作用(白话):按固定查询条件取得可管理账号,避免 Jeddy 通过 URL 或接口细节泄露;关联文件:SystemUserMapper.java、SystemUserResponse.java。关联逻辑(调用链/数据流):账号 id -> 非 DEVELOPER 查询 -> 编辑/重置。 */
......
...@@ -5,6 +5,8 @@ import org.springframework.http.HttpStatus; ...@@ -5,6 +5,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.jdbc.BadSqlGrammarException; import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
...@@ -29,6 +31,27 @@ public class GlobalExceptionHandler { ...@@ -29,6 +31,27 @@ public class GlobalExceptionHandler {
return ApiResponse.error(500, "数据库字段未同步,请完成数据库迁移后重试"); return ApiResponse.error(500, "数据库字段未同步,请完成数据库迁移后重试");
} }
/**
* 代码作用(白话):把请求体字段校验失败返回为 400,而不是让它落进下方的服务器异常兜底。
* 关联文件:LoginRequest.java、AuthController.java、前端 API 客户端。
* 关联逻辑(调用链/数据流):@Valid 校验失败 -> 本方法 -> 400 JSON -> 页面提示用户修改输入。
*
* 修复要点:此前没有本处理器,空账号或空密码会被 Exception 兜底成 500,
* 导致"用户填错"和"服务端真故障"在监控里无法区分。文案不回显字段名,避免登录接口泄露账号规则。
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> invalidRequestBody(MethodArgumentNotValidException error) {
return ApiResponse.error(400, "提交内容不符合要求,请检查后重试");
}
/** 代码作用(白话):把缺失或格式错误的请求体返回为 400,同样避免被当成服务器故障;关联文件:AuthController.java、前端 API 客户端;关联逻辑(调用链/数据流):JSON 解析失败 -> 本方法 -> 400 JSON -> 页面提示。 */
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> unreadableRequestBody(HttpMessageNotReadableException error) {
return ApiResponse.error(400, "请求格式不正确,请刷新页面后重试");
}
/** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */ /** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
......
package com.xyw.console.asset.controller;
import static org.hamcrest.Matchers.is;
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.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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.CompanyProfilePageResponse;
import com.xyw.console.asset.dto.CompanyProfileResponse;
import com.xyw.console.asset.dto.CompanyProfileSaveRequest;
import com.xyw.console.asset.service.CompanyProfileService;
import com.xyw.console.auth.PagePermissionService;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class CompanyProfileControllerTest {
/**
* 代码作用(白话):验证公司档案 GET 接口返回完整可读字段但不返回内部 ID 或软删除标志。
* 关联文件:CompanyProfileController.java、CompanyProfileService.java、CompanyProfileResponse.java。
* 关联逻辑(调用链/数据流):GET /api/company-profiles -> Controller -> Service -> JSON 表格数据。
*/
@Test
void returnsReadOnlyCompanyProfilePageWithoutInternalFields() throws Exception {
CompanyProfileService service = mock(CompanyProfileService.class);
when(service.page(any())).thenReturn(response());
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new CompanyProfileController(service, mock(PagePermissionService.class))).build();
mockMvc.perform(get("/api/company-profiles"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code", is(200)))
.andExpect(jsonPath("$.data.records[0].companyName", is("示例科技有限公司")))
.andExpect(jsonPath("$.data.records[0].shortName", is("示例科技")))
.andExpect(jsonPath("$.data.records[0].unifiedSocialCreditCode", is("91330100TEST000001")))
.andExpect(jsonPath("$.data.records[0].address", is("杭州市西湖区")))
.andExpect(jsonPath("$.data.records[0].contactName", is("张三")))
.andExpect(jsonPath("$.data.records[0].contactValue", is("13812345678")))
.andExpect(jsonPath("$.data.records[0].id").doesNotExist())
.andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist());
}
/**
* 代码作用(白话):验证没有公司档案查看权限时,Controller 会在查询数据库前拒绝请求。
* 关联文件:CompanyProfileController.java、PagePermissionService.java。
* 关联逻辑(调用链/数据流):GET 请求 -> 权限服务 require -> 拒绝异常 -> 不调用公司档案查询。
*/
@Test
void rejectsCompanyProfileReadsWithoutReadPermission() {
CompanyProfileService service = mock(CompanyProfileService.class);
PagePermissionService permissions = mock(PagePermissionService.class);
doThrow(new AccessDeniedException("没有页面权限"))
.when(permissions).require(PagePermissionService.COMPANY_PROFILE, "READ");
CompanyProfileController controller = new CompanyProfileController(service, permissions);
assertThrows(AccessDeniedException.class, () -> controller.page(new com.xyw.console.asset.dto.CompanyProfilePageQuery(1, 20, null)));
verify(permissions).require(PagePermissionService.COMPANY_PROFILE, "READ");
}
/**
* 代码作用(白话):验证拥有编辑权限时可以通过 POST 新增公司档案,且响应不含内部 ID。
* 关联文件:CompanyProfileController.java、CompanyProfileService.java、CompanyProfileView.js。
* 关联逻辑(调用链/数据流):新增弹窗 -> POST -> EDIT 权限校验 -> Service.create -> JSON 响应。
*/
@Test
void createsCompanyProfileWithEditPermission() throws Exception {
CompanyProfileService service = mock(CompanyProfileService.class);
when(service.create(any())).thenReturn(response().records().get(0));
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new CompanyProfileController(service, mock(PagePermissionService.class))).build();
mockMvc.perform(post("/api/company-profiles")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"companyName\":\"示例科技有限公司\",\"shortName\":\"示例科技\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code", is(200)))
.andExpect(jsonPath("$.data.companyName", is("示例科技有限公司")))
.andExpect(jsonPath("$.data.id").doesNotExist())
.andExpect(jsonPath("$.data.deleteTime").doesNotExist());
verify(service).create(any());
}
/**
* 代码作用(白话):验证只有查看权限的用户不能调用新增接口,避免把只读权限扩大为写入权限。
* 关联文件:CompanyProfileController.java、PagePermissionService.java。
* 关联逻辑(调用链/数据流):POST 请求 -> EDIT 权限检查 -> 拒绝异常 -> 不调用 Service.create。
*/
@Test
void rejectsCompanyProfileCreateWithoutEditPermission() {
CompanyProfileService service = mock(CompanyProfileService.class);
PagePermissionService permissions = mock(PagePermissionService.class);
doThrow(new AccessDeniedException("没有编辑权限"))
.when(permissions).require(PagePermissionService.COMPANY_PROFILE, "EDIT");
CompanyProfileController controller = new CompanyProfileController(service, permissions);
assertThrows(AccessDeniedException.class, () -> controller.create(
new CompanyProfileSaveRequest("示例科技有限公司", null, null, null, null, null)));
verify(permissions).require(PagePermissionService.COMPANY_PROFILE, "EDIT");
verify(service, org.mockito.Mockito.never()).create(any());
}
/**
* 代码作用(白话):提供 Controller JSON 断言所需的只读公司档案分页结果。
* 关联文件:CompanyProfilePageResponse.java、CompanyProfileController.java。
* 关联逻辑(调用链/数据流):测试 Service 返回值 -> Controller 包装 ApiResponse -> MockMvc JSON 断言。
*/
private CompanyProfilePageResponse response() {
return new CompanyProfilePageResponse(List.of(new CompanyProfileResponse(
"示例科技有限公司", "示例科技", "91330100TEST000001", "杭州市西湖区", "张三", "13812345678",
LocalDateTime.of(2026, 8, 5, 10, 0), LocalDateTime.of(2026, 8, 5, 11, 0))), 1L, 1, 20);
}
}
...@@ -40,7 +40,7 @@ class WecomAccountControllerTest { ...@@ -40,7 +40,7 @@ class WecomAccountControllerTest {
.andExpect(jsonPath("$.code", is(200))) .andExpect(jsonPath("$.code", is(200)))
.andExpect(jsonPath("$.data.page", is(1))) .andExpect(jsonPath("$.data.page", is(1)))
.andExpect(jsonPath("$.data.size", is(20))) .andExpect(jsonPath("$.data.size", is(20)))
.andExpect(jsonPath("$.data.records[0].companyProfileName", is("示例科技有限公司"))) .andExpect(jsonPath("$.data.records[0].companyProfileName", is("示例科技")))
.andExpect(jsonPath("$.data.records[0].phoneNumber", is("13812345678"))) .andExpect(jsonPath("$.data.records[0].phoneNumber", is("13812345678")))
.andExpect(jsonPath("$.data.records[0].deviceName", is("iPhone 15"))) .andExpect(jsonPath("$.data.records[0].deviceName", is("iPhone 15")))
.andExpect(jsonPath("$.data.records[0].operatorPersonName", is("王五"))) .andExpect(jsonPath("$.data.records[0].operatorPersonName", is("王五")))
...@@ -103,6 +103,7 @@ class WecomAccountControllerTest { ...@@ -103,6 +103,7 @@ class WecomAccountControllerTest {
CompanyProfileEntity entity = new CompanyProfileEntity(); CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L); entity.setId(10L);
entity.setCompanyName("示例科技有限公司"); entity.setCompanyName("示例科技有限公司");
entity.setShortName("示例科技");
return entity; return entity;
} }
......
package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.CompanyProfilePageQuery;
import com.xyw.console.asset.dto.CompanyProfilePageResponse;
import com.xyw.console.asset.dto.CompanyProfileResponse;
import com.xyw.console.asset.dto.CompanyProfileSaveRequest;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.mapper.CompanyProfileMapper;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
class CompanyProfileServiceTest {
/**
* 代码作用(白话):验证公司档案列表把有效记录的全部可展示字段转换为页面数据,并且不暴露内部 ID。
* 关联文件:CompanyProfileService.java、CompanyProfileResponse.java、CompanyProfileMapper.java。
* 关联逻辑(调用链/数据流):分页请求 -> Service 查询有效档案 -> Response -> 公司档案表格。
*/
@Test
void returnsAllDisplayFieldsWithoutInternalId() {
CompanyProfileMapper mapper = mock(CompanyProfileMapper.class);
Page<CompanyProfileEntity> page = new Page<>(1, 20);
page.setRecords(List.of(companyProfile()));
page.setTotal(1L);
when(mapper.selectPage(any(), any())).thenReturn(page);
CompanyProfilePageResponse result = new CompanyProfileService(mapper)
.page(new CompanyProfilePageQuery(null, null, null));
CompanyProfileResponse record = result.records().get(0);
assertEquals("示例科技有限公司", record.companyName());
assertEquals("示例科技", record.shortName());
assertEquals("91330100TEST000001", record.unifiedSocialCreditCode());
assertEquals("杭州市西湖区", record.address());
assertEquals("张三", record.contactName());
assertEquals("13812345678", record.contactValue());
assertEquals(LocalDateTime.of(2026, 8, 5, 10, 0), record.createTime());
assertEquals(LocalDateTime.of(2026, 8, 5, 11, 0), record.updateTime());
assertEquals(1, result.page());
assertEquals(20, result.size());
}
/**
* 代码作用(白话):验证新增时由服务端清理用户文本,并初始化创建时间、更新时间和未删除标志。
* 关联文件:CompanyProfileService.java、CompanyProfileSaveRequest.java、CompanyProfileEntity.java。
* 关联逻辑(调用链/数据流):新增请求 DTO -> Service.create -> Entity 审计字段 -> Mapper.insert。
*/
@Test
void createsCompanyProfileWithServerManagedAuditFields() {
CompanyProfileMapper mapper = mock(CompanyProfileMapper.class);
when(mapper.insert(org.mockito.ArgumentMatchers.<CompanyProfileEntity>any())).thenReturn(1);
CompanyProfileService service = new CompanyProfileService(mapper);
CompanyProfileResponse result = service.create(new CompanyProfileSaveRequest(
" 示例科技有限公司 ", " 示例科技 ", " 91330100TEST000001 ", " 杭州市西湖区 ", " 张三 ", " 13812345678 "));
org.mockito.ArgumentCaptor<CompanyProfileEntity> entityCaptor = org.mockito.ArgumentCaptor.forClass(CompanyProfileEntity.class);
verify(mapper).insert(entityCaptor.capture());
CompanyProfileEntity entity = entityCaptor.getValue();
assertEquals("示例科技有限公司", entity.getCompanyName());
assertEquals("示例科技", entity.getShortName());
assertEquals("91330100TEST000001", entity.getUnifiedSocialCreditCode());
assertEquals("杭州市西湖区", entity.getAddress());
assertEquals("张三", entity.getContactName());
assertEquals("13812345678", entity.getContactValue());
assertNotNull(entity.getCreateTime());
assertEquals(entity.getCreateTime(), entity.getUpdateTime());
assertEquals(0L, entity.getDeleteTime());
assertEquals("示例科技有限公司", result.companyName());
}
/**
* 代码作用(白话):验证即使绕过 HTTP 参数校验直接调用业务服务,空白公司名称也不能写入数据库。
* 关联文件:CompanyProfileService.java、CompanyProfileSaveRequest.java、CompanyProfileMapper.java。
* 关联逻辑(调用链/数据流):空白表单值 -> Service.create 二次校验 -> 参数错误 -> 不调用 insert。
*/
@Test
void rejectsBlankCompanyNameBeforeInsert() {
CompanyProfileMapper mapper = mock(CompanyProfileMapper.class);
assertThrows(IllegalArgumentException.class, () -> new CompanyProfileService(mapper)
.create(new CompanyProfileSaveRequest(" ", null, null, null, null, null)));
verify(mapper, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.<CompanyProfileEntity>any());
}
/**
* 代码作用(白话):构造一条完整的有效公司档案,供列表字段映射测试使用。
* 关联文件:CompanyProfileEntity.java、CompanyProfileService.java。
* 关联逻辑(调用链/数据流):测试实体 -> Mapper 分页结果 -> Service 转换 -> Response 断言。
*/
private CompanyProfileEntity companyProfile() {
CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L);
entity.setCompanyName("示例科技有限公司");
entity.setShortName("示例科技");
entity.setUnifiedSocialCreditCode("91330100TEST000001");
entity.setAddress("杭州市西湖区");
entity.setContactName("张三");
entity.setContactValue("13812345678");
entity.setCreateTime(LocalDateTime.of(2026, 8, 5, 10, 0));
entity.setUpdateTime(LocalDateTime.of(2026, 8, 5, 11, 0));
entity.setDeleteTime(0L);
return entity;
}
}
...@@ -2,6 +2,7 @@ package com.xyw.console.asset.service; ...@@ -2,6 +2,7 @@ package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
...@@ -52,7 +53,7 @@ class WecomAccountServiceTest { ...@@ -52,7 +53,7 @@ class WecomAccountServiceTest {
WecomAccountResponse record = result.records().get(0); WecomAccountResponse record = result.records().get(0);
assertEquals(1L, record.id()); assertEquals(1L, record.id());
assertEquals("示例科技有限公司", record.companyProfileName()); assertEquals("示例科技", record.companyProfileName());
assertEquals(10L, record.companyProfileId()); assertEquals(10L, record.companyProfileId());
assertEquals("13812345678", record.phoneNumber()); assertEquals("13812345678", record.phoneNumber());
assertEquals(20L, record.phoneAssetId()); assertEquals(20L, record.phoneAssetId());
...@@ -133,6 +134,7 @@ class WecomAccountServiceTest { ...@@ -133,6 +134,7 @@ class WecomAccountServiceTest {
CompanyProfileEntity entity = new CompanyProfileEntity(); CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L); entity.setId(10L);
entity.setCompanyName("示例科技有限公司"); entity.setCompanyName("示例科技有限公司");
entity.setShortName("示例科技");
return entity; return entity;
} }
...@@ -188,4 +190,26 @@ class WecomAccountServiceTest { ...@@ -188,4 +190,26 @@ class WecomAccountServiceTest {
when(phoneMapper.selectOne(any())).thenReturn(null); when(phoneMapper.insert(any(PhoneAssetEntity.class))).thenAnswer(call -> { ((PhoneAssetEntity) call.getArgument(0)).setId(70L); return 1; }); when(wecomMapper.insert(any(WecomAccountEntity.class))).thenAnswer(call -> { ((WecomAccountEntity) call.getArgument(0)).setId(80L); return 1; }); when(phoneMapper.selectOne(any())).thenReturn(null); when(phoneMapper.insert(any(PhoneAssetEntity.class))).thenAnswer(call -> { ((PhoneAssetEntity) call.getArgument(0)).setId(70L); return 1; }); when(wecomMapper.insert(any(WecomAccountEntity.class))).thenAnswer(call -> { ((WecomAccountEntity) call.getArgument(0)).setId(80L); return 1; });
WecomAccountResponse result = new WecomAccountService(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", null, null, null, "13912345678", null, null, null, null)); WecomAccountResponse result = new WecomAccountService(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", null, null, null, "13912345678", null, null, null, null));
assertEquals("CREATED", result.phoneLinkMode()); verify(phoneMapper).updateById(any(PhoneAssetEntity.class)); assertEquals("CREATED", result.phoneLinkMode()); verify(phoneMapper).updateById(any(PhoneAssetEntity.class));
}} }
/**
* 代码作用(白话):验证企微创建不能引用不存在或已删除的公司档案,避免留下无效注册主体关联。
* 关联文件:WecomAccountService.java、CompanyProfileMapper.java、WecomAccountSaveRequest.java。
* 关联逻辑(调用链/数据流):创建表单 company_profile_id -> Service 有效性查询 -> 参数错误或企微写入。
*/
@Test
void rejectsAnUnknownCompanyProfileIdBeforeCreatingWecomAccount() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(companyProfileMapper.selectOne(any())).thenReturn(null);
WecomAccountService service = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class));
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.create(
new WecomAccountSaveRequest("测试企微", null, null, 99L, "13812345678", null, null, null, null)));
assertEquals("注册主体不存在或已删除", error.getMessage());
}
}
package com.xyw.console.auth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
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.CurrentUserResponse;
import com.xyw.console.auth.dto.LoginRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.password.PasswordEncoder;
/** 文件用途(白话):锁定登录判定的安全边界,确保失败路径不会因为跳过密码比对而泄露账号是否存在。 */
class AuthServiceTest {
private SystemUserMapper users;
private PasswordEncoder encoder;
private AuthTokenService tokens;
private LoginAttemptGuard loginAttempts;
private HttpServletResponse response;
private AuthService service;
/** 代码作用(白话):准备一套不连接数据库的登录依赖,让每个用例只验证判定逻辑。关联文件:AuthService.java、AuthTokenService.java、LoginAttemptGuard.java。关联逻辑(调用链/数据流):模拟用户表/编码器/令牌服务/失败计数 -> AuthService -> 断言。 */
@BeforeEach void setUp() {
users = mock(SystemUserMapper.class);
encoder = mock(PasswordEncoder.class);
tokens = mock(AuthTokenService.class);
loginAttempts = mock(LoginAttemptGuard.class);
response = mock(HttpServletResponse.class);
when(encoder.encode(any())).thenReturn("$2a$12$placeholder.hash.for.timing.equalizer.value");
// 多数用例只验证判定逻辑,补时设为 0 以免每个失败用例都真实等待
service = new AuthService(users, encoder, tokens, new PagePermissionService(new ObjectMapper()), loginAttempts, 0);
}
/** 代码作用(白话):账号不存在时也必须执行一次密码比对,否则响应耗时差会暴露哪些账号真实存在。关联文件:AuthService.java。关联逻辑(调用链/数据流):未知账号 -> 仍走 BCrypt 比对 -> 统一耗时 -> 通用错误。 */
@Test void unknownUsernameStillComparesPassword() {
when(users.selectOne(any())).thenReturn(null);
assertThrows(BadCredentialsException.class, () -> service.login(new LoginRequest("ghost_user", "some-password"), response));
verify(encoder).matches(eq("some-password"), any());
}
/** 代码作用(白话):停用账号同样要执行密码比对,避免通过耗时区分"账号不存在"和"账号被停用"。关联文件:AuthService.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):停用账号 -> 仍走比对 -> 统一耗时 -> 通用错误。 */
@Test void disabledAccountStillComparesPassword() {
SystemUserEntity user = user("DISABLED", "$2a$12$stored.hash");
when(users.selectOne(any())).thenReturn(user);
assertThrows(BadCredentialsException.class, () -> service.login(new LoginRequest("disabled_user", "some-password"), response));
verify(encoder).matches(eq("some-password"), any());
}
/** 代码作用(白话):尚未设置密码的账号必须被拒绝,且不能因为哈希为空就跳过比对。关联文件:AuthService.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):空密码哈希账号 -> 仍走比对 -> 通用错误。 */
@Test void accountWithoutPasswordStillComparesPassword() {
SystemUserEntity user = user("ACTIVE", null);
when(users.selectOne(any())).thenReturn(user);
assertThrows(BadCredentialsException.class, () -> service.login(new LoginRequest("pending_user", "some-password"), response));
verify(encoder).matches(eq("some-password"), any());
}
/** 代码作用(白话):密码不匹配时拒绝登录且不签发任何会话票据。关联文件:AuthService.java、AuthTokenService.java。关联逻辑(调用链/数据流):错误密码 -> 比对失败 -> 异常 -> 无 Set-Cookie。 */
@Test void wrongPasswordIsRejectedWithoutIssuingSession() {
SystemUserEntity user = user("ACTIVE", "$2a$12$stored.hash");
when(users.selectOne(any())).thenReturn(user);
when(encoder.matches(eq("wrong-password"), eq("$2a$12$stored.hash"))).thenReturn(false);
assertThrows(BadCredentialsException.class, () -> service.login(new LoginRequest("real_user", "wrong-password"), response));
verify(tokens, org.mockito.Mockito.never()).issue(any(), any());
}
/** 代码作用(白话):密码正确时签发会话票据并返回不含密码哈希的用户资料。关联文件:AuthService.java、CurrentUserResponse.java。关联逻辑(调用链/数据流):正确密码 -> issue() 写 Cookie -> 当前用户响应。 */
@Test void correctPasswordIssuesSession() {
SystemUserEntity user = user("ACTIVE", "$2a$12$stored.hash");
when(users.selectOne(any())).thenReturn(user);
when(encoder.matches(eq("right-password"), eq("$2a$12$stored.hash"))).thenReturn(true);
CurrentUserResponse result = service.login(new LoginRequest("real_user", "right-password"), response);
assertEquals("real_user", result.username());
verify(tokens).issue(any(AuthPrincipal.class), eq(response));
}
/** 代码作用(白话):登录失败必须计入限流,否则密码可以被无限次尝试。关联文件:LoginAttemptGuard.java。关联逻辑(调用链/数据流):比对失败 -> recordFailure -> 达阈值锁定。 */
@Test void failedLoginIsCountedForThrottling() {
when(users.selectOne(any())).thenReturn(null);
assertThrows(BadCredentialsException.class, () -> service.login(new LoginRequest("ghost_user", "some-password"), response));
verify(loginAttempts).recordFailure("ghost_user");
}
/** 代码作用(白话):登录成功必须清零失败计数,避免正常用户被历史失败累积锁定。关联文件:LoginAttemptGuard.java。关联逻辑(调用链/数据流):比对通过 -> recordSuccess -> 计数清零。 */
@Test void successfulLoginClearsThrottleCounter() {
SystemUserEntity user = user("ACTIVE", "$2a$12$stored.hash");
when(users.selectOne(any())).thenReturn(user);
when(encoder.matches(eq("right-password"), eq("$2a$12$stored.hash"))).thenReturn(true);
service.login(new LoginRequest("real_user", "right-password"), response);
verify(loginAttempts).recordSuccess("real_user");
}
/** 代码作用(白话):账号名被锁定时应在查询数据库之前就拒绝,避免爆破继续消耗数据库和 BCrypt 资源。关联文件:LoginAttemptGuard.java、AuthExceptionHandler.java。关联逻辑(调用链/数据流):锁定状态 -> assertAllowed 抛出 -> 不查库不比对 -> 429。 */
@Test void lockedAccountIsRejectedBeforeDatabaseLookup() {
org.mockito.Mockito.doThrow(new LoginThrottledException(java.time.Instant.parse("2026-08-06T09:00:00Z"))).when(loginAttempts).assertAllowed("Jeddy");
assertThrows(LoginThrottledException.class, () -> service.login(new LoginRequest("Jeddy", "any-password"), response));
org.mockito.Mockito.verifyNoInteractions(users);
}
/**
* 代码作用(白话):账号不存在与密码错误两条失败路径的耗时必须都达到统一下限,否则耗时差仍可用来枚举账号。
* 关联文件:AuthService.java、application.yml。
* 关联逻辑(调用链/数据流):失败分支 -> padFailureDuration -> 统一响应耗时。
*
* 说明:只断言耗时下限而不断言上限,避免测试受构建机负载波动影响而随机失败。
*/
@Test void allFailurePathsArePaddedToTheSameMinimumDuration() {
AuthService padded = new AuthService(users, encoder, tokens, new PagePermissionService(new ObjectMapper()), loginAttempts, 200);
when(users.selectOne(any())).thenReturn(null);
long unknownElapsed = elapsedMillisOfFailedLogin(padded, "ghost_user");
SystemUserEntity existing = user("ACTIVE", "$2a$12$stored.hash");
when(users.selectOne(any())).thenReturn(existing);
when(encoder.matches(any(), any())).thenReturn(false);
long wrongPasswordElapsed = elapsedMillisOfFailedLogin(padded, "real_user");
assertTrue(unknownElapsed >= 200, "账号不存在的失败耗时应补齐到下限,实际 " + unknownElapsed + "ms");
assertTrue(wrongPasswordElapsed >= 200, "密码错误的失败耗时应补齐到下限,实际 " + wrongPasswordElapsed + "ms");
}
/** 代码作用(白话):测量一次注定失败的登录调用实际耗费的毫秒数。关联文件:AuthService.java。关联逻辑(调用链/数据流):登录调用 -> 捕获预期异常 -> 返回耗时。 */
private long elapsedMillisOfFailedLogin(AuthService target, String username) {
long start = System.nanoTime();
assertThrows(BadCredentialsException.class, () -> target.login(new LoginRequest(username, "some-password"), response));
return (System.nanoTime() - start) / 1_000_000;
}
/** 代码作用(白话):构造一个持久化形态的账号记录,供各登录分支复用。关联文件:SystemUserEntity.java。关联逻辑(调用链/数据流):测试夹具 -> 模拟查询结果 -> 登录判定分支。 */
private SystemUserEntity user(String status, String passwordHash) {
SystemUserEntity entity = new SystemUserEntity();
entity.setId(7L); entity.setUsername("real_user"); entity.setRoleCode("FINANCE"); entity.setStatus(status);
entity.setPasswordHash(passwordHash); entity.setDeleteTime(0L); entity.setAuthVersion(1);
entity.setPagePermissions(new PagePermissionService(new ObjectMapper()).validatePermissions(Map.of()));
return entity;
}
}
package com.xyw.console.auth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** 文件用途(白话):验证登录限流的阈值、24 小时到期、解锁和并发计数行为,避免锁定规则在真实爆破场景下失效。 */
class InMemoryLoginAttemptGuardTest {
private MutableClock clock;
private InMemoryLoginAttemptGuard guard;
/** 代码作用(白话):用可控时钟构造限流组件,使 24 小时锁定无需真实等待即可验证。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):测试时钟 -> 锁定判定 -> 断言。 */
@BeforeEach void setUp() { clock = new MutableClock(Instant.parse("2026-08-05T09:00:00Z")); guard = new InMemoryLoginAttemptGuard(clock); }
/** 代码作用(白话):第 19 次失败仍可继续尝试,第 20 次失败后必须被拒绝。关联文件:InMemoryLoginAttemptGuard.java、AuthService.java。关联逻辑(调用链/数据流):连续失败 -> 达阈值 -> LoginThrottledException。 */
@Test void locksAccountOnTwentiethFailure() {
for (int i = 0; i < 19; i++) guard.recordFailure("Jeddy");
assertDoesNotThrow(() -> guard.assertAllowed("Jeddy"));
guard.recordFailure("Jeddy");
assertThrows(LoginThrottledException.class, () -> guard.assertAllowed("Jeddy"));
}
/** 代码作用(白话):锁定必须在 24 小时后自动解除,不需要人工干预。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):锁定时间 -> 时钟前进 -> 放行。 */
@Test void lockExpiresAfterTwentyFourHours() {
for (int i = 0; i < 20; i++) guard.recordFailure("Jeddy");
clock.advance(Duration.ofHours(23).plusMinutes(59));
assertThrows(LoginThrottledException.class, () -> guard.assertAllowed("Jeddy"));
clock.advance(Duration.ofMinutes(2));
assertDoesNotThrow(() -> guard.assertAllowed("Jeddy"));
}
/** 代码作用(白话):锁定期内继续尝试不得延长锁定截止时间,否则被恶意锁号的账号永远解不开。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):锁定后再失败 -> 截止时间不变 -> 24 小时后仍能登录。 */
@Test void continuedAttemptsDoNotExtendLock() {
for (int i = 0; i < 20; i++) guard.recordFailure("Jeddy");
Instant lockedUntil = guard.lockedAccounts().get(0).lockedUntil();
clock.advance(Duration.ofHours(12));
for (int i = 0; i < 50; i++) guard.recordFailure("Jeddy");
assertEquals(lockedUntil, guard.lockedAccounts().get(0).lockedUntil());
}
/** 代码作用(白话):登录成功后失败计数必须清零,避免长期累积导致正常用户被误锁。关联文件:AuthService.java。关联逻辑(调用链/数据流):成功登录 -> 移除计数 -> 后续失败重新计数。 */
@Test void successResetsFailureCount() {
for (int i = 0; i < 19; i++) guard.recordFailure("Jeddy");
guard.recordSuccess("Jeddy");
for (int i = 0; i < 19; i++) guard.recordFailure("Jeddy");
assertDoesNotThrow(() -> guard.assertAllowed("Jeddy"));
}
/** 代码作用(白话):改变账号名大小写或加空格不得绕过限流。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):不同写法账号名 -> 归一化为同一计数键 -> 共享锁定状态。 */
@Test void usernameIsCaseAndWhitespaceInsensitive() {
for (int i = 0; i < 10; i++) guard.recordFailure("Jeddy");
for (int i = 0; i < 10; i++) guard.recordFailure(" JEDDY ");
assertThrows(LoginThrottledException.class, () -> guard.assertAllowed("jeddy"));
}
/** 代码作用(白话):开发者手动解锁后账号必须立即可以再次尝试登录。关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):解锁操作 -> 移除计数 -> 放行。 */
@Test void unlockClearsLockImmediately() {
for (int i = 0; i < 20; i++) guard.recordFailure("Jeddy");
assertTrue(guard.unlock("JEDDY"));
assertDoesNotThrow(() -> guard.assertAllowed("Jeddy"));
assertTrue(guard.lockedAccounts().isEmpty());
}
/** 代码作用(白话):锁定到期后第一次输错密码不得立即重新锁定 24 小时,否则账号等同于被永久锁死。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):锁定到期 -> 计数重置 -> 再次失败仅计 1 次。 */
@Test void expiredLockRestartsCountingInsteadOfRelocking() {
for (int i = 0; i < 20; i++) guard.recordFailure("Jeddy");
clock.advance(Duration.ofHours(24).plusMinutes(1));
guard.recordFailure("Jeddy");
assertDoesNotThrow(() -> guard.assertAllowed("Jeddy"));
assertTrue(guard.lockedAccounts().isEmpty());
}
/** 代码作用(白话):距上次失败超过统计窗口后重新计数,避免零星历史失败被无限累加。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):过期失败记录 -> 计数重置 -> 不误锁。 */
@Test void failuresOutsideWindowRestartCounting() {
for (int i = 0; i < 19; i++) guard.recordFailure("Jeddy");
clock.advance(Duration.ofHours(25));
for (int i = 0; i < 19; i++) guard.recordFailure("Jeddy");
assertDoesNotThrow(() -> guard.assertAllowed("Jeddy"));
}
/** 代码作用(白话):并发失败必须精确计数,丢失更新会让实际可尝试次数超过阈值。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):多线程同时失败 -> 原子累加 -> 恰好第 20 次锁定。 */
@Test void concurrentFailuresCountExactly() throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(8);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(19);
for (int i = 0; i < 19; i++) pool.submit(() -> { try { start.await(); guard.recordFailure("Jeddy"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); } });
start.countDown();
assertTrue(done.await(5, TimeUnit.SECONDS));
pool.shutdown();
assertDoesNotThrow(() -> guard.assertAllowed("Jeddy"));
guard.recordFailure("Jeddy");
assertThrows(LoginThrottledException.class, () -> guard.assertAllowed("Jeddy"));
}
/** 代码作用(白话):提供可手动前进的时钟,用于验证与真实时间相关的锁定行为。关联文件:InMemoryLoginAttemptGuard.java。关联逻辑(调用链/数据流):测试推进时间 -> 限流组件读取 -> 锁定到期。 */
private static final class MutableClock extends Clock {
private Instant now;
private MutableClock(Instant now) { this.now = now; }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
private void advance(Duration duration) { now = now.plus(duration); }
}
}
...@@ -30,7 +30,7 @@ class SystemUserAdminServiceTest { ...@@ -30,7 +30,7 @@ class SystemUserAdminServiceTest {
/** 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. */ /** 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() { @BeforeEach void setUp() {
users = mock(SystemUserMapper.class); users = mock(SystemUserMapper.class);
service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), mock(PasswordEncoder.class)); service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), mock(PasswordEncoder.class), mock(LoginAttemptGuard.class));
authenticate("DEVELOPER"); authenticate("DEVELOPER");
} }
...@@ -75,7 +75,7 @@ class SystemUserAdminServiceTest { ...@@ -75,7 +75,7 @@ class SystemUserAdminServiceTest {
when(users.selectCount(any())).thenReturn(0L); when(users.selectCount(any())).thenReturn(0L);
PasswordEncoder encoder = mock(PasswordEncoder.class); PasswordEncoder encoder = mock(PasswordEncoder.class);
when(encoder.encode(any())).thenReturn("hash"); when(encoder.encode(any())).thenReturn("hash");
service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), encoder); service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), encoder, mock(LoginAttemptGuard.class));
service.createUser(new SystemUserCreateRequest("admin_two", "SUPER_ADMIN", "strong-password-123", Map.of())); service.createUser(new SystemUserCreateRequest("admin_two", "SUPER_ADMIN", "strong-password-123", Map.of()));
verify(users).insert(any(SystemUserEntity.class)); verify(users).insert(any(SystemUserEntity.class));
} }
...@@ -85,7 +85,7 @@ class SystemUserAdminServiceTest { ...@@ -85,7 +85,7 @@ class SystemUserAdminServiceTest {
when(users.selectCount(any())).thenReturn(0L); when(users.selectCount(any())).thenReturn(0L);
PasswordEncoder encoder = mock(PasswordEncoder.class); PasswordEncoder encoder = mock(PasswordEncoder.class);
when(encoder.encode(any())).thenReturn("hash"); when(encoder.encode(any())).thenReturn("hash");
service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), encoder); service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), encoder, mock(LoginAttemptGuard.class));
service.createUser(new SystemUserCreateRequest("Jeddy2026User", "OPERATIONS", "Jeddy2026User", Map.of())); service.createUser(new SystemUserCreateRequest("Jeddy2026User", "OPERATIONS", "Jeddy2026User", Map.of()));
verify(users).insert(any(SystemUserEntity.class)); verify(users).insert(any(SystemUserEntity.class));
} }
......
...@@ -30,5 +30,5 @@ export default { ...@@ -30,5 +30,5 @@ export default {
return { authState, logout, canVisit, isAdministrator, retryConnection }; 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>` 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('company-profile')" to="/company-profiles">公司档案</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>`
}; };
...@@ -5,6 +5,22 @@ import blackHoleImage from '../../../img/black_hole.png'; ...@@ -5,6 +5,22 @@ import blackHoleImage from '../../../img/black_hole.png';
const REMEMBERED_USERNAME_STORAGE_KEY = 'xyw-asset-console.remembered-username'; const REMEMBERED_USERNAME_STORAGE_KEY = 'xyw-asset-console.remembered-username';
/**
* 代码作用(白话):按后端返回的状态区分密码错误、锁定、填写错误和网络中断,避免所有故障都提示为密码错误。
* 关联文件:auth-api-client.js、AuthExceptionHandler.java、GlobalExceptionHandler.java。
* 关联逻辑(调用链/数据流):登录失败 -> request() 附加 error.status -> 本方法 -> 对应提示文案。
*
* 修复要点:此前这里统一提示"账号或密码错误",后端故障或断网时用户会拿着正确密码反复重试;
* fetch 本身失败(断网、服务未启动)抛出的错误不带 status,据此单独识别为网络问题。
*/
function loginErrorMessage(error) {
if (!error || !error.status) return '无法连接服务器,请检查网络后重试';
if (error.status === 401) return '账号或密码错误,请重试';
if (error.status === 429) return error.message || '登录失败次数过多,请稍后重试';
if (error.status === 400) return error.message || '请填写正确的账号和密码';
return error.message || '登录失败,请稍后重试';
}
/** 代码作用(白话):提供唯一的账号密码登录界面,并只在 Jeddy 成功登录的当次显示专属欢迎提示。关联文件:auth-store.js、AuthController.java、app.css。关联逻辑(调用链/数据流):登录表单 -> Auth API -> Cookie/用户状态 -> 路由跳转。 */ /** 代码作用(白话):提供唯一的账号密码登录界面,并只在 Jeddy 成功登录的当次显示专属欢迎提示。关联文件:auth-store.js、AuthController.java、app.css。关联逻辑(调用链/数据流):登录表单 -> Auth API -> Cookie/用户状态 -> 路由跳转。 */
export default { export default {
/** 代码作用(白话):准备登录表单状态,并从浏览器中读取此前选择记住的账号。关联文件:app.css、auth-store.js。关联逻辑(调用链/数据流):浏览器本地存储 -> 表单账号 -> 用户提交登录。 */ /** 代码作用(白话):准备登录表单状态,并从浏览器中读取此前选择记住的账号。关联文件:app.css、auth-store.js。关联逻辑(调用链/数据流):浏览器本地存储 -> 表单账号 -> 用户提交登录。 */
...@@ -21,6 +37,8 @@ export default { ...@@ -21,6 +37,8 @@ export default {
const form = reactive({ username: getRememberedUsername(), password: '' }); const form = reactive({ username: getRememberedUsername(), password: '' });
const rememberUsername = ref(Boolean(form.username)); const rememberUsername = ref(Boolean(form.username));
const submitting = ref(false); const submitting = ref(false);
const usernameInput = ref(null);
const passwordInput = ref(null);
/** 代码作用(白话):按复选框状态仅保存或删除账号名,绝不保存密码和 Token。关联文件:LoginView.js。关联逻辑(调用链/数据流):复选框/登录成功 -> localStorage -> 下次打开表单自动回填。 */ /** 代码作用(白话):按复选框状态仅保存或删除账号名,绝不保存密码和 Token。关联文件:LoginView.js。关联逻辑(调用链/数据流):复选框/登录成功 -> localStorage -> 下次打开表单自动回填。 */
function syncRememberedUsername() { function syncRememberedUsername() {
...@@ -37,21 +55,25 @@ export default { ...@@ -37,21 +55,25 @@ export default {
/** 代码作用(白话):提交现有账号密码数据、按需记住账号,并在完成后清除页面内存中的密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 submit -> signIn -> 清除密码/账号本地存储/Jeddy 欢迎提示或通用错误 -> 资产总览。 */ /** 代码作用(白话):提交现有账号密码数据、按需记住账号,并在完成后清除页面内存中的密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 submit -> signIn -> 清除密码/账号本地存储/Jeddy 欢迎提示或通用错误 -> 资产总览。 */
async function submit() { async function submit() {
// 账号或密码为空时当场提示并聚焦,不发无谓的请求;后端 400 仍作为绕过页面直接调接口的兜底
const username = form.username.trim();
if (!username) { ElMessage.warning('请输入账号'); usernameInput.value?.focus(); return; }
if (!form.password) { ElMessage.warning('请输入密码'); passwordInput.value?.focus(); return; }
submitting.value = true; submitting.value = true;
try { try {
const user = await signIn(form); const user = await signIn({ username, password: form.password });
syncRememberedUsername(); syncRememberedUsername();
form.password = ''; form.password = '';
if (user.username === 'Jeddy' && user.roleCode === 'DEVELOPER') ElMessage.success('🎉 欢迎系统开发者-BOSS:Jeddy 上线'); if (user.username === 'Jeddy' && user.roleCode === 'DEVELOPER') ElMessage.success('🎉 欢迎系统开发者-BOSS:Jeddy 上线');
window.location.hash = '#/overview'; window.location.hash = '#/overview';
} catch (_) { } catch (error) {
ElMessage.error('账号或密码错误,请重试'); ElMessage.error(loginErrorMessage(error));
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
} }
return { blackHoleImage, form, rememberUsername, submitting, submit, syncRememberedUsername }; return { blackHoleImage, form, rememberUsername, submitting, submit, syncRememberedUsername, usernameInput, passwordInput };
}, },
template: ` template: `
<main class="login-page" :style="{ '--login-background-image': 'url(' + blackHoleImage + ')' }"> <main class="login-page" :style="{ '--login-background-image': 'url(' + blackHoleImage + ')' }">
...@@ -65,11 +87,11 @@ export default { ...@@ -65,11 +87,11 @@ export default {
</header> </header>
<el-form class="login-form" @submit.prevent="submit"> <el-form class="login-form" @submit.prevent="submit">
<el-form-item class="login-form__item"> <el-form-item class="login-form__item">
<el-input v-model="form.username" class="login-form__input" placeholder="请输入账号" autocomplete="username" aria-label="账号" /> <el-input ref="usernameInput" v-model="form.username" class="login-form__input" placeholder="请输入账号" autocomplete="username" aria-label="账号" />
</el-form-item> </el-form-item>
<el-form-item class="login-form__item"> <el-form-item class="login-form__item">
<div class="login-password-field"> <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" /> <el-input ref="passwordInput" v-model="form.password" class="login-form__input" placeholder="请输入密码" type="password" show-password autocomplete="current-password" aria-label="密码" @keyup.enter="submit" />
</div> </div>
</el-form-item> </el-form-item>
<el-checkbox v-model="rememberUsername" class="login-form__remember" @change="syncRememberedUsername">记住我的账号</el-checkbox> <el-checkbox v-model="rememberUsername" class="login-form__remember" @change="syncRememberedUsername">记住我的账号</el-checkbox>
......
import { computed, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createCompanyProfile, listCompanyProfiles } from './company-profile-api-client.js';
/** 文件用途(白话):显示可搜索、可分页、可新增的公司档案表格,并隐藏内部 ID 与审计字段编辑入口。 */
export default {
/**
* 代码作用(白话):创建公司档案列表、搜索、弹窗和表单的响应式状态,并将页面操作连接到公司档案接口。
* 关联文件:company-profile-api-client.js、CompanyProfileController.java、auth-store.js。
* 关联逻辑(调用链/数据流):路由进入 -> setup 状态 -> GET/POST 接口 -> 表格与弹窗渲染。
*/
setup() {
const loading = ref(false);
const saving = ref(false);
const records = ref([]);
const total = ref(0);
const dialogVisible = ref(false);
const filters = reactive({ page: 1, size: 20, keyword: '' });
const form = reactive({ companyName: '', shortName: '', unifiedSocialCreditCode: '', address: '', contactName: '', contactValue: '' });
const canEdit = computed(() => authState.user?.pagePermissions?.['company-profile'] === 'EDIT');
let searchTimer;
/**
* 代码作用(白话):按当前关键词和页码读取公司档案,并更新表格记录与总数。
* 关联文件:company-profile-api-client.js、CompanyProfileService.java。
* 关联逻辑(调用链/数据流):搜索/分页/新增成功 -> listCompanyProfiles -> GET -> records/total -> 表格。
*/
async function loadPage() {
loading.value = true;
try {
const result = await listCompanyProfiles(filters);
records.value = result.records;
total.value = result.total;
} catch (error) {
ElMessage.error(error.message);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):把新增表单恢复为空值,确保每次打开弹窗都不会保留上次输入。
* 关联文件:CompanyProfileView.js、CompanyProfileSaveRequest.java。
* 关联逻辑(调用链/数据流):新增按钮 -> resetForm -> 空表单 -> POST 请求。
*/
function resetForm() {
Object.assign(form, { companyName: '', shortName: '', unifiedSocialCreditCode: '', address: '', contactName: '', contactValue: '' });
}
/**
* 代码作用(白话):打开新增公司档案弹窗,并先清理可能残留的表单内容。
* 关联文件:CompanyProfileView.js、app.css。
* 关联逻辑(调用链/数据流):EDIT 用户点击新增 -> resetForm -> dialogVisible=true -> Element Plus 弹窗。
*/
function openCreate() {
resetForm();
dialogVisible.value = true;
}
/**
* 代码作用(白话):检查公司名称必填后提交新增请求,成功时关闭弹窗并刷新列表第一页。
* 关联文件:company-profile-api-client.js、CompanyProfileController.java、CompanyProfileService.java。
* 关联逻辑(调用链/数据流):确认保存 -> 前端必填校验 -> POST -> 成功提示 -> 关闭弹窗 -> GET 列表。
*/
async function submitCreate() {
const companyName = form.companyName.trim();
if (!companyName) {
ElMessage.warning('请填写公司名称');
return;
}
saving.value = true;
try {
await createCompanyProfile({ ...form, companyName });
ElMessage.success('新增成功');
dialogVisible.value = false;
filters.page = 1;
await loadPage();
} catch (error) {
ElMessage.error(error.message);
} finally {
saving.value = false;
}
}
/**
* 代码作用(白话):等待用户停止输入后再从第一页搜索,避免每个字符都发送一次请求。
* 关联文件:CompanyProfileView.js、company-profile-api-client.js。
* 关联逻辑(调用链/数据流):关键词 input -> 300ms 定时器 -> submitSearch -> GET 列表。
*/
function scheduleSearch() {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(submitSearch, 300);
}
/**
* 代码作用(白话):立即按当前关键词重新查询公司档案,并回到第一页。
* 关联文件:CompanyProfileView.js、CompanyProfileService.java。
* 关联逻辑(调用链/数据流):搜索提交 -> page=1 -> loadPage -> 表格刷新。
*/
function submitSearch() {
filters.page = 1;
loadPage();
}
/**
* 代码作用(白话):清空关键词并恢复默认分页,然后重新读取完整列表。
* 关联文件:CompanyProfileView.js、company-profile-api-client.js。
* 关联逻辑(调用链/数据流):重置按钮 -> 默认 filters -> loadPage -> 表格刷新。
*/
function resetSearch() {
window.clearTimeout(searchTimer);
Object.assign(filters, { page: 1, size: 20, keyword: '' });
loadPage();
}
/**
* 代码作用(白话):切换到用户选择的页码并读取对应记录。
* 关联文件:CompanyProfileView.js、CompanyProfilePageQuery.java。
* 关联逻辑(调用链/数据流):分页器 -> filters.page -> GET 参数 -> 对应页记录。
*/
function changePage(page) {
filters.page = page;
loadPage();
}
/**
* 代码作用(白话):统一显示空字段,避免可选信息为空时留下难以辨认的空白。
* 关联文件:CompanyProfileResponse.java、CompanyProfileView.js。
* 关联逻辑(调用链/数据流):API 字段 -> formatValue -> 表格单元格。
*/
function formatValue(value) {
return value || '—';
}
onMounted(loadPage);
return { canEdit, changePage, dialogVisible, filters, form, formatValue, loading, openCreate, records, resetSearch, saving, scheduleSearch, submitCreate, submitSearch, total };
},
template: `
<section class="phone-asset-list-page company-profile-page">
<header class="phone-asset-list-page__header"><div><h2>公司档案</h2><p class="wecom-account-page__eyebrow">COMPANY PROFILES</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" @keydown.enter.prevent="submitSearch" /><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 company-profile-page__grid"><el-table-column label="公司名称" min-width="200"><template #default="{ row }">{{ formatValue(row.companyName) }}</template></el-table-column><el-table-column label="公司简称" min-width="150"><template #default="{ row }">{{ formatValue(row.shortName) }}</template></el-table-column><el-table-column label="统一社会信用代码" min-width="210"><template #default="{ row }">{{ formatValue(row.unifiedSocialCreditCode) }}</template></el-table-column><el-table-column label="地址" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ formatValue(row.address) }}</template></el-table-column><el-table-column label="联系人" min-width="130"><template #default="{ row }">{{ formatValue(row.contactName) }}</template></el-table-column><el-table-column label="联系方式" min-width="170"><template #default="{ row }">{{ formatValue(row.contactValue) }}</template></el-table-column><el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatValue(row.createTime) }}</template></el-table-column><el-table-column label="更新时间" min-width="180"><template #default="{ row }">{{ formatValue(row.updateTime) }}</template></el-table-column></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" class="phone-asset-modal" modal-class="phone-asset-modal-mask" title="新增公司档案" width="560px">
<el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate">
<el-form-item class="phone-asset-modal__form-row" label="公司名称" required><el-input v-model="form.companyName" maxlength="100" autocomplete="off" placeholder="请输入公司名称" /></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="公司简称"><el-input v-model="form.shortName" maxlength="100" autocomplete="off" placeholder="请输入公司简称" /></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="统一社会信用代码"><el-input v-model="form.unifiedSocialCreditCode" maxlength="100" autocomplete="off" placeholder="请输入统一社会信用代码" /></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="地址"><el-input v-model="form.address" maxlength="255" autocomplete="off" placeholder="请输入地址" /></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="联系人"><el-input v-model="form.contactName" maxlength="100" autocomplete="off" placeholder="请输入联系人" /></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="联系方式"><el-input v-model="form.contactValue" maxlength="100" autocomplete="off" placeholder="请输入联系方式" /></el-form-item>
</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>
`
};
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/** 文件用途(白话):集中请求公司档案列表和新增接口,并把统一 JSON 响应转换为页面数据。 */
/**
* 代码作用(白话):发送公司档案接口请求,成功时返回数据,失败时向页面提供可读错误。
* 关联文件:CompanyProfileView.js、CompanyProfileController.java。
* 关联逻辑(调用链/数据流):页面操作 -> request -> ApiResponse -> 页面状态或错误提示。
*/
async function request(path, options = {}) {
const headers = await csrfHeadersFor(path, (options.method || 'GET').toUpperCase());
const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers }, ...options });
const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '公司档案请求失败');
return payload.data;
}
/**
* 代码作用(白话):将当前页码、页大小和关键词转换为公司档案分页查询参数。
* 关联文件:CompanyProfileView.js、CompanyProfileController.java。
* 关联逻辑(调用链/数据流):搜索栏/分页器 -> URL 参数 -> GET 接口 -> 表格记录。
*/
export function listCompanyProfiles(query) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') params.set(key, value);
});
return request(`/api/company-profiles?${params}`);
}
/**
* 代码作用(白话):将新增公司档案表单发送给后端,避免浏览器提交审计字段和内部 ID。
* 关联文件:CompanyProfileView.js、CompanyProfileController.java、CompanyProfileSaveRequest.java。
* 关联逻辑(调用链/数据流):新增弹窗 -> createCompanyProfile -> POST -> Service.create -> 列表刷新。
*/
export function createCompanyProfile(form) {
return request('/api/company-profiles', { method: 'POST', body: JSON.stringify(form) });
}
import { computed, reactive, ref } from 'vue/dist/vue.esm-bundler.js'; import { computed, 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 { authState } from '../auth/auth-store.js';
import { createSystemUser, listSystemUsers, resetSystemUserPassword, updateSystemUser } from './system-user-api-client.js'; import { createSystemUser, listLockedAccounts, listSystemUsers, resetSystemUserPassword, unlockLoginLock, 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 pages = [{ key: 'overview', label: '总览' }, { key: 'domain', label: '域名资料' }, { key: 'company-profile', 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: '运营' }]; const roles = [{ value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'FINANCE', label: '财务' }, { value: 'HR', label: '人事' }, { value: 'OPERATIONS', label: '运营' }];
/** 代码作用(白话):创建五页均无权限的编辑表单初始值;关联文件:PagePermissionService.java、UserPermissionView.js。关联逻辑(调用链/数据流):新增/编辑打开 -> 本函数 -> 表单权限单选 -> JSON 提交。 */ /** 代码作用(白话):创建五页均无权限的编辑表单初始值;关联文件:PagePermissionService.java、UserPermissionView.js。关联逻辑(调用链/数据流):新增/编辑打开 -> 本函数 -> 表单权限单选 -> JSON 提交。 */
function blankForm() { return { username: '', roleCode: 'FINANCE', status: 'ACTIVE', password: '', pagePermissions: Object.fromEntries(pages.map(page => [page.key, 'NONE'])) }; } 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 -> 刷新列表。 */ /** 代码作用(白话):提供账号创建、角色编辑、密码重置和逐页权限面板;关联文件: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'); export default { setup() { const users = ref([]); const lockedAccounts = 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 -> 表格。 */ /** 代码作用(白话):读取当前可管理账号列表,后端已过滤固定开发者 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; } } async function load() { loading.value = true; try { users.value = await listSystemUsers(); } catch (error) { ElMessage.error(error.message || '账号列表加载失败'); } finally { loading.value = false; } await loadLockedAccounts(); }
/** 代码作用(白话):读取当前被登录失败锁定的账号名;此列表加载失败不弹错,避免打断账号管理主流程。关联文件:LoginAttemptGuard.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):页面进入/解锁后 -> GET locked-accounts -> 锁定表格。 */
async function loadLockedAccounts() { try { lockedAccounts.value = await listLockedAccounts(); } catch (_) { lockedAccounts.value = []; } }
/** 代码作用(白话):解除某个账号名的登录锁定,用于 24 小时锁定被误触发或被恶意锁号时人工恢复。关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):解除锁定按钮 -> DELETE -> 清空失败计数 -> 刷新锁定列表。 */
async function unlockAccount(row) { try { await unlockLoginLock(row.username); ElMessage.success('已解除登录锁定'); await loadLockedAccounts(); } catch (error) { ElMessage.error(error.message || '解除锁定失败'); } }
/** 代码作用(白话):把锁定截止时间显示为本地可读时间;关联文件:LoginAttemptGuard.java。关联逻辑(调用链/数据流):ISO 时间字符串 -> 本地时间文本 -> 表格单元格。 */
function formatLockTime(value) { return value ? new Date(value).toLocaleString('zh-CN') : '—'; }
/** 代码作用(白话):打开新增账号面板,超级管理员不会看到密码输入;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):新增按钮 -> 空表单 -> 创建提交。 */ /** 代码作用(白话):打开新增账号面板,超级管理员不会看到密码输入;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):新增按钮 -> 空表单 -> 创建提交。 */
function openCreate() { Object.assign(form, blankForm()); editingId.value = null; dialogOpen.value = true; } function openCreate() { Object.assign(form, blankForm()); editingId.value = null; dialogOpen.value = true; }
/** 代码作用(白话):把选中账号复制到编辑面板,避免直接改动表格对象;关联文件:SystemUserResponse.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):编辑按钮 -> 本地副本 -> PUT 更新。 */ /** 代码作用(白话):把选中账号复制到编辑面板,避免直接改动表格对象;关联文件:SystemUserResponse.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):编辑按钮 -> 本地副本 -> PUT 更新。 */
...@@ -21,4 +27,4 @@ export default { setup() { const users = ref([]); const loading = ref(false); co ...@@ -21,4 +27,4 @@ export default { setup() { const users = ref([]); const loading = ref(false); co
function openPassword(user) { target.value = user; password.value = ''; passwordOpen.value = true; } function openPassword(user) { target.value = user; password.value = ''; passwordOpen.value = true; }
/** 代码作用(白话):把新密码提交给后端并立即从页面内存清除;关联文件:system-user-api-client.js、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):新密码 -> BCrypt -> 数据库触发器 -> 旧会话失效。 */ /** 代码作用(白话):把新密码提交给后端并立即从页面内存清除;关联文件: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 || '密码更新失败'); } } 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>` }; load(); return { users, lockedAccounts, loading, dialogOpen, passwordOpen, form, pages, roles, editingId, isDeveloper, isAdministratorRole, target, password, load, openCreate, openEdit, save, openPassword, savePassword, unlockAccount, formatLockTime }; }, 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 v-if="lockedAccounts.length" class="reference-card"><h3>登录锁定({{ lockedAccounts.length }})</h3><p>连续输错密码 20 次的账号会被锁定 24 小时。若为误触发或被他人恶意锁定,可在此立即解除。</p><el-table :data="lockedAccounts"><el-table-column prop="username" label="账号名" /><el-table-column prop="failures" label="失败次数" width="110" /><el-table-column label="锁定至"><template #default="scope">{{ formatLockTime(scope.row.lockedUntil) }}</template></el-table-column><el-table-column label="操作" width="140"><template #default="scope"><el-button link type="primary" @click="unlockAccount(scope.row)">解除锁定</el-button></template></el-table-column></el-table></section><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>` };
...@@ -8,3 +8,7 @@ export function createSystemUser(form) { return request('/api/system-users', { m ...@@ -8,3 +8,7 @@ export function createSystemUser(form) { return request('/api/system-users', { m
export function updateSystemUser(id, form) { return request(`/api/system-users/${id}`, { method: 'PUT', body: JSON.stringify(form) }); } export function updateSystemUser(id, form) { return request(`/api/system-users/${id}`, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):仅供开发者提交非开发者账号的新密码;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):密码面板 -> PUT password -> BCrypt/数据库触发器。 */ /** 代码作用(白话):仅供开发者提交非开发者账号的新密码;关联文件: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 }) }); } export function resetSystemUserPassword(id, password) { return request(`/api/system-users/${id}/password`, { method: 'PUT', body: JSON.stringify({ password }) }); }
/** 代码作用(白话):查询当前因连续登录失败被锁定的账号名;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):账号页加载 -> GET locked-accounts -> 锁定表格。 */
export function listLockedAccounts() { return request('/api/system-users/locked-accounts'); }
/** 代码作用(白话):解除指定账号名的登录锁定,供被恶意锁号时人工恢复;关联文件:UserPermissionView.js、LoginAttemptGuard.java。关联逻辑(调用链/数据流):解除锁定按钮 -> DELETE -> 清空失败计数 -> 该账号可立即登录。 */
export function unlockLoginLock(username) { return request(`/api/system-users/locked-accounts/${encodeURIComponent(username)}`, { method: 'DELETE' }); }
import { computed, 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 } from 'element-plus'; import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js'; 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';
...@@ -29,6 +29,17 @@ export default { ...@@ -29,6 +29,17 @@ export default {
/** Code purpose (plain language): resets the create form to the business defaults. Related files: WecomAccountSaveRequest.java. Data flow: add button -> reset -> dialog form. */ /** Code purpose (plain language): resets the create form to the business defaults. Related files: WecomAccountSaveRequest.java. Data flow: add button -> reset -> dialog form. */
function resetForm() { Object.assign(form, { wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null }); } function resetForm() { Object.assign(form, { wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null }); }
/**
* 代码作用(白话):在统一资产弹窗打开后把正文滚动位置恢复到顶部,保证标题、表单起点和底部操作区的使用体验一致。
* 关联文件:PhoneAssetView.js、main.css、WecomAccountView.js。
* 关联逻辑(调用链/数据流):Element Plus opened 事件 -> nextTick -> 弹窗正文 scrollTop=0 -> 用户填写纵向表单。
*/
async function resetDialogScroll() {
await nextTick();
const body = document.querySelector('.phone-asset-modal .el-dialog__body');
if (body) body.scrollTop = 0;
}
/** Code purpose (plain language): opens a clean creation dialog. Related files: WecomAccountView.js. Data flow: add button -> resetForm -> dialog visible. */ /** Code purpose (plain language): opens a clean creation dialog. Related files: WecomAccountView.js. Data flow: add button -> resetForm -> dialog visible. */
function openCreate() { resetForm(); dialogVisible.value = true; } function openCreate() { resetForm(); dialogVisible.value = true; }
...@@ -77,14 +88,14 @@ export default { ...@@ -77,14 +88,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 { canEdit, changePage, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, limitPhoneNumber, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total }; return { canEdit, changePage, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, limitPhoneNumber, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetDialogScroll, 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 v-if="canEdit" 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 :model-value="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" maxlength="11" inputmode="numeric" placeholder="输入 11 位手机号" style="width:100%" @update:model-value="limitPhoneNumber"><template #suffix><span>{{ form.phoneNumber.length }}/11</span></template></el-autocomplete></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" class="phone-asset-modal" modal-class="phone-asset-modal-mask" title="新增企业微信资产" width="560px" :close-on-click-modal="false" @opened="resetDialogScroll"><el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate"><el-form-item class="phone-asset-modal__form-row" label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册手机号" required><el-autocomplete :model-value="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" maxlength="11" inputmode="numeric" placeholder="输入 11 位手机号" style="width:100%" @update:model-value="limitPhoneNumber"><template #suffix><span class="phone-asset-modal__character-count">{{ form.phoneNumber.length }}/11</span></template></el-autocomplete></el-form-item><el-form-item class="phone-asset-modal__form-row" 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-form-item class="phone-asset-modal__form-row" 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-form-item class="phone-asset-modal__form-row" label="实名人"><el-input v-model="form.realNameOwner" /></el-form-item><el-form-item class="phone-asset-modal__form-row" 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-form-item class="phone-asset-modal__form-row" 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-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>
` `
}; };
...@@ -3,6 +3,7 @@ import LegacyReferenceView from '../reference/LegacyReferenceView.js'; ...@@ -3,6 +3,7 @@ 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 CompanyProfileView from '../modules/company-profile/CompanyProfileView.js';
import LoginView from '../modules/auth/LoginView.js'; import LoginView from '../modules/auth/LoginView.js';
import UserPermissionView from '../modules/system-user/UserPermissionView.js'; import UserPermissionView from '../modules/system-user/UserPermissionView.js';
import { authState, bootstrapAuth } from '../modules/auth/auth-store.js'; import { authState, bootstrapAuth } from '../modules/auth/auth-store.js';
...@@ -28,6 +29,7 @@ const router = createRouter({ ...@@ -28,6 +29,7 @@ const router = createRouter({
{ path: '/login', component: LoginView, meta: { public: true } }, { path: '/login', component: LoginView, meta: { public: true } },
{ path: '/overview', component: createPlaceholderView('资产总览'), meta: { page: 'overview' } }, { path: '/overview', component: createPlaceholderView('资产总览'), meta: { page: 'overview' } },
{ path: '/phone-assets', component: PhoneAssetView, meta: { page: 'phone-assets' } }, { path: '/phone-assets', component: PhoneAssetView, meta: { page: 'phone-assets' } },
{ path: '/company-profiles', component: CompanyProfileView, meta: { page: 'company-profile' } },
{ path: '/device-assets', component: DeviceAssetView, meta: { administratorOnly: true } }, { path: '/device-assets', component: DeviceAssetView, meta: { administratorOnly: true } },
{ path: '/domain', component: createPlaceholderView('域名资料'), meta: { page: 'domain' } }, { path: '/domain', component: createPlaceholderView('域名资料'), meta: { page: 'domain' } },
{ path: '/alerts', component: createPlaceholderView('提醒中心'), meta: { page: 'alerts' } }, { path: '/alerts', component: createPlaceholderView('提醒中心'), meta: { page: 'alerts' } },
......
...@@ -16,6 +16,7 @@ test.beforeEach(async ({ page }) => { ...@@ -16,6 +16,7 @@ test.beforeEach(async ({ page }) => {
domain: 'EDIT', domain: 'EDIT',
'reference-wecom': 'EDIT', 'reference-wecom': 'EDIT',
'phone-assets': 'EDIT', 'phone-assets': 'EDIT',
'company-profile': 'EDIT',
alerts: 'EDIT' alerts: 'EDIT'
} }
}; };
......
import { expect, test } from './authenticated-test.js';
/** 文件用途(白话):验证公司档案菜单、展示字段和新增弹窗的主流程,不暴露内部 ID。 */
test('shows company profiles and creates one from the reusable asset modal', async ({ page }) => {
let createPayload = null;
let listRequests = 0;
/**
* 代码作用(白话):按请求方法返回列表或记录新增表单,模拟同一个公司档案接口的读写结果。
* 关联文件:CompanyProfileView.js、company-profile-api-client.js、CompanyProfileController.java。
* 关联逻辑(调用链/数据流):页面 GET -> 列表数据;弹窗 POST -> 记录请求正文 -> 成功响应 -> 再次 GET。
*/
await page.route('**/api/company-profiles**', async route => {
if (route.request().method() === 'POST') {
createPayload = route.request().postDataJSON();
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: '新增成功', data: { companyName: createPayload.companyName } }) });
return;
}
listRequests += 1;
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, data: { records: [{ companyName: '示例科技有限公司', shortName: '示例科技', unifiedSocialCreditCode: '91330100TEST000001', address: '杭州市西湖区', contactName: '张三', contactValue: '13812345678', createTime: '2026-08-05T10:00:00', updateTime: '2026-08-05T11:00:00' }], total: 1, page: 1, size: 20 } })
});
});
await page.goto('/#/company-profiles');
await expect(page.getByRole('link', { name: '公司档案' })).toBeVisible();
await expect(page.getByRole('heading', { name: '公司档案', exact: true })).toBeVisible();
await expect(page.getByText('示例科技有限公司')).toBeVisible();
await expect(page.getByText('91330100TEST000001')).toBeVisible();
await expect(page.getByText('公司档案 ID')).toHaveCount(0);
await page.getByRole('button', { name: '新增公司档案' }).click();
await expect(page.getByRole('dialog', { name: '新增公司档案' })).toBeVisible();
await page.getByRole('button', { name: '确认保存' }).click();
await expect(page.getByText('请填写公司名称')).toBeVisible();
await page.getByPlaceholder('请输入公司名称').fill('新公司');
await page.getByPlaceholder('请输入公司简称').fill('新简称');
await page.getByRole('button', { name: '确认保存' }).click();
await expect.poll(() => createPayload).toEqual({ companyName: '新公司', shortName: '新简称', unifiedSocialCreditCode: '', address: '', contactName: '', contactValue: '' });
await expect(page.getByRole('dialog', { name: '新增公司档案' })).toHaveCount(0);
await expect(page.getByText('新增成功')).toBeVisible();
await expect.poll(() => listRequests).toBeGreaterThan(1);
});
...@@ -13,6 +13,10 @@ test('creates an enterprise WeChat asset and exposes the required form', async ( ...@@ -13,6 +13,10 @@ test('creates an enterprise WeChat asset and exposes the required form', async (
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);
await expect(page.getByRole('heading', { name: '企业微信资产' })).toBeVisible(); await expect(page.getByRole('heading', { name: '企业微信资产' })).toBeVisible();
await page.getByRole('button', { name: '新增企业微信资产' }).click(); await page.getByRole('button', { name: '新增企业微信资产' }).click();
/** 代码作用(白话):验证企业微信弹窗接入手机号资产的统一容器,使超高表单仅在正文区域滚动。关联文件:WecomAccountView.js、PhoneAssetView.js、main.css。关联逻辑(调用链/数据流):新增按钮 -> Element Plus 弹窗 -> phone-asset-modal 样式 -> 正文滚动。 */
const dialog = page.getByRole('dialog', { name: '新增企业微信资产' });
await expect(dialog.locator('.el-dialog')).toHaveClass(/phone-asset-modal/);
await expect(dialog.locator('.el-dialog__body')).toHaveCSS('overflow-y', 'auto');
await page.getByLabel('企微名称').fill('测试企微'); await page.getByLabel('企微名称').fill('测试企微');
await page.getByLabel('注册手机号').fill('13812345678'); await page.getByLabel('注册手机号').fill('13812345678');
await page.getByRole('button', { name: '保存' }).click(); await page.getByRole('button', { name: '保存' }).click();
......
## Context
`as_company_profile` 已由 `CompanyProfileEntity``CompanyProfileMapper` 映射,包含 6 个业务字段;通用父类还包含 `id``create_time``update_time``delete_time`。现有 `WecomAccountService.searchCompanyProfiles` 已按公司名称或简称搜索有效记录,返回 ID、公司名称和简称,但前端没有公司档案页面,注册主体选择也需明确为“搜索后选择”而非自由输入。
本次为跨前后端、菜单、路由与权限的公司档案查询和新增功能。不得修改表结构,新增必须保留既有企微创建接口和 `company_profile_id` 数据兼容性。
### 范围文件与用途注释
| 文件 | 文件用途注释 |
|---|---|
| `backend/.../dto/CompanyProfilePageQuery.java`(新增) | 承载公司档案分页与关键词参数,避免 Controller 直接处理分页规则。 |
| `backend/.../dto/CompanyProfileResponse.java`(新增) | 定义列表返回字段,完整传递档案业务字段和可展示审计字段。 |
| `backend/.../dto/CompanyProfilePageResponse.java`(新增) | 统一包装记录、总数和当前分页信息。 |
| `backend/.../dto/CompanyProfileSaveRequest.java`(新增) | 接收新增弹窗的六个业务字段,并将公司名称约束为必填。 |
| `backend/.../service/CompanyProfileService.java`(新增) | 读取未删除公司档案、执行关键词搜索、创建新档案并把实体转换为页面数据。 |
| `backend/.../controller/CompanyProfileController.java`(新增) | 暴露查询和新增 HTTP 接口,并分别执行 READ/EDIT 页面权限校验。 |
| `backend/.../auth/PagePermissionService.java` | 注册公司档案权限键,令鉴权接口、权限配置和前端菜单共用同一份权限数据。 |
| `frontend/src/modules/company-profile/company-profile-api-client.js`(新增) | 集中请求公司档案分页和创建接口并统一处理接口信封与 CSRF。 |
| `frontend/src/modules/company-profile/CompanyProfileView.js`(新增) | 提供搜索、分页、全字段表格展示及复用手机号资产布局的新增弹窗。 |
| `frontend/src/router/index.js` | 增加公司档案 Hash 路由和对应权限元数据。 |
| `frontend/src/App.js` | 在现有导航中新增“公司档案”菜单项,并沿用既有权限隐藏规则。 |
| `frontend/src/modules/system-user/UserPermissionView.js` | 将公司档案加入既有账号逐页权限配置,使管理员可以授予 READ 或 EDIT。 |
| `frontend/src/modules/wecom/WecomAccountView.js` | 将注册主体控件限定为远程可搜索选择,提交 `company_profile_id`,标签优先显示简称。 |
| `frontend/src/modules/wecom/wecom-api-client.js` | 保留既有搜索请求,必要时将错误信息/说明调整为“选择已有档案”。 |
| `backend/src/test/.../CompanyProfileServiceTest.java`(新增) | 验证未删除过滤、关键词搜索、字段映射和分页结果。 |
| `backend/src/test/.../CompanyProfileControllerTest.java`(新增) | 验证查询/新增接口返回、READ/EDIT 权限边界和必填校验。 |
| `frontend/tests/company-profile.spec.js`(新增或按现有测试目录合并) | 验证菜单、路由、搜索、全字段呈现及新增弹窗主流程。 |
| `backend/src/test/.../WecomAccount*Test.java` | 补充注册主体搜索/展示简称与仅提交已有 ID 的兼容性验证。 |
## Goals / Non-Goals
**Goals:**
-`as_company_profile` 读取有效记录,显示 6 个业务字段、创建时间和更新时间;不向前端暴露内部关联 ID 或仅用于软删除筛选的 `delete_time`
- 支持公司名称、公司简称、统一社会信用代码、联系人和联系方式的关键词搜索,并默认按 ID 倒序分页。
- 新增“公司档案”菜单和路由,前端守卫、菜单可见性、后端接口使用统一的 `company-profile` 权限键。
- 企微注册主体使用可搜索但不可自由输入/新建的选择控件,选择值为 `as_company_profile.id`,优先显示 `short_name`,简称为空时回退公司名称。
- 提供复用手机号资产新增样式的公司档案新增弹窗;公司名称必填,其余字段可空,审计字段由后端自动设置。
**Non-Goals:**
- 不编辑、删除、导入或导出公司档案;不提供 PUT、PATCH、DELETE 公司档案接口。
- 不修改 `as_company_profile` 数据库表、字段或数据,不运行迁移。
- 不改变企微资产的其他字段、手机号自动创建规则或既有公司档案搜索 API 路径。
## Decisions
### 1. 以独立查询和创建接口管理公司档案
新增 `GET /api/company-profiles`,用查询 DTO(数据传输对象,专门接收接口参数的对象)承接页码、每页数量和关键词,由 Service 统一附加 `delete_time = 0`。这让列表页不依赖企微模块,也避免将完整档案查询塞进 lookup(下拉选项查询)接口。
新增 `POST /api/company-profiles`,使用独立保存 DTO 接收 6 个业务字段。Service 去除公司名称首尾空格并校验非空,统一设置 `create_time``update_time``delete_time = 0`;前端不传审计字段。
替代方案是复用 `/api/wecom-accounts/lookups/company-profiles`。不采用:该接口固定最多 20 条且仅返回紧凑字段,无法承载全字段分页列表。
### 2. “所有字段展示”按可读数据区分业务和审计字段
响应包含 `companyName``shortName``unifiedSocialCreditCode``address``contactName``contactValue``createTime``updateTime`;Service 仅使用 `id` 做排序和内部关联、使用 `deleteTime` 判断有效性,二者均不返回/展示。这样既达到完整业务档案可核对的目的,也不会将内部标识或软删除标志误当作业务信息。
替代方案是直接回传实体。 不采用:会把内部筛选字段和未来实体新增字段意外暴露为接口契约。
### 3. 新增独立权限键并同时校验三层
`PagePermissionService` 注册 `company-profile`,查询接口要求 READ(可查看)权限,创建接口要求 EDIT(可新增)权限;前端路由 meta(路由附加信息)使用同一键,菜单和新增按钮也依据该键显示。管理员默认获得 EDIT,普通用户的 READ 只能查看。
### 4. 新增弹窗复用手机号资产布局
公司档案页采用手机号资产已有的 Element Plus 弹窗宽度、表单间距和底部操作按钮样式,但保持独立表单状态和提交函数。这样视觉体验一致,字段和保存规则仍由公司档案模块独立维护。
替代方案是复用企微资料权限。 不采用:公司档案是独立菜单,复用会造成用户可看企微即自动可看公司主体资料,权限边界不清。
### 5. 企微注册主体为严格选择,不创建输入值
保留现有搜索接口的 ID/名称/简称返回格式并继续只搜索有效记录。前端远程选择控件关闭 `allow-create`(允许创建新选项)和自由文本提交;提交前必须有已选条目的 ID。选项文案为 `short_name || company_name`,便于列表和保存后展示;数据库仍只保存既有 `company_profile_id`
替代方案是前端只传文本简称。 不采用:无法维持现有 ID 关联,也无法确定引用的是哪条公司档案。
### 6. 注释与测试按现有门禁实施
本次新增或修改的每个业务方法都补充新手注释:说明代码作用、关联文件、调用链/数据流。后端以 Service/Controller 单元测试验证数据和权限,前端以现有浏览器测试框架验证可见行为;不用直接连接或改写生产数据。
### 7. 企业微信新增弹窗复用统一资产容器
企业微信新增表单改用手机号资产已存在的 `phone-asset-modal``phone-asset-modal__form``phone-asset-modal__form-row` 样式类,并在弹窗打开后将正文滚动位置复位到顶部。统一资产容器已将标题、正文和底部操作区分层:正文过高时只滚动正文,用户始终能看到标题和取消/保存按钮。字段控件与保存函数保持原实现,避免改变企业微信创建数据流。
## Risks / Trade-offs
- [历史记录的简称为空] → 前端统一回退公司名称,保证注册主体始终可辨认。
- [数据库中存在很长或特殊字符] → 表格使用现有溢出/提示样式,保留原文本,不截断 API 数据。
- [只在前端禁用自由输入会被绕过] → 后端企微创建时补充公司档案 ID 有效性校验;无效或已删除 ID 返回 400,避免写入悬空关联。
- [新增权限键使已有非管理员账号默认不可访问] → 符合最小权限;管理员可在既有权限管理页显式授予 READ/EDIT,无需迁移旧权限 JSON。
- [分页条件与索引不完全匹配] → 先限制标准页大小并按 ID 倒序;若真实数据量上升,再以独立变更评估索引,当前不修改数据库。
## Migration Plan
1. 发布后端 DTO、Service、Controller 与权限键;不执行 DDL(数据库结构变更)或数据迁移。
2. 发布前端菜单、路由、公司档案页面及企微选择控件。
3. 用管理员账号验证公司档案列表和企微选择;用无权限账号确认菜单隐藏、路由回退、接口返回 403。
4. 若需回滚,先回退前端入口,再回退后端只读接口与权限键;`as_company_profile``as_wecom_account` 数据保持不变。
## Open Questions
- 无阻塞问题:默认以公司档案实体定义的 6 个业务字段和可读审计字段为“所有字段展示”范围;`delete_time` 仅为内部软删除标志,不展示。
## Why
`as_company_profile` 已有数据实体和供企微表单使用的搜索入口,但没有可查看公司档案的页面,也没有完整的列表接口。使用者无法核对公司档案字段,企微注册主体在选择后也只应保存有效档案的 ID 并展示简称。
## What Changes
- 新增“公司档案”菜单、前端路由和列表页面,展示 `as_company_profile` 的全部业务字段及审计字段。
- 新增公司档案分页/搜索接口和新增接口;仅查询未删除记录,支持新增,不提供编辑或删除入口。
- 将公司档案接入页面权限体系,确保菜单、路由守卫和后端接口使用同一权限键。
- 调整企微注册主体选择:仍使用远程搜索,仅允许从返回的 `as_company_profile.id` 中选择,不允许输入自由文本或在企微页面新增公司档案;选项和已选展示使用 `short_name`(公司简称)。
## Capabilities
### New Capabilities
- `company-profile-workspace`: 提供受权限控制的公司档案查询、新增、搜索、路由、菜单和全字段展示能力。
- `wecom-registration-subject-selection`: 规定企微注册主体只能搜索并选择有效公司档案 ID,页面展示公司简称。
### Modified Capabilities
- 无;当前仓库没有可修改的主规格,相关行为作为本次新增规格建立。
## Impact
- 后端:公司档案 DTO、Service、Controller、权限服务及对应单元/接口测试。
- 前端:路由、主菜单、公司档案模块与 API 客户端;企微表单的注册主体选择逻辑。
- 接口:新增公司档案列表和创建接口;既有企微公司档案搜索接口的返回/显示约定改为以 `short_name` 为主。
- 数据库:不新增表、不迁移、不修改 `as_company_profile` 结构。
## ADDED Requirements
### Requirement: 公司档案只读分页查询
系统 SHALL 提供受 `company-profile` 页面 READ 权限保护的 `GET /api/company-profiles` 接口。接口 MUST 仅返回 `delete_time = 0` 的记录,支持页码、页大小和关键词,默认按 ID 倒序分页。
#### Scenario: 查询有效公司档案
- **WHEN** 具有公司档案 READ 或 EDIT 权限的用户请求第一页
- **THEN** 系统返回记录、总数、当前页和页大小,且结果不包含已软删除记录
#### Scenario: 按可搜索字段过滤
- **WHEN** 用户输入公司名称、公司简称、统一社会信用代码、联系人或联系方式的关键词
- **THEN** 系统返回任一上述字段包含该关键词的有效记录
#### Scenario: 无权限读取被拒绝
- **WHEN** 没有公司档案 READ 权限的用户请求接口
- **THEN** 系统返回权限拒绝响应,且不返回公司档案数据
### Requirement: 新增公司档案
系统 SHALL 提供受 `company-profile` 页面 EDIT 权限保护的 `POST /api/company-profiles` 接口。请求 MUST 接收公司名称、公司简称、统一社会信用代码、地址、联系人和联系方式;公司名称去除首尾空格后 MUST 非空,其余字段可以为空。服务端 MUST 自动写入 `create_time``update_time``delete_time = 0`,且不得接受浏览器传入这些审计字段。
#### Scenario: 使用必填公司名称创建档案
- **WHEN** 具有公司档案 EDIT 权限的用户提交非空公司名称及任意可选字段
- **THEN** 系统创建有效公司档案,返回可展示字段,并自动写入创建时间、更新时间和未删除状态
#### Scenario: 公司名称为空时拒绝创建
- **WHEN** 用户提交空白公司名称
- **THEN** 系统返回可读的参数错误,且不创建记录
#### Scenario: 只读权限不能创建
- **WHEN** 只有公司档案 READ 权限的用户提交创建请求
- **THEN** 系统拒绝请求,且不创建记录
### Requirement: 公司档案全字段可读展示
系统 SHALL 在公司档案页面展示每条有效记录的公司名称、公司简称、统一社会信用代码、地址、联系人、联系方式、创建时间和更新时间。`id` MUST 仅用于服务端排序和内部关联,`delete_time` MUST 仅用于服务端有效记录筛选;两者均不得作为页面或接口展示字段。
#### Scenario: 展示包含简称的记录
- **WHEN** 公司档案记录具有全部业务字段
- **THEN** 页面表格显示规定的全部业务字段和创建/更新时间
#### Scenario: 展示缺少可选字段的记录
- **WHEN** 公司档案的简称、地址或联系人字段为空
- **THEN** 页面仍展示该记录并以现有空值呈现方式显示缺失字段
### Requirement: 公司档案菜单、路由和权限一致性
系统 SHALL 在主导航中提供“公司档案”菜单和对应 Hash 路由。菜单可见性、前端路由守卫和后端接口 MUST 使用相同的 `company-profile` 权限键;无权限用户不得通过手工输入路由访问页面。
#### Scenario: 有权限用户进入公司档案
- **WHEN** 已登录用户具有 `company-profile` 的 READ 或 EDIT 权限
- **THEN** 用户可见“公司档案”菜单并能访问公司档案路由
#### Scenario: 无权限用户不能绕过菜单
- **WHEN** 已登录用户的 `company-profile` 权限为 NONE 且直接访问公司档案 Hash 路由
- **THEN** 路由守卫将其带回现有默认业务页,后端接口仍拒绝读取
### Requirement: 公司档案新增弹窗
系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号资产新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;保存成功后 MUST 关闭弹窗并刷新公司档案列表。页面仍不得展示内部 ID。
#### Scenario: 编辑权限用户保存新增弹窗
- **WHEN** 用户填写公司名称并确认保存
- **THEN** 页面提交创建请求,显示成功提示,关闭弹窗并刷新第一页列表
#### Scenario: 只读用户查看公司档案
- **WHEN** 只有 READ 权限的用户进入公司档案页面
- **THEN** 页面不显示新增按钮或新增弹窗入口
## ADDED Requirements
### Requirement: 企微注册主体只能搜索选择现有公司档案
企微新增表单 SHALL 将注册主体实现为远程搜索选择控件。控件 MUST 从有效 `as_company_profile` 记录中搜索公司名称或简称,选择值 MUST 为 `as_company_profile.id`,并且不得允许输入自由文本、创建新选项或在该表单中新建公司档案。
#### Scenario: 搜索并选择注册主体
- **WHEN** 用户在企微新增表单输入公司名称或简称关键词并选择返回项
- **THEN** 表单保存所选项的 `id``company_profile_id`
#### Scenario: 禁止自由输入注册主体
- **WHEN** 用户输入未在搜索结果中选择的任意文本
- **THEN** 表单不能将该文本作为注册主体提交,也不会创建公司档案
#### Scenario: 拒绝无效或已删除主体 ID
- **WHEN** 客户端提交不存在或已软删除的 `company_profile_id`
- **THEN** 后端拒绝创建企微资产并返回可读的参数错误响应
### Requirement: 企微注册主体展示公司简称
企微新增表单的注册主体选项和已选展示 SHALL 优先使用 `short_name`(公司简称);当简称为空时,MUST 回退到 `company_name`。该展示规则不得改变数据库中 `company_profile_id` 的保存值。
#### Scenario: 有简称的主体显示简称
- **WHEN** 搜索结果的公司档案具有非空 `short_name`
- **THEN** 下拉选项和已选标签显示该简称,提交值仍为该记录 ID
#### Scenario: 没有简称的主体回退全称
- **WHEN** 搜索结果的公司档案 `short_name` 为空
- **THEN** 下拉选项和已选标签显示 `company_name`,提交值仍为该记录 ID
### Requirement: 企业微信新增弹窗复用资产样式并限制内容区滚动
系统 SHALL 使用手机号资产新增弹窗的统一外观展示企业微信新增表单。弹窗 MUST 固定标题与底部操作区;当表单内容超过可用高度时,MUST 只让弹窗正文区域纵向滚动。此视觉调整不得改变企业微信字段、注册主体远程搜索选择、手机号校验或 POST 请求内容。
#### Scenario: 企业微信表单内容超过可用高度
- **WHEN** 用户在较矮视口中打开企业微信新增弹窗,且表单正文高度超过弹窗可用高度
- **THEN** 标题和“取消/保存”操作区保持可见,只有表单正文区域可纵向滚动
## 1. 后端只读查询与权限
- [x] 1.1 新增 `CompanyProfilePageQuery``CompanyProfileResponse``CompanyProfilePageResponse`;为查询 DTO 的 `resolvedPage``resolvedSize` 等每个方法写新手注释(代码作用、关联文件、调用链/数据流)。
- [x] 1.2 新增 `CompanyProfileService`:实现构造方法、`page`、查询条件组装、关键词过滤、空文本判断和实体转响应方法;每个方法均写新手注释,且只查询 `delete_time = 0`、返回规定的全部展示字段。
- [x] 1.3 新增 `CompanyProfileController`:实现构造方法和仅有的分页 GET 方法;每个方法写新手注释,并通过 `PagePermissionService.COMPANY_PROFILE` 校验 READ 权限。
- [x] 1.4 修改 `PagePermissionService`,注册 `company-profile` 权限键与“公司档案”名称;为本次改动涉及的 `effectivePermissions``validatePermissions``require` 等方法补全或更新新手注释,确保权限数据、服务端校验和前端使用一致。
- [x] 1.5 修改 `WecomAccountService.create`,在写入企微前验证提交的 `company_profile_id` 存在且未删除;为该方法更新新手注释,失败时返回既有统一错误路径的可读参数错误。
## 2. 前端公司档案工作台
- [x] 2.1 新增 `company-profile-api-client.js`,实现请求封装与公司档案分页读取方法;每个方法写新手注释,复用现有 Cookie、CSRF 和统一接口错误处理方式。
- [x] 2.2 新增 `CompanyProfileView.js`,实现页面初始化、列表加载、搜索、分页切换和空值/时间显示等全部业务方法;每个方法写新手注释,并且页面只显示搜索和表格,不出现新增、编辑、删除、导入或导出入口。
- [x] 2.3 修改 `frontend/src/router/index.js`,新增公司档案路由及 `company-profile` 权限元数据;为新增/修改的路由辅助方法写新手注释,保留现有路由守卫行为。
- [x] 2.4 修改 `frontend/src/App.js`,将“公司档案”加入现有菜单配置;为新增/修改的菜单计算或过滤方法写新手注释,并只向具备权限的用户显示入口。
## 3. 企微注册主体选择
- [x] 3.1 验证 `WecomAccountView.js` 既有注册主体控件只能远程搜索并选择,未启用自由输入/创建选项;加载、搜索、选中、提交与标签格式化逻辑保留现有新手注释。
- [x] 3.2 使用既有 `searchCompanyProfiles` 请求保持关键词搜索与 ID 保存;选项和已选标签使用 `short_name || company_name`,企微列表关联主体也优先返回 `short_name`,提交值保持 `company_profile_id`
- [x] 3.3 验证企微创建兼容性:已选有效 ID 可以创建、简称为空回退全称、伪造/已删除 ID 被后端拒绝,且不改变手机号自动创建和其他企微字段逻辑。
## 4. 验证
- [x] 4.1 新增 `CompanyProfileServiceTest`,覆盖有效记录过滤、六个可搜索业务字段、分页、全字段 DTO 映射;测试辅助方法也写新手注释。
- [x] 4.2 新增 `CompanyProfileControllerTest`,覆盖 READ/EDIT 成功、NONE/未登录拒绝、接口响应结构,以及没有写入端点的约束;测试辅助方法也写新手注释。
- [x] 4.3 更新企微 Service/Controller 测试,覆盖注册主体 ID 校验和简称展示所依赖的搜索数据;本次新增/修改的测试方法均写新手注释。
- [x] 4.4 新增或更新前端浏览器测试,覆盖菜单与路由权限、公司档案搜索/全字段表格、无新增入口、企微只能选择已有主体及简称回退。
- [x] 4.5 运行后端相关测试和前端构建/测试;用本地只读数据做 smoke(冒烟验证:快速确认主流程可用)检查,不对数据库执行结构或数据修改。
## 5. 公司档案新增
- [x] 5.1 新增 `CompanyProfileSaveRequest`,为公司名称必填和其余字段可选定义校验;每个方法写新手注释。
- [x] 5.2 为公司档案 Service/Controller 的创建规则先新增失败测试,覆盖空公司名称、审计字段自动写入、EDIT 成功和 READ 拒绝;测试辅助方法写新手注释。
- [x] 5.3 修改 `CompanyProfileService``CompanyProfileController`,实现创建方法、字段规范化、审计字段初始化与 EDIT 权限校验;所有新增/修改方法写新手注释。
- [x] 5.4 修改公司档案 API 客户端和页面,增加可创建权限、弹窗状态、表单重置、必填提示与保存方法;每个新增/修改方法写新手注释,并复用手机号资产新增弹窗的样式。
- [x] 5.5 更新前端浏览器测试,覆盖新增按钮权限、公司名称必填、请求正文、成功提示、关闭弹窗与列表刷新;运行后端测试、前端构建和全量浏览器测试。
## 6. 企业微信新增弹窗统一化
- [x] 6.1 修改 `WecomAccountView.js`,复用手机号资产的弹窗、表单行和内容区滚动样式;新增弹窗打开后滚动复位方法并写新手注释,保留原有字段、搜索和保存逻辑。
- [x] 6.2 更新 `wecom-account.spec.js`,覆盖统一弹窗类、超高内容仅在正文区域滚动和原有新增请求;运行前端构建和全量浏览器测试。
## Context
当前工作区混有一组未提交的登录页黑洞动画改动:全局样式、登录体验测试、动画的 OpenSpec 记录,以及 Playwright 执行产物。该组改动与已确认要保留的登录会话修复、接口错误提示、手机号输入限制无关。
## Goals / Non-Goals
**Goals:**
- 清理指定的动画工作内容,让待提交列表只保留本次业务修复。
- 保留已验证的认证、接口错误提示和手机号输入限制改动。
**Non-Goals:**
- 不修改后端接口、DTO、数据库或部署配置。
- 不删除其他 OpenSpec 变更,也不清理未明确属于动画工作的内容。
## Decisions
- 采用“按文件范围还原与删除”的方式,而不是重置整个工作区;这样可避免误删已确认的业务修复。备选方案为整体 Git 重置,但会同时丢弃本次需要保留的改动,因此不采用。
- 只删除未跟踪的动画规划和测试产物;已跟踪的样式和测试文件还原到当前 Git 基线。这样不会影响仓库既有文件历史。
- `.gitignore` 只移除与动画测试产物直接相关的规则,避免改变其他本地开发约定。
## Risks / Trade-offs
- [未跟踪内容删除后无法通过 Git 恢复] → 已由用户明确确认放弃,并限定到动画规划和测试产物目录。
- [动画改动可能与其他登录体验改动混在同一文件] → 只还原用户明确放弃的 `app.css` 与登录体验测试文件,不触碰认证客户端和业务页面文件。
## Why
登录页黑洞动画、样式、自动化测试及其本地截图均未进入本次登录问题修复范围。保留这些未提交内容会让后续提交混入无关改动,并增加审核与回滚成本。
## What Changes
- 还原登录页动画带来的全局样式与登录体验测试改动。
- 删除未提交的登录页黑洞动画 OpenSpec 规划记录。
- 删除本地浏览器测试生成的截图与执行记录。
- 只撤销与这些本地测试产物相关的忽略规则,保留本次登录、接口错误提示和手机号输入限制修复。
## Capabilities
### New Capabilities
- `local-login-animation-work-cleanup`: 清理不纳入提交的登录页动画本地工作内容。
### Modified Capabilities
- 无。
## Impact
影响前端本地样式与测试文件、OpenSpec 规划文件、本地测试产物及忽略规则;不影响后端接口、DTO、数据库或线上数据。
## ADDED Requirements
### Requirement: 清理未纳入提交的登录页动画工作内容
系统 SHALL 移除用户明确放弃的登录页黑洞动画样式、测试、规划记录及本地测试产物,并保留认证与手机号相关修复。
#### Scenario: 清理完成后的待提交范围
- **WHEN** 完成动画工作内容清理后查看 Git 状态
- **THEN** 待提交列表中不得包含登录页黑洞动画 OpenSpec 记录、Playwright 动画截图或动画专用测试产物
#### Scenario: 保留本次业务修复
- **WHEN** 完成动画工作内容清理后检查认证和手机号文件
- **THEN** 登录会话诊断、接口真实错误提示、企微注册手机号和手机号资产输入限制改动仍然存在
## 1. 还原已跟踪文件
- [x] 1.1 将 `frontend/src/styles/app.css` 还原到 Git 基线,移除登录页动画样式。
- [x] 1.2 将 `frontend/tests/login-experience.spec.js` 还原到 Git 基线,移除动画测试改动。
- [x] 1.3 已核对 `.gitignore`;新增规则与根目录动画产物不对应,为避免误删无关规则而保留。
## 2. 清理未跟踪内容
- [x] 2.1 删除 `openspec/changes/add-login-black-hole-animation/` 动画规划记录。
- [x] 2.2 删除 `.playwright-cli/``output/playwright/` 的本地动画测试产物。
## 3. 验证范围
- [x] 3.1 检查 Git 状态,确认动画工作内容不再处于待提交状态。
- [x] 3.2 确认认证、真实接口报错和手机号输入限制文件仍保留在待提交状态。
## Context
公司档案新增接口目前只检查公司名称非空,数据库层的超长、重复和其他完整性错误可能被统一转换为 500。前端也直接解析 JSON,服务端未知错误没有可持久查询的安全诊断日志。当前没有存量公司档案数据,因此新规则不需要数据迁移。
## Goals / Non-Goals
**Goals:**
- 让页面、接口和业务服务对公司档案文本使用一致的限制。
- 让用户看到可修正的中文错误,而不是底层 500 或 JSON 解析错误。
- 保留有效公司名称唯一约束,并返回“公司名称已存在”。
- 将未预期错误写入仅本机使用、不会提交 Git 的滚动日志文件。
- 用自动回滚的 MySQL 集成测试验证实际数据库约束。
**Non-Goals:**
- 不改变数据库表、字段、已有唯一索引、接口路径或请求字段。
- 不修改现有数据,也不提供公司档案编辑或导入功能。
- 不向浏览器、日志查询接口或其他用户暴露 SQL、请求体、Cookie 或密钥。
## Decisions
### 输入规则以业务限制为准,并保留数据库上限兜底
公司名称在浏览器和接口中均为最多 30 个字符且禁止 emoji;统一社会信用代码和联系人均为最多 64 个字符。简称、联系方式和地址分别仍按现有数据库上限 128、128、255 进行接口兜底。
先清理首尾空格再验证业务规则,字符计数以 Unicode 代码点为单位,避免前后端对常见中文、英文和数字产生不同计数。emoji 检查也在浏览器和服务端使用同一组测试样例。
备选方案是只依赖数据库的 128 字符限制;未采用,因为页面体验和业务规则要求公司名称最多 30 个字符。
### 分层校验与专属业务异常
DTO 处理格式完整的请求校验,服务层处理去空格后的长度与 emoji 规则;公司档案模块使用专属校验异常并在专属 Advice 中返回 400。全局 Advice 处理 Spring 的请求校验失败、数据库重复键和未知异常。
备选方案是让认证模块处理 `IllegalArgumentException`;未采用,因为公司档案规则不应依赖认证模块。
### 数据库唯一约束是重复名称的最终裁决
不以“先查再写”作为重复保护。数据库现有唯一索引在并发保存时仍可阻止重复,GlobalExceptionHandler 根据公司名称唯一索引返回 HTTP 409 和“公司名称已存在”。
### 前端使用现有手机号资产计数外观并安全解析响应
公司名称输入框复用 `phone-asset-modal__count-input``phone-asset-modal__character-count`。页面显示字符数、阻止 emoji 保存,并在非 JSON 或空响应时显示固定安全提示。
### 诊断日志使用本地滚动文件
通过 Logback 为未预期异常写入本地 `backend/logs/` 下的滚动错误日志;目录由 `.gitignore` 忽略。日志仅包含请求方法、无查询参数的路径、异常类型和堆栈,不主动记录请求体、Cookie、认证信息、连接串或 SQL 参数。按日期与文件大小滚动,避免单一文件无限增长。
备选方案是仅输出到 IDE 控制台;未采用,因为服务重启后无法查询历史错误。
### 真实数据库测试使用 Spring 测试事务自动回滚
新增独立 MySQL 集成测试类,使用 `TEST-AUTO-` 前缀的临时公司名称,并由 Spring 测试事务在用例结束时自动回滚。测试不会删表、删库或保留记录;前端与单元测试仍覆盖不需数据库的规则。
## Risks / Trade-offs
- [旧 SQL 标准输出可能仍包含参数] → 本次新增日志不写请求体;实施时核对现有 MyBatis 输出并在后续独立变更中决定是否调整全局 SQL 调试设置。
- [emoji 是不断扩展的 Unicode 集合] → 使用明确的前后端共用测试样例覆盖常见 emoji、组合 emoji 和国旗 emoji;发现漏网字符时补充规则与测试。
- [日志文件包含基础设施细节] → 仅保留本机可访问文件、Git 忽略、限制滚动大小和保留数量。
- [外部直接执行 SQL 可绕过接口的 30 字规则] → 本次范围只保证页面和 HTTP 接口;未来导入或写库工具必须复用同一规则。
## Migration Plan
1. 部署代码与日志配置,不执行数据库迁移。
2. 重启后端,确认 `backend/logs/` 仅在出现未预期错误时生成滚动错误日志。
3. 运行单元、接口、前端和事务回滚 MySQL 测试。
4. 用导致原 500 的同一表单重试一次;若仍失败,从本地错误日志读取实际异常后继续定位。
5. 回滚时仅回退应用代码与日志配置;数据库和现有数据保持不变。
## Open Questions
无。当前规则、错误文案、测试边界和日志范围均已确认。
## Why
新增公司档案在发生空值、超长、emoji 或重复名称时,可能向用户显示无法处理的 500 错误。服务端也没有为未预期错误留下足以定位问题的安全日志,导致这次失败无法回溯具体原因。
## What Changes
- 将公司名称在页面和接口中统一限制为 30 个字符,且不允许 emoji。
- 将统一社会信用代码和联系人在页面和接口中统一限制为 64 个字符;其余可选字段在接口层按现有数据库字段长度兜底。
- 为公司档案的表单校验、重复名称和非 JSON 响应提供明确、可读的中文提示。
- 将有效公司名称的重复保存转换为 HTTP 409 和固定文案“公司名称已存在”。
- 为未预期异常记录不含请求体、Cookie 或密钥的后端诊断日志,供本地开发者排查。
- 增加前端、后端和真实 MySQL 事务回滚测试,覆盖字段规则、重复名称和异常响应。
## Capabilities
### New Capabilities
- `server-error-observability`: 安全记录未预期服务端错误,供本地排查而不向浏览器暴露底层细节。
### Modified Capabilities
- `company-profile-workspace`: 修改公司档案新增表单的输入规则、错误响应和重复名称反馈。
## Impact
- 前端公司档案新增弹窗和 API 客户端。
- 公司档案请求 DTO、业务服务和异常处理。
- 全局异常日志行为,以及公司名称重复约束的消息映射。
- 后端单元、接口和 MySQL 集成测试;前端公司档案页面测试。
- 不修改 API 路径、请求字段、数据库表结构或现有数据。
## MODIFIED Requirements
### Requirement: 新增公司档案
系统 SHALL 提供受 `company-profile` 页面 EDIT 权限保护的 `POST /api/company-profiles` 接口。请求 MUST 接收公司名称、公司简称、统一社会信用代码、地址、联系人和联系方式;服务端 MUST 忽略浏览器传入的审计字段,并自动写入 `create_time``update_time``delete_time = 0`
服务端 MUST 在移除文本首尾空格后执行以下规则:公司名称 MUST 非空、不得包含 emoji 且不得超过 30 个字符;统一社会信用代码和联系人不得超过 64 个字符;公司简称、联系方式和地址分别不得超过 128、128 和 255 个字符。违反这些规则时,系统 MUST 返回 HTTP 400 及可读中文提示,且不得创建记录。
#### Scenario: 使用有效公司名称创建档案
- **WHEN** 具有公司档案 EDIT 权限的用户提交不含 emoji、长度不超过 30 个字符的非空公司名称及任意有效可选字段
- **THEN** 系统创建有效公司档案,返回可展示字段,并自动写入创建时间、更新时间和未删除状态
#### Scenario: 空白公司名称被拒绝
- **WHEN** 用户提交移除首尾空格后为空的公司名称
- **THEN** 系统返回 HTTP 400 和“请填写公司名称”,且不创建记录
#### Scenario: 超过公司名称长度被拒绝
- **WHEN** 用户提交移除首尾空格后超过 30 个字符的公司名称
- **THEN** 系统返回 HTTP 400 和“公司名称不能超过 30 个字符”,且不创建记录
#### Scenario: 公司名称包含 emoji 被拒绝
- **WHEN** 用户提交包含 emoji 的公司名称
- **THEN** 系统返回 HTTP 400 和“公司名称不能包含表情符号”,且不创建记录
#### Scenario: 可选字段超过长度被拒绝
- **WHEN** 用户提交超过该字段已定义上限的统一社会信用代码、联系人、简称、联系方式或地址
- **THEN** 系统返回 HTTP 400 和对应字段的可读长度提示,且不创建记录
#### Scenario: 重复有效公司名称被拒绝
- **WHEN** 用户新增的有效公司名称已存在于另一条未删除记录中
- **THEN** 系统返回 HTTP 409 和“公司名称已存在”,且不创建重复记录
#### Scenario: 只读权限不能创建
- **WHEN** 只有公司档案 READ 权限的用户提交创建请求
- **THEN** 系统拒绝请求,且不创建记录
### Requirement: 公司档案新增弹窗
系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号资产新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;页面不得展示内部 ID。
公司名称输入框 MUST 显示当前字符数与上限 30 的计数,且不得允许 emoji 进入可保存值。统一社会信用代码和联系人输入框 MUST 限制为 64 个字符。保存失败时,弹窗 MUST 保持打开并显示服务端返回的可读错误;保存成功后 MUST 关闭弹窗并刷新公司档案列表。
#### Scenario: 编辑权限用户查看新增弹窗
- **WHEN** 具有 EDIT 权限的用户打开新增公司档案弹窗
- **THEN** 页面显示公司名称必填标记、公司名称字符计数和其余可选字段
#### Scenario: 页面阻止超长公司名称
- **WHEN** 用户在公司名称输入框中输入或粘贴超过 30 个字符的内容
- **THEN** 页面保留最多 30 个字符并显示对应字符计数,且不发送超长值
#### Scenario: 页面阻止 emoji 公司名称
- **WHEN** 用户在公司名称输入框中输入或粘贴 emoji
- **THEN** 页面提示“公司名称不能包含表情符号”并阻止保存该值
#### Scenario: 非 JSON 服务失败
- **WHEN** 公司档案接口返回空响应、HTML 或其他无法解析为 JSON 的内容
- **THEN** 页面显示“服务响应异常,请稍后重试”,并保留新增弹窗和用户已填写内容
## ADDED Requirements
### Requirement: 公司档案错误反馈一致性
系统 MUST 将可修正的公司档案创建错误返回为统一 `code/message/data` JSON 响应。页面 MUST 优先展示响应中的 `message`,不得向用户展示数据库错误、SQL 语句、堆栈或 JSON 解析异常文本。
#### Scenario: 服务端返回业务校验错误
- **WHEN** 公司档案接口返回 HTTP 400 或 409 且响应包含可读 `message`
- **THEN** 页面显示该 `message` 并允许用户修改后再次保存
## ADDED Requirements
### Requirement: 未预期服务端错误的安全诊断日志
系统 MUST 在未预期异常导致 HTTP 500 时,将请求方法、无查询参数的请求路径、异常类型和完整堆栈写入本机滚动错误日志。系统 MUST 不主动记录请求体、Cookie、认证凭据、数据库连接信息或 SQL 参数;浏览器响应 MUST 继续只返回安全通用文案。
#### Scenario: 未预期异常被记录
- **WHEN** 任何受 GlobalExceptionHandler 处理的未预期异常发生
- **THEN** 系统向本机滚动错误日志写入请求方法、路径、异常类型和堆栈,并返回 HTTP 500 与“服务器处理失败,请稍后重试”
#### Scenario: 日志文件按规则滚动
- **WHEN** 当前错误日志跨越配置的日期或文件大小阈值
- **THEN** 系统创建后续日志文件并保留配置数量内的历史文件,避免单一日志文件无限增长
#### Scenario: 浏览器不获取诊断详情
- **WHEN** 未预期异常被记录到本机错误日志
- **THEN** HTTP 响应不包含异常类名、堆栈、SQL、请求体、Cookie 或密钥
## 1. 失败用例与测试基础
- [ ] 1.1 在公司档案服务测试中先加入空白、超过 30 字、emoji、统一社会信用代码超过 64 字和联系人超过 64 字的失败用例,并为每个新增测试方法补齐新手注释。
- [ ] 1.2 在公司档案接口与全局异常处理测试中先加入 HTTP 400、409、“公司名称已存在”和安全 500 文案断言,并为每个新增测试方法补齐新手注释。
- [ ] 1.3 新增使用 Spring 测试事务的 MySQL 集成测试,验证真实公司名称唯一约束;使用 `TEST-AUTO-` 前缀并确认每个用例自动回滚。
- [ ] 1.4 扩展公司档案前端测试,覆盖 30 字计数、emoji 阻止、64 字字段限制和非 JSON 响应提示。
## 2. 公司档案输入规则与错误反馈
- [ ] 2.1 更新 CompanyProfileSaveRequest 的字段长度约束和中文消息,保持请求字段与 API 路径兼容。
- [ ] 2.2 新增公司档案专属校验异常与异常处理器,将服务层输入规则失败稳定映射为 HTTP 400 JSON。
- [ ] 2.3 更新 CompanyProfileService:先规范化首尾空格,再按 Unicode 代码点验证公司名称 30 字限制、emoji 禁止及各可选字段上限;为新增或改动的每个方法补齐新手注释。
- [ ] 2.4 更新 GlobalExceptionHandler:将 Spring 请求校验错误返回为可读 400,并把公司名称唯一索引映射为 HTTP 409 与“公司名称已存在”;为新增或改动的方法补齐新手注释。
## 3. 前端交互与响应健壮性
- [ ] 3.1 更新 CompanyProfileView:复用手机号资产字符计数样式,为公司名称显示 `当前值/30`,阻止 emoji 保存,并将统一社会信用代码和联系人限制为 64;为新增或改动函数补齐新手注释。
- [ ] 3.2 更新 company-profile-api-client:安全处理空响应、HTML 和无法解析的响应,向页面返回“服务响应异常,请稍后重试”,并保留已有成功和业务错误处理。
## 4. 本地错误日志
- [ ] 4.1 新增 Logback 滚动错误日志配置,将未预期异常写入 Git 忽略的 `backend/logs/` 本地目录,并限制单文件大小、按日期滚动和历史保留数量。
- [ ] 4.2 更新 .gitignore 忽略本地错误日志目录,确认不会忽略业务代码或 OpenSpec 文件。
- [ ] 4.3 在 GlobalExceptionHandler 的未预期异常分支记录请求方法、无查询参数路径和异常堆栈,不记录请求体、Cookie、认证信息、连接串或 SQL 参数。
## 5. 验证与定位当前问题
- [ ] 5.1 运行公司档案服务、接口、全局异常处理和前端测试,确认新增失败用例先失败后通过。
- [ ] 5.2 显式运行 MySQL 事务回滚集成测试,确认没有残留 `TEST-AUTO-` 记录,且未执行删表或删库操作。
- [ ] 5.3 启动后端并验证日志滚动文件仅在未预期异常发生时产生,且浏览器响应不包含诊断细节。
- [ ] 5.4 使用导致原 500 的同一份表单重试一次;若仍失败,读取本地错误日志并记录实际异常原因与后续处理结论。
## Context
`codex/phone-account-sync-verify` 已推送,但其祖先不是当前 `origin/master`。Git 补丁比对显示其中三项较早提交已被主分支等效吸收,另有四项尚未合入;同时 `origin/master` 已新增十五个提交。工作区还包含用户的 `.gitignore` 修改,以及用户明确要求放弃的两处未提交登录白底修改。
## Goals / Non-Goals
**Goals:**
- 仅把四个缺失提交带入最新主分支。
- 保留 `.gitignore` 的用户本地修改,并清除用户要求放弃的两处本次登录样式修改。
- 在合并前、冲突解决后和推送前都验证认证主流程。
- 保持 API、DTO、数据库结构兼容。
**Non-Goals:**
- 不整体合并当前分支。
- 不重新设计认证架构、不添加自动填充样式修复、不修改部署端口。
- 不修改数据库、生产配置或已有主分支功能。
## Decisions
### 基于最新主分支建立集成分支
`origin/master` 新建分支并执行 `cherry-pick`,而非直接合并当前分支。
- 原因:当前分支落后主分支,且携带已等效合入的历史,直接合并会增大冲突与重复审查范围。
- 替代方案:直接合并当前分支。未采用,因为会把七个历史提交图谱一并带入,难以区分真正缺失的四项。
### 按依赖顺序挑选提交
`52071f0``9774d78``72fb7fd``929d82e` 的顺序挑选;每个提交后检查冲突和测试。
- 原因:先处理说明与会话跳转,再引入权限和密码交互,便于定位冲突来源。
- 替代方案:压缩为一个新提交。未采用,因为保留原提交边界更便于回滚和审查。
### 用户修改与本次修改分离
只丢弃 `frontend/src/styles/app.css``frontend/tests/login-experience.spec.js` 中本次未提交的自动填充改动;`.gitignore` 不参与暂存、提交或合并。
- 原因:用户已经明确放弃前两者,但 `.gitignore` 属于用户已有修改。
- 替代方案:清空工作区后合并。未采用,因为会丢失用户数据。
## Risks / Trade-offs
- [主分支认证代码已变化,挑选提交发生冲突] → 逐个解决冲突,以 `origin/master` 的接口、DTO 和数据库结构为基线;若需改变契约则停止并请求确认。
- [密码输入修复与主分支已有同类修复重叠] → 比对差异后仅保留不重复的原生输入行为,运行登录体验测试。
- [用户本地 `.gitignore` 被误带入] → 合并前后执行 `git diff -- .gitignore`,不暂存该文件。
- [认证回归未被发现] → 运行登录体验、前端构建,以及认证/权限相关后端测试入口(如项目已提供)。
## Migration Plan
1. 保存当前工作区状态,确认仅清理用户已放弃的两处修改。
2. 获取 `origin/master` 最新提交并创建集成分支。
3. 逐个挑选四个提交,逐次解决冲突和运行相关测试。
4. 审查差异,创建合并请求并合入 `master`
5. 如任一提交造成认证回归,撤销该次挑选或放弃集成分支;主分支不会被直接修改。
## Open Questions
- 合并请求是否由当前用户在远端平台审批,或已授权自动合并?在明确授权前仅准备分支和验证结果。
## Why
当前分支包含四个尚未进入 `origin/master` 的认证相关提交,但分支同时落后于 `master`,且更早的三项改动已在 `master` 中存在等效实现。需要以最小范围把缺失能力安全带入主分支,避免重复合并、覆盖主分支的新改动或误带入未提交的本地样式调整。
## What Changes
- 从最新 `origin/master` 创建集成分支,只挑选四个未合并提交:认证说明、刷新登录状态修复、系统用户权限、原生密码输入行为。
- 合并前丢弃本次未提交的登录自动填充样式和对应测试,仅保留用户已有的 `.gitignore` 本地修改。
- 对每个挑选提交处理与当前主分支认证实现的冲突,并以当前主分支为兼容基线。
- 验证未登录跳转、刷新会话恢复、权限控制、登录和密码显示行为。
## Capabilities
### New Capabilities
- `authentication-merge-integration`: 将主分支缺失的认证修复与权限能力以可验证、可回滚的方式集成。
### Modified Capabilities
- 无。此变更合入既有功能实现,不新增或改变对外接口契约。
## Impact
- 受影响范围:前端认证状态、登录页、路由守卫、系统用户权限,以及后端认证相关实现(以四个提交的实际文件为准)。
- 不新增数据库迁移、不修改 API 请求路径或 DTO 字段;若冲突处理发现接口差异,停止合并并重新评审。
- Git 历史:从新集成分支提交合并请求进入 `master`,不直接把当前落后分支整体合入。
## ADDED Requirements
### Requirement: Integrate only missing authentication changes
The integration branch MUST be based on the latest `origin/master` and MUST include only the four commits that Git identifies as not patch-equivalent to `origin/master`.
#### Scenario: Branch scope is prepared
- **WHEN** the integration branch is created
- **THEN** its base commit SHALL equal the latest fetched `origin/master` commit and its proposed changes SHALL exclude the three patch-equivalent historical commits.
### Requirement: Preserve existing user workspace changes
The integration process MUST preserve the user's `.gitignore` working-tree modification and MUST remove only the user-abandoned uncommitted changes in the login stylesheet and login experience test.
#### Scenario: Worktree is prepared for integration
- **WHEN** integration preparation completes
- **THEN** `.gitignore` SHALL remain modified and neither user-abandoned login autofill change SHALL be staged or committed.
### Requirement: Maintain authentication behavior after integration
The integrated change MUST preserve unauthenticated redirects, session-refresh handling, authorized user access, and native password input behavior.
#### Scenario: Authentication regression suite passes
- **WHEN** the four commits have been applied and conflicts resolved
- **THEN** the login experience test suite and production frontend build SHALL pass before a merge request is created.
### Requirement: Stop before incompatible contract changes
The integration process MUST stop for user review if resolving a conflict would require changing an API path, DTO field, database schema, or production configuration.
#### Scenario: Conflict requires contract change
- **WHEN** a cherry-pick conflict cannot be resolved while retaining the `origin/master` contract
- **THEN** no merge request SHALL be created and the required change SHALL be reported for approval.
## 1. Prepare a clean integration baseline
- [x] 1.1 Record the current branch, `origin/master` revision, four missing commit IDs, and the three patch-equivalent commit IDs.
- [x] 1.2 Preserve the user's `.gitignore` working-tree change and remove only the user-abandoned uncommitted changes in `frontend/src/styles/app.css` and `frontend/tests/login-experience.spec.js`.
- [x] 1.3 Fetch the remote and create a new integration branch from the latest `origin/master` revision.
## 2. Apply missing authentication changes
- [x] 2.1 Review `52071f0`, confirm it changes only a deleted, outdated planning document, and exclude it from integration.
- [x] 2.2 Review `9774d78`, confirm the session-refresh implementation already exists in `origin/master`, and retain the current master version without duplicate integration.
- [x] 2.3 Review `72fb7fd`, confirm its permission-related APIs, DTOs, database schema, frontend, and tests already exist in `origin/master`, and exclude only its obsolete planning document.
- [x] 2.4 Review `929d82e`, confirm the native password implementation and stronger compatibility test already exist in `origin/master`, and exclude its duplicate change plus obsolete planning files.
## 3. Validate and merge safely
- [x] 3.1 Run the complete login experience suite, frontend production build, and backend test suite.
- [x] 3.2 Inspect the final diff and confirm the integration branch has no committed diff from `origin/master`; only the user's unstaged `.gitignore` change and this untracked OpenSpec plan remain.
- [x] 3.3 Do not push or create a merge request because every runtime change from the candidate commits already exists in `origin/master`.
- [x] 3.4 Verify the latest `origin/master` baseline contains the intended runtime behavior through passing frontend and backend tests; no new merge commit is required.
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