Commit 83ab85f1 by DaiJiezhang

feat: 企业微信资产支持编辑与删除,并新增关联设备字段

企微此前只能新增。补上编辑和删除后,难点在注册手机号的联动——新增时填一个
库里没有的号码,系统会自动建一条外部号码资产(phoneLinkMode=CREATED)并把它
的 sourceAssetId 指回这条企微。

换号和删除时,那条自动新建的号码要满足三个条件才会被一并软删:
- phoneLinkMode 是 CREATED(复用的已有号码是独立资产,绝不能碰)
- 来源标记对得上,防止误删同号但另有出处的记录
- 没有别的企微、微信、抖音仍在引用它

三道闸门缺一不可。号码没改动时整段联动直接跳过,避免把复用号码误标成新建,
或把还在用的号码当成旧号清理掉。删除确认框会明说自动新建的号码将一并删除。

关联设备:表单加远程搜索选择,新增和编辑都支持,列表也展示。打开弹窗即预载
一页设备,否则用户点开下拉只看到「无数据」,得先猜着打字才有列表。已选设备
不在后端返回的前 20 条里时会被补回列表头部,避免选择框丢掉名称回落成裸 ID。

顺带修复 PhoneAssetValidationException 没有任何 handler 的问题:它会一路落进
GlobalExceptionHandler 的 Exception 兜底,用户填错手机号却收到「服务器处理
失败,请稍后重试」。同样地,接口不存在或请求方式不对此前也被兜底成 500,把
「前后端版本不同步」伪装成线上故障,排查时会往完全错误的方向查;现在分别返回
400、405、404。

