Commit 870deb9d by DaiJiezhang

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

feat: 设备图片性能优化、企业微信资产增删改与可清空字段修复

See merge request !11
parents fc0e6702 ea21e468
...@@ -12,7 +12,8 @@ backend/target/ ...@@ -12,7 +12,8 @@ backend/target/
F_TMP_WRITE_TEST.txt F_TMP_WRITE_TEST.txt
localhost localhost
wechat-page.png wechat-page.png
backend/src/main/resources/application.yml # application.yml 不再忽略:里面的数据库账号密码和 JWT 密钥全是 ${} 环境变量占位符,
# 真值在 .env(仍然忽略)。挡住它只会让新克隆的人缺配置、后端起不来。
start-backend.bat start-backend.bat
# Local JDK setup, IDE-generated repository notes, and test artifacts. # Local JDK setup, IDE-generated repository notes, and test artifacts.
.qoder/ .qoder/
......
...@@ -5,8 +5,10 @@ import com.xyw.console.asset.service.DeviceAssetService; ...@@ -5,8 +5,10 @@ import com.xyw.console.asset.service.DeviceAssetService;
import com.xyw.console.auth.PagePermissionService; import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse; import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.time.Duration;
import java.util.List; import java.util.List;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory; import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
...@@ -36,6 +38,20 @@ public class DeviceAssetController { ...@@ -36,6 +38,20 @@ public class DeviceAssetController {
/** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */ /** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */
@GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { permissions.requireAdministrator(); return ApiResponse.success(service.searchCompanyPersons(keyword)); } @GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { permissions.requireAdministrator(); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
/** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */ /** 代码作用(白话):返回按前缀顺延的下一个设备编号名称,供新增弹窗一键填充。关联文件:DeviceNameSuggestionResponse.java、DeviceAssetView.js。关联逻辑(调用链/数据流):一键编号 -> 全库最大编号 -> prefix+N+号机 -> 输入框。 */
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { permissions.requireAdministrator(); Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); } @GetMapping("/lookups/next-device-name") public ApiResponse<DeviceNameSuggestionResponse> nextDeviceName(@RequestParam(defaultValue="") String prefix) { permissions.requireAdministrator(); return ApiResponse.success(new DeviceNameSuggestionResponse(service.suggestNextDeviceName(prefix))); }
/**
* 代码作用(白话):按不透明标识读取设备图片,variant=thumb 时返回列表用的小缩略图,不返回服务器路径。
* 关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。
* 关联逻辑(调用链/数据流):img URL -> findImage/findThumbnail -> Resource -> 浏览器预览。
* 缓存头是安全的:文件名是随机 UUID,同一个标识的内容永远不会被改写,换图必然换标识。
* 用 private 而不是 public:这些图受登录态保护,不能被共享代理缓存后发给别人。
*/
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier, @RequestParam(required=false) String variant) {
permissions.requireAdministrator();
Resource resource="thumb".equals(variant)?service.findThumbnail(identifier):service.findImage(identifier);
MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePrivate().immutable()).contentType(type).body(resource);
}
} }
...@@ -11,16 +11,16 @@ import org.springframework.web.bind.annotation.*; ...@@ -11,16 +11,16 @@ import org.springframework.web.bind.annotation.*;
public class PhoneAssetController { public class PhoneAssetController {
private final PhoneAssetService service; private final PhoneAssetService service;
private final PagePermissionService permissions; private final PagePermissionService permissions;
/** 代码作用(白话):接收手机号资产接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */ /** 代码作用(白话):接收手机号码管理接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */
/** 代码作用(白话):接收手机号业务服务和页面权限服务。关联文件:PagePermissionService.java、PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 请求 -> READ/EDIT 校验 -> 原有业务服务。 */ /** 代码作用(白话):接收手机号业务服务和页面权限服务。关联文件:PagePermissionService.java、PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 请求 -> READ/EDIT 校验 -> 原有业务服务。 */
public PhoneAssetController(PhoneAssetService service, PagePermissionService permissions){this.service=service;this.permissions=permissions;} public PhoneAssetController(PhoneAssetService service, PagePermissionService permissions){this.service=service;this.permissions=permissions;}
/** 代码作用(白话):接收浏览器分页查询并返回统一 JSON。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):GET /api/phone-assets -> Service -> ApiResponse -> Vue 表格。 */ /** 代码作用(白话):接收浏览器分页查询并返回统一 JSON。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):GET /api/phone-assets -> Service -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){permissions.require(PagePermissionService.PHONE,"READ");return ApiResponse.success(service.page(query));} @GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){permissions.require(PagePermissionService.PHONE,"READ");return ApiResponse.success(service.page(query));}
/** 代码作用(白话):接收新增表单并创建手机号资产。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):POST -> DTO -> Service.create -> ApiResponse -> 弹窗。 */ /** 代码作用(白话):接收新增表单并创建手机号码管理。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):POST -> DTO -> Service.create -> ApiResponse -> 弹窗。 */
@PostMapping public ApiResponse<PhoneAssetResponse> create(@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");return ApiResponse.success("新增成功",service.create(request));} @PostMapping public ApiResponse<PhoneAssetResponse> create(@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");return ApiResponse.success("新增成功",service.create(request));}
/** 代码作用(白话):接收编辑表单并更新允许修改的字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):PUT -> Service.update -> ApiResponse -> 列表刷新。 */ /** 代码作用(白话):接收编辑表单并更新允许修改的字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):PUT -> Service.update -> ApiResponse -> 列表刷新。 */
@PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");return ApiResponse.success("编辑成功",service.update(id,request));} @PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");return ApiResponse.success("编辑成功",service.update(id,request));}
/** 代码作用(白话):软删除没有关联阻止的手机号资产。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):DELETE -> Service.softDelete -> ApiResponse -> 列表刷新。 */ /** 代码作用(白话):软删除没有关联阻止的手机号码管理。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):DELETE -> Service.softDelete -> ApiResponse -> 列表刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){permissions.require(PagePermissionService.PHONE,"EDIT");service.softDelete(id);return ApiResponse.success("删除成功",null);} @DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){permissions.require(PagePermissionService.PHONE,"EDIT");service.softDelete(id);return ApiResponse.success("删除成功",null);}
/** 代码作用(白话):按设备名称关键字搜索可关联的设备,供弹窗"关联设备"下拉使用。这里用手机号页面的 READ 权限而不是设备页的管理员权限,避免只有手机号权限的用户搜不到设备。关联文件:DeviceAssetLookupResponse.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):下拉输入 -> GET /lookups/devices -> Service 模糊查询 -> 选项列表。 */ /** 代码作用(白话):按设备名称关键字搜索可关联的设备,供弹窗"关联设备"下拉使用。这里用手机号页面的 READ 权限而不是设备页的管理员权限,避免只有手机号权限的用户搜不到设备。关联文件:DeviceAssetLookupResponse.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):下拉输入 -> GET /lookups/devices -> Service 模糊查询 -> 选项列表。 */
@GetMapping("/lookups/devices") public ApiResponse<List<DeviceAssetLookupResponse>> devices(@RequestParam(defaultValue="") String keyword){permissions.require(PagePermissionService.PHONE,"READ");return ApiResponse.success(service.searchDevices(keyword));} @GetMapping("/lookups/devices") public ApiResponse<List<DeviceAssetLookupResponse>> devices(@RequestParam(defaultValue="") String keyword){permissions.require(PagePermissionService.PHONE,"READ");return ApiResponse.success(service.searchDevices(keyword));}
......
...@@ -2,6 +2,7 @@ package com.xyw.console.asset.controller; ...@@ -2,6 +2,7 @@ package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.CompanyPersonLookupResponse; import com.xyw.console.asset.dto.CompanyPersonLookupResponse;
import com.xyw.console.asset.dto.CompanyProfileLookupResponse; 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.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery; import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse; import com.xyw.console.asset.dto.WecomAccountPageResponse;
...@@ -11,8 +12,11 @@ import com.xyw.console.auth.PagePermissionService; ...@@ -11,8 +12,11 @@ import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse; import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List; import java.util.List;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; 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.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
...@@ -33,6 +37,14 @@ public class WecomAccountController { ...@@ -33,6 +37,14 @@ public class WecomAccountController {
@PostMapping @PostMapping
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { permissions.require(PagePermissionService.WECOM, "EDIT"); return ApiResponse.success("新增成功", service.create(request)); } 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. */ /** 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 @GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.page(query)); } 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 { ...@@ -45,6 +57,10 @@ public class WecomAccountController {
@GetMapping("/lookups/phone-assets") @GetMapping("/lookups/phone-assets")
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchPhoneAssets(keyword)); } 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. */ /** 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") @GetMapping("/lookups/company-persons")
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchCompanyPersons(keyword)); } 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()); }
}
package com.xyw.console.asset.dto; package com.xyw.console.asset.dto;
/** 文件用途(白话):承载设备资产的搜索结果,供手机号资产弹窗按设备名称选择关联设备时使用。 */ /** 文件用途(白话):承载设备资产的搜索结果,供手机号码管理弹窗按设备名称选择关联设备时使用。 */
public record DeviceAssetLookupResponse(Long id, String deviceName) {} public record DeviceAssetLookupResponse(Long id, String deviceName) {}
...@@ -2,7 +2,12 @@ package com.xyw.console.asset.dto; ...@@ -2,7 +2,12 @@ package com.xyw.console.asset.dto;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。 */ /**
* 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。
* 缩略图 URL 与原图 URL 分开返回:列表里的 40px 小图和弹窗预览用缩略图(约几十 KB),
* 只有点开大图才请求原图(可达 20MB),否则一页 20 条会拉几十兆图片把页面拖垮。
*/
public record DeviceAssetResponse(Long id, String deviceName, String imageAttachment1Url, String imageAttachment2Url, public record DeviceAssetResponse(Long id, String deviceName, String imageAttachment1Url, String imageAttachment2Url,
String imageAttachment1ThumbUrl, String imageAttachment2ThumbUrl,
Long userPersonId, String userPersonName, String userUsageStatus, String assetRelationStatus, Long userPersonId, String userPersonName, String userUsageStatus, String assetRelationStatus,
LocalDateTime createTime, LocalDateTime updateTime) {} LocalDateTime createTime, LocalDateTime updateTime) {}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载"下一个可用设备编号名称"的建议值,供新增弹窗一键填充。 */
public record DeviceNameSuggestionResponse(String deviceName) {}
...@@ -3,7 +3,7 @@ package com.xyw.console.asset.dto; ...@@ -3,7 +3,7 @@ package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收手机号资产列表的分页和筛选条件,不改变新增、编辑接口的数据结构。 */ /** 文件用途(白话):接收手机号码管理列表的分页和筛选条件,不改变新增、编辑接口的数据结构。 */
public record PhoneAssetPageQuery( public record PhoneAssetPageQuery(
@Min(1) Integer page, @Min(1) Integer page,
@Min(1) @Max(100) Integer size, @Min(1) @Max(100) Integer size,
......
...@@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank; ...@@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
/** /**
* 代码作用(白话):限定新增和编辑时浏览器可以提交的手机号资产字段。 * 代码作用(白话):限定新增和编辑时浏览器可以提交的手机号码管理字段。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。 * 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):弹窗表单 -> DTO 校验 -> Service 写入允许修改的实体字段。 * 关联逻辑(调用链/数据流):弹窗表单 -> DTO 校验 -> Service 写入允许修改的实体字段。
*/ */
......
...@@ -12,5 +12,6 @@ public record WecomAccountSaveRequest( ...@@ -12,5 +12,6 @@ public record WecomAccountSaveRequest(
String realNameOwner, String realNameOwner,
String realNameOwnerStatus, String realNameOwnerStatus,
String gender, String gender,
Long deviceId,
Long operatorPersonId) { Long operatorPersonId) {
} }
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
...@@ -16,11 +17,17 @@ import lombok.EqualsAndHashCode; ...@@ -16,11 +17,17 @@ import lombok.EqualsAndHashCode;
@TableName("as_asset_device") @TableName("as_asset_device")
public class AssetDeviceEntity extends AssetBaseEntity { public class AssetDeviceEntity extends AssetBaseEntity {
private String deviceName; private String deviceName;
/** 必须显式指定列名:MyBatis-Plus 的驼峰转下划线不会在数字前加下划线,会把 imageAttachment1 推成 image_attachment1,与表中的 image_attachment_1 对不上并抛 BadSqlGrammarException。 */ /**
@TableField("image_attachment_1") * 必须显式指定列名:MyBatis-Plus 的驼峰转下划线不会在数字前加下划线,会把 imageAttachment1 推成 image_attachment1,与表中的 image_attachment_1 对不上并抛 BadSqlGrammarException。
* updateStrategy 必须写 ALWAYS:MyBatis-Plus 默认的 NOT_NULL 会把值为 null 的字段整个从 UPDATE 的 SET 子句里剔掉,
* 于是"移除图片"设置的 null 永远写不进数据库——接口返回成功、update_time 也变了,唯独图片引用还在,列表刷新后图片照旧显示。
*/
@TableField(value = "image_attachment_1", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment1; private String imageAttachment1;
@TableField("image_attachment_2") @TableField(value = "image_attachment_2", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment2; private String imageAttachment2;
/** 同上:使用人允许清空,不写 ALWAYS 的话清空操作同样会被默默丢弃。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long userPersonId; private Long userPersonId;
private String userUsageStatus; private String userUsageStatus;
private String assetRelationStatus; private String assetRelationStatus;
......
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
...@@ -18,6 +20,8 @@ public class DouyinAccountEntity extends AssetBaseEntity { ...@@ -18,6 +20,8 @@ public class DouyinAccountEntity extends AssetBaseEntity {
private String realNameOwner; private String realNameOwner;
private Long companyProfileId; private Long companyProfileId;
private Long phoneAssetId; private Long phoneAssetId;
/** 同企微:目前没有编辑接口,这里提前声明策略,避免将来补编辑功能时"取消关联设备"静默失效。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId; private Long deviceId;
private Long operatorPersonId; private Long operatorPersonId;
} }
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
...@@ -15,11 +17,22 @@ import lombok.EqualsAndHashCode; ...@@ -15,11 +17,22 @@ import lombok.EqualsAndHashCode;
@TableName("as_phone_asset") @TableName("as_phone_asset")
public class PhoneAssetEntity extends AssetBaseEntity { public class PhoneAssetEntity extends AssetBaseEntity {
private String phoneNumber; private String phoneNumber;
/**
* 以下五个字段都是编辑弹窗里允许清空的(运营商、管理模式、关联设备是 clearable 下拉,ICCID 和实名人是可删空的输入框),
* 所以必须写 ALWAYS:MyBatis-Plus 默认的 NOT_NULL 会把值为 null 的字段整个从 UPDATE 的 SET 子句里剔掉,
* 于是"清空后保存"接口返回成功、update_time 也变了,唯独这一列的旧值原封不动,刷新后又冒出来。
* 这里能安全放开的前提是 PhoneAssetService.update 走的是"先查全量实体再改字段",没有只 set 部分字段的局部更新。
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String cardType; private String cardType;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String iccid; private String iccid;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String realNameOwner; private String realNameOwner;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String managementType; private String managementType;
private String disposalStatus; private String disposalStatus;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId; private Long deviceId;
private String numberType; private String numberType;
private String sourceAssetType; private String sourceAssetType;
......
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
...@@ -17,6 +19,8 @@ public class WechatAccountEntity extends AssetBaseEntity { ...@@ -17,6 +19,8 @@ public class WechatAccountEntity extends AssetBaseEntity {
private String wechatId; private String wechatId;
private String realNameOwner; private String realNameOwner;
private Long phoneAssetId; private Long phoneAssetId;
/** 同企微:目前没有编辑接口,这里提前声明策略,避免将来补编辑功能时"取消关联设备"静默失效。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId; private Long deviceId;
private Long operatorPersonId; private Long operatorPersonId;
} }
package com.xyw.console.asset.entity; package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
...@@ -23,6 +25,11 @@ public class WecomAccountEntity extends AssetBaseEntity { ...@@ -23,6 +25,11 @@ public class WecomAccountEntity extends AssetBaseEntity {
private String realNameOwner; private String realNameOwner;
private String realNameOwnerStatus; private String realNameOwnerStatus;
private String gender; private String gender;
/**
* 这条目前还没有编辑接口,清空路径走不到,这里是提前立规矩:一旦补上编辑功能,
* 默认的 NOT_NULL 策略会让"取消关联设备"静默失效(设备资产的移除图片就是这么坏的)。
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId; private Long deviceId;
private Long operatorPersonId; private Long operatorPersonId;
} }
package com.xyw.console.asset.exception; package com.xyw.console.asset.exception;
/** 代码作用(白话):表示请求的手机号资产不存在或已软删除。关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查无记录 -> 异常 -> 404 响应。 */ /** 代码作用(白话):表示请求的手机号码管理不存在或已软删除。关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查无记录 -> 异常 -> 404 响应。 */
public class PhoneAssetNotFoundException extends RuntimeException { public PhoneAssetNotFoundException(String message){super(message);} } public class PhoneAssetNotFoundException extends RuntimeException { public PhoneAssetNotFoundException(String message){super(message);} }
\ No newline at end of file
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); }
}
...@@ -29,6 +29,8 @@ import java.util.List; ...@@ -29,6 +29,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
...@@ -37,6 +39,10 @@ import org.springframework.stereotype.Service; ...@@ -37,6 +39,10 @@ import org.springframework.stereotype.Service;
public class DeviceAssetService { public class DeviceAssetService {
private static final Set<String> USAGE_STATUSES = Set.of("\u4f7f\u7528\u4e2d", "\u95f2\u7f6e", "\u7ef4\u4fee\u4e2d", "\u505c\u7528"); private static final Set<String> USAGE_STATUSES = Set.of("\u4f7f\u7528\u4e2d", "\u95f2\u7f6e", "\u7ef4\u4fee\u4e2d", "\u505c\u7528");
private static final Set<String> RELATION_STATUSES = Set.of("\u5df2\u5173\u8054", "\u672a\u5173\u8054", "\u5f85\u786e\u8ba4"); private static final Set<String> RELATION_STATUSES = Set.of("\u5df2\u5173\u8054", "\u672a\u5173\u8054", "\u5f85\u786e\u8ba4");
/** \u672a\u6307\u5b9a\u524d\u7f00\u65f6\u7684\u9ed8\u8ba4\u7f16\u53f7\u524d\u7f00\uff0c\u5bf9\u5e94\u6700\u5e38\u89c1\u7684\u4e00\u6279\u8bbe\u5907\u3002 */
private static final String DEFAULT_DEVICE_NAME_PREFIX = "\u5b66\u7ba1\u5e08";
private static final Pattern SAFE_PREFIX = Pattern.compile("[\\u4e00-\\u9fa5A-Za-z0-9]{1,20}");
private static final Pattern NUMBERED_SUFFIX = Pattern.compile("(\\d{1,6})\u53f7\u673a");
private final AssetDeviceMapper deviceMapper; private final AssetDeviceMapper deviceMapper;
private final CompanyPersonMapper personMapper; private final CompanyPersonMapper personMapper;
private final PhoneAssetMapper phoneMapper; private final PhoneAssetMapper phoneMapper;
...@@ -97,9 +103,35 @@ public class DeviceAssetService { ...@@ -97,9 +103,35 @@ public class DeviceAssetService {
.stream().limit(20).map(item->new DevicePersonLookupResponse(item.getId(),item.getPersonName())).toList(); .stream().limit(20).map(item->new DevicePersonLookupResponse(item.getId(),item.getPersonName())).toList();
} }
/**
* Plain purpose: suggest the next free "<prefix>N号机" name so the dialog can fill a sequential number in one click.
* Related files: DeviceAssetController.java, DeviceAssetView.js.
* Flow: 一键编号 -> prefix 前缀匹配查询 -> 取现有最大编号 +1 -> 输入框。
* 必须查数据库而不是只看当前页:列表一页只有 20 条,最大编号很可能在别的页上,
* 只按当前页推算会算出一个已存在的名字,保存时被唯一性校验直接打回。
*/
public String suggestNextDeviceName(String prefix) {
String base=hasText(prefix)?prefix.trim():DEFAULT_DEVICE_NAME_PREFIX;
// 前缀直接拼进 LIKE 条件,必须限定字符集:% 和 _ 会被当通配符,其他符号也没有作为设备前缀的意义。
if(!SAFE_PREFIX.matcher(base).matches()) throw new DeviceAssetValidationException("设备名称前缀只能是 1-20 位中文、字母或数字");
int largest=0;
// 用 likeRight 让 SQL 只捞前缀命中的行,剩下的形状判断放在 Java 侧:LIKE 表达不了"后面必须是纯数字加号机"。
for(AssetDeviceEntity item:deviceMapper.selectList(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).likeRight(AssetDeviceEntity::getDeviceName,base))) {
String name=item.getDeviceName();
if(name==null||!name.startsWith(base)) continue;
Matcher matcher=NUMBERED_SUFFIX.matcher(name.substring(base.length()));
// 位数上限交给正则:设备编号不会有七位数,放开会让脏数据把 parseInt 撑爆。
if(matcher.matches()) largest=Math.max(largest,Integer.parseInt(matcher.group(1)));
}
return base+(largest+1)+"号机";
}
/** Plain purpose: resolve a safe image identifier to a controlled resource. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: image URL -> service -> storage -> response body. */ /** Plain purpose: resolve a safe image identifier to a controlled resource. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: image URL -> service -> storage -> response body. */
public Resource findImage(String identifier) { return fileStorage.resolve(identifier); } public Resource findImage(String identifier) { return fileStorage.resolve(identifier); }
/** Plain purpose: resolve the small list/preview thumbnail, generating it once for images stored before thumbnails existed. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: thumb URL -> service -> storage -> cached small JPEG. */
public Resource findThumbnail(String identifier) { return fileStorage.resolveThumbnail(identifier); }
/** Plain purpose: combine active-only and optional page filters. Related files: DeviceAssetPageQuery.java, AssetDeviceEntity.java. Flow: query DTO -> LambdaQueryWrapper -> SQL WHERE. */ /** Plain purpose: combine active-only and optional page filters. Related files: DeviceAssetPageQuery.java, AssetDeviceEntity.java. Flow: query DTO -> LambdaQueryWrapper -> SQL WHERE. */
private LambdaQueryWrapper<AssetDeviceEntity> activeQuery(DeviceAssetPageQuery query) { private LambdaQueryWrapper<AssetDeviceEntity> activeQuery(DeviceAssetPageQuery query) {
return new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).like(hasText(query.deviceName()),AssetDeviceEntity::getDeviceName,query.deviceName()).eq(query.userPersonId()!=null,AssetDeviceEntity::getUserPersonId,query.userPersonId()).eq(hasText(query.userUsageStatus()),AssetDeviceEntity::getUserUsageStatus,query.userUsageStatus()).eq(hasText(query.assetRelationStatus()),AssetDeviceEntity::getAssetRelationStatus,query.assetRelationStatus()).orderByDesc(AssetDeviceEntity::getId); return new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).like(hasText(query.deviceName()),AssetDeviceEntity::getDeviceName,query.deviceName()).eq(query.userPersonId()!=null,AssetDeviceEntity::getUserPersonId,query.userPersonId()).eq(hasText(query.userUsageStatus()),AssetDeviceEntity::getUserUsageStatus,query.userUsageStatus()).eq(hasText(query.assetRelationStatus()),AssetDeviceEntity::getAssetRelationStatus,query.assetRelationStatus()).orderByDesc(AssetDeviceEntity::getId);
...@@ -146,9 +178,11 @@ public class DeviceAssetService { ...@@ -146,9 +178,11 @@ public class DeviceAssetService {
/** Plain purpose: resolve person IDs in one query to avoid row-by-row lookups. Related files: CompanyPersonEntity.java, DeviceAssetResponse.java. Flow: IDs -> mapper IN query -> name map -> response. */ /** Plain purpose: resolve person IDs in one query to avoid row-by-row lookups. Related files: CompanyPersonEntity.java, DeviceAssetResponse.java. Flow: IDs -> mapper IN query -> name map -> response. */
private Map<Long,String> personNames(Set<Long> ids) { if(ids.isEmpty()) return Map.of(); Map<Long,String> result=new HashMap<>(); personMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>().in(CompanyPersonEntity::getId,ids).eq(CompanyPersonEntity::getDeleteTime,0L)).forEach(item->result.put(item.getId(),item.getPersonName())); return result; } private Map<Long,String> personNames(Set<Long> ids) { if(ids.isEmpty()) return Map.of(); Map<Long,String> result=new HashMap<>(); personMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>().in(CompanyPersonEntity::getId,ids).eq(CompanyPersonEntity::getDeleteTime,0L)).forEach(item->result.put(item.getId(),item.getPersonName())); return result; }
/** Plain purpose: expose safe URLs rather than internal image identifiers or paths. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: entity -> URL conversion -> API JSON -> image tag. */ /** Plain purpose: expose safe URLs rather than internal image identifiers or paths. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: entity -> URL conversion -> API JSON -> image tag. */
private DeviceAssetResponse toResponse(AssetDeviceEntity entity,Map<Long,String> names) { return new DeviceAssetResponse(entity.getId(),entity.getDeviceName(),imageUrl(entity.getImageAttachment1()),imageUrl(entity.getImageAttachment2()),entity.getUserPersonId(),entity.getUserPersonId()==null?null:names.get(entity.getUserPersonId()),entity.getUserUsageStatus(),entity.getAssetRelationStatus(),entity.getCreateTime(),entity.getUpdateTime()); } private DeviceAssetResponse toResponse(AssetDeviceEntity entity,Map<Long,String> names) { return new DeviceAssetResponse(entity.getId(),entity.getDeviceName(),imageUrl(entity.getImageAttachment1()),imageUrl(entity.getImageAttachment2()),thumbnailUrl(entity.getImageAttachment1()),thumbnailUrl(entity.getImageAttachment2()),entity.getUserPersonId(),entity.getUserPersonId()==null?null:names.get(entity.getUserPersonId()),entity.getUserUsageStatus(),entity.getAssetRelationStatus(),entity.getCreateTime(),entity.getUpdateTime()); }
/** Plain purpose: translate a stored opaque image identifier into a controlled URL. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: identifier -> imageUrl -> browser GET. */ /** Plain purpose: translate a stored opaque image identifier into a controlled URL. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: identifier -> imageUrl -> browser GET. */
private String imageUrl(String identifier) { return identifier==null||identifier.isBlank()?null:"/api/device-assets/files/"+identifier; } private String imageUrl(String identifier) { return identifier==null||identifier.isBlank()?null:"/api/device-assets/files/"+identifier; }
/** Plain purpose: point the list and dialog preview at the small derived image instead of the full-size original. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: identifier -> thumbnailUrl -> browser GET with variant=thumb. */
private String thumbnailUrl(String identifier) { String url=imageUrl(identifier); return url==null?null:url+"?variant=thumb"; }
/** Plain purpose: copy editable request fields into a database entity. Related files: DeviceAssetSaveRequest.java, AssetDeviceEntity.java. Flow: DTO -> entity -> mapper insert/update. */ /** Plain purpose: copy editable request fields into a database entity. Related files: DeviceAssetSaveRequest.java, AssetDeviceEntity.java. Flow: DTO -> entity -> mapper insert/update. */
private void applyEditableFields(AssetDeviceEntity entity,DeviceAssetSaveRequest request,String image1,String image2) { entity.setDeviceName(request.getDeviceName().trim()); entity.setUserPersonId(request.getUserPersonId()); entity.setUserUsageStatus(request.getUserUsageStatus()); entity.setAssetRelationStatus(request.getAssetRelationStatus()); entity.setImageAttachment1(image1); entity.setImageAttachment2(image2); } private void applyEditableFields(AssetDeviceEntity entity,DeviceAssetSaveRequest request,String image1,String image2) { entity.setDeviceName(request.getDeviceName().trim()); entity.setUserPersonId(request.getUserPersonId()); entity.setUserUsageStatus(request.getUserUsageStatus()); entity.setAssetRelationStatus(request.getAssetRelationStatus()); entity.setImageAttachment1(image1); entity.setImageAttachment2(image2); }
/** Plain purpose: decide whether a text filter has meaningful input. Related files: DeviceAssetPageQuery.java, DeviceAssetService.java. Flow: HTTP query -> filter presence -> SQL predicate. */ /** Plain purpose: decide whether a text filter has meaningful input. Related files: DeviceAssetPageQuery.java, DeviceAssetService.java. Flow: HTTP query -> filter presence -> SQL predicate. */
......
...@@ -23,7 +23,7 @@ public class PhoneAssetService { ...@@ -23,7 +23,7 @@ public class PhoneAssetService {
private static final int DEVICE_LOOKUP_LIMIT = 20; private static final int DEVICE_LOOKUP_LIMIT = 20;
private final PhoneAssetMapper mapper; private final PhoneAssetMapper mapper;
private final AssetDeviceMapper deviceMapper; private final AssetDeviceMapper deviceMapper;
/** 代码作用(白话):接收手机号资产表和设备资产表的数据库访问入口。关联文件:PhoneAssetMapper.java、AssetDeviceMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_phone_asset / as_asset_device。 */ /** 代码作用(白话):接收手机号码管理表和设备资产表的数据库访问入口。关联文件:PhoneAssetMapper.java、AssetDeviceMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_phone_asset / as_asset_device。 */
public PhoneAssetService(PhoneAssetMapper mapper, AssetDeviceMapper deviceMapper) { this.mapper = mapper; this.deviceMapper = deviceMapper; } public PhoneAssetService(PhoneAssetMapper mapper, AssetDeviceMapper deviceMapper) { this.mapper = mapper; this.deviceMapper = deviceMapper; }
/** 代码作用(白话):按分页、手机号片段、ICCID、实名人和使用状态读取未删除资产,并补上关联设备名称。关联文件:PhoneAssetPageQuery.java、PhoneAssetController.java。关联逻辑(调用链/数据流):GET 参数 -> 查询条件 -> Mapper -> 设备名批量解析 -> Response。 */ /** 代码作用(白话):按分页、手机号片段、ICCID、实名人和使用状态读取未删除资产,并补上关联设备名称。关联文件:PhoneAssetPageQuery.java、PhoneAssetController.java。关联逻辑(调用链/数据流):GET 参数 -> 查询条件 -> Mapper -> 设备名批量解析 -> Response。 */
public PhoneAssetPageResponse page(PhoneAssetPageQuery query) { public PhoneAssetPageResponse page(PhoneAssetPageQuery query) {
...@@ -60,14 +60,14 @@ public class PhoneAssetService { ...@@ -60,14 +60,14 @@ public class PhoneAssetService {
} }
/** 代码作用(白话):判断筛选文本是否有内容,避免空字符串参与数据库筛选。关联文件:PhoneAssetPageQuery.java、PhoneAssetService.java。关联逻辑(调用链/数据流):请求参数 -> hasText -> 是否追加条件。 */ /** 代码作用(白话):判断筛选文本是否有内容,避免空字符串参与数据库筛选。关联文件:PhoneAssetPageQuery.java、PhoneAssetService.java。关联逻辑(调用链/数据流):请求参数 -> hasText -> 是否追加条件。 */
private boolean hasText(String value) { return value != null && !value.isBlank(); } 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。 */ /** 代码作用(白话):新增手机号码管理并初始化审计字段和内部关联快照。关联文件: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); 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,deviceNames(singleDeviceId(entity.getDeviceId()))); } 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,deviceNames(singleDeviceId(entity.getDeviceId()))); }
/** 代码作用(白话):更新有效资产的用户可写字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):PUT 请求 -> Service 查找 -> Mapper.updateById -> Response。 */ /** 代码作用(白话):更新有效资产的用户可写字段。关联文件: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,deviceNames(singleDeviceId(entity.getDeviceId()))); } 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,deviceNames(singleDeviceId(entity.getDeviceId()))); }
/** 代码作用(白话):将资产标记为删除,供 Controller 删除接口调用。关联文件:PhoneAssetController.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE 请求 -> Service -> Mapper.updateById。 */ /** 代码作用(白话):将资产标记为删除,供 Controller 删除接口调用。关联文件:PhoneAssetController.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE 请求 -> Service -> Mapper.updateById。 */
public void softDelete(Long id) { PhoneAssetEntity entity=requireActiveEntity(id); entity.setDeleteTime(System.currentTimeMillis()); entity.setUpdateTime(LocalDateTime.now()); if(mapper.updateById(entity)!=1) throw new PhoneAssetNotFoundException("手机号资产不存在或已删除"); } public void softDelete(Long id) { PhoneAssetEntity entity=requireActiveEntity(id); entity.setDeleteTime(System.currentTimeMillis()); entity.setUpdateTime(LocalDateTime.now()); if(mapper.updateById(entity)!=1) throw new PhoneAssetNotFoundException("手机号码管理不存在或已删除"); }
/** 代码作用(白话):读取一条仍有效的资产,供编辑和删除共用。关联文件:PhoneAssetMapper.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404 异常。 */ /** 代码作用(白话):读取一条仍有效的资产,供编辑和删除共用。关联文件:PhoneAssetMapper.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404 异常。 */
private PhoneAssetEntity requireActiveEntity(Long id){ PhoneAssetEntity entity=mapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime,0L).eq(PhoneAssetEntity::getId,id)); if(entity==null) throw new PhoneAssetNotFoundException("手机号资产不存在或已删除"); return entity; } private PhoneAssetEntity requireActiveEntity(Long id){ PhoneAssetEntity entity=mapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime,0L).eq(PhoneAssetEntity::getId,id)); if(entity==null) throw new PhoneAssetNotFoundException("手机号码管理不存在或已删除"); return entity; }
/** 代码作用(白话):把表单允许提交的字段写入实体,并统一处理手机号和默认使用状态。关联文件:PhoneAssetSaveRequest.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):弹窗表单 -> DTO -> Entity -> Mapper 保存。 */ /** 代码作用(白话):把表单允许提交的字段写入实体,并统一处理手机号和默认使用状态。关联文件:PhoneAssetSaveRequest.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):弹窗表单 -> DTO -> Entity -> Mapper 保存。 */
private void applyEditableFields(PhoneAssetEntity entity, PhoneAssetSaveRequest request){ entity.setPhoneNumber(normalizePhoneNumber(request.phoneNumber())); entity.setCardType(request.cardType()); entity.setIccid(request.iccid()); entity.setRealNameOwner(request.realNameOwner()); entity.setManagementType(request.managementType()); entity.setDisposalStatus(request.disposalStatus()==null||request.disposalStatus().isBlank()?"正常使用":request.disposalStatus()); entity.setDeviceId(request.deviceId()); } private void applyEditableFields(PhoneAssetEntity entity, PhoneAssetSaveRequest request){ entity.setPhoneNumber(normalizePhoneNumber(request.phoneNumber())); entity.setCardType(request.cardType()); entity.setIccid(request.iccid()); entity.setRealNameOwner(request.realNameOwner()); entity.setManagementType(request.managementType()); entity.setDisposalStatus(request.disposalStatus()==null||request.disposalStatus().isBlank()?"正常使用":request.disposalStatus()); entity.setDeviceId(request.deviceId()); }
/** 代码作用(白话):按已确认规则清理手机号并验证其最终格式。关联文件:PhoneAssetSaveRequest.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):前端输入 -> 去空格和 +86 -> 格式失败返回 400。 */ /** 代码作用(白话):按已确认规则清理手机号并验证其最终格式。关联文件:PhoneAssetSaveRequest.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):前端输入 -> 去空格和 +86 -> 格式失败返回 400。 */
......
...@@ -18,7 +18,7 @@ public class PagePermissionService { ...@@ -18,7 +18,7 @@ public class PagePermissionService {
public static final String PHONE = "phone-assets"; public static final String PHONE = "phone-assets";
public static final String COMPANY_PROFILE = "company-profile"; public static final String COMPANY_PROFILE = "company-profile";
public static final String ALERTS = "alerts"; public static final String ALERTS = "alerts";
private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号资产", COMPANY_PROFILE, "公司档案", ALERTS, "提醒中心"); private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号码管理", COMPANY_PROFILE, "公司档案", ALERTS, "提醒中心");
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** 代码作用(白话):接收 JSON 工具以读取数据库权限映射;关联文件:SystemUserEntity.java。关联逻辑(调用链/数据流):page_permissions JSON -> 权限 Map -> Controller 判定。 */ /** 代码作用(白话):接收 JSON 工具以读取数据库权限映射;关联文件:SystemUserEntity.java。关联逻辑(调用链/数据流):page_permissions JSON -> 权限 Map -> Controller 判定。 */
......
...@@ -6,10 +6,12 @@ import org.springframework.jdbc.BadSqlGrammarException; ...@@ -6,10 +6,12 @@ import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/** 文件用途(白话):把未被业务模块处理的数据库和服务器异常统一转换为页面可展示的 JSON 错误,避免浏览器把非认证故障误认为未登录。 */ /** 文件用途(白话):把未被业务模块处理的数据库和服务器异常统一转换为页面可展示的 JSON 错误,避免浏览器把非认证故障误认为未登录。 */
@RestControllerAdvice @RestControllerAdvice
...@@ -52,6 +54,27 @@ public class GlobalExceptionHandler { ...@@ -52,6 +54,27 @@ public class GlobalExceptionHandler {
return ApiResponse.error(400, "请求格式不正确,请刷新页面后重试"); 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 -> 页面错误提示。 */ /** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
......
spring:
application:
name: xyw-console-backend
config:
import: optional:file:.env[.properties]
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${XYW_DB_URL}
username: ${XYW_DB_USERNAME}
password: ${XYW_DB_PASSWORD}
server:
port: 7689
xyw:
auth:
# 部署环境必须提供至少 32 字符的随机签名密钥;仓库不保存真实密钥。
jwt-secret: ${XYW_AUTH_JWT_SECRET:}
session-hours: 8
# 登录失败统一补齐到该毫秒数,消除"账号不存在/账号停用/密码错误"之间的响应耗时差异,防止据此枚举有效账号。
# 取值必须高于最慢失败路径(查库 + BCrypt 比对),本机实测 cost 12 比对约 244ms,故默认 400ms。
login-failure-min-millis: ${XYW_AUTH_LOGIN_FAILURE_MIN_MILLIS:400}
# HTTPS 部署设为 true,避免浏览器通过非加密连接发送会话 Cookie。
cookie-secure: ${XYW_AUTH_COOKIE_SECURE:false}
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
...@@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; ...@@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
...@@ -47,7 +48,7 @@ class DeviceAssetControllerTest { ...@@ -47,7 +48,7 @@ class DeviceAssetControllerTest {
} }
/** 代码作用(白话):验证删除被关联资产阻断时,接口返回 400 和可读错误信息。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):DELETE -> Service.softDelete 异常 -> Advice -> 400 JSON。*/ /** 代码作用(白话):验证删除被关联资产阻断时,接口返回 400 和可读错误信息。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):DELETE -> Service.softDelete 异常 -> Advice -> 400 JSON。*/
@Test void returnsBadRequestWhenDeleteIsBlocked() throws Exception { @Test void returnsBadRequestWhenDeleteIsBlocked() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);doThrow(new DeviceAssetValidationException("设备仍被手机号码资产引用,不能删除")).when(service).softDelete(1L); DeviceAssetService service=mock(DeviceAssetService.class);doThrow(new DeviceAssetValidationException("设备仍被手机号码管理引用,不能删除")).when(service).softDelete(1L);
mockMvc(service).perform(delete("/api/device-assets/1")).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(400)); mockMvc(service).perform(delete("/api/device-assets/1")).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(400));
} }
/** Plain purpose: verify the edit endpoint binds multipart fields and delegates to the update service. Related files: DeviceAssetController.java, DeviceAssetSaveRequest.java. Flow: multipart PUT -> model binding -> service.update -> success JSON. */ /** Plain purpose: verify the edit endpoint binds multipart fields and delegates to the update service. Related files: DeviceAssetController.java, DeviceAssetSaveRequest.java. Flow: multipart PUT -> model binding -> service.update -> success JSON. */
...@@ -61,6 +62,15 @@ class DeviceAssetControllerTest { ...@@ -61,6 +62,15 @@ class DeviceAssetControllerTest {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.findImage("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{7,8}) { @Override public String getFilename(){return "opaque.png";} }); DeviceAssetService service=mock(DeviceAssetService.class);when(service.findImage("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{7,8}) { @Override public String getFilename(){return "opaque.png";} });
mockMvc(service).perform(get("/api/device-assets/files/opaque.png")).andExpect(status().isOk()).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{7,8})); mockMvc(service).perform(get("/api/device-assets/files/opaque.png")).andExpect(status().isOk()).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{7,8}));
} }
/** Plain purpose: prove variant=thumb serves the derived small image and that both variants carry a long private cache header. Related files: DeviceAssetFileStorageService.java, DeviceAssetView.js. Flow: thumb URL -> findThumbnail -> cached small JPEG bytes. */
@Test void servesThumbnailVariantWithLongLivedPrivateCache() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);
when(service.findThumbnail("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{1,2}) { @Override public String getFilename(){return "opaque.thumb.jpg";} });
mockMvc(service).perform(get("/api/device-assets/files/opaque.png").param("variant","thumb")).andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{1,2}))
.andExpect(header().string("Cache-Control","max-age=31536000, private, immutable"));
verify(service,org.mockito.Mockito.never()).findImage(any());
}
/** Plain purpose: assert the public list response has URLs but never deleteTime or a disk path field. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: service response -> ApiResponse JSON -> browser. */ /** Plain purpose: assert the public list response has URLs but never deleteTime or a disk path field. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: service response -> ApiResponse JSON -> browser. */
@Test void hidesDeleteTimeAndPhysicalImagePath() throws Exception { @Test void hidesDeleteTimeAndPhysicalImagePath() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20)); DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
...@@ -76,7 +86,8 @@ class DeviceAssetControllerTest { ...@@ -76,7 +86,8 @@ class DeviceAssetControllerTest {
assertThrows(AccessDeniedException.class, () -> controller.update(1L, new DeviceAssetSaveRequest())); assertThrows(AccessDeniedException.class, () -> controller.update(1L, new DeviceAssetSaveRequest()));
assertThrows(AccessDeniedException.class, () -> controller.delete(1L)); assertThrows(AccessDeniedException.class, () -> controller.delete(1L));
assertThrows(AccessDeniedException.class, () -> controller.companyPersons("name")); assertThrows(AccessDeniedException.class, () -> controller.companyPersons("name"));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png")); assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png", null));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png", "thumb"));
verifyNoInteractions(service); verifyNoInteractions(service);
} finally { SecurityContextHolder.clearContext(); } } finally { SecurityContextHolder.clearContext(); }
} }
...@@ -84,5 +95,5 @@ class DeviceAssetControllerTest { ...@@ -84,5 +95,5 @@ class DeviceAssetControllerTest {
/** Plain purpose: create an administrator-authorized controller test harness without a live Spring Security filter chain. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: mock permission check -> controller endpoint -> mocked device service -> HTTP assertion. */ /** Plain purpose: create an administrator-authorized controller test harness without a live Spring Security filter chain. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: mock permission check -> controller endpoint -> mocked device service -> HTTP assertion. */
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service,mock(PagePermissionService.class))).setControllerAdvice(new DeviceAssetExceptionHandler()).build();} private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service,mock(PagePermissionService.class))).setControllerAdvice(new DeviceAssetExceptionHandler()).build();}
/** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):服务层响应 -> ApiResponse -> 页面展示。*/ /** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):服务层响应 -> ApiResponse -> 页面展示。*/
private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,9L,"张三","使用中","未关联",null,null);} private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,"/api/device-assets/files/image.png?variant=thumb",null,9L,"张三","使用中","未关联",null,null);}
} }
...@@ -24,7 +24,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; ...@@ -24,7 +24,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class PhoneAssetControllerTest { class PhoneAssetControllerTest {
/** /**
* 代码作用(白话):证明浏览器可以通过新增接口创建手机号资产 * 代码作用(白话):证明浏览器可以通过新增接口创建手机号码管理
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetSaveRequest.java。 * 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetSaveRequest.java。
* 关联逻辑(调用链/数据流):POST /api/phone-assets -> Controller -> Service -> Mapper -> as_phone_asset。 * 关联逻辑(调用链/数据流):POST /api/phone-assets -> Controller -> Service -> Mapper -> as_phone_asset。
*/ */
......
...@@ -48,6 +48,24 @@ class WecomAccountControllerTest { ...@@ -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,避免只验证模拟返回值。 * 代码作用(白话):构造使用真实 Service 的 Controller,避免只验证模拟返回值。
* 关联文件:WecomAccountController.java、WecomAccountService.java、各资产 Mapper。 * 关联文件:WecomAccountController.java、WecomAccountService.java、各资产 Mapper。
* 关联逻辑(调用链/数据流):测试 HTTP 请求 -> Controller -> Service -> Mapper 模拟数据库结果。 * 关联逻辑(调用链/数据流):测试 HTTP 请求 -> Controller -> Service -> Mapper 模拟数据库结果。
...@@ -67,7 +85,9 @@ class WecomAccountControllerTest { ...@@ -67,7 +85,9 @@ class WecomAccountControllerTest {
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device())); 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())); org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService( 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 { ...@@ -108,9 +128,9 @@ class WecomAccountControllerTest {
} }
/** /**
* 代码作用(白话):构造手机号资产关联测试数据。 * 代码作用(白话):构造手机号码管理关联测试数据。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。 * 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):手机号资产 ID -> 手机号 -> JSON 字段 phoneNumber。 * 关联逻辑(调用链/数据流):手机号码管理 ID -> 手机号 -> JSON 字段 phoneNumber。
*/ */
private PhoneAssetEntity phoneAsset() { private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity(); PhoneAssetEntity entity = new PhoneAssetEntity();
......
package com.xyw.console.asset.entity;
import static org.junit.jupiter.api.Assertions.assertFalse;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/**
* 文件用途(白话):验证"业务上允许清空"的列真的会出现在 UPDATE 语句里。
*
* 为什么要有这个测试:MyBatis-Plus 的 updateById 默认用 FieldStrategy.NOT_NULL,
* 实体里值为 null 的字段会被整个从 SET 子句里剔掉。表现极具欺骗性——接口返回成功、
* update_time 也更新了,唯独那一列的旧值纹丝不动,刷新后又冒出来。
*
* 这个测试直接检查生成的 SQL 片段,而不是 mock 掉 Mapper:
* 设备资产"移除图片保存后列表还显示旧图"就是因为原来的 Service 测试 mock 了 Mapper、
* SQL 从头到尾没被验证过,才让 bug 一路漏到线上。
*/
class NullableColumnUpdateTest {
/** 代码作用(白话):逐个确认可清空列不会被 null 判断包裹。关联文件:各资产 Entity、对应 Service 的 update 方法。关联逻辑(调用链/数据流):表单清空 -> entity 字段置 null -> updateById -> SET 子句必须包含该列。 */
@DisplayName("可清空的列必须留在 UPDATE 的 SET 子句里")
@ParameterizedTest(name = "{0}.{1}")
@CsvSource({
// 设备资产:移除图片、清空使用人
"AssetDeviceEntity, image_attachment_1",
"AssetDeviceEntity, image_attachment_2",
"AssetDeviceEntity, user_person_id",
// 手机号码管理:运营商、ICCID、实名人、管理模式、关联设备都能在弹窗里清空
"PhoneAssetEntity, card_type",
"PhoneAssetEntity, iccid",
"PhoneAssetEntity, real_name_owner",
"PhoneAssetEntity, management_type",
"PhoneAssetEntity, device_id",
// 这三类目前还没有编辑接口,先把规矩立住,补编辑功能时不会重蹈覆辙
"WecomAccountEntity, device_id",
"WechatAccountEntity, device_id",
"DouyinAccountEntity, device_id"
})
void keepsNullableColumnsInsideTheUpdateStatement(String entityName, String column) throws Exception {
Class<?> entity = Class.forName(getClass().getPackageName() + "." + entityName.trim());
TableInfo table = TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), entity);
String assignment = table.getAllSqlSet(false, "et.").lines().filter(line -> line.contains(column.trim() + "=#{et.")).findFirst()
.orElseThrow(() -> new AssertionError(entityName + " 的 " + column + " 没有出现在 UPDATE 的 SET 子句里"));
// NOT_NULL 策略会把赋值包进 <if test="et['xxx'] != null">,一旦出现就说明置空又会被悄悄丢弃。
// 按整行判断而不是匹配具体表达式:MyBatis-Plus 换个写法(et.x / et['x'])断言也不会失效。
assertFalse(assignment.contains("<if"), entityName + " 的 " + column + " 被 null 判断包裹,置空将写不进数据库");
}
}
...@@ -32,9 +32,38 @@ class DeviceAssetFileStorageServiceTest { ...@@ -32,9 +32,38 @@ class DeviceAssetFileStorageServiceTest {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.png","image/png",new byte[]{1,2,3})));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.txt","text/plain",png())));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","large.png","image/png",new byte[20*1024*1024+1]))); DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.png","image/png",new byte[]{1,2,3})));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.txt","text/plain",png())));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","large.png","image/png",new byte[20*1024*1024+1])));
} }
/** 代码作用(白话):验证文件标识不能离开设备图片根目录。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL 标识 -> resolve -> 根目录校验。 */ /** 代码作用(白话):验证文件标识不能离开设备图片根目录。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL 标识 -> resolve -> 根目录校验。 */
@Test void rejectsPathTraversalIdentifier(@TempDir Path directory) { DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.resolve("../secret.png")); } @Test void rejectsPathTraversalIdentifier(@TempDir Path directory) { DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.resolve("../secret.png"));assertThrows(DeviceAssetValidationException.class,()->storage.resolveThumbnail("../secret.png")); }
/** 代码作用(白话):验证保存时会产出一张长边不超过 240 像素、体积远小于原图的缩略图。关联文件:DeviceAssetFileStorageService.java、DeviceAssetView.js。关联逻辑(调用链/数据流):大图 -> store -> 缩略图文件 -> 列表小图请求。 */
@Test void writesBoundedThumbnailAlongsideOriginal(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","big.png","image/png",image("png",1600,900)));
// 必须关流:Windows 下未关闭的文件句柄会锁住 @TempDir,导致测试结束时删不掉临时目录。
BufferedImage thumbnail;
try (var stream=storage.resolveThumbnail(identifier).getInputStream()) { thumbnail=ImageIO.read(stream); }
assertTrue(Math.max(thumbnail.getWidth(),thumbnail.getHeight())<=240,"缩略图长边应被限制在 240 像素内");
assertTrue(Math.abs(thumbnail.getWidth()/(double)thumbnail.getHeight()-1600/900.0)<0.05,"缩略图应保持原图宽高比");
assertTrue(storage.resolveThumbnail(identifier).contentLength()<storage.resolve(identifier).contentLength(),"缩略图应明显小于原图");
}
/** 代码作用(白话):验证缩略图出现前上传的历史图片,在首次请求时会被补生成并落盘复用。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):缩略图缺失 -> resolveThumbnail -> 即时生成 -> 写盘 -> 后续直接命中。 */
@Test void regeneratesThumbnailForImagesStoredBeforeThisFeature(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","legacy.png","image/png",image("png",800,600)));
Path thumbnail=directory.resolve(identifier.substring(0,identifier.lastIndexOf('.'))+".thumb.jpg");
Files.delete(thumbnail);
assertTrue(storage.resolveThumbnail(identifier).exists());
assertTrue(Files.exists(thumbnail),"补生成的缩略图应落盘,避免每次请求都重新解码");
}
/** 代码作用(白话):验证原图被清理后请求缩略图会报图片不存在,而不是抛出解码异常。关联文件:DeviceAssetFileStorageService.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):原图缺失 -> resolveThumbnail -> 业务异常 -> 400。 */
@Test void rejectsThumbnailRequestWhenOriginalIsGone(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","gone.png","image/png",png()));
storage.cleanupNewFile(identifier);
assertThrows(DeviceAssetValidationException.class,()->storage.resolveThumbnail(identifier));
}
/** 代码作用(白话):在测试中生成 ImageIO 必然可解码的 PNG 字节。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> PNG 字节 -> MockMultipartFile -> 服务校验。 */ /** 代码作用(白话):在测试中生成 ImageIO 必然可解码的 PNG 字节。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> PNG 字节 -> MockMultipartFile -> 服务校验。 */
private byte[] png() { try { ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } } private byte[] png() { try { ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
/** 代码作用(白话):按指定格式生成可被 ImageIO 解码的小图片,避免测试数据本身失真。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> 指定格式字节 -> MockMultipartFile -> 上传格式校验。*/ /** 代码作用(白话):按指定格式生成可被 ImageIO 解码的小图片,避免测试数据本身失真。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> 指定格式字节 -> MockMultipartFile -> 上传格式校验。*/
private byte[] image(String format) { try { ByteArrayOutputStream output=new ByteArrayOutputStream();if(!ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_RGB),format,output)){throw new IllegalStateException("测试运行环境不支持图片格式:"+format);}return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } } private byte[] image(String format) { return image(format,1,1); }
/** 代码作用(白话):按指定尺寸生成可解码图片,用来验证缩略图的等比缩放和长边上限。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):宽高 -> 图片字节 -> store -> 缩略图断言。*/
private byte[] image(String format,int width,int height) { try { ByteArrayOutputStream output=new ByteArrayOutputStream();if(!ImageIO.write(new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB),format,output)){throw new IllegalStateException("测试运行环境不支持图片格式:"+format);}return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
} }
...@@ -47,7 +47,7 @@ class DeviceAssetServiceTest { ...@@ -47,7 +47,7 @@ class DeviceAssetServiceTest {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectCount(any(Wrapper.class))).thenReturn(1L); AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class)).create(validRequest("重复设备"))); assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class)).create(validRequest("重复设备")));
} }
/** 代码作用(白话):验证手机号码资产仍关联设备时,删除请求会被阻断,且不更新设备删除时间。关联文件:DeviceAssetService.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE -> 查询设备 -> 手机号码引用计数 -> 400 业务异常。*/ /** 代码作用(白话):验证手机号码管理仍关联设备时,删除请求会被阻断,且不更新设备删除时间。关联文件:DeviceAssetService.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE -> 查询设备 -> 手机号码引用计数 -> 400 业务异常。*/
@Test void blocksDeleteWhenPhoneAssetStillReferencesDevice() { @Test void blocksDeleteWhenPhoneAssetStillReferencesDevice() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);PhoneAssetMapper phones=mock(PhoneAssetMapper.class);when(devices.selectOne(any(Wrapper.class))).thenReturn(activeDevice(1L));when(phones.selectCount(any(Wrapper.class))).thenReturn(1L); AssetDeviceMapper devices=mock(AssetDeviceMapper.class);PhoneAssetMapper phones=mock(PhoneAssetMapper.class);when(devices.selectOne(any(Wrapper.class))).thenReturn(activeDevice(1L));when(phones.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class),phones,new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images")).softDelete(1L)); assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class),phones,new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images")).softDelete(1L));
...@@ -70,7 +70,32 @@ class DeviceAssetServiceTest { ...@@ -70,7 +70,32 @@ class DeviceAssetServiceTest {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(3L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device); DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(3L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);
DeviceAssetSaveRequest request=validRequest("update-failure");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png())); DeviceAssetSaveRequest request=validRequest("update-failure");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png()));
assertThrows(com.xyw.console.asset.exception.DeviceAssetNotFoundException.class,()->service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(3L,request)); assertThrows(com.xyw.console.asset.exception.DeviceAssetNotFoundException.class,()->service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(3L,request));
try(var files=Files.list(directory)){assertEquals(1,files.count());}assertTrue(Files.exists(directory.resolve(original))); assertEquals(1,countOriginals(directory));assertTrue(Files.exists(directory.resolve(original)));
}
/** Plain purpose: suggest the number after the largest existing one, ignoring rows whose suffix is not a plain 号机 number. Related files: DeviceAssetService.java, DeviceAssetView.js. Flow: 一键编号 -> 前缀匹配行 -> 最大编号+1。 */
@Test void suggestsTheNumberAfterTheLargestExistingDevice() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);
when(devices.selectList(any(Wrapper.class))).thenReturn(List.of(named("学管师1号机"),named("学管师7号机"),named("学管师3号机"),named("学管师备用机"),named("学管师12号机备注")));
assertEquals("学管师8号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(""));
}
/** Plain purpose: start at one when nothing matches the prefix yet, and honour a caller-supplied prefix. Related files: DeviceAssetService.java, DeviceAssetView.js. Flow: 空结果 -> prefix+1号机。 */
@Test void startsNumberingAtOneAndHonoursCustomPrefix() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectList(any(Wrapper.class))).thenReturn(List.of());
assertEquals("学管师1号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(""));
assertEquals("班主任1号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(" 班主任 "));
}
/** Plain purpose: refuse prefixes that would smuggle LIKE wildcards or unbounded text into the query. Related files: DeviceAssetService.java, DeviceAssetExceptionHandler.java. Flow: 非法前缀 -> 校验 -> 400。 */
@Test void rejectsUnsafeDeviceNamePrefix() {
DeviceAssetService service=service(mock(AssetDeviceMapper.class),mock(CompanyPersonMapper.class));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("%"));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("学管师_"));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("学".repeat(21)));
}
/** Plain purpose: ignore absurdly long digit runs so a bad row cannot overflow the counter parse. Related files: DeviceAssetService.java. Flow: 七位以上编号 -> 正则不匹配 -> 忽略该行。 */
@Test void ignoresDeviceNumbersBeyondSixDigits() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);
when(devices.selectList(any(Wrapper.class))).thenReturn(List.of(named("学管师2号机"),named("学管师99999999999999999999号机")));
assertEquals("学管师3号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName("学管师"));
} }
/** Plain purpose: reject a supplied company-person ID when that person is no longer active. Related files: DeviceAssetService.java, CompanyPersonMapper.java. Flow: POST userPersonId -> active-person count -> validation error before insert. */ /** Plain purpose: reject a supplied company-person ID when that person is no longer active. Related files: DeviceAssetService.java, CompanyPersonMapper.java. Flow: POST userPersonId -> active-person count -> validation error before insert. */
@Test void rejectsDeletedUserPersonBeforeCreate() { @Test void rejectsDeletedUserPersonBeforeCreate() {
...@@ -98,7 +123,7 @@ class DeviceAssetServiceTest { ...@@ -98,7 +123,7 @@ class DeviceAssetServiceTest {
@Test void updateReplacesImageWithoutDeletingOriginal(@TempDir Path directory) throws Exception { @Test void updateReplacesImageWithoutDeletingOriginal(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(5L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);when(devices.updateById(any(AssetDeviceEntity.class))).thenReturn(1); DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(5L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);when(devices.updateById(any(AssetDeviceEntity.class))).thenReturn(1);
DeviceAssetSaveRequest request=validRequest("replace-image");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png()));String responseUrl=service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(5L,request).imageAttachment1Url(); DeviceAssetSaveRequest request=validRequest("replace-image");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png()));String responseUrl=service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(5L,request).imageAttachment1Url();
assertTrue(Files.exists(directory.resolve(original)));assertTrue(!responseUrl.endsWith(original));try(var files=Files.list(directory)){assertEquals(2,files.count());} assertTrue(Files.exists(directory.resolve(original)));assertTrue(!responseUrl.endsWith(original));assertEquals(2,countOriginals(directory));
} }
/** Plain purpose: verify explicit removal clears the database-facing URL but retains the original physical file for recovery. Related files: DeviceAssetService.java, DeviceAssetFileStorageService.java. Flow: PUT remove flag -> null reference -> mapper update; source file stays. */ /** Plain purpose: verify explicit removal clears the database-facing URL but retains the original physical file for recovery. Related files: DeviceAssetService.java, DeviceAssetFileStorageService.java. Flow: PUT remove flag -> null reference -> mapper update; source file stays. */
@Test void updateRemovalClearsImageReferenceButRetainsFile(@TempDir Path directory) throws Exception { @Test void updateRemovalClearsImageReferenceButRetainsFile(@TempDir Path directory) throws Exception {
...@@ -122,4 +147,8 @@ class DeviceAssetServiceTest { ...@@ -122,4 +147,8 @@ class DeviceAssetServiceTest {
private AssetDeviceEntity activeDevice(Long id){AssetDeviceEntity device=new AssetDeviceEntity();device.setId(id);device.setDeviceName("删除测试设备");device.setUserUsageStatus("使用中");device.setAssetRelationStatus("未关联");device.setDeleteTime(0L);return device;} private AssetDeviceEntity activeDevice(Long id){AssetDeviceEntity device=new AssetDeviceEntity();device.setId(id);device.setDeviceName("删除测试设备");device.setUserUsageStatus("使用中");device.setAssetRelationStatus("未关联");device.setDeleteTime(0L);return device;}
/** 代码作用(白话):生成 ImageIO 可解码的 PNG 字节,确保失败清理测试验证的是业务流程。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> MultipartFile -> 文件保存 -> 异常清理。*/ /** 代码作用(白话):生成 ImageIO 可解码的 PNG 字节,确保失败清理测试验证的是业务流程。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> MultipartFile -> 文件保存 -> 异常清理。*/
private byte[] png(){try{ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray();}catch(Exception exception){throw new IllegalStateException(exception);}} private byte[] png(){try{ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray();}catch(Exception exception){throw new IllegalStateException(exception);}}
/** 代码作用(白话):生成一条只带名称的设备行,用于编号建议的纯计算断言。关联文件:AssetDeviceEntity.java、DeviceAssetService.java。关联逻辑(调用链/数据流):Mapper.selectList -> 名称解析 -> 下一个编号。*/
private AssetDeviceEntity named(String deviceName){AssetDeviceEntity device=new AssetDeviceEntity();device.setDeviceName(deviceName);return device;}
/** 代码作用(白话):只统计原图数量,忽略每张图派生出的缩略图。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):上传目录 -> 过滤 .thumb.jpg -> 原图张数断言。这里刻意不数总文件数:派生文件是实现细节,测试要断言的是"新图被清理、原图还在"。*/
private long countOriginals(Path directory){try(var files=Files.list(directory)){return files.filter(file->!file.getFileName().toString().endsWith(".thumb.jpg")).count();}catch(Exception exception){throw new IllegalStateException(exception);}}
} }
...@@ -6,7 +6,10 @@ import java.sql.SQLIntegrityConstraintViolationException; ...@@ -6,7 +6,10 @@ import java.sql.SQLIntegrityConstraintViolationException;
import java.sql.SQLSyntaxErrorException; import java.sql.SQLSyntaxErrorException;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpMethod;
import org.springframework.jdbc.BadSqlGrammarException; import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/** 文件用途(白话):验证未处理的数据库异常会被转换成页面可以直接展示的统一错误消息。 */ /** 文件用途(白话):验证未处理的数据库异常会被转换成页面可以直接展示的统一错误消息。 */
class GlobalExceptionHandlerTest { class GlobalExceptionHandlerTest {
...@@ -27,4 +30,22 @@ class GlobalExceptionHandlerTest { ...@@ -27,4 +30,22 @@ class GlobalExceptionHandlerTest {
assertEquals(500, response.code()); assertEquals(500, response.code());
assertEquals("数据库字段未同步,请完成数据库迁移后重试", response.message()); 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());
}
} }
...@@ -30,5 +30,5 @@ export default { ...@@ -30,5 +30,5 @@ export default {
return { authState, logout, canVisit, isAdministrator, retryConnection }; return { authState, logout, canVisit, isAdministrator, retryConnection };
}, },
template: `<main v-if="authState.connectionError" class="auth-network-error" role="alert"><section class="auth-network-error__panel"><h1>网络连接异常</h1><p>暂时无法确认登录状态,请检查网络后重试。</p><button type="button" @click="retryConnection">重新连接</button></section></main><main v-else-if="!authState.ready" class="auth-loading" role="status" aria-live="polite">正在验证登录状态…</main><RouterView v-else-if="$route.path === '/login'" /><div v-else class="app-shell"><aside class="sidebar"><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink v-if="canVisit('overview')" to="/overview">总览</RouterLink><RouterLink v-if="canVisit('domain')" to="/domain">域名资料</RouterLink><RouterLink v-if="canVisit('company-profile')" to="/company-profiles">公司档案</RouterLink><RouterLink v-if="canVisit('reference-wecom')" to="/reference/wecom">企微资料</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号资产</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>` template: `<main v-if="authState.connectionError" class="auth-network-error" role="alert"><section class="auth-network-error__panel"><h1>网络连接异常</h1><p>暂时无法确认登录状态,请检查网络后重试。</p><button type="button" @click="retryConnection">重新连接</button></section></main><main v-else-if="!authState.ready" class="auth-loading" role="status" aria-live="polite">正在验证登录状态…</main><RouterView v-else-if="$route.path === '/login'" /><div v-else class="app-shell"><aside class="sidebar"><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink v-if="canVisit('overview')" to="/overview">总览</RouterLink><RouterLink v-if="canVisit('domain')" to="/domain">域名资料</RouterLink><RouterLink v-if="canVisit('company-profile')" to="/company-profiles">公司档案</RouterLink><RouterLink v-if="canVisit('reference-wecom')" to="/reference/wecom">企业微信资产</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号码管理</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>`
}; };
...@@ -156,7 +156,7 @@ export default { ...@@ -156,7 +156,7 @@ export default {
<section class="phone-asset-list-page company-profile-page"> <section class="phone-asset-list-page company-profile-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> <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" @keydown.enter.prevent="submitSearch" /><el-button @click="resetSearch">重置</el-button></el-form></section> <section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="公司名称、简称、信用代码、地址、联系人或联系方式" clearable @input="scheduleSearch" @clear="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>公司档案列表</h3><span>共 {{ total }} 条</span></header><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid company-profile-page__grid"><el-table-column label="公司名称" min-width="200"><template #default="{ row }">{{ formatValue(row.companyName) }}</template></el-table-column><el-table-column label="公司简称" min-width="150"><template #default="{ row }">{{ formatValue(row.shortName) }}</template></el-table-column><el-table-column label="统一社会信用代码" min-width="210"><template #default="{ row }">{{ formatValue(row.unifiedSocialCreditCode) }}</template></el-table-column><el-table-column label="地址" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ formatValue(row.address) }}</template></el-table-column><el-table-column label="联系人" min-width="130"><template #default="{ row }">{{ formatValue(row.contactName) }}</template></el-table-column><el-table-column label="联系方式" min-width="170"><template #default="{ row }">{{ formatValue(row.contactValue) }}</template></el-table-column><el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatValue(row.createTime) }}</template></el-table-column><el-table-column label="更新时间" min-width="180"><template #default="{ row }">{{ formatValue(row.updateTime) }}</template></el-table-column></el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize" /></section> <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 company-profile-page__grid"><el-table-column label="公司名称" min-width="200"><template #default="{ row }">{{ formatValue(row.companyName) }}</template></el-table-column><el-table-column label="公司简称" min-width="150"><template #default="{ row }">{{ formatValue(row.shortName) }}</template></el-table-column><el-table-column label="统一社会信用代码" min-width="210"><template #default="{ row }">{{ formatValue(row.unifiedSocialCreditCode) }}</template></el-table-column><el-table-column label="地址" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ formatValue(row.address) }}</template></el-table-column><el-table-column label="联系人" min-width="130"><template #default="{ row }">{{ formatValue(row.contactName) }}</template></el-table-column><el-table-column label="联系方式" min-width="170"><template #default="{ row }">{{ formatValue(row.contactValue) }}</template></el-table-column><el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatValue(row.createTime) }}</template></el-table-column><el-table-column label="更新时间" min-width="180"><template #default="{ row }">{{ formatValue(row.updateTime) }}</template></el-table-column></el-table></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"> <el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" title="新增公司档案" width="560px">
<el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate"> <el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate">
<el-form-item class="phone-asset-modal__form-row" label="公司名称" required><el-input v-model="form.companyName" maxlength="100" autocomplete="off" placeholder="请输入公司名称" /></el-form-item> <el-form-item class="phone-asset-modal__form-row" label="公司名称" required><el-input v-model="form.companyName" maxlength="100" autocomplete="off" placeholder="请输入公司名称" /></el-form-item>
......
...@@ -11,3 +11,5 @@ export function updateDeviceAsset(id, form) { return request('/api/device-assets ...@@ -11,3 +11,5 @@ export function updateDeviceAsset(id, form) { return request('/api/device-assets
export function deleteDeviceAsset(id) { return request('/api/device-assets/' + id, { method: 'DELETE' }); } export function deleteDeviceAsset(id) { return request('/api/device-assets/' + id, { method: 'DELETE' }); }
/** 代码作用(白话):搜索可作为设备使用人的公司人员。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):远程选择器 -> GET lookup -> 人员选项。 */ /** 代码作用(白话):搜索可作为设备使用人的公司人员。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):远程选择器 -> GET lookup -> 人员选项。 */
export function searchDeviceCompanyPersons(keyword) { return request('/api/device-assets/lookups/company-persons?keyword=' + encodeURIComponent(keyword || '')); } export function searchDeviceCompanyPersons(keyword) { return request('/api/device-assets/lookups/company-persons?keyword=' + encodeURIComponent(keyword || '')); }
/** 代码作用(白话):问后端"这个前缀下一个空编号是几号机"。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):一键编号按钮 -> GET next-device-name -> 全库最大编号+1 -> 设备名称输入框。 */
export function suggestNextDeviceName(prefix) { return request('/api/device-assets/lookups/next-device-name?prefix=' + encodeURIComponent(prefix || '')); }
/* 文件用途(白话):仅为设备资产管理页面提供样式,避免修改正在使用的全局 app.css。 */ /* 文件用途(白话):仅为设备资产管理页面提供样式,避免修改正在使用的全局 app.css。 */
.device-asset-page{max-width:1680px;margin:0 auto;padding:30px 4px}.device-asset-page__header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.device-asset-page__header h2{margin:0;font-size:28px}.device-asset-page__panel{padding:20px;margin-bottom:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px}.device-asset-page__filters{display:flex;flex-wrap:wrap;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:210px}.device-asset-page__images{display:flex;gap:14px;flex-wrap:wrap}.device-asset-page__image-slot{width:150px}.device-asset-page__preview{display:flex;width:132px;height:96px;margin:8px 0;align-items:center;justify-content:center;overflow:hidden;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc}.device-asset-page__preview img{width:100%;height:100%;object-fit:cover}.device-asset-page__image-cell img{width:44px;height:44px;object-fit:cover;border-radius:6px;cursor:pointer}/* 固定高度骨架:宽屏下页面锁死一屏,表格内部滚动;断点与 app.css 中的同类规则保持一致。 */ /* 页头、新增按钮和弹窗刻意复用 app.css 里的 .phone-asset-list-page__/.phone-asset-modal__ 类,
与手机号码管理页保持同一套外观;这里只补设备页独有的图片缩略图、占位框和上传位。 */
.device-asset-page{max-width:1680px;margin:0 auto;padding:30px 4px}.device-asset-page__panel{padding:20px;margin-bottom:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px}.device-asset-page__filters{display:flex;flex-wrap:wrap;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:210px}
/* 列表图片列:一行最多两张,鼠标移上去压一层暗色遮罩并露出眼睛图标提示可放大。
图标用内联 SVG,和手机号页的 ICCID 图标一致,避免为几个图标引入 @element-plus/icons-vue(不在依赖里)。 */
.device-asset-page__thumbs{display:flex;align-items:center;gap:6px}
.device-asset-page__thumb{position:relative;display:block;flex:0 0 auto;width:40px;height:40px;padding:0;overflow:hidden;border:1px solid #e5e5e8;border-radius:6px;background:#fff;cursor:pointer}
.device-asset-page__thumb img{display:block;width:100%;height:100%;object-fit:cover}
.device-asset-page__thumb-mask{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.55);color:#fff;opacity:0;transition:opacity .16s ease}
.device-asset-page__thumb:hover .device-asset-page__thumb-mask{opacity:1}
.device-asset-page__thumb-mask svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
/* 无图占位:虚线灰框,保证有图和无图两种行的高度完全一致,列表不会忽高忽低。 */
.device-asset-page__thumb--empty{display:flex;align-items:center;justify-content:center;border-style:dashed;border-color:#dcdce0;background:#fafafa;color:#c4c4cc;cursor:default}
.device-asset-page__thumb--empty svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}
/* 设备名称右侧的"一键编号"按钮:贴在输入框 suffix 位,样式与手机号页 ICCID 图标按钮保持一致。 */
.device-asset-modal__auto-name{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:5px;background:transparent;color:#a1a1aa;cursor:pointer;transition:color .16s ease,background .16s ease}
.device-asset-modal__auto-name:hover:not(:disabled){background:#f1f1f3;color:#18181b}
.device-asset-modal__auto-name:disabled{opacity:.5;cursor:not-allowed}
.device-asset-modal__auto-name svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.35;stroke-linejoin:round}
/* 弹窗图片行:标签仍右对齐在 96px 栏位里,但内容要顶部对齐,否则两个上传框会被 36px 行高压偏。 */
.device-asset-modal__images-row.el-form-item{align-items:start}
.device-asset-modal__images-row .el-form-item__label{line-height:78px}
.device-asset-modal__images-row .el-form-item__content{line-height:normal}
.device-asset-modal__images{display:flex;gap:12px}
.device-asset-modal__preview{position:relative;display:flex;width:104px;height:78px;align-items:center;justify-content:center;overflow:hidden;border:1px dashed #d4d4d8;border-radius:8px;background:#fafafa}
.device-asset-modal__preview--filled{border-style:solid;border-color:#e4e4e7;background:#fff}
.device-asset-modal__preview img{display:block;width:100%;height:100%;object-fit:cover}
/* 整块图都是预览触发区:之前只有遮罩里那个小眼睛能点,点图片中间毫无反应,很容易被当成"坏了"。 */
.device-asset-modal__preview-open{position:absolute;inset:0;display:block;width:100%;height:100%;padding:0;border:0;background:none;cursor:zoom-in}
.device-asset-modal__preview-mask{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;background:rgba(24,24,27,.55);opacity:0;transition:opacity .16s ease}
.device-asset-modal__preview:hover .device-asset-modal__preview-mask{opacity:1}
.device-asset-modal__preview-mask svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
/* 移除角标常驻显示,不藏在 hover 里:删除是低频但要命的操作,找不到入口比多一个角标更糟。 */
.device-asset-modal__preview-remove{position:absolute;top:4px;right:4px;z-index:1;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:rgba(24,24,27,.62);color:#fff;cursor:pointer;transition:background .16s ease}
.device-asset-modal__preview-remove:hover{background:#e5484d}
.device-asset-modal__preview-remove svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round}
/* 空位整块都是上传触发区:省掉一个独立的“选择图片”按钮,弹窗第一行才放得下两个图位。 */
.device-asset-modal__upload,.device-asset-modal__upload .el-upload{display:block;width:100%;height:100%}
.device-asset-modal__placeholder{display:flex;width:100%;height:100%;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:#a1a1aa;font-size:12px;line-height:1}
.device-asset-modal__placeholder svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round}
.device-asset-modal__preview:hover .device-asset-modal__placeholder{color:#71717a}
/* 固定高度骨架:宽屏下页面锁死一屏,表格内部滚动;断点与 app.css 中的同类规则保持一致。 */
@media(min-width:641px){/* 用 min-height 而不是 height:正常一屏不出外层滚动条,窗口过矮时页面被撑高改由内容区整页滚动。 */.device-asset-page{display:flex;flex-direction:column;min-height:100%;padding-bottom:24px}.device-asset-page__header,.device-asset-page__panel,.device-asset-page .app-pagination{flex:0 0 auto}.device-asset-page__table{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;margin-bottom:0}/* 表格必须绝对定位:el-table 会用自身内容高度反向撑开父级,留在文档流里 flex 就收缩不下去。 *//* min-height 220px 是兜底:表头约 40px,再留三行左右可视区,低于这个高度就不再压缩表格。 */.device-asset-page__grid-wrap{position:relative;flex:1 1 auto;min-height:220px}/* height 必须显式写:Element Plus 自带 .el-table{height:fit-content},只给 inset 会被它按内容高度顶掉。 */.device-asset-page__grid-wrap > .el-table{position:absolute;inset:0;height:100%}.device-asset-page__grid-wrap > .el-table > .el-table__inner-wrapper{height:100%}.device-asset-page__grid-wrap .el-table__body-wrapper{flex:1 1 auto;min-height:0}.device-asset-page__grid-wrap .el-table__body-wrapper > .el-scrollbar{height:100%}} @media(min-width:641px){/* 用 min-height 而不是 height:正常一屏不出外层滚动条,窗口过矮时页面被撑高改由内容区整页滚动。 */.device-asset-page{display:flex;flex-direction:column;min-height:100%;padding-bottom:24px}.device-asset-page__header,.device-asset-page__panel,.device-asset-page .app-pagination{flex:0 0 auto}.device-asset-page__table{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;margin-bottom:0}/* 表格必须绝对定位:el-table 会用自身内容高度反向撑开父级,留在文档流里 flex 就收缩不下去。 *//* min-height 220px 是兜底:表头约 40px,再留三行左右可视区,低于这个高度就不再压缩表格。 */.device-asset-page__grid-wrap{position:relative;flex:1 1 auto;min-height:220px}/* height 必须显式写:Element Plus 自带 .el-table{height:fit-content},只给 inset 会被它按内容高度顶掉。 */.device-asset-page__grid-wrap > .el-table{position:absolute;inset:0;height:100%}.device-asset-page__grid-wrap > .el-table > .el-table__inner-wrapper{height:100%}.device-asset-page__grid-wrap .el-table__body-wrapper{flex:1 1 auto;min-height:0}.device-asset-page__grid-wrap .el-table__body-wrapper > .el-scrollbar{height:100%}}
@media(max-width:700px){.device-asset-page{padding:20px 0}.device-asset-page__header{align-items:stretch;flex-direction:column;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:100%}} @media(max-width:700px){.device-asset-page{padding:20px 0}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:100%}}
@media(max-width:600px){.device-asset-modal__images-row .el-form-item__label{line-height:36px}}
import { csrfHeadersFor } from '../auth/auth-api-client.js'; import { csrfHeadersFor } from '../auth/auth-api-client.js';
/** /**
* 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字。 * 代码作用(白话):统一发送手机号码管理请求并把后端错误转换为页面可提示的文字。
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。 * 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误。 * 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误。
*/ */
...@@ -8,16 +8,16 @@ async function request(path, options = {}) { ...@@ -8,16 +8,16 @@ async function request(path, options = {}) {
const headers = await csrfHeadersFor(path, (options.method || 'GET').toUpperCase()); const headers = await csrfHeadersFor(path, (options.method || 'GET').toUpperCase());
const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers }, ...options }); const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers }, ...options });
const payload = await response.json(); const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败'); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号码管理请求失败');
return payload.data; return payload.data;
} }
/** 代码作用(白话):按筛选条件读取手机号资产列表。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):筛选条件 -> GET -> 表格。 */ /** 代码作用(白话):按筛选条件读取手机号码管理列表。关联文件: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 && value !== 'ALL') 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 -> 后端创建。 */ /** 代码作用(白话):提交新增表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 后端创建。 */
export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); } export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */ /** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */
export function updatePhoneAsset(id, form) { return request('/api/phone-assets/' + id, { method: 'PUT', body: JSON.stringify(form) }); } export function updatePhoneAsset(id, form) { return request('/api/phone-assets/' + id, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):确认后删除一条手机号资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 后端软删除。 */ /** 代码作用(白话):确认后删除一条手机号码管理。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 后端软删除。 */
export function deletePhoneAsset(id) { return request('/api/phone-assets/' + id, { method: 'DELETE' }); } export function deletePhoneAsset(id) { return request('/api/phone-assets/' + id, { method: 'DELETE' }); }
/** 代码作用(白话):按设备名称搜索可关联的设备资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):关联设备下拉输入 -> GET lookups/devices -> 设备选项。 */ /** 代码作用(白话):按设备名称搜索可关联的设备资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):关联设备下拉输入 -> GET lookups/devices -> 设备选项。 */
export function searchLinkableDevices(keyword) { return request('/api/phone-assets/lookups/devices?keyword=' + encodeURIComponent(keyword || '')); } export function searchLinkableDevices(keyword) { return request('/api/phone-assets/lookups/devices?keyword=' + encodeURIComponent(keyword || '')); }
...@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'; ...@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js'; import { authState } from '../auth/auth-store.js';
import { createSystemUser, listLockedAccounts, listSystemUsers, resetSystemUserPassword, unlockLoginLock, updateSystemUser } from './system-user-api-client.js'; import { createSystemUser, listLockedAccounts, listSystemUsers, resetSystemUserPassword, unlockLoginLock, updateSystemUser } from './system-user-api-client.js';
const pages = [{ key: 'overview', label: '总览' }, { key: 'domain', label: '域名资料' }, { key: 'company-profile', label: '公司档案' }, { key: 'reference-wecom', label: '企微资料' }, { key: 'phone-assets', label: '手机号资产' }, { key: 'alerts', label: '提醒中心' }]; const pages = [{ key: 'overview', label: '总览' }, { key: 'domain', label: '域名资料' }, { key: 'company-profile', label: '公司档案' }, { key: 'reference-wecom', label: '企微资料' }, { key: 'phone-assets', label: '手机号码管理' }, { key: 'alerts', label: '提醒中心' }];
const roles = [{ value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'FINANCE', label: '财务' }, { value: 'HR', label: '人事' }, { value: 'OPERATIONS', label: '运营' }]; const roles = [{ value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'FINANCE', label: '财务' }, { value: 'HR', label: '人事' }, { value: 'OPERATIONS', label: '运营' }];
/** 代码作用(白话):创建五页均无权限的编辑表单初始值;关联文件:PagePermissionService.java、UserPermissionView.js。关联逻辑(调用链/数据流):新增/编辑打开 -> 本函数 -> 表单权限单选 -> JSON 提交。 */ /** 代码作用(白话):创建五页均无权限的编辑表单初始值;关联文件:PagePermissionService.java、UserPermissionView.js。关联逻辑(调用链/数据流):新增/编辑打开 -> 本函数 -> 表单权限单选 -> JSON 提交。 */
function blankForm() { return { username: '', roleCode: 'FINANCE', status: 'ACTIVE', password: '', pagePermissions: Object.fromEntries(pages.map(page => [page.key, 'NONE'])) }; } function blankForm() { return { username: '', roleCode: 'FINANCE', status: 'ACTIVE', password: '', pagePermissions: Object.fromEntries(pages.map(page => [page.key, 'NONE'])) }; }
......
...@@ -20,6 +20,12 @@ export function listWecomAccounts(query) { ...@@ -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. */ /** 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) }); } 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. */ /** 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 || '')}`); } 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 ...@@ -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. */ /** 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 || '')}`); } 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 || '')}`); }
...@@ -38,7 +38,7 @@ body { margin: 0; } ...@@ -38,7 +38,7 @@ body { margin: 0; }
.reference-card { padding: 24px; border: 1px solid #e2e8f0; border-radius: 14px; background: white; box-shadow: 0 8px 28px rgba(15, 23, 42, .05); } .reference-card { padding: 24px; border: 1px solid #e2e8f0; border-radius: 14px; background: white; box-shadow: 0 8px 28px rgba(15, 23, 42, .05); }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; } .toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
@media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; } .sidebar nav { grid-template-columns: repeat(2, 1fr); } .page-header { display: grid; } .content { padding: 24px; } } @media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; } .sidebar nav { grid-template-columns: repeat(2, 1fr); } .page-header { display: grid; } .content { padding: 24px; } }
/* 手机号资产弹窗:所有规则均以该弹窗类名开头,避免影响项目中的其他 Element Plus 弹窗。 */ /* 手机号码管理弹窗:所有规则均以该弹窗类名开头,避免影响项目中的其他 Element Plus 弹窗。 */
.phone-asset-modal-mask { .phone-asset-modal-mask {
background: rgba(15, 23, 42, 0.34); background: rgba(15, 23, 42, 0.34);
} }
...@@ -281,7 +281,7 @@ body { margin: 0; } ...@@ -281,7 +281,7 @@ body { margin: 0; }
font-weight: 550; font-weight: 550;
background: #f4f4f5; background: #f4f4f5;
} }
/* 手机号资产列表:参考 mobile-number-asset-management-v4.html,仅作用于当前列表页。 */ /* 手机号码管理列表:参考 mobile-number-asset-management-v4.html,仅作用于当前列表页。 */
.phone-asset-list-page { width:100%; max-width:1680px; margin:0 auto; padding:34px 4px 56px; } .phone-asset-list-page { width:100%; max-width:1680px; margin:0 auto; padding:34px 4px 56px; }
.phone-asset-list-page__header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; } .phone-asset-list-page__header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; }
.phone-asset-list-page__header h2 { margin:0; color:#111113; font-size:29px; font-weight:700; line-height:1.25; letter-spacing:-.035em; } .phone-asset-list-page__header h2 { margin:0; color:#111113; font-size:29px; font-weight:700; line-height:1.25; letter-spacing:-.035em; }
...@@ -311,7 +311,8 @@ body { margin: 0; } ...@@ -311,7 +311,8 @@ body { margin: 0; }
.phone-asset-list-page__iccid-pop-label{color:#8b8b93;font-size:12px;letter-spacing:.02em} .phone-asset-list-page__iccid-pop-label{color:#8b8b93;font-size:12px;letter-spacing:.02em}
/* ICCID 是长串数字,等宽字体便于逐位核对;allow-select 让用户能直接框选复制。 */ /* ICCID 是长串数字,等宽字体便于逐位核对;allow-select 让用户能直接框选复制。 */
.phone-asset-list-page__iccid-pop-value{color:#18181b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.5;word-break:break-all;user-select:text} .phone-asset-list-page__iccid-pop-value{color:#18181b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.5;word-break:break-all;user-select:text}
.phone-asset-list-page__table { overflow:hidden; }.phone-asset-list-page__table-header{display:flex;align-items:center;gap:9px;min-height:62px;padding:0 22px;border-bottom:1px solid #e5e5e8}.phone-asset-list-page__table-header h3{margin:0;color:#18181b;font-size:15px;font-weight:650}.phone-asset-list-page__table-header span{color:#8b8b93;font-size:13px} /* 列表卡片不再有"资产列表 共 N 条"标题栏:总条数底部分页器已经在报,重复一遍只是占掉一行高度。 */
.phone-asset-list-page__table { overflow:hidden; }
.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__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}
/* 列表页统一分页器(AppPagination.js):容器高度恒定,空数据时按钮灰化但不塌陷,四个列表页共用。 */ /* 列表页统一分页器(AppPagination.js):容器高度恒定,空数据时按钮灰化但不塌陷,四个列表页共用。 */
.app-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} .app-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}
...@@ -338,6 +339,29 @@ body { margin: 0; } ...@@ -338,6 +339,29 @@ body { margin: 0; }
@media(max-width:900px){.phone-asset-list-page{padding:26px 0 40px}.app-pagination{align-items:flex-start;flex-direction:column;gap:10px}.app-pagination .el-pagination{width:100%;justify-content:space-between}} @media(max-width:900px){.phone-asset-list-page{padding:26px 0 40px}.app-pagination{align-items:flex-start;flex-direction:column;gap:10px}.app-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,.app-pagination{padding-left:18px;padding-right:18px}.app-pagination .el-pagination__jump{display:none}} @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,.app-pagination{padding-left:18px;padding-right:18px}.app-pagination .el-pagination__jump{display:none}}
.wecom-account-page{min-width:0;max-width:100%;overflow-x:hidden}.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} .wecom-account-page{min-width:0;max-width:100%;overflow-x:hidden}.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}
/* 企微名称格:别名是名称的补充说明而不是并列字段,压到第二行做浅灰小字,省下一整列宽度给真正要横向对比的信息。 */
.wecom-account-page__name{display:flex;flex-direction:column;gap:2px;min-width:0}
.wecom-account-page__name-main{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wecom-account-page__name-alias{overflow:hidden;color:#a1a1aa;font-size:12px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}
/* 性别收成名称右侧的图标:一个字的信息不值得占一整列,图标扫一眼就能分辨,具体文案留在 title 里。 */
.wecom-account-page__name-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 男蓝女粉。蓝直接复用全站链接色,只为女性新增一个粉,把新引入的色值压到一个。
同一行右侧还有绿红状态点,所以这两个色只上在 14px 的图标描边上,不铺底色,避免和状态点抢眼。 */
.wecom-account-page__gender{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:14px;height:14px}
.wecom-account-page__gender svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
.wecom-account-page__gender.is-male{color:#409eff}
.wecom-account-page__gender.is-female{color:#ec4899}
/* 实名状态收成实名人右上角的圆点:只有"正常/异常"两态,颜色就够表达,写成文字反而占一列。
align-items:flex-start 加负的 margin-top 把圆点顶到文字上沿,形成上标而不是并排。 */
.wecom-account-page__owner{display:inline-flex;align-items:flex-start;gap:3px;min-width:0}
.wecom-account-page__owner-main{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wecom-account-page__owner-dot{flex:0 0 auto;width:6px;height:6px;margin-top:1px;border-radius:50%;background:#2f9e68;box-shadow:0 0 0 2.5px rgba(47,158,104,.12)}
.wecom-account-page__owner-dot.is-left{background:#e5484d;box-shadow:0 0 0 2.5px rgba(229,72,77,.12)}
/* 关联方式收成手机号右上角的一字角标:「已」复用链接蓝表示沿用已有资产,「新」用绿表示这条号码是系统新建的。 */
.wecom-account-page__phone{display:inline-flex;align-items:flex-start;gap:4px;min-width:0}
.wecom-account-page__phone-tag{flex:0 0 auto;margin-top:-1px;padding:0 4px;border-radius:4px;font-size:10px;font-weight:600;line-height:15px}
.wecom-account-page__phone-tag.is-existing{color:#3f6ea8;background:#eaf1fb}
.wecom-account-page__phone-tag.is-created{color:#2f7a56;background:#e6f4ec}
/* 登录页视觉微调:输入本体保持透明,玻璃质感由外层承载,避免影响其他业务表单。 */ /* 登录页视觉微调:输入本体保持透明,玻璃质感由外层承载,避免影响其他业务表单。 */
.login-page { .login-page {
...@@ -576,7 +600,7 @@ body { margin: 0; } ...@@ -576,7 +600,7 @@ body { margin: 0; }
} }
/* 列表页固定高度骨架:宽屏下页面高度锁死在一屏,数据变多只在表格内部滚动;≤640px 回到整页滚动,避免小屏可视行数过少。 */ /* 列表页固定高度骨架:宽屏下页面高度锁死在一屏,数据变多只在表格内部滚动;≤640px 回到整页滚动,避免小屏可视行数过少。 */
@media (min-width: 641px) { @media (min-width: 641px) {
/* 手机号资产、企微资料、公司档案三页共用同一套骨架:标题与筛选保持原高度,列表面板吃掉剩余空间。 /* 手机号码管理、企微资料、公司档案三页共用同一套骨架:标题与筛选保持原高度,列表面板吃掉剩余空间。
用 min-height 而不是 height:正常情况刚好一屏、内容区不出滚动条;窗口矮到表格触及最小高度时页面被撑高,改由内容区整页滚动,避免表格被压成一两行。 */ 用 min-height 而不是 height:正常情况刚好一屏、内容区不出滚动条;窗口矮到表格触及最小高度时页面被撑高,改由内容区整页滚动,避免表格被压成一两行。 */
.phone-asset-list-page { display: flex; flex-direction: column; min-height: 100%; padding-bottom: 28px; } .phone-asset-list-page { display: flex; flex-direction: column; min-height: 100%; padding-bottom: 28px; }
.phone-asset-list-page__header, .phone-asset-list-page__header,
......
import { expect, test } from './authenticated-test.js'; import { expect, test } from './authenticated-test.js';
/** /**
* 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表。 * 代码作用(白话):拦截手机号码管理列表请求,给弹窗测试准备不依赖后端的空列表。
* 关联文件:PhoneAssetView.js、phone-api-client.js。 * 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):页面打开 -> listPhoneAssets -> 浏览器路由拦截 -> 空列表 -> 新增弹窗。 * 关联逻辑(调用链/数据流):页面打开 -> listPhoneAssets -> 浏览器路由拦截 -> 空列表 -> 新增弹窗。
*/ */
...@@ -19,15 +19,15 @@ async function mockPhoneAssetList(page) { ...@@ -19,15 +19,15 @@ async function mockPhoneAssetList(page) {
} }
/** /**
* 代码作用(白话):打开新增手机号资产弹窗,供多个验收场景复用。 * 代码作用(白话):打开新增手机号码管理弹窗,供多个验收场景复用。
* 关联文件:PhoneAssetView.js、phone-asset.spec.js。 * 关联文件:PhoneAssetView.js、phone-asset.spec.js。
* 关联逻辑(调用链/数据流):测试页面 -> 新增按钮 -> openCreate -> dialogVisible -> 弹窗。 * 关联逻辑(调用链/数据流):测试页面 -> 新增按钮 -> openCreate -> dialogVisible -> 弹窗。
*/ */
async function openCreateDialog(page) { async function openCreateDialog(page) {
await mockPhoneAssetList(page); await mockPhoneAssetList(page);
await page.goto('/#/phone-assets'); await page.goto('/#/phone-assets');
await page.getByRole('button', { name: '新增手机号资产' }).click(); await page.getByRole('button', { name: '新增手机号码管理' }).click();
return page.getByRole('dialog', { name: '新增手机号资产' }); return page.getByRole('dialog', { name: '新增手机号码管理' });
} }
/** /**
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
| `backend/.../controller/CompanyProfileController.java`(新增) | 暴露查询和新增 HTTP 接口,并分别执行 READ/EDIT 页面权限校验。 | | `backend/.../controller/CompanyProfileController.java`(新增) | 暴露查询和新增 HTTP 接口,并分别执行 READ/EDIT 页面权限校验。 |
| `backend/.../auth/PagePermissionService.java` | 注册公司档案权限键,令鉴权接口、权限配置和前端菜单共用同一份权限数据。 | | `backend/.../auth/PagePermissionService.java` | 注册公司档案权限键,令鉴权接口、权限配置和前端菜单共用同一份权限数据。 |
| `frontend/src/modules/company-profile/company-profile-api-client.js`(新增) | 集中请求公司档案分页和创建接口并统一处理接口信封与 CSRF。 | | `frontend/src/modules/company-profile/company-profile-api-client.js`(新增) | 集中请求公司档案分页和创建接口并统一处理接口信封与 CSRF。 |
| `frontend/src/modules/company-profile/CompanyProfileView.js`(新增) | 提供搜索、分页、全字段表格展示及复用手机号资产布局的新增弹窗。 | | `frontend/src/modules/company-profile/CompanyProfileView.js`(新增) | 提供搜索、分页、全字段表格展示及复用手机号码管理布局的新增弹窗。 |
| `frontend/src/router/index.js` | 增加公司档案 Hash 路由和对应权限元数据。 | | `frontend/src/router/index.js` | 增加公司档案 Hash 路由和对应权限元数据。 |
| `frontend/src/App.js` | 在现有导航中新增“公司档案”菜单项,并沿用既有权限隐藏规则。 | | `frontend/src/App.js` | 在现有导航中新增“公司档案”菜单项,并沿用既有权限隐藏规则。 |
| `frontend/src/modules/system-user/UserPermissionView.js` | 将公司档案加入既有账号逐页权限配置,使管理员可以授予 READ 或 EDIT。 | | `frontend/src/modules/system-user/UserPermissionView.js` | 将公司档案加入既有账号逐页权限配置,使管理员可以授予 READ 或 EDIT。 |
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
- 支持公司名称、公司简称、统一社会信用代码、联系人和联系方式的关键词搜索,并默认按 ID 倒序分页。 - 支持公司名称、公司简称、统一社会信用代码、联系人和联系方式的关键词搜索,并默认按 ID 倒序分页。
- 新增“公司档案”菜单和路由,前端守卫、菜单可见性、后端接口使用统一的 `company-profile` 权限键。 - 新增“公司档案”菜单和路由,前端守卫、菜单可见性、后端接口使用统一的 `company-profile` 权限键。
- 企微注册主体使用可搜索但不可自由输入/新建的选择控件,选择值为 `as_company_profile.id`,优先显示 `short_name`,简称为空时回退公司名称。 - 企微注册主体使用可搜索但不可自由输入/新建的选择控件,选择值为 `as_company_profile.id`,优先显示 `short_name`,简称为空时回退公司名称。
- 提供复用手机号资产新增样式的公司档案新增弹窗;公司名称必填,其余字段可空,审计字段由后端自动设置。 - 提供复用手机号码管理新增样式的公司档案新增弹窗;公司名称必填,其余字段可空,审计字段由后端自动设置。
**Non-Goals:** **Non-Goals:**
...@@ -63,9 +63,9 @@ ...@@ -63,9 +63,9 @@
`PagePermissionService` 注册 `company-profile`,查询接口要求 READ(可查看)权限,创建接口要求 EDIT(可新增)权限;前端路由 meta(路由附加信息)使用同一键,菜单和新增按钮也依据该键显示。管理员默认获得 EDIT,普通用户的 READ 只能查看。 `PagePermissionService` 注册 `company-profile`,查询接口要求 READ(可查看)权限,创建接口要求 EDIT(可新增)权限;前端路由 meta(路由附加信息)使用同一键,菜单和新增按钮也依据该键显示。管理员默认获得 EDIT,普通用户的 READ 只能查看。
### 4. 新增弹窗复用手机号资产布局 ### 4. 新增弹窗复用手机号码管理布局
公司档案页采用手机号资产已有的 Element Plus 弹窗宽度、表单间距和底部操作按钮样式,但保持独立表单状态和提交函数。这样视觉体验一致,字段和保存规则仍由公司档案模块独立维护。 公司档案页采用手机号码管理已有的 Element Plus 弹窗宽度、表单间距和底部操作按钮样式,但保持独立表单状态和提交函数。这样视觉体验一致,字段和保存规则仍由公司档案模块独立维护。
替代方案是复用企微资料权限。 不采用:公司档案是独立菜单,复用会造成用户可看企微即自动可看公司主体资料,权限边界不清。 替代方案是复用企微资料权限。 不采用:公司档案是独立菜单,复用会造成用户可看企微即自动可看公司主体资料,权限边界不清。
...@@ -81,7 +81,7 @@ ...@@ -81,7 +81,7 @@
### 7. 企业微信新增弹窗复用统一资产容器 ### 7. 企业微信新增弹窗复用统一资产容器
企业微信新增表单改用手机号资产已存在的 `phone-asset-modal``phone-asset-modal__form``phone-asset-modal__form-row` 样式类,并在弹窗打开后将正文滚动位置复位到顶部。统一资产容器已将标题、正文和底部操作区分层:正文过高时只滚动正文,用户始终能看到标题和取消/保存按钮。字段控件与保存函数保持原实现,避免改变企业微信创建数据流。 企业微信新增表单改用手机号码管理已存在的 `phone-asset-modal``phone-asset-modal__form``phone-asset-modal__form-row` 样式类,并在弹窗打开后将正文滚动位置复位到顶部。统一资产容器已将标题、正文和底部操作区分层:正文过高时只滚动正文,用户始终能看到标题和取消/保存按钮。字段控件与保存函数保持原实现,避免改变企业微信创建数据流。
## Risks / Trade-offs ## Risks / Trade-offs
......
...@@ -68,7 +68,7 @@ ...@@ -68,7 +68,7 @@
### Requirement: 公司档案新增弹窗 ### Requirement: 公司档案新增弹窗
系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号资产新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;保存成功后 MUST 关闭弹窗并刷新公司档案列表。页面仍不得展示内部 ID。 系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号码管理新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;保存成功后 MUST 关闭弹窗并刷新公司档案列表。页面仍不得展示内部 ID。
#### Scenario: 编辑权限用户保存新增弹窗 #### Scenario: 编辑权限用户保存新增弹窗
......
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
### Requirement: 企业微信新增弹窗复用资产样式并限制内容区滚动 ### Requirement: 企业微信新增弹窗复用资产样式并限制内容区滚动
系统 SHALL 使用手机号资产新增弹窗的统一外观展示企业微信新增表单。弹窗 MUST 固定标题与底部操作区;当表单内容超过可用高度时,MUST 只让弹窗正文区域纵向滚动。此视觉调整不得改变企业微信字段、注册主体远程搜索选择、手机号校验或 POST 请求内容。 系统 SHALL 使用手机号码管理新增弹窗的统一外观展示企业微信新增表单。弹窗 MUST 固定标题与底部操作区;当表单内容超过可用高度时,MUST 只让弹窗正文区域纵向滚动。此视觉调整不得改变企业微信字段、注册主体远程搜索选择、手机号校验或 POST 请求内容。
#### Scenario: 企业微信表单内容超过可用高度 #### Scenario: 企业微信表单内容超过可用高度
......
...@@ -32,10 +32,10 @@ ...@@ -32,10 +32,10 @@
- [x] 5.1 新增 `CompanyProfileSaveRequest`,为公司名称必填和其余字段可选定义校验;每个方法写新手注释。 - [x] 5.1 新增 `CompanyProfileSaveRequest`,为公司名称必填和其余字段可选定义校验;每个方法写新手注释。
- [x] 5.2 为公司档案 Service/Controller 的创建规则先新增失败测试,覆盖空公司名称、审计字段自动写入、EDIT 成功和 READ 拒绝;测试辅助方法写新手注释。 - [x] 5.2 为公司档案 Service/Controller 的创建规则先新增失败测试,覆盖空公司名称、审计字段自动写入、EDIT 成功和 READ 拒绝;测试辅助方法写新手注释。
- [x] 5.3 修改 `CompanyProfileService``CompanyProfileController`,实现创建方法、字段规范化、审计字段初始化与 EDIT 权限校验;所有新增/修改方法写新手注释。 - [x] 5.3 修改 `CompanyProfileService``CompanyProfileController`,实现创建方法、字段规范化、审计字段初始化与 EDIT 权限校验;所有新增/修改方法写新手注释。
- [x] 5.4 修改公司档案 API 客户端和页面,增加可创建权限、弹窗状态、表单重置、必填提示与保存方法;每个新增/修改方法写新手注释,并复用手机号资产新增弹窗的样式。 - [x] 5.4 修改公司档案 API 客户端和页面,增加可创建权限、弹窗状态、表单重置、必填提示与保存方法;每个新增/修改方法写新手注释,并复用手机号码管理新增弹窗的样式。
- [x] 5.5 更新前端浏览器测试,覆盖新增按钮权限、公司名称必填、请求正文、成功提示、关闭弹窗与列表刷新;运行后端测试、前端构建和全量浏览器测试。 - [x] 5.5 更新前端浏览器测试,覆盖新增按钮权限、公司名称必填、请求正文、成功提示、关闭弹窗与列表刷新;运行后端测试、前端构建和全量浏览器测试。
## 6. 企业微信新增弹窗统一化 ## 6. 企业微信新增弹窗统一化
- [x] 6.1 修改 `WecomAccountView.js`,复用手机号资产的弹窗、表单行和内容区滚动样式;新增弹窗打开后滚动复位方法并写新手注释,保留原有字段、搜索和保存逻辑。 - [x] 6.1 修改 `WecomAccountView.js`,复用手机号码管理的弹窗、表单行和内容区滚动样式;新增弹窗打开后滚动复位方法并写新手注释,保留原有字段、搜索和保存逻辑。
- [x] 6.2 更新 `wecom-account.spec.js`,覆盖统一弹窗类、超高内容仅在正文区域滚动和原有新增请求;运行前端构建和全量浏览器测试。 - [x] 6.2 更新 `wecom-account.spec.js`,覆盖统一弹窗类、超高内容仅在正文区域滚动和原有新增请求;运行前端构建和全量浏览器测试。
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
`xyw_data_test.as_phone_asset``PhoneAssetEntity``PhoneAssetMapper` 已存在,但没有 Controller、Service、DTO、分页配置或可操作的前端页面。当前前端只在 `/reference/phone` 保留静态参考;根导航中的“手机号卡(参考)”不连接任何新表接口。 `xyw_data_test.as_phone_asset``PhoneAssetEntity``PhoneAssetMapper` 已存在,但没有 Controller、Service、DTO、分页配置或可操作的前端页面。当前前端只在 `/reference/phone` 保留静态参考;根导航中的“手机号卡(参考)”不连接任何新表接口。
本变更是前后端联动:以已有 `as_phone_asset` 为唯一持久化来源,补齐手机号资产独立维护闭环。它不假设其他资产表的业务 Service 已完成,因此不能把手机号的关联快照当成用户可编辑的关系来源。 本变更是前后端联动:以已有 `as_phone_asset` 为唯一持久化来源,补齐手机号码管理独立维护闭环。它不假设其他资产表的业务 Service 已完成,因此不能把手机号的关联快照当成用户可编辑的关系来源。
### 范围文件用途注释表 ### 范围文件用途注释表
...@@ -18,9 +18,9 @@ ...@@ -18,9 +18,9 @@
| `backend/.../asset/exception/*.java`(新增) | 业务异常与统一返回 | 将不存在、重复、请求校验失败稳定映射为 404/409/400 | Service/Validator → Exception handler → `ApiResponse.error` | | `backend/.../asset/exception/*.java`(新增) | 业务异常与统一返回 | 将不存在、重复、请求校验失败稳定映射为 404/409/400 | Service/Validator → Exception handler → `ApiResponse.error` |
| `backend/src/test/.../asset/**/*.java`(新增) | 后端自动化测试 | 覆盖分页、重复、软删除、输入校验和错误返回 | Service/Controller → MockMapper/MockMvc | | `backend/src/test/.../asset/**/*.java`(新增) | 后端自动化测试 | 覆盖分页、重复、软删除、输入校验和错误返回 | Service/Controller → MockMapper/MockMvc |
| `frontend/src/modules/phone/phone-api-client.js`(新增) | 浏览器 API 客户端 | 组装请求、解析统一返回体、抛出可显示错误 | 视图事件 → fetch → 后端 API | | `frontend/src/modules/phone/phone-api-client.js`(新增) | 浏览器 API 客户端 | 组装请求、解析统一返回体、抛出可显示错误 | 视图事件 → fetch → 后端 API |
| `frontend/src/modules/phone/PhoneAssetView.js`(新增) | 手机号资产工作页面 | 筛选、表格、分页、编辑弹窗、删除确认和详情 | 路由 → View → API client → 页面状态 | | `frontend/src/modules/phone/PhoneAssetView.js`(新增) | 手机号码管理工作页面 | 筛选、表格、分页、编辑弹窗、删除确认和详情 | 路由 → View → API client → 页面状态 |
| `frontend/src/router/index.js`(修改) | 前端路由 | 注册 `/phone-assets`;保留 `/reference/phone` 静态回退 | URL → PhoneAssetView | | `frontend/src/router/index.js`(修改) | 前端路由 | 注册 `/phone-assets`;保留 `/reference/phone` 静态回退 | URL → PhoneAssetView |
| `frontend/src/App.js`(修改) | 导航壳 | 将主导航指向可操作手机号资产页 | 导航点击 → RouterLink → 路由 | | `frontend/src/App.js`(修改) | 导航壳 | 将主导航指向可操作手机号码管理页 | 导航点击 → RouterLink → 路由 |
| `frontend/src/styles/app.css`(修改) | 共享样式 | 为工作页筛选区、表格、详情和窄屏布局提供样式 | View DOM → CSS | | `frontend/src/styles/app.css`(修改) | 共享样式 | 为工作页筛选区、表格、详情和窄屏布局提供样式 | View DOM → CSS |
| `frontend/tests/phone-asset.spec.js`(新增) | 前端端到端测试 | 以 mock API 验证页面加载、保存、删除和错误展示 | Playwright → View → mock `/api/phone-assets` | | `frontend/tests/phone-asset.spec.js`(新增) | 前端端到端测试 | 以 mock API 验证页面加载、保存、删除和错误展示 | Playwright → View → mock `/api/phone-assets` |
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
**Goals:** **Goals:**
- 实现手机号资产的分页查询、单条查询、新增、编辑与软删除,所有有效记录读取均固定 `delete_time = 0` - 实现手机号码管理的分页查询、单条查询、新增、编辑与软删除,所有有效记录读取均固定 `delete_time = 0`
- 保存前去除手机号的首尾空格和 `+86` 前缀,并校验最终值为 11 位数字;手机号重复必须反馈为冲突,不覆盖原记录。 - 保存前去除手机号的首尾空格和 `+86` 前缀,并校验最终值为 11 位数字;手机号重复必须反馈为冲突,不覆盖原记录。
- 使用固定下拉选项:卡类型为中国移动/中国电信/中国联通/中国广电/虚拟号码/空串,管理类型为自有/租用/代运营/空值,处置状态为正常使用/闲置/停机/已注销且必填、默认正常使用。 - 使用固定下拉选项:卡类型为中国移动/中国电信/中国联通/中国广电/虚拟号码/空串,管理类型为自有/租用/代运营/空值,处置状态为正常使用/闲置/停机/已注销且必填、默认正常使用。
- 新建记录将五个 JSON 关联快照显式初始化为 `[]`,并只读返回数组形式给前端。 - 新建记录将五个 JSON 关联快照显式初始化为 `[]`,并只读返回数组形式给前端。
...@@ -66,7 +66,7 @@ ...@@ -66,7 +66,7 @@
### 4. 前端采用模块化 JS 视图与 Element Plus 原生组件 ### 4. 前端采用模块化 JS 视图与 Element Plus 原生组件
新增 `PhoneAssetView.js``phone-api-client.js`,延续当前非 SFC 的 Vue JavaScript 写法。页面由筛选栏、分页表格、右侧详情抽屉、同一新增/编辑弹窗和删除二次确认组成;所有 API 错误用 `ElMessage` 展示,保存成功后刷新当前筛选条件。路由新增 `/phone-assets`,导航文案从“手机号卡(参考)”调整为“手机号资产”;`/reference/phone` 继续保留。 新增 `PhoneAssetView.js``phone-api-client.js`,延续当前非 SFC 的 Vue JavaScript 写法。页面由筛选栏、分页表格、右侧详情抽屉、同一新增/编辑弹窗和删除二次确认组成;所有 API 错误用 `ElMessage` 展示,保存成功后刷新当前筛选条件。路由新增 `/phone-assets`,导航文案从“手机号卡(参考)”调整为“手机号码管理”;`/reference/phone` 继续保留。
备选为直接替换 `LegacyReferenceView`。该组件同时承载静态回退语义,直接替换会损失安全参考入口,故不采用。 备选为直接替换 `LegacyReferenceView`。该组件同时承载静态回退语义,直接替换会损失安全参考入口,故不采用。
......
## Why ## Why
`xyw_data_test` 已完成 `as_phone_asset` 表创建,后端也已有对应的 Entity 与 Mapper,但系统尚不能查询或维护手机号资产;前端仅保留不可操作的旧界面参考。先完成手机号资产的独立闭环,可以验证新表、MyBatis-Plus 和新 Vue 工作区的整合方式,并为后续企微、微信、抖音、域名与商户资产复用。 `xyw_data_test` 已完成 `as_phone_asset` 表创建,后端也已有对应的 Entity 与 Mapper,但系统尚不能查询或维护手机号码管理;前端仅保留不可操作的旧界面参考。先完成手机号码管理的独立闭环,可以验证新表、MyBatis-Plus 和新 Vue 工作区的整合方式,并为后续企微、微信、抖音、域名与商户资产复用。
## What Changes ## What Changes
- 新增以 `as_phone_asset` 为唯一写入目标的手机号资产 API:分页查询、单条查询、新增、编辑和软删除。 - 新增以 `as_phone_asset` 为唯一写入目标的手机号码管理 API:分页查询、单条查询、新增、编辑和软删除。
- 新增手机号规范化与唯一性保护:保存前去除首尾空格和 `+86` 前缀,最终必须为 11 位数字。 - 新增手机号规范化与唯一性保护:保存前去除首尾空格和 `+86` 前缀,最终必须为 11 位数字。
- 将卡类型、管理类型和处置状态做成已确认选项的下拉框;处置状态必填且默认“正常使用”,其余非必填。 - 将卡类型、管理类型和处置状态做成已确认选项的下拉框;处置状态必填且默认“正常使用”,其余非必填。
- 由后端在新增时初始化五个关联快照字段为 JSON 空数组;本期不自动写入或同步其他资产表的数据。 - 由后端在新增时初始化五个关联快照字段为 JSON 空数组;本期不自动写入或同步其他资产表的数据。
...@@ -15,8 +15,8 @@ ...@@ -15,8 +15,8 @@
### New Capabilities ### New Capabilities
- `phone-asset-api`: 以软删除和字段校验保护 `as_phone_asset` 的手机号资产 REST API。 - `phone-asset-api`: 以软删除和字段校验保护 `as_phone_asset` 的手机号码管理 REST API。
- `phone-asset-workspace`: 用于浏览和维护手机号资产的 Vue 工作页面,以及与 API 的交互状态。 - `phone-asset-workspace`: 用于浏览和维护手机号码管理的 Vue 工作页面,以及与 API 的交互状态。
### Modified Capabilities ### Modified Capabilities
...@@ -25,5 +25,5 @@ ...@@ -25,5 +25,5 @@
## Impact ## Impact
- 后端新增 `asset` 模块的 Controller、Service、请求/响应 DTO、异常处理及对应测试;复用既有 `PhoneAssetEntity``PhoneAssetMapper``ApiResponse`,不改表结构或 Mapper XML。 - 后端新增 `asset` 模块的 Controller、Service、请求/响应 DTO、异常处理及对应测试;复用既有 `PhoneAssetEntity``PhoneAssetMapper``ApiResponse`,不改表结构或 Mapper XML。
- 前端新增手机号资产页面和 API 客户端,修改路由、导航与共享样式,并新增端到端测试。 - 前端新增手机号码管理页面和 API 客户端,修改路由、导航与共享样式,并新增端到端测试。
- 新增 `/api/phone-assets` 系列接口;不修改既有接口、DTO、数据库结构、认证或配置,也不新增依赖。本期不做登录权限和关联快照自动同步,`deviceId` 可为空且不校验设备是否存在。 - 新增 `/api/phone-assets` 系列接口;不修改既有接口、DTO、数据库结构、认证或配置,也不新增依赖。本期不做登录权限和关联快照自动同步,`deviceId` 可为空且不校验设备是否存在。
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
The system SHALL provide a routed phone-asset workspace at `/phone-assets` and SHALL retain `/reference/phone` as a non-operational static reference route. The system SHALL provide a routed phone-asset workspace at `/phone-assets` and SHALL retain `/reference/phone` as a non-operational static reference route.
#### Scenario: User opens the main workspace #### Scenario: User opens the main workspace
- **WHEN** a user selects “手机号资产” from the main navigation - **WHEN** a user selects “手机号码管理” from the main navigation
- **THEN** the router opens `/phone-assets` and the page requests the first asset page - **THEN** the router opens `/phone-assets` and the page requests the first asset page
#### Scenario: User opens the legacy route #### Scenario: User opens the legacy route
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
- [ ] 1.3 新增“未找到”和“手机号冲突”业务异常及统一异常处理;为 `handleValidation()``handleNotFound()``handleConflict()` 添加新手注释,分别说明校验/Service 异常 → `ApiResponse.error` → 前端错误提示的调用链。 - [ ] 1.3 新增“未找到”和“手机号冲突”业务异常及统一异常处理;为 `handleValidation()``handleNotFound()``handleConflict()` 添加新手注释,分别说明校验/Service 异常 → `ApiResponse.error` → 前端错误提示的调用链。
- [ ] 1.4 为 DTO 校验、分页默认值和 400/404/409 响应编写后端测试,验证失败请求不写入 `as_phone_asset` - [ ] 1.4 为 DTO 校验、分页默认值和 400/404/409 响应编写后端测试,验证失败请求不写入 `as_phone_asset`
## 2. 后端手机号资产服务与接口 ## 2. 后端手机号码管理服务与接口
- [ ] 2.1 新增 `PhoneAssetService`,实现 `page()``getActiveById()``create()``update()``softDelete()`;每个方法添加新手注释,至少说明各自的白话作用、关联 Controller/Mapper/DTO 和“HTTP 请求 → Service → `PhoneAssetMapper``as_phone_asset`”数据流。 - [ ] 2.1 新增 `PhoneAssetService`,实现 `page()``getActiveById()``create()``update()``softDelete()`;每个方法添加新手注释,至少说明各自的白话作用、关联 Controller/Mapper/DTO 和“HTTP 请求 → Service → `PhoneAssetMapper``as_phone_asset`”数据流。
- [ ] 2.2 在 `PhoneAssetService` 中实现 `buildActiveQuery()``ensurePhoneNumberAvailable()``applyEditableFields()``toResponse()``parseRelationIds()`;每个方法添加新手注释,说明有效记录过滤、唯一性保护、可写字段边界、Entity→Response 转换和 JSON 快照解析各自的关联文件与数据流。 - [ ] 2.2 在 `PhoneAssetService` 中实现 `buildActiveQuery()``ensurePhoneNumberAvailable()``applyEditableFields()``toResponse()``parseRelationIds()`;每个方法添加新手注释,说明有效记录过滤、唯一性保护、可写字段边界、Entity→Response 转换和 JSON 快照解析各自的关联文件与数据流。
...@@ -19,13 +19,13 @@ ...@@ -19,13 +19,13 @@
- [ ] 2.4 新增 `PhoneAssetController``page()``getById()``create()``update()``delete()`;每个方法添加新手注释,说明 HTTP 路径、关联 Service/DTO、以及请求 → `ApiResponse` → 浏览器客户端的数据流。 - [ ] 2.4 新增 `PhoneAssetController``page()``getById()``create()``update()``delete()`;每个方法添加新手注释,说明 HTTP 路径、关联 Service/DTO、以及请求 → `ApiResponse` → 浏览器客户端的数据流。
- [ ] 2.5 为服务与 Controller 编写测试:默认分页、组合筛选、精确手机号、创建、重复手机号、编辑冲突、软删除后不可读、软删除后可重建、关联字段不可由请求覆盖。 - [ ] 2.5 为服务与 Controller 编写测试:默认分页、组合筛选、精确手机号、创建、重复手机号、编辑冲突、软删除后不可读、软删除后可重建、关联字段不可由请求覆盖。
## 3. 前端手机号资产工作页 ## 3. 前端手机号码管理工作页
- [ ] 3.1 新增 `phone-api-client.js``request()``listPhoneAssets()``getPhoneAsset()``createPhoneAsset()``updatePhoneAsset()``deletePhoneAsset()`;每个方法添加新手注释,说明浏览器请求、关联 `PhoneAssetController`、以及统一响应解析/异常消息的数据流。 - [ ] 3.1 新增 `phone-api-client.js``request()``listPhoneAssets()``getPhoneAsset()``createPhoneAsset()``updatePhoneAsset()``deletePhoneAsset()`;每个方法添加新手注释,说明浏览器请求、关联 `PhoneAssetController`、以及统一响应解析/异常消息的数据流。
- [ ] 3.2 新增 `PhoneAssetView.js``setup()``loadPage()``submitSearch()``resetSearch()``openCreate()``openEdit()``submitForm()``confirmDelete()``openDetails()`;每个方法或含业务逻辑回调添加新手注释,说明页面事件、关联 API 客户端/Element Plus、以及“用户操作 → API → 列表或弹窗状态”的消息链。 - [ ] 3.2 新增 `PhoneAssetView.js``setup()``loadPage()``submitSearch()``resetSearch()``openCreate()``openEdit()``submitForm()``confirmDelete()``openDetails()`;每个方法或含业务逻辑回调添加新手注释,说明页面事件、关联 API 客户端/Element Plus、以及“用户操作 → API → 列表或弹窗状态”的消息链。
- [ ] 3.3 实现筛选栏、分页表格、新增/编辑共用表单、删除二次确认和只读关联快照详情;保存失败时保留弹窗与表单数据,成功后仅刷新当前筛选列表。 - [ ] 3.3 实现筛选栏、分页表格、新增/编辑共用表单、删除二次确认和只读关联快照详情;保存失败时保留弹窗与表单数据,成功后仅刷新当前筛选列表。
- [ ] 3.4 修改 `frontend/src/router/index.js` 注册 `/phone-assets` 并保留 `/reference/phone`;为 `createPlaceholderView()` 重新核对并补充其新手注释,说明占位页面与新路由的边界,避免旧入口误接新 API。 - [ ] 3.4 修改 `frontend/src/router/index.js` 注册 `/phone-assets` 并保留 `/reference/phone`;为 `createPlaceholderView()` 重新核对并补充其新手注释,说明占位页面与新路由的边界,避免旧入口误接新 API。
- [ ] 3.5 修改 `frontend/src/App.js` 的导航模板,将“手机号卡(参考)”改为“手机号资产”并指向 `/phone-assets`;补充组件模板相关注释,说明 RouterLink → RouterView 的导航链。 - [ ] 3.5 修改 `frontend/src/App.js` 的导航模板,将“手机号卡(参考)”改为“手机号码管理”并指向 `/phone-assets`;补充组件模板相关注释,说明 RouterLink → RouterView 的导航链。
- [ ] 3.6 修改 `frontend/src/styles/app.css`,为筛选、表格、抽屉、弹窗和窄屏布局增加与现有后台一致的样式;不改变其他参考页面的可读性。 - [ ] 3.6 修改 `frontend/src/styles/app.css`,为筛选、表格、抽屉、弹窗和窄屏布局增加与现有后台一致的样式;不改变其他参考页面的可读性。
## 4. 验证与交付 ## 4. 验证与交付
......
## Context ## Context
企业微信资产目前只能分页查询;手机号资产仅能在自己的页面创建。`as_phone_asset` 尚未保存号码类型和外部来源,企业微信列表也不能按注册手机号筛选。仓库没有可执行的数据库迁移目录,因此数据库变更只作为受控 SQL 交付,绝不在应用启动或本次任务中直接执行。 企业微信资产目前只能分页查询;手机号码管理仅能在自己的页面创建。`as_phone_asset` 尚未保存号码类型和外部来源,企业微信列表也不能按注册手机号筛选。仓库没有可执行的数据库迁移目录,因此数据库变更只作为受控 SQL 交付,绝不在应用启动或本次任务中直接执行。
## Goals / Non-Goals ## Goals / Non-Goals
**Goals:** **Goals:**
- 提供企业微信资产新增表单、关联查询和手机号自动创建。 - 提供企业微信资产新增表单、关联查询和手机号自动创建。
- 在手机号资产中长期保存并显示“自有号码”或“外部号码”。 - 在手机号码管理中长期保存并显示“自有号码”或“外部号码”。
- 让外部号码链接到对应来源资产列表,并按手机号资产筛选。 - 让外部号码链接到对应来源资产列表,并按手机号码管理筛选。
- 用可复用的来源类型字段为后续微信、抖音等页面预留接入点。 - 用可复用的来源类型字段为后续微信、抖音等页面预留接入点。
**Non-Goals:** **Non-Goals:**
...@@ -21,11 +21,11 @@ ...@@ -21,11 +21,11 @@
### 手机号号码类型与来源分开保存 ### 手机号号码类型与来源分开保存
手机号资产新增 `number_type``source_asset_type``source_asset_id``number_type` 只表示号码归属:手机号资产页直接创建为 `SELF`(自有号码),其他资产流程自动创建为 `EXTERNAL`(外部号码);来源字段记录创建它的资产类型和主键。相比把“已有/新建”写入手机号资产,这能避免把一次关联动作误当成号码自身属性。 手机号码管理新增 `number_type``source_asset_type``source_asset_id``number_type` 只表示号码归属:手机号码管理页直接创建为 `SELF`(自有号码),其他资产流程自动创建为 `EXTERNAL`(外部号码);来源字段记录创建它的资产类型和主键。相比把“已有/新建”写入手机号码管理,这能避免把一次关联动作误当成号码自身属性。
### 企业微信关联方式单独展示 ### 企业微信关联方式单独展示
企业微信资产新增 `phone_link_mode`。选择已有手机号资产时保存 `EXISTING`(已有号码);自动创建手机号资产时保存 `CREATED`(新建号码)。这与手机号资产的号码类型是两条不同维度的信息。 企业微信资产新增 `phone_link_mode`。选择已有手机号码管理时保存 `EXISTING`(已有号码);自动创建手机号码管理时保存 `CREATED`(新建号码)。这与手机号码管理的号码类型是两条不同维度的信息。
### 原子保存 ### 原子保存
...@@ -37,15 +37,15 @@ ...@@ -37,15 +37,15 @@
## Risks / Trade-offs ## Risks / Trade-offs
- [历史手机号没有来源] → 数据库交付将历史记录初始化为 `SELF`,与“非手机号资产创建才是外部号码”的规则一致。 - [历史手机号没有来源] → 数据库交付将历史记录初始化为 `SELF`,与“非手机号码管理创建才是外部号码”的规则一致。
- [并发提交同一新号码] → 复用手机号资产表既有“号码 + 删除状态”的唯一约束,并将重复键错误转换为重新读取已有号码。 - [并发提交同一新号码] → 复用手机号码管理表既有“号码 + 删除状态”的唯一约束,并将重复键错误转换为重新读取已有号码。
- [未来来源页面尚未实现] → 当前仅企业微信具备完整跳转;来源类型字段和前端映射为未来页面预留扩展点。 - [未来来源页面尚未实现] → 当前仅企业微信具备完整跳转;来源类型字段和前端映射为未来页面预留扩展点。
- [数据库脚本不在仓库] → 交付明确的字段与回填要求,实际执行必须获得目标数据库和执行窗口的单独授权。 - [数据库脚本不在仓库] → 交付明确的字段与回填要求,实际执行必须获得目标数据库和执行窗口的单独授权。
## Migration Plan ## Migration Plan
1. 在受控数据库变更中为手机号资产增加号码类型、来源资产类型、来源资产 ID,为企业微信资产增加手机号关联方式。 1. 在受控数据库变更中为手机号码管理增加号码类型、来源资产类型、来源资产 ID,为企业微信资产增加手机号关联方式。
2. 将历史手机号资产初始化为 `SELF`,并为新字段建立筛选索引。 2. 将历史手机号码管理初始化为 `SELF`,并为新字段建立筛选索引。
3. 发布后先验证现有列表仍可正常查询,再验证新建企微资产的已有号码和新建号码两条流程。 3. 发布后先验证现有列表仍可正常查询,再验证新建企微资产的已有号码和新建号码两条流程。
4. 回滚时保留新列但停止写入;前后端对空值回退为“自有号码”,避免旧数据不可读。 4. 回滚时保留新列但停止写入;前后端对空值回退为“自有号码”,避免旧数据不可读。
......
## Why ## Why
企业微信资料页目前只有只读列表,且布局与手机号资产页不一致。登记企业微信账号时无法复用或安全地新增注册手机号,也无法区分手机号是自有号码还是由外部资产登记流程创建的号码。 企业微信资料页目前只有只读列表,且布局与手机号码管理页不一致。登记企业微信账号时无法复用或安全地新增注册手机号,也无法区分手机号是自有号码还是由外部资产登记流程创建的号码。
## What Changes ## What Changes
- 将“企微资料”页面改为“企业微信资产”,复用手机号资产页的标题、按钮位置、面板宽度和表格内部横向滚动体验。 - 将“企微资料”页面改为“企业微信资产”,复用手机号码管理页的标题、按钮位置、面板宽度和表格内部横向滚动体验。
- 新增企业微信资产创建表单;注册手机号必填,支持搜索已有手机号资产,或在号码不存在时自动创建手机号资产 - 新增企业微信资产创建表单;注册手机号必填,支持搜索已有手机号码管理,或在号码不存在时自动创建手机号码管理
- 新增手机号号码类型:直接在手机号资产页创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。 - 新增手机号号码类型:直接在手机号码管理页创建的号码为“自有号码”,由非手机号码管理创建流程自动创建的号码为“外部号码”。
- 为外部号码保存来源资产信息;手机号资产页将“外部号码”显示为无下划线的蓝色链接,点击后进入来源资产列表并按手机号筛选关联记录。 - 为外部号码保存来源资产信息;手机号码管理页将“外部号码”显示为无下划线的蓝色链接,点击后进入来源资产列表并按手机号筛选关联记录。
- 新增注册主体与企微号归属人的搜索选择能力;注册主体使用公司简称显示,企微号归属人复用公司人员记录。 - 新增注册主体与企微号归属人的搜索选择能力;注册主体使用公司简称显示,企微号归属人复用公司人员记录。
- 企业微信资产列表新增注册手机号筛选,并展示“已有号码”或“新建号码”的关联方式。 - 企业微信资产列表新增注册手机号筛选,并展示“已有号码”或“新建号码”的关联方式。
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
- `wecom-account-creation`: 创建企业微信资产,并完成注册主体、注册手机号和归属人员的选择与保存。 - `wecom-account-creation`: 创建企业微信资产,并完成注册主体、注册手机号和归属人员的选择与保存。
- `phone-asset-origin-tracking`: 保存手机号号码类型及来源资产,并支持从外部号码跳转到对应资产列表。 - `phone-asset-origin-tracking`: 保存手机号号码类型及来源资产,并支持从外部号码跳转到对应资产列表。
- `asset-reference-lookups`: 为企业微信资产表单提供注册主体、手机号资产和公司人员的搜索选择接口。 - `asset-reference-lookups`: 为企业微信资产表单提供注册主体、手机号码管理和公司人员的搜索选择接口。
### Modified Capabilities ### Modified Capabilities
...@@ -25,6 +25,6 @@ ...@@ -25,6 +25,6 @@
## Impact ## Impact
- 前端:企业微信资产页、手机号资产页、对应 API 客户端、公共样式及 Playwright 测试。 - 前端:企业微信资产页、手机号码管理页、对应 API 客户端、公共样式及 Playwright 测试。
- 后端:企业微信和手机号资产的 Controller、Service、DTO、响应对象与单元测试。 - 后端:企业微信和手机号码管理的 Controller、Service、DTO、响应对象与单元测试。
- 数据库:手机号资产表新增号码类型和来源资产字段;不在本次实现中直接执行数据库变更。 - 数据库:手机号码管理表新增号码类型和来源资产字段;不在本次实现中直接执行数据库变更。
## ADDED Requirements ## ADDED Requirements
### Requirement: 资产引用搜索 ### Requirement: 资产引用搜索
系统 SHALL 提供公司档案、手机号资产和公司人员的只读搜索接口,供企业微信资产表单选择引用资产。 系统 SHALL 提供公司档案、手机号码管理和公司人员的只读搜索接口,供企业微信资产表单选择引用资产。
#### Scenario: 搜索注册主体 #### Scenario: 搜索注册主体
- **WHEN** 用户输入公司名称或简称 - **WHEN** 用户输入公司名称或简称
...@@ -9,4 +9,4 @@ ...@@ -9,4 +9,4 @@
#### Scenario: 搜索注册手机号 #### Scenario: 搜索注册手机号
- **WHEN** 用户输入手机号片段 - **WHEN** 用户输入手机号片段
- **THEN** 系统 MUST 返回有效手机号资产的 ID、手机号与号码类型,且不返回已删除资产 - **THEN** 系统 MUST 返回有效手机号码管理的 ID、手机号与号码类型,且不返回已删除资产
## ADDED Requirements ## ADDED Requirements
### Requirement: 手机号号码类型 ### Requirement: 手机号号码类型
系统 SHALL 为手机号资产保存并显示号码类型;手机号资产页直接创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。 系统 SHALL 为手机号码管理保存并显示号码类型;手机号码管理页直接创建的号码为“自有号码”,由非手机号码管理创建流程自动创建的号码为“外部号码”。
#### Scenario: 显示外部号码 #### Scenario: 显示外部号码
- **WHEN** 手机号资产的号码类型为外部号码 - **WHEN** 手机号码管理的号码类型为外部号码
- **THEN** 手机号资产列表 MUST 以无下划线的蓝色文字显示“外部号码” - **THEN** 手机号码管理列表 MUST 以无下划线的蓝色文字显示“外部号码”
### Requirement: 外部号码来源跳转 ### Requirement: 外部号码来源跳转
系统 SHALL 为外部号码保存来源资产,并允许用户点击号码类型跳转到来源资产列表且按该手机号筛选。 系统 SHALL 为外部号码保存来源资产,并允许用户点击号码类型跳转到来源资产列表且按该手机号筛选。
#### Scenario: 跳转企业微信资产 #### Scenario: 跳转企业微信资产
- **WHEN** 用户点击来源为企业微信资产的外部号码 - **WHEN** 用户点击来源为企业微信资产的外部号码
- **THEN** 系统 MUST 打开企业微信资产列表并仅显示关联该手机号资产的记录 - **THEN** 系统 MUST 打开企业微信资产列表并仅显示关联该手机号码管理的记录
...@@ -4,12 +4,12 @@ ...@@ -4,12 +4,12 @@
系统 SHALL 在企业微信资产页提供“新增企业微信资产”按钮及表单;企微名称和注册手机号 MUST 必填,企微别名 MUST 默认填入“记忆力梅老师-助教老师”。 系统 SHALL 在企业微信资产页提供“新增企业微信资产”按钮及表单;企微名称和注册手机号 MUST 必填,企微别名 MUST 默认填入“记忆力梅老师-助教老师”。
#### Scenario: 以已有号码创建 #### Scenario: 以已有号码创建
- **WHEN** 用户选择一个有效的已有手机号资产并保存企业微信资产 - **WHEN** 用户选择一个有效的已有手机号码管理并保存企业微信资产
- **THEN** 系统 MUST 保存该手机号资产 ID,并在企业微信资产列表显示“已有号码” - **THEN** 系统 MUST 保存该手机号码管理 ID,并在企业微信资产列表显示“已有号码”
#### Scenario: 以新号码创建 #### Scenario: 以新号码创建
- **WHEN** 用户输入一个不存在的有效手机号并保存企业微信资产 - **WHEN** 用户输入一个不存在的有效手机号并保存企业微信资产
- **THEN** 系统 MUST 原子地创建外部号码手机号资产与企业微信资产,并在列表显示“新建号码” - **THEN** 系统 MUST 原子地创建外部号码手机号码管理与企业微信资产,并在列表显示“新建号码”
### Requirement: 企业微信资产表单字段 ### Requirement: 企业微信资产表单字段
系统 SHALL 提供注册主体搜索、注册手机号搜索、实名状态单选、性别单选与企微号归属人搜索;注册主体和归属人不是必填项,设备 ID 不在表单中出现。 系统 SHALL 提供注册主体搜索、注册手机号搜索、实名状态单选、性别单选与企微号归属人搜索;注册主体和归属人不是必填项,设备 ID 不在表单中出现。
......
## 1. 数据模型与后端接口 ## 1. 数据模型与后端接口
- [x] 1.1 为手机号资产和企业微信资产补充号码来源、关联方式及对应 DTO/响应字段。 - [x] 1.1 为手机号码管理和企业微信资产补充号码来源、关联方式及对应 DTO/响应字段。
- [x] 1.2 实现注册主体、手机号资产和公司人员的只读搜索接口。 - [x] 1.2 实现注册主体、手机号码管理和公司人员的只读搜索接口。
- [x] 1.3 实现企业微信资产创建接口,并在新手机号场景中原子创建手机号资产 - [x] 1.3 实现企业微信资产创建接口,并在新手机号场景中原子创建手机号码管理
- [x] 1.4 实现按手机号资产筛选企业微信资产的查询兼容逻辑。 - [x] 1.4 实现按手机号码管理筛选企业微信资产的查询兼容逻辑。
## 2. 前端页面与交互 ## 2. 前端页面与交互
- [x] 2.1 将企业微信资产页改为手机号资产页一致的标题、按钮、面板和宽表布局。 - [x] 2.1 将企业微信资产页改为手机号码管理页一致的标题、按钮、面板和宽表布局。
- [x] 2.2 实现企业微信资产新增弹窗及注册主体、注册手机号、归属人员的搜索选择。 - [x] 2.2 实现企业微信资产新增弹窗及注册主体、注册手机号、归属人员的搜索选择。
- [x] 2.3 在手机号资产列表显示号码类型,并实现外部号码蓝色无下划线跳转。 - [x] 2.3 在手机号码管理列表显示号码类型,并实现外部号码蓝色无下划线跳转。
- [x] 2.4 实现企业微信资产页接收手机号筛选参数并显示关联记录。 - [x] 2.4 实现企业微信资产页接收手机号筛选参数并显示关联记录。
## 3. 数据库交付与验证 ## 3. 数据库交付与验证
......
## Context ## Context
当前 `#/reference/wechat` 是不请求接口的静态旧界面参考,而 `as_wecom_account` 已经具备 `WecomAccountEntity``WecomAccountMapper`,尚未具备列表 Service、Controller、DTO 与真实前端页面。资产人员需要查看企微资料的全部业务字段,并将公司档案、手机号资产、设备、经办人等关联 ID 理解为可读名称。 当前 `#/reference/wechat` 是不请求接口的静态旧界面参考,而 `as_wecom_account` 已经具备 `WecomAccountEntity``WecomAccountMapper`,尚未具备列表 Service、Controller、DTO 与真实前端页面。资产人员需要查看企微资料的全部业务字段,并将公司档案、手机号码管理、设备、经办人等关联 ID 理解为可读名称。
约束:本次只读主数据源,不修改表结构、不新增编辑删除能力;`delete_time` 延续现有手机号资产规则,仅用于过滤;用户已确认它不应返回或展示。 约束:本次只读主数据源,不修改表结构、不新增编辑删除能力;`delete_time` 延续现有手机号码管理规则,仅用于过滤;用户已确认它不应返回或展示。
## Goals / Non-Goals ## Goals / Non-Goals
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
### 分页与筛选契约 ### 分页与筛选契约
接口使用现有手机号资产`page``size` 约定:默认第 1 页、每页 20 条、最大 100 条;支持精确筛选 `wecomName``wecomAccount`。查询显式限定 `delete_time = 0` 并按 `id` 倒序,保证与既有资产列表的正常记录定义一致。 接口使用现有手机号码管理`page``size` 约定:默认第 1 页、每页 20 条、最大 100 条;支持精确筛选 `wecomName``wecomAccount`。查询显式限定 `delete_time = 0` 并按 `id` 倒序,保证与既有资产列表的正常记录定义一致。
### 关联 ID 与名称 ### 关联 ID 与名称
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
### 字段暴露与页面展示 ### 字段暴露与页面展示
响应和表格展示 `id`、企微名称、别名、账号、公司档案 ID/名称、手机号资产 ID/手机号、企微实名人、实名状态、性别、设备 ID/名称、经办人 ID/名称、创建时间和更新时间。`deleteTime` 只在 Service 查询条件中使用,既不写入响应 DTO,也不创建表格列。 响应和表格展示 `id`、企微名称、别名、账号、公司档案 ID/名称、手机号码管理 ID/手机号、企微实名人、实名状态、性别、设备 ID/名称、经办人 ID/名称、创建时间和更新时间。`deleteTime` 只在 Service 查询条件中使用,既不写入响应 DTO,也不创建表格列。
## Risks / Trade-offs ## Risks / Trade-offs
......
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
系统 MUST 在每条列表记录中返回 `id``wecomName``wecomAlias``wecomAccount``companyProfileId``phoneAssetId``realNameOwner``realNameOwnerStatus``gender``deviceId``operatorPersonId``createTime``updateTime`,并同时返回关联名称字段。 系统 MUST 在每条列表记录中返回 `id``wecomName``wecomAlias``wecomAccount``companyProfileId``phoneAssetId``realNameOwner``realNameOwnerStatus``gender``deviceId``operatorPersonId``createTime``updateTime`,并同时返回关联名称字段。
#### Scenario: 关联 ID 可读化 #### Scenario: 关联 ID 可读化
- **WHEN** 企微资产的公司档案、手机号资产、设备或经办人 ID 可以关联到正常的资产记录 - **WHEN** 企微资产的公司档案、手机号码管理、设备或经办人 ID 可以关联到正常的资产记录
- **THEN** 响应分别包含 `companyProfileName``phoneNumber``deviceName``operatorPersonName`,同时保留原始 ID - **THEN** 响应分别包含 `companyProfileName``phoneNumber``deviceName``operatorPersonName`,同时保留原始 ID
#### Scenario: 关联记录缺失 #### Scenario: 关联记录缺失
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
系统 MUST 在企微资料列表中展示企微资产 ID、企微名称、别名、账号、企微实名人、实名状态、性别、创建时间、更新时间,以及每个关联资源的“名称(ID)”。 系统 MUST 在企微资料列表中展示企微资产 ID、企微名称、别名、账号、企微实名人、实名状态、性别、创建时间、更新时间,以及每个关联资源的“名称(ID)”。
#### Scenario: 关联名称与 ID 同时展示 #### Scenario: 关联名称与 ID 同时展示
- **WHEN** 列表记录包含公司档案、手机号资产、设备或经办人关联 - **WHEN** 列表记录包含公司档案、手机号码管理、设备或经办人关联
- **THEN** 页面分别展示公司名称、手机号、设备名称、人员名称及对应 ID - **THEN** 页面分别展示公司名称、手机号、设备名称、人员名称及对应 ID
#### Scenario: 关联名称不可用 #### Scenario: 关联名称不可用
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
- [x] 1.1 新增 `WecomAccountPageQuery.java`(文件用途:接收分页与筛选参数),为 `resolvedPage``resolvedSize` 写新手注释:代码作用(白话)、关联文件、关联逻辑;校验页码与页大小。 - [x] 1.1 新增 `WecomAccountPageQuery.java`(文件用途:接收分页与筛选参数),为 `resolvedPage``resolvedSize` 写新手注释:代码作用(白话)、关联文件、关联逻辑;校验页码与页大小。
- [x] 1.2 新增 `WecomAccountResponse.java`(文件用途:限定企微列表单行返回字段),包含企微业务字段、创建/更新时间、关联 ID 与关联名称,不包含 `deleteTime` - [x] 1.2 新增 `WecomAccountResponse.java`(文件用途:限定企微列表单行返回字段),包含企微业务字段、创建/更新时间、关联 ID 与关联名称,不包含 `deleteTime`
- [x] 1.3 新增 `WecomAccountPageResponse.java`(文件用途:承载列表、总数、页码、页大小),复用当前手机号资产分页的返回结构。 - [x] 1.3 新增 `WecomAccountPageResponse.java`(文件用途:承载列表、总数、页码、页大小),复用当前手机号码管理分页的返回结构。
## 2. 后端只读列表实现 ## 2. 后端只读列表实现
......
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
### Requirement: 一期字段与索引契约 ### Requirement: 一期字段与索引契约
系统 SHALL 按设计文档的 11 表字段字典创建业务列、主键、复合唯一索引和查询索引。逻辑关联字段 MUST 保存为 `*_id``BIGINT UNSIGNED` 列,但数据库 MUST NOT 定义 `FOREIGN KEY``REFERENCES``CHECK` 系统 SHALL 按设计文档的 11 表字段字典创建业务列、主键、复合唯一索引和查询索引。逻辑关联字段 MUST 保存为 `*_id``BIGINT UNSIGNED` 列,但数据库 MUST NOT 定义 `FOREIGN KEY``REFERENCES``CHECK`
#### Scenario: 验证手机号资产关联快照 #### Scenario: 验证手机号码管理关联快照
- **WHEN** 验收 `phone_asset` 的结构 - **WHEN** 验收 `phone_asset` 的结构
- **THEN** 必须存在手机号、卡/实名/设备字段、五个 `JSON` 关联快照字段、`relation_synced_at`、手机号有效记录复合唯一索引,以及卡类型、ICCID、设备查询索引 - **THEN** 必须存在手机号、卡/实名/设备字段、五个 `JSON` 关联快照字段、`relation_synced_at`、手机号有效记录复合唯一索引,以及卡类型、ICCID、设备查询索引
......
...@@ -9,4 +9,4 @@ ...@@ -9,4 +9,4 @@
#### Scenario: 保留本次业务修复 #### Scenario: 保留本次业务修复
- **WHEN** 完成动画工作内容清理后检查认证和手机号文件 - **WHEN** 完成动画工作内容清理后检查认证和手机号文件
- **THEN** 登录会话诊断、接口真实错误提示、企微注册手机号和手机号资产输入限制改动仍然存在 - **THEN** 登录会话诊断、接口真实错误提示、企微注册手机号和手机号码管理输入限制改动仍然存在
...@@ -38,7 +38,7 @@ DTO 处理格式完整的请求校验,服务层处理去空格后的长度与 ...@@ -38,7 +38,7 @@ DTO 处理格式完整的请求校验,服务层处理去空格后的长度与
不以“先查再写”作为重复保护。数据库现有唯一索引在并发保存时仍可阻止重复,GlobalExceptionHandler 根据公司名称唯一索引返回 HTTP 409 和“公司名称已存在”。 不以“先查再写”作为重复保护。数据库现有唯一索引在并发保存时仍可阻止重复,GlobalExceptionHandler 根据公司名称唯一索引返回 HTTP 409 和“公司名称已存在”。
### 前端使用现有手机号资产计数外观并安全解析响应 ### 前端使用现有手机号码管理计数外观并安全解析响应
公司名称输入框复用 `phone-asset-modal__count-input``phone-asset-modal__character-count`。页面显示字符数、阻止 emoji 保存,并在非 JSON 或空响应时显示固定安全提示。 公司名称输入框复用 `phone-asset-modal__count-input``phone-asset-modal__character-count`。页面显示字符数、阻止 emoji 保存,并在非 JSON 或空响应时显示固定安全提示。
......
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
### Requirement: 公司档案新增弹窗 ### Requirement: 公司档案新增弹窗
系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号资产新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;页面不得展示内部 ID。 系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号码管理新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;页面不得展示内部 ID。
公司名称输入框 MUST 显示当前字符数与上限 30 的计数,且不得允许 emoji 进入可保存值。统一社会信用代码和联系人输入框 MUST 限制为 64 个字符。保存失败时,弹窗 MUST 保持打开并显示服务端返回的可读错误;保存成功后 MUST 关闭弹窗并刷新公司档案列表。 公司名称输入框 MUST 显示当前字符数与上限 30 的计数,且不得允许 emoji 进入可保存值。统一社会信用代码和联系人输入框 MUST 限制为 64 个字符。保存失败时,弹窗 MUST 保持打开并显示服务端返回的可读错误;保存成功后 MUST 关闭弹窗并刷新公司档案列表。
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
## 3. 前端交互与响应健壮性 ## 3. 前端交互与响应健壮性
- [ ] 3.1 更新 CompanyProfileView:复用手机号资产字符计数样式,为公司名称显示 `当前值/30`,阻止 emoji 保存,并将统一社会信用代码和联系人限制为 64;为新增或改动函数补齐新手注释。 - [ ] 3.1 更新 CompanyProfileView:复用手机号码管理字符计数样式,为公司名称显示 `当前值/30`,阻止 emoji 保存,并将统一社会信用代码和联系人限制为 64;为新增或改动函数补齐新手注释。
- [ ] 3.2 更新 company-profile-api-client:安全处理空响应、HTML 和无法解析的响应,向页面返回“服务响应异常,请稍后重试”,并保留已有成功和业务错误处理。 - [ ] 3.2 更新 company-profile-api-client:安全处理空响应、HTML 和无法解析的响应,向页面返回“服务响应异常,请稍后重试”,并保留已有成功和业务错误处理。
## 4. 本地错误日志 ## 4. 本地错误日志
......
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