Commit 028e5bb6 by DaiJiezhang

feat: add wecom account asset creation

parent 6c6363e4
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.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 文件用途(白话):提供企微资产列表的只读 HTTP 入口,让前端无需直接访问数据库。 */
/** File purpose (plain language): exposes enterprise WeChat asset list, creation, and read-only selector endpoints to the frontend. */
@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 表格。
*/
/** Code purpose (plain language): receives the service that coordinates WeCom assets and referenced assets. Related files: WecomAccountService.java. Data flow: HTTP controller -> service -> mappers. */
public WecomAccountController(WecomAccountService service) { this.service = service; }
/** Code purpose (plain language): saves a new enterprise WeChat asset. Related files: WecomAccountSaveRequest.java, WecomAccountService.java. Data flow: create dialog -> POST -> transaction -> response. */
@PostMapping
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { return ApiResponse.success("新增成功", service.create(request)); }
/** Code purpose (plain language): returns the paged enterprise WeChat asset list and optional registration-phone filter. Related files: WecomAccountPageQuery.java, WecomAccountService.java. Data flow: list query -> GET -> service.page -> table. */
@GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) {
return ApiResponse.success(service.page(query));
}
}
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { return ApiResponse.success(service.page(query)); }
/** Code purpose (plain language): searches registration subjects by company name or short name. Related files: CompanyProfileLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-profiles")
public ApiResponse<List<CompanyProfileLookupResponse>> companyProfiles(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyProfiles(keyword)); }
/** Code purpose (plain language): searches reusable registration phone assets. Related files: PhoneAssetLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/phone-assets")
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchPhoneAssets(keyword)); }
/** Code purpose (plain language): searches optional WeCom owners from company people. Related files: CompanyPersonLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-persons")
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyPersons(keyword)); }
}
\ No newline at end of file
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载企微号归属人的搜索结果,供企业微信资产表单选择公司人员。 */
public record CompanyPersonLookupResponse(Long id, String personName) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载注册主体搜索结果,避免把完整公司档案暴露给新增企业微信表单。 */
public record CompanyProfileLookupResponse(Long id, String companyName, String shortName) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载注册手机号搜索结果,供企业微信资产选择已有手机号时复用。 */
public record PhoneAssetLookupResponse(Long id, String phoneNumber, String numberType) {}
\ No newline at end of file
package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
public record PhoneAssetResponse(Long id,String phoneNumber,String cardType,String iccid,String realNameOwner,String managementType,String disposalStatus,Long deviceId,LocalDateTime relationSyncedAt) {}
\ No newline at end of file
public record PhoneAssetResponse(Long id,String phoneNumber,String numberType,String sourceAssetType,Long sourceAssetId,String cardType,String iccid,String realNameOwner,String managementType,String disposalStatus,Long deviceId,LocalDateTime relationSyncedAt) {}
\ No newline at end of file
......@@ -3,28 +3,19 @@ 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) {
String keyword,
String wecomAccount,
Long phoneAssetId,
Long companyProfileId,
String realNameOwnerStatus) {
/**
* 代码作用(白话):在调用方没有传页码时返回第 1 页,保证列表可以直接打开。
* 关联文件:WecomAccountController.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):HTTP 查询参数 -> resolvedPage -> MyBatis Page -> 列表响应。
*/
public int resolvedPage() {
return page == null ? 1 : page;
}
/** 代码作用(白话):未传页码时返回第一页。关联文件:WecomAccountController.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedPage -> MyBatis 分页。 */
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;
}
}
/** 代码作用(白话):未传每页条数时返回 20 条。关联文件:WecomAccountController.java。关联逻辑(调用链/数据流):HTTP 参数 -> resolvedSize -> MyBatis 分页。 */
public int resolvedSize() { return size == null ? 20 : size; }
}
\ No newline at end of file
......@@ -12,6 +12,7 @@ public record WecomAccountResponse(
String companyProfileName,
Long phoneAssetId,
String phoneNumber,
String phoneLinkMode,
String realNameOwner,
String realNameOwnerStatus,
String gender,
......
package com.xyw.console.asset.dto;
import jakarta.validation.constraints.NotBlank;
/** ???????????????????????? Controller ??????????? */
public record WecomAccountSaveRequest(
@NotBlank String wecomName,
String wecomAlias,
String wecomAccount,
Long companyProfileId,
@NotBlank String phoneNumber,
String realNameOwner,
String realNameOwnerStatus,
String gender,
Long operatorPersonId) {
}
......@@ -21,6 +21,9 @@ public class PhoneAssetEntity extends AssetBaseEntity {
private String managementType;
private String disposalStatus;
private Long deviceId;
private String numberType;
private String sourceAssetType;
private Long sourceAssetId;
private String linkedWecomAccounts;
private String linkedWechatAccounts;
private String linkedDouyinAccounts;
......
......@@ -19,6 +19,7 @@ public class WecomAccountEntity extends AssetBaseEntity {
private Long companyProfileId;
private String wecomAccount;
private Long phoneAssetId;
private String phoneLinkMode;
private String realNameOwner;
private String realNameOwnerStatus;
private String gender;
......
......@@ -43,7 +43,7 @@ public class PhoneAssetService {
/** 代码作用(白话):判断筛选文本是否有内容,避免空字符串参与数据库筛选。关联文件:PhoneAssetPageQuery.java、PhoneAssetService.java。关联逻辑(调用链/数据流):请求参数 -> hasText -> 是否追加条件。 */
private boolean hasText(String value) { return value != null && !value.isBlank(); }
/** 代码作用(白话):新增手机号资产并初始化审计字段和内部关联快照。关联文件:PhoneAssetSaveRequest.java、PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):POST 请求 -> DTO -> Service.create -> Mapper.insert -> as_phone_asset。 */
public PhoneAssetResponse create(PhoneAssetSaveRequest request) { PhoneAssetEntity entity=new PhoneAssetEntity(); applyEditableFields(entity,request); LocalDateTime now=LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); entity.setLinkedWecomAccounts("[]"); entity.setLinkedWechatAccounts("[]"); entity.setLinkedDouyinAccounts("[]"); entity.setLinkedDomainAccounts("[]"); entity.setLinkedMerchants("[]"); if(mapper.insert(entity)!=1) throw new IllegalStateException("手机号资产新增失败"); return toResponse(entity); }
public PhoneAssetResponse create(PhoneAssetSaveRequest request) { PhoneAssetEntity entity=new PhoneAssetEntity(); applyEditableFields(entity,request); entity.setNumberType("SELF"); LocalDateTime now=LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); entity.setLinkedWecomAccounts("[]"); entity.setLinkedWechatAccounts("[]"); entity.setLinkedDouyinAccounts("[]"); entity.setLinkedDomainAccounts("[]"); entity.setLinkedMerchants("[]"); if(mapper.insert(entity)!=1) throw new IllegalStateException("手机号资产新增失败"); return toResponse(entity); }
/** 代码作用(白话):更新有效资产的用户可写字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):PUT 请求 -> Service 查找 -> Mapper.updateById -> Response。 */
public PhoneAssetResponse update(Long id, PhoneAssetSaveRequest request) { PhoneAssetEntity entity=requireActiveEntity(id); applyEditableFields(entity,request); entity.setUpdateTime(LocalDateTime.now()); if(mapper.updateById(entity)!=1) throw new PhoneAssetNotFoundException("手机号资产不存在或已删除"); return toResponse(entity); }
/** 代码作用(白话):将资产标记为删除,供 Controller 删除接口调用。关联文件:PhoneAssetController.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE 请求 -> Service -> Mapper.updateById。 */
......@@ -55,5 +55,5 @@ public class PhoneAssetService {
/** 代码作用(白话):按已确认规则清理手机号并验证其最终格式。关联文件:PhoneAssetSaveRequest.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):前端输入 -> 去空格和 +86 -> 格式失败返回 400。 */
private String normalizePhoneNumber(String value){ String normalized=value==null?"":value.trim(); if(normalized.startsWith("+86")) normalized=normalized.substring(3); if(!normalized.matches("\\d{11}")) throw new PhoneAssetValidationException("手机号必须是 11 位数字"); return normalized; }
/** 代码作用(白话):只挑选列表需要展示的字段。关联文件:PhoneAssetResponse.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):实体 -> DTO -> ApiResponse -> 表格。 */
private PhoneAssetResponse toResponse(PhoneAssetEntity entity){ return new PhoneAssetResponse(entity.getId(),entity.getPhoneNumber(),entity.getCardType(),entity.getIccid(),entity.getRealNameOwner(),entity.getManagementType(),entity.getDisposalStatus(),entity.getDeviceId(),entity.getRelationSyncedAt()); }
private PhoneAssetResponse toResponse(PhoneAssetEntity entity){ return new PhoneAssetResponse(entity.getId(),entity.getPhoneNumber(),entity.getNumberType(),entity.getSourceAssetType(),entity.getSourceAssetId(),entity.getCardType(),entity.getIccid(),entity.getRealNameOwner(),entity.getManagementType(),entity.getDisposalStatus(),entity.getDeviceId(),entity.getRelationSyncedAt()); }
}
\ No newline at end of file
......@@ -5,11 +5,13 @@ 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 static org.mockito.Mockito.verify;
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.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
......@@ -46,7 +48,7 @@ class WecomAccountServiceTest {
WecomAccountPageResponse result = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(null, null, null, null));
.page(new WecomAccountPageQuery(null, null, null, null, null, null, null));
WecomAccountResponse record = result.records().get(0);
assertEquals(1L, record.id());
......@@ -83,7 +85,7 @@ class WecomAccountServiceTest {
WecomAccountResponse record = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(1, 20, null, null)).records().get(0);
.page(new WecomAccountPageQuery(1, 20, null, null, null, null, null)).records().get(0);
assertEquals(10L, record.companyProfileId());
assertNull(record.companyProfileName());
......@@ -169,4 +171,21 @@ class WecomAccountServiceTest {
entity.setPersonName("王五");
return entity;
}
}
/** Code purpose (plain language): proves an entered existing number is reused and marked as an existing link. Related files: WecomAccountService.java, WecomAccountSaveRequest.java. Data flow: create request -> existing phone -> WeCom response. */
@Test
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));
assertEquals(20L, result.phoneAssetId()); assertEquals("EXISTING", result.phoneLinkMode());
}
/** Code purpose (plain language): proves a missing number creates an external phone asset and traces it back to the WeCom record. Related files: WecomAccountService.java, PhoneAssetEntity.java. Data flow: create request -> new phone -> new WeCom -> source update. */
@Test
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));
assertEquals("CREATED", result.phoneLinkMode()); verify(phoneMapper).updateById(any(PhoneAssetEntity.class));
}}
# 企业微信资产号码来源:受控数据库变更说明
本文件只定义待 DBA 在指定数据库和变更窗口执行的内容;本次没有连接、迁移或修改任何真实数据库。
## 变更字段
- `as_phone_asset.number_type``SELF`(自有号码)或 `EXTERNAL`(外部号码)。
- `as_phone_asset.source_asset_type`:创建外部号码的来源资产类型,例如 `WECOM`
- `as_phone_asset.source_asset_id`:来源资产主键。
- `as_wecom_account.phone_link_mode``EXISTING`(已有号码)或 `CREATED`(新建号码)。
## 回填与索引
1. 历史 `as_phone_asset` 记录回填为 `SELF`,来源字段保持空。
2.`as_phone_asset(source_asset_type, source_asset_id, delete_time)` 建立普通索引,支持来源跳转追溯。
3. 保留既有手机号与删除标记的唯一约束;部署前确认其可防止同一有效号码重复创建。
## 执行前检查与回滚
- 由 DBA 先在备份或预发布库验证列名、数据量和锁表影响。
- 应用已对空 `number_type` 兼容显示为“自有号码”,因此可先发布应用、后受控加列。
- 回滚时停止写入新字段并保留列,不删除已经写入的来源数据。
......@@ -10,7 +10,7 @@ async function request(path, options = {}) {
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()); }
export function listPhoneAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined && value !== 'ALL') params.set(key,value); }); return request('/api/phone-assets?' + params.toString()); }
/** 代码作用(白话):提交新增表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 后端创建。 */
export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */
......
/** 文件用途(白话):集中发送企微资产列表请求并统一解析后端的成功或失败响应。 */
/** File purpose (plain language): centralizes enterprise WeChat asset requests and turns API envelopes into usable data. */
/**
* 代码作用(白话):请求后端并从统一 JSON 响应中取出真正的列表数据,失败时抛出可显示的错误信息。
* 关联文件:WecomAccountView.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):列表页 -> request -> GET /api/wecom-accounts -> ApiResponse.data -> 表格数据。
*/
async function request(path) {
const response = await fetch(path);
/** Code purpose (plain language): sends an API request and exposes either its data or readable error. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: view action -> request -> ApiResponse -> view state. */
async function request(path, options = {}) {
const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
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;
}
/**
* 代码作用(白话):把页面的分页和筛选状态转换为 URL 参数,再读取对应的企微资产页。
* 关联文件:WecomAccountView.js、WecomAccountController.java、WecomAccountPageQuery.java。
* 关联逻辑(调用链/数据流):筛选条件 -> URLSearchParams -> GET 接口 -> records/total。
*/
/** Code purpose (plain language): loads a filtered enterprise WeChat asset page. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: filters -> URL params -> GET list -> table. */
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
Object.entries(query).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '' && value !== 'ALL') params.set(key, value); });
return request(`/api/wecom-accounts?${params}`);
}
/** 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) }); }
/** 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 || '')}`); }
/** Code purpose (plain language): searches existing phone assets for the registration-phone input. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: autocomplete -> GET lookup -> selectable numbers. */
export function searchPhoneAssets(keyword) { return request(`/api/wecom-accounts/lookups/phone-assets?keyword=${encodeURIComponent(keyword || '')}`); }
/** Code purpose (plain language): searches optional WeCom owners. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyPersons(keyword) { return request(`/api/wecom-accounts/lookups/company-persons?keyword=${encodeURIComponent(keyword || '')}`); }
\ No newline at end of file
:root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px 1fr; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); }
.sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; }
.sidebar h1 { margin: 0 0 32px; font-size: 20px; }
.sidebar nav { display: grid; gap: 8px; }
.sidebar a { border-radius: 8px; color: #cbd5e1; padding: 10px 12px; text-decoration: none; }
.sidebar a.router-link-active, .sidebar a:hover { background: #1f3b59; color: white; }
.content { padding: 40px; }
.content { min-width: 0; padding: 40px; }
.eyebrow { margin: 0 0 8px; color: #5f8fcb; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.reference-page, .placeholder { max-width: 1180px; margin: 0 auto; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
......@@ -272,4 +272,5 @@ body { margin: 0; }
.phone-asset-list-page__grid .el-table__header th.el-table__cell{height:46px;background:#fafafa;color:#71717a;font-size:13px;font-weight:600}.phone-asset-list-page__grid .el-table__cell{height:56px;color:#3f3f46;font-size:14px}.phone-asset-list-page__grid .el-table__row:hover>td.el-table__cell{background:#fafafa}.phone-asset-list-page__status{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}.phone-asset-list-page__status-dot{width:7px;height:7px;border-radius:50%;background:#a1a1aa;box-shadow:0 0 0 3px rgba(161,161,170,.12)}.phone-asset-list-page__status-dot.正常使用{background:#2f9e68;box-shadow:0 0 0 3px rgba(47,158,104,.1)}.phone-asset-list-page__status-dot.闲置,.phone-asset-list-page__status-dot.停机{background:#d28b24;box-shadow:0 0 0 3px rgba(210,139,36,.11)}.phone-asset-list-page__actions{display:inline-flex;gap:14px}.phone-asset-list-page__actions .el-button{padding:0;color:#3f3f46;font-size:13px}.phone-asset-list-page__actions .el-button--danger{color:#e5484d}
.phone-asset-list-page__pagination{display:flex;align-items:center;justify-content:space-between;gap:20px;min-height:66px;padding:12px 22px;border-top:1px solid #e5e5e8;color:#71717a;font-size:13px}.phone-asset-list-page__pagination .el-pagination{justify-content:flex-end}.phone-asset-list-page__pagination .el-pager li,.phone-asset-list-page__pagination .btn-prev,.phone-asset-list-page__pagination .btn-next{min-width:34px;height:34px;border:1px solid #e5e5e8;border-radius:6px;background:#fff}.phone-asset-list-page__pagination .el-pager li.is-active{background:#18181b;color:#fff}
@media(max-width:900px){.phone-asset-list-page{padding:26px 0 40px}.phone-asset-list-page__pagination{align-items:flex-start;flex-direction:column}.phone-asset-list-page__pagination .el-pagination{width:100%;justify-content:space-between}}
@media(max-width:640px){.phone-asset-list-page{padding:20px 0 32px}.phone-asset-list-page__header{align-items:stretch;flex-direction:column;margin-bottom:18px}.phone-asset-list-page__add.el-button{width:100%}.phone-asset-list-page__header h2{font-size:25px}.phone-asset-list-page__search{padding:18px}.phone-asset-list-page__filters,.phone-asset-list-page__filters .el-input,.phone-asset-list-page__filters .el-select,.phone-asset-list-page__filters .el-button{width:100%!important}.phone-asset-list-page__table-header,.phone-asset-list-page__pagination{padding-left:18px;padding-right:18px}.phone-asset-list-page__pagination .el-pagination__jump{display:none}}
\ No newline at end of file
@media(max-width:640px){.phone-asset-list-page{padding:20px 0 32px}.phone-asset-list-page__header{align-items:stretch;flex-direction:column;margin-bottom:18px}.phone-asset-list-page__add.el-button{width:100%}.phone-asset-list-page__header h2{font-size:25px}.phone-asset-list-page__search{padding:18px}.phone-asset-list-page__filters,.phone-asset-list-page__filters .el-input,.phone-asset-list-page__filters .el-select,.phone-asset-list-page__filters .el-button{width:100%!important}.phone-asset-list-page__table-header,.phone-asset-list-page__pagination{padding-left:18px;padding-right:18px}.phone-asset-list-page__pagination .el-pagination__jump{display:none}}
.wecom-account-page{min-width:0;max-width:100%;overflow-x:hidden}.wecom-account-page__eyebrow{margin:7px 0 0;color:#71717a;font-size:12px;font-weight:700;letter-spacing:.12em}.wecom-account-page .phone-asset-list-page__table,.wecom-account-page__grid{min-width:0;max-width:100%}.wecom-account-page__grid .el-scrollbar__wrap{overflow-x:auto!important}.phone-asset-list-page__external-link{color:#409eff;text-decoration:none}.phone-asset-list-page__external-link:hover{color:#337ecc;text-decoration:none}
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
}
})
});
/** File purpose (plain language): checks the enterprise WeChat page's create flow, external-number navigation, and phone filter. */
test('creates an enterprise WeChat asset and exposes the required form', async ({ page }) => {
/** Code purpose (plain language): serves deterministic list and lookup API responses. Related files: WecomAccountView.js, wecom-api-client.js. Data flow: browser request -> route fixture -> rendered page. */
await page.route('**/api/wecom-accounts**', async route => {
const url = route.request().url();
if (url.includes('/lookups/phone-assets')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [{ id: 7, phoneNumber: '13812345678', numberType: 'SELF' }] }) });
if (route.request().method() === 'POST') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { id: 1 } }) });
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [], total: 0, 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);
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();
await page.getByLabel('企微名称').fill('测试企微');
await page.getByLabel('注册手机号').fill('13812345678');
await page.getByRole('button', { name: '保存' }).click();
await expect(page.getByText('新增成功')).toBeVisible();
});
/**
* 代码作用(白话):验证用户筛选和翻页时,页面会把新的名称、账号和页码参数提交给企微列表接口。
* 关联文件: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);
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 } }) }));
await page.goto('/asset/#/phone-assets');
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
const link = page.getByRole('link', { name: '外部号码' });
await expect(link).toHaveAttribute('href', '#/reference/wecom?phoneAssetId=9');
});
\ No newline at end of file
## Context
企业微信资产目前只能分页查询;手机号资产仅能在自己的页面创建。`as_phone_asset` 尚未保存号码类型和外部来源,企业微信列表也不能按注册手机号筛选。仓库没有可执行的数据库迁移目录,因此数据库变更只作为受控 SQL 交付,绝不在应用启动或本次任务中直接执行。
## Goals / Non-Goals
**Goals:**
- 提供企业微信资产新增表单、关联查询和手机号自动创建。
- 在手机号资产中长期保存并显示“自有号码”或“外部号码”。
- 让外部号码链接到对应来源资产列表,并按手机号资产筛选。
- 用可复用的来源类型字段为后续微信、抖音等页面预留接入点。
**Non-Goals:**
- 不实现设备资产选择或企业微信资产编辑页。
- 不直接连接、修改或迁移任何数据库。
- 不在本次开发中实现尚无创建页面的微信、抖音等资产页。
## Decisions
### 手机号号码类型与来源分开保存
手机号资产新增 `number_type``source_asset_type``source_asset_id``number_type` 只表示号码归属:手机号资产页直接创建为 `SELF`(自有号码),其他资产流程自动创建为 `EXTERNAL`(外部号码);来源字段记录创建它的资产类型和主键。相比把“已有/新建”写入手机号资产,这能避免把一次关联动作误当成号码自身属性。
### 企业微信关联方式单独展示
企业微信资产新增 `phone_link_mode`。选择已有手机号资产时保存 `EXISTING`(已有号码);自动创建手机号资产时保存 `CREATED`(新建号码)。这与手机号资产的号码类型是两条不同维度的信息。
### 原子保存
企业微信资产服务使用事务(把自动创建手机号、创建企微资产、回填来源主键当成一组操作;任一步失败就全部撤销)。相比前端连续调用,可避免只有手机号、没有企微资产的孤立记录。
### 搜索接口按最小字段返回
新增注册主体、手机号和公司人员的只读搜索接口;注册主体返回 ID、名称和简称,手机号返回 ID、号码和号码类型,人员返回 ID 和姓名。前端不直接读取数据库,也不为选择器加载全部数据。
## Risks / Trade-offs
- [历史手机号没有来源] → 数据库交付将历史记录初始化为 `SELF`,与“非手机号资产创建才是外部号码”的规则一致。
- [并发提交同一新号码] → 复用手机号资产表既有“号码 + 删除状态”的唯一约束,并将重复键错误转换为重新读取已有号码。
- [未来来源页面尚未实现] → 当前仅企业微信具备完整跳转;来源类型字段和前端映射为未来页面预留扩展点。
- [数据库脚本不在仓库] → 交付明确的字段与回填要求,实际执行必须获得目标数据库和执行窗口的单独授权。
## Migration Plan
1. 在受控数据库变更中为手机号资产增加号码类型、来源资产类型、来源资产 ID,为企业微信资产增加手机号关联方式。
2. 将历史手机号资产初始化为 `SELF`,并为新字段建立筛选索引。
3. 发布后先验证现有列表仍可正常查询,再验证新建企微资产的已有号码和新建号码两条流程。
4. 回滚时保留新列但停止写入;前后端对空值回退为“自有号码”,避免旧数据不可读。
## Open Questions
- 无。数据库执行时间和目标库授权不属于本次代码实施范围。
## Why
企业微信资料页目前只有只读列表,且布局与手机号资产页不一致。登记企业微信账号时无法复用或安全地新增注册手机号,也无法区分手机号是自有号码还是由外部资产登记流程创建的号码。
## What Changes
- 将“企微资料”页面改为“企业微信资产”,复用手机号资产页的标题、按钮位置、面板宽度和表格内部横向滚动体验。
- 新增企业微信资产创建表单;注册手机号必填,支持搜索已有手机号资产,或在号码不存在时自动创建手机号资产。
- 新增手机号号码类型:直接在手机号资产页创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。
- 为外部号码保存来源资产信息;手机号资产页将“外部号码”显示为无下划线的蓝色链接,点击后进入来源资产列表并按手机号筛选关联记录。
- 新增注册主体与企微号归属人的搜索选择能力;注册主体使用公司简称显示,企微号归属人复用公司人员记录。
- 企业微信资产列表新增注册手机号筛选,并展示“已有号码”或“新建号码”的关联方式。
## Capabilities
### New Capabilities
- `wecom-account-creation`: 创建企业微信资产,并完成注册主体、注册手机号和归属人员的选择与保存。
- `phone-asset-origin-tracking`: 保存手机号号码类型及来源资产,并支持从外部号码跳转到对应资产列表。
- `asset-reference-lookups`: 为企业微信资产表单提供注册主体、手机号资产和公司人员的搜索选择接口。
### Modified Capabilities
- 无;仓库没有可修改的主规格目录。
## Impact
- 前端:企业微信资产页、手机号资产页、对应 API 客户端、公共样式及 Playwright 测试。
- 后端:企业微信和手机号资产的 Controller、Service、DTO、响应对象与单元测试。
- 数据库:手机号资产表新增号码类型和来源资产字段;不在本次实现中直接执行数据库变更。
## ADDED Requirements
### Requirement: 资产引用搜索
系统 SHALL 提供公司档案、手机号资产和公司人员的只读搜索接口,供企业微信资产表单选择引用资产。
#### Scenario: 搜索注册主体
- **WHEN** 用户输入公司名称或简称
- **THEN** 系统 MUST 返回有效公司档案的 ID、公司名称和简称
#### Scenario: 搜索注册手机号
- **WHEN** 用户输入手机号片段
- **THEN** 系统 MUST 返回有效手机号资产的 ID、手机号与号码类型,且不返回已删除资产
## ADDED Requirements
### Requirement: 手机号号码类型
系统 SHALL 为手机号资产保存并显示号码类型;手机号资产页直接创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。
#### Scenario: 显示外部号码
- **WHEN** 手机号资产的号码类型为外部号码
- **THEN** 手机号资产列表 MUST 以无下划线的蓝色文字显示“外部号码”
### Requirement: 外部号码来源跳转
系统 SHALL 为外部号码保存来源资产,并允许用户点击号码类型跳转到来源资产列表且按该手机号筛选。
#### Scenario: 跳转企业微信资产
- **WHEN** 用户点击来源为企业微信资产的外部号码
- **THEN** 系统 MUST 打开企业微信资产列表并仅显示关联该手机号资产的记录
## ADDED Requirements
### Requirement: 创建企业微信资产
系统 SHALL 在企业微信资产页提供“新增企业微信资产”按钮及表单;企微名称和注册手机号 MUST 必填,企微别名 MUST 默认填入“记忆力梅老师-助教老师”。
#### Scenario: 以已有号码创建
- **WHEN** 用户选择一个有效的已有手机号资产并保存企业微信资产
- **THEN** 系统 MUST 保存该手机号资产 ID,并在企业微信资产列表显示“已有号码”
#### Scenario: 以新号码创建
- **WHEN** 用户输入一个不存在的有效手机号并保存企业微信资产
- **THEN** 系统 MUST 原子地创建外部号码手机号资产与企业微信资产,并在列表显示“新建号码”
### Requirement: 企业微信资产表单字段
系统 SHALL 提供注册主体搜索、注册手机号搜索、实名状态单选、性别单选与企微号归属人搜索;注册主体和归属人不是必填项,设备 ID 不在表单中出现。
#### Scenario: 默认实名状态
- **WHEN** 用户打开新增企业微信资产表单
- **THEN** 实名状态 MUST 默认选择“在职”,并只允许“在职”或“离职”之一
## 1. 数据模型与后端接口
- [x] 1.1 为手机号资产和企业微信资产补充号码来源、关联方式及对应 DTO/响应字段。
- [x] 1.2 实现注册主体、手机号资产和公司人员的只读搜索接口。
- [x] 1.3 实现企业微信资产创建接口,并在新手机号场景中原子创建手机号资产。
- [x] 1.4 实现按手机号资产筛选企业微信资产的查询兼容逻辑。
## 2. 前端页面与交互
- [x] 2.1 将企业微信资产页改为手机号资产页一致的标题、按钮、面板和宽表布局。
- [x] 2.2 实现企业微信资产新增弹窗及注册主体、注册手机号、归属人员的搜索选择。
- [x] 2.3 在手机号资产列表显示号码类型,并实现外部号码蓝色无下划线跳转。
- [x] 2.4 实现企业微信资产页接收手机号筛选参数并显示关联记录。
## 3. 数据库交付与验证
- [x] 3.1 编写受控数据库变更说明:新增字段、历史数据回填和索引要求;不直接执行数据库变更。
- [x] 3.2 补充后端单元测试,覆盖已有号码、新建外部号码、搜索和筛选。
- [x] 3.3 补充 Playwright 测试,覆盖新增表单、号码类型展示与跳转筛选。
- [x] 3.4 运行 OpenSpec 校验及前后端相关测试。
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