Commit cfd24c73 by DaiJiezhang

feat: add authenticated asset management

parent 27c7b6d1
......@@ -32,6 +32,27 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
......
package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
......@@ -8,14 +9,16 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/phone-assets")
public class PhoneAssetController {
private final PhoneAssetService service;
private final PagePermissionService permissions;
/** 代码作用(白话):接收手机号资产接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */
public PhoneAssetController(PhoneAssetService service){this.service=service;}
/** 代码作用(白话):接收手机号业务服务和页面权限服务。关联文件:PagePermissionService.java、PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 请求 -> READ/EDIT 校验 -> 原有业务服务。 */
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 表格。 */
@GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){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 -> 弹窗。 */
@PostMapping public ApiResponse<PhoneAssetResponse> create(@Valid @RequestBody PhoneAssetSaveRequest request){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 -> 列表刷新。 */
@PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){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 -> 列表刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){service.softDelete(id);return ApiResponse.success("删除成功",null);}
}
\ No newline at end of file
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){permissions.require(PagePermissionService.PHONE,"EDIT");service.softDelete(id);return ApiResponse.success("删除成功",null);}
}
......@@ -7,6 +7,7 @@ import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
......@@ -22,27 +23,29 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/wecom-accounts")
public class WecomAccountController {
private final WecomAccountService service;
private final PagePermissionService permissions;
/** Code purpose (plain language): receives the service that coordinates WeCom assets and referenced assets. Related files: WecomAccountService.java. Data flow: HTTP controller -> service -> mappers. */
public WecomAccountController(WecomAccountService service) { this.service = service; }
/** 代码作用(白话):接收企微业务服务和页面权限服务。关联文件:PagePermissionService.java、WecomAccountService.java。关联逻辑(调用链/数据流):HTTP 请求 -> READ/EDIT 校验 -> 原有企微业务服务。 */
public WecomAccountController(WecomAccountService service, PagePermissionService permissions) { this.service = service; this.permissions = permissions; }
/** Code purpose (plain language): saves a new enterprise WeChat asset. Related files: WecomAccountSaveRequest.java, WecomAccountService.java. Data flow: create dialog -> POST -> transaction -> response. */
@PostMapping
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { return ApiResponse.success("新增成功", service.create(request)); }
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { permissions.require(PagePermissionService.WECOM, "EDIT"); return ApiResponse.success("新增成功", service.create(request)); }
/** Code purpose (plain language): returns the paged enterprise WeChat asset list and optional registration-phone filter. Related files: WecomAccountPageQuery.java, WecomAccountService.java. Data flow: list query -> GET -> service.page -> table. */
@GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { return ApiResponse.success(service.page(query)); }
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.page(query)); }
/** Code purpose (plain language): searches registration subjects by company name or short name. Related files: CompanyProfileLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-profiles")
public ApiResponse<List<CompanyProfileLookupResponse>> companyProfiles(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyProfiles(keyword)); }
public ApiResponse<List<CompanyProfileLookupResponse>> companyProfiles(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchCompanyProfiles(keyword)); }
/** Code purpose (plain language): searches reusable registration phone assets. Related files: PhoneAssetLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/phone-assets")
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchPhoneAssets(keyword)); }
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchPhoneAssets(keyword)); }
/** Code purpose (plain language): searches optional WeCom owners from company people. Related files: CompanyPersonLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-persons")
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { return ApiResponse.success(service.searchCompanyPersons(keyword)); }
}
\ No newline at end of file
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
}
......@@ -18,4 +18,7 @@ public class SystemUserEntity extends AssetBaseEntity {
private String passwordHash;
private String roleCode;
private String status;
private String pagePermissions;
private LocalDateTime passwordUpdatedAt;
private Integer authVersion;
}
......@@ -2,101 +2,155 @@ package com.xyw.console.asset.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.entity.*;
import com.xyw.console.asset.dto.DeviceAssetPageQuery;
import com.xyw.console.asset.dto.DeviceAssetPageResponse;
import com.xyw.console.asset.dto.DeviceAssetResponse;
import com.xyw.console.asset.dto.DeviceAssetSaveRequest;
import com.xyw.console.asset.dto.DevicePersonLookupResponse;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.DouyinAccountEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WechatAccountEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.exception.DeviceAssetNotFoundException;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.asset.mapper.*;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.DouyinAccountMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WechatAccountMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
/** 文件用途(白话):实现设备资产查询、增改删、使用人解析、图片引用与关联保护规则。 */
/** File purpose: device asset CRUD, user-name resolution, image references, and reference-safe deletion. */
@Service
public class DeviceAssetService {
private static final Set<String> USAGE_STATUSES = Set.of("使用中", "闲置", "维修中", "停用");
private static final Set<String> RELATION_STATUSES = Set.of("已关联", "未关联", "待确认");
private final AssetDeviceMapper deviceMapper; private final CompanyPersonMapper personMapper; private final PhoneAssetMapper phoneMapper;
private final WecomAccountMapper wecomMapper; private final WechatAccountMapper wechatMapper; private final DouyinAccountMapper douyinMapper;
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 final AssetDeviceMapper deviceMapper;
private final CompanyPersonMapper personMapper;
private final PhoneAssetMapper phoneMapper;
private final WecomAccountMapper wecomMapper;
private final WechatAccountMapper wechatMapper;
private final DouyinAccountMapper douyinMapper;
private final DeviceAssetFileStorageService fileStorage;
/** 代码作用(白话):接收设备、人员、引用资产和文件服务入口。关联文件:DeviceAssetController.java、各 Mapper。关联逻辑(调用链/数据流):Controller -> Service -> Mapper/文件服务。 */
/** Plain purpose: wire controller-facing persistence and storage collaborators. Related files: DeviceAssetController.java and Mapper classes. Flow: Controller -> Service -> Mapper/file storage. */
public DeviceAssetService(AssetDeviceMapper deviceMapper, CompanyPersonMapper personMapper, PhoneAssetMapper phoneMapper,
WecomAccountMapper wecomMapper, WechatAccountMapper wechatMapper, DouyinAccountMapper douyinMapper, DeviceAssetFileStorageService fileStorage) {
this.deviceMapper=deviceMapper; this.personMapper=personMapper; this.phoneMapper=phoneMapper; this.wecomMapper=wecomMapper; this.wechatMapper=wechatMapper; this.douyinMapper=douyinMapper; this.fileStorage=fileStorage;
}
/** 代码作用(白话):按条件读取未删除设备并补齐使用人名称。关联文件:DeviceAssetPageQuery.java、DeviceAssetController.java。关联逻辑(调用链/数据流):GET 参数 -> page -> Mapper -> Response -> Vue 表格。 */
/** Plain purpose: return one active device page with display names. Related files: DeviceAssetPageQuery.java, DeviceAssetController.java. Flow: GET filters -> Mapper page -> response rows -> Vue table. */
public DeviceAssetPageResponse page(DeviceAssetPageQuery query) {
Page<AssetDeviceEntity> result=deviceMapper.selectPage(new Page<>(query.resolvedPage(),query.resolvedSize()),activeQuery(query));
Map<Long,String> names=personNames(collectIds(result.getRecords(),AssetDeviceEntity::getUserPersonId));
return new DeviceAssetPageResponse(result.getRecords().stream().map(item->toResponse(item,names)).toList(),result.getTotal(),query.resolvedPage(),query.resolvedSize());
}
/** 代码作用(白话):创建一条设备和其两张可选图片引用。关联文件:DeviceAssetSaveRequest.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):multipart POST -> create -> 文件保存 + Mapper.insert -> JSON 响应。 */
/** Plain purpose: save a new device and its optional images, removing newly stored files if database creation fails. Related files: DeviceAssetSaveRequest.java, DeviceAssetFileStorageService.java. Flow: multipart POST -> store -> insert -> response or cleanup. */
public DeviceAssetResponse create(DeviceAssetSaveRequest request) {
validateSaveRequest(request,null); String image1=null,image2=null;
try { image1=fileStorage.store(request.getImageAttachment1()); image2=fileStorage.store(request.getImageAttachment2());
try {
image1=fileStorage.store(request.getImageAttachment1()); image2=fileStorage.store(request.getImageAttachment2());
AssetDeviceEntity entity=new AssetDeviceEntity(); applyEditableFields(entity,request,image1,image2); LocalDateTime now=LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L);
if(deviceMapper.insert(entity)!=1) throw new IllegalStateException("设备资产新增失败"); return toResponse(entity,personNames(singleId(entity.getUserPersonId())));
if(deviceMapper.insert(entity)!=1) throw new IllegalStateException("\u8bbe\u5907\u8d44\u4ea7\u65b0\u589e\u5931\u8d25");
return toResponse(entity,personNames(singleId(entity.getUserPersonId())));
} catch (RuntimeException exception) { fileStorage.cleanupNewFile(image1); fileStorage.cleanupNewFile(image2); throw exception; }
}
/** 代码作用(白话):更新有效设备,未选择新图时保留旧图。关联文件:DeviceAssetSaveRequest.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):multipart PUT -> update -> 文件标识调整 + Mapper.update -> 列表刷新。 */
/** Plain purpose: update an active device while retaining old images unless a new image or remove flag is supplied. Related files: DeviceAssetSaveRequest.java, DeviceAssetFileStorageService.java. Flow: multipart PUT -> replace references -> update -> response. */
public DeviceAssetResponse update(Long id, DeviceAssetSaveRequest request) {
AssetDeviceEntity entity=requireActiveDevice(id); validateSaveRequest(request,id); String image1=null,image2=null;
try { image1=fileStorage.replace(entity.getImageAttachment1(),request.getImageAttachment1(),Boolean.TRUE.equals(request.getRemoveImageAttachment1())); image2=fileStorage.replace(entity.getImageAttachment2(),request.getImageAttachment2(),Boolean.TRUE.equals(request.getRemoveImageAttachment2()));
applyEditableFields(entity,request,image1,image2); entity.setUpdateTime(LocalDateTime.now()); if(deviceMapper.updateById(entity)!=1) throw new DeviceAssetNotFoundException("设备资产不存在或已删除"); return toResponse(entity,personNames(singleId(entity.getUserPersonId())));
} catch (RuntimeException exception) { if(image1!=null&&!image1.equals(entity.getImageAttachment1())) fileStorage.cleanupNewFile(image1); if(image2!=null&&!image2.equals(entity.getImageAttachment2())) fileStorage.cleanupNewFile(image2); throw exception; }
AssetDeviceEntity entity=requireActiveDevice(id); validateSaveRequest(request,id); String originalImage1=entity.getImageAttachment1(),originalImage2=entity.getImageAttachment2(),image1=null,image2=null;
try {
image1=fileStorage.replace(entity.getImageAttachment1(),request.getImageAttachment1(),Boolean.TRUE.equals(request.getRemoveImageAttachment1()));
image2=fileStorage.replace(entity.getImageAttachment2(),request.getImageAttachment2(),Boolean.TRUE.equals(request.getRemoveImageAttachment2()));
applyEditableFields(entity,request,image1,image2); entity.setUpdateTime(LocalDateTime.now());
if(deviceMapper.updateById(entity)!=1) throw new DeviceAssetNotFoundException("\u8bbe\u5907\u8d44\u4ea7\u4e0d\u5b58\u5728\u6216\u5df2\u5220\u9664");
return toResponse(entity,personNames(singleId(entity.getUserPersonId())));
} catch (RuntimeException exception) {
if(image1!=null&&!image1.equals(originalImage1)) fileStorage.cleanupNewFile(image1);
if(image2!=null&&!image2.equals(originalImage2)) fileStorage.cleanupNewFile(image2);
throw exception;
}
}
/** 代码作用(白话):在确认没有有效关联资产后软删除设备。关联文件:PhoneAssetEntity.java、WecomAccountEntity.java。关联逻辑(调用链/数据流):DELETE -> 引用检查 -> deleteTime 更新 -> 列表隐藏。 */
public void softDelete(Long id) { AssetDeviceEntity entity=requireActiveDevice(id); checkActiveReferences(id); entity.setDeleteTime(System.currentTimeMillis()); entity.setUpdateTime(LocalDateTime.now()); if(deviceMapper.updateById(entity)!=1) throw new DeviceAssetNotFoundException("设备资产不存在或已删除"); }
/** Plain purpose: soft-delete an unreferenced active device without physically deleting its images. Related files: PhoneAssetEntity.java and account entities. Flow: DELETE -> reference counts -> update deleteTime -> hidden from page. */
public void softDelete(Long id) {
AssetDeviceEntity entity=requireActiveDevice(id); checkActiveReferences(id); entity.setDeleteTime(System.currentTimeMillis()); entity.setUpdateTime(LocalDateTime.now());
if(deviceMapper.updateById(entity)!=1) throw new DeviceAssetNotFoundException("\u8bbe\u5907\u8d44\u4ea7\u4e0d\u5b58\u5728\u6216\u5df2\u5220\u9664");
}
/** 代码作用(白话):按姓名返回可选择的有效公司人员。关联文件:DeviceAssetController.java、CompanyPersonEntity.java。关联逻辑(调用链/数据流):远程下拉输入 -> lookup -> person Mapper -> option 列表。 */
public List<DevicePersonLookupResponse> searchCompanyPersons(String keyword) { return personMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getDeleteTime,0L).like(hasText(keyword),CompanyPersonEntity::getPersonName,keyword).orderByDesc(CompanyPersonEntity::getId)).stream().limit(20).map(item->new DevicePersonLookupResponse(item.getId(),item.getPersonName())).toList(); }
/** Plain purpose: return up to twenty active company persons for remote select. Related files: DeviceAssetController.java, CompanyPersonEntity.java. Flow: select input -> service lookup -> mapper -> dropdown options. */
public List<DevicePersonLookupResponse> searchCompanyPersons(String keyword) {
return personMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getDeleteTime,0L).like(hasText(keyword),CompanyPersonEntity::getPersonName,keyword).orderByDesc(CompanyPersonEntity::getId))
.stream().limit(20).map(item->new DevicePersonLookupResponse(item.getId(),item.getPersonName())).toList();
}
/** 代码作用(白话):读取一张受控设备图片。关联文件:DeviceAssetController.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):图片 URL -> findImage -> 文件服务 -> Resource 响应。 */
/** 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); }
/** 代码作用(白话):组合列表筛选和未删除条件。关联文件:DeviceAssetPageQuery.java、AssetDeviceEntity.java。关联逻辑(调用链/数据流):Query DTO -> LambdaQueryWrapper -> SQL where。 */
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); }
/** 代码作用(白话):读取一条仍有效设备,供编辑和删除共用。关联文件:AssetDeviceMapper.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404 JSON。 */
private AssetDeviceEntity requireActiveDevice(Long id) { AssetDeviceEntity entity=deviceMapper.selectOne(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getId,id).eq(AssetDeviceEntity::getDeleteTime,0L)); if(entity==null) throw new DeviceAssetNotFoundException("设备资产不存在或已删除"); return entity; }
/** 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) {
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);
}
/** 代码作用(白话):校验设备名称、状态、使用人和有效名称唯一性。关联文件:DeviceAssetSaveRequest.java、CompanyPersonMapper.java。关联逻辑(调用链/数据流):POST/PUT 表单 -> validateSaveRequest -> 允许写库或返回 400。 */
private void validateSaveRequest(DeviceAssetSaveRequest request,Long currentId) { if(request.getDeviceName()==null||request.getDeviceName().isBlank()) throw new DeviceAssetValidationException("设备名称不能为空"); validateStatus(request.getUserUsageStatus(),USAGE_STATUSES,"使用状态"); validateStatus(request.getAssetRelationStatus(),RELATION_STATUSES,"资产关联状态"); validateUserPerson(request.getUserPersonId()); long sameName=deviceMapper.selectCount(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).eq(AssetDeviceEntity::getDeviceName,request.getDeviceName().trim()).ne(currentId!=null,AssetDeviceEntity::getId,currentId)); if(sameName>0) throw new DeviceAssetValidationException("设备名称已存在"); }
/** Plain purpose: require one non-deleted device for edit and delete. Related files: AssetDeviceMapper.java, DeviceAssetExceptionHandler.java. Flow: service query -> missing item -> HTTP 404. */
private AssetDeviceEntity requireActiveDevice(Long id) {
AssetDeviceEntity entity=deviceMapper.selectOne(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getId,id).eq(AssetDeviceEntity::getDeleteTime,0L));
if(entity==null) throw new DeviceAssetNotFoundException("\u8bbe\u5907\u8d44\u4ea7\u4e0d\u5b58\u5728\u6216\u5df2\u5220\u9664"); return entity;
}
/** 代码作用(白话):确认选择的使用人仍有效。关联文件:CompanyPersonEntity.java、DeviceAssetSaveRequest.java。关联逻辑(调用链/数据流):表单 personId -> Mapper 查询 -> 允许保存或错误提示。 */
private void validateUserPerson(Long userPersonId) { if(userPersonId==null) return; Long count=personMapper.selectCount(new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getId,userPersonId).eq(CompanyPersonEntity::getDeleteTime,0L)); if(count==0) throw new DeviceAssetValidationException("使用人不存在或已删除"); }
/** Plain purpose: validate names, dropdown values, optional person, and active-name uniqueness before writes. Related files: DeviceAssetSaveRequest.java, CompanyPersonMapper.java. Flow: POST/PUT -> validation -> write or HTTP 400. */
private void validateSaveRequest(DeviceAssetSaveRequest request,Long currentId) {
if(request.getDeviceName()==null||request.getDeviceName().isBlank()) throw new DeviceAssetValidationException("\u8bbe\u5907\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a");
validateStatus(request.getUserUsageStatus(),USAGE_STATUSES,"\u4f7f\u7528\u72b6\u6001"); validateStatus(request.getAssetRelationStatus(),RELATION_STATUSES,"\u8d44\u4ea7\u5173\u8054\u72b6\u6001"); validateUserPerson(request.getUserPersonId());
long sameName=deviceMapper.selectCount(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).eq(AssetDeviceEntity::getDeviceName,request.getDeviceName().trim()).ne(currentId!=null,AssetDeviceEntity::getId,currentId));
if(sameName>0) throw new DeviceAssetValidationException("\u8bbe\u5907\u540d\u79f0\u5df2\u5b58\u5728");
}
/** 代码作用(白话):限制状态只能来自已确认下拉选项。关联文件:DeviceAssetView.js、DeviceAssetSaveRequest.java。关联逻辑(调用链/数据流):前端下拉/请求参数 -> validateStatus -> 安全写库。 */
private void validateStatus(String value,Set<String> allowed,String label) { if(value==null||!allowed.contains(value)) throw new DeviceAssetValidationException(label+"取值无效"); }
/** Plain purpose: ensure an optional selected person is still active. Related files: CompanyPersonEntity.java, DeviceAssetSaveRequest.java. Flow: form person ID -> mapper count -> allow save or HTTP 400. */
private void validateUserPerson(Long userPersonId) {
if(userPersonId==null) return;
Long count=personMapper.selectCount(new LambdaQueryWrapper<CompanyPersonEntity>().eq(CompanyPersonEntity::getId,userPersonId).eq(CompanyPersonEntity::getDeleteTime,0L));
if(count==0) throw new DeviceAssetValidationException("\u4f7f\u7528\u4eba\u4e0d\u5b58\u5728\u6216\u5df2\u5220\u9664");
}
/** 代码作用(白话):查找仍引用设备的四类资产并阻止删除。关联文件:PhoneAssetEntity.java、WecomAccountEntity.java、WechatAccountEntity.java、DouyinAccountEntity.java。关联逻辑(调用链/数据流):DELETE -> 各 Mapper count -> 引用提示或软删除。 */
private void checkActiveReferences(Long deviceId) { List<String> types=new ArrayList<>(); if(phoneMapper.selectCount(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeviceId,deviceId).eq(PhoneAssetEntity::getDeleteTime,0L))>0) types.add("手机号资产"); if(wecomMapper.selectCount(new LambdaQueryWrapper<WecomAccountEntity>().eq(WecomAccountEntity::getDeviceId,deviceId).eq(WecomAccountEntity::getDeleteTime,0L))>0) types.add("企业微信资产"); if(wechatMapper.selectCount(new LambdaQueryWrapper<WechatAccountEntity>().eq(WechatAccountEntity::getDeviceId,deviceId).eq(WechatAccountEntity::getDeleteTime,0L))>0) types.add("微信资产"); if(douyinMapper.selectCount(new LambdaQueryWrapper<DouyinAccountEntity>().eq(DouyinAccountEntity::getDeviceId,deviceId).eq(DouyinAccountEntity::getDeleteTime,0L))>0) types.add("抖音资产"); if(!types.isEmpty()) throw new DeviceAssetValidationException("设备仍被"+String.join("、",types)+"引用,不能删除"); }
/** Plain purpose: reject forged dropdown values. Related files: DeviceAssetView.js, DeviceAssetSaveRequest.java. Flow: dropdown value -> validation -> safe database write. */
private void validateStatus(String value,Set<String> allowed,String label) { if(value==null||!allowed.contains(value)) throw new DeviceAssetValidationException(label+"\u53d6\u503c\u65e0\u6548"); }
/** Plain purpose: prevent deletion while any active phone or account references the device. Related files: PhoneAssetEntity.java and account entities. Flow: DELETE -> four reference counts -> error or soft delete. */
private void checkActiveReferences(Long deviceId) {
List<String> types=new ArrayList<>();
if(phoneMapper.selectCount(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeviceId,deviceId).eq(PhoneAssetEntity::getDeleteTime,0L))>0) types.add("\u624b\u673a\u53f7\u8d44\u4ea7");
if(wecomMapper.selectCount(new LambdaQueryWrapper<WecomAccountEntity>().eq(WecomAccountEntity::getDeviceId,deviceId).eq(WecomAccountEntity::getDeleteTime,0L))>0) types.add("\u4f01\u4e1a\u5fae\u4fe1\u8d44\u4ea7");
if(wechatMapper.selectCount(new LambdaQueryWrapper<WechatAccountEntity>().eq(WechatAccountEntity::getDeviceId,deviceId).eq(WechatAccountEntity::getDeleteTime,0L))>0) types.add("\u5fae\u4fe1\u8d44\u4ea7");
if(douyinMapper.selectCount(new LambdaQueryWrapper<DouyinAccountEntity>().eq(DouyinAccountEntity::getDeviceId,deviceId).eq(DouyinAccountEntity::getDeleteTime,0L))>0) types.add("\u6296\u97f3\u8d44\u4ea7");
if(!types.isEmpty()) throw new DeviceAssetValidationException("\u8bbe\u5907\u4ecd\u88ab"+String.join("\u3001",types)+"\u5f15\u7528\uff0c\u4e0d\u80fd\u5220\u9664");
}
/** 代码作用(白话):收集当前页不为空的使用人 ID,供一次性查姓名。关联文件:DeviceAssetResponse.java、CompanyPersonMapper.java。关联逻辑(调用链/数据流):分页记录 -> collectIds -> 批量人员查询。 */
/** Plain purpose: collect non-null person IDs for one batch query. Related files: DeviceAssetResponse.java, CompanyPersonMapper.java. Flow: page records -> IDs -> person query. */
private <T> Set<Long> collectIds(List<T> items,Function<T,Long> getter) { Set<Long> ids=new HashSet<>(); items.forEach(item->{Long id=getter.apply(item);if(id!=null)ids.add(id);}); return ids; }
/** 代码作用(白话):把一个可空人员 ID 转成供批量查询复用的集合。关联文件:DeviceAssetService.java、CompanyPersonMapper.java。关联逻辑(调用链/数据流):单条保存结果 -> singleId -> personNames -> 返回姓名。 */
/** Plain purpose: adapt one optional person ID into the batch-name lookup shape. Related files: DeviceAssetService.java, CompanyPersonMapper.java. Flow: created/updated row -> single ID -> personNames. */
private Set<Long> singleId(Long id) { return id == null ? Set.of() : Set.of(id); }
/** 代码作用(白话):批量把人员 ID 转为人员姓名,避免逐行查询。关联文件:CompanyPersonEntity.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):设备 userPersonId 集合 -> IN 查询 -> 姓名映射 -> 响应。 */
/** 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; }
/** 代码作用(白话):将数据库实体转换为页面安全字段。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):Entity + 人员名 -> toResponse -> ApiResponse -> Vue 行。 */
/** 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()); }
/** 代码作用(白话):将内部图片标识转换为浏览器受控访问 URL。关联文件:DeviceAssetController.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):数据库标识 -> imageUrl -> 浏览器 img 请求。 */
/** 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; }
/** 代码作用(白话):把可编辑表单字段写入实体。关联文件:DeviceAssetSaveRequest.java、AssetDeviceEntity.java。关联逻辑(调用链/数据流):DTO -> applyEditableFields -> Entity -> Mapper 写库。 */
/** 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); }
/** 代码作用(白话):判断筛选文本是否有内容。关联文件:DeviceAssetPageQuery.java、DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP 参数 -> hasText -> 是否追加 SQL 条件。 */
/** Plain purpose: decide whether a text filter has meaningful input. Related files: DeviceAssetPageQuery.java, DeviceAssetService.java. Flow: HTTP query -> filter presence -> SQL predicate. */
private boolean hasText(String value) { return value!=null&&!value.isBlank(); }
}
package com.xyw.console.auth;
import com.xyw.console.auth.dto.CurrentUserResponse; import com.xyw.console.auth.dto.LoginRequest; import com.xyw.console.common.ApiResponse;
import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/api/auth")
public class AuthController {
private final AuthService service;
/** 代码作用(白话):接收认证业务服务;关联文件:AuthService.java。关联逻辑(调用链/数据流):浏览器认证请求 -> Controller -> Service。 */
public AuthController(AuthService service) { this.service=service; }
/** 代码作用(白话):接收登录表单并写入安全 Cookie;关联文件:LoginRequest.java、LoginView.js。关联逻辑(调用链/数据流):用户名密码 -> AuthService -> Set-Cookie/当前用户。 */
@PostMapping("/login") public ApiResponse<CurrentUserResponse> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) { return ApiResponse.success(service.login(request,response)); }
/** 代码作用(白话):返回当前登录身份及页面权限;关联文件:AuthService.java、auth-store.js。关联逻辑(调用链/数据流):Cookie -> Filter -> me -> 菜单路由。 */
@GetMapping("/me") public ApiResponse<CurrentUserResponse> me() { return ApiResponse.success(service.me()); }
/** 代码作用(白话):让浏览器取得写操作所需的 CSRF 校验值;关联文件:auth-api-client.js、SecurityConfig.java。关联逻辑(调用链/数据流):应用启动/提交前 -> csrf 接口 -> XSRF-TOKEN Cookie 和请求头。 */
@GetMapping("/csrf") public ApiResponse<String> csrf(CsrfToken token) { return ApiResponse.success(token.getToken()); }
/** 代码作用(白话):清除登录 Cookie 结束当前浏览器会话;关联文件:AuthService.java、App.js。关联逻辑(调用链/数据流):登出 -> Controller -> 过期 Cookie。 */
@PostMapping("/logout") public ApiResponse<Void> logout(HttpServletResponse response) { service.logout(response); return ApiResponse.success(null); }
}
package com.xyw.console.auth;
import com.xyw.console.common.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 代码作用(白话):把认证与账号管理的预期失败统一转成前端可读取的 JSON;关联文件:AuthController.java、system-user-api-client.js。关联逻辑(调用链/数据流):Service 抛出规则异常 -> 本处理器 -> ApiResponse -> 提示消息。 */
@RestControllerAdvice
public class AuthExceptionHandler {
/** 代码作用(白话):隐藏登录失败细节并返回统一未认证状态;关联文件:AuthService.java、LoginView.js。关联逻辑(调用链/数据流):密码校验失败 -> 401 JSON -> 登录页通用错误提示。 */
@ExceptionHandler(BadCredentialsException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResponse<Void> badCredentials(BadCredentialsException error) { return ApiResponse.error(401, "账号或密码错误"); }
/** 代码作用(白话):把账号、角色和密码规则失败返回为明确的 400 提示;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):规则校验失败 -> 400 JSON -> 表单提示。 */
@ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> invalidArgument(IllegalArgumentException error) { return ApiResponse.error(400, error.getMessage()); }
}
package com.xyw.console.auth;
/**
* 代码作用(白话):保存已通过令牌验证的最小用户身份,避免把密码哈希放进 Spring Security 上下文。
* 关联文件:AuthTokenFilter.java、PagePermissionService.java。
* 关联逻辑(调用链/数据流):Cookie -> AuthTokenFilter -> AuthPrincipal -> Controller/权限服务。
*/
public record AuthPrincipal(Long userId, String username, String roleCode, int authVersion) {}
package com.xyw.console.auth;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import com.xyw.console.auth.dto.CurrentUserResponse;
import com.xyw.console.auth.dto.LoginRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final SystemUserMapper users; private final PasswordEncoder encoder; private final AuthTokenService tokens; private final PagePermissionService permissions;
/** 代码作用(白话):接收认证依赖;关联文件:AuthController.java、SystemUserMapper.java。关联逻辑(调用链/数据流):Controller -> Service -> 用户表/令牌/权限。 */
public AuthService(SystemUserMapper users, PasswordEncoder encoder, AuthTokenService tokens, PagePermissionService permissions) { this.users = users; this.encoder = encoder; this.tokens = tokens; this.permissions = permissions; }
/** 代码作用(白话):验证账号密码并签发登录 Cookie;关联文件:LoginRequest.java、AuthTokenService.java。关联逻辑(调用链/数据流):登录表单 -> 用户表/BCrypt -> Cookie -> 当前用户响应。 */
public CurrentUserResponse login(LoginRequest request, HttpServletResponse response) { SystemUserEntity user = users.selectOne(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getUsername, request.username()).eq(SystemUserEntity::getDeleteTime, 0L)); if (user == null || !"ACTIVE".equals(user.getStatus()) || user.getPasswordHash() == null || !encoder.matches(request.password(), user.getPasswordHash())) throw new BadCredentialsException("账号或密码错误"); tokens.issue(new AuthPrincipal(user.getId(), user.getUsername(), user.getRoleCode(), user.getAuthVersion() == null ? 1 : user.getAuthVersion()), response); return responseOf(user); }
/** 代码作用(白话):把当前令牌身份重新读取为最新菜单资料;关联文件:AuthTokenFilter.java、App.js。关联逻辑(调用链/数据流):Cookie -> SecurityContext -> 用户表 -> 菜单权限响应。 */
public CurrentUserResponse me() { Object value = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (!(value instanceof AuthPrincipal principal)) throw new BadCredentialsException("登录已失效"); SystemUserEntity user = users.selectById(principal.userId()); if (user == null || !"ACTIVE".equals(user.getStatus())) throw new BadCredentialsException("登录已失效"); return responseOf(user); }
/** 代码作用(白话):让浏览器删除登录 Cookie;关联文件:AuthController.java。关联逻辑(调用链/数据流):登出按钮 -> Service -> 过期 Cookie。 */
public void logout(HttpServletResponse response) { tokens.clear(response); }
/** 代码作用(白话):将用户实体转换为绝不包含密码哈希的当前登录响应;关联文件:CurrentUserResponse.java、auth-store.js。关联逻辑(调用链/数据流):用户表 -> 计算有效权限 -> 前端会话状态。 */
private CurrentUserResponse responseOf(SystemUserEntity user) { return new CurrentUserResponse(user.getId(), user.getUsername(), user.getRoleCode(), permissions.effectivePermissions(user)); }
}
package com.xyw.console.auth;
import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.util.Collections;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class AuthTokenFilter extends OncePerRequestFilter {
private final AuthTokenService tokens; private final SystemUserMapper users; private final PagePermissionService permissions;
/** 代码作用(白话):接收令牌、用户表和权限服务;关联文件:SecurityConfig.java。关联逻辑(调用链/数据流):Security Filter Chain -> 本过滤器 -> 用户表/权限。 */
public AuthTokenFilter(AuthTokenService tokens, SystemUserMapper users, PagePermissionService permissions) { this.tokens=tokens; this.users=users; this.permissions=permissions; }
/** 代码作用(白话):跳过登录和 CSRF 令牌接口,避免公开的初始化请求被自身拦截;关联文件:AuthController.java。关联逻辑(调用链/数据流):/api/auth/login 或 /api/auth/csrf -> Controller,不进入验签。 */
@Override protected boolean shouldNotFilter(HttpServletRequest request) { return "/api/auth/login".equals(request.getRequestURI()) || "/api/auth/csrf".equals(request.getRequestURI()); }
/** 代码作用(白话):将有效 Cookie 转成 Spring Security 身份并二次核验账号状态和版本;关联文件:AuthTokenService.java、SystemUserEntity.java。关联逻辑(调用链/数据流):请求 Cookie -> JWT -> 用户表 -> SecurityContext -> Controller。 */
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { String token=tokens.extract(request.getCookies()); if (token != null) { AuthPrincipal principal=tokens.parse(token); SystemUserEntity user=users.selectById(principal.userId()); if (user != null && "ACTIVE".equals(user.getStatus()) && user.getAuthVersion()!=null && user.getAuthVersion()==principal.authVersion()) { var authentication=new UsernamePasswordAuthenticationToken(principal, null, Collections.singleton(new SimpleGrantedAuthority("ROLE_"+principal.roleCode()))); authentication.setDetails(permissions.effectivePermissions(user)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception ignored) { SecurityContextHolder.clearContext(); } chain.doFilter(request,response); }
}
package com.xyw.console.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
@Service
public class AuthTokenService {
private static final String COOKIE_NAME = "XYW_SESSION";
private final String secret;
private final Duration lifetime;
private final boolean cookieSecure;
/** 代码作用(白话):读取部署密钥和会话时长;关联文件:application.yml。关联逻辑(调用链/数据流):环境变量 -> 配置 -> JWT 签发/验签。 */
public AuthTokenService(@Value("${xyw.auth.jwt-secret:}") String secret, @Value("${xyw.auth.session-hours:8}") long sessionHours, @Value("${xyw.auth.cookie-secure:false}") boolean cookieSecure) { this.secret = secret; this.lifetime = Duration.ofHours(sessionHours); this.cookieSecure = cookieSecure; }
/** 代码作用(白话):为已登录用户签发仅浏览器自动携带的 HttpOnly Cookie;关联文件:AuthService.java。关联逻辑(调用链/数据流):登录成功 -> JWT -> Set-Cookie -> 后续过滤器。 */
public void issue(AuthPrincipal principal, HttpServletResponse response) {
Instant now = Instant.now();
String token = Jwts.builder().subject(principal.userId().toString()).claim("username", principal.username()).claim("role", principal.roleCode()).claim("version", principal.authVersion()).issuedAt(Date.from(now)).expiration(Date.from(now.plus(lifetime))).signWith(key()).compact();
response.addHeader("Set-Cookie", ResponseCookie.from(COOKIE_NAME, token).httpOnly(true).secure(cookieSecure).sameSite("Lax").path("/").maxAge(lifetime).build().toString());
}
/** 代码作用(白话):解析并验签 Cookie 中的短期登录票据;关联文件:AuthTokenFilter.java。关联逻辑(调用链/数据流):请求 Cookie -> JWT claims -> 数据库二次核验。 */
public AuthPrincipal parse(String token) {
Claims claims = Jwts.parser().verifyWith((javax.crypto.SecretKey) key()).build().parseSignedClaims(token).getPayload();
return new AuthPrincipal(Long.valueOf(claims.getSubject()), claims.get("username", String.class), claims.get("role", String.class), claims.get("version", Integer.class));
}
/** 代码作用(白话):清空浏览器登录 Cookie;关联文件:AuthService.java。关联逻辑(调用链/数据流):登出 -> 过期 Cookie -> 下次请求无身份。 */
public void clear(HttpServletResponse response) { response.addHeader("Set-Cookie", ResponseCookie.from(COOKIE_NAME, "").httpOnly(true).secure(cookieSecure).sameSite("Lax").path("/").maxAge(Duration.ZERO).build().toString()); }
/** 代码作用(白话):从请求 Cookie 取出登录票据;关联文件:AuthTokenFilter.java。关联逻辑(调用链/数据流):HTTP 请求 -> Cookie 数组 -> JWT 文本。 */
public String extract(Cookie[] cookies) { if (cookies != null) for (Cookie cookie : cookies) if (COOKIE_NAME.equals(cookie.getName())) return cookie.getValue(); return null; }
/** 代码作用(白话):验证密钥足够安全,避免使用默认或过短密钥签发票据;关联文件:application.yml。关联逻辑(调用链/数据流):配置 -> HMAC Key -> JWT 签名。 */
private Key key() { if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) throw new IllegalStateException("认证签名密钥未配置或长度不足"); return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); }
}
package com.xyw.console.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xyw.console.asset.entity.SystemUserEntity;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Service
public class PagePermissionService {
public static final String OVERVIEW = "overview";
public static final String DOMAIN = "domain";
public static final String WECOM = "reference-wecom";
public static final String PHONE = "phone-assets";
public static final String ALERTS = "alerts";
private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号资产", ALERTS, "提醒中心");
private final ObjectMapper objectMapper;
/** 代码作用(白话):接收 JSON 工具以读取数据库权限映射;关联文件:SystemUserEntity.java。关联逻辑(调用链/数据流):page_permissions JSON -> 权限 Map -> Controller 判定。 */
public PagePermissionService(ObjectMapper objectMapper) { this.objectMapper = objectMapper; }
/** 代码作用(白话):生成当前账号的有效五页权限,开发者和超级管理员固定全编辑;关联文件:AuthService.java、App.js。关联逻辑(调用链/数据流):用户表 -> 有效权限 -> /api/auth/me -> 菜单和路由。 */
public Map<String, String> effectivePermissions(SystemUserEntity user) {
Map<String, String> result = new LinkedHashMap<>();
PAGES.keySet().forEach(key -> result.put(key, isAdministrator(user.getRoleCode()) ? "EDIT" : "NONE"));
if (!isAdministrator(user.getRoleCode()) && user.getPagePermissions() != null) try { Map<String, String> saved = objectMapper.readValue(user.getPagePermissions(), new TypeReference<>() {}); saved.forEach((key, value) -> { if (PAGES.containsKey(key) && validLevel(value)) result.put(key, value); }); } catch (Exception ignored) { }
return result;
}
/** 代码作用(白话):校验权限面板提交内容,只接受已注册页面和三档权限;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):表单 Map -> 校验/标准化 -> JSON 入库。 */
public String validatePermissions(Map<String, String> input) {
Map<String, String> normalized = new LinkedHashMap<>();
if (input != null) input.forEach((key, value) -> { if (!PAGES.containsKey(key) || !validLevel(value)) throw new IllegalArgumentException("页面权限配置无效"); normalized.put(key, value); });
try { return objectMapper.writeValueAsString(normalized); } catch (Exception error) { throw new IllegalStateException("页面权限无法保存", error); }
}
/** 代码作用(白话):在资产接口执行服务端最低权限检查;关联文件:PhoneAssetController.java、WecomAccountController.java。关联逻辑(调用链/数据流):Controller -> 当前认证身份 -> READ/EDIT 决定 403 或继续业务服务。 */
public void require(String pageKey, String minimum) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof AuthPrincipal principal)) throw new AccessDeniedException("没有页面权限");
String permission = authentication.getDetails() instanceof Map<?, ?> map ? String.valueOf(map.get(pageKey)) : "NONE";
if (!("EDIT".equals(permission) || ("READ".equals(permission) && "READ".equals(minimum)))) throw new AccessDeniedException("没有页面权限");
}
/** 代码作用(白话):判断角色是否固定拥有全部编辑权限;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):role_code -> 全量权限或逐页权限。 */
public boolean isAdministrator(String roleCode) { return "DEVELOPER".equals(roleCode) || "SUPER_ADMIN".equals(roleCode); }
/** 代码作用(白话):限制权限值为三档,避免未知值意外放行;关联文件:UserPermissionView.js。关联逻辑(调用链/数据流):前端单选值 -> 后端验证 -> 持久化。 */
private boolean validLevel(String value) { return "NONE".equals(value) || "READ".equals(value) || "EDIT".equals(value); }
}
package com.xyw.console.auth;
import com.xyw.console.auth.dto.PasswordResetRequest;
import com.xyw.console.auth.dto.SystemUserCreateRequest;
import com.xyw.console.auth.dto.SystemUserResponse;
import com.xyw.console.auth.dto.SystemUserUpdateRequest;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/system-users")
public class SystemUserAdminController {
private final SystemUserAdminService service;
/** 代码作用(白话):接收账号管理业务服务;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):管理页面请求 -> Controller -> 角色边界/用户表。 */
public SystemUserAdminController(SystemUserAdminService service) { this.service = service; }
/** 代码作用(白话):返回隐藏 Jeddy 后的账号列表;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):设置页面 -> GET -> Service 查询 -> 安全列表。 */
@GetMapping public ApiResponse<List<SystemUserResponse>> list() { return ApiResponse.success(service.listVisibleUsers()); }
/** 代码作用(白话):创建账号并由服务端执行创建者角色规则;关联文件:SystemUserCreateRequest.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):创建表单 -> POST -> 账号表。 */
@PostMapping public ApiResponse<SystemUserResponse> create(@Valid @RequestBody SystemUserCreateRequest request) { return ApiResponse.success(service.createUser(request)); }
/** 代码作用(白话):编辑账号资料和页面权限;关联文件:SystemUserUpdateRequest.java、PagePermissionService.java。关联逻辑(调用链/数据流):权限面板 -> PUT -> 用户表。 */
@PutMapping("/{id}") public ApiResponse<SystemUserResponse> update(@PathVariable Long id, @Valid @RequestBody SystemUserUpdateRequest request) { return ApiResponse.success(service.updateUser(id, request)); }
/** 代码作用(白话):仅开发者可重置非开发者密码;关联文件:PasswordResetRequest.java、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):开发者表单 -> PUT password -> BCrypt/触发器。 */
@PutMapping("/{id}/password") public ApiResponse<Void> resetPassword(@PathVariable Long id, @Valid @RequestBody PasswordResetRequest request) { service.resetPassword(id, request); return ApiResponse.success(null); }
}
package com.xyw.console.auth;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import com.xyw.console.auth.dto.PasswordResetRequest;
import com.xyw.console.auth.dto.SystemUserCreateRequest;
import com.xyw.console.auth.dto.SystemUserResponse;
import com.xyw.console.auth.dto.SystemUserUpdateRequest;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class SystemUserAdminService {
private final SystemUserMapper users; private final PagePermissionService permissions; private final PasswordEncoder encoder;
/** 代码作用(白话):接收账号表、页面权限和密码哈希依赖;关联文件:SystemUserAdminController.java、SystemUserMapper.java。关联逻辑(调用链/数据流):管理接口 -> 本服务 -> 用户表/权限 JSON/BCrypt。 */
public SystemUserAdminService(SystemUserMapper users, PagePermissionService permissions, PasswordEncoder encoder) { this.users = users; this.permissions = permissions; this.encoder = encoder; }
/** 代码作用(白话):查询管理者可见的非开发者账号,任何响应都不返回哈希;关联文件:SystemUserAdminController.java、UserPermissionView.js。关联逻辑(调用链/数据流):账号页面 -> GET users -> as_system_user(role != DEVELOPER) -> 列表。 */
public List<SystemUserResponse> listVisibleUsers() { assertAdministrator(); return users.selectList(new LambdaQueryWrapper<SystemUserEntity>().ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L).orderByDesc(SystemUserEntity::getId)).stream().map(this::responseOf).toList(); }
/** 代码作用(白话):按当前创建者的角色边界新增非开发者账号;关联文件:SystemUserCreateRequest.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):创建表单 -> 创建者校验 -> 密码/禁用规则 -> as_system_user。 */
public SystemUserResponse createUser(SystemUserCreateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); if (users.selectCount(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getUsername, request.username()).eq(SystemUserEntity::getDeleteTime, 0L)) > 0) throw new IllegalArgumentException("用户名已存在"); SystemUserEntity user = new SystemUserEntity(); user.setUsername(request.username()); user.setRoleCode(request.roleCode()); user.setPagePermissions(permissions.validatePermissions(request.pagePermissions())); user.setDeleteTime(0L); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); user.setAuthVersion(1); if ("SUPER_ADMIN".equals(actor)) { user.setStatus("DISABLED"); user.setPasswordHash(null); } else { requireValidPassword(request.password(), request.username()); user.setStatus("ACTIVE"); user.setPasswordHash(encoder.encode(request.password())); } users.insert(user); return responseOf(user); }
/** 代码作用(白话):更新非开发者账号的角色、启停和逐页权限,禁止绕过 Jeddy;关联文件:SystemUserUpdateRequest.java、PagePermissionService.java。关联逻辑(调用链/数据流):编辑面板 -> 角色边界 -> 用户表更新 -> 下次请求按新权限判定。 */
public SystemUserResponse updateUser(Long id, SystemUserUpdateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); SystemUserEntity user = findManageableUser(id); user.setRoleCode(request.roleCode()); user.setStatus(request.status()); if (!("ACTIVE".equals(user.getStatus()) || "DISABLED".equals(user.getStatus()))) throw new IllegalArgumentException("账号状态无效"); if ("ACTIVE".equals(user.getStatus()) && user.getPasswordHash() == null) throw new IllegalArgumentException("请由开发者先设置密码后再启用账号"); user.setPagePermissions(permissions.validatePermissions(request.pagePermissions())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); return responseOf(user); }
/** 代码作用(白话):仅允许开发者为非开发者账号设置密码,并交给数据库触发器记录时间及失效旧会话;关联文件:PasswordResetRequest.java、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):开发者表单 -> BCrypt hash -> UPDATE password_hash -> trigger(auth_version)。 */
public void resetPassword(Long id, PasswordResetRequest request) { if (!"DEVELOPER".equals(currentRole())) throw new AccessDeniedException("只有开发者可以修改密码"); SystemUserEntity user = findManageableUser(id); requireValidPassword(request.password(), user.getUsername()); user.setPasswordHash(encoder.encode(request.password())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); }
/** 代码作用(白话):确认当前会话属于开发者或超级管理员;关联文件:AuthTokenFilter.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):Cookie 身份 -> SecurityContext -> 管理接口准入。 */
private void assertAdministrator() { if (!permissions.isAdministrator(currentRole())) throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):读取过滤器已经核验过的当前角色;关联文件:AuthPrincipal.java、AuthTokenFilter.java。关联逻辑(调用链/数据流):JWT -> SecurityContext principal -> 创建/修改边界。 */
private String currentRole() { Object principal = SecurityContextHolder.getContext().getAuthentication() == null ? null : SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (!(principal instanceof AuthPrincipal user)) throw new AccessDeniedException("没有账号管理权限"); return user.roleCode(); }
/** 代码作用(白话):按角色边界阻止创建开发者以及普通角色创建账号;关联文件:SystemUserCreateRequest.java、SystemUserUpdateRequest.java。关联逻辑(调用链/数据流):当前角色 + 目标角色 -> 允许或 403。 */
private void assertCreatableRole(String actor, String roleCode) { if ("DEVELOPER".equals(roleCode)) throw new AccessDeniedException("开发者账号仅允许固定账号 Jeddy"); if (!("SUPER_ADMIN".equals(roleCode) || "FINANCE".equals(roleCode) || "HR".equals(roleCode) || "OPERATIONS".equals(roleCode))) throw new IllegalArgumentException("角色无效"); if ("DEVELOPER".equals(actor) || "SUPER_ADMIN".equals(actor)) return; throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):按固定查询条件取得可管理账号,避免 Jeddy 通过 URL 或接口细节泄露;关联文件:SystemUserMapper.java、SystemUserResponse.java。关联逻辑(调用链/数据流):账号 id -> 非 DEVELOPER 查询 -> 编辑/重置。 */
private SystemUserEntity findManageableUser(Long id) { SystemUserEntity user = users.selectOne(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getId, id).ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L)); if (user == null) throw new IllegalArgumentException("账号不存在"); return user; }
/** 代码作用(白话):验证密码长度和用户名相似度,避免弱密码直接进入数据库;关联文件:PasswordResetRequest.java、SystemUserCreateRequest.java。关联逻辑(调用链/数据流):密码输入 -> 规则校验 -> BCrypt 哈希。 */
private void requireValidPassword(String password, String username) { if (password == null || password.length() < 12 || password.length() > 72 || password.toLowerCase().contains(username.toLowerCase())) throw new IllegalArgumentException("密码需为 12-72 位且不能包含用户名"); }
/** 代码作用(白话):将实体转为安全响应,并统一展开固定管理员的有效权限;关联文件:SystemUserResponse.java、PagePermissionService.java。关联逻辑(调用链/数据流):实体 -> 权限计算 -> 前端列表。 */
private SystemUserResponse responseOf(SystemUserEntity user) { return new SystemUserResponse(user.getId(), user.getUsername(), user.getRoleCode(), user.getStatus(), permissions.effectivePermissions(user), user.getPasswordUpdatedAt()); }
}
package com.xyw.console.auth.dto;
import java.util.Map;
/** 代码作用(白话):返回前端菜单所需的安全身份,不返回密码或哈希;关联文件:AuthService.java、auth-store.js。关联逻辑(调用链/数据流):用户表 -> 安全响应 -> 菜单/路由。 */
public record CurrentUserResponse(Long id, String username, String roleCode, Map<String, String> pagePermissions) {}
package com.xyw.console.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/** 代码作用(白话):限定登录请求只接收合法账号名和暂存密码;关联文件:AuthController.java。关联逻辑(调用链/数据流):登录表单 -> DTO 校验 -> AuthService。 */
public record LoginRequest(@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{3,64}") String username, @NotBlank String password) {}
package com.xyw.console.auth.dto;
import jakarta.validation.constraints.NotBlank;
/** 代码作用(白话):只接收开发者为他人设置的新密码,密码仅在本次请求中使用;关联文件:SystemUserAdminController.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):重置密码表单 -> DTO -> BCrypt 哈希 -> 数据库触发器失效旧会话。 */
public record PasswordResetRequest(@NotBlank String password) {}
package com.xyw.console.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import java.util.Map;
/** 代码作用(白话):接收新账号的基础信息,不把密码哈希暴露给浏览器;关联文件:SystemUserAdminController.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):账号创建表单 -> DTO 校验 -> 管理服务 -> as_system_user。 */
public record SystemUserCreateRequest(
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{3,64}") String username,
@NotBlank String roleCode,
String password,
Map<String, String> pagePermissions) {}
package com.xyw.console.auth.dto;
import java.time.LocalDateTime;
import java.util.Map;
/** 代码作用(白话):向管理页面返回可展示的账号资料,明确不含 password_hash;关联文件:SystemUserAdminService.java、UserPermissionView.js。关联逻辑(调用链/数据流):用户表实体 -> 安全响应 DTO -> 账号列表/编辑面板。 */
public record SystemUserResponse(Long id, String username, String roleCode, String status, Map<String, String> pagePermissions, LocalDateTime passwordUpdatedAt) {}
package com.xyw.console.auth.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
/** 代码作用(白话):接收非开发者账号的角色、启停和页面权限编辑结果;关联文件:SystemUserAdminController.java、SystemUserAdminService.java。关联逻辑(调用链/数据流):权限面板 -> DTO -> 管理服务 -> 用户表。 */
public record SystemUserUpdateRequest(@NotBlank String roleCode, @NotBlank String status, Map<String, String> pagePermissions) {}
package com.xyw.console.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.xyw.console.auth.AuthTokenFilter; import com.xyw.console.common.ApiResponse;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration @EnableWebSecurity
public class SecurityConfig {
/** 代码作用(白话):配置无状态认证、CSRF 和统一 401/403 响应;关联文件:AuthTokenFilter.java、WebConfig.java。关联逻辑(调用链/数据流):浏览器请求 -> Security Filter Chain -> Cookie 验签 -> Controller。 */
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http, AuthTokenFilter filter, ObjectMapper mapper) throws Exception { return http.httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable).csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).ignoringRequestMatchers("/api/auth/login")).cors(cors -> {}).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.OPTIONS,"/**").permitAll().requestMatchers("/api/auth/login", "/api/auth/csrf").permitAll().anyRequest().authenticated()).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class).exceptionHandling(errors -> errors.authenticationEntryPoint((request,response,error)->write(mapper,response,401,"请先登录")).accessDeniedHandler((request,response,error)->write(mapper,response,403,"没有权限"))).build(); }
/** 代码作用(白话):生成 BCrypt 编码器,确保密码只存不可逆哈希;关联文件:AuthService.java。关联逻辑(调用链/数据流):开发者设密码/登录验证 -> BCrypt。 */
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
/** 代码作用(白话):把安全层错误也保持为现有 code/message/data 格式;关联文件:ApiResponse.java、前端 API 客户端。关联逻辑(调用链/数据流):安全拒绝 -> JSON 响应 -> 页面错误提示。 */
private void write(ObjectMapper mapper, HttpServletResponse response, int status, String message) throws java.io.IOException { response.setStatus(status); response.setContentType("application/json;charset=UTF-8"); mapper.writeValue(response.getWriter(), ApiResponse.error(status,message)); }
}
......@@ -17,6 +17,7 @@ public class WebConfig implements WebMvcConfigurer {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:8000", "http://127.0.0.1:8000", "http://localhost:5173", "http://127.0.0.1:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*");
.allowedHeaders("*")
.allowCredentials(true);
}
}
-- 代码作用(白话):在不写入任何账号密码或 Jeddy 哈希的前提下,为现有账号表补齐页面权限和会话失效字段。
-- 关联文件:SystemUserEntity.java、AuthTokenFilter.java、SystemUserAdminService.java。
-- 关联逻辑(调用链/数据流):数据库字段/触发器 -> 认证过滤器校验 auth_version -> 登录 Cookie 立即失效。
-- 本脚本在部署前的只读核对确认字段不存在后执行一次;MySQL 不支持 ADD COLUMN IF NOT EXISTS。
ALTER TABLE as_system_user
ADD COLUMN page_permissions JSON NULL COMMENT '页面权限映射:NONE/READ/EDIT',
ADD COLUMN password_updated_at DATETIME NULL COMMENT '密码哈希最后更新时间',
ADD COLUMN auth_version INT NOT NULL DEFAULT 1 COMMENT '登录凭证版本号';
CREATE UNIQUE INDEX uk_as_system_user_username ON as_system_user (username);
DROP TRIGGER IF EXISTS trg_as_system_user_password_changed;
DELIMITER $$
CREATE TRIGGER trg_as_system_user_password_changed
BEFORE UPDATE ON as_system_user
FOR EACH ROW
BEGIN
IF NOT (NEW.password_hash <=> OLD.password_hash) THEN
SET NEW.password_updated_at = CURRENT_TIMESTAMP;
SET NEW.auth_version = COALESCE(OLD.auth_version, 1) + 1;
END IF;
END$$
DELIMITER ;
-- 回滚说明:仅在确认没有依赖新字段和唯一索引后,先 DROP TRIGGER,再 DROP INDEX,最后 DROP COLUMN;
-- 绝不可在未备份 as_system_user 或存在新账号数据时直接执行回滚,也不得在此脚本写入 Jeddy 哈希。
package com.xyw.console.asset.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.xyw.console.asset.dto.DeviceAssetPageResponse;
import com.xyw.console.asset.dto.DeviceAssetResponse;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.asset.service.DeviceAssetService;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/** 文件用途注释:验证设备资产 HTTP 接口可正确绑定查询、multipart 表单和删除异常,不依赖真实数据库或文件目录。*/
class DeviceAssetControllerTest {
/** 代码作用(白话):验证列表接口将服务层的设备与人员名称数据按统一 JSON 返回。关联文件:DeviceAssetController.java、DeviceAssetService.java。关联逻辑(调用链/数据流):GET -> Controller.page -> Service.page -> ApiResponse -> 前端表格。*/
@Test void returnsPagedDeviceAssets() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
mockMvc(service).perform(get("/api/device-assets")).andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)).andExpect(jsonPath("$.data.records[0].deviceName").value("测试电脑")).andExpect(jsonPath("$.data.records[0].userPersonName").value("张三"));
}
/** 代码作用(白话):验证新增接口接收 multipart 字段和图片并把保存请求交给服务层。关联文件:DeviceAssetController.java、DeviceAssetSaveRequest.java。关联逻辑(调用链/数据流):表单/图片 -> @ModelAttribute -> Service.create -> 成功 JSON。*/
@Test void createsDeviceAssetThroughMultipartEndpoint() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.create(any())).thenReturn(response());MockMultipartFile image=new MockMultipartFile("imageAttachment1","device.png","image/png",new byte[]{1,2,3});
mockMvc(service).perform(multipart("/api/device-assets").file(image).param("deviceName","测试电脑").param("userUsageStatus","使用中").param("assetRelationStatus","未关联"))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)).andExpect(jsonPath("$.data.id").value(1));verify(service).create(any());
}
/** 代码作用(白话):验证删除被关联资产阻断时,接口返回 400 和可读错误信息。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):DELETE -> Service.softDelete 异常 -> Advice -> 400 JSON。*/
@Test void returnsBadRequestWhenDeleteIsBlocked() throws Exception {
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));
}
/** 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. */
@Test void updatesDeviceAssetThroughMultipartEndpoint() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.update(any(),any())).thenReturn(response());MockMultipartFile image=new MockMultipartFile("imageAttachment2","edited.png","image/png",new byte[]{1,2,3});
mockMvc(service).perform(multipart(HttpMethod.PUT,"/api/device-assets/{id}",1L).file(image).param("deviceName","\u6d4b\u8bd5\u7535\u8111").param("userUsageStatus","\u4f7f\u7528\u4e2d").param("assetRelationStatus","\u672a\u5173\u8054"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.imageAttachment1Url").value("/api/device-assets/files/image.png"));verify(service).update(org.mockito.ArgumentMatchers.eq(1L),any());
}
/** Plain purpose: verify image bytes are served only through the controlled opaque identifier endpoint. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: GET file identifier -> service resource -> response bytes. */
@Test void readsImageThroughControlledEndpoint() throws Exception {
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}));
}
/** 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 {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
mockMvc(service).perform(get("/api/device-assets")).andExpect(status().isOk()).andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist()).andExpect(jsonPath("$.data.records[0].imageAttachment1Url").value("/api/device-assets/files/image.png")).andExpect(jsonPath("$.data.records[0].imageAttachment1").doesNotExist());
}
/** 代码作用(白话):集中创建带设备异常转换器的 MockMvc,保证控制器返回契约可被测试。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):HTTP 模拟请求 -> Controller -> Advice -> JSON 响应。*/
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service)).setControllerAdvice(new DeviceAssetExceptionHandler()).build();}
/** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):服务层响应 -> ApiResponse -> 页面展示。*/
private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,9L,"张三","使用中","未关联",null,null);}
}
......@@ -11,6 +11,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.service.PhoneAssetService;
import com.xyw.console.auth.PagePermissionService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
......@@ -27,7 +28,7 @@ class PhoneAssetControllerTest {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.insert(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper), mock(PagePermissionService.class))).build();
mockMvc.perform(post("/api/phone-assets").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13812345678\",\"realNameOwner\":\"张三\"}"))
......@@ -46,7 +47,7 @@ class PhoneAssetControllerTest {
when(mapper.selectOne(any())).thenReturn(entity);
when(mapper.selectCount(any())).thenReturn(0L);
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper), mock(PagePermissionService.class))).build();
mockMvc.perform(put("/api/phone-assets/1").contentType(MediaType.APPLICATION_JSON)
.content("{\"phoneNumber\":\"13912345678\",\"realNameOwner\":\"李四\"}"))
......@@ -63,7 +64,7 @@ class PhoneAssetControllerTest {
PhoneAssetMapper mapper = mock(PhoneAssetMapper.class);
when(mapper.selectOne(any())).thenReturn(activeEntity());
when(mapper.updateById(any(PhoneAssetEntity.class))).thenReturn(1);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper))).build();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PhoneAssetController(new PhoneAssetService(mapper), mock(PagePermissionService.class))).build();
mockMvc.perform(delete("/api/phone-assets/1"))
.andExpect(status().isOk());
......
......@@ -17,6 +17,7 @@ import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import com.xyw.console.asset.service.WecomAccountService;
import com.xyw.console.auth.PagePermissionService;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
......@@ -66,7 +67,7 @@ class WecomAccountControllerTest {
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device()));
org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper));
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper), org.mockito.Mockito.mock(PagePermissionService.class));
}
/**
......
package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
/** 文件用途(白话):验证设备图片的格式、大小、路径约束和失败清理,不依赖真实上传目录。 */
class DeviceAssetFileStorageServiceTest {
/** 代码作用(白话):验证合法 PNG 会存为不透明标识,且失败清理后不能再次读取。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):图片 -> store -> 标识 -> cleanupNewFile -> 受控读取失败。 */
@Test void storesAndCleansOnlyNewImage(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String identifier=storage.store(new MockMultipartFile("image","one.png","image/png",png()));
assertTrue(Files.exists(directory.resolve(identifier)));storage.cleanupNewFile(identifier);assertThrows(DeviceAssetValidationException.class,()->storage.resolve(identifier));
}
/** 代码作用(白话):验证允许的 JPG、PNG、GIF 都会经过真实图片解码后保存。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):三种 MultipartFile -> validateImage -> store -> 受控文件目录。*/
@Test void storesEverySupportedImageFormat(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
assertTrue(Files.exists(directory.resolve(storage.store(new MockMultipartFile("image","one.jpg","image/jpeg",image("jpg"))))));
assertTrue(Files.exists(directory.resolve(storage.store(new MockMultipartFile("image","two.png","image/png",image("png"))))));
assertTrue(Files.exists(directory.resolve(storage.store(new MockMultipartFile("image","three.gif","image/gif",image("gif"))))));
}
/** 代码作用(白话):验证损坏、超限和不支持类型会在保存前被拒绝。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):multipart 文件 -> validateImage -> 400 业务异常。 */
@Test void rejectsCorruptedOversizedAndUnsupportedImages(@TempDir Path directory) {
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 -> 根目录校验。 */
@Test void rejectsPathTraversalIdentifier(@TempDir Path directory) { DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.resolve("../secret.png")); }
/** 代码作用(白话):在测试中生成 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); } }
/** 代码作用(白话):按指定格式生成可被 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); } }
}
......@@ -2,8 +2,10 @@ package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
......@@ -15,8 +17,16 @@ import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.asset.mapper.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import javax.imageio.ImageIO;
import org.mockito.ArgumentCaptor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
/** 文件用途(白话):以 Mock Mapper 验证设备列表名称解析和关键校验规则,不访问真实数据库。 */
class DeviceAssetServiceTest {
......@@ -32,6 +42,84 @@ class DeviceAssetServiceTest {
DeviceAssetService service=service(mock(AssetDeviceMapper.class),mock(CompanyPersonMapper.class));DeviceAssetSaveRequest request=new DeviceAssetSaveRequest();request.setDeviceName("测试机");request.setUserUsageStatus("未知");request.setAssetRelationStatus("已关联");
assertThrows(DeviceAssetValidationException.class,()->service.create(request));
}
/** 代码作用(白话):验证重名的有效设备不能再次创建,防止列表出现无法区分的资产。关联文件:DeviceAssetService.java、AssetDeviceMapper.java。关联逻辑(调用链/数据流):POST 表单 -> validateSaveRequest -> selectCount -> 400 业务异常。*/
@Test void rejectsDuplicateActiveDeviceName() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class)).create(validRequest("重复设备")));
}
/** 代码作用(白话):验证手机号码资产仍关联设备时,删除请求会被阻断,且不更新设备删除时间。关联文件:DeviceAssetService.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE -> 查询设备 -> 手机号码引用计数 -> 400 业务异常。*/
@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);
assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class),phones,new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images")).softDelete(1L));
}
/** 代码作用(白话):验证软删除只隐藏数据库记录,不物理删除原图,保留审计和后续恢复的可能。关联文件:DeviceAssetService.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):DELETE -> 关联检查 -> updateById(deleteTime);图片文件继续留在受控目录。*/
@Test void softDeleteKeepsExistingImageFile(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String identifier=storage.store(new MockMultipartFile("image","retained.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(2L);device.setImageAttachment1(identifier);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);when(devices.updateById(any(AssetDeviceEntity.class))).thenReturn(1);
service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).softDelete(2L);
assertTrue(Files.exists(directory.resolve(identifier)));
}
/** 代码作用(白话):验证图片先保存但数据库新增失败时,刚上传的新图片会立即清理,避免留下孤儿文件。关联文件:DeviceAssetService.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):POST 图片 -> store -> Mapper.insert 失败 -> cleanupNewFile。*/
@Test void createFailureCleansNewImageFile(@TempDir Path directory) {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);DeviceAssetSaveRequest request=validRequest("保存失败设备");request.setImageAttachment1(new MockMultipartFile("image","failed.png","image/png",png()));
assertThrows(IllegalStateException.class,()->service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),new DeviceAssetFileStorageService(directory.toString())).create(request));
try(var files=Files.list(directory)){assertEquals(0,files.count());}catch(Exception exception){throw new IllegalStateException(exception);}
}
/** 代码作用(白话):构造只含当前测试所需 Mock 的设备服务。关联文件:DeviceAssetService.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):测试 -> Mock Mapper/文件服务 -> Service 规则。 */
/** Plain purpose: prove a failed update removes only the new replacement file and retains the pre-existing original. Related files: DeviceAssetService.java, DeviceAssetFileStorageService.java. Flow: PUT image -> store replacement -> mapper failure -> cleanup replacement; original stays. */
@Test void updateFailureCleansReplacementButRetainsOriginal(@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(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()));
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)));
}
/** 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() {
CompanyPersonMapper people=mock(CompanyPersonMapper.class);when(people.selectCount(any(Wrapper.class))).thenReturn(0L);DeviceAssetSaveRequest request=validRequest("inactive-person");request.setUserPersonId(9L);
assertThrows(DeviceAssetValidationException.class,()->service(mock(AssetDeviceMapper.class),people).create(request));
}
/** Plain purpose: reject deletion when an enterprise-WeChat record references the device. Related files: DeviceAssetService.java, WecomAccountMapper.java. Flow: DELETE -> Wecom reference count -> validation error. */
@Test void blocksDeleteWhenWecomAccountReferencesDevice() { assertReferenceBlocksDelete(ReferenceType.WECOM); }
/** Plain purpose: reject deletion when a WeChat record references the device. Related files: DeviceAssetService.java, WechatAccountMapper.java. Flow: DELETE -> WeChat reference count -> validation error. */
@Test void blocksDeleteWhenWechatAccountReferencesDevice() { assertReferenceBlocksDelete(ReferenceType.WECHAT); }
/** Plain purpose: reject deletion when a Douyin record references the device. Related files: DeviceAssetService.java, DouyinAccountMapper.java. Flow: DELETE -> Douyin reference count -> validation error. */
@Test void blocksDeleteWhenDouyinAccountReferencesDevice() { assertReferenceBlocksDelete(ReferenceType.DOUYIN); }
/** Plain purpose: verify omitted page values become page 1 with twenty rows. Related files: DeviceAssetPageQuery.java, DeviceAssetService.java. Flow: GET without page/size -> resolved defaults -> MyBatis page. */
@Test void usesDefaultPaginationForDevicePage() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);Page<AssetDeviceEntity> result=new Page<>(1,20);result.setRecords(List.of());when(devices.selectPage(any(Page.class),any(Wrapper.class))).thenReturn(result);
service(devices,mock(CompanyPersonMapper.class)).page(new DeviceAssetPageQuery(null,null,null,null,null,null));ArgumentCaptor<Page<AssetDeviceEntity>> page=ArgumentCaptor.forClass(Page.class);verify(devices).selectPage(page.capture(),any(Wrapper.class));assertEquals(1,page.getValue().getCurrent());assertEquals(20,page.getValue().getSize());
}
/** Plain purpose: verify every supported list filter contributes a database predicate. Related files: DeviceAssetPageQuery.java, DeviceAssetService.java. Flow: GET filter values -> LambdaQueryWrapper -> Mapper page query. */
@Test void appliesEverySupportedDevicePageFilter() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);Page<AssetDeviceEntity> result=new Page<>(2,5);result.setRecords(List.of());when(devices.selectPage(any(Page.class),any(Wrapper.class))).thenReturn(result);
service(devices,mock(CompanyPersonMapper.class)).page(new DeviceAssetPageQuery(2,5,"filter-name",9L,"\u4f7f\u7528\u4e2d","\u672a\u5173\u8054"));ArgumentCaptor<Page<AssetDeviceEntity>> page=ArgumentCaptor.forClass(Page.class);ArgumentCaptor<Wrapper<AssetDeviceEntity>> filter=ArgumentCaptor.forClass(Wrapper.class);verify(devices).selectPage(page.capture(),filter.capture());
assertEquals(2,page.getValue().getCurrent());assertEquals(5,page.getValue().getSize());assertTrue(filter.getValue()!=null);
}
/** Plain purpose: verify a successful replacement changes the stored identifier but retains the historical original file. Related files: DeviceAssetService.java, DeviceAssetFileStorageService.java. Flow: PUT replacement -> new store -> mapper update -> new URL; old file remains. */
@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);
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());}
}
/** 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 {
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(6L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);when(devices.updateById(any(AssetDeviceEntity.class))).thenReturn(1);
DeviceAssetSaveRequest request=validRequest("remove-image");request.setRemoveImageAttachment1(true);assertEquals(null,service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(6L,request).imageAttachment1Url());assertTrue(Files.exists(directory.resolve(original)));
}
/** Plain purpose: exercise the three account-reference variants through their actual mapper count boundary. Related files: DeviceAssetService.java, account mappers. Flow: selected mapper count=1 -> softDelete -> validation error. */
private void assertReferenceBlocksDelete(ReferenceType type) {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);PhoneAssetMapper phones=mock(PhoneAssetMapper.class);WecomAccountMapper wecom=mock(WecomAccountMapper.class);WechatAccountMapper wechat=mock(WechatAccountMapper.class);DouyinAccountMapper douyin=mock(DouyinAccountMapper.class);when(devices.selectOne(any(Wrapper.class))).thenReturn(activeDevice(4L));
if(type==ReferenceType.WECOM)when(wecom.selectCount(any(Wrapper.class))).thenReturn(1L);if(type==ReferenceType.WECHAT)when(wechat.selectCount(any(Wrapper.class))).thenReturn(1L);if(type==ReferenceType.DOUYIN)when(douyin.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThrows(DeviceAssetValidationException.class,()->new DeviceAssetService(devices,mock(CompanyPersonMapper.class),phones,wecom,wechat,douyin,new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images")).softDelete(4L));
}
/** Plain purpose: name the account-mapper variant used by the shared reference-protection test. Related files: DeviceAssetService.java, account mappers. Flow: test case -> one mapper count -> delete protection. */
private enum ReferenceType { WECOM, WECHAT, DOUYIN }
private DeviceAssetService service(AssetDeviceMapper devices,CompanyPersonMapper people){return new DeviceAssetService(devices,people,mock(PhoneAssetMapper.class),mock(WecomAccountMapper.class),mock(WechatAccountMapper.class),mock(DouyinAccountMapper.class),new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images"));}
/** 代码作用(白话):构造可替换手机引用和图片目录的设备服务,供删除与失败清理场景使用。关联文件:DeviceAssetService.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):测试用例 -> 特定 Mock Mapper/文件服务 -> Service 业务规则。*/
private DeviceAssetService service(AssetDeviceMapper devices,CompanyPersonMapper people,PhoneAssetMapper phones,DeviceAssetFileStorageService storage){return new DeviceAssetService(devices,people,phones,mock(WecomAccountMapper.class),mock(WechatAccountMapper.class),mock(DouyinAccountMapper.class),storage);}
/** 代码作用(白话):生成符合下拉选项约束的最小新增表单。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):测试输入 -> 服务端状态校验 -> 创建或校验异常。*/
private DeviceAssetSaveRequest validRequest(String name){DeviceAssetSaveRequest request=new DeviceAssetSaveRequest();request.setDeviceName(name);request.setUserUsageStatus("使用中");request.setAssetRelationStatus("未关联");return request;}
/** 代码作用(白话):生成一条仍有效的设备记录,供删除前读取流程使用。关联文件:AssetDeviceEntity.java、DeviceAssetService.java。关联逻辑(调用链/数据流):Mapper.selectOne -> requireActiveDevice -> 删除关联检查。*/
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 -> 文件保存 -> 异常清理。*/
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);}}
}
/** 代码作用(白话):提供前端外壳和中文导航。关联文件:router/index.js、WecomAccountView.js、DeviceAssetView.js。关联逻辑(调用链/数据流):导航点击 -> RouterLink -> RouterView 渲染目标页面。 */
export default { template: `<div class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink to="/overview">总览</RouterLink><RouterLink to="/domain">域名资料</RouterLink><RouterLink to="/reference/wecom">企微资料</RouterLink><RouterLink to="/phone-assets">手机号资产</RouterLink><RouterLink to="/device-assets">设备资产管理</RouterLink><RouterLink to="/alerts">提醒中心</RouterLink></nav></aside><main class="content"><RouterView /></main></div>` };
import { authState, signOut } from './modules/auth/auth-store.js';
/** 代码作用(白话):提供已登录后台外壳,让登录路由不显示侧栏。关联文件:router/index.js、LoginView.js。关联逻辑(调用链/数据流):当前 Hash 路由 -> 登录页或菜单壳 -> RouterView。 */
export default { setup() { /** 代码作用(白话):退出当前会话并无论后端 Cookie 清理是否报错都跳回登录路由。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):退出按钮 -> POST logout -> 清空前端身份 -> finally 跳转 /login。 */ async function logout() { try { await signOut(); } finally { window.location.hash = '#/login'; } } /** 代码作用(白话):判断当前用户能否看见某个受控菜单,编辑权限自然包含只读菜单;关联文件:router/index.js、PagePermissionService.java。关联逻辑(调用链/数据流):/api/auth/me 权限 -> 侧栏菜单 -> 路由守卫。 */ function canVisit(page) { return authState.user?.pagePermissions?.[page] && authState.user.pagePermissions[page] !== 'NONE'; } /** 代码作用(白话):判断当前用户是否可以使用设置下的账号与权限功能;关联文件:UserPermissionView.js、SystemUserAdminService.java。关联逻辑(调用链/数据流):角色码 -> 菜单展示 -> 后端二次授权。 */ function isAdministrator() { return ['DEVELOPER', 'SUPER_ADMIN'].includes(authState.user?.roleCode); } return { authState, logout, canVisit, isAdministrator }; }, template: `<RouterView v-if="$route.path === '/login'" /><div v-else class="app-shell"><aside class="sidebar"><p class="eyebrow">学有为资产</p><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('reference-wecom')" to="/reference/wecom">企微资料</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号资产</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>` };
import { reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { signIn } from './auth-store.js';
/** 代码作用(白话):提供唯一的账号密码登录界面,并只在 Jeddy 成功登录的当次显示专属欢迎提示。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> Auth API -> Cookie/用户状态 -> 路由跳转。 */
export default { setup() { const form = reactive({ username: '', password: '' }); const submitting = ref(false); /** 代码作用(白话):提交登录并清空内存中的密码字段,避免页面长期保留密码。关联文件:auth-store.js、router/index.js。关联逻辑(调用链/数据流):表单 -> signIn -> Jeddy 提示/目标路由。 */ async function submit() { submitting.value = true; try { const user = await signIn(form); form.password = ''; if (user.username === 'Jeddy' && user.roleCode === 'DEVELOPER') ElMessage.success('🎉 欢迎系统开发者-BOSS:Jeddy 上线'); window.location.hash = '#/overview'; } catch (_) { ElMessage.error('账号或密码错误,请重试'); } finally { submitting.value = false; } } return { form, submitting, submit }; }, template: `<main class="login-page"><section class="login-card" aria-labelledby="login-title"><p class="eyebrow">XYW CONSOLE</p><h1 id="login-title">登录资产后台</h1><p>请输入由开发者设置的账号和密码。</p><el-form @submit.prevent="submit"><el-form-item label="用户名"><el-input v-model="form.username" autocomplete="username" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item><el-button type="primary" native-type="submit" :loading="submitting" class="login-submit">登录</el-button></el-form></section></main>` };
let csrfReady = false;
/** 代码作用(白话):读取同站 Cookie 中的 CSRF 值,供会改变数据的请求放入校验头;关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):csrf 接口写 Cookie -> 此方法读取 Cookie -> POST/PUT/DELETE 请求头。 */
function csrfHeader() { const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/); return match ? { 'X-XSRF-TOKEN': decodeURIComponent(match[1]) } : {}; }
/** 代码作用(白话):首次写操作前请求 CSRF Cookie,登录接口已由后端单独豁免。关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):前端写请求 -> /api/auth/csrf -> XSRF-TOKEN Cookie -> 安全层校验。 */
export async function csrfHeadersFor(path, method) { if (method === 'GET' || path === '/api/auth/login' || csrfReady) return csrfHeader(); const response = await fetch('/api/auth/csrf', { credentials: 'include' }); if (!response.ok) throw new Error('安全校验初始化失败'); csrfReady = true; return csrfHeader(); }
/** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */
export async function request(path, options = {}) { const method = (options.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrf, ...(options.headers || {}) }, ...options }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '请求失败'); return payload.data; }
/** 代码作用(白话):提交账号密码以建立 HttpOnly 会话。关联文件:LoginView.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> POST login -> Set-Cookie -> 当前用户。 */
export function login(credentials) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(credentials) }); }
/** 代码作用(白话):恢复浏览器已有会话并获取菜单权限。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):应用启动 -> GET me -> 路由/菜单状态。 */
export function getCurrentUser() { return request('/api/auth/me'); }
/** 代码作用(白话):请求后端清除当前浏览器的登录会话。关联文件:App.js、AuthController.java。关联逻辑(调用链/数据流):登出按钮 -> POST logout -> Cookie 过期 -> 登录页。 */
export function logout() { return request('/api/auth/logout', { method: 'POST' }); }
import { reactive } from 'vue/dist/vue.esm-bundler.js';
import { getCurrentUser, login, logout } from './auth-api-client.js';
/** 代码作用(白话):保存内存中的安全用户资料,页面刷新后只从后端 Cookie 恢复而不写入浏览器存储。关联文件:router/index.js、App.js。关联逻辑(调用链/数据流):Auth API -> authState -> 路由守卫/菜单。 */
export const authState = reactive({ ready: false, user: null });
/** 代码作用(白话):向后端恢复一次当前会话,失败时清除内存身份。关联文件:auth-api-client.js。关联逻辑(调用链/数据流):路由守卫 -> /api/auth/me -> authState。 */
export async function bootstrapAuth() { try { authState.user = await getCurrentUser(); } catch (_) { authState.user = null; } finally { authState.ready = true; } return authState.user; }
/** 代码作用(白话):完成登录并保存安全用户资料供当前页面使用。关联文件:LoginView.js。关联逻辑(调用链/数据流):表单 -> login API -> authState -> 跳转。 */
export async function signIn(credentials) { authState.user = await login(credentials); authState.ready = true; return authState.user; }
/** 代码作用(白话):尝试登出并无论结果都清空本地身份。关联文件:App.js。关联逻辑(调用链/数据流):登出 -> API -> authState 清空 -> 登录路由。 */
export async function signOut() { try { await logout(); } finally { authState.user = null; authState.ready = true; } }
......@@ -3,52 +3,56 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { createDeviceAsset, deleteDeviceAsset, listDeviceAssets, searchDeviceCompanyPersons, updateDeviceAsset } from './device-api-client.js';
import './device-asset.css';
/** 文件用途(白话):渲染设备资产管理列表、表单、图片原图缩略预览和受保护删除操作。 */
/** File purpose: render device asset list, CRUD dialog, image thumbnail/original preview, and guarded deletion. */
export default {
/** 代码作用(白话):创建页面响应式数据与业务动作。关联文件:device-api-client.js、DeviceAssetController.java。关联逻辑(调用链/数据流):路由进入 -> setup -> API 请求/表单状态 -> Element Plus 页面。 */
/** Plain purpose: create page state and all UI actions. Related files: device-api-client.js, DeviceAssetController.java. Flow: route -> setup -> API/form state -> Element Plus view. */
setup() {
const loading=ref(false), saving=ref(false), dialogVisible=ref(false), editingId=ref(null), records=ref([]), total=ref(0), personOptions=ref([]), imageViewerVisible=ref(false), imageViewerUrl=ref('');
const usageStatuses=['\u4f7f\u7528\u4e2d','\u95f2\u7f6e','\u7ef4\u4fee\u4e2d','\u505c\u7528'];
const relationStatuses=['\u5df2\u5173\u8054','\u672a\u5173\u8054','\u5f85\u786e\u8ba4'];
const loading=ref(false),saving=ref(false),dialogVisible=ref(false),editingId=ref(null),records=ref([]),total=ref(0),personOptions=ref([]),imageViewerVisible=ref(false),imageViewerUrl=ref('');
const filters=reactive({page:1,size:20,deviceName:'',userPersonId:null,userUsageStatus:'',assetRelationStatus:''});
const form=reactive({deviceName:'',userPersonId:null,userUsageStatus:'使用中',assetRelationStatus:'待确认',imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:'',imageAttachment2Url:''});
const form=reactive(emptyForm());
let searchTimer=null;
/** 代码作用(白话):读取当前筛选条件下的设备分页数据。关联文件:device-api-client.js、DeviceAssetService.java。关联逻辑(调用链/数据流):页面加载/筛选 -> GET -> records/total -> 表格。 */
/** Plain purpose: fetch the currently filtered device page. Related files: device-api-client.js, DeviceAssetService.java. Flow: page/filter event -> GET -> records/total -> table. */
async function loadPage(){loading.value=true;try{const result=await listDeviceAssets(filters);records.value=result.records;total.value=result.total;}catch(error){ElMessage.error(error.message);}finally{loading.value=false;}}
/** 代码作用(白话):恢复新增设备的默认表单。关联文件:DeviceAssetSaveRequest.java、DeviceAssetView.js。关联逻辑(调用链/数据流):新增/保存完成 -> resetForm -> 空表单。 */
function resetForm(){clearPreview('imageAttachment1');clearPreview('imageAttachment2');Object.assign(form,{deviceName:'',userPersonId:null,userUsageStatus:'使用中',assetRelationStatus:'待确认',imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:'',imageAttachment2Url:''});personOptions.value=[];}
/** 代码作用(白话):打开空白新增弹窗。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):新增按钮 -> resetForm -> dialogVisible=true。 */
/** Plain purpose: return the create-form defaults that obey both dropdown rules. Related files: DeviceAssetSaveRequest.java, DeviceAssetView.js. Flow: create/reset -> defaults -> multipart POST. */
function emptyForm(){return {deviceName:'',userPersonId:null,userUsageStatus:'\u4f7f\u7528\u4e2d',assetRelationStatus:'\u5f85\u786e\u8ba4',imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:'',imageAttachment2Url:''};}
/** Plain purpose: clear temporary preview URLs and restore a new-device form. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: open create/save -> reset -> safe empty dialog. */
function resetForm(){clearPreview('imageAttachment1');clearPreview('imageAttachment2');Object.assign(form,emptyForm());personOptions.value=[];}
/** Plain purpose: show a fresh create dialog. Related files: DeviceAssetView.js, device-api-client.js. Flow: add button -> resetForm -> dialog. */
function openCreate(){editingId.value=null;resetForm();dialogVisible.value=true;}
/** 代码作用(白话):把列表设备回填到编辑弹窗。关联文件:DeviceAssetResponse.java、DeviceAssetView.js。关联逻辑(调用链/数据流):编辑按钮 -> row -> form -> PUT 保存。 */
function openEdit(row){editingId.value=row.id;clearPreview('imageAttachment1');clearPreview('imageAttachment2');Object.assign(form,{deviceName:row.deviceName,userPersonId:row.userPersonId,userUsageStatus:row.userUsageStatus,assetRelationStatus:row.assetRelationStatus,imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:row.imageAttachment1Url||'',imageAttachment2Url:row.imageAttachment2Url||''});personOptions.value=row.userPersonId?[{id:row.userPersonId,personName:row.userPersonName||`人员 ${row.userPersonId}`}]:[];dialogVisible.value=true;}
/** 代码作用(白话):把表单转换成 multipart 数据并提交。关联文件:device-api-client.js、DeviceAssetController.java。关联逻辑(调用链/数据流):保存按钮 -> FormData -> POST/PUT -> 成功刷新表格。 */
async function submitForm(){if(!form.deviceName.trim()){ElMessage.error('请输入设备名称');return;}saving.value=true;try{const payload=new FormData();['deviceName','userPersonId','userUsageStatus','assetRelationStatus','removeImageAttachment1','removeImageAttachment2'].forEach(key=>{const value=form[key];if(value!==null&&value!==undefined&&value!=='')payload.append(key,value);});if(form.imageAttachment1)payload.append('imageAttachment1',form.imageAttachment1);if(form.imageAttachment2)payload.append('imageAttachment2',form.imageAttachment2);if(editingId.value===null)await createDeviceAsset(payload);else await updateDeviceAsset(editingId.value,payload);ElMessage.success(editingId.value===null?'新增成功':'编辑成功');dialogVisible.value=false;filters.page=1;await loadPage();}catch(error){ElMessage.error(error.message);}finally{saving.value=false;}}
/** 代码作用(白话):确认后请求软删除,并显示后端返回的关联保护原因。关联文件:device-api-client.js、DeviceAssetService.java。关联逻辑(调用链/数据流):删除按钮 -> 确认框 -> DELETE -> 列表刷新或错误提示。 */
async function confirmDelete(row){try{await ElMessageBox.confirm(`确认删除设备“${row.deviceName}”吗?`,'删除确认',{type:'warning'});await deleteDeviceAsset(row.id);ElMessage.success('删除成功');if(records.value.length===1&&filters.page>1)filters.page-=1;await loadPage();}catch(error){if(error!=='cancel'&&error!=='close')ElMessage.error(error.message);}}
/** 代码作用(白话):根据关键字远程加载可选择的公司人员。关联文件:device-api-client.js、DeviceAssetController.java。关联逻辑(调用链/数据流):人员选择器输入 -> lookup API -> personOptions -> 下拉项。 */
/** Plain purpose: populate the dialog from one table row for editing. Related files: DeviceAssetResponse.java, DeviceAssetView.js. Flow: edit button -> row -> form -> PUT. */
function openEdit(row){editingId.value=row.id;clearPreview('imageAttachment1');clearPreview('imageAttachment2');Object.assign(form,{...emptyForm(),deviceName:row.deviceName,userPersonId:row.userPersonId,userUsageStatus:row.userUsageStatus,assetRelationStatus:row.assetRelationStatus,imageAttachment1Url:row.imageAttachment1Url||'',imageAttachment2Url:row.imageAttachment2Url||''});personOptions.value=row.userPersonId?[{id:row.userPersonId,personName:row.userPersonName||`${'\u4eba\u5458'} ${row.userPersonId}`}]:[];dialogVisible.value=true;}
/** Plain purpose: serialize form values and optional files to a multipart create/update request. Related files: device-api-client.js, DeviceAssetController.java. Flow: save -> FormData -> POST/PUT -> refresh table. */
async function submitForm(){if(!form.deviceName.trim()){ElMessage.error('\u8bf7\u8f93\u5165\u8bbe\u5907\u540d\u79f0');return;}saving.value=true;try{const payload=new FormData();['deviceName','userPersonId','userUsageStatus','assetRelationStatus','removeImageAttachment1','removeImageAttachment2'].forEach(key=>{const value=form[key];if(value!==null&&value!==undefined&&value!=='')payload.append(key,value);});if(form.imageAttachment1)payload.append('imageAttachment1',form.imageAttachment1);if(form.imageAttachment2)payload.append('imageAttachment2',form.imageAttachment2);if(editingId.value===null)await createDeviceAsset(payload);else await updateDeviceAsset(editingId.value,payload);ElMessage.success(editingId.value===null?'\u65b0\u589e\u6210\u529f':'\u7f16\u8f91\u6210\u529f');dialogVisible.value=false;filters.page=1;await loadPage();}catch(error){ElMessage.error(error.message);}finally{saving.value=false;}}
/** Plain purpose: ask for confirmation then request a reference-protected soft delete. Related files: device-api-client.js, DeviceAssetService.java. Flow: delete click -> confirm -> DELETE -> refresh or show error. */
async function confirmDelete(row){try{await ElMessageBox.confirm(`\u786e\u8ba4\u5220\u9664\u8bbe\u5907\u201c${row.deviceName}\u201d\u5417\uff1f`,'\u5220\u9664\u786e\u8ba4',{type:'warning'});await deleteDeviceAsset(row.id);ElMessage.success('\u5220\u9664\u6210\u529f');if(records.value.length===1&&filters.page>1)filters.page-=1;await loadPage();}catch(error){if(error!=='cancel'&&error!=='close')ElMessage.error(error.message);}}
/** Plain purpose: load active company people for the remote selector. Related files: device-api-client.js, DeviceAssetController.java. Flow: selector input -> lookup API -> dropdown options. */
async function fetchPersonSuggestions(keyword){try{personOptions.value=await searchDeviceCompanyPersons(keyword);}catch(error){ElMessage.error(error.message);}}
/** 代码作用(白话):在浏览器端先限制图片类型和单张 20MB,减少无效上传。关联文件:DeviceAssetFileStorageService.java、DeviceAssetView.js。关联逻辑(调用链/数据流):选择文件 -> validateImageBeforeSelect -> FormData 或错误提示。 */
function validateImageBeforeSelect(file){const raw=file.raw||file;const allowed=['image/jpeg','image/png','image/gif'];if(!allowed.includes(raw.type)){ElMessage.error('仅支持 JPG、PNG、GIF 图片');return false;}if(raw.size>20*1024*1024){ElMessage.error('每张图片不能超过 20MB');return false;}return true;}
/** 代码作用(白话):接收某个图片槽选择的新原图并生成浏览器预览地址。关联文件:DeviceAssetFileStorageService.java、DeviceAssetView.js。关联逻辑(调用链/数据流):上传组件 -> chooseImage -> form File/URL -> 缩略预览与保存。 */
/** Plain purpose: reject unsupported or over-20MB files before uploading. Related files: DeviceAssetFileStorageService.java, DeviceAssetView.js. Flow: file choose -> local validation -> FormData or message. */
function validateImageBeforeSelect(file){const raw=file.raw||file;if(!['image/jpeg','image/png','image/gif'].includes(raw.type)){ElMessage.error('\u4ec5\u652f\u6301 JPG\u3001PNG\u3001GIF \u56fe\u7247');return false;}if(raw.size>20*1024*1024){ElMessage.error('\u6bcf\u5f20\u56fe\u7247\u4e0d\u80fd\u8d85\u8fc7 20MB');return false;}return true;}
/** Plain purpose: keep the selected original file and use a browser object URL for a scaled thumbnail preview. Related files: DeviceAssetFileStorageService.java, DeviceAssetView.js. Flow: upload component -> file/object URL -> preview and save. */
function chooseImage(slot,file){if(!validateImageBeforeSelect(file))return false;clearPreview(slot);form[slot]=file.raw||file;form[`${slot}Url`]=URL.createObjectURL(form[slot]);form[`remove${slot.charAt(0).toUpperCase()+slot.slice(1)}`]=false;return false;}
/** 代码作用(白话):清理浏览器本地创建的预览 URL,避免重复编辑占用内存。关联文件:DeviceAssetView.js。关联逻辑(调用链/数据流):替换/关闭弹窗 -> clearPreview -> revokeObjectURL。 */
/** Plain purpose: release temporary object URLs so repeated edits do not retain browser memory. Related files: DeviceAssetView.js. Flow: replace/reset/remove -> clearPreview -> revokeObjectURL. */
function clearPreview(slot){const url=form[`${slot}Url`];if(url&&url.startsWith('blob:'))URL.revokeObjectURL(url);}
/** 代码作用(白话):标记一个图片槽在保存时清空,同时隐藏其预览。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):移除按钮 -> removeImage -> multipart flag -> 数据库附件字段置空。 */
/** Plain purpose: mark an existing image reference for removal while preserving the historical original file on the server. Related files: DeviceAssetSaveRequest.java, DeviceAssetService.java. Flow: remove click -> multipart flag -> DB reference cleared. */
function removeImage(slot){clearPreview(slot);form[slot]=null;form[`${slot}Url`]='';form[`remove${slot.charAt(0).toUpperCase()+slot.slice(1)}`]=true;}
/** 代码作用(白话):打开原图查看器而非生成另一份缩略图文件。关联文件:DeviceAssetResponse.java、DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):缩略图点击 -> previewImage -> 受控图片 URL -> 查看器。 */
function previewImage(url){if(!url)return;imageViewerUrl.value=url;imageViewerVisible.value=true;}
/** 代码作用(白话):延迟执行文本筛选,避免用户每输入一个字都请求接口。关联文件:device-api-client.js、DeviceAssetView.js。关联逻辑(调用链/数据流):输入事件 -> 定时器 -> loadPage -> 表格。 */
/** Plain purpose: open the original image in the viewer instead of generating a second thumbnail file. Related files: DeviceAssetResponse.java, DeviceAssetFileStorageService.java. Flow: thumbnail click -> controlled URL -> viewer. */
function previewImage(url){if(url){imageViewerUrl.value=url;imageViewerVisible.value=true;}}
/** Plain purpose: delay text filtering to avoid a request for every keystroke. Related files: DeviceAssetView.js, device-api-client.js. Flow: text input -> timer -> page reload. */
function scheduleSearch(){window.clearTimeout(searchTimer);searchTimer=window.setTimeout(()=>{filters.page=1;loadPage();},300);}
/** 代码作用(白话):立即按当前筛选条件查询第一页。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):筛选提交 -> page=1 -> GET -> 表格。 */
/** Plain purpose: apply the selected filters immediately from the first page. Related files: DeviceAssetPageQuery.java, DeviceAssetView.js. Flow: filter change -> page=1 -> GET -> table. */
function submitSearch(){window.clearTimeout(searchTimer);filters.page=1;loadPage();}
/** 代码作用(白话):清空所有筛选条件并重新加载第一页。关联文件:DeviceAssetView.js、DeviceAssetPageQuery.java。关联逻辑(调用链/数据流):重置按钮 -> 默认 filters -> GET -> 表格。 */
/** Plain purpose: remove every filter and reload the first page. Related files: DeviceAssetPageQuery.java, DeviceAssetView.js. Flow: reset -> default filters -> GET -> table. */
function resetSearch(){window.clearTimeout(searchTimer);Object.assign(filters,{page:1,size:20,deviceName:'',userPersonId:null,userUsageStatus:'',assetRelationStatus:''});loadPage();}
/** 代码作用(白话):切换页码并读取对应设备数据。关联文件:DeviceAssetView.js、DeviceAssetPageQuery.java。关联逻辑(调用链/数据流):分页器 -> page -> GET -> 表格。 */
/** Plain purpose: switch to a requested page. Related files: DeviceAssetPageQuery.java, DeviceAssetView.js. Flow: pagination -> page -> GET. */
function changePage(page){filters.page=page;loadPage();}
/** 代码作用(白话):切换每页条数后回到第一页。关联文件:DeviceAssetView.js、DeviceAssetPageQuery.java。关联逻辑(调用链/数据流):分页器 -> size/page -> GET -> 表格。 */
/** Plain purpose: apply a new page size and return to page one. Related files: DeviceAssetPageQuery.java, DeviceAssetView.js. Flow: pagination -> size/page -> GET. */
function changePageSize(size){filters.size=size;filters.page=1;loadPage();}
/** 代码作用(白话):页面首次显示时加载设备列表。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):组件挂载 -> loadPage -> 表格初始数据。 */
/** Plain purpose: load the initial page when the routed component appears. Related files: DeviceAssetView.js, device-api-client.js. Flow: mount -> loadPage -> table. */
onMounted(loadPage);
return {loading,saving,dialogVisible,editingId,records,total,filters,form,personOptions,imageViewerVisible,imageViewerUrl,loadPage,openCreate,openEdit,submitForm,confirmDelete,fetchPersonSuggestions,chooseImage,removeImage,previewImage,scheduleSearch,submitSearch,resetSearch,changePage,changePageSize};
return {usageStatuses,relationStatuses,loading,saving,dialogVisible,editingId,records,total,filters,form,personOptions,imageViewerVisible,imageViewerUrl,loadPage,openCreate,openEdit,submitForm,confirmDelete,fetchPersonSuggestions,chooseImage,removeImage,previewImage,scheduleSearch,submitSearch,resetSearch,changePage,changePageSize};
},
template:`<section class="device-asset-page"><header class="device-asset-page__header"><div><h2>设备资产管理</h2><p>DEVICE ASSETS</p></div><el-button type="primary" @click="openCreate">新增设备</el-button></header><section class="device-asset-page__panel"><el-form class="device-asset-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.deviceName" placeholder="设备名称" @input="scheduleSearch"/><el-select v-model="filters.userPersonId" filterable remote clearable placeholder="使用人" :remote-method="fetchPersonSuggestions" @change="submitSearch"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select><el-select v-model="filters.userUsageStatus" clearable placeholder="使用状态" @change="submitSearch"><el-option label="使用中" value="使用中"/><el-option label="闲置" value="闲置"/><el-option label="维修中" value="维修中"/><el-option label="停用" value="停用"/></el-select><el-select v-model="filters.assetRelationStatus" clearable placeholder="资产关联状态" @change="submitSearch"><el-option label="已关联" value="已关联"/><el-option label="未关联" value="未关联"/><el-option label="待确认" value="待确认"/></el-select><el-button @click="resetSearch">重置</el-button></el-form></section><section class="device-asset-page__panel"><el-table :data="records" v-loading="loading" empty-text="暂无匹配数据"><el-table-column prop="id" label="ID" width="90"/><el-table-column prop="deviceName" label="设备名称" min-width="180"/><el-table-column label="图片" width="90"><template #default="{row}"><span v-if="!row.imageAttachment1Url">—</span><span v-else class="device-asset-page__image-cell"><img :src="row.imageAttachment1Url" @click="previewImage(row.imageAttachment1Url)"/></span></template></el-table-column><el-table-column label="使用人" min-width="160"><template #default="{row}">{{row.userPersonName?row.userPersonName+'('+row.userPersonId+')':(row.userPersonId?'—('+row.userPersonId+')':'—')}}</template></el-table-column><el-table-column prop="userUsageStatus" label="使用状态" min-width="120"/><el-table-column prop="assetRelationStatus" label="资产关联状态" min-width="130"/><el-table-column prop="updateTime" label="更新时间" min-width="180"/><el-table-column label="操作" width="150"><template #default="{row}"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></template></el-table-column></el-table><footer v-if="total" class="device-asset-page__pagination"><span>共 {{total}} 条</span><el-pagination layout="sizes, prev, pager, next" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize"/></footer></section><el-dialog v-model="dialogVisible" :title="editingId===null?'新增设备':'编辑设备'" width="640px"><el-form label-position="top" @submit.prevent="submitForm"><el-row :gutter="16"><el-col :span="12"><el-form-item label="设备名称" required><el-input v-model="form.deviceName"/></el-form-item></el-col><el-col :span="12"><el-form-item label="使用人"><el-select v-model="form.userPersonId" filterable remote clearable :remote-method="fetchPersonSuggestions" placeholder="输入人员姓名" style="width:100%"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="使用状态" required><el-select v-model="form.userUsageStatus" style="width:100%"><el-option label="使用中" value="使用中"/><el-option label="闲置" value="闲置"/><el-option label="维修中" value="维修中"/><el-option label="停用" value="停用"/></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="资产关联状态" required><el-select v-model="form.assetRelationStatus" style="width:100%"><el-option label="已关联" value="已关联"/><el-option label="未关联" value="未关联"/><el-option label="待确认" value="待确认"/></el-select></el-form-item></el-col></el-row><div class="device-asset-page__images"><div v-for="slot in ['imageAttachment1','imageAttachment2']" :key="slot" class="device-asset-page__image-slot"><strong>{{slot==='imageAttachment1'?'图片附件 1':'图片附件 2'}}</strong><div class="device-asset-page__preview"><img v-if="form[slot+'Url']" :src="form[slot+'Url']" @click="previewImage(form[slot+'Url'])"/><span v-else>暂无图片</span></div><el-upload :auto-upload="false" :show-file-list="false" :on-change="file=>chooseImage(slot,file)"><el-button size="small">选择图片</el-button></el-upload><el-button v-if="form[slot+'Url']" size="small" link type="danger" @click="removeImage(slot)">移除</el-button></div></div></el-form><template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="submitForm">保存</el-button></template></el-dialog><el-image-viewer v-if="imageViewerVisible" :url-list="[imageViewerUrl]" @close="imageViewerVisible=false"/></section>`
template:`<section class="device-asset-page"><header class="device-asset-page__header"><div><h2>&#x8bbe;&#x5907;&#x8d44;&#x4ea7;&#x7ba1;&#x7406;</h2><p>DEVICE ASSETS</p></div><el-button type="primary" @click="openCreate">&#x65b0;&#x589e;&#x8bbe;&#x5907;</el-button></header><section class="device-asset-page__panel"><el-form class="device-asset-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.deviceName" placeholder="&#x8bbe;&#x5907;&#x540d;&#x79f0;" @input="scheduleSearch"/><el-select v-model="filters.userPersonId" filterable remote clearable placeholder="&#x4f7f;&#x7528;&#x4eba;" :remote-method="fetchPersonSuggestions" @change="submitSearch"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select><el-select v-model="filters.userUsageStatus" clearable placeholder="&#x4f7f;&#x7528;&#x72b6;&#x6001;" @change="submitSearch"><el-option v-for="item in usageStatuses" :key="item" :label="item" :value="item"/></el-select><el-select v-model="filters.assetRelationStatus" clearable placeholder="&#x8d44;&#x4ea7;&#x5173;&#x8054;&#x72b6;&#x6001;" @change="submitSearch"><el-option v-for="item in relationStatuses" :key="item" :label="item" :value="item"/></el-select><el-button @click="resetSearch">&#x91cd;&#x7f6e;</el-button></el-form></section><section class="device-asset-page__panel"><el-table :data="records" v-loading="loading" empty-text="&#x6682;&#x65e0;&#x5339;&#x914d;&#x6570;&#x636e;"><el-table-column prop="id" label="ID" width="90"/><el-table-column prop="deviceName" label="&#x8bbe;&#x5907;&#x540d;&#x79f0;" min-width="180"/><el-table-column label="&#x56fe;&#x7247;" width="90"><template #default="{row}"><span v-if="!row.imageAttachment1Url">-</span><span v-else class="device-asset-page__image-cell"><img :src="row.imageAttachment1Url" @click="previewImage(row.imageAttachment1Url)"/></span></template></el-table-column><el-table-column label="&#x4f7f;&#x7528;&#x4eba;" min-width="160"><template #default="{row}">{{row.userPersonName?row.userPersonName+'('+row.userPersonId+')':(row.userPersonId?'--('+row.userPersonId+')':'-')}}</template></el-table-column><el-table-column prop="userUsageStatus" label="&#x4f7f;&#x7528;&#x72b6;&#x6001;" min-width="120"/><el-table-column prop="assetRelationStatus" label="&#x8d44;&#x4ea7;&#x5173;&#x8054;&#x72b6;&#x6001;" min-width="130"/><el-table-column prop="updateTime" label="&#x66f4;&#x65b0;&#x65f6;&#x95f4;" min-width="180"/><el-table-column label="&#x64cd;&#x4f5c;" width="150"><template #default="{row}"><el-button link @click="openEdit(row)">&#x7f16;&#x8f91;</el-button><el-button link type="danger" @click="confirmDelete(row)">&#x5220;&#x9664;</el-button></template></el-table-column></el-table><footer v-if="total" class="device-asset-page__pagination"><span>&#x5171; {{total}} &#x6761;</span><el-pagination layout="sizes, prev, pager, next" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize"/></footer></section><el-dialog v-model="dialogVisible" :title="editingId===null?'\u65b0\u589e\u8bbe\u5907':'\u7f16\u8f91\u8bbe\u5907'" width="640px"><el-form label-position="top" @submit.prevent="submitForm"><el-row :gutter="16"><el-col :span="12"><el-form-item label="&#x8bbe;&#x5907;&#x540d;&#x79f0;" required><el-input v-model="form.deviceName"/></el-form-item></el-col><el-col :span="12"><el-form-item label="&#x4f7f;&#x7528;&#x4eba;"><el-select v-model="form.userPersonId" filterable remote clearable :remote-method="fetchPersonSuggestions" placeholder="&#x8f93;&#x5165;&#x4eba;&#x5458;&#x59d3;&#x540d;" style="width:100%"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="&#x4f7f;&#x7528;&#x72b6;&#x6001;" required><el-select v-model="form.userUsageStatus" style="width:100%"><el-option v-for="item in usageStatuses" :key="item" :label="item" :value="item"/></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="&#x8d44;&#x4ea7;&#x5173;&#x8054;&#x72b6;&#x6001;" required><el-select v-model="form.assetRelationStatus" style="width:100%"><el-option v-for="item in relationStatuses" :key="item" :label="item" :value="item"/></el-select></el-form-item></el-col></el-row><div class="device-asset-page__images"><div v-for="slot in ['imageAttachment1','imageAttachment2']" :key="slot" class="device-asset-page__image-slot"><strong>{{slot==='imageAttachment1'?'\u56fe\u7247\u9644\u4ef6 1':'\u56fe\u7247\u9644\u4ef6 2'}}</strong><div class="device-asset-page__preview"><img v-if="form[slot+'Url']" :src="form[slot+'Url']" @click="previewImage(form[slot+'Url'])"/><span v-else>&#x6682;&#x65e0;&#x56fe;&#x7247;</span></div><el-upload :auto-upload="false" :show-file-list="false" :on-change="file=>chooseImage(slot,file)"><el-button size="small">&#x9009;&#x62e9;&#x56fe;&#x7247;</el-button></el-upload><el-button v-if="form[slot+'Url']" size="small" link type="danger" @click="removeImage(slot)">&#x79fb;&#x9664;</el-button></div></div></el-form><template #footer><el-button @click="dialogVisible=false">&#x53d6;&#x6d88;</el-button><el-button type="primary" :loading="saving" @click="submitForm">&#x4fdd;&#x5b58;</el-button></template></el-dialog><el-image-viewer v-if="imageViewerVisible" :url-list="[imageViewerUrl]" @close="imageViewerVisible=false"/></section>`
};
import { nextTick, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { computed, nextTick, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage, ElMessageBox } from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn.mjs';
import { authState } from '../auth/auth-store.js';
import { createPhoneAsset, deletePhoneAsset, listPhoneAssets, updatePhoneAsset } from './phone-api-client.js';
/**
......@@ -22,6 +23,8 @@ export default {
const total = ref(0);
const dialogVisible = ref(false);
const editingId = ref(null);
/** 代码作用(白话):把当前手机号资产权限转换为是否展示写操作;关联文件:auth-store.js、PhoneAssetController.java。关联逻辑(调用链/数据流):/api/auth/me -> EDIT 判断 -> 新增/编辑/删除控件 -> 后端二次校验。 */
const canEdit = computed(() => authState.user?.pagePermissions?.['phone-assets'] === 'EDIT');
const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: 'ALL' });
let searchTimer = null;
let latestRequest = 0;
......@@ -257,6 +260,7 @@ export default {
changePage,
changePageSize,
changeStatus,
canEdit,
confirmDelete,
dialogVisible,
editingId,
......@@ -285,9 +289,9 @@ export default {
},
template: `
<el-config-provider :locale="elementLocale"><section class="phone-asset-page phone-asset-list-page">
<header class="phone-asset-list-page__header"><h2>手机号资产</h2><el-button class="phone-asset-list-page__add" type="primary" @click="openCreate">新增手机号资产</el-button></header>
<header class="phone-asset-list-page__header"><h2>手机号资产</h2><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" aria-label="筛选手机号资产"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.phoneNumber" maxlength="11" inputmode="numeric" placeholder="手机号前3位、后4位或完整号码" @input="limitSearchPhone" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.iccid" maxlength="20" placeholder="请输入 ICCID" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.realNameOwner" placeholder="请输入实名人" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-select v-model="filters.disposalStatus" placeholder="使用状态:" clearable @change="changeStatus" @clear="restoreAllDisposalStatuses"><template #prefix>使用状态:</template><el-option label="全部" value="ALL" /><el-option label="正常" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="注销" value="已注销" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="phoneNumber" label="手机号" min-width="150" show-overflow-tooltip /><el-table-column label="号码类型" min-width="120"><template #default="{ row }"><a v-if="row.numberType === 'EXTERNAL' && row.sourceAssetType === 'WECOM'" class="phone-asset-list-page__external-link" :href="'#/reference/wecom?phoneAssetId=' + row.id">外部号码</a><span v-else>{{ row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码' }}</span></template></el-table-column><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="220" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><el-table-column prop="managementType" label="管理模式" min-width="120" show-overflow-tooltip /><el-table-column label="使用状态" min-width="130"><template #default="{ row }"><span class="phone-asset-list-page__status"><i :class="['phone-asset-list-page__status-dot', row.disposalStatus]"></i>{{ formatDisposalStatus(row.disposalStatus) }}</span></template></el-table-column><el-table-column prop="deviceId" label="关联设备(ID)" min-width="150" show-overflow-tooltip /><el-table-column label="操作" width="120"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="sizes, prev, pager, next, jumper" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize" /></footer></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><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column prop="phoneNumber" label="手机号" min-width="150" show-overflow-tooltip /><el-table-column label="号码类型" min-width="120"><template #default="{ row }"><a v-if="row.numberType === 'EXTERNAL' && row.sourceAssetType === 'WECOM'" class="phone-asset-list-page__external-link" :href="'#/reference/wecom?phoneAssetId=' + row.id">外部号码</a><span v-else>{{ row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码' }}</span></template></el-table-column><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column prop="iccid" label="ICCID" min-width="220" show-overflow-tooltip /><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><el-table-column prop="managementType" label="管理模式" min-width="120" show-overflow-tooltip /><el-table-column label="使用状态" min-width="130"><template #default="{ row }"><span class="phone-asset-list-page__status"><i :class="['phone-asset-list-page__status-dot', row.disposalStatus]"></i>{{ formatDisposalStatus(row.disposalStatus) }}</span></template></el-table-column><el-table-column prop="deviceId" label="关联设备(ID)" min-width="150" show-overflow-tooltip /><el-table-column v-if="canEdit" label="操作" width="120"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></template></el-table-column></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="sizes, prev, pager, next, jumper" :current-page="filters.page" :page-size="filters.size" :page-sizes="[5,10,20,50]" :total="total" @current-change="changePage" @size-change="changePageSize" /></footer></section>
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll">
<el-form class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required>
......@@ -315,4 +319,4 @@ export default {
</el-dialog>
</section></el-config-provider>
`
};
\ No newline at end of file
};
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/**
* 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字。
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误。
*/
async function request(path, options = {}) {
const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
const headers = await csrfHeadersFor(path, (options.method || 'GET').toUpperCase());
const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers }, ...options });
const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败');
return payload.data;
......
import { computed, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createSystemUser, listSystemUsers, resetSystemUserPassword, updateSystemUser } from './system-user-api-client.js';
const pages = [{ key: 'overview', label: '总览' }, { key: 'domain', 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: '运营' }];
/** 代码作用(白话):创建五页均无权限的编辑表单初始值;关联文件:PagePermissionService.java、UserPermissionView.js。关联逻辑(调用链/数据流):新增/编辑打开 -> 本函数 -> 表单权限单选 -> JSON 提交。 */
function blankForm() { return { username: '', roleCode: 'FINANCE', status: 'ACTIVE', password: '', pagePermissions: Object.fromEntries(pages.map(page => [page.key, 'NONE'])) }; }
/** 代码作用(白话):提供账号创建、角色编辑、密码重置和逐页权限面板;关联文件:system-user-api-client.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):设置路由 -> 页面操作 -> 管理 API -> 刷新列表。 */
export default { setup() { const users = ref([]); const loading = ref(false); const dialogOpen = ref(false); const passwordOpen = ref(false); const editingId = ref(null); const target = ref(null); const form = reactive(blankForm()); const password = ref(''); const isDeveloper = computed(() => authState.user?.roleCode === 'DEVELOPER'); const isAdministratorRole = computed(() => form.roleCode === 'SUPER_ADMIN');
/** 代码作用(白话):读取当前可管理账号列表,后端已过滤固定开发者 Jeddy;关联文件:system-user-api-client.js、SystemUserAdminService.java。关联逻辑(调用链/数据流):页面进入/保存后 -> GET users -> 表格。 */
async function load() { loading.value = true; try { users.value = await listSystemUsers(); } catch (error) { ElMessage.error(error.message || '账号列表加载失败'); } finally { loading.value = false; } }
/** 代码作用(白话):打开新增账号面板,超级管理员不会看到密码输入;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):新增按钮 -> 空表单 -> 创建提交。 */
function openCreate() { Object.assign(form, blankForm()); editingId.value = null; dialogOpen.value = true; }
/** 代码作用(白话):把选中账号复制到编辑面板,避免直接改动表格对象;关联文件:SystemUserResponse.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):编辑按钮 -> 本地副本 -> PUT 更新。 */
function openEdit(user) { Object.assign(form, blankForm(), { username: user.username, roleCode: user.roleCode, status: user.status, pagePermissions: { ...user.pagePermissions } }); editingId.value = user.id; dialogOpen.value = true; }
/** 代码作用(白话):创建或更新账号,并把超级管理员创建的账号交给后端固定为禁用无密码;关联文件:SystemUserAdminService.java、system-user-api-client.js。关联逻辑(调用链/数据流):表单 -> POST/PUT -> 用户表 -> 列表刷新。 */
async function save() { try { if (editingId.value) await updateSystemUser(editingId.value, { roleCode: form.roleCode, status: form.status, pagePermissions: form.pagePermissions }); else await createSystemUser({ username: form.username, roleCode: form.roleCode, password: isDeveloper.value ? form.password : null, pagePermissions: form.pagePermissions }); ElMessage.success(editingId.value ? '账号已更新' : '账号已创建'); dialogOpen.value = false; await load(); } catch (error) { ElMessage.error(error.message || '保存失败'); } }
/** 代码作用(白话):打开仅开发者可见的密码设置面板;关联文件:SystemUserAdminController.java、PasswordResetRequest.java。关联逻辑(调用链/数据流):开发者点击设置密码 -> 输入暂存 -> 安全接口。 */
function openPassword(user) { target.value = user; password.value = ''; passwordOpen.value = true; }
/** 代码作用(白话):把新密码提交给后端并立即从页面内存清除;关联文件:system-user-api-client.js、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):新密码 -> BCrypt -> 数据库触发器 -> 旧会话失效。 */
async function savePassword() { try { await resetSystemUserPassword(target.value.id, password.value); password.value = ''; passwordOpen.value = false; ElMessage.success('密码已更新,旧会话已失效'); await load(); } catch (error) { ElMessage.error(error.message || '密码更新失败'); } }
load(); return { users, loading, dialogOpen, passwordOpen, form, pages, roles, editingId, isDeveloper, isAdministratorRole, target, password, load, openCreate, openEdit, save, openPassword, savePassword }; }, template: `<section class="user-permission-page"><header class="page-header"><div><p class="eyebrow">设置 / 账号与权限</p><h2>账号与权限</h2><p>开发者账号 Jeddy 不会在此页面显示;逐页权限由此面板独立配置。</p></div><el-button type="primary" @click="openCreate">新增账号</el-button></header><section class="reference-card"><el-table :data="users" v-loading="loading"><el-table-column prop="username" label="用户名" /><el-table-column prop="roleCode" label="角色" /><el-table-column prop="status" label="状态" /><el-table-column label="操作" width="210"><template #default="scope"><el-button link @click="openEdit(scope.row)">编辑权限</el-button><el-button v-if="isDeveloper" link type="primary" @click="openPassword(scope.row)">设置密码</el-button></template></el-table-column></el-table></section><el-dialog v-model="dialogOpen" :title="editingId ? '编辑账号与权限' : '新增账号'" width="680px"><el-form label-width="112px"><el-form-item label="用户名" v-if="!editingId"><el-input v-model="form.username" /></el-form-item><el-form-item label="角色"><el-select v-model="form.roleCode"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item><el-form-item label="账号状态" v-if="editingId"><el-select v-model="form.status"><el-option label="启用" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item><el-form-item label="初始密码" v-if="!editingId && isDeveloper"><el-input v-model="form.password" type="password" show-password /><small>12-72 位,且不能包含用户名。</small></el-form-item><el-alert v-if="!editingId && !isDeveloper" title="超级管理员创建的账号会保持禁用且不设密码,需由开发者设置密码后再启用。" type="info" :closable="false" /><el-divider>页面权限</el-divider><div v-if="isAdministratorRole" class="permission-hint">超级管理员固定拥有全部页面的编辑权限。</div><el-form-item v-for="page in pages" :key="page.key" :label="page.label"><el-radio-group v-model="form.pagePermissions[page.key]" :disabled="isAdministratorRole"><el-radio value="NONE">无权限</el-radio><el-radio value="READ">只读</el-radio><el-radio value="EDIT">编辑</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogOpen=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog><el-dialog v-model="passwordOpen" title="设置账号密码" width="420px"><p>仅开发者可以设置密码:{{ target?.username }}</p><el-input v-model="password" type="password" show-password placeholder="12-72 位且不含用户名" /><template #footer><el-button @click="passwordOpen=false">取消</el-button><el-button type="primary" @click="savePassword">确认设置</el-button></template></el-dialog></section>` };
import { request } from '../auth/auth-api-client.js';
/** 代码作用(白话):集中发送账号管理请求,并复用认证模块的 Cookie 与 CSRF 保护;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):设置页面操作 -> request -> 账号管理 API -> 安全响应。 */
export function listSystemUsers() { return request('/api/system-users'); }
/** 代码作用(白话):提交创建账号资料;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):新增账号面板 -> POST -> as_system_user。 */
export function createSystemUser(form) { return request('/api/system-users', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交角色、启停和逐页权限修改;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):编辑面板 -> PUT -> as_system_user。 */
export function updateSystemUser(id, form) { return request(`/api/system-users/${id}`, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):仅供开发者提交非开发者账号的新密码;关联文件:UserPermissionView.js、SystemUserAdminController.java。关联逻辑(调用链/数据流):密码面板 -> PUT password -> BCrypt/数据库触发器。 */
export function resetSystemUserPassword(id, password) { return request(`/api/system-users/${id}/password`, { method: 'PUT', body: JSON.stringify({ password }) }); }
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { computed, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchPhoneAssets } from './wecom-api-client.js';
/** File purpose (plain language): renders the enterprise WeChat asset list and its creation dialog with reusable asset searches. */
......@@ -13,6 +14,8 @@ export default {
const total = ref(0);
const companyOptions = ref([]);
const ownerOptions = ref([]);
/** 代码作用(白话):把企微页面的有效权限转换为新增按钮和弹窗是否可用;关联文件:auth-store.js、WecomAccountController.java。关联逻辑(调用链/数据流):认证资料 -> EDIT 判断 -> 写操作控件 -> 后端 EDIT 校验。 */
const canEdit = computed(() => authState.user?.pagePermissions?.['reference-wecom'] === 'EDIT');
let searchTimer;
const filters = reactive({ page: 1, size: 20, keyword: '', wecomAccount: '', phoneAssetId: new URLSearchParams(window.location.hash.split('?')[1] || '').get('phoneAssetId') || '', companyProfileId: 'ALL', realNameOwnerStatus: 'ALL' });
const form = reactive({ wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null });
......@@ -69,14 +72,14 @@ export default {
function formatRelation(name, id) { return id === null || id === undefined ? '—' : `${name || '—'}(ID:${id})`; }
onMounted(loadPage);
return { changePage, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
return { canEdit, changePage, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
},
template: `
<section class="phone-asset-list-page wecom-account-page">
<header class="phone-asset-list-page__header"><div><h2>企业微信资产</h2><p class="wecom-account-page__eyebrow">WECOM ACCOUNTS</p></div><el-button class="phone-asset-list-page__add" type="primary" @click="openCreate">新增企业微信资产</el-button></header>
<header class="phone-asset-list-page__header"><div><h2>企业微信资产</h2><p class="wecom-account-page__eyebrow">WECOM ACCOUNTS</p></div><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增企业微信资产</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="企微名称或手机号" clearable @input="scheduleSearch" @clear="scheduleSearch" /><el-select v-model="filters.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="注册主体:" @change="submitSearch" @clear="restoreAllCompanyProfiles"><template #prefix>注册主体:</template><el-option label="全部" value="ALL" /><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select><el-select v-model="filters.realNameOwnerStatus" clearable placeholder="实名状态:" @change="submitSearch" @clear="restoreAllRealNameStatuses"><template #prefix>实名状态:</template><el-option label="全部" value="ALL" /><el-option label="在职" value="在职" /><el-option label="离职" value="离职" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid wecom-account-page__grid"><el-table-column prop="id" label="企业微信资产 ID" min-width="140" /><el-table-column prop="wecomName" label="企微名称" min-width="150" show-overflow-tooltip /><el-table-column prop="wecomAlias" label="企微别名" min-width="180" show-overflow-tooltip /><el-table-column prop="wecomAccount" label="企微账号" min-width="160" show-overflow-tooltip /><el-table-column label="注册主体" min-width="180"><template #default="{ row }">{{ formatRelation(row.companyProfileName, row.companyProfileId) }}</template></el-table-column><el-table-column label="注册手机号" min-width="180"><template #default="{ row }">{{ formatRelation(row.phoneNumber, row.phoneAssetId) }}</template></el-table-column><el-table-column label="关联方式" min-width="120"><template #default="{ row }">{{ row.phoneLinkMode === 'CREATED' ? '新建号码' : '已有号码' }}</template></el-table-column><el-table-column prop="realNameOwner" label="实名人" min-width="120" /><el-table-column prop="realNameOwnerStatus" label="实名状态" min-width="110" /><el-table-column prop="gender" label="性别" min-width="90" /><el-table-column label="企微号归属人" min-width="180"><template #default="{ row }">{{ formatRelation(row.operatorPersonName, row.operatorPersonId) }}</template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table><footer v-if="total" class="phone-asset-list-page__pagination"><span>共 {{ total }} 条</span><el-pagination layout="prev, pager, next" :current-page="filters.page" :page-size="filters.size" :total="total" @current-change="changePage" /></footer></section>
<el-dialog v-model="dialogVisible" title="新增企业微信资产" width="640px" :close-on-click-modal="false"><el-form label-position="top" @submit.prevent="submitCreate"><el-row :gutter="16"><el-col :span="12"><el-form-item label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item></el-col><el-col :span="12"><el-form-item label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item></el-col><el-col :span="12"><el-form-item label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item></el-col><el-col :span="12"><el-form-item label="注册手机号" required><el-autocomplete v-model="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" placeholder="输入或选择手机号" style="width:100%" /></el-form-item></el-col><el-col :span="12"><el-form-item label="注册主体"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="企微号归属人"><el-select v-model="form.operatorPersonId" filterable remote clearable :remote-method="loadOwners" placeholder="输入人员姓名" style="width:100%"><el-option v-for="item in ownerOptions" :key="item.id" :label="item.personName" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="实名人"><el-input v-model="form.realNameOwner" /></el-form-item></el-col><el-col :span="12"><el-form-item label="实名状态"><el-radio-group v-model="form.realNameOwnerStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item></el-col><el-col :span="12"><el-form-item label="性别"><el-radio-group v-model="form.gender"><el-radio value="男">男</el-radio><el-radio value="女">女</el-radio></el-radio-group></el-form-item></el-col></el-row></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreate">保存</el-button></template></el-dialog>
</section>
`
};
\ No newline at end of file
};
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/** File purpose (plain language): centralizes enterprise WeChat asset requests and turns API envelopes into usable data. */
/** Code purpose (plain language): sends an API request and exposes either its data or readable error. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: view action -> request -> ApiResponse -> view state. */
async function request(path, options = {}) {
const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
const headers = await csrfHeadersFor(path, (options.method || 'GET').toUpperCase());
const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers }, ...options });
const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '企业微信资产请求失败');
return payload.data;
......@@ -25,4 +27,4 @@ export function searchCompanyProfiles(keyword) { return request(`/api/wecom-acco
export function searchPhoneAssets(keyword) { return request(`/api/wecom-accounts/lookups/phone-assets?keyword=${encodeURIComponent(keyword || '')}`); }
/** Code purpose (plain language): searches optional WeCom owners. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyPersons(keyword) { return request(`/api/wecom-accounts/lookups/company-persons?keyword=${encodeURIComponent(keyword || '')}`); }
\ No newline at end of file
export function searchCompanyPersons(keyword) { return request(`/api/wecom-accounts/lookups/company-persons?keyword=${encodeURIComponent(keyword || '')}`); }
......@@ -3,6 +3,9 @@ import LegacyReferenceView from '../reference/LegacyReferenceView.js';
import PhoneAssetView from '../modules/phone/PhoneAssetView.js';
import WecomAccountView from '../modules/wecom/WecomAccountView.js';
import DeviceAssetView from '../modules/device/DeviceAssetView.js';
import LoginView from '../modules/auth/LoginView.js';
import UserPermissionView from '../modules/system-user/UserPermissionView.js';
import { authState, bootstrapAuth } from '../modules/auth/auth-store.js';
/**
* 代码作用(白话):生成暂未重构完成的普通页面,避免旧业务模块被删除后导航落到空白或继续请求旧接口。
......@@ -22,14 +25,20 @@ const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', redirect: '/overview' },
{ path: '/overview', component: createPlaceholderView('资产总览') },
{ path: '/phone-assets', component: PhoneAssetView },
{ path: '/device-assets', component: DeviceAssetView },
{ path: '/domain', component: createPlaceholderView('域名资料') },
{ path: '/alerts', component: createPlaceholderView('提醒中心') },
{ path: '/reference/wecom', component: WecomAccountView },
{ path: '/login', component: LoginView, meta: { public: true } },
{ path: '/overview', component: createPlaceholderView('资产总览'), meta: { page: 'overview' } },
{ path: '/phone-assets', component: PhoneAssetView, meta: { page: 'phone-assets' } },
{ path: '/device-assets', component: DeviceAssetView, meta: { administratorOnly: true } },
{ path: '/domain', component: createPlaceholderView('域名资料'), meta: { page: 'domain' } },
{ path: '/alerts', component: createPlaceholderView('提醒中心'), meta: { page: 'alerts' } },
{ path: '/reference/wecom', component: WecomAccountView, meta: { page: 'reference-wecom' } },
{ path: '/settings/users-permissions', component: UserPermissionView, meta: { administratorOnly: true } },
{ path: '/reference/phone', component: LegacyReferenceView, props: { kind: 'phone' } }
]
});
/** 代码作用(白话):在进入业务路由前恢复 Cookie 会话,未登录用户一律进入新建的登录路由。关联文件:auth-store.js、LoginView.js。关联逻辑(调用链/数据流):Hash 地址 -> 路由守卫 -> /api/auth/me -> 业务页或 /login。 */
/** 代码作用(白话):在已登录后继续按逐页权限和管理角色校验目标路由,避免只隐藏菜单就能手输地址绕过;关联文件:App.js、PagePermissionService.java。关联逻辑(调用链/数据流):路由地址 -> authState 有效权限 -> 放行或返回总览。 */
router.beforeEach(async to => { if (!authState.ready) await bootstrapAuth(); if (to.meta.public) return authState.user ? '/overview' : true; if (!authState.user) return '/login'; const isAdministrator = ['DEVELOPER', 'SUPER_ADMIN'].includes(authState.user.roleCode); if (to.meta.administratorOnly && !isAdministrator) return '/overview'; if (to.meta.page && authState.user.pagePermissions?.[to.meta.page] === 'NONE' && to.path !== '/overview') return '/overview'; return true; });
export default router;
:root { color: #1f2937; background: #f7f8fb; font-family: Inter, "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: linear-gradient(135deg, #132338, #28537d); }
.login-card { width: min(420px, 100%); padding: 32px; border-radius: 16px; background: #fff; box-shadow: 0 24px 70px rgba(0,0,0,.24); }
.login-card h1 { margin: 0 0 8px; }.login-card > p:not(.eyebrow) { margin: 0 0 24px; color: #64748b; }.login-submit { width: 100%; }.logout-button { width: 100%; margin-top: 24px; padding: 10px 12px; border: 1px solid #496b8c; border-radius: 8px; color: #cbd5e1; background: transparent; cursor: pointer; }.logout-button:hover { color:#fff; background:#1f3b59; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); }
.sidebar { padding: 28px 20px; background: #132338; color: #f8fafc; }
.sidebar h1 { margin: 0 0 32px; font-size: 20px; }
.sidebar nav { display: grid; gap: 8px; }
.sidebar a { border-radius: 8px; color: #cbd5e1; padding: 10px 12px; text-decoration: none; }
.sidebar a.router-link-active, .sidebar a:hover { background: #1f3b59; color: white; }
.sidebar .settings-link { margin-top: 16px; border-top: 1px solid #385673; border-radius: 0; padding-top: 18px; }
.content { min-width: 0; padding: 40px; }
.user-permission-page { max-width: 1180px; margin: 0 auto; }.user-permission-page small { display:block; margin-top:6px; color:#64748b; }.permission-hint { margin:-4px 0 16px; color:#64748b; font-size:13px; }
.eyebrow { margin: 0 0 8px; color: #5f8fcb; font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.reference-page, .placeholder { max-width: 1180px; margin: 0 auto; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
......
import { expect, test as base } from '@playwright/test';
/** File purpose (plain language): supplies the same developer login fixture to every asset browser test without changing production authentication code. */
export const test = base;
/** Plain purpose: register deterministic authentication responses before a test opens a protected route. Related files: auth-store.js, router/index.js, and all files in frontend/tests. Data flow: test setup -> mocked current-user/CSRF requests -> route guard -> protected page. */
test.beforeEach(async ({ page }) => {
const user = {
id: 1,
username: 'playwright-asset-admin',
roleCode: 'DEVELOPER',
pagePermissions: {
overview: 'EDIT',
domain: 'EDIT',
'reference-wecom': 'EDIT',
'phone-assets': 'EDIT',
alerts: 'EDIT'
}
};
/** Plain purpose: return an administrator identity to the route guard. Related files: auth-store.js and router/index.js. Data flow: GET /api/auth/me -> mocked envelope -> auth state -> allowed route. */
await page.route('**/api/auth/me', async route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: 'success', data: user })
}));
/** Plain purpose: make protected test requests receive a successful CSRF preflight. Related files: api-client.js and all asset API clients. Data flow: API client -> CSRF request -> mocked success -> test-owned business request mock. */
await page.route('**/api/auth/csrf', async route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: 'success', data: null })
}));
});
export { expect };
import { expect, test } from '@playwright/test';
import { expect, test } from './authenticated-test.js';
/** 文件用途(白话):验证设备资产管理路由、列表数据和状态筛选控件,不依赖真实后端数据库。 */
/** File purpose: exercise the device route, readable rows, client image limit, and create/edit requests without a real database. */
test('shows the device management page and readable device row', async ({ page }) => {
/** 代码作用(白话):为设备列表和人员搜索提供稳定模拟响应。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> route.fulfill -> Vue 表格与下拉项。 */
/** Plain purpose: provide stable device-list and person-lookup responses. Related files: DeviceAssetView.js, device-api-client.js. Flow: browser request -> route.fulfill -> Vue table/dropdown. */
await page.route('**/api/device-assets**', async route => {
const url = route.request().url();
const data = url.includes('/lookups/company-persons') ? [{ id: 9, personName: '张三' }] : { records: [{ id: 1, deviceName: 'iPhone 15-01', imageAttachment1Url: null, imageAttachment2Url: null, userPersonId: 9, userPersonName: '张三', userUsageStatus: '使用中', assetRelationStatus: '已关联', createTime: '2026-08-01T10:00:00', updateTime: '2026-08-01T10:00:00' }], total: 1, page: 1, size: 20 };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data }) });
const url=route.request().url();
const data=url.includes('/lookups/company-persons')?[{id:9,personName:'\u5f20\u4e09'}]:{records:[{id:1,deviceName:'iPhone 15-01',imageAttachment1Url:null,imageAttachment2Url:null,userPersonId:9,userPersonName:'\u5f20\u4e09',userUsageStatus:'\u4f7f\u7528\u4e2d',assetRelationStatus:'\u5df2\u5173\u8054',createTime:'2026-08-01T10:00:00',updateTime:'2026-08-01T10:00:00'}],total:1,page:1,size:20};
await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,message:'success',data})});
});
await page.goto('/asset/#/device-assets');
await expect(page.getByRole('heading', { name: '设备资产管理' })).toBeVisible();
await expect(page.getByRole('heading',{name:'\u8bbe\u5907\u8d44\u4ea7\u7ba1\u7406'})).toBeVisible();
await expect(page.getByText('iPhone 15-01')).toBeVisible();
await expect(page.getByText('张三(9)')).toBeVisible();
await expect(page.locator('.el-table').getByText('使用中', { exact: true })).toBeVisible();
await expect(page.locator('.el-table').getByText('已关联', { exact: true })).toBeVisible();
await expect(page.getByText('\u5f20\u4e09(9)')).toBeVisible();
await expect(page.locator('.el-table').getByText('\u4f7f\u7528\u4e2d',{exact:true})).toBeVisible();
await expect(page.locator('.el-table').getByText('\u5df2\u5173\u8054',{exact:true})).toBeVisible();
});
/** 文件用途(白话):验证图片选择在浏览器端拒绝超过 20MB 的文件,避免无效请求发送到服务器。 */
/** Plain purpose: ensure an image over 20MB is rejected before a device save request. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: choose file -> client validation -> message and no POST. */
test('rejects an oversized image before device save', async ({ page }) => {
/** 代码作用(白话):模拟空设备列表,让测试直接打开新增弹窗。关联文件:DeviceAssetView.js、device-api-client.js。关联逻辑(调用链/数据流):页面 GET -> route.fulfill -> 新增表单。 */
await page.route('**/api/device-assets**', async route => { await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { records: [], total: 0, page: 1, size: 20 } }) }); });
/** Plain purpose: provide an empty device page so the create dialog can be opened. Related files: DeviceAssetView.js, device-api-client.js. Flow: GET -> route response -> add dialog. */
await page.route('**/api/device-assets**', async route=>route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,message:'success',data:{records:[],total:0,page:1,size:20}})}));
await page.goto('/asset/#/device-assets');
await page.getByRole('button', { name: '新增设备' }).click();
const file = { name: 'too-large.png', mimeType: 'image/png', buffer: Buffer.alloc(20 * 1024 * 1024 + 1) };
await page.locator('input[type=file]').first().setInputFiles(file);
await expect(page.getByText('每张图片不能超过 20MB')).toBeVisible();
await page.getByRole('button',{name:'\u65b0\u589e\u8bbe\u5907'}).click();
await page.locator('input[type=file]').first().setInputFiles({name:'too-large.png',mimeType:'image/png',buffer:Buffer.alloc(20*1024*1024+1)});
await expect(page.getByText('\u6bcf\u5f20\u56fe\u7247\u4e0d\u80fd\u8d85\u8fc7 20MB')).toBeVisible();
});
/** Plain purpose: verify dialog save sends POST for new devices and PUT for edits. Related files: DeviceAssetView.js, device-api-client.js. Flow: dialog save -> fetch POST/PUT -> API response -> list refresh. */
test('creates and edits a device asset through the dialog', async ({ page }) => {
let createRequested=false,updateRequested=false;
/** Plain purpose: return list data and record which save method was sent. Related files: DeviceAssetView.js, device-api-client.js. Flow: form submit -> route interception -> successful JSON -> refreshed table. */
await page.route('**/api/device-assets**', async route=>{const request=route.request();if(request.method()==='POST')createRequested=true;if(request.method()==='PUT')updateRequested=true;const data=request.method()==='GET'?{records:[{id:1,deviceName:'iPhone 15-01',imageAttachment1Url:null,imageAttachment2Url:null,userPersonId:null,userPersonName:null,userUsageStatus:'\u4f7f\u7528\u4e2d',assetRelationStatus:'\u5f85\u786e\u8ba4',createTime:'',updateTime:''}],total:1,page:1,size:20}:{id:1,deviceName:'iPhone 15-01'};await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,message:'success',data})});});
await page.goto('/asset/#/device-assets');
await page.getByRole('button',{name:'\u65b0\u589e\u8bbe\u5907'}).click();
await page.getByRole('dialog').getByRole('textbox').first().fill('\u65b0\u589e\u6d4b\u8bd5\u8bbe\u5907');
await page.getByRole('dialog').getByRole('button',{name:'\u4fdd\u5b58'}).click();
await expect.poll(()=>createRequested).toBe(true);
await page.locator('.el-table').getByRole('button',{name:'\u7f16\u8f91'}).click();
await page.getByRole('dialog').getByRole('textbox').first().fill('\u7f16\u8f91\u540e\u7684\u8bbe\u5907');
await page.getByRole('dialog').getByRole('button',{name:'\u4fdd\u5b58'}).click();
await expect.poll(()=>updateRequested).toBe(true);
});
/** Plain purpose: verify fixed status options are shown and an unsupported file never becomes an upload value. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: open form -> status dropdown/file choose -> client validation message. */
test('shows fixed statuses and rejects an unsupported image type', async ({ page }) => {
/** Plain purpose: return an empty list to isolate the create dialog. Related files: DeviceAssetView.js, device-api-client.js. Flow: GET -> empty table -> create dialog. */
await page.route('**/api/device-assets**', async route=>route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,message:'success',data:{records:[],total:0,page:1,size:20}})}));
await page.goto('/asset/#/device-assets');
await page.getByRole('button',{name:'\u65b0\u589e\u8bbe\u5907'}).click();
await page.getByRole('dialog').locator('.el-select').first().click();
expect(await page.getByText('\u4f7f\u7528\u4e2d',{exact:true}).allTextContents()).toContain('\u4f7f\u7528\u4e2d');
expect(await page.getByText('\u95f2\u7f6e',{exact:true}).allTextContents()).toContain('\u95f2\u7f6e');
expect(await page.getByText('\u7ef4\u4fee\u4e2d',{exact:true}).allTextContents()).toContain('\u7ef4\u4fee\u4e2d');
expect(await page.getByText('\u505c\u7528',{exact:true}).allTextContents()).toContain('\u505c\u7528');
await page.locator('input[type=file]').first().setInputFiles({name:'not-image.txt',mimeType:'text/plain',buffer:Buffer.from('not an image')});
await expect(page.getByText('\u4ec5\u652f\u6301 JPG\u3001PNG\u3001GIF \u56fe\u7247')).toBeVisible();
});
/** Plain purpose: verify a protected delete keeps the row visible and displays the API reason. Related files: DeviceAssetView.js, DeviceAssetService.java. Flow: delete confirmation -> DELETE 400 -> Element Plus message -> unchanged table row. */
test('keeps a referenced device visible after protected delete response', async ({ page }) => {
/** Plain purpose: simulate one active row and a server-side reference-protection error. Related files: device-api-client.js, DeviceAssetController.java. Flow: DELETE -> error envelope -> page message. */
await page.route('**/api/device-assets**', async route=>{const request=route.request();const data=request.method()==='GET'?{records:[{id:8,deviceName:'protected-device',imageAttachment1Url:null,imageAttachment2Url:null,userPersonId:null,userPersonName:null,userUsageStatus:'\u4f7f\u7528\u4e2d',assetRelationStatus:'\u5df2\u5173\u8054',createTime:'',updateTime:''}],total:1,page:1,size:20}:null;const response=request.method()==='DELETE'?{code:400,message:'\u8bbe\u5907\u4ecd\u88ab\u624b\u673a\u53f7\u8d44\u4ea7\u5f15\u7528\uff0c\u4e0d\u80fd\u5220\u9664',data:null}:{code:200,message:'success',data};await route.fulfill({contentType:'application/json',body:JSON.stringify(response)});});
await page.goto('/asset/#/device-assets');
await page.locator('.el-table').getByRole('button',{name:'\u5220\u9664'}).click();
await page.getByRole('button',{name:'OK'}).click();
await expect(page.getByText('\u8bbe\u5907\u4ecd\u88ab\u624b\u673a\u53f7\u8d44\u4ea7\u5f15\u7528\uff0c\u4e0d\u80fd\u5220\u9664')).toBeVisible();
await expect(page.locator('.el-table').getByText('protected-device',{exact:true})).toBeVisible();
});
import { expect, test } from '@playwright/test';
import { expect, test } from './authenticated-test.js';
const referencePages = [
{ path: '#/reference/phone', title: '手机号卡旧界面参考' }
......
import { expect, test } from '@playwright/test';
import { expect, test } from './authenticated-test.js';
/**
* 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表。
......@@ -125,4 +125,4 @@ test('窄窗口下弹窗不超出屏幕且页面没有横向滚动', async ({ pa
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(320);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
\ No newline at end of file
});
import { expect, test } from '@playwright/test';
import { expect, test } from './authenticated-test.js';
/** File purpose (plain language): checks the enterprise WeChat page's create flow, external-number navigation, and phone filter. */
test('creates an enterprise WeChat asset and exposes the required form', async ({ page }) => {
......@@ -26,4 +26,4 @@ test('external number links to the related enterprise WeChat asset filter', asyn
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
const link = page.getByRole('link', { name: '外部号码' });
await expect(link).toHaveAttribute('href', '#/reference/wecom?phoneAssetId=9');
});
\ No newline at end of file
});
## Context
The project already maps `as_asset_device` through `AssetDeviceEntity` and `AssetDeviceMapper`, but it has no device Controller, Service, DTOs, page, image upload path, route, or menu. The table keeps a device name, two optional image attachment fields, a logical `user_person_id`, two text status fields, and the common `delete_time` soft-delete marker.
Enterprise WeChat asset work is currently in progress in the same repository. Its files are already dirty, including the enterprise WeChat Controller, Service, DTOs, frontend module, CSS, and tests. Device management must therefore be independently buildable and defer the two shared frontend registrations until final integration.
There is no existing upload service. This change stores original image files locally and records controlled, application-relative file identifiers in `image_attachment_1` and `image_attachment_2`. The browser renders a fixed-size preview of the original image; it does not generate or store a second thumbnail file.
## Goals / Non-Goals
**Goals:**
- Provide a complete, safe device-asset CRUD workflow over existing `as_asset_device` rows.
- Support up to two JPG, PNG, or GIF images per device, with a 20 MB maximum for each image.
- Provide searchable company-person selection and readable person names in the list response.
- Keep active device names unique, apply approved dropdown values, and protect referenced devices from deletion.
- Add a dedicated Device Asset Management page, route, and menu without changing enterprise WeChat business behavior.
- Allow parallel implementation by isolating device work to new files and assigning final shared-file integration to one owner.
**Non-Goals:**
- Do not modify database schema, run migrations, or directly operate the database.
- Do not add device selection to the enterprise WeChat creation form; that is a later change.
- Do not generate physical thumbnail files, convert image formats, or add an object-storage dependency.
- Do not permanently remove original image files during normal device soft deletion.
- Do not introduce authentication or a general-purpose file-management module.
## Decisions
### Device API and data contract
Use `/api/device-assets` for list, creation, update, and deletion. `GET` accepts `page`, `size`, `deviceName`, `userPersonId`, `userUsageStatus`, and `assetRelationStatus`; it filters `delete_time = 0` and orders by descending ID. `POST` and `PUT /{id}` accept multipart form data so device fields and both optional image files are saved together. `DELETE /{id}` performs a soft deletion.
The list and detail response return `id`, device fields, original-image access URLs, `userPersonId`, `userPersonName`, and audit times, but never return `deleteTime` or the physical storage path. This follows the current asset API envelope and pagination shape.
### Validation and reference handling
`deviceName` is required and is unique among active records. Save operations reject values outside the fixed approved status lists. A selected `userPersonId` must resolve to a non-deleted company person; it can be omitted.
Before a device is soft-deleted, the service checks active `as_phone_asset`, `as_wecom_account`, `as_wechat_account`, and `as_douyin_account` rows for the device ID. If any exists, deletion fails with a readable list of reference sources. This preserves logical association integrity because the database intentionally has no foreign keys.
### Image storage and access
The server validates extension and image content for JPG, PNG, and GIF and limits each file to 20 MB. It saves each original file beneath a configurable local root, defaulting to `./uploads/device-assets`, with a generated opaque file identifier. Attachment database columns store only that identifier.
An application endpoint resolves an identifier to a file only after constraining it to the upload root; response DTOs expose the endpoint URL rather than a disk path. On update, omitted image parts preserve the current image; an explicit remove flag clears its database reference. A successful replacement saves the new file before updating the row, while failed requests clean up newly written temporary files. Soft deletion keeps files for recoverability.
Browser-sized preview was selected over server-generated thumbnail files because it adds no image-processing dependency or duplicate storage. The trade-off is that a list may download larger original files; lazy image loading will reduce initial page work.
### Frontend behavior
`DeviceAssetView` provides filters, a paged table, a create/edit dialog, remote company-person search, the two status dropdowns, two image selectors, inline browser previews, and a delete confirmation. Each slot supports retain, replace, or explicit removal during edit. The page sets `loading` and `saving` states and surfaces API errors through the existing Element Plus message pattern.
Use a dedicated `device-asset.css`, imported by the device module, rather than editing the currently modified shared stylesheet. The final integration adds the `设备资产管理` navigation item and maps `#/device-assets` to the new view.
### Parallel ownership and integration
Device work owns only newly added `DeviceAsset*` backend files, device frontend module files, device CSS, and device tests. It reuses but does not edit `AssetDeviceEntity`, `AssetDeviceMapper`, or enterprise WeChat code. Enterprise WeChat work retains ownership of all current dirty files. A single integration owner changes `App.js` and `router/index.js` after both feature branches are ready.
This ownership model avoids merge conflicts. The device branch is developed in a separate Git worktree from the current dirty enterprise-WeChat worktree; final integration runs all relevant tests after both changes are present.
## Risks / Trade-offs
- [Original files can be 20 MB] → Lazy-load list previews, display a loading state, and never generate duplicate thumbnail files.
- [Local disk is not shared across multiple application instances] → Keep the upload root configurable and document that a future multi-instance deployment must move to shared/object storage.
- [Soft deletion retains image files] → Retention is deliberate for recovery; any physical cleanup must be a separately authorized maintenance workflow.
- [Database has no foreign keys] → Service-level active-reference checks block deletion, and tests cover each referencing asset type.
- [User-provided file extension can be misleading] → Validate both allowed type and decodable image content; serve files only through opaque identifiers constrained to the upload root.
- [Shared menu and router files are collision points] → Make their two-line integration a separately owned final commit only.
## Migration Plan
1. Create the device feature branch/worktree from the agreed base without resetting or stashing the current enterprise-WeChat working tree.
2. Implement and test only device-owned new files; do not change enterprise-WeChat files, shared CSS, or shared navigation during this phase.
3. Configure the multipart request maximum to accommodate two 20 MB files plus form metadata, without changing database schema.
4. Deploy with a writable device upload directory and verify that its access endpoint can read only files under the configured root.
5. Run device API tests, frontend build, device Playwright tests, then the existing phone and enterprise-WeChat smoke tests after final integration.
6. Roll back code by reverting the device feature and route/menu integration commits. Existing rows and retained files remain recoverable; no database rollback is required.
## Open Questions
None. The approved status values, per-image 20 MB limit, browser-sized previews, local storage, deletion protection, route, menu label, and deferred enterprise-WeChat selection are all fixed for this change.
## Why
`as_asset_device` already has an Entity and Mapper, but the asset console cannot browse, create, edit, upload images for, or safely delete device assets. Completing this closed management loop is needed before later work can let enterprise WeChat assets choose a device.
## What Changes
- Add a device-asset REST API for paged listing, creation, editing, image upload and protected soft deletion against `as_asset_device`.
- Add a Device Asset Management workspace at `#/device-assets`, including filters, paged table, creation/edit dialog, two-image upload and browser-sized thumbnail preview.
- Limit each uploaded JPG, PNG, or GIF image to 20 MB. Store only original files locally; the list renders a fixed-size browser preview and does not create a second thumbnail file.
- Add fixed dropdown options for user usage status (`使用中`, `闲置`, `维修中`, `停用`) and asset relation status (`已关联`, `未关联`, `待确认`).
- Resolve `user_person_id` to a readable company-person name and prohibit deletion while an active phone, enterprise WeChat, WeChat, or Douyin asset still references the device.
- Add the `设备资产管理` menu item and the `#/device-assets` route as a final, isolated integration change so this work can proceed in parallel with enterprise WeChat asset work.
## Capabilities
### New Capabilities
- `device-asset-api`: Provides safe device-asset CRUD, filtered pagination, image attachment handling, company-person lookup, and reference-protected deletion.
- `device-asset-workspace`: Provides the Device Asset Management Vue workspace, its filters, forms, image previews, and menu/route entry.
### Modified Capabilities
- None.
## Impact
- Backend: adds device-specific Controller, Service, request/response DTOs, local file-storage support, and API tests; reuses the existing `AssetDeviceEntity`, `AssetDeviceMapper`, `CompanyPersonMapper`, and referencing asset Mappers without changing database schema.
- Frontend: adds an isolated device module, API client, dedicated CSS, and Playwright coverage. Only the final integration changes `frontend/src/App.js` and `frontend/src/router/index.js`.
- API and files: introduces `/api/device-assets` CRUD, company-person lookup, and controlled image-file access endpoints. Images are stored under a configurable local directory, defaulting to `./uploads/device-assets`; each image is limited to 20 MB.
- Database: no DDL, migration, or direct database operation is included. Existing `delete_time` semantics remain the soft-delete mechanism.
## ADDED Requirements
### Requirement: Paged active device list
The system SHALL provide `GET /api/device-assets` with validated `page` and `size` parameters and optional `deviceName`, `userPersonId`, `userUsageStatus`, and `assetRelationStatus` filters. It MUST return only rows whose `delete_time` is `0`, ordered by descending ID, in the existing `records`, `total`, `page`, and `size` pagination shape.
#### Scenario: Filtered active-device page
- **WHEN** a user requests page 1 with `deviceName` and an approved status filter
- **THEN** the response contains only matching non-deleted device records and their pagination metadata
### Requirement: Readable device response and company-person lookup
The system SHALL return each device's ID, device name, two image access URLs when present, `userPersonId`, resolved `userPersonName`, both status fields, and creation and update times. It MUST NOT return `deleteTime` or a physical file path. The system SHALL provide a read-only company-person lookup endpoint that returns active matching person IDs and names for the device form.
#### Scenario: Missing or deleted device user
- **WHEN** a device has no user person or its referenced person is not active
- **THEN** the device record retains `userPersonId` when present and returns a null user-person name without failing the page
### Requirement: Device creation and update
The system SHALL provide multipart `POST /api/device-assets` and `PUT /api/device-assets/{id}` endpoints. Creation and update MUST require a nonblank device name, reject duplicate active device names, accept only the approved status values, and require an active company person when `userPersonId` is supplied. Update MUST preserve an existing image when no replacement or explicit removal is supplied.
#### Scenario: Create a device with valid approved values
- **WHEN** a user submits a unique device name, optional active user person, and approved usage and relation statuses
- **THEN** the system creates an active device record with creation and update timestamps and returns its readable response
#### Scenario: Reject invalid status or duplicate name
- **WHEN** a user submits an unsupported status value or a device name already used by an active device
- **THEN** the system rejects the request without writing a new or changed device row
### Requirement: Device image attachment handling
The system SHALL accept at most two optional device images, one for each attachment slot. Each file MUST be JPG, PNG, or GIF, MUST be decodable as that image type, and MUST not exceed 20 MB. The system MUST store original files under a configurable local root and expose them only through an opaque application file URL; it MUST NOT return local disk paths or create separate thumbnail files.
#### Scenario: Display image by browser-sized preview
- **WHEN** a device response contains an image access URL
- **THEN** the client can load the original image through the controlled URL and render it in a fixed-size preview without requesting a separately generated thumbnail
#### Scenario: Replace or remove an image while editing
- **WHEN** a user updates one image slot with a valid replacement or an explicit remove flag
- **THEN** the system respectively records the new opaque identifier or clears that slot while leaving the other slot unchanged
### Requirement: Reference-protected device deletion
The system SHALL provide `DELETE /api/device-assets/{id}` as a soft delete. Before deletion it MUST check active phone, enterprise WeChat, WeChat, and Douyin assets for the target device ID. If any active reference exists, it MUST reject deletion and identify the referencing asset types; otherwise it MUST update `delete_time` and `update_time` without physically deleting stored image files.
#### Scenario: Reject deletion of a referenced device
- **WHEN** an active phone or account asset references the requested device ID
- **THEN** the system returns a readable failure and the device remains active
#### Scenario: Soft-delete an unreferenced device
- **WHEN** no active supported asset references the requested device ID
- **THEN** the system marks the device deleted and it no longer appears in the device list
## ADDED Requirements
### Requirement: Device Asset Management route and menu
The frontend SHALL provide a Device Asset Management workspace at `#/device-assets` and a sidebar menu item labelled `设备资产管理` that opens it. The route and menu integration MUST preserve the existing enterprise-WeChat route and page behavior.
#### Scenario: Open Device Asset Management
- **WHEN** a user selects `设备资产管理` from the sidebar
- **THEN** the application navigates to `#/device-assets` and renders the device list workspace
### Requirement: Device list filters and pagination
The workspace SHALL render device name, user person, usage status, relation status, and audit-time columns with filters for name, user person, usage status, and relation status. It MUST expose pagination and show loading, empty, and request-error states.
#### Scenario: Reset a filtered device list
- **WHEN** a user clears the filters through the reset control
- **THEN** the workspace requests the first unfiltered page and displays its returned records
### Requirement: Device create and edit form
The workspace SHALL provide create and edit dialogs with a required device name, remote company-person selector, the approved usage-status dropdown, and the approved relation-status dropdown. It MUST prevent duplicate save submissions while a request is pending and show validation or API failures to the user.
#### Scenario: Submit a valid device form
- **WHEN** a user completes a valid create or edit dialog and selects save
- **THEN** the workspace submits multipart form data, closes the dialog after success, and refreshes the list
### Requirement: Two-image browser preview
The workspace SHALL allow each device form to select up to two JPG, PNG, or GIF files, each no larger than 20 MB. It MUST render fixed-size previews of existing and newly selected original images, allow an existing slot to be retained, replaced, or marked for removal, and allow a user to open the original image preview.
#### Scenario: Reject an oversized or unsupported image before save
- **WHEN** a user selects an image larger than 20 MB or outside the supported formats
- **THEN** the workspace displays an error and does not include that file in the save request
### Requirement: Protected delete interaction
The workspace SHALL require delete confirmation and refresh the list after a successful soft delete. If the API reports active references, it MUST display the returned reason and keep the device row visible.
#### Scenario: Attempt to delete a referenced device
- **WHEN** a user confirms deletion of a device that is still referenced
- **THEN** the workspace shows the API's reference warning and does not remove the row from the table
## 0. Parallel boundary and file-purpose annotations
| File | File purpose (plain language) | Ownership |
|---|---|---|
| `backend/src/main/java/com/xyw/console/asset/dto/DeviceAssetPageQuery.java` | Receives device-list page and filter values. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/dto/DeviceAssetSaveRequest.java` | Receives create/edit fields and the two optional image parts. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/dto/DeviceAssetResponse.java` | Defines one safe device row returned to the browser. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/dto/DeviceAssetPageResponse.java` | Carries rows, total count, page number, and page size. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/dto/DevicePersonLookupResponse.java` | Carries compact person-search results for the device form. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/exception/DeviceAssetNotFoundException.java` | Represents a missing or already deleted device request. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/exception/DeviceAssetValidationException.java` | Represents invalid device fields, image files, or protected deletion. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/service/DeviceAssetFileStorageService.java` | Validates, stores, reads, replaces, and retains device image files. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/service/DeviceAssetService.java` | Implements device CRUD, list filtering, person resolution, and reference checks. | Device feature |
| `backend/src/main/java/com/xyw/console/asset/controller/DeviceAssetController.java` | Exposes browser endpoints for devices, images, and person lookup. | Device feature |
| `backend/src/test/java/com/xyw/console/asset/service/DeviceAssetServiceTest.java` | Verifies device business rules without a live database. | Device feature |
| `backend/src/test/java/com/xyw/console/asset/controller/DeviceAssetControllerTest.java` | Verifies HTTP contracts, multipart handling, and file access. | Device feature |
| `frontend/src/modules/device/device-api-client.js` | Sends device API requests and converts failures to readable errors. | Device feature |
| `frontend/src/modules/device/DeviceAssetView.js` | Renders list, CRUD dialogs, person selector, images, and delete flow. | Device feature |
| `frontend/src/modules/device/device-asset.css` | Holds device-only CSS to avoid modifying shared styles. | Device feature |
| `frontend/tests/device-asset.spec.js` | Verifies the user-facing device workflow. | Device feature |
| `frontend/tests/authenticated-test.js` | Provides a shared developer-session mock so protected asset-page tests can exercise their own API fixtures. | Test integration |
| `frontend/src/App.js` | Renders global sidebar navigation. | Final integration owner only |
| `frontend/src/router/index.js` | Registers global Hash routes. | Final integration owner only |
- [x] 0.1 Create a dedicated device-asset branch/worktree from the approved base; preserve the current enterprise-WeChat working tree and do not reset, stash, or edit its files.
- [x] 0.2 Keep `AssetDeviceEntity.java`, `AssetDeviceMapper.java`, all enterprise-WeChat files, and `frontend/src/styles/app.css` read-only for the device feature; record the final integration owner for `App.js` and `router/index.js`.
## 1. Device request, response, and error contract
- [x] 1.1 Add `DeviceAssetPageQuery.java` with validated page/size and device filter fields. Add beginner comments to `resolvedPage` and `resolvedSize`: code purpose (plain language), related files, and request-to-pagination data flow.
- [x] 1.2 Add `DeviceAssetSaveRequest.java` for required device name, optional active user person, approved statuses, two optional multipart images, and explicit image-removal flags; document its file purpose.
- [x] 1.3 Add `DeviceAssetResponse.java`, `DeviceAssetPageResponse.java`, and `DevicePersonLookupResponse.java`; document each file purpose and exclude physical paths and `deleteTime` from browser responses.
- [x] 1.4 Add device-specific not-found and validation exceptions, plus focused exception-to-HTTP-response handling if the existing handler cannot safely support them; document each new method and error data flow.
## 2. Local image-file support
- [x] 2.1 Add `DeviceAssetFileStorageService.java` with a configurable `./uploads/device-assets` default and an application multipart limit that permits two 20 MB files plus form data, without exposing physical paths.
- [x] 2.2 Implement and fully annotate every storage method: constructor, `store`, `resolve`, `replace`, `removeReference`, `validateImage`, `createOpaqueIdentifier`, `resolveInsideRoot`, and temporary-file cleanup helpers. Each comment MUST state code purpose (plain language), related files, and upload-to-database-to-preview data flow.
- [x] 2.3 Validate JPG, PNG, and GIF extension plus decodable image content, reject any image over 20 MB, and test path traversal, unsupported type, corrupted content, and oversized file rejection.
- [x] 2.4 Preserve original files through normal soft deletion; ensure failed create/update operations remove only files written by that failed request and never remove an existing referenced original.
## 3. Backend device CRUD and integrity protection
- [x] 3.1 Add `DeviceAssetService.java`, injecting existing device, company-person, phone, enterprise-WeChat, WeChat, and Douyin Mappers without changing their source files.
- [x] 3.2 Implement and fully annotate every service method: constructor, `page`, `create`, `update`, `softDelete`, `searchCompanyPersons`, `findImage`, `activeQuery`, `requireActiveDevice`, `validateSaveRequest`, `validateUserPerson`, `validateStatus`, `checkActiveReferences`, `collectIds`, `personNames`, `toResponse`, `hasText`, and any added helper. Each comment MUST state code purpose (plain language), related files, and Controller-to-Service-to-Mapper/file data flow.
- [x] 3.3 Enforce nonblank active-unique device names, approved values `使用中/闲置/维修中/停用` and `已关联/未关联/待确认`, optional active company-person ownership, audit-time initialization, and `delete_time = 0` list filtering.
- [x] 3.4 Enforce soft-delete protection across active phone, enterprise-WeChat, WeChat, and Douyin records; return a readable list of source types when deletion is blocked.
- [x] 3.5 Add `DeviceAssetController.java` for `GET/POST/PUT/DELETE /api/device-assets`, company-person lookup, and controlled opaque-file access. Fully annotate its constructor and every endpoint method with code purpose (plain language), related files, and HTTP-to-Service-to-response data flow.
## 4. Device Asset Management frontend
- [x] 4.1 Add `device-api-client.js`, including `request`, `listDeviceAssets`, `createDeviceAsset`, `updateDeviceAsset`, `deleteDeviceAsset`, and `searchDeviceCompanyPersons`. Add required beginner comments to each function describing purpose, related files, and view-to-API-to-Controller flow.
- [x] 4.2 Add `DeviceAssetView.js` and document the component file purpose. Implement and fully annotate `setup`, `loadPage`, `openCreate`, `openEdit`, `resetForm`, `submitForm`, `confirmDelete`, `fetchPersonSuggestions`, `validateImageBeforeSelect`, image preview callbacks, image-removal callbacks, `scheduleSearch`, `submitSearch`, `resetSearch`, `changePage`, `changePageSize`, and the mount callback. Every annotation MUST contain code purpose (plain language), related files, and user-action-to-API-to-rendered-state flow.
- [x] 4.3 Render the approved filters, paged table, create/edit dialog, remote person selector, fixed status dropdowns, save/loading/error states, and soft-delete confirmation with reference-error feedback.
- [x] 4.4 Implement two image slots that accept JPG/PNG/GIF up to 20 MB each, preview the original image in a fixed-size browser frame, allow retain/replace/remove on edit, and do not request or create a generated thumbnail file.
- [x] 4.5 Add `device-asset.css` and import it only from the device module; use device-prefixed selectors and preserve `app.css` unchanged.
## 5. Final route and menu integration
- [x] 5.1 After enterprise-WeChat work is ready, have the designated integration owner add only the `设备资产管理` menu link in `frontend/src/App.js`; preserve all existing links and add the required file/method comment if any logic changes.
- [x] 5.2 Have the same integration owner add only `/device-assets``DeviceAssetView` in `frontend/src/router/index.js`; preserve the enterprise-WeChat route and add the required file/function comment if any logic changes.
- [x] 5.3 Rebase or merge the device branch only after both feature branches are independently verified; resolve no business changes in shared files outside the dedicated integration commit.
## 6. Verification and handoff
- [x] 6.1 Add `DeviceAssetServiceTest.java` coverage for default pagination, every filter, active-name uniqueness, approved-status validation, person validation, all four deletion-reference sources, safe soft deletion, image preservation, replacement, explicit removal, and failed-upload cleanup. Add required comments to every test method and business callback.
- [x] 6.2 Add `DeviceAssetControllerTest.java` coverage for GET pagination, multipart create/update, image validation and controlled image reads, error responses, and hidden `deleteTime`/physical paths. Add required comments to every test method and callback.
- [x] 6.3 Add `frontend/tests/device-asset.spec.js` coverage for the new route/menu, filters, pagination, create/edit, status dropdowns, image type/20 MB checks, browser-sized preview, retained/replaced/removed images, and deletion-protection feedback. Add required comments to every test callback.
- [x] 6.4 Run `openspec validate add-device-asset-management --strict`, backend compile and focused tests, frontend build, device Playwright tests, then existing phone and enterprise-WeChat smoke tests after integration. Analyze any failure before changing code.
- [x] 6.5 Record the implementation file list, route/menu effect, API/DTO/configuration impact, database non-impact, executed verification, and any unexecuted check in the completion handoff.
## Completion handoff (2026-08-03)
### Implementation files
- Backend: `DeviceAssetMultipartConfig`, `DeviceAssetController`, `DeviceAssetExceptionHandler`, five `DeviceAsset*` DTOs, two device exceptions, `DeviceAssetFileStorageService`, and `DeviceAssetService`.
- Backend tests: `DeviceAssetServiceTest`, `DeviceAssetFileStorageServiceTest`, and `DeviceAssetControllerTest`.
- Frontend: `device-api-client.js`, `DeviceAssetView.js`, `device-asset.css`, and `playwright.device.config.js`.
- Test integration: `device-asset.spec.js` plus `authenticated-test.js`; the existing phone, enterprise-WeChat, and legacy-reference specifications now import the shared authenticated test object.
### Product and contract effect
- Route and menu: `#/device-assets` renders Device Asset Management and the sidebar exposes the Device Asset Management entry. The integration preserves the enterprise-WeChat route.
- API and DTOs: `GET/POST/PUT/DELETE /api/device-assets`, company-person lookup, and controlled opaque image reads are available. Multipart saves accept two original JPG/PNG/GIF files, each at most 20 MB; response DTOs omit physical paths and `deleteTime`.
- Configuration: `DeviceAssetMultipartConfig` permits two 20 MB uploads plus form data and `DeviceAssetFileStorageService` defaults to `./uploads/device-assets`.
- Database: no DDL, migration, or direct database operation was performed for device management; the existing `as_asset_device` table and its soft-delete fields are reused.
### Executed verification
- `openspec validate add-device-asset-management --strict` passed.
- `mvn -q test` passed.
- `npm run build` passed. Vite retained its existing third-party PURE-comment and bundle-size warnings.
- `npx playwright test tests/device-asset.spec.js --config=playwright.device.config.js --reporter=list --timeout=30000` passed: 5/5.
- `npx playwright test --config=playwright.device.config.js --reporter=list --timeout=30000` passed: 13/13, including device, phone, enterprise-WeChat, and legacy-reference tests.
### Resolution of the prior test blocker
The newly integrated authentication guard redirected protected asset pages before their business request fixtures were registered. The shared `authenticated-test.js` fixture now returns a deterministic developer session and CSRF preflight response. It changes test setup only; production authentication, permissions, APIs, and asset business behavior remain unchanged.
### Unexecuted checks
None for the approved implementation scope.
## Context
现有 Vue 应用使用 Hash 路由,侧栏直接展示所有资产页;后端使用 Spring Boot、MyBatis-Plus 和 MySQL,尚未引入 Spring Security,也没有任何登录接口。`SystemUserEntity` 已映射 `as_system_user``username``password_hash``role_code``status`,但当前没有 Service、Controller 或测试使用它。
本设计服务于开发者、超级管理员和业务角色。唯一开发者为固定账号 `Jeddy`,它是系统最高权限角色,但账号绝不进入前端账号列表、筛选项、下拉项或接口返回;其初始密码哈希仅由受控数据库操作维护。超级管理员可管理除开发者外的账号但不能修改密码;财务、人事和运营仅访问被分配的页面,且没有账号管理能力。
## Goals / Non-Goals
**Goals:**
-`as_system_user` 作为唯一账号来源,完成可验证的登录、登出、禁用和仅开发者可执行的密码设置/重置闭环。
- 在后端强制执行账号创建边界和页面读写权限,前端仅负责呈现服务端已判定的结果。
- 将逐页权限保存在可扩展的结构中,并在设置页以独立面板勾选“无权限 / 只读 / 编辑”。
- 保证密码从不以明文写入数据库、接口响应、日志或前端持久化存储。
**Non-Goals:**
- 本期不做手机号/邮箱找回、第三方单点登录、角色自定义、数据级(行级)权限或审批流。
- 本期不提供任何账号的自助改密页;不允许前端创建、修改或展示开发者账号;也不改动现有资产业务字段和接口响应结构。
- 本期不把权限配置送入 AI 或聊天链路。
## Decisions
### 1. 固定角色负责“能管理谁”,逐页权限负责“能做什么”
角色固定为 `DEVELOPER``SUPER_ADMIN``FINANCE``HR``OPERATIONS`。服务端的创建规则为:`DEVELOPER` 可创建全部五种角色并设置、重置所有非开发者账号密码;`SUPER_ADMIN` 可创建 `SUPER_ADMIN``FINANCE``HR``OPERATIONS`,但创建时不得提交密码且不能修改或重置密码;其余角色一律拒绝创建与管理账号。所有对开发者账号的查询、详情、编辑和列表 API 都返回“不可见/不可操作”,即使调用者本身是开发者。
每个非开发者账号保存页面权限映射,例如 `{"phone-assets":"EDIT","reference-wecom":"READ"}`。首期页面注册表固定为“总览、域名资料、企微资料、手机号资产、提醒中心”;权限值只能是 `NONE``READ``EDIT``EDIT` 自动包含读取能力。超级管理员固定获得这五页的 `EDIT`;开发者拥有隐式全量 `EDIT`,均不依赖前端配置。新页面默认 `NONE`,必须由开发者明确扩展注册表和权限面板后才能开放。
备选方案是在数据库拆出角色表、权限表和关联表。它更适合大量自定义角色,但本期只有五个固定角色、页面数量有限;先用受校验的 JSON 映射可以减少迁移和管理复杂度。若未来出现自定义角色,再迁移至关联表而不改变前端权限枚举。
### 2. 账号表扩展和兼容迁移
保留既有 `as_system_user` 列,新增:`page_permissions`(JSON 文本,默认 `{}`)、`password_updated_at``auth_version`(默认 `1`)和必要的唯一索引 `uk_as_system_user_username``status` 统一为 `ACTIVE`/`DISABLED`;超级管理员创建的账号以 `DISABLED` 保存,待开发者设置密码后才可启用。
用户已明确授权扩展 `as_system_user`:迁移先检查线上实际列和重复用户名,检查通过后新增列、默认值和索引,并增加“密码哈希变更”数据库触发器。触发器仅在 `password_hash` 实际变化时自动将 `password_updated_at` 写为当前时间、将 `auth_version` 加一;因此受控人工更新 `Jeddy` 的 BCrypt 哈希也会留下时间记录并立即使旧会话失效。固定开发者账号 `Jeddy` 由人工受控写入用户名、角色、状态和 BCrypt 哈希,迁移不得写入任何密码或哈希。旧数据会保留:空 `page_permissions` 解释为 `NONE`,已有角色码需要被映射到新枚举或列入人工处理清单,绝不猜测为高权限。
### 3. 使用短期签名会话令牌和服务端版本失效
采用 Spring Security 的无状态认证过滤器,并使用 HMAC 签名的短期 JWT(默认 8 小时)。令牌只放在 `HttpOnly``Secure`(生产环境)、`SameSite=Lax` Cookie 中;前端不读取、不写入 token。本地跨端口开发使用 `fetch(..., { credentials: 'include' })`,后端 CORS 仅放行已配置的前端来源和凭据。
JWT 载有用户 ID、角色和 `authVersion`。每次访问受保护接口,过滤器同时验证签名、过期时间、用户状态和数据库中的 `auth_version`。禁用和开发者设置/重置密码都会递增 `auth_version`,使旧会话立即失效。写操作采用 Spring Security CSRF Token Cookie + 请求头校验;登录接口仅接受账号密码并有统一的失败响应,避免泄漏“用户名是否存在”。
备选方案是把访问令牌放进 `localStorage``sessionStorage`。它实现更快,但脚本注入时更容易被窃取,故不采用。备选方案二是服务器内存 Session;它依赖单机内存,在重启或多实例扩展时会中断,且不利于立即撤销,故不采用。
### 4. 密码存储和后续改密方案
使用 Spring Security `BCryptPasswordEncoder` 生成 `password_hash`,成本系数由配置控制(初始建议 12)。数据库只存哈希值,盐由 BCrypt 每次编码自动生成;不增加“加密后的明文密码”列,也不把初始密码写进迁移、日志、测试快照或接口响应。
只有开发者能在创建账号、为超级管理员已创建的禁用账号补设密码,或重置非开发者账号密码时输入新密码与确认密码;后端校验 12–72 字符、拒绝与用户名相同及常见弱密码。系统不提供本人改密入口,所有账号均通过开发者操作更新密码哈希和时间、递增 `auth_version` 并使旧会话失效。管理员重置不会回显或再次读取任何密码;`Jeddy` 的密码变更仍由受控数据库操作人工写入 BCrypt 哈希。
未来调整密码规则或 BCrypt 成本时,在开发者重置密码时检测 `passwordEncoder.upgradeEncoding(hash)` 并重写哈希;这让业务账号在下一次受控重置时逐步升级,无需知道旧密码或执行全表重置。
### 5. 路由、菜单和接口三层一致授权
新增 `/login``/settings/users-permissions`。应用启动先读取 `/api/auth/me`,未登录时路由守卫跳转登录页;登录成功后根据服务端返回的 `pagePermissions` 渲染菜单。当当前登录身份为 `Jeddy` 且角色为 `DEVELOPER` 时,前端只在登录成功事件中弹出“🎉 欢迎系统开发者-BOSS:Jeddy 上线”;其他账号绝不显示该提示。设置菜单采用 `Setting` 图标和“账号与权限”文字,仅开发者与超级管理员可见。权限页内有独立“页面权限”抽屉或卡片面板,为首期五个注册页面选择三档权限;开发者角色不会作为任何创建选项或账号行出现。
后端为每个资产 Controller 方法声明所需的页面键和最低权限,统一由权限服务校验。若用户绕过菜单直接打开 Hash 地址,路由守卫显示无权页;若绕过前端调用接口,后端返回统一 403。这避免仅隐藏菜单的“假权限”。
### 工程术语注释表
| 术语 | 白话解释 | 使用位置 | 不这样做的风险 | 示例 |
| --- | --- | --- | --- | --- |
| BCrypt 密码哈希 | 把密码变成不可逆校验值,验证时只比对,不会还原原密码。这里用于保护 `password_hash`;若存明文或可逆密文,数据库泄露会直接暴露所有账号。 | 创建、登录、改密、重置密码 | 密码泄露和横向撞库风险显著增加。 | `encoder.matches(输入密码, passwordHash)` |
| JWT(签名登录凭证) | 服务端签名的一张短期“登录票”,浏览器只带回票据,服务端验证票据是否真实和过期。这里用于无状态登录;若没有签名校验,攻击者可伪造身份。 | 认证过滤器、登录、登出 | 未授权用户可能伪造高权限身份。 | Cookie 中的短期访问凭证 |
| `auth_version`(登录票据版本号) | 用户每次改密、重置或禁用时递增的数字;旧票据版本不一致即作废。这里用于立即踢出旧会话;若没有它,改密后被盗会话仍可使用到过期。 | `as_system_user`、认证过滤器 | 密码重置无法及时阻断旧设备。 | 令牌版本 2,数据库版本 3 → 401 |
| CSRF(借用登录态的伪造请求防护) | 防止别的网站借浏览器已有登录 Cookie 偷发写操作。这里用请求头令牌校验;不做会导致用户访问恶意页面时可能被悄悄改数据。 | 所有 POST/PUT/DELETE | 跨站页面可能冒用用户登录态提交写操作。 | `X-XSRF-TOKEN` 与 Cookie 值匹配 |
| 迁移/回滚 | 迁移是按步骤调整数据库;回滚是失败时恢复到原结构和可用版本。这里必须先检查旧数据再加列;否则可能因重复用户名或空权限锁死用户。 | 数据库部署 | 数据损坏或系统无法登录。 | 先备份、加可空列、回填、建索引 |
## Risks / Trade-offs
- [线上 `as_system_user` 实际结构与实体不一致] → 部署前执行只读列/索引/数据质量检查;迁移脚本分阶段执行并保留回滚脚本,检查失败则不写库。
- [固定开发者 `Jeddy` 缺失、哈希不正确或被禁用] → 部署前只读核对该账号的用户名、角色、状态和 BCrypt 哈希格式;运行时 UI 不提供开发者管理入口,紧急恢复仅走受控数据库维护流程。
- [人工直接更新哈希未撤销旧会话] → 数据库触发器仅在 `password_hash` 实际变化时更新时间和 `auth_version`;部署后用测试账号验证手工更新后旧 Cookie 返回 401。
- [权限配置遗漏新页面] → 页面注册表要求每个路由同时声明菜单元数据和后端权限键;未知页面默认 `NONE`,并有测试验证。
- [Cookie 跨域配置错误导致本地无法登录] → 明确配置本地允许源、`allowCredentials(true)``credentials: include` 与 Playwright 登录 smoke;生产环境通过同域反向代理优先消除跨域。
- [密码/令牌误写日志] → 对认证 DTO、异常处理和测试日志做脱敏;禁止 `toString()`、接口响应、审计记录包含密码或 Cookie。
- [管理员误禁用唯一可管理账号] → 禁止禁用最后一个可用开发者或超级管理员;操作前返回可读错误。
## Migration Plan
1. 只读核对生产数据库的 `as_system_user` 列、索引、重复用户名、角色码和状态值,并备份该表。
2. 发布包含安全配置但尚未强制保护现有资产接口的兼容版本,验证认证与权限接口;随后执行可回滚的列新增和索引迁移。
3. 由受控数据库操作写入或核对固定开发者 `Jeddy` 的用户名、`DEVELOPER` 角色、启用状态和 BCrypt 哈希;明文密码与哈希均不进入仓库、迁移文件、接口响应或日志。
4. 发布前端登录页、设置入口和权限页;确认管理员能创建业务账号和分配最小权限。
5. 启用 `/api/**` 的认证与权限拦截,运行资产接口、登录、越权、开发者重置密码和禁用回归。
6. 若失败,先关闭新安全强制开关、恢复上一版应用;数据库只新增且保留兼容列时无需删数据。若已启用索引或回填失败,使用迁移脚本按相反顺序恢复,并保留备份供人工核对。
## Open Questions
- 已确认:固定开发者账号为 `Jeddy`,其密码哈希由受控数据库操作人工维护;所有密码仅由开发者创建、重置和修改。
- 已确认:超级管理员固定拥有首期五个页面的全部编辑权限。
- 已确认:首期权限面板覆盖总览、域名资料、企微资料、手机号资产和提醒中心;查询/查看为 `READ`,新增、编辑、删除、导入/导出为 `EDIT`
- 已确认:允许在 `as_system_user` 新增 `page_permissions``password_updated_at``auth_version` 和用户名唯一索引;登录会话默认 8 小时,用户名仅允许字母、数字、下划线,超级管理员创建账号默认 `DISABLED`
## Why
当前资产后台已经预留 `as_system_user` 用户表映射,但没有登录校验、账号管理或页面权限控制;任何访问者都能直接进入现有路由和接口。现在需要让开发者与超级管理员能够安全创建和维护业务账号,并让财务、人事、运营按被授予的页面读写权限使用系统。
## What Changes
- 新增账号密码登录、登录态校验与登出能力,未登录用户只能访问登录页和登录接口。
- 接入现有 `as_system_user` 作为唯一账号表:保存用户名、不可逆密码哈希、角色、启停状态和逐页权限配置;不保存明文密码。
- 定义五种固定角色:开发者、超级管理员、财务、人事、运营;唯一开发者固定账号为 `Jeddy`,且不出现在任何前端列表或下拉选项中。
- 新增账号与权限管理能力:开发者可创建所有角色并管理全部密码;超级管理员只能创建财务、人事、运营与其他超级管理员,且不能创建或修改任何密码;其他角色不能创建账号。
- 新增独立的“设置 / 账号与权限”菜单项及路由页,以图标加文字呈现;页面包含账号列表、创建/编辑账号、启停和逐页面板的“只读/编辑/无权限”勾选。
- 将菜单展示、前端路由守卫与后端接口授权统一建立在同一份服务端权限结果上,避免只隐藏前端入口而仍可调用接口。
- 所有密码创建、重置和修改均只由开发者执行;`Jeddy` 的初始密码哈希由受控数据库操作人工写入。登录成功后仅对 `Jeddy` 弹出“🎉 欢迎系统开发者-BOSS:Jeddy 上线”,其他账号不展示欢迎提示。
## Capabilities
### New Capabilities
- `system-user-authentication`: 以 `as_system_user` 为账号来源的安全登录、会话校验、登出和开发者专属登录欢迎提示能力。
- `system-user-administration`: 按创建者角色限制的账号查询、创建、编辑、启停与仅开发者可执行的密码重置能力,并隐藏开发者账号。
- `page-permission-management`: 服务端维护的逐页“无权限/只读/编辑”配置,以及设置入口、菜单过滤、路由和接口授权能力。
### Modified Capabilities
- 无现有 OpenSpec 主规格需要修改;现有资产接口的实现将增加统一授权校验,但其业务响应字段和资产数据规则不改变。
## Impact
- 后端:新增认证/账号/权限模块、Spring Security 依赖与安全配置、`as_system_user` 查询和写入逻辑、现有 `/api/**` 的授权拦截,以及相应单元和接口测试。
- 数据库:复用 `as_system_user` 的账号字段;需先核对线上表的实际列和索引,必要时通过可回滚迁移补充权限 JSON、密码更新时间和版本字段。不会存储明文密码。
- 前端:新增登录页、认证状态、请求鉴权头、路由守卫、设置菜单和独立账号与权限管理页;`Jeddy` 登录后仅在当前登录成功流程展示专属欢迎提示;现有资产页根据服务端返回的页面权限显示或禁用编辑操作。
- 运维:新增仅部署环境持有的令牌签名密钥;`Jeddy` 的初始密码哈希由受控数据库操作维护,密钥、明文密码和哈希均不提交到仓库。
## ADDED Requirements
### Requirement: 每个非开发者账号拥有逐页三档权限
系统 SHALL 为每个非开发者账号保存首期五个注册页面(总览、域名资料、企微资料、手机号资产、提醒中心)的权限值,且值 MUST 仅为 `NONE``READ``EDIT``EDIT` MUST 包含 `READ`;未配置和未知页面 MUST 按 `NONE` 处理。超级管理员对上述五页固定为 `EDIT`
#### Scenario: 管理员配置页面权限
- **WHEN** 有权管理员在独立页面权限面板为可管理账号勾选各页面的只读或编辑权限并保存
- **THEN** 系统校验页面键和值后持久化权限映射并返回最新有效权限
#### Scenario: 新页面没有被配置
- **WHEN** 系统发布了一个尚未存在于账号权限映射中的页面
- **THEN** 非开发者用户不能访问该页面,直到有权管理员明确授予权限
### Requirement: 菜单、路由和接口必须使用服务端权限结论
系统 MUST 从当前用户接口取得有效页面权限来决定菜单可见性和路由访问,并 MUST 在后端对每个页面关联接口执行同等或更严格的最低权限校验。
#### Scenario: 只读用户进入资产页
- **WHEN** 用户对手机号资产页只有 `READ` 权限
- **THEN** 菜单和路由允许查看列表,但新增、编辑和删除操作不显示或禁用,且后端拒绝对应写请求
#### Scenario: 用户绕过菜单访问无权限地址
- **WHEN** 用户手动输入没有权限的 Hash 路由或直接请求对应接口
- **THEN** 前端显示无权页且后端返回 403,不泄露业务数据
### Requirement: 设置入口仅向账户管理员显示
系统 SHALL 在主菜单的“设置”分组内以图标和文字显示“账号与权限”入口,且 MUST 只向开发者和超级管理员显示并允许访问。
#### Scenario: 超级管理员打开设置
- **WHEN** 超级管理员登录系统
- **THEN** 主菜单显示带设置图标的账号与权限入口并可跳转至独立管理路由
#### Scenario: 运营用户查看菜单
- **WHEN** 运营用户登录系统
- **THEN** 主菜单不显示账号与权限入口且直接访问该路由会被拒绝
## ADDED Requirements
### Requirement: 开发者和超级管理员按边界创建账号
系统 MUST 只允许开发者和超级管理员创建账号。开发者 SHALL 能创建所有角色并设置非开发者账号密码;超级管理员 SHALL 只能创建超级管理员、财务、人事和运营账号,且新账号 MUST 以 `DISABLED` 状态等待开发者设置密码;财务、人事和运营 MUST 被拒绝创建账号。
#### Scenario: 开发者创建任意角色
- **WHEN** 开发者提交合法的新账号、角色与密码
- **THEN** 系统创建该账号并返回不含密码的账号资料
#### Scenario: 超级管理员创建业务账号
- **WHEN** 超级管理员提交财务、人事、运营或超级管理员账号且请求不含密码
- **THEN** 系统创建 `DISABLED` 账号并等待开发者后续设置密码和启用
#### Scenario: 超级管理员尝试创建开发者
- **WHEN** 超级管理员提交角色为 `DEVELOPER` 的创建请求
- **THEN** 系统返回 403 且不创建账号
#### Scenario: 业务角色尝试创建账号
- **WHEN** 财务、人事或运营用户调用账号创建接口
- **THEN** 系统返回 403 且不写入 `as_system_user`
### Requirement: 开发者账号不得在前端出现
系统 MUST 从所有面向浏览器的账号列表、查询结果、详情、筛选项和角色选项中排除 `DEVELOPER` 账号;任何浏览器请求开发者账号 ID 的管理接口 MUST 返回不可见或无权结果。
#### Scenario: 开发者查看账号列表
- **WHEN** 开发者打开账号与权限页面
- **THEN** 列表中不出现任何开发者账号,也不提供开发者角色的创建或编辑选项
### Requirement: 管理员可维护可管理账号但密码仅开发者可改
开发者或超级管理员 SHALL 能查询、编辑、启用和禁用其可管理账号;仅开发者 SHALL 能设置或重置非开发者账号密码。系统 MUST 禁止禁用最后一个可用的开发者或超级管理员账号。
#### Scenario: 禁用业务账号
- **WHEN** 有权管理员禁用财务、人事或运营账号
- **THEN** 系统将状态改为 `DISABLED`、撤销该账号的现有登录态并使其无法再次登录
#### Scenario: 尝试禁用最后一个管理账号
- **WHEN** 操作会导致系统不存在可用开发者或超级管理员
- **THEN** 系统拒绝操作并说明需要保留至少一个可用管理账号
## ADDED Requirements
### Requirement: 用户必须通过受保护登录进入系统
系统 SHALL 提供账号密码登录、当前登录人查询和登出接口,并 SHALL 拒绝未认证用户访问任何受保护的 `/api/**` 接口。认证失败响应 MUST 不透露用户名是否存在、账号是否被禁用或密码是否错误。
#### Scenario: 有效账号成功登录
- **WHEN** 状态为 `ACTIVE` 的用户提交正确用户名和密码
- **THEN** 系统返回当前用户的安全资料与页面权限,并写入短期 HttpOnly 登录 Cookie
#### Scenario: 无效凭据无法判断原因
- **WHEN** 用户名不存在、密码错误或账号被禁用
- **THEN** 系统返回相同的认证失败响应且不创建登录 Cookie
#### Scenario: 未登录请求资产接口
- **WHEN** 浏览器未携带有效登录凭证而请求任一受保护资产接口
- **THEN** 系统返回 401 且不返回资产数据
### Requirement: 密码仅以不可逆哈希保存且仅开发者可维护
系统 MUST 使用 BCrypt 保存密码哈希,且 MUST NOT 在数据库、API 响应、日志、审计文本或前端持久化存储中保存或回显明文密码。只有 `DEVELOPER` 可以通过系统接口设置或重置非开发者账号密码;固定开发者 `Jeddy` 的密码哈希只允许通过受控数据库操作维护。系统 MUST NOT 提供任何账号的自助改密接口或页面。
#### Scenario: 开发者设置业务账号密码
- **WHEN** 开发者提交可管理非开发者账号的新密码和确认密码
- **THEN** `as_system_user.password_hash` 保存 BCrypt 哈希且任何响应均不包含提交的密码
#### Scenario: 超级管理员尝试提交密码
- **WHEN** 超级管理员在创建或编辑账号请求中提交密码字段,或调用密码重置接口
- **THEN** 系统返回 403 且不写入或修改任何密码哈希
#### Scenario: 用户尝试自助改密
- **WHEN** 任意已登录用户访问自助改密路由或调用自助改密接口
- **THEN** 系统不提供该路由或接口,并拒绝对应请求
### Requirement: 密码重置和禁用必须即时撤销旧会话
系统 MUST 在开发者设置/重置密码、受控直接更新数据库哈希或禁用账号时递增 `auth_version`,并在每个受保护请求验证令牌版本和账号状态。数据库 MUST 提供仅在 `password_hash` 实际变化时自动更新 `password_updated_at` 并递增 `auth_version` 的触发器。
#### Scenario: 已重置密码的旧浏览器继续访问
- **WHEN** 开发者重置业务账号密码后,该账号先前浏览器携带旧登录 Cookie 请求接口
- **THEN** 系统拒绝该请求并要求重新登录
#### Scenario: 手工更新 Jeddy 的密码哈希
- **WHEN** 受控数据库操作将 `Jeddy``password_hash` 更新为新的 BCrypt 哈希
- **THEN** 数据库触发器自动更新 `password_updated_at`、递增 `auth_version`,且 `Jeddy` 的旧登录 Cookie 随后被拒绝
### Requirement: 开发者登录显示专属欢迎提示
系统 SHALL 仅在固定账号 `Jeddy``DEVELOPER` 角色成功登录的当次前端登录流程中显示“🎉 欢迎系统开发者-BOSS:Jeddy 上线”。系统 MUST NOT 为其他账号显示该提示。
#### Scenario: Jeddy 登录成功
- **WHEN** `Jeddy` 使用有效开发者凭据完成登录
- **THEN** 前端在登录成功后显示一次专属欢迎提示并进入系统
#### Scenario: 其他账号登录成功
- **WHEN** 超级管理员、财务、人事或运营账号完成登录
- **THEN** 前端进入系统且不显示开发者专属欢迎提示
## 0. 范围文件用途注释与注释门禁
| 文件 | 文件用途 | 核心职责 | 上下游关联文件 | 主要关联逻辑 | 风险点 |
| --- | --- | --- | --- | --- | --- |
| `backend/pom.xml` | 后端依赖清单 | 引入 Security、JWT、迁移测试所需依赖 | `SecurityConfig.java`、认证服务 | Maven → Spring Security/密码编码器/JWT | 版本不兼容或引入脆弱依赖 |
| `backend/src/main/resources/db/migration/V*_system_user_auth.sql` | 数据库迁移脚本 | 以可检查、可回滚的步骤扩展 `as_system_user`,创建哈希变更触发器但不写入 `Jeddy` 的哈希 | `SystemUserEntity.java`、部署文档 | 迁移 → 表列/索引/触发器 → 认证服务 | 线上表结构、重复用户名、默认权限或触发器逻辑错误 |
| `backend/src/main/java/com/xyw/console/asset/entity/SystemUserEntity.java` | 用户表实体映射 | 读写账号、哈希、状态、权限和版本字段 | Mapper、用户服务 | Controller → Service → Entity/Mapper → `as_system_user` | 字段名不一致或意外序列化密码哈希 |
| `backend/src/main/java/com/xyw/console/config/SecurityConfig.java` | Spring Security 总开关 | 配置认证过滤器、Cookie、CSRF、公开路由和 401/403 | `AuthTokenFilter.java``WebConfig.java` | 浏览器 → Filter Chain → Controller | 保护不足或 CORS/CSRF 阻断合法请求 |
| `backend/src/main/java/com/xyw/console/auth/AuthTokenFilter.java` | 每请求身份验证 | 校验 Cookie 内令牌、账号状态和版本 | `AuthTokenService.java``SystemUserMapper.java` | Request → Cookie → 用户查询 → SecurityContext | 旧令牌未失效或错误放行 |
| `backend/src/main/java/com/xyw/console/auth/AuthTokenService.java` | 登录凭证生成与验证 | 签发、解析和清除短期令牌 Cookie | Auth 服务、过滤器 | 登录/改密 → JWT → HttpOnly Cookie → 过滤器 | 密钥泄露、过期策略错误 |
| `backend/src/main/java/com/xyw/console/auth/AuthService.java` | 认证业务规则 | 登录、当前用户与登出 | 用户服务、令牌服务、Mapper | AuthController → AuthService → 密码/令牌/用户表 | 密码明文进入日志或无法撤销会话 |
| `backend/src/main/java/com/xyw/console/auth/SystemUserAdminService.java` | 账号管理规则 | 角色创建边界、开发者隐藏、启停、仅开发者重置密码与权限保存 | Mapper、权限服务 | UserAdminController → Service → `as_system_user` | 超级管理员越权创建开发者或修改密码 |
| `backend/src/main/java/com/xyw/console/auth/PagePermissionService.java` | 页面权限判定 | 校验注册页面、三档权限并向接口提供最低权限校验 | 认证服务、资产 Controller | 当前用户 → 权限映射 → READ/EDIT 决策 | 新页面默认被意外授权 |
| `backend/src/main/java/com/xyw/console/auth/dto/*.java` | 认证与账号接口契约 | 校验请求、限制响应字段、避免回传密码 | 两个 Controller、前端 API 客户端 | 页面表单 → DTO → Service → 安全响应 | DTO 误含哈希或明文 |
| `backend/src/main/java/com/xyw/console/auth/AuthController.java` | 登录/本人接口入口 | 暴露登录、当前用户、登出、改密 API | `AuthService.java` | Vue → `/api/auth/*` → 认证服务 | 认证错误信息泄漏 |
| `backend/src/main/java/com/xyw/console/auth/SystemUserAdminController.java` | 账号管理接口入口 | 暴露列表、创建、编辑、启停、重置、权限 API | 管理服务、权限服务 | 权限页 → `/api/system-users/*` → 用户表 | 仅前端限制导致绕过 |
| `backend/src/main/java/com/xyw/console/asset/controller/PhoneAssetController.java``WecomAccountController.java` | 现有资产业务 API | 为读/写方法接入最低页面权限 | `PagePermissionService.java` | 资产页 API → 页面权限 → 原业务服务 | 破坏原有成功响应 |
| `frontend/src/main.js` | Vue 应用入口 | 注入认证状态和启动身份恢复 | `App.js`、router、认证模块 | 浏览器启动 → `/api/auth/me` → 路由 | 启动闪屏或未认证短暂显示业务页 |
| `frontend/src/router/index.js` | 前端路由表 | 注册登录、改密、无权、账号权限页及守卫 | `auth-store.js`、页面组件 | URL → 守卫 → 认证/权限 → 组件 | 手工地址绕过菜单 |
| `frontend/src/App.js` | 主壳和侧栏 | 用服务端权限过滤菜单,增加设置图标入口 | 路由、认证状态 | 权限结果 → 菜单 → RouterLink | 开发者入口被展示 |
| `frontend/src/modules/auth/auth-api-client.js``auth-store.js` | 认证请求和内存状态 | 使用 Cookie 凭证请求、保存安全用户资料、统一 401 和开发者登录欢迎条件 | 后端 AuthController、路由 | 页面事件 → API → Cookie/当前用户 → UI | token 被写入浏览器持久存储或错误展示专属提示 |
| `frontend/src/modules/auth/LoginView.js` | 登录页 | 收集密码、不回显、不持久化,并仅为 `Jeddy` 显示专属欢迎提示 | 认证客户端、router | 表单 → Auth API → 成功提示/跳转 | 失败信息、密码显示或误提示不当 |
| `frontend/src/modules/settings/UserPermissionView.js` | 独立账号和权限页 | 管理账号与逐页面板,开发者永不展示 | 用户管理 API、Element Plus | 管理员 → 表单/权限面板 → API → 列表 | 错误角色选项或权限值 |
| `frontend/src/modules/*/*-api-client.js`、资产视图 | 现有业务 API 与页面 | 自动携带 Cookie,并按 READ/EDIT 控制按钮 | 后端资产 Controller、认证状态 | 权限 → 控件状态 → 接口调用 | 只读用户仍发送写请求 |
| `frontend/src/styles/app.css` | 页面样式 | 登录页、设置菜单、权限面板和无权页的局部样式 | 新增 Vue 视图 | 组件 class → CSS | 全局样式污染资产页 |
| `backend/src/test/**/auth/*.java``frontend/tests/auth-permission.spec.js` | 回归测试 | 覆盖登录、开发者专属提示、角色边界、开发者隐藏和 READ/EDIT/NONE | 所有认证/权限模块 | 测试 → API/UI → 断言 | 仅测前端不测后端越权 |
实施时,表中每个新增或改动的方法、对象方法、箭头函数以及含业务逻辑的回调 MUST 增加中文新手注释,且每条注释必须写清:代码作用(白话)、关联文件、关联逻辑(调用链/消息链/数据流)。不得只写“登录”“查询”等重复代码字面意思的注释。
| 方法/回调范围 | 必须说明的关联逻辑 |
| --- | --- |
| `SecurityConfig.securityFilterChain``passwordEncoder``corsConfigurationSource` | 浏览器请求 → Security Filter Chain → Controller;为何 Cookie/CSRF/CORS 必须同时配置 |
| `AuthTokenFilter.shouldNotFilter``doFilterInternal` | HTTP 请求 → Cookie → JWT 解析 → 用户状态/版本核验 → SecurityContext |
| `AuthTokenService.issue``parseAndValidate``clearCookie` | 登录 → 签名 Cookie;请求 → 验签;登出 → 清 Cookie |
| `AuthService.login``me``logout` | AuthController → 密码校验/用户表/令牌服务 → 安全响应;登录成功如何传递 `Jeddy` 专属提示判定所需的安全身份 |
| `SystemUserAdminService.listVisibleUsers``createUser``updateUser``resetPassword``changeStatus``assertManageableRole` | 管理员身份 → 角色边界/开发者隐藏 → `as_system_user` → 会话撤销 |
| `PagePermissionService.validatePermissions``effectivePermissions``requirePageAccess` | 权限面板 → JSON 映射 → READ/EDIT 判定 → 资产 Controller |
| `AuthController``SystemUserAdminController` 所有接口方法 | 浏览器 DTO → Service → 统一 `ApiResponse`;响应为何绝不含密码/哈希 |
| `PhoneAssetController``WecomAccountController` 被改动的读写方法 | 页面权限校验 → 保留原业务 Service 和原响应结构 |
| `auth-api-client``request/login/me/logout``auth-store``bootstrap/setCurrentUser/clearCurrentUser` | Vue 页面 → `credentials: include` 请求 → Cookie 会话 → 路由/菜单状态;为何只有登录成功事件可触发 `Jeddy` 提示 |
| `router``beforeEach` 守卫和 `App.js` 的菜单生成回调 | Hash URL/当前权限 → 允许、跳转登录或无权页;菜单隐藏不是后端授权替代品 |
| `LoginView``UserPermissionView` 的提交、加载、权限转换和确认回调 | 表单 → DTO → API → 成功刷新或不泄漏原因的错误反馈;密码字段为何仅开发者可见和提交 |
## 1. 迁移前核对与安全基础
- [ ] 1.1 对目标环境的 `as_system_user` 执行只读结构与数据质量核对:列、索引、重复用户名、角色码、状态值和现有数据量;把结果记录为部署前置条件,不写入数据库。
- [ ] 1.2 已获数据库结构变更批准:只读核对通过后,新增可审查的版本化迁移和反向回滚说明;保留原列,增加 `page_permissions``password_updated_at``auth_version`,建立用户名唯一索引,并创建仅在 `password_hash` 实际变化时自动更新时间、递增会话版本的触发器;迁移不得写入 `Jeddy` 的密码或哈希。
- [ ] 1.3 增加 Spring Security、JWT 与迁移工具依赖,固定兼容版本,并通过依赖检查确认不存在已知高危漏洞。
- [ ] 1.4 新增安全配置、密码编码器、Cookie/CSRF/CORS 策略和认证过滤器;仅公开登录、当前身份初始化所需接口与静态前端资源,其他 API 默认拒绝未认证访问。
- [ ] 1.5 增加部署前只读核对清单:固定开发者 `Jeddy` 必须为 `DEVELOPER` 且启用,其 BCrypt 哈希只由受控数据库操作人工写入;严禁将明文密码、哈希、JWT 密钥或数据库凭据写入 Git、迁移脚本或日志。
## 2. 后端认证与密码生命周期
- [ ] 2.1 新建认证请求/响应 DTO,验证用户名仅含字母、数字、下划线及密码边界,并在序列化和异常处理路径排除密码、哈希、Cookie 和密钥。
- [ ] 2.2 实现 BCrypt 密码校验与升级编码检测;密码设置/重置仅由开发者账号服务复用,且不创建任何自助改密接口或页面。
- [ ] 2.3 实现令牌签发、验签、过期、清除和 `auth_version` 校验;开发者重置密码、受控人工更新哈希和禁用账号必须立即使旧会话失效。
- [ ] 2.4 实现登录、当前用户和登出 API;失败响应保持一致,防止账户枚举;当前用户安全资料仅提供前端判断 `Jeddy` 专属欢迎提示所需的用户名与角色。
- [ ] 2.5 为认证接口添加单元/集成测试:正确登录、错误凭据统一响应、禁用账号、未登录 401、过期令牌、开发者重置后旧会话失效、手工更新 `Jeddy` 哈希后触发器更新时间并使旧会话失效、无自助改密端点和 CSRF 写操作。
## 3. 后端账号与逐页权限
- [ ] 3.1 扩展 `SystemUserEntity` 与 Mapper 查询,显式映射新增字段并为所有用户管理查询恒定排除 `DEVELOPER`,避免直接 `selectById` 泄露。
- [ ] 3.2 建立固定角色、首期五个注册页面键(总览、域名资料、企微资料、手机号资产、提醒中心)和 `NONE`/`READ`/`EDIT` 枚举;未知角色、未知页面和缺失权限全部按拒绝处理。
- [ ] 3.3 实现页面权限服务和统一 Controller 权限检查;开发者全量编辑、超级管理员全量编辑、其他角色仅使用已保存的逐页权限。
- [ ] 3.4 实现账号列表、创建、编辑、启停、仅开发者可用的密码设置/重置与权限保存 API,强制开发者/超级管理员创建边界:超级管理员新建账号必须无密码且默认禁用;阻止禁用最后一个可用管理账号。
- [ ] 3.5 为手机号和企微资产的 GET/POST/PUT/DELETE 接入相应页面的 READ/EDIT 校验,保持其原有 DTO、成功响应和业务服务调用不变。
- [ ] 3.6 为账号和权限 API 添加测试:开发者隐藏、开发者可创建全部、超级管理员不能创建开发者或修改密码、业务角色 403、只读用户读成功写失败、无权限用户 403、新页面默认拒绝。
## 4. 前端认证、路由与设置入口
- [ ] 4.1 新增认证 API 客户端和内存认证状态;所有请求使用 `credentials: 'include'`,统一处理 401 并禁止任何 token/password 写入 localStorage、sessionStorage 或日志。
- [ ] 4.2 新增登录页和无权页;登录失败使用不暴露账号状态的提示,且只有 `Jeddy` 以开发者角色登录成功的当次显示“🎉 欢迎系统开发者-BOSS:Jeddy 上线”。
- [ ] 4.3 扩展路由:注册 `/login`、无权和 `/settings/users-permissions`,并在全局守卫中根据 `/api/auth/me` 的服务端权限结果决定跳转;不注册自助改密路由。
- [ ] 4.4 改造主侧栏:按有效权限展示业务菜单,在“设置”分组新增图标加“账号与权限”文字入口;该入口仅开发者与超级管理员可见。
- [ ] 4.5 新增账号与权限独立路由页:列表中绝不展示开发者;创建和编辑表单按当前管理员身份过滤角色选项和密码输入(仅开发者可填);逐页面板仅显示首期五个页面,并以单选/互斥勾选保证每页仅有无权限、只读、编辑之一。
- [ ] 4.6 让手机号和企微页面依据当前权限禁用或隐藏写操作,并保留后端 403 的错误提示;不以 UI 隐藏替代接口校验。
- [ ] 4.7 为新页面添加局部样式、键盘可达标签、加载/空态/403 状态,并在窄屏下验证菜单与权限面板可用。
## 5. 接口契约、测试和验收
- [ ] 5.1 编写或更新 OpenAPI/Apifox 可导入文档,分为“认证”和“账号与权限”两组,至少包含最小请求、成功响应、401/403/校验失败示例、错误码和前端调用点。
- [ ] 5.2 为后端增加 API 集成测试,为前端增加 Playwright 测试:登录、仅 `Jeddy` 的专属欢迎提示、菜单过滤、路由守卫、开发者隐藏、角色创建边界、超级管理员无密码权限、只读禁写、无权限 403、开发者重置后旧会话失效。
- [ ] 5.3 使用本地 `playwright_local` 执行登录与权限 smoke;不得对线上环境进行保存、提交、删除、密码重置或其他写操作。
- [ ] 5.4 执行 Maven 测试、前端构建/测试和最小 Apifox 回归;为每项输出 `[PASS]`/`[FAIL]`,失败退出码非 0,并把测试证据记入验收记录。
- [ ] 5.5 完成部署前检查:配置 JWT 密钥、Cookie Secure 策略、允许来源、数据库备份、首个开发者账号交接;验证密钥和 `.env` 均未被 Git 追踪。
- [ ] 5.6 输出最终新手流程图,覆盖登录成功/失败、`Jeddy` 专属欢迎提示、菜单/路由/接口三层权限、只读/编辑/无权限、开发者改密或禁用后的会话失效和迁移失败回滚。
## Context
当前手机号资产页面的筛选栏、列表列和新增/编辑表单直接展示后端字段的旧业务名称:`cardType` 显示为“卡类型”,`realNameOwner` 显示为“实名归属”,`managementType` 显示为“管理方式”,`disposalStatus` 显示为“处置状态”,`deviceId` 显示为“关联设备 ID”。
从已配置的选项可知,`cardType` 实际保存的是运营商;“正常使用、闲置、停机、已注销”描述的是当前状态而非处置动作;`deviceId` 仅保存数值 ID,尚未验证或读取设备名称。本次仅改用户可见文本和表单帮助说明,不能改动接口键、DTO 或数据库列,以保护已有记录和调用方兼容。
## Goals / Non-Goals
**Goals:**
- 让每个字段名称对应一个独立且可理解的业务维度:运营商、实名登记、管理模式、使用状态和设备关联。
- 在列表与表单中使用同一套名称,并在表单中说明容易误解字段的录入含义。
- 维持现有请求体、响应体与表结构完全不变。
**Non-Goals:**
- 不重命名 JavaScript、接口、DTO 或数据库字段。
- 不调整下拉选项、校验规则、筛选条件、数据迁移或历史记录。
- 不接入设备选择器、设备名称回显或设备存在性校验。
## Decisions
### 1. 保持内部键不变,只替换展示名称
展示名称与接口字段分离:页面继续绑定 `cardType``realNameOwner``managementType``disposalStatus``deviceId`,但将标签分别显示为“运营商、实名主体、管理模式、使用状态、关联设备(ID)”。
- 术语(白话解释):接口字段是浏览器和后端交换数据时使用的固定键名;展示名称是页面上给人看的文字。这里要分开,是因为后端键名已被现有数据和代码使用,而人的理解需要更准确的词。若直接改接口字段,会让已有请求、测试或数据映射失效。
- 备选方案:同时改接口和数据库字段。未采用,因为本次问题是业务文案歧义,不值得引入跨前后端及数据迁移风险。
### 2. 以现有枚举值反推正确业务名称
“移动、联通、电信、广电、虚拟运营商”统一称为“运营商”;“自有、租用、代运营”统一称为“管理模式”;“正常使用、闲置、停机、已注销”统一称为“使用状态”。“实名主体”用于记录与号码实名登记一致的个人或单位名称。
- 术语(白话解释):枚举值是下拉框中预先限定的一组可选值。这里用现有选项判断字段含义,能够避免名称和实际可选值不一致;否则用户仍可能把“卡类型”理解成实体 SIM/eSIM 类型,把“处置状态”理解成审批结果。
- 备选方案:继续保留旧名称并只增加提示。未采用,因为表格中的旧列名仍会造成长期误读。
### 3. 明确设备字段当前只记录 ID
页面标签使用“关联设备(ID)”,表单帮助文字说明“填写设备资产记录的数字 ID;当前不校验是否存在”。
- 术语(白话解释):ID 是系统给一条记录的唯一编号。这里明确显示 ID,是为了避免用户误以为可以输入设备名称;不说明会导致保存后找不到关联设备的预期落差。
- 备选方案:改为设备下拉选择。未采用,因为需要设备查询接口和存在性校验,超出单纯字段释义的范围。
## Risks / Trade-offs
- [旧标签可能已经被少量用户记住] → 在表单帮助文字中保留可理解的定义,不改变已存数据和值。
- [“实名主体”可能被理解为仅企业主体] → 帮助文字明确“个人或单位名称”,并以测试覆盖标签和说明。
- [测试通过文字定位页面元素] → 更新页面测试中的可访问名称,避免真实页面文案变更后测试产生误报。
## Migration Plan
1. 更新列表列名、表单标签、占位文字与帮助说明,并同步更新对应页面测试。
2. 在本地打开 `http://localhost:5173/asset/#/phone-assets`,核对列表、新增和编辑弹窗的名称一致性。
3. 回滚时仅还原前端展示文本和测试断言;不存在数据、接口或数据库迁移。
## Open Questions
- 无。当前字段的选项和值足以确定本次仅展示层的命名修正。
## Why
手机号资产页当前的“卡类型、实名归属、管理方式、处置状态、关联设备 ID”等名称没有明确区分运营商、实名登记、资产管理和当前使用状态。录入人员容易根据字面误填,后续筛选与资产盘点也难以得到一致的数据。
现在页面、接口字段和数据表已经形成可用闭环,因此应先只校正前端的业务名称与填写提示,消除理解偏差,同时保持已上线的字段名和数据兼容。
## What Changes
-`cardType` 的展示名称从“卡类型”调整为“运营商”,并明确其值用于记录移动、联通、电信、广电或虚拟运营商。
-`realNameOwner` 的展示名称调整为“实名主体”,说明其记录与该号码实名登记一致的个人或单位名称。
-`managementType` 的展示名称调整为“管理模式”,使“自有、租用、代运营”作为同一管理维度的选项。
-`disposalStatus` 的展示名称调整为“使用状态”,使“正常使用、闲置、停机、已注销”表达资产当前状态而非处理动作。
-`deviceId` 的展示名称调整为“关联设备(ID)”,并在字段旁说明当前只保存设备记录的 ID;本次不新增设备校验或选择器。
- 在新增和编辑表单中补充简短帮助说明;列表列名与表单标签保持一致。
## Capabilities
### New Capabilities
- 无。
### Modified Capabilities
- `phone-asset-workspace`: 统一手机号资产列表和表单的业务字段名称,并为易混淆字段提供填写含义。
## Impact
- 受影响前端文件为 `frontend/src/modules/phone/PhoneAssetView.js` 与其页面测试 `frontend/tests/phone-asset.spec.js`
- 不修改 `/api/phone-assets` 请求或响应字段、DTO、数据库表、已有资产记录、路由、权限或依赖。
- 页面内部和接口继续使用现有字段名 `cardType``realNameOwner``managementType``disposalStatus``deviceId`,仅改变用户可见名称与辅助文案,因此不产生接口兼容风险。
## MODIFIED Requirements
### Requirement: Use confirmed form controls
The workspace SHALL restrict the phone input to 11 digits after trimming whitespace and removing a leading `+86`. It SHALL display `cardType` as “运营商”, `realNameOwner` as “实名主体”, `managementType` as “管理模式”, `disposalStatus` as “使用状态”, and `deviceId` as “关联设备(ID)” consistently in the list and create/edit form. The form SHALL explain that the real-name subject is the individual or organization registered to the phone number, and that the device field accepts only an asset-record ID. Management type SHALL display empty for null and disposal status is required with initial value `正常使用`.
#### Scenario: User views a record in the list
- **WHEN** the workspace renders phone-asset records
- **THEN** it shows the columns “运营商、实名主体、管理模式、使用状态、关联设备(ID)” and does not expose the prior ambiguous labels for those fields
#### Scenario: User creates or edits a record
- **WHEN** a user opens the create or edit form
- **THEN** the field labels match the list names and the form explains the real-name subject and device-ID meanings without changing the submitted property names
#### Scenario: User saves confirmed dropdown values
- **WHEN** a user creates or edits using existing dropdown values
- **THEN** the workspace submits the existing `cardType`, `managementType`, and `disposalStatus` properties unchanged while the visible labels remain “运营商、管理模式、使用状态”
## 1. 页面字段命名
- [ ] 1.1 修改 `frontend/src/modules/phone/PhoneAssetView.js`(文件用途:承载手机号资产的筛选、列表、新增与编辑页面;关联逻辑:路由进入页面 → 表单/表格渲染 → `phone-api-client.js` 请求),将列表列名、表单标签、占位文字改为“运营商、实名主体、管理模式、使用状态、关联设备(ID)”。
- [ ] 1.2 在同一表单中为“实名主体”和“关联设备(ID)”增加简短帮助说明,分别明确“手机号实名登记的个人或单位名称”和“设备资产记录的数字 ID,当前不校验是否存在”。
- [ ] 1.3 保持 `cardType``realNameOwner``managementType``disposalStatus``deviceId` 的响应式表单属性及提交数据不变;不修改 `phone-api-client.js`(文件用途:封装手机号资产 HTTP 请求;关联逻辑:View → `/api/phone-assets` → Controller)或任何后端文件。
- [ ] 1.4 注释门禁:本组不新增或调整 JavaScript 方法;检查 `PhoneAssetView.js` 内已有方法的新手注释仍覆盖代码作用、关联文件和调用链/数据流。若实现时新增或改动任何方法,必须先补齐该方法的三项新手注释。
## 2. 自动化验证
- [ ] 2.1 修改 `frontend/tests/phone-asset.spec.js`(文件用途:验证手机号资产页的新增弹窗与输入行为;关联逻辑:浏览器页面 → 前端表单 → 被拦截的手机号资产接口),将按可访问名称断言的旧标签替换为新标签,并新增列表列和表单说明的断言。
- [ ] 2.2 注释门禁:本组不新增或调整测试辅助方法;检查现有测试方法注释仍说明代码作用、关联文件和调用链/数据流。若实现时新增或改动任何测试方法,必须先补齐该方法的三项新手注释。
- [ ] 2.3 运行手机号资产 Playwright 用例,并在本地打开 `http://localhost:5173/asset/#/phone-assets`,核对筛选栏、列表、新增弹窗和编辑弹窗的文字一致,且保存请求仍使用原有字段键。
## 3. 兼容性确认
- [ ] 3.1 对比修改前后的网络请求体与列表响应,确认接口、DTO、数据库和配置均未变化;本次没有新增、重命名或删除业务文件。
## Context
The frontend identifies the current user through `GET /api/auth/me`, which reads the current database row. Protected backend writes use the role stored in the signed login token. `SystemUserAdminService.updateUser` currently changes role, status, and page permissions without increasing `auth_version`; therefore an old token can retain a lower role while the UI renders the newly promoted role.
`phone-api-client.js`, `wecom-api-client.js`, and `system-user-api-client.js` use the shared authentication request helper. `device-api-client.js` uses a separate raw `fetch`, so it neither requests the CSRF cookie nor sends the `X-XSRF-TOKEN` header. Spring Security maps both a missing CSRF token and role denial to a generic 403 response, which conceals the cause during diagnosis.
## Goals / Non-Goals
**Goals:**
- Make a role, status, or page-permission change invalidate the account's existing login token before its new authorization state is shown as usable.
- Preserve the existing fixed roles and server-side authorization checks; a developer remains the highest-privilege role but must reauthenticate after an authorization change.
- Use one authenticated request path for device GET, multipart create/update, and delete operations, including cookies and CSRF headers for every write.
- Return a safe, actionable distinction between expired authentication, CSRF failure, and ordinary authorization denial.
**Non-Goals:**
- Do not add a database migration, create more developer accounts, weaken CSRF, or change device asset fields, upload validation, or response DTOs.
- Do not add self-service role changes or browser-stored login tokens.
- Do not modify the enterprise-WeChat or phone asset business rules.
## Decisions
### 1. Invalidate on every authorization-state mutation
`SystemUserAdminService.updateUser` will compare the persisted and requested role, status, and normalized page permissions. If any effective authorization value changes, it will increment `auth_version` in the same database update. `AuthTokenFilter` already rejects a token whose version differs from the database value, so the next request with that old cookie returns 401 and the user must sign in again.
`auth_version`(登录票据版本号:服务端用来立即作废旧登录票据的整数) is used because the role embedded in a signed JWT cannot safely be altered in-place. Relying only on `/api/auth/me` would keep the UI and API authorization sources inconsistent; rereading the role from the database on each request would reduce this mismatch but would not reliably revoke sessions after status or page-permission changes.
### 2. Reuse the existing authenticated request helper for multipart safely
Exported `request` in `auth-api-client.js` will remain the single browser request entry point. It will preserve caller headers, request CSRF only for non-GET non-login methods, and omit `Content-Type` when the body is `FormData` so the browser can supply the multipart boundary.
`device-api-client.js` will call this helper for list, lookup, create, update, delete, and image access requests as appropriate. The alternative of disabling CSRF for device endpoints is rejected because it would permit forged writes from a third-party page that can use a user's login cookie.
### 3. Keep safe 401/403 diagnostic classes
The security error handler will retain generic authorization wording for ordinary role/page denial, return a session-expired/login-required message for an invalidated token, and return a refresh-and-retry message for missing or invalid CSRF input. It will not reveal account existence, target roles, permission maps, token values, or internal exception details.
CSRF(跨站请求伪造防护:要求浏览器把服务端发出的随机值同时放在 Cookie 和请求头中) remains mandatory for all non-login writes. Without it, another website could submit a write using the user's ambient login Cookie.
### 4. Test the real failure paths before declaring the fix
Backend tests will prove that a promoted user token becomes invalid, a fresh developer token can manage users, and a non-developer token remains denied. Browser tests will verify that the first device write initializes CSRF, sends cookies and the token header, preserves multipart boundaries, and shows the distinct safe error messages.
## Risks / Trade-offs
- [An administrator changes their own authorization state] -> Their next request becomes 401 by design; the frontend redirects to login with a clear reauthentication message.
- [A change increments `auth_version` unnecessarily] -> Compare the persisted role, status, and normalized permission JSON before incrementing; no-op edits retain the current session.
- [Setting JSON content type for multipart] -> Detect `FormData` and let the browser add the boundary; otherwise uploads would reach the server as malformed data.
- [Detailed 403 messages disclose security state] -> Restrict messages to three generic client actions: login again, refresh/retry, or no permission.
- [Concurrent account updates] -> Use the existing single-row update path and increment from the persisted version; the current project has no optimistic-lock column, so concurrent administrator updates remain outside this targeted fix.
## Migration Plan
1. Deploy the backend and frontend together; no data migration or schema change is required.
2. Existing sessions continue until a managed account's authorization state changes. That change invalidates its previous session at the next protected request.
3. Verify a device create, multipart device update, phone write, enterprise-WeChat write, and system-user write using a fresh developer login.
4. Roll back by reverting the application changes. Existing `auth_version` values and asset data remain intact; already-invalidated sessions simply need a new login.
## Open Questions
None. Reauthentication after a role, status, or page-permission change is the selected security behavior.
## Why
An account promoted to `DEVELOPER` can receive full permissions from `/api/auth/me` while its already-issued login token still contains the prior role. The UI then identifies the user as an administrator but protected writes, such as `POST /api/system-users`, return 403. Separately, device-asset writes bypass the existing CSRF protection flow and are rejected regardless of the user's role.
## What Changes
- Invalidate an existing login session whenever a managed account's role, enabled status, or page permissions change. The next protected request must require a fresh login so the token role and database role cannot diverge.
- Keep the current rule that `DEVELOPER` and `SUPER_ADMIN` are administrators; do not relax role checks or allow creation of extra developer accounts.
- Route every device-asset write request through the shared authenticated request helper so it includes cookies and the CSRF header, including multipart `POST` and `PUT` requests.
- Make 403 verification distinguish an authorization failure from a missing/invalid CSRF token in automated tests and developer diagnostics, without exposing sensitive account information.
## Capabilities
### New Capabilities
- `authorization-session-consistency`: Keeps the role and permissions used by a protected API request consistent with the currently effective account state.
- `authenticated-device-writes`: Makes device-asset create, update, and delete requests use the same authenticated CSRF-safe request flow as the existing asset modules.
### Modified Capabilities
- None. The related authentication and device specifications are still in unarchived changes rather than the repository's main OpenSpec specification set; this change records the corrective requirements as standalone capabilities.
## Impact
- Backend: `SystemUserAdminService`, authentication-token filtering, and focused authorization tests. No database schema change or migration is required because `auth_version` already exists.
- Frontend: the shared auth request utility, device API client, and browser tests. The page layout, role names, and asset data contract remain unchanged.
- Security: CSRF(跨站请求伪造防护:阻止第三方网站借用登录 Cookie 发起写操作) continues to protect every non-login write. Not preserving it would make the 403 disappear at the cost of allowing forged writes.
## ADDED Requirements
### Requirement: Device write requests use the shared authenticated CSRF flow
The browser SHALL send device-asset create, update, and delete requests through the shared authenticated request helper. It MUST include login cookies and obtain/send the CSRF header for each protected non-login write. Multipart create and update requests MUST allow the browser to set the multipart boundary and MUST NOT force a JSON content type.
#### Scenario: Create a device with an image
- **WHEN** an authenticated administrator submits a device create form with a valid image
- **THEN** the client obtains CSRF input when needed, sends the login cookie and CSRF header with multipart form data, and the backend receives a valid device create request
#### Scenario: Update a device with multipart data
- **WHEN** an authenticated administrator edits a device name, image, or remove-image flag
- **THEN** the client sends the protected multipart update without overriding the browser-generated multipart boundary
#### Scenario: Missing device CSRF input
- **WHEN** a device write is attempted without a valid CSRF token
- **THEN** the backend rejects it before asset or file mutation and the browser shows the safe refresh-and-retry message
### Requirement: Device read contract remains compatible
The device list, company-person lookup, and controlled opaque image access SHALL retain their existing request paths and response shapes. This change MUST NOT expose physical file paths, disable reference-protected deletion, or relax the existing image validation rules.
#### Scenario: Read device list after security fix
- **WHEN** an authenticated authorized user loads the device asset page
- **THEN** the client receives the existing paged list shape and renders it without a write-oriented CSRF requirement
## ADDED Requirements
### Requirement: Authorization-state changes revoke stale login sessions
The system SHALL increment `auth_version` whenever a managed account's effective role, enabled status, or validated page-permission map changes. A token whose embedded version differs from the current account version MUST be rejected before a protected controller or service method executes.
#### Scenario: Promoted account uses an old token
- **WHEN** an active account is changed from a non-administrator role to `DEVELOPER` or `SUPER_ADMIN` while it still holds an earlier login token
- **THEN** its next protected request is rejected as requiring a fresh login, and a new login receives a token with the current role
#### Scenario: Permission map changes without a role change
- **WHEN** an administrator changes an account's validated page permissions or enabled status
- **THEN** the prior token is rejected on its next protected request and the account's next login uses the new effective permissions and status
#### Scenario: No-op account edit
- **WHEN** an administrator saves an account with the same effective role, status, and validated page permissions
- **THEN** the account's `auth_version` is unchanged and its current session remains valid
### Requirement: Protected write failures provide safe actionable categories
The system SHALL preserve generic authorization denial for a valid authenticated user without the required role or page permission. It SHALL return a login-required response for an invalidated or absent session, and a refresh-and-retry response for a missing or invalid CSRF token. Responses MUST NOT disclose token values, account existence, roles, or permission maps.
#### Scenario: Valid non-administrator creates an account
- **WHEN** a valid non-administrator token submits `POST /api/system-users`
- **THEN** the system returns a generic authorization-denied response and does not write an account row
#### Scenario: Browser omits CSRF input
- **WHEN** an authenticated browser sends a protected non-login write without a valid CSRF token
- **THEN** the system returns a safe refresh-and-retry response and does not execute the controller business method
## 0. Scope, ownership, and file-purpose annotations
| File | File purpose (plain language) | Ownership / collision boundary |
|---|---|---|
| `backend/src/main/java/com/xyw/console/auth/SystemUserAdminService.java` | Changes an account and invalidates its old login only when effective authorization changes. | Authentication change; coordinate with current account-permission work. |
| `backend/src/main/java/com/xyw/console/config/SecurityConfig.java` | Maps security-layer authentication, CSRF, and authorization failures to safe browser responses. | Authentication change; do not relax CSRF or global protection. |
| `backend/src/test/java/com/xyw/console/auth/SystemUserAdminServiceTest.java` | Proves version changes and account-management role boundaries without a live database. | New focused test file. |
| `backend/src/test/java/com/xyw/console/config/SecurityConfigTest.java` | Proves safe response categories for expired sessions, CSRF failures, and ordinary permission denial. | New focused test file. |
| `frontend/src/modules/auth/auth-api-client.js` | Is the shared browser helper that sends cookies, fetches CSRF input, and preserves multipart requests. | Authentication change; all API clients depend on it. |
| `frontend/src/modules/device/device-api-client.js` | Sends device list, lookup, CRUD, and image requests through the shared authenticated helper. | Device integration only; do not change device view behavior. |
| `frontend/tests/auth-session-consistency.spec.js` | Exercises stale-session, CSRF, and multipart browser request behavior. | New focused browser test. |
| `frontend/tests/device-asset.spec.js` | Extends device workflow tests with real request-header and multipart-boundary assertions. | Device integration test; preserve existing scenarios. |
- [ ] 0.1 Confirm the active authentication and device working-tree changes, preserve unrelated edits, and designate one integration owner for the four shared files above.
- [ ] 0.2 Add a plain-language file-purpose annotation to each new test file. For every method, callback, or helper added or changed in the scoped files, add a beginner comment describing its purpose, related files, and request-to-security-to-response data flow.
- [ ] 0.3 Do not run a database migration or modify `as_system_user` schema; verify `auth_version` is already readable and writable before implementation.
## 1. Authorization-session consistency
- [ ] 1.1 Add focused helpers in `SystemUserAdminService` that compare persisted and requested role, status, and normalized page permissions, and calculate the next `auth_version` safely from the persisted value.
- [ ] 1.2 Update `SystemUserAdminService.updateUser` so a true authorization-state change increments `auth_version` in the same row update, while a no-op update does not invalidate the current session.
- [ ] 1.3 Keep existing developer/super-administrator creation boundaries unchanged; a fresh `DEVELOPER` token can manage accounts, a valid non-administrator token remains denied, and no additional developer account can be created.
- [ ] 1.4 Update `SecurityConfig` to return safe distinct messages for invalidated/absent login sessions, missing-or-invalid CSRF input, and valid-session authorization denial, without returning account, role, permission, token, or exception details.
## 2. Shared device write authentication
- [ ] 2.1 Update `auth-api-client.js` request handling to preserve caller headers, send credentials, initialize CSRF for protected writes, and omit a forced JSON `Content-Type` when the request body is `FormData`.
- [ ] 2.2 Refactor every `device-api-client.js` operation to use the shared authenticated request helper; retain existing URLs, query parameter behavior, and API response shape.
- [ ] 2.3 Verify device multipart create and update retain the browser-generated boundary, and device delete includes the CSRF header and cookie.
- [ ] 2.4 Ensure a 401 session-invalid response clears local authentication state and leads the user to sign in again; do not silently retry a write that might repeat a user action.
## 3. Focused verification
- [ ] 3.1 Add `SystemUserAdminServiceTest` coverage for role promotion, status change, permission change, no-op update, developer management success, and non-administrator denial. Annotate every test method and business callback.
- [ ] 3.2 Add `SecurityConfigTest` coverage for 401 expired-session handling, CSRF 403 handling, ordinary authorization 403 handling, and the absence of sensitive details. Annotate every test method and callback.
- [ ] 3.3 Add browser coverage for the first device write obtaining CSRF, multipart request header/boundary behavior, device delete CSRF behavior, and the safe refresh-and-retry message. Annotate every test method and route callback.
- [ ] 3.4 Run `mvn -q test`, `npm run build`, focused authentication/device Playwright tests, and the existing full frontend Playwright suite. Diagnose a failing check before changing code.
- [ ] 3.5 Record changed files, no-database-impact confirmation, security behavior, executed verification, and any unexecuted check in the completion handoff.
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