列表信息密度:去掉资产 ID 列与关联字段的(ID:n)后缀,别名收进名称下方做
浅灰小字,性别做成名称右侧图标,实名状态做成实名人右上角圆点,关联方式做成
手机号右上角一字角标。注册手机号和关联设备做成链接,跳转到对应资产页并预筛
出该条——为此手机号码管理页和设备资产管理页补上了读取地址栏参数的能力,
否则链接点过去只是一个没筛选的全量列表。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent ceb45728
......@@ -2,6 +2,7 @@ package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.CompanyPersonLookupResponse;
import com.xyw.console.asset.dto.CompanyProfileLookupResponse;
import com.xyw.console.asset.dto.DeviceAssetLookupResponse;
import com.xyw.console.asset.dto.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
......@@ -11,8 +12,11 @@ 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;
......@@ -33,6 +37,14 @@ public class WecomAccountController {
@PostMapping
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { permissions.require(PagePermissionService.WECOM, "EDIT"); return ApiResponse.success("新增成功", service.create(request)); }
/** 代码作用(白话):编辑一条企微资产,改了注册手机号时由 Service 处理重新关联与旧号清理。关联文件:WecomAccountSaveRequest.java、WecomAccountService.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 事务 -> 响应。 */
@PutMapping("/{id}")
public ApiResponse<?> update(@PathVariable Long id, @Valid @RequestBody WecomAccountSaveRequest request) { permissions.require(PagePermissionService.WECOM, "EDIT"); return ApiResponse.success("编辑成功", service.update(id, request)); }
/** 代码作用(白话):软删除一条企微资产,并顺带清理当初为它自动新建、现在没人用的外部号码。关联文件:WecomAccountService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 事务 -> 列表刷新。 */
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) { permissions.require(PagePermissionService.WECOM, "EDIT"); service.softDelete(id); return ApiResponse.success("删除成功", null); }
/** Code purpose (plain language): returns the paged enterprise WeChat asset list and optional registration-phone filter. Related files: WecomAccountPageQuery.java, WecomAccountService.java. Data flow: list query -> GET -> service.page -> table. */
@GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.page(query)); }
......@@ -45,6 +57,10 @@ public class WecomAccountController {
@GetMapping("/lookups/phone-assets")
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchPhoneAssets(keyword)); }
/** 代码作用(白话):按名称搜索可关联的有效设备,供表单下拉远程查询。关联文件:DeviceAssetLookupResponse.java、WecomAccountService.java。关联逻辑(调用链/数据流):下拉输入 -> GET lookup -> 设备选项。 */
@GetMapping("/lookups/devices")
public ApiResponse<List<DeviceAssetLookupResponse>> devices(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchDevices(keyword)); }
/** Code purpose (plain language): searches optional WeCom owners from company people. Related files: CompanyPersonLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-persons")
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
......
package com.xyw.console.asset.controller;
import com.xyw.console.asset.exception.PhoneAssetValidationException;
import com.xyw.console.asset.exception.WecomAccountNotFoundException;
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 = WecomAccountController.class)
public class WecomAccountExceptionHandler {
/** 代码作用(白话):把不存在或已删除的企微记录转为 404。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):Service 查无记录 -> Advice -> ApiResponse -> 前端提示。 */
@ExceptionHandler(WecomAccountNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> notFound(WecomAccountNotFoundException exception) { return ApiResponse.error(404, exception.getMessage()); }
/**
* 代码作用(白话):把注册手机号的格式错误转为 400。
* 关联文件:WecomAccountService.java、PhoneAssetValidationException.java。
* 关联逻辑(调用链/数据流):手机号校验失败 -> Advice -> ApiResponse -> 弹窗提示。
* 这个处理器必须有:该异常此前没有任何 Advice 认领,会一路落进 GlobalExceptionHandler 的 Exception 兜底,
* 用户填错手机号却收到"服务器处理失败,请稍后重试",既看不到真正原因,也让监控分不清用户输错和服务端故障。
*/
@ExceptionHandler(PhoneAssetValidationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> phoneValidation(PhoneAssetValidationException exception) { return ApiResponse.error(400, exception.getMessage()); }
}
......@@ -12,5 +12,6 @@ public record WecomAccountSaveRequest(
String realNameOwner,
String realNameOwnerStatus,
String gender,
Long deviceId,
Long operatorPersonId) {
}
package com.xyw.console.asset.exception;
/** 文件用途(白话):表示要编辑或删除的企业微信资产不存在或已被删除,供 Advice 转成 404。 */
public class WecomAccountNotFoundException extends RuntimeException {
/** 代码作用(白话):携带可直接展示给用户的提示。关联文件:WecomAccountExceptionHandler.java、WecomAccountService.java。关联逻辑(调用链/数据流):Service 查无记录 -> 本异常 -> Advice -> 404 JSON。 */
public WecomAccountNotFoundException(String message) { super(message); }
}
......@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.CompanyPersonLookupResponse;
import com.xyw.console.asset.dto.CompanyProfileLookupResponse;
import com.xyw.console.asset.dto.DeviceAssetLookupResponse;
import com.xyw.console.asset.dto.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
......@@ -11,14 +12,19 @@ import com.xyw.console.asset.dto.WecomAccountResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.exception.PhoneAssetValidationException;
import com.xyw.console.asset.exception.WecomAccountNotFoundException;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.DouyinAccountEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WechatAccountEntity;
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.DouyinAccountMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WechatAccountMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.Collection;
......@@ -38,6 +44,9 @@ public class WecomAccountService {
private final PhoneAssetMapper phoneAssetMapper;
private final AssetDeviceMapper assetDeviceMapper;
private final CompanyPersonMapper companyPersonMapper;
/** 删除或换号时要确认那条自动新建的号码没有被别的资产接手,所以这两张表也得能查。 */
private final WechatAccountMapper wechatAccountMapper;
private final DouyinAccountMapper douyinAccountMapper;
/**
* 代码作用(白话):接收企微和关联资产表的数据库访问入口,供列表查询一次性解析名称。
......@@ -49,12 +58,16 @@ public class WecomAccountService {
CompanyProfileMapper companyProfileMapper,
PhoneAssetMapper phoneAssetMapper,
AssetDeviceMapper assetDeviceMapper,
CompanyPersonMapper companyPersonMapper) {
CompanyPersonMapper companyPersonMapper,
WechatAccountMapper wechatAccountMapper,
DouyinAccountMapper douyinAccountMapper) {
this.wecomAccountMapper = wecomAccountMapper;
this.companyProfileMapper = companyProfileMapper;
this.phoneAssetMapper = phoneAssetMapper;
this.assetDeviceMapper = assetDeviceMapper;
this.companyPersonMapper = companyPersonMapper;
this.wechatAccountMapper = wechatAccountMapper;
this.douyinAccountMapper = douyinAccountMapper;
}
/**
......@@ -66,14 +79,145 @@ public class WecomAccountService {
@Transactional
public WecomAccountResponse create(WecomAccountSaveRequest request) {
requireActiveCompanyProfile(request.companyProfileId());
requireActiveDevice(request.deviceId());
String phoneNumber = normalizePhoneNumber(request.phoneNumber());
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).eq(PhoneAssetEntity::getPhoneNumber, phoneNumber));
boolean created = phone == null;
LocalDateTime now = LocalDateTime.now();
if (created) { phone = new PhoneAssetEntity(); phone.setPhoneNumber(phoneNumber); phone.setNumberType("EXTERNAL"); phone.setSourceAssetType("WECOM"); phone.setDisposalStatus("正常使用"); phone.setCreateTime(now); phone.setUpdateTime(now); phone.setDeleteTime(0L); phone.setLinkedWecomAccounts("[]"); phone.setLinkedWechatAccounts("[]"); phone.setLinkedDouyinAccounts("[]"); phone.setLinkedDomainAccounts("[]"); phone.setLinkedMerchants("[]"); if (phoneAssetMapper.insert(phone) != 1) throw new IllegalStateException("手机号资产新增失败"); }
WecomAccountEntity entity = new WecomAccountEntity(); entity.setWecomName(request.wecomName()); entity.setWecomAlias(request.wecomAlias()); entity.setWecomAccount(request.wecomAccount()); entity.setCompanyProfileId(request.companyProfileId()); entity.setPhoneAssetId(phone.getId()); entity.setPhoneLinkMode(created ? "CREATED" : "EXISTING"); entity.setRealNameOwner(request.realNameOwner()); entity.setRealNameOwnerStatus(request.realNameOwnerStatus() == null ? "在职" : request.realNameOwnerStatus()); entity.setGender(request.gender()); entity.setOperatorPersonId(request.operatorPersonId()); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); if (wecomAccountMapper.insert(entity) != 1) throw new IllegalStateException("企业微信资产新增失败"); if (created) { phone.setSourceAssetId(entity.getId()); phoneAssetMapper.updateById(phone); } return toResponse(entity, Map.of(), Map.of(phone.getId(), phone.getPhoneNumber()), Map.of(), Map.of());
if (created) { phone = new PhoneAssetEntity(); phone.setPhoneNumber(phoneNumber); phone.setNumberType("EXTERNAL"); phone.setSourceAssetType("WECOM"); phone.setDisposalStatus("正常使用"); phone.setCreateTime(now); phone.setUpdateTime(now); phone.setDeleteTime(0L); phone.setLinkedWecomAccounts("[]"); phone.setLinkedWechatAccounts("[]"); phone.setLinkedDouyinAccounts("[]"); phone.setLinkedDomainAccounts("[]"); phone.setLinkedMerchants("[]"); if (phoneAssetMapper.insert(phone) != 1) throw new IllegalStateException("手机号码管理新增失败"); }
WecomAccountEntity entity = new WecomAccountEntity(); applyEditableFields(entity, request); entity.setPhoneAssetId(phone.getId()); entity.setPhoneLinkMode(created ? "CREATED" : "EXISTING"); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); if (wecomAccountMapper.insert(entity) != 1) throw new IllegalStateException("企业微信资产新增失败"); if (created) { phone.setSourceAssetId(entity.getId()); phoneAssetMapper.updateById(phone); } return toResponse(entity, Map.of(), Map.of(phone.getId(), phone.getPhoneNumber()), Map.of(), Map.of());
}
/**
* 代码作用(白话):编辑一条有效企微资产;改了注册手机号时按新号重新关联,并清理当初为它自动新建、现在没人用的旧号码。
* 关联文件:WecomAccountController.java、WecomAccountSaveRequest.java、PhoneAssetEntity.java。
* 关联逻辑(调用链/数据流):PUT 表单 -> 注册主体校验 -> 手机号重新关联 -> 旧号清理 -> 企微更新 -> 响应。
*/
@Transactional
public WecomAccountResponse update(Long id, WecomAccountSaveRequest request) {
WecomAccountEntity entity = requireActiveAccount(id);
requireActiveCompanyProfile(request.companyProfileId());
requireActiveDevice(request.deviceId());
String phoneNumber = normalizePhoneNumber(request.phoneNumber());
Long previousPhoneAssetId = entity.getPhoneAssetId();
String previousLinkMode = entity.getPhoneLinkMode();
LocalDateTime now = LocalDateTime.now();
// 号码没变就不动关联,避免把 EXISTING 误改成 CREATED、或把还在用的号码当成旧号清理掉。
if (!phoneNumber.equals(currentPhoneNumber(previousPhoneAssetId))) {
PhoneAssetEntity phone = attachPhone(phoneNumber, entity.getId(), now);
entity.setPhoneAssetId(phone.getId());
entity.setPhoneLinkMode(isCreatedFor(phone, entity.getId()) ? "CREATED" : "EXISTING");
releaseAutoCreatedPhone(previousPhoneAssetId, previousLinkMode, entity.getId(), now);
}
applyEditableFields(entity, request);
entity.setUpdateTime(now);
if (wecomAccountMapper.updateById(entity) != 1) throw new WecomAccountNotFoundException("企业微信资产不存在或已删除");
return toResponse(entity, Map.of(), phoneNumbers(singleId(entity.getPhoneAssetId())), Map.of(), Map.of());
}
/**
* 代码作用(白话):软删除一条企微资产,并顺带清理当初为它自动新建、现在没人用的外部号码。
* 关联文件:WecomAccountController.java、PhoneAssetEntity.java。
* 关联逻辑(调用链/数据流):DELETE -> 企微 deleteTime -> 自动新建号码判定 -> 一并软删或保留。
*/
@Transactional
public void softDelete(Long id) {
WecomAccountEntity entity = requireActiveAccount(id);
LocalDateTime now = LocalDateTime.now();
entity.setDeleteTime(System.currentTimeMillis());
entity.setUpdateTime(now);
if (wecomAccountMapper.updateById(entity) != 1) throw new WecomAccountNotFoundException("企业微信资产不存在或已删除");
releaseAutoCreatedPhone(entity.getPhoneAssetId(), entity.getPhoneLinkMode(), entity.getId(), now);
}
/** 代码作用(白话):读取一条仍有效的企微资产,供编辑和删除共用。关联文件:WecomAccountMapper.java、WecomAccountExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404。 */
private WecomAccountEntity requireActiveAccount(Long id) {
WecomAccountEntity entity = wecomAccountMapper.selectOne(new LambdaQueryWrapper<WecomAccountEntity>().eq(WecomAccountEntity::getId, id).eq(WecomAccountEntity::getDeleteTime, 0L));
if (entity == null) throw new WecomAccountNotFoundException("企业微信资产不存在或已删除");
return entity;
}
/** 代码作用(白话):读取当前关联手机号的号码文本,用于判断编辑时号码到底有没有变。关联文件:PhoneAssetEntity.java。关联逻辑(调用链/数据流):phoneAssetId -> 号码 -> 与表单比较。 */
private String currentPhoneNumber(Long phoneAssetId) {
if (phoneAssetId == null) return null;
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getId, phoneAssetId).eq(PhoneAssetEntity::getDeleteTime, 0L));
return phone == null ? null : phone.getPhoneNumber();
}
/**
* 代码作用(白话):把一个号码解析成可关联的手机号码管理,库里没有就按外部号码新建一条。
* 关联文件:PhoneAssetEntity.java、PhoneAssetMapper.java。
* 关联逻辑(调用链/数据流):号码 -> 查已有 -> 命中则复用,未命中则新建 EXTERNAL/WECOM 记录。
*/
private PhoneAssetEntity attachPhone(String phoneNumber, Long wecomAccountId, LocalDateTime now) {
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).eq(PhoneAssetEntity::getPhoneNumber, phoneNumber));
if (phone != null) return phone;
phone = new PhoneAssetEntity();
phone.setPhoneNumber(phoneNumber); phone.setNumberType("EXTERNAL"); phone.setSourceAssetType("WECOM"); phone.setSourceAssetId(wecomAccountId); phone.setDisposalStatus("正常使用");
phone.setCreateTime(now); phone.setUpdateTime(now); phone.setDeleteTime(0L);
phone.setLinkedWecomAccounts("[]"); phone.setLinkedWechatAccounts("[]"); phone.setLinkedDouyinAccounts("[]"); phone.setLinkedDomainAccounts("[]"); phone.setLinkedMerchants("[]");
if (phoneAssetMapper.insert(phone) != 1) throw new IllegalStateException("手机号码管理新增失败");
return phone;
}
/**
* 代码作用(白话):判断一条手机号码管理是不是当初专门为这条企微自动新建的外部号码。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):手机号码管理 -> 来源标记比对 -> 是否可随企微一起清理。
*/
private boolean isCreatedFor(PhoneAssetEntity phone, Long wecomAccountId) {
return phone != null && "EXTERNAL".equals(phone.getNumberType()) && "WECOM".equals(phone.getSourceAssetType()) && wecomAccountId.equals(phone.getSourceAssetId());
}
/**
* 代码作用(白话):换号或删除企微后,把当初为它自动新建、现在已经没人引用的外部号码一并软删。
* 关联文件:PhoneAssetEntity.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):旧 phoneAssetId -> CREATED 与来源校验 -> 其余引用计数 -> 软删或保留。
*
* 三道闸门缺一不可:只有本企微自己创建的号码才轮得到清理(复用的已有号码是独立资产,绝不能碰);
* 来源标记要对得上,防止误删同名但另有出处的号码;最后还要确认没有别的企微、微信、抖音仍在引用它。
*/
private void releaseAutoCreatedPhone(Long phoneAssetId, String linkMode, Long wecomAccountId, LocalDateTime now) {
if (phoneAssetId == null || !"CREATED".equals(linkMode)) return;
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getId, phoneAssetId).eq(PhoneAssetEntity::getDeleteTime, 0L));
if (!isCreatedFor(phone, wecomAccountId) || isPhoneStillReferenced(phoneAssetId, wecomAccountId)) return;
phone.setDeleteTime(System.currentTimeMillis());
phone.setUpdateTime(now);
phoneAssetMapper.updateById(phone);
}
/** 代码作用(白话):确认除本企微之外,还有没有别的有效资产在用这个手机号。关联文件:WecomAccountMapper.java、WechatAccountMapper.java、DouyinAccountMapper.java。关联逻辑(调用链/数据流):phoneAssetId -> 三张表引用计数 -> 是否允许清理。 */
private boolean isPhoneStillReferenced(Long phoneAssetId, Long excludedWecomAccountId) {
if (wecomAccountMapper.selectCount(new LambdaQueryWrapper<WecomAccountEntity>().eq(WecomAccountEntity::getPhoneAssetId, phoneAssetId).eq(WecomAccountEntity::getDeleteTime, 0L).ne(WecomAccountEntity::getId, excludedWecomAccountId)) > 0) return true;
if (wechatAccountMapper.selectCount(new LambdaQueryWrapper<WechatAccountEntity>().eq(WechatAccountEntity::getPhoneAssetId, phoneAssetId).eq(WechatAccountEntity::getDeleteTime, 0L)) > 0) return true;
return douyinAccountMapper.selectCount(new LambdaQueryWrapper<DouyinAccountEntity>().eq(DouyinAccountEntity::getPhoneAssetId, phoneAssetId).eq(DouyinAccountEntity::getDeleteTime, 0L)) > 0;
}
/** 代码作用(白话):把表单允许提交的字段写入企微实体,新增和编辑共用同一套赋值。关联文件:WecomAccountSaveRequest.java、WecomAccountEntity.java。关联逻辑(调用链/数据流):弹窗表单 -> DTO -> Entity -> Mapper 保存。 */
private void applyEditableFields(WecomAccountEntity entity, WecomAccountSaveRequest request) {
entity.setWecomName(request.wecomName()); entity.setWecomAlias(request.wecomAlias()); entity.setWecomAccount(request.wecomAccount());
entity.setCompanyProfileId(request.companyProfileId()); entity.setRealNameOwner(request.realNameOwner());
entity.setRealNameOwnerStatus(request.realNameOwnerStatus() == null ? "在职" : request.realNameOwnerStatus());
entity.setGender(request.gender()); entity.setDeviceId(request.deviceId()); entity.setOperatorPersonId(request.operatorPersonId());
}
/** 代码作用(白话):确认选中的关联设备仍然有效,阻止伪造或已删除的设备 ID 写入。关联文件:AssetDeviceMapper.java、WecomAccountSaveRequest.java。关联逻辑(调用链/数据流):表单 deviceId -> 有效性查询 -> 参数错误或继续保存。 */
private void requireActiveDevice(Long deviceId) {
if (deviceId == null) return;
if (assetDeviceMapper.selectOne(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getId, deviceId).eq(AssetDeviceEntity::getDeleteTime, 0L)) == null) {
throw new IllegalArgumentException("关联设备不存在或已删除");
}
}
/** 代码作用(白话):按名称搜索可关联的有效设备,供表单下拉远程查询。关联文件:WecomAccountController.java、AssetDeviceEntity.java。关联逻辑(调用链/数据流):下拉输入 -> GET lookup -> 设备选项。 */
public List<DeviceAssetLookupResponse> searchDevices(String keyword) {
LambdaQueryWrapper<AssetDeviceEntity> query = new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime, 0L).like(hasText(keyword), AssetDeviceEntity::getDeviceName, keyword).orderByDesc(AssetDeviceEntity::getId);
return assetDeviceMapper.selectList(query).stream().limit(20).map(item -> new DeviceAssetLookupResponse(item.getId(), item.getDeviceName())).toList();
}
/** 代码作用(白话):把单个可空 ID 转成批量查询需要的集合形状。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):单条结果 -> ID 集合 -> 名称查询。 */
private Set<Long> singleId(Long id) { return id == null ? Set.of() : Set.of(id); }
/** Code purpose (plain language): finds active company profiles for the registration-subject selector. Related files: WecomAccountController.java, CompanyProfileEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */
public List<CompanyProfileLookupResponse> searchCompanyProfiles(String keyword) {
LambdaQueryWrapper<CompanyProfileEntity> query = new LambdaQueryWrapper<CompanyProfileEntity>().eq(CompanyProfileEntity::getDeleteTime, 0L);
......@@ -151,7 +295,7 @@ public class WecomAccountService {
return wrapper.orderByDesc(WecomAccountEntity::getId);
}
/** 代码作用(白话):找出手机号包含统一搜索词的有效手机号资产 ID。关联文件:PhoneAssetEntity.java、WecomAccountPageQuery.java。关联逻辑(调用链/数据流):统一搜索词 -> 手机号模糊查询 -> 企业微信关联手机号条件。 */
/** 代码作用(白话):找出手机号包含统一搜索词的有效手机号码管理 ID。关联文件:PhoneAssetEntity.java、WecomAccountPageQuery.java。关联逻辑(调用链/数据流):统一搜索词 -> 手机号模糊查询 -> 企业微信关联手机号条件。 */
private Set<Long> matchingPhoneAssetIds(String keyword) {
return phoneAssetMapper.selectList(new LambdaQueryWrapper<PhoneAssetEntity>()
.eq(PhoneAssetEntity::getDeleteTime, 0L)
......@@ -194,7 +338,7 @@ public class WecomAccountService {
}
/**
* 代码作用(白话):批量读取手机号资产号码,供手机号资产 ID 在页面显示为手机号(ID)。
* 代码作用(白话):批量读取手机号码管理号码,供手机号码管理 ID 在页面显示为手机号(ID)。
* 关联文件:PhoneAssetEntity.java、PhoneAssetMapper.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):phoneAssetId 集合 -> as_phone_asset -> phoneNumber。
*/
......
......@@ -6,10 +6,12 @@ import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/** 文件用途(白话):把未被业务模块处理的数据库和服务器异常统一转换为页面可展示的 JSON 错误,避免浏览器把非认证故障误认为未登录。 */
@RestControllerAdvice
......@@ -52,6 +54,27 @@ public class GlobalExceptionHandler {
return ApiResponse.error(400, "请求格式不正确,请刷新页面后重试");
}
/**
* 代码作用(白话):请求打到了不支持该方法的接口时返回 405,而不是让它冒充服务器故障。
* 关联文件:各 Controller、前端 API 客户端。
* 关联逻辑(调用链/数据流):路由无匹配方法 -> 本方法 -> 405 JSON -> 前端提示接口不可用。
*
* 修复要点:此前没有本处理器,前后端版本不同步(后端没重启、新接口还没加载)时浏览器只会收到
* "服务器处理失败,请稍后重试",把一眼可辨的"接口还不存在"伪装成线上故障,排查时会往完全错误的方向查。
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ApiResponse<Void> methodNotSupported(HttpRequestMethodNotSupportedException error) {
return ApiResponse.error(405, "接口不支持该请求方式,请确认前后端版本一致");
}
/** 代码作用(白话):请求打到不存在的路径时返回 404,同样避免被当成服务器故障;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):无匹配路由 -> 本方法 -> 404 JSON -> 前端提示。 */
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> noResource(NoResourceFoundException error) {
return ApiResponse.error(404, "接口不存在,请确认前后端版本一致");
}
/** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
......
......@@ -48,6 +48,24 @@ class WecomAccountControllerTest {
}
/**
* 代码作用(白话):证明编辑和删除都必须先通过企微页的 EDIT 权限,只读账号无法绕过侧边栏直接调接口。
* 关联文件:WecomAccountController.java、PagePermissionService.java。
* 关联逻辑(调用链/数据流):PUT/DELETE -> require(WECOM, EDIT) -> 拒绝时业务服务完全不被触碰。
*/
@Test
void blocksEditAndDeleteWithoutEditPermission() {
WecomAccountService service = org.mockito.Mockito.mock(WecomAccountService.class);
PagePermissionService permissions = org.mockito.Mockito.mock(PagePermissionService.class);
org.mockito.Mockito.doThrow(new org.springframework.security.access.AccessDeniedException("无权限"))
.when(permissions).require(PagePermissionService.WECOM, "EDIT");
WecomAccountController controller = new WecomAccountController(service, permissions);
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.security.access.AccessDeniedException.class,
() -> controller.update(1L, new com.xyw.console.asset.dto.WecomAccountSaveRequest("名称", null, null, null, "13812345678", null, null, null, null, null)));
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.security.access.AccessDeniedException.class, () -> controller.delete(1L));
org.mockito.Mockito.verifyNoInteractions(service);
}
/**
* 代码作用(白话):构造使用真实 Service 的 Controller,避免只验证模拟返回值。
* 关联文件:WecomAccountController.java、WecomAccountService.java、各资产 Mapper。
* 关联逻辑(调用链/数据流):测试 HTTP 请求 -> Controller -> Service -> Mapper 模拟数据库结果。
......@@ -67,7 +85,9 @@ class WecomAccountControllerTest {
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device()));
org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper), org.mockito.Mockito.mock(PagePermissionService.class));
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper,
org.mockito.Mockito.mock(com.xyw.console.asset.mapper.WechatAccountMapper.class),
org.mockito.Mockito.mock(com.xyw.console.asset.mapper.DouyinAccountMapper.class)), org.mockito.Mockito.mock(PagePermissionService.class));
}
/**
......@@ -108,9 +128,9 @@ class WecomAccountControllerTest {
}
/**
* 代码作用(白话):构造手机号资产关联测试数据。
* 代码作用(白话):构造手机号码管理关联测试数据。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):手机号资产 ID -> 手机号 -> JSON 字段 phoneNumber。
* 关联逻辑(调用链/数据流):手机号码管理 ID -> 手机号 -> JSON 字段 phoneNumber。
*/
private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity();
......
......@@ -3,8 +3,10 @@ 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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
......@@ -18,17 +20,31 @@ 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.exception.WecomAccountNotFoundException;
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.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WechatAccountMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class WecomAccountServiceTest {
/** 代码作用(白话):构造只关心当前场景的企微服务,微信和抖音引用检查默认为"没人在用"。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):测试 -> Mock Mapper -> Service 规则。 */
private WecomAccountService service(WecomAccountMapper wecom, CompanyProfileMapper company, PhoneAssetMapper phone, AssetDeviceMapper device, CompanyPersonMapper person) {
return service(wecom, company, phone, device, person, mock(WechatAccountMapper.class), mock(DouyinAccountMapper.class));
}
/** 代码作用(白话):在需要模拟"号码仍被别的资产引用"时,允许测试自己接管微信和抖音的计数。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):引用计数 -> 旧号是否可清理。 */
private WecomAccountService service(WecomAccountMapper wecom, CompanyProfileMapper company, PhoneAssetMapper phone, AssetDeviceMapper device, CompanyPersonMapper person, WechatAccountMapper wechat, DouyinAccountMapper douyin) {
return new WecomAccountService(wecom, company, phone, device, person, wechat, douyin);
}
/**
* 代码作用(白话):证明企微列表会把当前页关联 ID 转成可读名称,同时保留 ID 且不产生删除标记字段。
* 关联文件:WecomAccountService.java、WecomAccountResponse.java、WecomAccountMapper.java。
......@@ -47,8 +63,7 @@ class WecomAccountServiceTest {
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)
WecomAccountPageResponse result = service(wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(null, null, null, null, null, null, null));
WecomAccountResponse record = result.records().get(0);
......@@ -84,8 +99,7 @@ class WecomAccountServiceTest {
when(assetDeviceMapper.selectList(any())).thenReturn(List.of());
when(companyPersonMapper.selectList(any())).thenReturn(List.of());
WecomAccountResponse record = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
WecomAccountResponse record = service(wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(1, 20, null, null, null, null, null)).records().get(0);
assertEquals(10L, record.companyProfileId());
......@@ -139,7 +153,7 @@ class WecomAccountServiceTest {
}
/**
* 代码作用(白话):构造手机号资产名称,验证手机号资产 ID 能转成手机号。
* 代码作用(白话):构造手机号码管理名称,验证手机号码管理 ID 能转成手机号。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):phoneAssetId -> PhoneAssetMapper -> phoneNumber。
*/
......@@ -179,7 +193,7 @@ class WecomAccountServiceTest {
void createsWithAnExistingPhoneAsset() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
PhoneAssetEntity phone = phoneAsset(); when(phoneMapper.selectOne(any())).thenReturn(phone); when(wecomMapper.insert(any(WecomAccountEntity.class))).thenReturn(1);
WecomAccountResponse result = new WecomAccountService(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", "别名", null, null, "13812345678", null, null, null, null));
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", "别名", null, null, "13812345678", null, null, null, null, null));
assertEquals(20L, result.phoneAssetId()); assertEquals("EXISTING", result.phoneLinkMode());
}
......@@ -188,7 +202,7 @@ class WecomAccountServiceTest {
void createsAnExternalPhoneAssetForANewNumber() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(phoneMapper.selectOne(any())).thenReturn(null); when(phoneMapper.insert(any(PhoneAssetEntity.class))).thenAnswer(call -> { ((PhoneAssetEntity) call.getArgument(0)).setId(70L); return 1; }); when(wecomMapper.insert(any(WecomAccountEntity.class))).thenAnswer(call -> { ((WecomAccountEntity) call.getArgument(0)).setId(80L); return 1; });
WecomAccountResponse result = new WecomAccountService(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", null, null, null, "13912345678", null, null, null, null));
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", null, null, null, "13912345678", null, null, null, null, null));
assertEquals("CREATED", result.phoneLinkMode()); verify(phoneMapper).updateById(any(PhoneAssetEntity.class));
}
......@@ -204,12 +218,151 @@ class WecomAccountServiceTest {
PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(companyProfileMapper.selectOne(any())).thenReturn(null);
WecomAccountService service = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class));
WecomAccountService service = service(wecomMapper, companyProfileMapper, phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class));
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.create(
new WecomAccountSaveRequest("测试企微", null, null, 99L, "13812345678", null, null, null, null)));
new WecomAccountSaveRequest("测试企微", null, null, 99L, "13812345678", null, null, null, null, null)));
assertEquals("注册主体不存在或已删除", error.getMessage());
}
/** 代码作用(白话):换成一个库里已有的号码时,应该直接关联它并标记为复用。关联文件:WecomAccountService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):PUT 新号 -> 命中已有资产 -> EXISTING。 */
@Test
void updateLinksAnExistingNumberAsAReusedPhone() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
// 依次是:查旧号(判断号码变没变)、按新号码查已有资产、清理旧号时再查一次旧号。
when(phoneMapper.selectOne(any())).thenReturn(phoneAsset(), existingPhone(90L, "13912345678"), null);
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class))
.update(5L, saveRequest("13912345678"));
assertEquals(90L, result.phoneAssetId()); assertEquals("EXISTING", result.phoneLinkMode());
verify(phoneMapper, never()).insert(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):换成库里没有的号码时,应该新建一条外部号码并标记为新建。关联文件:WecomAccountService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):PUT 新号 -> 查无资产 -> 新建 EXTERNAL -> CREATED。 */
@Test
void updateCreatesAnExternalPhoneForAnUnknownNumber() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "EXISTING"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(phoneMapper.selectOne(any())).thenReturn(phoneAsset(), null, null);
when(phoneMapper.insert(any(PhoneAssetEntity.class))).thenAnswer(call -> { ((PhoneAssetEntity) call.getArgument(0)).setId(70L); return 1; });
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class))
.update(5L, saveRequest("13912345678"));
assertEquals(70L, result.phoneAssetId()); assertEquals("CREATED", result.phoneLinkMode());
}
/** 代码作用(白话):换号后,当初专门为这条企微新建的旧号码应该被一并软删,不留孤儿外部号码。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):旧号 CREATED + 来源匹配 + 无人引用 -> deleteTime 置位。 */
@Test
void updateSoftDeletesThePhoneItCreatedEarlier() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
PhoneAssetEntity autoCreated = autoCreatedPhone(20L, "13812345678", 5L);
when(phoneMapper.selectOne(any())).thenReturn(autoCreated, existingPhone(90L, "13912345678"), autoCreated);
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).update(5L, saveRequest("13912345678"));
ArgumentCaptor<PhoneAssetEntity> saved = ArgumentCaptor.forClass(PhoneAssetEntity.class);
verify(phoneMapper).updateById(saved.capture());
assertEquals(20L, saved.getValue().getId());
assertTrue(saved.getValue().getDeleteTime() > 0L, "自动新建的旧号码应被软删");
}
/** 代码作用(白话):换号后,当初复用的已有号码是独立资产,绝不能被顺手删掉。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):旧号 EXISTING -> 跳过清理。 */
@Test
void updateKeepsAReusedPhoneAssetUntouched() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "EXISTING"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(phoneMapper.selectOne(any())).thenReturn(phoneAsset(), existingPhone(90L, "13912345678"), null);
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).update(5L, saveRequest("13912345678"));
verify(phoneMapper, never()).updateById(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):旧号码若仍被微信资产引用,即使是自动新建的也必须保留。关联文件:WecomAccountService.java、WechatAccountMapper.java。关联逻辑(调用链/数据流):引用计数 > 0 -> 跳过清理。 */
@Test
void updateKeepsThePhoneWhenAnotherAssetStillReferencesIt() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class); WechatAccountMapper wechatMapper = mock(WechatAccountMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(wechatMapper.selectCount(any())).thenReturn(1L);
PhoneAssetEntity autoCreated = autoCreatedPhone(20L, "13812345678", 5L);
when(phoneMapper.selectOne(any())).thenReturn(autoCreated, existingPhone(90L, "13912345678"), autoCreated);
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class), wechatMapper, mock(DouyinAccountMapper.class))
.update(5L, saveRequest("13912345678"));
verify(phoneMapper, never()).updateById(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):号码没改动时不应触碰任何关联,避免把复用号码误标成新建或误删还在用的号码。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):号码相同 -> 跳过整段联动。 */
@Test
void updateSkipsPhoneLinkageWhenTheNumberIsUnchanged() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(phoneMapper.selectOne(any())).thenReturn(phoneAsset());
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class))
.update(5L, saveRequest("13812345678"));
assertEquals(20L, result.phoneAssetId()); assertEquals("CREATED", result.phoneLinkMode());
verify(phoneMapper, never()).insert(any(PhoneAssetEntity.class));
verify(phoneMapper, never()).updateById(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):删除企微时,当初为它自动新建的号码应一并软删。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):DELETE -> 企微 deleteTime -> 号码 deleteTime。 */
@Test
void softDeleteAlsoRemovesTheAutoCreatedPhone() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(phoneMapper.selectOne(any())).thenReturn(autoCreatedPhone(20L, "13812345678", 5L));
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).softDelete(5L);
ArgumentCaptor<PhoneAssetEntity> saved = ArgumentCaptor.forClass(PhoneAssetEntity.class);
verify(phoneMapper).updateById(saved.capture());
assertTrue(saved.getValue().getDeleteTime() > 0L, "自动新建的号码应随企微一起软删");
}
/** 代码作用(白话):删除企微时,复用的已有号码必须原样保留。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):DELETE + EXISTING -> 号码不动。 */
@Test
void softDeleteKeepsAReusedPhoneAsset() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "EXISTING"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).softDelete(5L);
verify(phoneMapper, never()).updateById(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):编辑或删除不存在的企微应返回 404 语义的异常,而不是静默成功。关联文件:WecomAccountExceptionHandler.java。关联逻辑(调用链/数据流):查无记录 -> WecomAccountNotFoundException -> 404。 */
@Test
void rejectsEditingOrDeletingAMissingWecomAccount() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(null);
WecomAccountService service = service(wecomMapper, mock(CompanyProfileMapper.class), mock(PhoneAssetMapper.class), mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class));
assertThrows(WecomAccountNotFoundException.class, () -> service.update(404L, saveRequest("13812345678")));
assertThrows(WecomAccountNotFoundException.class, () -> service.softDelete(404L));
}
/** 代码作用(白话):构造一条仍有效的企微记录,供编辑和删除场景复用。关联文件:WecomAccountEntity.java。关联逻辑(调用链/数据流):Mapper.selectOne -> requireActiveAccount -> 业务流程。 */
private WecomAccountEntity activeAccount(Long id, Long phoneAssetId, String linkMode) {
WecomAccountEntity entity = new WecomAccountEntity();
entity.setId(id); entity.setWecomName("原名称"); entity.setPhoneAssetId(phoneAssetId); entity.setPhoneLinkMode(linkMode); entity.setDeleteTime(0L);
return entity;
}
/** 代码作用(白话):构造一条与任何企微都无来源关系的普通手机号码管理。关联文件:PhoneAssetEntity.java。关联逻辑(调用链/数据流):号码查询 -> 复用为 EXISTING。 */
private PhoneAssetEntity existingPhone(Long id, String phoneNumber) {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(id); entity.setPhoneNumber(phoneNumber); entity.setNumberType("SELF"); entity.setDeleteTime(0L);
return entity;
}
/** 代码作用(白话):构造一条当初由指定企微自动新建的外部号码。关联文件:PhoneAssetEntity.java。关联逻辑(调用链/数据流):来源标记 -> isCreatedFor 判定 -> 可清理。 */
private PhoneAssetEntity autoCreatedPhone(Long id, String phoneNumber, Long sourceWecomAccountId) {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(id); entity.setPhoneNumber(phoneNumber); entity.setNumberType("EXTERNAL"); entity.setSourceAssetType("WECOM"); entity.setSourceAssetId(sourceWecomAccountId); entity.setDeleteTime(0L);
return entity;
}
/** 代码作用(白话):构造一份只关心手机号的编辑表单。关联文件:WecomAccountSaveRequest.java。关联逻辑(调用链/数据流):测试 -> DTO -> Service.update。 */
private WecomAccountSaveRequest saveRequest(String phoneNumber) {
return new WecomAccountSaveRequest("编辑后的企微", "别名", "account", null, phoneNumber, "张三", "在职", "男", null, null);
}
}
......@@ -6,7 +6,10 @@ import java.sql.SQLIntegrityConstraintViolationException;
import java.sql.SQLSyntaxErrorException;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpMethod;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/** 文件用途(白话):验证未处理的数据库异常会被转换成页面可以直接展示的统一错误消息。 */
class GlobalExceptionHandlerTest {
......@@ -27,4 +30,22 @@ class GlobalExceptionHandlerTest {
assertEquals(500, response.code());
assertEquals("数据库字段未同步,请完成数据库迁移后重试", response.message());
}
/**
* 代码作用(白话):证明"接口还不存在"会返回 405 而不是 500,避免把前后端版本不同步当成服务器故障排查。
* 关联文件:GlobalExceptionHandler.java、各 Controller。
* 关联逻辑(调用链/数据流):无匹配请求方式 -> Advice -> 405 JSON -> 前端提示版本不一致。
*/
@Test void unsupportedMethodReturnsMethodNotAllowed() {
ApiResponse<Void> response = new GlobalExceptionHandler().methodNotSupported(new HttpRequestMethodNotSupportedException("PUT"));
assertEquals(405, response.code());
assertEquals("接口不支持该请求方式,请确认前后端版本一致", response.message());
}
/** 代码作用(白话):证明不存在的路径返回 404 而不是 500。关联文件:GlobalExceptionHandler.java。关联逻辑(调用链/数据流):无匹配路由 -> Advice -> 404 JSON -> 前端提示。 */
@Test void missingRouteReturnsNotFound() {
ApiResponse<Void> response = new GlobalExceptionHandler().noResource(new NoResourceFoundException(HttpMethod.GET, "/api/does-not-exist"));
assertEquals(404, response.code());
assertEquals("接口不存在,请确认前后端版本一致", response.message());
}
}
import { computed, nextTick, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchPhoneAssets } from './wecom-api-client.js';
import { createWecomAccount, deleteWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchDevices, searchPhoneAssets, updateWecomAccount } from './wecom-api-client.js';
/** File purpose (plain language): renders the enterprise WeChat asset list and its creation dialog with reusable asset searches. */
export default {
......@@ -10,15 +10,18 @@ export default {
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
/** null 表示新增态,有值表示正在编辑那一条;弹窗标题、保存走的接口都看它。 */
const editingId = ref(null);
const records = ref([]);
const total = ref(0);
const companyOptions = ref([]);
const ownerOptions = ref([]);
const deviceOptions = ref([]);
/** 代码作用(白话):把企微页面的有效权限转换为新增按钮和弹窗是否可用;关联文件:auth-store.js、WecomAccountController.java。关联逻辑(调用链/数据流):认证资料 -> EDIT 判断 -> 写操作控件 -> 后端 EDIT 校验。 */
const canEdit = computed(() => authState.user?.pagePermissions?.['reference-wecom'] === 'EDIT');
let searchTimer;
const filters = reactive({ page: 1, size: 20, keyword: '', wecomAccount: '', phoneAssetId: new URLSearchParams(window.location.hash.split('?')[1] || '').get('phoneAssetId') || '', companyProfileId: 'ALL', realNameOwnerStatus: 'ALL' });
const form = reactive({ wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null });
const form = reactive({ wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', deviceId: null, operatorPersonId: null });
/** Code purpose (plain language): reloads the table using the active filters. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: page action -> GET -> records/total -> table. */
async function loadPage() {
......@@ -28,7 +31,7 @@ export default {
}
/** Code purpose (plain language): resets the create form to the business defaults. Related files: WecomAccountSaveRequest.java. Data flow: add button -> reset -> dialog form. */
function resetForm() { Object.assign(form, { wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null }); }
function resetForm() { Object.assign(form, { wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', deviceId: null, operatorPersonId: null }); }
/**
* 代码作用(白话):在统一资产弹窗打开后把正文滚动位置恢复到顶部,保证标题、表单起点和底部操作区的使用体验一致。
......@@ -42,18 +45,64 @@ export default {
}
/** Code purpose (plain language): opens a clean creation dialog. Related files: WecomAccountView.js. Data flow: add button -> resetForm -> dialog visible. */
function openCreate() { resetForm(); dialogVisible.value = true; }
/** 打开弹窗就先拉一页设备:远程下拉不预载的话,用户点开只看到"无数据",得先猜着打字才有列表。 */
function openCreate() { editingId.value = null; resetForm(); deviceOptions.value = []; loadDevices(''); dialogVisible.value = true; }
/** 代码作用(白话):用当前表格行回填同一个弹窗进入编辑态。关联文件:WecomAccountResponse.java、wecom-api-client.js。关联逻辑(调用链/数据流):点击编辑 -> row -> form -> PUT 保存。 */
function openEdit(row) {
editingId.value = row.id;
Object.assign(form, {
wecomName: row.wecomName || '', wecomAlias: row.wecomAlias || '', wecomAccount: row.wecomAccount || '',
companyProfileId: row.companyProfileId ?? null, phoneNumber: row.phoneNumber || '',
realNameOwner: row.realNameOwner || '', realNameOwnerStatus: row.realNameOwnerStatus || '在职',
gender: row.gender || '', deviceId: row.deviceId ?? null, operatorPersonId: row.operatorPersonId ?? null
});
// 先用行数据兜底一条选项:三个远程下拉在还没搜索前会空白,回退显示当前值而不是让人以为没关联。
// 名称取不到时退回显示 ID(关联资产被软删就属于这种情况),也比一片空白好判断。
companyOptions.value = row.companyProfileId ? [{ id: row.companyProfileId, companyName: row.companyProfileName || `主体 ${row.companyProfileId}`, shortName: row.companyProfileName }] : [];
ownerOptions.value = row.operatorPersonId ? [{ id: row.operatorPersonId, personName: row.operatorPersonName || `人员 ${row.operatorPersonId}` }] : [];
deviceOptions.value = row.deviceId ? [{ id: row.deviceId, deviceName: row.deviceName || `设备 ${row.deviceId}` }] : [];
loadDevices('');
dialogVisible.value = true;
}
/**
* 代码作用(白话):确认后删除一条企微资产,若它当初自动新建过手机号,提前把这件事说清楚。
* 关联文件:wecom-api-client.js、WecomAccountService.java。
* 关联逻辑(调用链/数据流):删除按钮 -> 确认框 -> DELETE -> 号码联动清理 -> loadPage。
*/
async function confirmDelete(row) {
const extra = row.phoneLinkMode === 'CREATED' ? `\n注册手机号 ${row.phoneNumber || ''} 是新增这条企微时自动创建的,若没有其他资产在用,会一并删除。` : '';
try {
await ElMessageBox.confirm(`确认删除企微「${row.wecomName}」吗?${extra}`, '删除确认', { type: 'warning' });
await deleteWecomAccount(row.id);
ElMessage.success('删除成功');
if (records.value.length === 1 && filters.page > 1) filters.page -= 1;
await loadPage();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message);
}
}
/** 代码作用(白话):把注册手机号实时限制为最多 11 位数字,输入非数字或粘贴混合文字时自动清理;关联文件:PhoneAssetView.js、WecomAccountSaveRequest.java;关联逻辑(调用链/数据流):手机号输入框 -> 本方法 -> form.phoneNumber -> 保存请求 -> 后端格式校验。 */
function limitPhoneNumber(value) { form.phoneNumber = String(value || '').replace(/\D/g, '').slice(0, 11); }
/** Code purpose (plain language): saves a new asset and refreshes the first page. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: dialog submit -> POST -> transaction -> table refresh. */
/** Code purpose (plain language): saves the dialog as a create or an edit and refreshes the list. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: dialog submit -> POST/PUT -> transaction -> table refresh. */
async function submitCreate() {
limitPhoneNumber(form.phoneNumber);
if (!form.wecomName.trim() || !form.phoneNumber) { ElMessage.warning('请填写企微名称和注册手机号'); return; }
if (form.phoneNumber.length !== 11) { ElMessage.warning('手机号必须是 11 位数字'); return; }
saving.value = true;
try { await createWecomAccount({ ...form, wecomName: form.wecomName.trim(), phoneNumber: form.phoneNumber }); ElMessage.success('新增成功'); dialogVisible.value = false; filters.page = 1; await loadPage(); } catch (error) { ElMessage.error(error.message); } finally { saving.value = false; }
const payload = { ...form, wecomName: form.wecomName.trim(), phoneNumber: form.phoneNumber };
try {
if (editingId.value === null) await createWecomAccount(payload);
else await updateWecomAccount(editingId.value, payload);
ElMessage.success(editingId.value === null ? '新增成功' : '编辑成功');
dialogVisible.value = false;
// 编辑保持当前页,新增才跳回第一页:改完一条却被弹回首页会让人找不到刚才那行。
if (editingId.value === null) filters.page = 1;
await loadPage();
} catch (error) { ElMessage.error(error.message); } finally { saving.value = false; }
}
/** Code purpose (plain language): supplies existing phone assets to the autocomplete while leaving a typed new number usable. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: phone input -> lookup -> autocomplete suggestions. */
......@@ -67,6 +116,20 @@ export default {
/** Code purpose (plain language): looks up possible WeCom owners. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: remote select -> lookup -> option list. */
async function loadOwners(keyword) { ownerOptions.value = await searchCompanyPersons(keyword); }
/**
* 代码作用(白话):按设备名称拉取可关联的设备选项,并保留当前已选设备。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):下拉输入 -> GET lookups/devices -> deviceOptions -> 下拉列表。
* 后端只返回前 20 条,已选设备可能不在结果里;直接覆盖会让选择框丢掉名称回落成裸 ID,所以把它补回列表头部。
*/
async function loadDevices(keyword) {
try {
const options = await searchDevices(keyword);
const selected = deviceOptions.value.find(item => item.id === form.deviceId);
deviceOptions.value = selected && !options.some(item => item.id === selected.id) ? [selected, ...options] : options;
} catch (error) { ElMessage.error(error.message); }
}
/** Code purpose (plain language): waits briefly for typing to stop before querying unified WeCom-name-or-phone keywords. Related files: WecomAccountView.js, WecomAccountService.java. Data flow: input -> 300ms timer -> submitSearch -> filtered table. */
function scheduleSearch() { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(submitSearch, 300); }
......@@ -88,18 +151,35 @@ export default {
/** Code purpose (plain language): applies a new page size and returns to the first page. Related files: WecomAccountView.js, AppPagination.js. Data flow: pagination -> filters.size/page -> loadPage. */
function changePageSize(size) { filters.size = size; filters.page = 1; loadPage(); }
/** Code purpose (plain language): formats an optional referenced ID for table display. Related files: WecomAccountResponse.java. Data flow: API record -> formatter -> table cell. */
function formatRelation(name, id) { return id === null || id === undefined ? '—' : `${name || '—'}(ID:${id})`; }
/**
* Code purpose (plain language): shows only the readable name of a referenced asset. Related files: WecomAccountResponse.java.
* Data flow: API record -> formatter -> table cell.
* 不再拼「(ID:4)」:ID 是内部主键,对使用者没有意义,反而把每一格撑成两行、挤掉真正要看的名称。
* 关联资产被软删时名称查不到,这里退回「—」,而不是露出一个孤零零的 ID。
*/
function formatRelation(name) { return name || '—'; }
/** Code purpose (plain language): keeps the create column at date precision. Related files: WecomAccountResponse.java. Data flow: ISO timestamp -> first 10 characters -> table cell. */
function formatDate(value) { const text = String(value || ''); return text ? text.slice(0, 10) : '—'; }
/** 代码作用(白话):把性别转成图标要用的样式类,取值异常时不画图标。关联文件:app.css。关联逻辑(调用链/数据流):row.gender -> 样式类 -> 名称右侧的性别图标。 */
function genderClass(row) { return row.gender === '男' ? 'is-male' : row.gender === '女' ? 'is-female' : ''; }
/** 代码作用(白话):拼出跳到手机号码管理页并筛出该号码的地址。关联文件:PhoneAssetView.js。关联逻辑(调用链/数据流):关联方式链接 -> Hash 查询参数 -> 手机号列表按号码筛选。 */
function phoneAssetLink(row) { return row.phoneNumber ? `#/phone-assets?phoneNumber=${encodeURIComponent(row.phoneNumber)}` : ''; }
/** 代码作用(白话):拼出跳到设备资产管理页并筛出该设备的地址。关联文件:DeviceAssetView.js。关联逻辑(调用链/数据流):关联设备链接 -> Hash 查询参数 -> 设备列表按名称筛选。 */
function deviceAssetLink(row) { return row.deviceName ? `#/device-assets?deviceName=${encodeURIComponent(row.deviceName)}` : ''; }
onMounted(loadPage);
return { canEdit, changePage, changePageSize, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, limitPhoneNumber, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetDialogScroll, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
return { canEdit, changePage, changePageSize, companyOptions, confirmDelete, deviceOptions, dialogVisible, editingId, deviceAssetLink, fetchPhoneSuggestions, filters, form, formatDate, formatRelation, genderClass, limitPhoneNumber, phoneAssetLink, loadCompanies, loadDevices, loadOwners, loading, openCreate, openEdit, ownerOptions, records, resetDialogScroll, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
},
template: `
<section class="phone-asset-list-page wecom-account-page">
<header class="phone-asset-list-page__header"><div><h2>企业微信资产</h2></div><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增企业微信资产</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="企微名称或手机号" clearable @input="scheduleSearch" @clear="scheduleSearch" /><el-select v-model="filters.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="注册主体:" @change="submitSearch" @clear="restoreAllCompanyProfiles"><template #prefix>注册主体:</template><el-option label="全部" value="ALL" /><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select><el-select v-model="filters.realNameOwnerStatus" clearable placeholder="实名状态:" @change="submitSearch" @clear="restoreAllRealNameStatuses"><template #prefix>实名状态:</template><el-option label="全部" value="ALL" /><el-option label="在职" value="在职" /><el-option label="离职" value="离职" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid wecom-account-page__grid"><el-table-column prop="id" label="企业微信资产 ID" min-width="140" /><el-table-column prop="wecomName" label="企微名称" min-width="150" show-overflow-tooltip /><el-table-column prop="wecomAlias" label="企微别名" min-width="180" show-overflow-tooltip /><el-table-column prop="wecomAccount" label="企微账号" min-width="160" show-overflow-tooltip /><el-table-column label="注册主体" min-width="180"><template #default="{ row }">{{ formatRelation(row.companyProfileName, row.companyProfileId) }}</template></el-table-column><el-table-column label="注册手机号" min-width="180"><template #default="{ row }">{{ formatRelation(row.phoneNumber, row.phoneAssetId) }}</template></el-table-column><el-table-column label="关联方式" min-width="120"><template #default="{ row }">{{ row.phoneLinkMode === 'CREATED' ? '新建号码' : '已有号码' }}</template></el-table-column><el-table-column prop="realNameOwner" label="实名人" min-width="120" /><el-table-column prop="realNameOwnerStatus" label="实名状态" min-width="110" /><el-table-column prop="gender" label="性别" min-width="90" /><el-table-column label="企微号归属人" min-width="180"><template #default="{ row }">{{ formatRelation(row.operatorPersonName, row.operatorPersonId) }}</template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize" /></section>
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" title="新增企业微信资产" width="560px" :close-on-click-modal="false" @opened="resetDialogScroll"><el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate"><el-form-item class="phone-asset-modal__form-row" label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册手机号" required><el-autocomplete :model-value="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" maxlength="11" inputmode="numeric" placeholder="输入 11 位手机号" style="width:100%" @update:model-value="limitPhoneNumber"><template #suffix><span class="phone-asset-modal__character-count">{{ form.phoneNumber.length }}/11</span></template></el-autocomplete></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册主体"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微号归属人"><el-select v-model="form.operatorPersonId" filterable remote clearable :remote-method="loadOwners" placeholder="输入人员姓名" style="width:100%"><el-option v-for="item in ownerOptions" :key="item.id" :label="item.personName" :value="item.id" /></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="实名人"><el-input v-model="form.realNameOwner" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="实名状态"><el-radio-group v-model="form.realNameOwnerStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item><el-form-item class="phone-asset-modal__form-row" label="性别"><el-radio-group v-model="form.gender"><el-radio value="男">男</el-radio><el-radio value="女">女</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreate">保存</el-button></template></el-dialog>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid wecom-account-page__grid"><el-table-column label="企微名称" min-width="200" show-overflow-tooltip><template #default="{ row }"><span class="wecom-account-page__name"><span class="wecom-account-page__name-main"><span class="wecom-account-page__name-text">{{ row.wecomName }}</span><span v-if="genderClass(row)" class="wecom-account-page__gender" :class="genderClass(row)" :title="'性别:' + row.gender"><svg v-if="row.gender === '男'" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="10" cy="14.5" r="5.5"/><path d="M14.5 10L20 4.5M15 4.5h5v5"/></svg><svg v-else viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="9" r="5.5"/><path d="M12 14.5v6M9 18h6"/></svg></span></span><span v-if="row.wecomAlias" class="wecom-account-page__name-alias">{{ row.wecomAlias }}</span></span></template></el-table-column><el-table-column prop="wecomAccount" label="企微账号" min-width="160" show-overflow-tooltip /><el-table-column label="注册主体" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ formatRelation(row.companyProfileName) }}</template></el-table-column><el-table-column label="注册手机号" min-width="170"><template #default="{ row }"><span v-if="row.phoneNumber" class="wecom-account-page__phone"><a class="phone-asset-list-page__external-link" :href="phoneAssetLink(row)" :title="'在手机号码管理中查看 ' + row.phoneNumber">{{ row.phoneNumber }}</a><span class="wecom-account-page__phone-tag" :class="row.phoneLinkMode === 'CREATED' ? 'is-created' : 'is-existing'" :title="row.phoneLinkMode === 'CREATED' ? '新建号码:新增这条企微时自动创建' : '已有号码:关联的是已存在的手机号码管理'">{{ row.phoneLinkMode === 'CREATED' ? '新' : '已' }}</span></span><span v-else>—</span></template></el-table-column><el-table-column label="实名人" min-width="120"><template #default="{ row }"><span v-if="row.realNameOwner" class="wecom-account-page__owner"><span class="wecom-account-page__owner-main">{{ row.realNameOwner }}</span><i v-if="row.realNameOwnerStatus" class="wecom-account-page__owner-dot" :class="{ 'is-left': row.realNameOwnerStatus === '离职' }" :title="'实名状态:' + row.realNameOwnerStatus"></i></span><span v-else>—</span></template></el-table-column><el-table-column label="关联设备" min-width="160" show-overflow-tooltip><template #default="{ row }"><a v-if="deviceAssetLink(row)" class="phone-asset-list-page__external-link" :href="deviceAssetLink(row)" :title="'在设备资产管理中查看 ' + row.deviceName">{{ row.deviceName }}</a><span v-else>—</span></template></el-table-column><el-table-column label="企微号归属人" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatRelation(row.operatorPersonName) }}</template></el-table-column><el-table-column label="创建时间" min-width="120"><template #default="{ row }">{{ formatDate(row.createTime) }}</template></el-table-column><el-table-column v-if="canEdit" label="操作" width="120" fixed="right"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize" /></section>
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增企业微信资产' : '编辑企业微信资产'" width="560px" :close-on-click-modal="false" @opened="resetDialogScroll"><el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate"><el-form-item class="phone-asset-modal__form-row" label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册手机号" required><el-autocomplete :model-value="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" maxlength="11" inputmode="numeric" placeholder="输入 11 位手机号" style="width:100%" @update:model-value="limitPhoneNumber"><template #suffix><span class="phone-asset-modal__character-count">{{ form.phoneNumber.length }}/11</span></template></el-autocomplete></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册主体"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="关联设备"><el-select v-model="form.deviceId" filterable remote clearable :remote-method="loadDevices" placeholder="输入设备名称搜索" style="width:100%"><el-option v-for="item in deviceOptions" :key="item.id" :label="item.deviceName" :value="item.id" /></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微号归属人"><el-select v-model="form.operatorPersonId" filterable remote clearable :remote-method="loadOwners" placeholder="输入人员姓名" style="width:100%"><el-option v-for="item in ownerOptions" :key="item.id" :label="item.personName" :value="item.id" /></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="实名人"><el-input v-model="form.realNameOwner" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="实名状态"><el-radio-group v-model="form.realNameOwnerStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item><el-form-item class="phone-asset-modal__form-row" label="性别"><el-radio-group v-model="form.gender"><el-radio value="男">男</el-radio><el-radio value="女">女</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreate">保存</el-button></template></el-dialog>
</section>
`
};
......@@ -20,6 +20,12 @@ export function listWecomAccounts(query) {
/** Code purpose (plain language): saves the create-dialog values. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: dialog submit -> POST -> transaction -> refreshed table. */
export function createWecomAccount(form) { return request('/api/wecom-accounts', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交编辑弹窗的内容。关联文件:WecomAccountView.js、WecomAccountController.java。关联逻辑(调用链/数据流):编辑保存 -> PUT -> 手机号重新关联与旧号清理 -> 列表刷新。 */
export function updateWecomAccount(id, form) { return request(`/api/wecom-accounts/${id}`, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):软删除一条企微资产。关联文件:WecomAccountView.js、WecomAccountController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 自动新建号码一并清理 -> 列表刷新。 */
export function deleteWecomAccount(id) { return request(`/api/wecom-accounts/${id}`, { method: 'DELETE' }); }
/** Code purpose (plain language): searches company profiles for the registration-subject picker. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyProfiles(keyword) { return request(`/api/wecom-accounts/lookups/company-profiles?keyword=${encodeURIComponent(keyword || '')}`); }
......@@ -28,3 +34,6 @@ export function searchPhoneAssets(keyword) { return request(`/api/wecom-accounts
/** Code purpose (plain language): searches optional WeCom owners. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyPersons(keyword) { return request(`/api/wecom-accounts/lookups/company-persons?keyword=${encodeURIComponent(keyword || '')}`); }
/** 代码作用(白话):按设备名称搜索可关联的有效设备。关联文件:WecomAccountView.js、WecomAccountController.java。关联逻辑(调用链/数据流):关联设备下拉输入 -> GET lookup -> 设备选项。 */
export function searchDevices(keyword) { return request(`/api/wecom-accounts/lookups/devices?keyword=${encodeURIComponent(keyword || '')}`); }
......@@ -13,7 +13,7 @@ test('creates an enterprise WeChat asset and exposes the required form', async (
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
await expect(page.getByRole('heading', { name: '企业微信资产' })).toBeVisible();
await page.getByRole('button', { name: '新增企业微信资产' }).click();
/** 代码作用(白话):验证企业微信弹窗接入手机号资产的统一容器,使超高表单仅在正文区域滚动。关联文件:WecomAccountView.js、PhoneAssetView.js、main.css。关联逻辑(调用链/数据流):新增按钮 -> Element Plus 弹窗 -> phone-asset-modal 样式 -> 正文滚动。 */
/** 代码作用(白话):验证企业微信弹窗接入手机号码管理的统一容器,使超高表单仅在正文区域滚动。关联文件:WecomAccountView.js、PhoneAssetView.js、main.css。关联逻辑(调用链/数据流):新增按钮 -> Element Plus 弹窗 -> phone-asset-modal 样式 -> 正文滚动。 */
const dialog = page.getByRole('dialog', { name: '新增企业微信资产' });
await expect(dialog.locator('.el-dialog')).toHaveClass(/phone-asset-modal/);
await expect(dialog.locator('.el-dialog__body')).toHaveCSS('overflow-y', 'auto');
......@@ -23,6 +23,230 @@ test('creates an enterprise WeChat asset and exposes the required form', async (
await expect(page.getByText('新增成功')).toBeVisible();
});
/** Plain purpose: build one list row so the edit/delete cases share the same shape. Related files: WecomAccountResponse.java, WecomAccountView.js. Flow: fixture -> mocked GET -> table row. */
function wecomRow(overrides = {}) {
return { id: 5, wecomName: '测试企微', wecomAlias: '销售一组', wecomAccount: 'zhangsan', companyProfileId: 10, companyProfileName: '示例科技', phoneAssetId: 20, phoneNumber: '13812345678', phoneLinkMode: 'EXISTING', realNameOwner: '张三', realNameOwnerStatus: '在职', gender: '男', deviceId: null, deviceName: null, operatorPersonId: 40, operatorPersonName: '王五', createTime: '2026-08-01T10:00:00', updateTime: '2026-08-01T11:00:00', ...overrides };
}
/** Plain purpose: keep internal IDs and clock times out of the list, and fold the alias under the name. Related files: WecomAccountView.js, WecomAccountResponse.java. Flow: API record -> 列渲染 -> 只剩人看得懂的信息。 */
test('shows readable names only, with the alias tucked under the name', async ({ page }) => {
await mockWecomPage(page, wecomRow({ deviceId: 31, deviceName: '学管师31号机' }), []);
await page.goto('/asset/#/reference/wecom');
const headers = page.locator('.el-table__header th .cell');
await expect(headers.filter({ hasText: '企业微信资产 ID' })).toHaveCount(0);
await expect(headers.filter({ hasText: '企微别名' })).toHaveCount(0);
const row = page.locator('.el-table__body tr').first();
// 关联字段只留名称,不再拖着一个内部主键。
await expect(row.getByText('示例科技', { exact: true })).toBeVisible();
await expect(row.getByText(/(ID:/)).toHaveCount(0);
// 别名和名称同格,别名在下方。
const nameCell = row.locator('.wecom-account-page__name');
await expect(nameCell.locator('.wecom-account-page__name-main')).toHaveText('测试企微');
await expect(nameCell.locator('.wecom-account-page__name-alias')).toHaveText('销售一组');
// 创建时间只到天。
await expect(row.getByText('2026-08-01', { exact: true })).toBeVisible();
await expect(row.getByText(/10:00/)).toHaveCount(0);
// 性别、实名状态、关联方式都收成了角标,各自不再占一列。
for (const gone of ['实名状态', '性别', '关联方式']) {
await expect(headers.filter({ hasText: gone })).toHaveCount(0);
}
await expect(row.locator('.wecom-account-page__owner-main')).toHaveText('张三');
await expect(row.locator('.wecom-account-page__gender svg')).toBeVisible();
});
/** Plain purpose: the gender icon carries its meaning in both shape and colour. Related files: app.css, WecomAccountView.js. Flow: 男/女 -> is-male/is-female -> 蓝/粉描边。 */
test('colours the gender icon blue for male and pink for female', async ({ page }) => {
await mockWecomPage(page, wecomRow(), []);
await page.goto('/asset/#/reference/wecom');
const male = page.locator('.el-table__body tr').first().locator('.wecom-account-page__gender');
await expect(male).toHaveClass(/is-male/);
await expect(male).toHaveCSS('color', 'rgb(64, 158, 255)');
await expect(male).toHaveAttribute('title', '性别:男');
});
/** Plain purpose: same check on the female side, so a swapped class cannot slip through. Related files: app.css. Flow: 女 -> is-female -> 粉色描边。 */
test('uses the pink gender icon for a female owner', async ({ page }) => {
await mockWecomPage(page, wecomRow({ gender: '女' }), []);
await page.goto('/asset/#/reference/wecom');
const female = page.locator('.el-table__body tr').first().locator('.wecom-account-page__gender');
await expect(female).toHaveClass(/is-female/);
await expect(female).toHaveCSS('color', 'rgb(236, 72, 153)');
});
/** Plain purpose: an unknown gender must draw nothing rather than half an icon. Related files: WecomAccountView.js. Flow: 脏数据 -> genderClass 返回空 -> 不渲染。 */
test('draws no gender icon when the value is neither male nor female', async ({ page }) => {
await mockWecomPage(page, wecomRow({ gender: null }), []);
await page.goto('/asset/#/reference/wecom');
await expect(page.locator('.el-table__body tr').first().locator('.wecom-account-page__gender')).toHaveCount(0);
});
/** Plain purpose: the status badge must stay a bare dot — colour carries the meaning, the wording only lives in the tooltip. Related files: app.css, WecomAccountView.js. Flow: 在职/离职 -> 绿点/红点 + title。 */
test('shows the real-name status as a coloured dot without any wording', async ({ page }) => {
await mockWecomPage(page, wecomRow({ realNameOwnerStatus: '离职' }), []);
await page.goto('/asset/#/reference/wecom');
const cell = page.locator('.el-table__body tr').first().locator('.wecom-account-page__owner');
await expect(cell).toContainText('张三');
await expect(cell).not.toContainText('离职');
const dot = cell.locator('.wecom-account-page__owner-dot');
await expect(dot).toHaveClass(/is-left/);
await expect(dot).toHaveAttribute('title', '实名状态:离职');
await expect(dot).toHaveCSS('background-color', 'rgb(229, 72, 77)');
});
/** Plain purpose: an active owner keeps the same dot in the calm colour. Related files: app.css. Flow: 在职 -> 绿点。 */
test('uses a green dot for an active real-name owner', async ({ page }) => {
await mockWecomPage(page, wecomRow(), []);
await page.goto('/asset/#/reference/wecom');
const dot = page.locator('.el-table__body tr').first().locator('.wecom-account-page__owner-dot');
await expect(dot).not.toHaveClass(/is-left/);
await expect(dot).toHaveCSS('background-color', 'rgb(47, 158, 104)');
});
/** Plain purpose: an owner-less row shows a dash and no dangling status dot. Related files: WecomAccountView.js. Flow: 无实名人 -> —。 */
test('shows a dash when there is no real-name owner', async ({ page }) => {
await mockWecomPage(page, wecomRow({ realNameOwner: null }), []);
await page.goto('/asset/#/reference/wecom');
await expect(page.locator('.el-table__body tr').first().locator('.wecom-account-page__owner-dot')).toHaveCount(0);
});
/** Plain purpose: the link mode becomes a one-character badge on the number itself. Related files: WecomAccountView.js, app.css. Flow: CREATED/EXISTING -> 新/已 角标 + title。 */
test('marks the phone link mode as a one-character badge', async ({ page }) => {
await mockWecomPage(page, wecomRow({ phoneLinkMode: 'CREATED', phoneNumber: '13912345678' }), []);
await page.goto('/asset/#/reference/wecom');
const tag = page.locator('.el-table__body tr').first().locator('.wecom-account-page__phone-tag');
await expect(tag).toHaveText('新');
await expect(tag).toHaveClass(/is-created/);
await expect(tag).toHaveAttribute('title', /新建号码/);
});
/** Plain purpose: prove both relation links point at the matching row on their own pages. Related files: PhoneAssetView.js, DeviceAssetView.js. Flow: 链接 href -> 目标页 Hash 参数 -> 该页初始筛选。 */
test('links the phone link-mode and the device to their own asset pages', async ({ page }) => {
await mockWecomPage(page, wecomRow({ phoneLinkMode: 'CREATED', phoneNumber: '13912345678', deviceId: 31, deviceName: '学管师31号机' }), []);
await page.goto('/asset/#/reference/wecom');
const row = page.locator('.el-table__body tr').first();
await expect(row.getByRole('link', { name: '13912345678' })).toHaveAttribute('href', '#/phone-assets?phoneNumber=13912345678');
await expect(row.getByRole('link', { name: '学管师31号机' })).toHaveAttribute('href', '#/device-assets?deviceName=' + encodeURIComponent('学管师31号机'));
});
/** Plain purpose: a link that cannot filter anything is worse than plain text. Related files: WecomAccountView.js. Flow: 缺号码/缺设备 -> 破折号,不生成死链也不留下无所指的角标。 */
test('drops the link and its badge when there is nothing to jump to', async ({ page }) => {
await mockWecomPage(page, wecomRow({ phoneNumber: null, deviceId: null, deviceName: null }), []);
await page.goto('/asset/#/reference/wecom');
const row = page.locator('.el-table__body tr').first();
await expect(row.locator('.wecom-account-page__phone-tag')).toHaveCount(0);
await expect(row.locator('.phone-asset-list-page__external-link')).toHaveCount(0);
await expect(row.getByText('—').first()).toBeVisible();
});
/** Plain purpose: prove the phone page actually honours the routed number, so the link is not a dead end. Related files: PhoneAssetView.js. Flow: Hash 参数 -> 初始筛选 -> GET 带上号码。 */
test('phone asset page pre-filters from the routed number', async ({ page }) => {
let requested = '';
await page.route('**/api/phone-assets**', route => {
requested = route.request().url();
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [], total: 0, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/phone-assets?phoneNumber=13912345678');
await expect.poll(() => requested).toContain('phoneNumber=13912345678');
});
/** Plain purpose: prove the device page actually honours the routed device name. Related files: DeviceAssetView.js. Flow: Hash 参数 -> 初始筛选 -> GET 带上名称。 */
test('device asset page pre-filters from the routed device name', async ({ page }) => {
let requested = '';
await page.route('**/api/device-assets**', route => {
if (!route.request().url().includes('/lookups/')) requested = route.request().url();
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [], total: 0, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/device-assets?deviceName=' + encodeURIComponent('学管师31号机'));
await expect.poll(() => requested).toContain('deviceName=' + encodeURIComponent('学管师31号机'));
});
/** Plain purpose: prove the device selector searches remotely and submits the chosen ID on both create and edit. Related files: WecomAccountView.js, WecomAccountService.java. Flow: 下拉输入 -> GET lookups/devices -> 选中 -> payload.deviceId。 */
test('picks an associated device through remote search and submits its id', async ({ page }) => {
const writes = [];
const deviceQueries = [];
await page.route('**/api/wecom-accounts**', route => {
const request = route.request();
const url = request.url();
if (url.includes('/lookups/devices')) { deviceQueries.push(decodeURIComponent(url.split('keyword=')[1] || '')); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [{ id: 31, deviceName: '学管师31号机' }] }) }); }
if (url.includes('/lookups/')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [] }) });
if (request.method() !== 'GET') { writes.push(JSON.parse(request.postData() || '{}')); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: {} }) }); }
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [wecomRow({ deviceId: 31, deviceName: '学管师31号机' })], total: 1, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/reference/wecom');
// 列表要能看到关联设备。
await expect(page.locator('.el-table').getByRole('link', { name: '学管师31号机' })).toBeVisible();
// 编辑时回填已选设备,即使还没搜索过。
await page.locator('.el-table').getByRole('button', { name: '编辑' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('学管师31号机')).toBeVisible();
await dialog.getByRole('button', { name: '保存' }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].deviceId).toBe(31);
expect(deviceQueries.length).toBeGreaterThan(0);
});
/** Plain purpose: serve the list plus lookups and record every write. Related files: wecom-api-client.js, WecomAccountController.java. Flow: page action -> intercepted route -> recorded request. */
async function mockWecomPage(page, row, writes) {
await page.route('**/api/wecom-accounts**', route => {
const request = route.request();
const url = request.url();
if (url.includes('/lookups/')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [] }) });
if (request.method() !== 'GET') { writes.push({ method: request.method(), url, body: request.postData() || '' }); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: {} }) }); }
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [row], total: 1, page: 1, size: 20 } }) });
});
}
/** Plain purpose: verify the edit dialog reuses the create form, prefills the row, and sends a PUT. Related files: WecomAccountView.js, WecomAccountService.java. Flow: 编辑 -> 回填 -> PUT。 */
test('edits an enterprise WeChat asset through the reused dialog', async ({ page }) => {
const writes = [];
await mockWecomPage(page, wecomRow(), writes);
await page.goto('/asset/#/reference/wecom');
await page.locator('.el-table').getByRole('button', { name: '编辑' }).click();
const dialog = page.getByRole('dialog', { name: '编辑企业微信资产' });
await expect(dialog).toBeVisible();
await expect(page.getByLabel('企微名称')).toHaveValue('测试企微');
await expect(page.getByLabel('注册手机号')).toHaveValue('13812345678');
await page.getByLabel('企微名称').fill('改名后的企微');
await dialog.getByRole('button', { name: '保存' }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].method).toBe('PUT');
expect(writes[0].url).toContain('/api/wecom-accounts/5');
expect(JSON.parse(writes[0].body).wecomName).toBe('改名后的企微');
await expect(page.getByText('编辑成功')).toBeVisible();
});
/** Plain purpose: verify deletion asks for confirmation and issues a DELETE. Related files: WecomAccountView.js, WecomAccountService.java. Flow: 删除 -> 确认 -> DELETE -> 刷新。 */
test('deletes an enterprise WeChat asset after confirmation', async ({ page }) => {
const writes = [];
await mockWecomPage(page, wecomRow(), writes);
await page.goto('/asset/#/reference/wecom');
await page.locator('.el-table').getByRole('button', { name: '删除' }).click();
await page.getByRole('button', { name: '确定' }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].method).toBe('DELETE');
expect(writes[0].url).toContain('/api/wecom-accounts/5');
await expect(page.getByText('删除成功')).toBeVisible();
});
/** Plain purpose: warn that an auto-created registration number will go away with the asset. Related files: WecomAccountView.js, WecomAccountService.java. Flow: CREATED 行 -> 确认框文案含号码提示。 */
test('warns that an auto-created registration number is removed too', async ({ page }) => {
await mockWecomPage(page, wecomRow({ phoneLinkMode: 'CREATED', phoneNumber: '13912345678' }), []);
await page.goto('/asset/#/reference/wecom');
await page.locator('.el-table').getByRole('button', { name: '删除' }).click();
await expect(page.getByText(/13912345678 是新增这条企微时自动创建的/)).toBeVisible();
});
/** Plain purpose: keep write controls away from read-only accounts. Related files: WecomAccountView.js, WecomAccountController.java. Flow: READ 权限 -> 无操作列与新增按钮。 */
test('hides the action column from read-only accounts', async ({ page }) => {
await page.route('**/api/auth/me', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { id: 1, username: 'reader', roleCode: 'FINANCE', pagePermissions: { 'reference-wecom': 'READ' } } }) }));
await mockWecomPage(page, wecomRow(), []);
await page.goto('/asset/#/reference/wecom');
await expect(page.locator('.el-table').getByText('测试企微')).toBeVisible();
await expect(page.locator('.el-table').getByRole('button', { name: '编辑' })).toHaveCount(0);
await expect(page.locator('.el-table').getByRole('button', { name: '删除' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '新增企业微信资产' })).toHaveCount(0);
});
test('external number links to the related enterprise WeChat asset filter', async ({ page }) => {
/** Code purpose (plain language): returns one external WeCom-created number. Related files: PhoneAssetView.js, WecomAccountView.js. Data flow: phone list fixture -> external link -> WeCom URL filter. */
await page.route('**/api/phone-assets**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [{ id: 9, phoneNumber: '13912345678', numberType: 'EXTERNAL', sourceAssetType: 'WECOM', cardType: '', iccid: '', realNameOwner: '', managementType: '', disposalStatus: '正常使用' }], total: 1, page: 1, size: 20 } }) }));
......
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