Commit 9a51f1ba by DaiJiezhang

Merge branch 'codex/phone-account-sync-verify' into 'master'

功能:改进手机号资产列表管理

See merge request !1
parents c95e91c1 45443f00
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
- 前端已迁入 `frontend/`,开发入口为 Vite。 - 前端已迁入 `frontend/`,开发入口为 Vite。
- 旧 phone/wechat 后端接口与业务模块已移除。 - 旧 phone/wechat 后端接口与业务模块已移除。
- `#/reference/phone``#/reference/wechat` 是不可操作的旧界面参考页,不请求旧 API - `#/reference/phone` 是不可操作的旧界面参考页,不请求旧 API;`#/reference/wecom` 展示企微账号资产真实列表
- 新后端持久层映射 `as_*` 资产表;Service、Controller 与真实资产 API 留待后续重构。 - 新后端持久层映射 `as_*` 资产表;Service、Controller 与真实资产 API 留待后续重构。
## 前端开发 ## 前端开发
...@@ -15,7 +15,7 @@ npm install ...@@ -15,7 +15,7 @@ npm install
npm run dev npm run dev
``` ```
访问:`http://localhost:5173/#/reference/phone` 访问:`http://localhost:5173/asset/#/reference/wecom`
生产构建仍使用 `/assets/` 基础路径: 生产构建仍使用 `/assets/` 基础路径:
```powershell ```powershell
......
...@@ -7,7 +7,15 @@ import org.springframework.web.bind.annotation.*; ...@@ -7,7 +7,15 @@ import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/phone-assets") @RequestMapping("/api/phone-assets")
public class PhoneAssetController { public class PhoneAssetController {
private final PhoneAssetService service; public PhoneAssetController(PhoneAssetService service){this.service=service;} private final PhoneAssetService service;
/** 代码作用(白话):接收手机号资产接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */
public PhoneAssetController(PhoneAssetService service){this.service=service;}
/** 代码作用(白话):接收浏览器分页查询并返回统一 JSON。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):GET /api/phone-assets -> Service -> ApiResponse -> Vue 表格。 */ /** 代码作用(白话):接收浏览器分页查询并返回统一 JSON。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):GET /api/phone-assets -> Service -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){return ApiResponse.success(service.page(query));} @GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){return ApiResponse.success(service.page(query));}
/** 代码作用(白话):接收新增表单并创建手机号资产。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):POST -> DTO -> Service.create -> ApiResponse -> 弹窗。 */
@PostMapping public ApiResponse<PhoneAssetResponse> create(@Valid @RequestBody PhoneAssetSaveRequest request){return ApiResponse.success("新增成功",service.create(request));}
/** 代码作用(白话):接收编辑表单并更新允许修改的字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):PUT -> Service.update -> ApiResponse -> 列表刷新。 */
@PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){return ApiResponse.success("编辑成功",service.update(id,request));}
/** 代码作用(白话):软删除没有关联阻止的手机号资产。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):DELETE -> Service.softDelete -> ApiResponse -> 列表刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){service.softDelete(id);return ApiResponse.success("删除成功",null);}
} }
\ No newline at end of file
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 文件用途(白话):提供企微资产列表的只读 HTTP 入口,让前端无需直接访问数据库。 */
@RestController
@RequestMapping("/api/wecom-accounts")
public class WecomAccountController {
private final WecomAccountService service;
/**
* 代码作用(白话):接收企微资产查询服务,让 HTTP 请求能进入统一的分页查询逻辑。
* 关联文件:WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。
*/
public WecomAccountController(WecomAccountService service) {
this.service = service;
}
/**
* 代码作用(白话):接收浏览器的分页和筛选参数,返回统一 JSON 格式的企微资产列表。
* 关联文件:WecomAccountPageQuery.java、WecomAccountService.java、wecom-api-client.js。
* 关联逻辑(调用链/数据流):GET /api/wecom-accounts -> Service.page -> ApiResponse -> Vue 表格。
*/
@GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) {
return ApiResponse.success(service.page(query));
}
}
package com.xyw.console.asset.dto; package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
public record PhoneAssetPageQuery(@Min(1) Integer page, @Min(1) @Max(100) Integer size, String phoneNumber, String iccid) {
/** 代码作用(白话):在调用方未传分页参数时提供安全默认值。关联文件:PhoneAssetController.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认值 -> Service Page。 */ /** 文件用途(白话):接收手机号资产列表的分页和筛选条件,不改变新增、编辑接口的数据结构。 */
public int resolvedPage(){ return page == null ? 1 : page; } public record PhoneAssetPageQuery(
/** 代码作用(白话):限制每页返回数量,防止一次取走过多数据。关联文件:PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认值 -> Mapper limit。 */ @Min(1) Integer page,
public int resolvedSize(){ return size == null ? 20 : size; } @Min(1) @Max(100) Integer size,
String phoneNumber,
String iccid,
String realNameOwner,
String disposalStatus) {
/** 代码作用(白话):在调用方未传页码时提供安全默认值。关联文件:PhoneAssetController.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认页码 -> Service 分页查询。 */
public int resolvedPage() { return page == null ? 1 : page; }
/** 代码作用(白话):限制每页返回数量,防止一次取走过多数据。关联文件:PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 查询参数 -> 默认页大小 -> Mapper limit。 */
public int resolvedSize() { return size == null ? 20 : size; }
} }
\ No newline at end of file
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 代码作用(白话):限定新增和编辑时浏览器可以提交的手机号资产字段。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):弹窗表单 -> DTO 校验 -> Service 写入允许修改的实体字段。
*/
public record PhoneAssetSaveRequest(
@NotBlank(message = "手机号不能为空") String phoneNumber,
String cardType,
String iccid,
@Size(max = 20, message = "实名归属最多 20 个字符") String realNameOwner,
String managementType,
String disposalStatus,
Long deviceId
) {
}
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收企微资产列表的分页和筛选参数,避免 Controller 直接处理零散 URL 参数。 */
public record WecomAccountPageQuery(
@Min(1) Integer page,
@Min(1) @Max(100) Integer size,
String wecomName,
String wecomAccount) {
/**
* 代码作用(白话):在调用方没有传页码时返回第 1 页,保证列表可以直接打开。
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedPage -> MyBatis Page -> 列表响应。
*/
public int resolvedPage() {
return page == null ? 1 : page;
}
/**
* 代码作用(白话):在调用方没有传每页数量时使用 20 条,并由注解阻止一次查询过多记录。
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedSize -> MyBatis Page -> 列表响应。
*/
public int resolvedSize() {
return size == null ? 20 : size;
}
}
package com.xyw.console.asset.dto;
import java.util.List;
/** 文件用途(白话):将企微资产列表、总条数和当前分页信息打包为统一的页面数据。 */
public record WecomAccountPageResponse(
List<WecomAccountResponse> records,
long total,
int page,
int size) {
}
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
/** 文件用途(白话):定义一条企微资产返回给页面的字段,包含关联 ID 及其可读名称,不暴露删除标记。 */
public record WecomAccountResponse(
Long id,
String wecomName,
String wecomAlias,
String wecomAccount,
Long companyProfileId,
String companyProfileName,
Long phoneAssetId,
String phoneNumber,
String realNameOwner,
String realNameOwnerStatus,
String gender,
Long deviceId,
String deviceName,
Long operatorPersonId,
String operatorPersonName,
LocalDateTime createTime,
LocalDateTime updateTime) {
}
package com.xyw.console.asset.exception;
/** 代码作用(白话):表示请求的手机号资产不存在或已软删除。关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查无记录 -> 异常 -> 404 响应。 */
public class PhoneAssetNotFoundException extends RuntimeException { public PhoneAssetNotFoundException(String message){super(message);} }
\ No newline at end of file
package com.xyw.console.asset.exception;
/**
* 代码作用(白话):表示手机号规范化后仍不符合保存规则。
* 关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。
* 关联逻辑(调用链/数据流):Service 校验失败 -> 异常处理 -> 400 响应 -> 前端保留弹窗并提示。
*/
public class PhoneAssetValidationException extends RuntimeException {
public PhoneAssetValidationException(String message) {
super(message);
}
}
...@@ -15,7 +15,7 @@ public class WebConfig implements WebMvcConfigurer { ...@@ -15,7 +15,7 @@ public class WebConfig implements WebMvcConfigurer {
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") registry.addMapping("/api/**")
.allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000") .allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000", "http://localhost:5173", "http://127.0.0.1:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*"); .allowedHeaders("*");
} }
......
package com.xyw.console.asset.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.service.PhoneAssetService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class PhoneAssetControllerTest {
/**
* 代码作用(白话):证明浏览器可以通过新增接口创建手机号资产。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetSaveRequest.java。
* 关联逻辑(调用链/数据流):POST /api/phone-assets -> Controller -> Service -> Mapper -> as_phone_asset。
*/
@Test
void createsPhoneAssetThroughPostEndpoint() throws Exception {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.insert(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
mockMvc.perform(post("/api/phone-assets").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13812345678\",\"realNameOwner\":\"张三\"}"))
.andExpect(status().isOk());
}
/**
* 代码作用(白话):证明列表中的编辑操作可以调用更新接口。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):PUT /api/phone-assets/1 -> Service.update -> Mapper.updateById -> 列表刷新。
*/
@Test
void updatesPhoneAssetThroughPutEndpoint() throws Exception {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
PhoneAssetEntity entity = activeEntity();
when(mapper.selectOne(any())).thenReturn(entity);
when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
mockMvc.perform(put("/api/phone-assets/1").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13912345678\",\"realNameOwner\":\"李四\"}"))
.andExpect(status().isOk());
}
/**
* 代码作用(白话):证明用户确认后可通过删除接口软删除无关联资产。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):DELETE /api/phone-assets/1 -> Service.softDelete -> Mapper.updateById -> 列表刷新。
*/
@Test
void softDeletesPhoneAssetThroughDeleteEndpoint() throws Exception {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectOne(any())).thenReturn(activeEntity());
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
mockMvc.perform(delete("/api/phone-assets/1"))
.andExpect(status().isOk());
}
/**
* 代码作用(白话):构造一条仍有效的资产,供编辑和删除接口测试使用。
* 关联文件:PhoneAssetService.java、PhoneAssetEntity.java。
* 关联逻辑(调用链/数据流):测试输入 -> Service 查询有效资产 -> 更新或软删除。
*/
private PhoneAssetEntity activeEntity() {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(1L);
entity.setPhoneNumber("13812345678");
entity.setDeleteTime(0L);
return entity;
}
}
package com.xyw.console.asset.controller;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
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.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import com.xyw.console.asset.service.WecomAccountService;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class WecomAccountControllerTest {
/**
* 代码作用(白话):证明浏览器请求企微列表会得到统一成功响应、关联名称和不含删除标记的记录。
* 关联文件:WecomAccountController.java、WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):GET /api/wecom-accounts -> Controller -> Service -> Mapper -> JSON 表格数据。
*/
@Test
void returnsWecomAccountPageWithoutDeleteTime() throws Exception {
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller()).build();
mockMvc.perform(get("/api/wecom-accounts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code", is(200)))
.andExpect(jsonPath("$.data.page", is(1)))
.andExpect(jsonPath("$.data.size", is(20)))
.andExpect(jsonPath("$.data.records[0].companyProfileName", is("示例科技有限公司")))
.andExpect(jsonPath("$.data.records[0].phoneNumber", is("13812345678")))
.andExpect(jsonPath("$.data.records[0].deviceName", is("iPhone 15")))
.andExpect(jsonPath("$.data.records[0].operatorPersonName", is("王五")))
.andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist());
}
/**
* 代码作用(白话):构造使用真实 Service 的 Controller,避免只验证模拟返回值。
* 关联文件:WecomAccountController.java、WecomAccountService.java、各资产 Mapper。
* 关联逻辑(调用链/数据流):测试 HTTP 请求 -> Controller -> Service -> Mapper 模拟数据库结果。
*/
private WecomAccountController controller() {
WecomAccountMapper wecomMapper = org.mockito.Mockito.mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = org.mockito.Mockito.mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = org.mockito.Mockito.mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = org.mockito.Mockito.mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = org.mockito.Mockito.mock(CompanyPersonMapper.class);
Page<WecomAccountEntity> page = new Page<>(1, 20);
page.setRecords(List.of(wecomAccount()));
page.setTotal(1L);
org.mockito.Mockito.when(wecomMapper.selectPage(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(page);
org.mockito.Mockito.when(companyProfileMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(companyProfile()));
org.mockito.Mockito.when(phoneAssetMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(phoneAsset()));
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device()));
org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper));
}
/**
* 代码作用(白话):构造接口测试的企微主记录。
* 关联文件:WecomAccountEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):企微主记录 -> Service 关联名称填充 -> Controller JSON。
*/
private WecomAccountEntity wecomAccount() {
WecomAccountEntity entity = new WecomAccountEntity();
entity.setId(1L);
entity.setWecomName("张三");
entity.setWecomAlias("销售一组");
entity.setWecomAccount("zhangsan");
entity.setCompanyProfileId(10L);
entity.setPhoneAssetId(20L);
entity.setRealNameOwner("张三");
entity.setRealNameOwnerStatus("已实名");
entity.setGender("男");
entity.setDeviceId(30L);
entity.setOperatorPersonId(40L);
entity.setCreateTime(LocalDateTime.of(2026, 7, 31, 10, 0));
entity.setUpdateTime(LocalDateTime.of(2026, 7, 31, 11, 0));
entity.setDeleteTime(0L);
return entity;
}
/**
* 代码作用(白话):构造公司档案关联测试数据。
* 关联文件:CompanyProfileEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):公司 ID -> 公司名称 -> JSON 字段 companyProfileName。
*/
private CompanyProfileEntity companyProfile() {
CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L);
entity.setCompanyName("示例科技有限公司");
return entity;
}
/**
* 代码作用(白话):构造手机号资产关联测试数据。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):手机号资产 ID -> 手机号 -> JSON 字段 phoneNumber。
*/
private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(20L);
entity.setPhoneNumber("13812345678");
return entity;
}
/**
* 代码作用(白话):构造设备关联测试数据。
* 关联文件:AssetDeviceEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):设备 ID -> 设备名称 -> JSON 字段 deviceName。
*/
private AssetDeviceEntity device() {
AssetDeviceEntity entity = new AssetDeviceEntity();
entity.setId(30L);
entity.setDeviceName("iPhone 15");
return entity;
}
/**
* 代码作用(白话):构造经办人关联测试数据。
* 关联文件:CompanyPersonEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):人员 ID -> 人员名称 -> JSON 字段 operatorPersonName。
*/
private CompanyPersonEntity operatorPerson() {
CompanyPersonEntity entity = new CompanyPersonEntity();
entity.setId(40L);
entity.setPersonName("王五");
return entity;
}
}
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.mockito.ArgumentMatchers.any;
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.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountResponse;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
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.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
class WecomAccountServiceTest {
/**
* 代码作用(白话):证明企微列表会把当前页关联 ID 转成可读名称,同时保留 ID 且不产生删除标记字段。
* 关联文件:WecomAccountService.java、WecomAccountResponse.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):分页请求 -> Service 查询企微及关联资产 -> Response -> 前端表格。
*/
@Test
void returnsReadableRelatedNamesForTheCurrentWecomAccountPage() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = mock(CompanyPersonMapper.class);
when(wecomMapper.selectPage(any(), any())).thenReturn(wecomPage());
when(companyProfileMapper.selectList(any())).thenReturn(List.of(companyProfile()));
when(phoneAssetMapper.selectList(any())).thenReturn(List.of(phoneAsset()));
when(assetDeviceMapper.selectList(any())).thenReturn(List.of(device()));
when(companyPersonMapper.selectList(any())).thenReturn(List.of(operatorPerson()));
WecomAccountPageResponse result = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(null, null, null, null));
WecomAccountResponse record = result.records().get(0);
assertEquals(1L, record.id());
assertEquals("示例科技有限公司", record.companyProfileName());
assertEquals(10L, record.companyProfileId());
assertEquals("13812345678", record.phoneNumber());
assertEquals(20L, record.phoneAssetId());
assertEquals("iPhone 15", record.deviceName());
assertEquals(30L, record.deviceId());
assertEquals("王五", record.operatorPersonName());
assertEquals(40L, record.operatorPersonId());
assertEquals("张三", record.realNameOwner());
assertEquals(1, result.page());
assertEquals(20, result.size());
}
/**
* 代码作用(白话):证明关联 ID 没有对应资产时,企微记录仍能返回且名称为空。
* 关联文件:WecomAccountService.java、WecomAccountResponse.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):企微记录含关联 ID -> 批量查询无匹配 -> Response 保留 ID 与空名称。
*/
@Test
void keepsRelatedIdsWhenTheirNamesCannotBeResolved() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
CompanyProfileMapper companyProfileMapper = mock(CompanyProfileMapper.class);
PhoneAssetMapper phoneAssetMapper = mock(PhoneAssetMapper.class);
AssetDeviceMapper assetDeviceMapper = mock(AssetDeviceMapper.class);
CompanyPersonMapper companyPersonMapper = mock(CompanyPersonMapper.class);
when(wecomMapper.selectPage(any(), any())).thenReturn(wecomPage());
when(companyProfileMapper.selectList(any())).thenReturn(List.of());
when(phoneAssetMapper.selectList(any())).thenReturn(List.of());
when(assetDeviceMapper.selectList(any())).thenReturn(List.of());
when(companyPersonMapper.selectList(any())).thenReturn(List.of());
WecomAccountResponse record = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(1, 20, null, null)).records().get(0);
assertEquals(10L, record.companyProfileId());
assertNull(record.companyProfileName());
assertEquals(20L, record.phoneAssetId());
assertNull(record.phoneNumber());
assertEquals(30L, record.deviceId());
assertNull(record.deviceName());
assertEquals(40L, record.operatorPersonId());
assertNull(record.operatorPersonName());
}
/**
* 代码作用(白话):构造一页正常企微资产记录,供关联名称转换场景使用。
* 关联文件:WecomAccountEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):测试记录 -> Mapper 分页结果 -> Service -> Response。
*/
private Page<WecomAccountEntity> wecomPage() {
WecomAccountEntity entity = new WecomAccountEntity();
entity.setId(1L);
entity.setWecomName("张三");
entity.setWecomAlias("销售一组");
entity.setWecomAccount("zhangsan");
entity.setCompanyProfileId(10L);
entity.setPhoneAssetId(20L);
entity.setRealNameOwner("张三");
entity.setRealNameOwnerStatus("已实名");
entity.setGender("男");
entity.setDeviceId(30L);
entity.setOperatorPersonId(40L);
entity.setCreateTime(LocalDateTime.of(2026, 7, 31, 10, 0));
entity.setUpdateTime(LocalDateTime.of(2026, 7, 31, 11, 0));
entity.setDeleteTime(0L);
Page<WecomAccountEntity> page = new Page<>(1, 20);
page.setRecords(List.of(entity));
page.setTotal(1L);
return page;
}
/**
* 代码作用(白话):构造公司档案名称,验证公司 ID 能转成页面可读名称。
* 关联文件:CompanyProfileEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):companyProfileId -> CompanyProfileMapper -> companyProfileName。
*/
private CompanyProfileEntity companyProfile() {
CompanyProfileEntity entity = new CompanyProfileEntity();
entity.setId(10L);
entity.setCompanyName("示例科技有限公司");
return entity;
}
/**
* 代码作用(白话):构造手机号资产名称,验证手机号资产 ID 能转成手机号。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):phoneAssetId -> PhoneAssetMapper -> phoneNumber。
*/
private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(20L);
entity.setPhoneNumber("13812345678");
return entity;
}
/**
* 代码作用(白话):构造设备名称,验证设备 ID 能转成设备名称。
* 关联文件:AssetDeviceEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):deviceId -> AssetDeviceMapper -> deviceName。
*/
private AssetDeviceEntity device() {
AssetDeviceEntity entity = new AssetDeviceEntity();
entity.setId(30L);
entity.setDeviceName("iPhone 15");
return entity;
}
/**
* 代码作用(白话):构造经办人名称,验证经办人 ID 能转成页面可读人员名称。
* 关联文件:CompanyPersonEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):operatorPersonId -> CompanyPersonMapper -> operatorPersonName。
*/
private CompanyPersonEntity operatorPerson() {
CompanyPersonEntity entity = new CompanyPersonEntity();
entity.setId(40L);
entity.setPersonName("王五");
return entity;
}
}
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"name": "xyw-console-frontend", "name": "xyw-console-frontend",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test:e2e": "playwright test" "test:e2e": "playwright test"
......
/** /** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */
* 代码作用(白话):提供新前端的稳定壳层和导航,只进入静态参考或重构中页面,不加载任何旧业务运行时。 export default { template: `<div class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink to="/overview">总览</RouterLink><RouterLink to="/domain">域名资料</RouterLink><RouterLink to="/reference/wecom">企微资料</RouterLink><RouterLink to="/phone-assets">手机号资产</RouterLink><RouterLink to="/alerts">提醒中心</RouterLink></nav></aside><main class="content"><RouterView /></main></div>` };
* 关联文件:frontend/src/router/index.js、frontend/src/reference/LegacyReferenceView.js、frontend/src/styles/app.css。 \ No newline at end of file
* 关联逻辑(调用链/数据流):导航点击 -> RouterLink -> Vue Router -> RouterView 渲染目标页面。
*/
export default {
template: `
<div class="app-shell">
<aside class="sidebar">
<p class="eyebrow">XYW ASSETS</p>
<h1>学有为资产后台</h1>
<nav aria-label="主导航">
<RouterLink to="/overview">总览</RouterLink>
<RouterLink to="/domain">域名资料</RouterLink>
<RouterLink to="/reference/wechat">企微资料(参考)</RouterLink>
<RouterLink to="/phone-assets">手机号资产</RouterLink>
<RouterLink to="/alerts">提醒中心</RouterLink>
</nav>
</aside>
<main class="content"><RouterView /></main>
</div>
`
};
/** /**
* 代码作用(白话):统一发送手机号资产列表请求,避免页面自己处理返回格式 * 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。 * 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面加载 -> listPhoneAssets -> GET API -> 表格数据 * 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误
*/ */
async function request(path) { async function request(path, options = {}) {
const response = await fetch(path); const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
const payload = await response.json(); const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败'); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败');
return payload.data; return payload.data;
} }
/** /** 代码作用(白话):按筛选条件读取手机号资产列表。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):筛选条件 -> GET -> 表格。 */
* 代码作用(白话):根据筛选条件读取手机号资产分页数据。 export function listPhoneAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key,value); }); return request('/api/phone-assets?' + params.toString()); }
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。 /** 代码作用(白话):提交新增表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 后端创建。 */
* 关联逻辑(调用链/数据流):筛选条件 -> URL 参数 -> 后端分页结果 -> 表格。 export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); }
*/ /** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */
export function listPhoneAssets(query) { export function updatePhoneAsset(id, form) { return request('/api/phone-assets/' + id, { method: 'PUT', body: JSON.stringify(form) }); }
const params = new URLSearchParams(); /** 代码作用(白话):确认后删除一条手机号资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 后端软删除。 */
Object.entries(query).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '') params.set(key, value); }); export function deletePhoneAsset(id) { return request('/api/phone-assets/' + id, { method: 'DELETE' }); }
return request(`/api/phone-assets?${params.toString()}`);
}
\ No newline at end of file
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { listWecomAccounts } from './wecom-api-client.js';
/** 文件用途(白话):显示可筛选、可分页的企微资料,并把关联资源呈现成用户可读的名称和 ID。 */
export default {
/**
* 代码作用(白话):创建页面加载、筛选、分页状态,并把操作方法交给模板中的按钮和表格使用。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):路由进入页面 -> setup 初始化 -> loadPage -> API -> Element Plus 表格。
*/
setup() {
const loading = ref(false);
const records = ref([]);
const total = ref(0);
const filters = reactive({ page: 1, size: 20, wecomName: '', wecomAccount: '' });
/**
* 代码作用(白话):按当前筛选条件读取一页企微资产,并同步更新表格记录和总条数。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):页面事件 -> listWecomAccounts -> GET API -> records/total -> 表格和分页器。
*/
async function loadPage() {
loading.value = true;
try {
const result = await listWecomAccounts(filters);
records.value = result.records;
total.value = result.total;
} catch (error) {
ElMessage.error(error.message);
} finally {
loading.value = false;
}
}
/**
* 代码作用(白话):在用户提交筛选时回到第一页,防止旧页码导致看不到匹配结果。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):查询按钮 -> page=1 -> loadPage -> 新筛选结果。
*/
function submitSearch() {
filters.page = 1;
loadPage();
}
/**
* 代码作用(白话):清空企微名称和企微账号筛选,并恢复默认分页后重新查询。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):重置按钮 -> filters 默认值 -> loadPage -> 默认列表。
*/
function resetSearch() {
Object.assign(filters, { page: 1, size: 20, wecomName: '', wecomAccount: '' });
loadPage();
}
/**
* 代码作用(白话):接收分页器选中的页码并加载该页的企微资产。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):分页器 -> filters.page -> loadPage -> 后端分页结果。
*/
function changePage(page) {
filters.page = page;
loadPage();
}
/**
* 代码作用(白话):将关联资源的名称和 ID 组合成“名称(ID)”,关联缺失时保留 ID 并显示“—”。
* 关联文件:WecomAccountResponse.java、WecomAccountService.java、WecomAccountView.js。
* 关联逻辑(调用链/数据流):接口关联字段 -> formatRelation -> 表格关联资源单元格。
*/
function formatRelation(name, id) {
if (id === null || id === undefined) {
return '—';
}
return `${name || '—'}(ID:${id})`;
}
onMounted(loadPage);
return { changePage, filters, formatRelation, loading, records, resetSearch, submitSearch, total };
},
template: `
<section class="wecom-account-page">
<header class="page-header">
<div><p class="eyebrow">WECOM ACCOUNTS</p><h2>企微资料</h2><p>查看企微账号资产、实名信息和关联资源。</p></div>
</header>
<article class="reference-card">
<el-form inline @submit.prevent="submitSearch">
<el-form-item label="企微名称"><el-input v-model="filters.wecomName" clearable /></el-form-item>
<el-form-item label="企微账号"><el-input v-model="filters.wecomAccount" clearable /></el-form-item>
<el-form-item><el-button type="primary" @click="submitSearch">查询</el-button><el-button @click="resetSearch">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="records">
<el-table-column prop="id" label="企微资产 ID" min-width="110" />
<el-table-column prop="wecomName" label="企微名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="wecomAlias" label="企微别名" min-width="120" show-overflow-tooltip />
<el-table-column prop="wecomAccount" label="企微账号" min-width="140" show-overflow-tooltip />
<el-table-column label="公司档案" min-width="220"><template #default="scope">{{ formatRelation(scope.row.companyProfileName, scope.row.companyProfileId) }}</template></el-table-column>
<el-table-column label="关联手机号资产" min-width="200"><template #default="scope">{{ formatRelation(scope.row.phoneNumber, scope.row.phoneAssetId) }}</template></el-table-column>
<el-table-column prop="realNameOwner" label="企微实名人" min-width="130" show-overflow-tooltip />
<el-table-column prop="realNameOwnerStatus" label="实名状态" min-width="120" show-overflow-tooltip />
<el-table-column prop="gender" label="性别" min-width="90" />
<el-table-column label="关联设备" min-width="190"><template #default="scope">{{ formatRelation(scope.row.deviceName, scope.row.deviceId) }}</template></el-table-column>
<el-table-column label="经办人" min-width="170"><template #default="scope">{{ formatRelation(scope.row.operatorPersonName, scope.row.operatorPersonId) }}</template></el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" min-width="180" show-overflow-tooltip />
</el-table>
<el-pagination v-if="total" background layout="total, prev, pager, next" :current-page="filters.page" :page-size="filters.size" :total="total" @current-change="changePage" />
</article>
</section>
`
};
\ No newline at end of file
/** 文件用途(白话):集中发送企微资产列表请求并统一解析后端的成功或失败响应。 */
/**
* 代码作用(白话):请求后端并从统一 JSON 响应中取出真正的列表数据,失败时抛出可显示的错误信息。
* 关联文件:WecomAccountView.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):列表页 -> request -> GET /api/wecom-accounts -> ApiResponse.data -> 表格数据。
*/
async function request(path) {
const response = await fetch(path);
const payload = await response.json();
if (!response.ok || payload.code !== 200) {
throw new Error(payload.message || '企微资产请求失败');
}
return payload.data;
}
/**
* 代码作用(白话):把页面的分页和筛选状态转换为 URL 参数,再读取对应的企微资产页。
* 关联文件:WecomAccountView.js、WecomAccountController.java、WecomAccountPageQuery.java。
* 关联逻辑(调用链/数据流):筛选条件 -> URLSearchParams -> GET 接口 -> records/total。
*/
export function listWecomAccounts(query) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params.set(key, value);
}
});
return request(`/api/wecom-accounts?${params.toString()}`);
}
\ No newline at end of file
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router';
import LegacyReferenceView from '../reference/LegacyReferenceView.js'; 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';
/** /**
* 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。 * 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。
...@@ -12,9 +13,9 @@ function createPlaceholderView(title) { ...@@ -12,9 +13,9 @@ function createPlaceholderView(title) {
} }
/** /**
* 代码作用(白话):定义清理旧 phone/wechat 模块后的新路由,只保留无接口依赖的参考页面。 * 代码作用(白话):定义资产后台 Hash 路由,将企微资料连接到真实列表,同时保留手机号静态参考页面。
* 关联文件:frontend/src/App.js、frontend/src/reference/LegacyReferenceView.js、frontend/tests/legacy-reference.spec.js。 * 关联文件:frontend/src/App.js、frontend/src/modules/wecom/WecomAccountView.js、frontend/tests/wecom-account.spec.js。
* 关联逻辑(调用链/数据流):Hash 地址 -> router -> LegacyReferenceView -> 静态示例数据与禁用操作按钮 * 关联逻辑(调用链/数据流):Hash 地址 -> router -> 真实企微列表或静态手机号参考页 -> 页面渲染
*/ */
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
...@@ -24,7 +25,7 @@ const router = createRouter({ ...@@ -24,7 +25,7 @@ const router = createRouter({
{ path: '/phone-assets', component: PhoneAssetView }, { path: '/phone-assets', component: PhoneAssetView },
{ path: '/domain', component: createPlaceholderView('域名资料') }, { path: '/domain', component: createPlaceholderView('域名资料') },
{ path: '/alerts', component: createPlaceholderView('提醒中心') }, { path: '/alerts', component: createPlaceholderView('提醒中心') },
{ path: '/reference/wechat', component: LegacyReferenceView, props: { kind: 'wechat' } }, { path: '/reference/wecom', component: WecomAccountView },
{ path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } } { path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } }
] ]
}); });
......
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
const referencePages = [ const referencePages = [
{ path: '#/reference/phone', title: '手机号卡旧界面参考' }, { path: '#/reference/phone', title: '手机号卡旧界面参考' }
{ path: '#/reference/wechat', title: '企微资料旧界面参考' }
]; ];
for (const referencePage of referencePages) { for (const referencePage of referencePages) {
......
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
/** /**
* 代码作用(白话):模拟手机号资产列表接口,先证明新页面尚未实现时测试会失败,后续用于保护页面加载流程 * 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表
* 关联文件:frontend/src/modules/phone/PhoneAssetView.js、frontend/src/modules/phone/phone-api-client.js。 * 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/消息链/数据流):浏览器打开手机号资产路由 -> View 请求列表 API -> route.fulfill 返回测试数据 -> 页面表格断言 * 关联逻辑(调用链/数据流):页面打开 -> listPhoneAssets -> 浏览器路由拦截 -> 空列表 -> 新增弹窗
*/ */
test('phone asset workspace loads the first page from its API', async ({ page }) => { async function mockPhoneAssetList(page) {
await page.route('**/api/phone-assets?**', async (route) => { await page.route('**/api/phone-assets?**', async (route) => {
await route.fulfill({ await route.fulfill({
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
code: 200, code: 200,
message: 'success', message: 'success',
data: { data: { records: [], total: 0, page: 1, size: 20 }
records: [{ id: 1, phoneNumber: '13812345678', cardType: '中国移动', disposalStatus: '正常使用' }],
total: 1,
page: 1,
size: 20
}
}) })
}); });
}); });
}
/**
* 代码作用(白话):打开新增手机号资产弹窗,供多个验收场景复用。
* 关联文件:PhoneAssetView.js、phone-asset.spec.js。
* 关联逻辑(调用链/数据流):测试页面 -> 新增按钮 -> openCreate -> dialogVisible -> 弹窗。
*/
async function openCreateDialog(page) {
await mockPhoneAssetList(page);
await page.goto('/#/phone-assets'); await page.goto('/#/phone-assets');
await page.getByRole('button', { name: '新增手机号资产' }).click();
return page.getByRole('dialog', { name: '新增手机号资产' });
}
/**
* 代码作用(白话):在浏览器中派发带剪贴板文字的真实粘贴事件。
* 关联文件:PhoneAssetView.js、phone-asset.spec.js。
* 关联逻辑(调用链/数据流):测试数据 -> paste 事件 -> handlePhonePaste/handleIccidPaste -> 表单字段。
*/
async function pasteText(locator, value) {
await locator.evaluate((input, pastedValue) => {
const clipboardData = new DataTransfer();
clipboardData.setData('text', pastedValue);
input.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData }));
}, value);
}
/**
* 代码作用(白话):验证新增弹窗保留七个字段,并将两个字数计数器放在各自输入框内部。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):新增按钮 -> 弹窗渲染 -> 表单字段和 suffix 计数器。
*/
test('新增弹窗保留七个字段,并在输入框内显示初始计数器', async ({ page }) => {
const dialog = await openCreateDialog(page);
await expect(dialog.locator('.phone-asset-modal__form-row')).toHaveCount(7);
await expect(dialog.locator('.phone-asset-modal__character-count')).toHaveText(['0/11', '0/20']);
await expect(dialog.locator('.phone-asset-modal__character-count').first()).toBeVisible();
});
/**
* 代码作用(白话):验证手机号和 ICCID 的输入、粘贴、删除都会实时更新计数器且不会超过长度上限。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):输入事件 -> limitPhoneNumber/limitIccid -> form -> suffix 计数器。
*/
test('手机号和 ICCID 会过滤并截断超长输入,同时更新框内计数器', async ({ page }) => {
const dialog = await openCreateDialog(page);
const phoneInput = dialog.getByLabel('手机号');
const iccidInput = dialog.getByLabel('ICCID');
await expect(page.getByRole('heading', { name: '手机号资产' })).toBeVisible(); await pasteText(phoneInput, '138-1234567899');
await expect(page.getByText('13812345678')).toBeVisible(); await expect(phoneInput).toHaveValue('13812345678');
await expect(page.getByRole('button', { name: '新增手机号资产' })).toBeVisible(); await expect(dialog.locator('.phone-asset-modal__character-count').first()).toHaveText('11/11');
await phoneInput.press('Control+A');
await phoneInput.press('Backspace');
await expect(dialog.locator('.phone-asset-modal__character-count').first()).toHaveText('0/11');
await pasteText(iccidInput, '8986032594020999545B-extra');
await expect(iccidInput).toHaveValue('8986032594020999545B');
await expect(dialog.locator('.phone-asset-modal__character-count').nth(1)).toHaveText('20/20');
}); });
/**
* 代码作用(白话):验证取消和右上角关闭按钮仍会关闭同一个业务弹窗。
* 关联文件:PhoneAssetView.js。
* 关联逻辑(调用链/数据流):按钮点击 -> dialogVisible=false -> Element Plus 弹窗关闭。
*/
test('取消和右上角关闭按钮仍可关闭弹窗', async ({ page }) => {
let dialog = await openCreateDialog(page);
await dialog.getByRole('button', { name: '取消' }).click();
await expect(dialog).toBeHidden();
dialog = await openCreateDialog(page);
await dialog.locator('.el-dialog__headerbtn').click();
await expect(dialog).toBeHidden();
});
/**
* 代码作用(白话):验证确认保存仍将原有表单字段通过新增接口提交。
* 关联文件:PhoneAssetView.js、phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):确认保存 -> createPhoneAsset -> POST /api/phone-assets -> 成功提示和关闭弹窗。
*/
test('确认保存仍调用原有新增接口并提交表单数据', async ({ page }) => {
let submittedPayload;
await page.route('**/api/phone-assets', async (route) => {
if (route.request().method() !== 'POST') return route.fallback();
submittedPayload = route.request().postDataJSON();
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: '新增成功', data: { id: 1 } })
});
});
const dialog = await openCreateDialog(page);
await dialog.getByLabel('手机号').fill('13812345678');
await dialog.getByRole('button', { name: '确认保存' }).click();
await expect.poll(() => submittedPayload?.phoneNumber).toBe('13812345678');
await expect(dialog).toBeHidden();
});
/**
* 代码作用(白话):验证窄窗口里的弹窗保持在可视区域内,页面不出现横向滚动条。
* 关联文件:PhoneAssetView.js、app.css。
* 关联逻辑(调用链/数据流):窄视口 -> 响应式 CSS -> 单列表单和受限弹窗宽度。
*/
test('窄窗口下弹窗不超出屏幕且页面没有横向滚动', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
const dialog = await openCreateDialog(page);
const bounds = await dialog.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(320);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
\ No newline at end of file
import { expect, test } from '@playwright/test';
/**
* 代码作用(白话):验证企微资料真实页面会请求新接口,并把关联名称与 ID、企微实名人完整展示出来。
* 关联文件:WecomAccountView.js、wecom-api-client.js、router/index.js。
* 关联逻辑(调用链/数据流):访问 Hash 路由 -> 拦截 API 响应 -> Vue 表格 -> 页面断言。
*/
test('shows readable related names without a delete-time column', async ({ page }) => {
/**
* 代码作用(白话):提供完整的企微列表接口响应,避免测试依赖本机数据库或后端服务状态。
* 关联文件:WecomAccountResponse.java、wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):浏览器 API 请求 -> route.fulfill -> 前端 records -> 表格单元格。
*/
await page.route('**/api/wecom-accounts**', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: {
records: [{
id: 1,
wecomName: '张三',
wecomAlias: '销售一组',
wecomAccount: 'zhangsan',
companyProfileId: 10,
companyProfileName: '示例科技有限公司',
phoneAssetId: 20,
phoneNumber: '13812345678',
realNameOwner: '张三',
realNameOwnerStatus: '已实名',
gender: '男',
deviceId: 30,
deviceName: 'iPhone 15',
operatorPersonId: 40,
operatorPersonName: '王五',
createTime: '2026-07-31T10:00:00',
updateTime: '2026-07-31T11:00:00'
}],
total: 1,
page: 1,
size: 20
}
})
});
});
await page.goto('/asset/#/reference/wecom');
await expect(page.getByRole('heading', { name: '企微资料' })).toBeVisible();
await expect(page.getByText('企微实名人')).toBeVisible();
await expect(page.getByText('示例科技有限公司(ID:10)')).toBeVisible();
await expect(page.getByText('13812345678(ID:20)')).toBeVisible();
await expect(page.getByText('iPhone 15(ID:30)')).toBeVisible();
await expect(page.getByText('王五(ID:40)')).toBeVisible();
await expect(page.getByText('删除标记')).toHaveCount(0);
});
/**
* 代码作用(白话):验证用户筛选和翻页时,页面会把新的名称、账号和页码参数提交给企微列表接口。
* 关联文件:WecomAccountView.js、wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):输入筛选 -> 查询按钮或下一页 -> URL 参数 -> 后端分页查询。
*/
test('sends current filters and page number to the wecom list API', async ({ page }) => {
const requestUrls = [];
/**
* 代码作用(白话):记录每次企微接口请求的 URL,并持续返回足够多的记录以显示分页器。
* 关联文件:wecom-api-client.js、WecomAccountView.js。
* 关联逻辑(调用链/数据流):前端请求 -> route 回调 -> requestUrls -> URL 参数断言。
*/
await page.route('**/api/wecom-accounts**', async (route) => {
requestUrls.push(route.request().url());
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: { records: [], total: 41, page: 1, size: 20 }
})
});
});
await page.goto('/asset/#/reference/wecom');
await page.locator('input').nth(0).fill('张三');
await page.locator('input').nth(1).fill('zhangsan');
await page.getByRole('button', { name: '查询' }).click();
await expect.poll(() => requestUrls.some((url) => url.includes('wecomName=%E5%BC%A0%E4%B8%89') && url.includes('wecomAccount=zhangsan'))).toBe(true);
await page.locator('.el-pagination .btn-next').click();
await expect.poll(() => requestUrls.some((url) => url.includes('page=2') && url.includes('wecomName=%E5%BC%A0%E4%B8%89'))).toBe(true);
});
\ No newline at end of file
schema: spec-driven
created: 2026-07-31
## Context
当前 `#/reference/wechat` 是不请求接口的静态旧界面参考,而 `as_wecom_account` 已经具备 `WecomAccountEntity``WecomAccountMapper`,尚未具备列表 Service、Controller、DTO 与真实前端页面。资产人员需要查看企微资料的全部业务字段,并将公司档案、手机号资产、设备、经办人等关联 ID 理解为可读名称。
约束:本次只读主数据源,不修改表结构、不新增编辑删除能力;`delete_time` 延续现有手机号资产规则,仅用于过滤;用户已确认它不应返回或展示。
## Goals / Non-Goals
**Goals:**
- 提供 `#/reference/wecom` 的可筛选、可分页企微资产列表。
- 提供 `GET /api/wecom-accounts`,返回企微字段、创建/更新时间、关联 ID 及关联名称。
-`realNameOwner` 以“企微实名人”展示。
- 对每页记录的关联 ID 批量解析公司名称、手机号、设备名称和经办人名称。
**Non-Goals:**
- 不显示或返回 `deleteTime`,不查询已删除记录。
- 不变更 `as_wecom_account` 或任何关联表的数据库结构与数据。
- 不实现新增、编辑、删除、导入、导出或权限模型。
- 不为不存在或已删除的关联记录自动修复数据。
## Decisions
### 新路由与旧路由
导航与真实页面使用 `#/reference/wecom`,并移除 `#/reference/wechat` 的静态企微参考页。采用直接替换而非保留别名,原因是需求明确要求将 wechat 换为 wecom;继续保留旧页会让用户进入过时且无真实数据的界面。
### 分页与筛选契约
接口使用现有手机号资产的 `page``size` 约定:默认第 1 页、每页 20 条、最大 100 条;支持精确筛选 `wecomName``wecomAccount`。查询显式限定 `delete_time = 0` 并按 `id` 倒序,保证与既有资产列表的正常记录定义一致。
### 关联 ID 与名称
响应同时返回原始 ID 和显示名称:`companyProfileId/companyProfileName``phoneAssetId/phoneNumber``deviceId/deviceName``operatorPersonId/operatorPersonName`。Service 收集当前页各类非空 ID 后,分别以 `IN` 条件批量查询关联表并构建 ID 到名称的映射。
批量查询(一次取得当前页所有关联记录)优于逐行查询,避免一页 20 条企微记录触发数十次数据库访问、导致列表加载变慢。关联 ID 未匹配记录时保留 ID,名称返回 `null`,前端显示“—”,不掩盖数据关系异常。
### 字段暴露与页面展示
响应和表格展示 `id`、企微名称、别名、账号、公司档案 ID/名称、手机号资产 ID/手机号、企微实名人、实名状态、性别、设备 ID/名称、经办人 ID/名称、创建时间和更新时间。`deleteTime` 只在 Service 查询条件中使用,既不写入响应 DTO,也不创建表格列。
## Risks / Trade-offs
- [历史数据的 `delete_time` 为 `NULL`] → 现有手机号列表以 `0` 表示正常,本期保持一致;上线前使用只读查询确认实际数据约定,必要时另行提出兼容变更。
- [关联记录缺失或已删除] → 保留原关联 ID,名称显示“—”,便于定位而不阻塞企微主记录显示。
- [个人信息展示范围扩大] → 当前需求明确展示企微实名人和手机号;后续如引入权限规则,再单独设计脱敏或字段授权。
- [路由书签失效] → 按确认的直接替换执行;发布说明标注新的 `#/reference/wecom` 地址。
## Migration Plan
1. 先添加后端 Controller 测试与前端路由/列表测试,定义新路由和只读响应。
2. 新增 DTO、Service、Controller 及前端列表页面,不改动既有实体、Mapper 和表。
3. 将导航和路由从 wechat 迁至 wecom,并移除企微静态参考测试。
4. 执行后端编译与测试、前端构建及 Playwright;失败时回退本变更的前端/后端代码和路由,不执行数据库回滚。
## Open Questions
- 无;字段展示、关联名称、路由替换和 `deleteTime` 不展示均已确认。
## Why
企微资料当前仅保留静态旧界面参考,无法查看主数据源中 `as_wecom_account` 的真实资产。资产人员还需要在同一列表中理解各关联 ID 代表的公司、手机号、设备和经办人,因此需要提供一个只读、可分页的企微资产列表。
## What Changes
- 新增 `#/reference/wecom` 企微资料列表路由,替代当前静态的 `#/reference/wechat` 参考入口。
- 新增只读分页接口 `GET /api/wecom-accounts`,按企微名称和企微账号筛选 `as_wecom_account` 的正常记录。
- 返回并展示企微账号的业务字段、创建/更新时间,以及关联 ID 对应的名称;保留关联 ID 便于追溯。
- 不返回、不展示 `deleteTime`;后端仅用它过滤已删除记录。
- **BREAKING**`#/reference/wechat` 不再提供旧静态企微参考页面,导航入口改为 `#/reference/wecom`
## Capabilities
### New Capabilities
- `wecom-account-api`: 提供企微账号资产的只读分页查询和关联名称解析。
- `wecom-account-workspace`: 提供企微资料列表页面、筛选、分页和完整字段展示。
### Modified Capabilities
- 无。
## Impact
- 前端:路由、导航、企微列表组件、接口客户端和 Playwright 测试。
- 后端:企微查询 DTO、Controller、Service、响应 DTO 和 Controller 测试;复用既有 `WecomAccountMapper`、公司档案、手机号、设备和人员 Mapper。
- API:新增 `GET /api/wecom-accounts`;不修改数据库结构,不写入或删除任何资产数据。
## ADDED Requirements
### Requirement: 企微资产分页查询
系统 MUST 提供 `GET /api/wecom-accounts`,接收可选的 `page``size``wecomName``wecomAccount` 参数,并以统一的 `code``message``data` 响应结构返回分页结果。
#### Scenario: 默认分页查询
- **WHEN** 客户端未提供分页或筛选参数而请求 `GET /api/wecom-accounts`
- **THEN** 系统返回第 1 页、每页 20 条、按 ID 倒序的正常企微资产记录
#### Scenario: 按企微名称和账号筛选
- **WHEN** 客户端提供非空的 `wecomName``wecomAccount`
- **THEN** 系统仅返回同时满足所提供精确筛选条件的正常企微资产记录
#### Scenario: 非法分页参数
- **WHEN** 客户端提供小于 1 的页码、页大小,或大于 100 的页大小
- **THEN** 系统拒绝请求并返回参数校验失败响应
### Requirement: 正常记录与删除标记隔离
系统 MUST 仅查询 `as_wecom_account.delete_time = 0` 的记录,且响应 `records` 和分页元数据 MUST 不包含 `deleteTime`
#### Scenario: 已删除记录不进入列表
- **WHEN** 数据库存在 `delete_time` 不等于 0 的企微账号记录
- **THEN** 该记录不计入接口的 `records``total`
### Requirement: 企微资产完整业务字段与关联名称
系统 MUST 在每条列表记录中返回 `id``wecomName``wecomAlias``wecomAccount``companyProfileId``phoneAssetId``realNameOwner``realNameOwnerStatus``gender``deviceId``operatorPersonId``createTime``updateTime`,并同时返回关联名称字段。
#### Scenario: 关联 ID 可读化
- **WHEN** 企微资产的公司档案、手机号资产、设备或经办人 ID 可以关联到正常的资产记录
- **THEN** 响应分别包含 `companyProfileName``phoneNumber``deviceName``operatorPersonName`,同时保留原始 ID
#### Scenario: 关联记录缺失
- **WHEN** 企微资产保存了关联 ID 但对应资产记录不存在或无法读取
- **THEN** 响应保留原始 ID,并将对应名称字段返回为 `null`
## ADDED Requirements
### Requirement: 企微资料真实列表路由
系统 MUST 在 `#/reference/wecom` 提供企微资料真实列表页面,并将导航入口指向该路由。
#### Scenario: 打开企微资料页面
- **WHEN** 用户访问 `#/reference/wecom`
- **THEN** 页面请求 `GET /api/wecom-accounts` 并展示返回的企微资产列表
#### Scenario: 旧企微参考路由替换
- **WHEN** 用户通过页面导航进入企微资料
- **THEN** 系统导航至 `#/reference/wecom`,且不展示旧的静态 wechat 参考页面
### Requirement: 企微资产完整字段展示
系统 MUST 在企微资料列表中展示企微资产 ID、企微名称、别名、账号、企微实名人、实名状态、性别、创建时间、更新时间,以及每个关联资源的“名称(ID)”。
#### Scenario: 关联名称与 ID 同时展示
- **WHEN** 列表记录包含公司档案、手机号资产、设备或经办人关联
- **THEN** 页面分别展示公司名称、手机号、设备名称、人员名称及对应 ID
#### Scenario: 关联名称不可用
- **WHEN** 接口返回关联 ID 但关联名称为 `null`
- **THEN** 页面保留该 ID 并以“—”展示名称位置
#### Scenario: 删除标记不展示
- **WHEN** 用户查看企微资料列表
- **THEN** 页面不包含 `deleteTime` 或“删除标记”列
### Requirement: 企微资产筛选与分页
系统 MUST 允许用户以企微名称、企微账号筛选,并使用后端返回的 `total``page``size` 呈现分页。
#### Scenario: 提交筛选条件
- **WHEN** 用户输入企微名称或企微账号并点击查询
- **THEN** 页面以第 1 页请求匹配记录并刷新表格和总数
#### Scenario: 切换页码
- **WHEN** 用户选择另一页
- **THEN** 页面以当前筛选条件和目标页码重新请求列表
## 1. 后端查询契约
- [x] 1.1 新增 `WecomAccountPageQuery.java`(文件用途:接收分页与筛选参数),为 `resolvedPage``resolvedSize` 写新手注释:代码作用(白话)、关联文件、关联逻辑;校验页码与页大小。
- [x] 1.2 新增 `WecomAccountResponse.java`(文件用途:限定企微列表单行返回字段),包含企微业务字段、创建/更新时间、关联 ID 与关联名称,不包含 `deleteTime`
- [x] 1.3 新增 `WecomAccountPageResponse.java`(文件用途:承载列表、总数、页码、页大小),复用当前手机号资产分页的返回结构。
## 2. 后端只读列表实现
- [x] 2.1 新增 `WecomAccountService.java`(文件用途:读取企微资产并解析关联名称),注入既有企微、公司档案、手机号、设备和人员 Mapper。
- [x] 2.2 在 `WecomAccountService` 实现 `page`(文件用途:按筛选、分页和 `delete_time = 0` 查询企微账号)与 `toResponse`(文件用途:将实体及关联名称转换为接口行);为构造方法和两个方法写完整新手注释:代码作用(白话)、关联文件、关联逻辑(调用链/数据流)。
- [x] 2.3 在 `WecomAccountService` 实现批量关联查询逻辑(文件用途:将当前页 ID 转换为名称),为每个辅助方法写完整新手注释;缺失名称返回 `null`,原 ID 保留。
- [x] 2.4 新增 `WecomAccountController.java`(文件用途:提供浏览器查询入口),实现 `GET /api/wecom-accounts`;为构造方法与 `page` 写完整新手注释:代码作用(白话)、关联文件、关联逻辑。
## 3. 前端企微资料页面
- [x] 3.1 新增 `frontend/src/modules/wecom/wecom-api-client.js`(文件用途:封装企微分页请求);实现 `request``listWecomAccounts`,并为每个方法写完整新手注释:代码作用(白话)、关联文件、关联逻辑。
- [x] 3.2 新增 `frontend/src/modules/wecom/WecomAccountView.js`(文件用途:渲染企微资料真实列表);实现 `setup``loadPage``submitSearch``resetSearch``changePage`,并为每个方法及业务回调写完整新手注释。
- [x] 3.3 在 `WecomAccountView.js` 展示企微字段、创建/更新时间,以及“名称(ID)”的关联字段;将 `realNameOwner` 标注为“企微实名人”,名称为空时显示“—”,不创建 `deleteTime` 列。
- [x] 3.4 修改 `frontend/src/router/index.js`(文件用途:管理 Hash 路由)并为新增或修改的方法写完整新手注释;用 `#/reference/wecom` 映射真实列表,移除静态 wechat 路由。
- [x] 3.5 修改 `frontend/src/App.js`(文件用途:提供全局导航)将企微导航入口更新为 `#/reference/wecom` 和真实页面文案;若新增方法或回调,写完整新手注释。
## 4. 自动化验证与文档
- [x] 4.1 新增 `backend/src/test/java/com/xyw/console/asset/controller/WecomAccountControllerTest.java`(文件用途:验证企微分页接口);覆盖默认分页、筛选、删除记录过滤、关联名称和 `deleteTime` 不返回;为测试方法及业务回调写完整新手注释。
- [x] 4.2 新增 `frontend/tests/wecom-account.spec.js`(文件用途:验证真实企微列表路由);覆盖接口请求、筛选参数、字段/关联名称展示、`deleteTime` 不展示和分页;为测试回调写完整新手注释。
- [x] 4.3 修改 `frontend/tests/legacy-reference.spec.js`(文件用途:验证仍保留的静态参考页)仅保留手机号参考页断言;为修改后的测试回调补齐完整新手注释。
- [x] 4.4 修改 `README.md`(文件用途:说明当前重构状态与本地入口)将企微页说明更新为真实列表和新的访问地址。
- [x] 4.5 执行 `mvn -q -DskipTests compile`、企微 Controller 测试、`npm run build` 与 Playwright;记录失败原因并先分析再修复。
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