Commit 90002afb by DaiJiezhang

Merge branch 'feat/device-image-and-wecom-crud' into 'master'

feat: add company people management

See merge request !14
parents 9ae57966 36b73908
package com.xyw.console.asset.controller;
import com.xyw.console.common.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 文件用途(白话):把公司档案和公司人员的字段、引用与不存在错误转换成页面可读 JSON。 */
@RestControllerAdvice(assignableTypes={CompanyProfileController.class,CompanyPersonController.class})
public class CompanyManagementExceptionHandler {
/** 代码作用(白话):把保存校验、记录不存在和引用保护提示返回为 400。关联文件:CompanyProfileService.java、CompanyPersonService.java。关联逻辑(调用链/数据流):Service 参数错误 -> Advice -> ApiResponse -> 页面提示。 */
@ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> invalid(IllegalArgumentException error){return ApiResponse.error(400,error.getMessage());}
}
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.CompanyPersonPageQuery;
import com.xyw.console.asset.dto.CompanyPersonPageResponse;
import com.xyw.console.asset.dto.CompanyPersonResponse;
import com.xyw.console.asset.dto.CompanyPersonSaveRequest;
import com.xyw.console.asset.dto.CompanyProfileLookupResponse;
import com.xyw.console.asset.service.CompanyPersonService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 文件用途(白话):提供公司人员分页、公司搜索和增删改接口,并统一检查独立页面权限。 */
@RestController
@RequestMapping("/api/company-persons")
public class CompanyPersonController {
private final CompanyPersonService service; private final PagePermissionService permissions;
/** 代码作用(白话):接收人员服务和权限服务。关联文件:CompanyPersonService.java、PagePermissionService.java。关联逻辑(调用链/数据流):HTTP -> Controller -> 权限/Service。 */
public CompanyPersonController(CompanyPersonService service,PagePermissionService permissions){this.service=service;this.permissions=permissions;}
/** 代码作用(白话):读取有权限用户可见的公司人员分页。关联文件:CompanyPersonPageQuery.java、CompanyPersonView.js。关联逻辑(调用链/数据流):GET -> READ -> Service.page -> JSON。 */
@GetMapping public ApiResponse<CompanyPersonPageResponse> page(@Valid CompanyPersonPageQuery query){permissions.require(PagePermissionService.COMPANY_PERSON,"READ");return ApiResponse.success(service.page(query));}
/** 代码作用(白话):为人员表单搜索有效所属公司。关联文件:CompanyPersonService.java、CompanyPersonView.js。关联逻辑(调用链/数据流):关键词 -> READ -> 公司选项。 */
@GetMapping("/lookups/company-profiles") public ApiResponse<List<CompanyProfileLookupResponse>> companies(@RequestParam(defaultValue="")String keyword){permissions.require(PagePermissionService.COMPANY_PERSON,"READ");return ApiResponse.success(service.searchCompanies(keyword));}
/** 代码作用(白话):新增公司人员。关联文件:CompanyPersonSaveRequest.java、CompanyPersonService.java。关联逻辑(调用链/数据流):POST 表单 -> EDIT -> create -> JSON。 */
@PostMapping public ApiResponse<CompanyPersonResponse> create(@Valid @RequestBody CompanyPersonSaveRequest request){permissions.require(PagePermissionService.COMPANY_PERSON,"EDIT");return ApiResponse.success("新增成功",service.create(request));}
/** 代码作用(白话):编辑指定公司人员。关联文件:CompanyPersonSaveRequest.java、CompanyPersonService.java。关联逻辑(调用链/数据流):PUT /{id} -> EDIT -> update -> JSON。 */
@PutMapping("/{id}") public ApiResponse<CompanyPersonResponse> update(@PathVariable Long id,@Valid @RequestBody CompanyPersonSaveRequest request){permissions.require(PagePermissionService.COMPANY_PERSON,"EDIT");return ApiResponse.success("修改成功",service.update(id,request));}
/** 代码作用(白话):引用检查通过后软删除公司人员。关联文件:CompanyPersonService.java、CompanyPersonView.js。关联逻辑(调用链/数据流):DELETE /{id} -> EDIT -> softDelete -> JSON。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){permissions.require(PagePermissionService.COMPANY_PERSON,"EDIT");service.softDelete(id);return ApiResponse.success("删除成功",null);}
}
...@@ -10,6 +10,9 @@ import com.xyw.console.common.ApiResponse; ...@@ -10,6 +10,9 @@ import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
...@@ -52,4 +55,19 @@ public class CompanyProfileController { ...@@ -52,4 +55,19 @@ public class CompanyProfileController {
permissions.require(PagePermissionService.COMPANY_PROFILE, "EDIT"); permissions.require(PagePermissionService.COMPANY_PROFILE, "EDIT");
return ApiResponse.success("新增成功", service.create(request)); return ApiResponse.success("新增成功", service.create(request));
} }
/** 代码作用(白话):校验编辑权限后更新指定公司档案。关联文件:CompanyProfileService.java、CompanyProfileView.js。关联逻辑(调用链/数据流):PUT /{id} -> EDIT 权限 -> Service.update -> JSON。 */
@PutMapping("/{id}")
public ApiResponse<CompanyProfileResponse> update(@PathVariable Long id, @Valid @RequestBody CompanyProfileSaveRequest request) {
permissions.require(PagePermissionService.COMPANY_PROFILE, "EDIT");
return ApiResponse.success("修改成功", service.update(id, request));
}
/** 代码作用(白话):校验编辑权限后发起引用安全软删除。关联文件:CompanyProfileService.java、CompanyProfileView.js。关联逻辑(调用链/数据流):DELETE /{id} -> EDIT 权限 -> 引用检查/软删除 -> JSON。 */
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
permissions.require(PagePermissionService.COMPANY_PROFILE, "EDIT");
service.softDelete(id);
return ApiResponse.success("删除成功", null);
}
} }
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收公司人员列表的分页、姓名、所属公司和状态筛选条件。 */
public record CompanyPersonPageQuery(
@Min(1) Integer page,
@Min(1) @Max(100) Integer size,
String keyword,
Long companyProfileId,
String employmentStatus) {
/** 代码作用(白话):未传页码时使用第一页。关联文件:CompanyPersonService.java。关联逻辑(调用链/数据流):HTTP 参数 -> 默认页码 -> 数据库分页。 */
public int resolvedPage() { return page == null ? 1 : page; }
/** 代码作用(白话):未传每页数量时使用 20 条。关联文件:CompanyPersonService.java、CompanyPersonView.js。关联逻辑(调用链/数据流):HTTP 参数 -> 默认数量 -> 数据库分页。 */
public int resolvedSize() { return size == null ? 20 : size; }
}
package com.xyw.console.asset.dto;
import java.util.List;
/** 文件用途(白话):把公司人员记录、总数和分页位置一起返回给列表页面。 */
public record CompanyPersonPageResponse(List<CompanyPersonResponse> records, long total, int page, int size) {
}
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
/** 文件用途(白话):定义公司人员页面可读取的字段,并补充所属公司的可读名称。 */
public record CompanyPersonResponse(
Long id,
Long companyProfileId,
String companyProfileName,
String personName,
String employmentStatus,
LocalDateTime resignedAt,
LocalDateTime createTime,
LocalDateTime updateTime) {
}
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
/** 文件用途(白话):定义公司人员新增和编辑时允许浏览器提交的业务字段,不接受主键或审计字段。 */
public record CompanyPersonSaveRequest(
Long companyProfileId,
@NotBlank(message = "人员姓名不能为空") String personName,
String employmentStatus,
LocalDateTime resignedAt) {
}
...@@ -4,6 +4,7 @@ import java.time.LocalDateTime; ...@@ -4,6 +4,7 @@ import java.time.LocalDateTime;
/** 文件用途(白话):定义公司档案页面允许展示的字段,不暴露内部 ID 和软删除标志。 */ /** 文件用途(白话):定义公司档案页面允许展示的字段,不暴露内部 ID 和软删除标志。 */
public record CompanyProfileResponse( public record CompanyProfileResponse(
Long id,
String companyName, String companyName,
String shortName, String shortName,
String unifiedSocialCreditCode, String unifiedSocialCreditCode,
......
...@@ -7,7 +7,18 @@ import com.xyw.console.asset.dto.CompanyProfilePageResponse; ...@@ -7,7 +7,18 @@ import com.xyw.console.asset.dto.CompanyProfilePageResponse;
import com.xyw.console.asset.dto.CompanyProfileResponse; import com.xyw.console.asset.dto.CompanyProfileResponse;
import com.xyw.console.asset.dto.CompanyProfileSaveRequest; import com.xyw.console.asset.dto.CompanyProfileSaveRequest;
import com.xyw.console.asset.entity.CompanyProfileEntity; import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.DomainAssetEntity;
import com.xyw.console.asset.entity.DouyinAccountEntity;
import com.xyw.console.asset.entity.MerchantEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.CompanyProfileMapper; import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.DomainAssetMapper;
import com.xyw.console.asset.mapper.DouyinAccountMapper;
import com.xyw.console.asset.mapper.MerchantMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.util.ArrayList;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
...@@ -16,14 +27,26 @@ import org.springframework.stereotype.Service; ...@@ -16,14 +27,26 @@ import org.springframework.stereotype.Service;
@Service @Service
public class CompanyProfileService { public class CompanyProfileService {
private final CompanyProfileMapper mapper; private final CompanyProfileMapper mapper;
private final CompanyPersonMapper personMapper;
private final WecomAccountMapper wecomMapper;
private final DouyinAccountMapper douyinMapper;
private final DomainAssetMapper domainMapper;
private final MerchantMapper merchantMapper;
/** /**
* 代码作用(白话):接收公司档案表的数据库访问入口,供只读列表查询使用。 * 代码作用(白话):接收公司档案表的数据库访问入口,供只读列表查询使用。
* 关联文件:CompanyProfileMapper.java、CompanyProfileController.java。 * 关联文件:CompanyProfileMapper.java、CompanyProfileController.java。
* 关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_company_profile。 * 关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_company_profile。
*/ */
public CompanyProfileService(CompanyProfileMapper mapper) { public CompanyProfileService(CompanyProfileMapper mapper, CompanyPersonMapper personMapper,
WecomAccountMapper wecomMapper, DouyinAccountMapper douyinMapper,
DomainAssetMapper domainMapper, MerchantMapper merchantMapper) {
this.mapper = mapper; this.mapper = mapper;
this.personMapper = personMapper;
this.wecomMapper = wecomMapper;
this.douyinMapper = douyinMapper;
this.domainMapper = domainMapper;
this.merchantMapper = merchantMapper;
} }
/** /**
...@@ -62,6 +85,32 @@ public class CompanyProfileService { ...@@ -62,6 +85,32 @@ public class CompanyProfileService {
} }
/** /**
* 代码作用(白话):编辑一条仍有效的公司档案,只覆盖页面允许修改的业务字段。
* 关联文件:CompanyProfileController.java、CompanyProfileView.js。
* 关联逻辑(调用链/数据流):PUT 表单 -> 查找有效档案 -> 更新字段 -> Mapper.updateById -> 响应。
*/
public CompanyProfileResponse update(Long id, CompanyProfileSaveRequest request) {
CompanyProfileEntity entity = requireActive(id);
applyEditableFields(entity, request);
entity.setUpdateTime(LocalDateTime.now());
if (mapper.updateById(entity) != 1) throw new IllegalArgumentException("公司档案不存在或已删除");
return toResponse(entity);
}
/**
* 代码作用(白话):确认没有有效业务引用后把公司档案标记为已删除,保留历史记录。
* 关联文件:CompanyProfileController.java、各引用资产实体。
* 关联逻辑(调用链/数据流):DELETE -> 有效引用计数 -> deleteTime/updateTime -> Mapper.updateById。
*/
public void softDelete(Long id) {
CompanyProfileEntity entity = requireActive(id);
checkActiveReferences(id);
entity.setDeleteTime(System.currentTimeMillis());
entity.setUpdateTime(LocalDateTime.now());
if (mapper.updateById(entity) != 1) throw new IllegalArgumentException("公司档案不存在或已删除");
}
/**
* 代码作用(白话):组合“未删除”和关键词条件,保证公司档案页面只读取仍有效的数据。 * 代码作用(白话):组合“未删除”和关键词条件,保证公司档案页面只读取仍有效的数据。
* 关联文件:CompanyProfileEntity.java、CompanyProfilePageQuery.java。 * 关联文件:CompanyProfileEntity.java、CompanyProfilePageQuery.java。
* 关联逻辑(调用链/数据流):页面关键词 -> 查询条件 -> as_company_profile SQL where/order by。 * 关联逻辑(调用链/数据流):页面关键词 -> 查询条件 -> as_company_profile SQL where/order by。
...@@ -127,12 +176,46 @@ public class CompanyProfileService { ...@@ -127,12 +176,46 @@ public class CompanyProfileService {
} }
/** /**
* 代码作用(白话):把公司档案表单允许编辑的六个字段统一复制到实体,并清理首尾空格。
* 关联文件:CompanyProfileSaveRequest.java、CompanyProfileEntity.java。
* 关联逻辑(调用链/数据流):新增/编辑请求 -> 字段清理 -> 实体 -> 数据库。
*/
private void applyEditableFields(CompanyProfileEntity entity, CompanyProfileSaveRequest request) {
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()));
}
/** 代码作用(白话):读取一条未删除公司供编辑和删除共用。关联文件:CompanyProfileMapper.java。关联逻辑(调用链/数据流):路径 ID -> 有效记录查询 -> 实体或参数错误。 */
private CompanyProfileEntity requireActive(Long id) {
CompanyProfileEntity entity = mapper.selectOne(new LambdaQueryWrapper<CompanyProfileEntity>()
.eq(CompanyProfileEntity::getId, id).eq(CompanyProfileEntity::getDeleteTime, 0L));
if (entity == null) throw new IllegalArgumentException("公司档案不存在或已删除");
return entity;
}
/** 代码作用(白话):汇总仍引用公司的业务类型并阻止删除。关联文件:人员、企微、抖音、域名、商户实体。关联逻辑(调用链/数据流):公司 ID -> 各表有效引用计数 -> 错误提示或允许软删除。 */
private void checkActiveReferences(Long id) {
List<String> types = new ArrayList<>();
if (personMapper.selectCount(new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getCompanyProfileId, id).eq(CompanyPersonEntity::getDeleteTime, 0L)) > 0) types.add("公司人员");
if (wecomMapper.selectCount(new LambdaQueryWrapper<WecomAccountEntity>().eq(WecomAccountEntity::getCompanyProfileId, id).eq(WecomAccountEntity::getDeleteTime, 0L)) > 0) types.add("企业微信资产");
if (douyinMapper.selectCount(new LambdaQueryWrapper<DouyinAccountEntity>().eq(DouyinAccountEntity::getCompanyProfileId, id).eq(DouyinAccountEntity::getDeleteTime, 0L)) > 0) types.add("抖音资产");
if (domainMapper.selectCount(new LambdaQueryWrapper<DomainAssetEntity>().eq(DomainAssetEntity::getCompanyProfileId, id).eq(DomainAssetEntity::getDeleteTime, 0L)) > 0) types.add("域名资产");
if (merchantMapper.selectCount(new LambdaQueryWrapper<MerchantEntity>().eq(MerchantEntity::getCompanyProfileId, id).eq(MerchantEntity::getDeleteTime, 0L)) > 0) types.add("商户");
if (!types.isEmpty()) throw new IllegalArgumentException("公司档案仍被" + String.join("、", types) + "引用,不能删除");
}
/**
* 代码作用(白话):从实体挑选公司档案页面允许展示的字段,隐藏内部 ID 和软删除标志。 * 代码作用(白话):从实体挑选公司档案页面允许展示的字段,隐藏内部 ID 和软删除标志。
* 关联文件:CompanyProfileEntity.java、CompanyProfileResponse.java。 * 关联文件:CompanyProfileEntity.java、CompanyProfileResponse.java。
* 关联逻辑(调用链/数据流):数据库实体 -> Response DTO -> ApiResponse -> Vue 表格。 * 关联逻辑(调用链/数据流):数据库实体 -> Response DTO -> ApiResponse -> Vue 表格。
*/ */
private CompanyProfileResponse toResponse(CompanyProfileEntity entity) { private CompanyProfileResponse toResponse(CompanyProfileEntity entity) {
return new CompanyProfileResponse( return new CompanyProfileResponse(
entity.getId(),
entity.getCompanyName(), entity.getCompanyName(),
entity.getShortName(), entity.getShortName(),
entity.getUnifiedSocialCreditCode(), entity.getUnifiedSocialCreditCode(),
......
...@@ -17,8 +17,9 @@ public class PagePermissionService { ...@@ -17,8 +17,9 @@ public class PagePermissionService {
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 COMPANY_PROFILE = "company-profile";
public static final String COMPANY_PERSON = "company-person";
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, "手机号码管理", COMPANY_PROFILE, "公司档案", ALERTS, "提醒中心"); private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号码管理", COMPANY_PROFILE, "公司档案", COMPANY_PERSON, "公司人员", 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 判定。 */
......
package com.xyw.console.asset.controller;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import com.xyw.console.asset.dto.CompanyPersonPageQuery;
import com.xyw.console.asset.service.CompanyPersonService;
import com.xyw.console.auth.PagePermissionService;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
/** 文件用途(白话):验证公司人员接口使用独立页面权限保护读写操作。 */
class CompanyPersonControllerTest {
/** 代码作用(白话):验证没有公司人员查看权限时在调用业务服务前拒绝分页请求。关联文件:CompanyPersonController.java、PagePermissionService.java。关联逻辑(调用链/数据流):GET -> company-person READ -> 拒绝。 */
@Test
void rejectsReadsWithoutCompanyPersonPermission() {
CompanyPersonService service=mock(CompanyPersonService.class); PagePermissionService permissions=mock(PagePermissionService.class);
doThrow(new AccessDeniedException("没有页面权限")).when(permissions).require(PagePermissionService.COMPANY_PERSON,"READ");
CompanyPersonController controller=new CompanyPersonController(service,permissions);
assertThrows(AccessDeniedException.class,()->controller.page(new CompanyPersonPageQuery(1,20,null,null,null)));
verify(permissions).require(PagePermissionService.COMPANY_PERSON,"READ");
}
}
...@@ -9,6 +9,8 @@ import static org.mockito.Mockito.when; ...@@ -9,6 +9,8 @@ import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.assertThrows; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
...@@ -27,7 +29,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; ...@@ -27,7 +29,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class CompanyProfileControllerTest { class CompanyProfileControllerTest {
/** /**
* 代码作用(白话):验证公司档案 GET 接口返回完整可读字段但不返回内部 ID 或软删除标志。 * 代码作用(白话):验证公司档案 GET 接口返回编辑删除定位所需 ID 和可读字段,但不返回软删除标志。
* 关联文件:CompanyProfileController.java、CompanyProfileService.java、CompanyProfileResponse.java。 * 关联文件:CompanyProfileController.java、CompanyProfileService.java、CompanyProfileResponse.java。
* 关联逻辑(调用链/数据流):GET /api/company-profiles -> Controller -> Service -> JSON 表格数据。 * 关联逻辑(调用链/数据流):GET /api/company-profiles -> Controller -> Service -> JSON 表格数据。
*/ */
...@@ -47,7 +49,7 @@ class CompanyProfileControllerTest { ...@@ -47,7 +49,7 @@ class CompanyProfileControllerTest {
.andExpect(jsonPath("$.data.records[0].address", is("杭州市西湖区"))) .andExpect(jsonPath("$.data.records[0].address", is("杭州市西湖区")))
.andExpect(jsonPath("$.data.records[0].contactName", is("张三"))) .andExpect(jsonPath("$.data.records[0].contactName", is("张三")))
.andExpect(jsonPath("$.data.records[0].contactValue", is("13812345678"))) .andExpect(jsonPath("$.data.records[0].contactValue", is("13812345678")))
.andExpect(jsonPath("$.data.records[0].id").doesNotExist()) .andExpect(jsonPath("$.data.records[0].id", is(10)))
.andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist()); .andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist());
} }
...@@ -69,7 +71,7 @@ class CompanyProfileControllerTest { ...@@ -69,7 +71,7 @@ class CompanyProfileControllerTest {
} }
/** /**
* 代码作用(白话):验证拥有编辑权限时可以通过 POST 新增公司档案,且响应不含内部 ID。 * 代码作用(白话):验证拥有编辑权限时可以通过 POST 新增公司档案,并返回后续编辑可用的 ID。
* 关联文件:CompanyProfileController.java、CompanyProfileService.java、CompanyProfileView.js。 * 关联文件:CompanyProfileController.java、CompanyProfileService.java、CompanyProfileView.js。
* 关联逻辑(调用链/数据流):新增弹窗 -> POST -> EDIT 权限校验 -> Service.create -> JSON 响应。 * 关联逻辑(调用链/数据流):新增弹窗 -> POST -> EDIT 权限校验 -> Service.create -> JSON 响应。
*/ */
...@@ -86,7 +88,7 @@ class CompanyProfileControllerTest { ...@@ -86,7 +88,7 @@ class CompanyProfileControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code", is(200))) .andExpect(jsonPath("$.code", is(200)))
.andExpect(jsonPath("$.data.companyName", is("示例科技有限公司"))) .andExpect(jsonPath("$.data.companyName", is("示例科技有限公司")))
.andExpect(jsonPath("$.data.id").doesNotExist()) .andExpect(jsonPath("$.data.id", is(10)))
.andExpect(jsonPath("$.data.deleteTime").doesNotExist()); .andExpect(jsonPath("$.data.deleteTime").doesNotExist());
verify(service).create(any()); verify(service).create(any());
} }
...@@ -110,6 +112,18 @@ class CompanyProfileControllerTest { ...@@ -110,6 +112,18 @@ class CompanyProfileControllerTest {
verify(service, org.mockito.Mockito.never()).create(any()); verify(service, org.mockito.Mockito.never()).create(any());
} }
/** 代码作用(白话):验证编辑和删除都要求公司档案编辑权限并调用对应服务。关联文件:CompanyProfileController.java、CompanyProfileService.java。关联逻辑(调用链/数据流):PUT/DELETE -> EDIT 权限 -> Service。 */
@Test
void updatesAndDeletesCompanyProfileWithEditPermission() throws Exception {
CompanyProfileService service = mock(CompanyProfileService.class);
when(service.update(org.mockito.ArgumentMatchers.eq(10L), any())).thenReturn(response().records().get(0));
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new CompanyProfileController(service, mock(PagePermissionService.class))).build();
mockMvc.perform(put("/api/company-profiles/10").contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"companyName\":\"新公司\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.id", is(10)));
mockMvc.perform(delete("/api/company-profiles/10")).andExpect(status().isOk());
verify(service).softDelete(10L);
}
/** /**
* 代码作用(白话):提供 Controller JSON 断言所需的只读公司档案分页结果。 * 代码作用(白话):提供 Controller JSON 断言所需的只读公司档案分页结果。
* 关联文件:CompanyProfilePageResponse.java、CompanyProfileController.java。 * 关联文件:CompanyProfilePageResponse.java、CompanyProfileController.java。
...@@ -117,7 +131,7 @@ class CompanyProfileControllerTest { ...@@ -117,7 +131,7 @@ class CompanyProfileControllerTest {
*/ */
private CompanyProfilePageResponse response() { private CompanyProfilePageResponse response() {
return new CompanyProfilePageResponse(List.of(new CompanyProfileResponse( return new CompanyProfilePageResponse(List.of(new CompanyProfileResponse(
"示例科技有限公司", "示例科技", "91330100TEST000001", "杭州市西湖区", "张三", "13812345678", 10L, "示例科技有限公司", "示例科技", "91330100TEST000001", "杭州市西湖区", "张三", "13812345678",
LocalDateTime.of(2026, 8, 5, 10, 0), LocalDateTime.of(2026, 8, 5, 11, 0))), 1L, 1, 20); LocalDateTime.of(2026, 8, 5, 10, 0), LocalDateTime.of(2026, 8, 5, 11, 0))), 1L, 1, 20);
} }
} }
package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.xyw.console.asset.dto.CompanyPersonSaveRequest;
import com.xyw.console.asset.dto.CompanyPersonResponse;
import com.xyw.console.asset.dto.CompanyPersonPageQuery;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.DouyinAccountMapper;
import com.xyw.console.asset.mapper.WechatAccountMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.List;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.Test;
/** 文件用途(白话):验证公司人员保存规则、公司名称解析和引用安全删除。 */
class CompanyPersonServiceTest {
/**
* 代码作用(白话):验证历史人员没有所属公司时仍能正常分页,并以空公司名称返回而不是抛出服务器错误。
* 关联文件:CompanyPersonService.java、CompanyPersonPageResponse.java。
* 关联逻辑(调用链/数据流):GET 分页 -> companyProfileId=null -> 不查询空关联键 -> 页面记录。
*/
@Test
void pagesPersonWithoutCompanyUsingEmptyDisplayName() {
CompanyPersonMapper mapper = mock(CompanyPersonMapper.class);
Page<CompanyPersonEntity> page = new Page<>(1, 20);
CompanyPersonEntity person = new CompanyPersonEntity();
person.setId(3L); person.setPersonName("张三"); person.setEmploymentStatus("在职"); person.setDeleteTime(0L);
page.setRecords(List.of(person)); page.setTotal(1L);
when(mapper.selectPage(any(), any())).thenReturn(page);
CompanyPersonResponse result = service(mapper, mock(CompanyProfileMapper.class))
.page(new CompanyPersonPageQuery(1, 20, null, null, null)).records().get(0);
assertNull(result.companyProfileName());
assertEquals("张三", result.personName());
}
/**
* 代码作用(白话):验证新增人员默认在职、所属公司可空,并由服务端初始化审计字段。
* 关联文件:CompanyPersonService.java、CompanyPersonSaveRequest.java。
* 关联逻辑(调用链/数据流):POST 表单 -> Service.create -> 人员实体 -> Mapper.insert。
*/
@Test
void createsActivePersonWithoutCompanyByDefault() {
CompanyPersonMapper mapper = mock(CompanyPersonMapper.class);
when(mapper.insert(org.mockito.ArgumentMatchers.<CompanyPersonEntity>any())).thenReturn(1);
CompanyPersonResponse result = service(mapper, mock(CompanyProfileMapper.class))
.create(new CompanyPersonSaveRequest(null, " 张三 ", null, null));
assertEquals("张三", result.personName());
assertEquals("在职", result.employmentStatus());
assertNull(result.companyProfileId());
assertNull(result.resignedAt());
}
/**
* 代码作用(白话):验证离职人员必须填写离职时间,防止状态与时间互相矛盾。
* 关联文件:CompanyPersonService.java、CompanyPersonSaveRequest.java。
* 关联逻辑(调用链/数据流):离职表单 -> 状态校验 -> 参数错误 -> 不写数据库。
*/
@Test
void requiresResignedAtForResignedPerson() {
CompanyPersonMapper mapper = mock(CompanyPersonMapper.class);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () ->
service(mapper, mock(CompanyProfileMapper.class)).create(
new CompanyPersonSaveRequest(null, "张三", "离职", null)));
assertEquals("离职人员必须填写离职时间", error.getMessage());
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.<CompanyPersonEntity>any());
}
/**
* 代码作用(白话):验证选择所属公司时只接受有效公司,并优先返回公司简称供页面展示。
* 关联文件:CompanyPersonService.java、CompanyProfileMapper.java。
* 关联逻辑(调用链/数据流):公司 ID -> 有效公司查询 -> 简称/全称 -> 人员响应。
*/
@Test
void resolvesSelectedCompanyUsingShortNameFirst() {
CompanyPersonMapper mapper = mock(CompanyPersonMapper.class);
CompanyProfileMapper companies = mock(CompanyProfileMapper.class);
CompanyProfileEntity company = new CompanyProfileEntity();
company.setId(7L); company.setCompanyName("示例科技有限公司"); company.setShortName("示例科技"); company.setDeleteTime(0L);
when(companies.selectOne(any())).thenReturn(company);
when(mapper.insert(org.mockito.ArgumentMatchers.<CompanyPersonEntity>any())).thenReturn(1);
CompanyPersonResponse result = service(mapper, companies).create(
new CompanyPersonSaveRequest(7L, "张三", "离职", LocalDateTime.of(2026, 8, 12, 9, 30)));
assertEquals("示例科技", result.companyProfileName());
}
/**
* 代码作用(白话):验证人员仍被设备引用时拒绝删除,避免设备使用人名称断链。
* 关联文件:CompanyPersonService.java、AssetDeviceMapper.java。
* 关联逻辑(调用链/数据流):DELETE -> 设备引用计数 -> 参数错误 -> 保留人员。
*/
@Test
void rejectsDeleteWhenDeviceReferencesPerson() {
CompanyPersonMapper mapper = mock(CompanyPersonMapper.class);
CompanyPersonEntity person = new CompanyPersonEntity(); person.setId(3L); person.setDeleteTime(0L);
when(mapper.selectOne(any())).thenReturn(person);
AssetDeviceMapper devices = mock(AssetDeviceMapper.class);
when(devices.selectCount(any())).thenReturn(1L);
CompanyPersonService service = new CompanyPersonService(mapper, mock(CompanyProfileMapper.class), devices,
mock(WecomAccountMapper.class), mock(WechatAccountMapper.class), mock(DouyinAccountMapper.class));
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.softDelete(3L));
assertEquals("公司人员仍被设备资产管理引用,不能删除", error.getMessage());
verify(mapper, never()).updateById(org.mockito.ArgumentMatchers.<CompanyPersonEntity>any());
}
/**
* 代码作用(白话):构造默认没有资产引用的人员服务供保存规则测试使用。
* 关联文件:CompanyPersonService.java、人员相关 Mapper。
* 关联逻辑(调用链/数据流):测试 -> service 工厂 -> 被测 Service。
*/
private CompanyPersonService service(CompanyPersonMapper mapper, CompanyProfileMapper companies) {
return new CompanyPersonService(mapper, companies, mock(AssetDeviceMapper.class), mock(WecomAccountMapper.class),
mock(WechatAccountMapper.class), mock(DouyinAccountMapper.class));
}
}
...@@ -15,6 +15,11 @@ import com.xyw.console.asset.dto.CompanyProfileResponse; ...@@ -15,6 +15,11 @@ import com.xyw.console.asset.dto.CompanyProfileResponse;
import com.xyw.console.asset.dto.CompanyProfileSaveRequest; import com.xyw.console.asset.dto.CompanyProfileSaveRequest;
import com.xyw.console.asset.entity.CompanyProfileEntity; import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.mapper.CompanyProfileMapper; import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.DomainAssetMapper;
import com.xyw.console.asset.mapper.DouyinAccountMapper;
import com.xyw.console.asset.mapper.MerchantMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
...@@ -34,7 +39,7 @@ class CompanyProfileServiceTest { ...@@ -34,7 +39,7 @@ class CompanyProfileServiceTest {
page.setTotal(1L); page.setTotal(1L);
when(mapper.selectPage(any(), any())).thenReturn(page); when(mapper.selectPage(any(), any())).thenReturn(page);
CompanyProfilePageResponse result = new CompanyProfileService(mapper) CompanyProfilePageResponse result = service(mapper)
.page(new CompanyProfilePageQuery(null, null, null)); .page(new CompanyProfilePageQuery(null, null, null));
CompanyProfileResponse record = result.records().get(0); CompanyProfileResponse record = result.records().get(0);
...@@ -59,7 +64,7 @@ class CompanyProfileServiceTest { ...@@ -59,7 +64,7 @@ class CompanyProfileServiceTest {
void createsCompanyProfileWithServerManagedAuditFields() { void createsCompanyProfileWithServerManagedAuditFields() {
CompanyProfileMapper mapper = mock(CompanyProfileMapper.class); CompanyProfileMapper mapper = mock(CompanyProfileMapper.class);
when(mapper.insert(org.mockito.ArgumentMatchers.<CompanyProfileEntity>any())).thenReturn(1); when(mapper.insert(org.mockito.ArgumentMatchers.<CompanyProfileEntity>any())).thenReturn(1);
CompanyProfileService service = new CompanyProfileService(mapper); CompanyProfileService service = service(mapper);
CompanyProfileResponse result = service.create(new CompanyProfileSaveRequest( CompanyProfileResponse result = service.create(new CompanyProfileSaveRequest(
" 示例科技有限公司 ", " 示例科技 ", " 91330100TEST000001 ", " 杭州市西湖区 ", " 张三 ", " 13812345678 ")); " 示例科技有限公司 ", " 示例科技 ", " 91330100TEST000001 ", " 杭州市西湖区 ", " 张三 ", " 13812345678 "));
...@@ -88,13 +93,64 @@ class CompanyProfileServiceTest { ...@@ -88,13 +93,64 @@ class CompanyProfileServiceTest {
void rejectsBlankCompanyNameBeforeInsert() { void rejectsBlankCompanyNameBeforeInsert() {
CompanyProfileMapper mapper = mock(CompanyProfileMapper.class); CompanyProfileMapper mapper = mock(CompanyProfileMapper.class);
assertThrows(IllegalArgumentException.class, () -> new CompanyProfileService(mapper) assertThrows(IllegalArgumentException.class, () -> service(mapper)
.create(new CompanyProfileSaveRequest(" ", null, null, null, null, null))); .create(new CompanyProfileSaveRequest(" ", null, null, null, null, null)));
verify(mapper, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.<CompanyProfileEntity>any()); verify(mapper, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.<CompanyProfileEntity>any());
} }
/** /**
* 代码作用(白话):验证编辑会保留原记录主键和创建时间,只更新业务字段与更新时间。
* 关联文件:CompanyProfileService.java、CompanyProfileSaveRequest.java。
* 关联逻辑(调用链/数据流):PUT 表单 -> Service.update -> 原实体更新 -> Mapper.updateById。
*/
@Test
void updatesAnActiveCompanyProfile() {
CompanyProfileMapper mapper = mock(CompanyProfileMapper.class);
CompanyProfileEntity existing = companyProfile();
when(mapper.selectOne(any())).thenReturn(existing);
when(mapper.updateById(existing)).thenReturn(1);
CompanyProfileResponse result = service(mapper).update(10L,
new CompanyProfileSaveRequest(" 新名称 ", " 新简称 ", null, null, null, null));
assertEquals(10L, result.id());
assertEquals("新名称", result.companyName());
assertEquals("新简称", result.shortName());
verify(mapper).updateById(existing);
}
/**
* 代码作用(白话):验证公司仍有有效人员引用时不能软删除,避免人员记录失去所属公司名称。
* 关联文件:CompanyProfileService.java、CompanyPersonMapper.java。
* 关联逻辑(调用链/数据流):DELETE -> 引用计数 -> 参数错误 -> 不更新 deleteTime。
*/
@Test
void rejectsDeleteWhenActivePeopleReferenceCompany() {
CompanyProfileMapper mapper = mock(CompanyProfileMapper.class);
CompanyPersonMapper people = mock(CompanyPersonMapper.class);
when(mapper.selectOne(any())).thenReturn(companyProfile());
when(people.selectCount(any())).thenReturn(1L);
CompanyProfileService service = new CompanyProfileService(mapper, people, mock(WecomAccountMapper.class),
mock(DouyinAccountMapper.class), mock(DomainAssetMapper.class), mock(MerchantMapper.class));
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.softDelete(10L));
assertEquals("公司档案仍被公司人员引用,不能删除", error.getMessage());
verify(mapper, org.mockito.Mockito.never()).updateById(org.mockito.ArgumentMatchers.<CompanyProfileEntity>any());
}
/**
* 代码作用(白话):为公司档案测试构造一套默认无引用的业务服务,减少各测试重复创建 Mapper。
* 关联文件:CompanyProfileService.java、各资产 Mapper。
* 关联逻辑(调用链/数据流):测试 -> service 工厂 -> 被测 Service。
*/
private CompanyProfileService service(CompanyProfileMapper mapper) {
return new CompanyProfileService(mapper, mock(CompanyPersonMapper.class), mock(WecomAccountMapper.class),
mock(DouyinAccountMapper.class), mock(DomainAssetMapper.class), mock(MerchantMapper.class));
}
/**
* 代码作用(白话):构造一条完整的有效公司档案,供列表字段映射测试使用。 * 代码作用(白话):构造一条完整的有效公司档案,供列表字段映射测试使用。
* 关联文件:CompanyProfileEntity.java、CompanyProfileService.java。 * 关联文件:CompanyProfileEntity.java、CompanyProfileService.java。
* 关联逻辑(调用链/数据流):测试实体 -> Mapper 分页结果 -> Service 转换 -> Response 断言。 * 关联逻辑(调用链/数据流):测试实体 -> Mapper 分页结果 -> Service 转换 -> Response 断言。
......
...@@ -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"><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>` 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"><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('company-person')" to="/company-persons">公司人员</RouterLink><RouterLink v-if="canVisit('reference-wecom')" to="/reference/wecom">企业微信资产</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号码管理</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>`
}; };
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/** 文件用途(白话):集中请求公司人员分页、公司搜索及增删改接口。 */
/** 代码作用(白话):发送人员模块请求并统一解包或抛出可读错误。关联文件:CompanyPersonView.js、CompanyPersonController.java。关联逻辑(调用链/数据流):页面操作 -> fetch -> 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;}
/** 代码作用(白话):把人员筛选和分页状态拼成查询地址。关联文件:CompanyPersonView.js、CompanyPersonPageQuery.java。关联逻辑(调用链/数据流):筛选表单 -> URL 参数 -> GET 分页。 */
export function listCompanyPersons(query){const params=new URLSearchParams();Object.entries(query).forEach(([key,value])=>{if(value!==null&&value!==undefined&&value!==''&&value!=='ALL')params.set(key,value);});return request(`/api/company-persons?${params.toString()}`);}
/** 代码作用(白话):按公司名称或简称获取远程下拉选项。关联文件:CompanyPersonView.js、CompanyPersonController.java。关联逻辑(调用链/数据流):输入关键词 -> lookup GET -> 公司选项。 */
export function searchPersonCompanies(keyword){return request(`/api/company-persons/lookups/company-profiles?keyword=${encodeURIComponent(keyword||'')}`);}
/** 代码作用(白话):提交新增人员表单。关联文件:CompanyPersonView.js、CompanyPersonService.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 列表刷新。 */
export function createCompanyPerson(form){return request('/api/company-persons',{method:'POST',body:JSON.stringify(form)});}
/** 代码作用(白话):提交指定人员的编辑表单。关联文件:CompanyPersonView.js、CompanyPersonService.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT /{id} -> 列表刷新。 */
export function updateCompanyPerson(id,form){return request(`/api/company-persons/${id}`,{method:'PUT',body:JSON.stringify(form)});}
/** 代码作用(白话):请求引用检查并软删除指定人员。关联文件:CompanyPersonView.js、CompanyPersonService.java。关联逻辑(调用链/数据流):删除确认 -> DELETE /{id} -> 列表刷新。 */
export function deleteCompanyPerson(id){return request(`/api/company-persons/${id}`,{method:'DELETE'});}
...@@ -36,3 +36,9 @@ export function listCompanyProfiles(query) { ...@@ -36,3 +36,9 @@ export function listCompanyProfiles(query) {
export function createCompanyProfile(form) { export function createCompanyProfile(form) {
return request('/api/company-profiles', { method: 'POST', body: JSON.stringify(form) }); return request('/api/company-profiles', { method: 'POST', body: JSON.stringify(form) });
} }
/** 代码作用(白话):把编辑后的公司档案字段发送给指定记录。关联文件:CompanyProfileView.js、CompanyProfileController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT /{id} -> Service.update -> 列表刷新。 */
export function updateCompanyProfile(id, form) { return request(`/api/company-profiles/${id}`, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):请求后端检查引用并软删除指定公司档案。关联文件:CompanyProfileView.js、CompanyProfileService.java。关联逻辑(调用链/数据流):删除确认 -> DELETE /{id} -> 引用检查/软删除 -> 列表刷新。 */
export function deleteCompanyProfile(id) { return request(`/api/company-profiles/${id}`, { method: 'DELETE' }); }
...@@ -3,9 +3,9 @@ import { ElMessage } from 'element-plus'; ...@@ -3,9 +3,9 @@ import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js'; import { authState } from '../auth/auth-store.js';
import { createSystemUser, listLockedAccounts, listSystemUsers, resetSystemUserPassword, unlockLoginLock, 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: 'company-profile', 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: 'company-person', 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 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'); 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');
......
...@@ -4,6 +4,7 @@ import PhoneAssetView from '../modules/phone/PhoneAssetView.js'; ...@@ -4,6 +4,7 @@ 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 CompanyProfileView from '../modules/company-profile/CompanyProfileView.js';
import CompanyPersonView from '../modules/company-person/CompanyPersonView.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';
...@@ -30,6 +31,7 @@ const router = createRouter({ ...@@ -30,6 +31,7 @@ const router = createRouter({
{ 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: '/company-profiles', component: CompanyProfileView, meta: { page: 'company-profile' } },
{ path: '/company-persons', component: CompanyPersonView, meta: { page: 'company-person' } },
{ 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' } },
......
...@@ -17,6 +17,7 @@ test.beforeEach(async ({ page }) => { ...@@ -17,6 +17,7 @@ test.beforeEach(async ({ page }) => {
'reference-wecom': 'EDIT', 'reference-wecom': 'EDIT',
'phone-assets': 'EDIT', 'phone-assets': 'EDIT',
'company-profile': 'EDIT', 'company-profile': 'EDIT',
'company-person': 'EDIT',
alerts: 'EDIT' alerts: 'EDIT'
} }
}; };
......
import { expect, test } from './authenticated-test.js';
/** 文件用途(白话):验证公司人员菜单、列表、公司搜索、状态联动及增删改主流程。 */
test('manages company people with the reusable asset dialog', async ({ page }) => {
let saved = null; let deletedId = null;
/** 代码作用(白话):模拟人员分页和增删改接口。关联文件:CompanyPersonView.js、company-person-api-client.js。关联逻辑(调用链/数据流):页面请求 -> 测试响应/记录正文 -> UI 断言。 */
await page.route('**/api/company-persons**', async route => {
const url=new URL(route.request().url()); const method=route.request().method();
if(url.pathname.endsWith('/lookups/company-profiles')) { await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,data:[{id:5,companyName:'示例科技有限公司',shortName:'示例科技'}]})}); return; }
if(method==='POST'||method==='PUT'){saved=route.request().postDataJSON();await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,data:{id:3,...saved}})});return;}
if(method==='DELETE'){deletedId=url.pathname.split('/').pop();await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,data:null})});return;}
expect(url.search).toBe('?page=1&size=20');
await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,data:{records:[{id:3,companyProfileId:5,companyProfileName:'示例科技',personName:'张三',employmentStatus:'在职',resignedAt:null,createTime:'2026-08-12T09:00:00',updateTime:'2026-08-12T09:00:00'}],total:1,page:1,size:20}})});
});
await page.goto('/#/company-persons');
await expect(page.getByRole('link',{name:'公司人员'})).toBeVisible();
await expect(page.getByText('张三')).toBeVisible();
await page.getByRole('button',{name:'新增公司人员'}).click();
await page.getByPlaceholder('请输入人员姓名').fill('李四');
await page.getByRole('dialog',{name:'新增公司人员'}).getByRole('combobox',{name:'所属公司'}).fill('示例');
await page.getByRole('option',{name:'示例科技'}).click();
await page.keyboard.press('Escape');
await page.getByRole('dialog',{name:'新增公司人员'}).getByText('离职',{exact:true}).click();
await page.getByPlaceholder('请选择离职时间').fill('2026-08-12 10:30:00');
await page.getByRole('button',{name:'确认保存'}).click();
await expect.poll(()=>saved?.personName).toBe('李四');
await page.getByRole('button',{name:'删除'}).first().click();
await page.getByRole('button',{name:'确认删除'}).click();
await expect.poll(()=>deletedId).toBe('3');
});
import { expect, test } from './authenticated-test.js'; import { expect, test } from './authenticated-test.js';
/** 文件用途(白话):验证公司档案菜单、展示字段和新增弹窗的主流程,不暴露内部 ID。 */ /** 文件用途(白话):验证公司档案菜单、展示字段和新增弹窗的主流程,不暴露内部 ID。 */
test('shows company profiles and creates one from the reusable asset modal', async ({ page }) => { test('shows and manages company profiles from the reusable asset modal', async ({ page }) => {
let createPayload = null; let createPayload = null;
let updatePayload = null;
let deletedId = null;
let listRequests = 0; let listRequests = 0;
/** /**
...@@ -11,15 +13,17 @@ test('shows company profiles and creates one from the reusable asset modal', asy ...@@ -11,15 +13,17 @@ test('shows company profiles and creates one from the reusable asset modal', asy
* 关联逻辑(调用链/数据流):页面 GET -> 列表数据;弹窗 POST -> 记录请求正文 -> 成功响应 -> 再次 GET。 * 关联逻辑(调用链/数据流):页面 GET -> 列表数据;弹窗 POST -> 记录请求正文 -> 成功响应 -> 再次 GET。
*/ */
await page.route('**/api/company-profiles**', async route => { await page.route('**/api/company-profiles**', async route => {
if (route.request().method() === 'POST') { if (route.request().method() === 'POST' || route.request().method() === 'PUT') {
createPayload = route.request().postDataJSON(); createPayload = route.request().postDataJSON();
if (route.request().method() === 'PUT') updatePayload = createPayload;
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: '新增成功', data: { companyName: createPayload.companyName } }) }); await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: '新增成功', data: { companyName: createPayload.companyName } }) });
return; return;
} }
if (route.request().method() === 'DELETE') { deletedId = new URL(route.request().url()).pathname.split('/').pop(); await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: null }) }); return; }
listRequests += 1; listRequests += 1;
await route.fulfill({ await route.fulfill({
contentType: 'application/json', 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 } }) body: JSON.stringify({ code: 200, data: { records: [{ id: 10, 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 } })
}); });
}); });
...@@ -41,4 +45,11 @@ test('shows company profiles and creates one from the reusable asset modal', asy ...@@ -41,4 +45,11 @@ test('shows company profiles and creates one from the reusable asset modal', asy
await expect(page.getByRole('dialog', { name: '新增公司档案' })).toHaveCount(0); await expect(page.getByRole('dialog', { name: '新增公司档案' })).toHaveCount(0);
await expect(page.getByText('新增成功')).toBeVisible(); await expect(page.getByText('新增成功')).toBeVisible();
await expect.poll(() => listRequests).toBeGreaterThan(1); await expect.poll(() => listRequests).toBeGreaterThan(1);
await page.getByRole('button', { name: '编辑' }).first().click();
await page.getByPlaceholder('请输入公司简称').fill('更新简称');
await page.getByRole('button', { name: '保存修改' }).click();
await expect.poll(() => updatePayload?.shortName).toBe('更新简称');
await page.getByRole('button', { name: '删除' }).first().click();
await page.getByRole('button', { name: '确认删除' }).click();
await expect.poll(() => deletedId).toBe('10');
}); });
...@@ -107,6 +107,10 @@ test('确认保存仍调用原有新增接口并提交表单数据', async ({ pa ...@@ -107,6 +107,10 @@ test('确认保存仍调用原有新增接口并提交表单数据', async ({ pa
}); });
const dialog = await openCreateDialog(page); const dialog = await openCreateDialog(page);
await dialog.getByLabel('手机号').fill('13812345678'); await dialog.getByLabel('手机号').fill('13812345678');
await dialog.getByLabel('实名人').fill('张三');
await dialog.locator('.phone-asset-modal__form-row').filter({ hasText: '运营商' }).locator('.el-select').click();
await page.getByRole('option', { name: '中国移动' }).click();
await dialog.getByLabel('ICCID').fill('89860012345678901234');
await dialog.getByRole('button', { name: '确认保存' }).click(); await dialog.getByRole('button', { name: '确认保存' }).click();
await expect.poll(() => submittedPayload?.phoneNumber).toBe('13812345678'); await expect.poll(() => submittedPayload?.phoneNumber).toBe('13812345678');
await expect(dialog).toBeHidden(); await expect(dialog).toBeHidden();
......
...@@ -7,6 +7,11 @@ import { defineConfig } from 'vite'; ...@@ -7,6 +7,11 @@ import { defineConfig } from 'vite';
*/ */
export default defineConfig({ export default defineConfig({
base: '/asset/', base: '/asset/',
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
},
server: { server: {
port: 5173, port: 5173,
strictPort: true, strictPort: true,
......
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