实现客户信息、客户部门、客户账号三个子模块的完整CRUD,包含: 1. 数据库实体、DTO、控制器、服务层代码 2. RBAC权限映射与菜单种子数据扩展 3. 前端路由与API封装 4. 数据权限隔离与鉴权逻辑 5. 配套工具脚本与测试验证清单
18 KiB
18 KiB
鑫潼智电系统 · 客户管理核心模块 - The Implementation Plan (Decomposed and Prioritized Task List)
[x] Task 1: 新增 3 个 Sequelize Entity 模型文件
- Priority: high
- Depends On: None
- Description:
- 在
server/src/entity/下新增customer.entity.ts:字段严格对齐 spec.md + DB 设计文档,新增customer_no(VARCHAR(64) UNIQUE) 和salesman_id(BIGINT FK→sys_user.id),province/city/district/address/contact_person/contact_phone/postal_code/fax/customer_level/status(SMALLINT default 1)/created_at/updated_at - 新增
customer_department.entity.ts:id/customer_id(FK)/dept_name/contact_person/contact_phone/function_desc/created_at,BelongsTo Customer 关联 - 新增
customer_account.entity.ts:id/customer_id(FK)/phone(UNIQUE)/password_hash/wx_openid/status/last_login_at/created_at,BelongsTo Customer 关联 - 每个 Entity 定义必要索引(customer: customer_name, salesman_id, status, customer_no unique; department: customer_id; account: customer_id, phone unique)
- 字段使用
field映射 snake_case 列名,comment加中文注释,@CreatedAt @UpdatedAt装饰器
- 在
- Acceptance Criteria Addressed: FR-1, FR-2, FR-3, NFR-1
- Test Requirements:
programmaticTR-1.1: Sequelize sync 后 PostgreSQL 中三张表成功建立,字段类型/长度/默认值/索引与定义一致,外键约束存在programmaticTR-1.2: customer 表插入一条含 phone 的 customer_account,尝试重号 phone 抛唯一约束错误human-judgementTR-1.3: 代码 review 确认字段未遗漏 customer_no / salesman_id / password_hash / status 等关键列
- Notes: 沿用现有
sysUser.entity.ts风格,DataType + 注释 + timestamps 配置一致
[x] Task 2: 扩展 db_seed.sql 客户域菜单与权限
- Priority: high
- Depends On: None
- Description:
- 把现有 id=1「客户管理」顶级菜单 menu_type 从 2 改为 1(目录),parent_id=NULL 不变
- 新增 3 条子菜单(id=12/13/14),parent_id=1:12=客户信息(/customer/info component=customer/info/index permission=customer:list)、13=客户部门(/customer/department component=customer/department/index permission=customer:department)、14=客户账号(/customer/account component=customer/account/index permission=customer:account)
- 新增 9 条按钮权限菜单(menu_type=3)id=15~23:customer:create / customer:update / customer:delete / department:create / department:update / department:delete / account:create / account:update / account:delete
- UPDATE
sys_role_menu:ADMIN(role_id=1) 新增绑定 1223 全部;SALESMAN(role_id=2) 新增绑定 1223 全部(删除原只绑定 id=1 的记录,或新增并保留 id=1) - 保持 ON CONFLICT DO UPDATE / DO NOTHING 幂等性
- Acceptance Criteria Addressed: FR-5, AC-4
- Test Requirements:
programmaticTR-2.1: 执行 psql -f db_seed.sql 后 sys_menu 表 id 1~23 全部存在,id=1 menu_type=1,id=12/13/14 parent_id=1programmaticTR-2.2: sys_role_menu 中 role_id=1 包含 menu_id 1~23 全部;role_id=2 至少包含 1,12,13,14,15..23
- Notes: 先 TRUNCATE 或逐条 DELETE ON CONFLICT 前需注意不要误删系统管理菜单,建议所有 INSERT 使用显式 id + ON CONFLICT (id) DO UPDATE
[x] Task 3: 扩展 RBAC 中间件 + 注册 Entity 到配置
- Priority: high
- Depends On: [Task 1, Task 2]
- Description:
- 在
server/src/middleware/rbac.middleware.ts的PERMISSION_MAP中新增 3 条:/api/customers→customer:list/api/customer-departments→customer:department/api/customer-accounts→customer:account
- 在
server/src/configuration.ts或 Sequelize 配置config.default.ts的models数组中,显式 import 并加入Customer/CustomerDepartment/CustomerAccount三个 Entity(确保 sync 时加载)
- 在
- Acceptance Criteria Addressed: FR-4, AC-2, AC-4
- Test Requirements:
programmaticTR-3.1: 用无权限 token 访问 GET /api/customers 返回 403(rbac 中间件工作);ADMIN token 返回 200programmaticTR-3.2: 服务启动无 Entity 加载报错,sync 后 3 张表存在
- Notes: 若 models 采用目录扫描自动加载,Task 1 把 Entity 放对目录即可;如显式 import 列表则需手动追加
[x] Task 4: 新增客户域 DTO 参数校验类
- Priority: high
- Depends On: [Task 1]
- Description:
- 新建
server/src/dto/customer.dto.ts:CustomerQueryDto:page/ pageSize(number, min 1, max 200)/ keyword(string, max 64)/ status(SMALLINT 1-4)/ salesmanId(optional)CustomerCreateDto:customerName(required, max 128, regex 禁 script tag)/ province/city/district/address(max 256)/ contactPerson/ contactPhone(regex 手机号)/ postalCode?/ fax?/ customerLevel?/ status(default 1)/ salesmanId?CustomerUpdateDto:字段可选,与 create 共用正则
- 新建
server/src/dto/customer.department.dto.ts:CustomerDepartmentQueryDto / Create / Update(customerId required 数字) - 新建
server/src/dto/customer.account.dto.ts:CustomerAccountQueryDto / Create / Update;CreateDto 无 password 字段(由 service 自动生成),UpdateDto 含可选 resetPassword 标志位 + status
- 新建
- Acceptance Criteria Addressed: FR-1, FR-2, FR-3, NFR-6
- Test Requirements:
programmaticTR-4.1: 传空 customerName 创建 → 返回 400 参数错误programmaticTR-4.2: 传 pageSize=500 → 校验失败,返回 max 200 提示programmaticTR-4.3: contactPhone 传 'abc' → regex 校验失败
- Notes: 使用 @midwayjs/validate + class-validator 装饰器,与现有 dto/*.ts 风格一致;禁止使用 any
[x] Task 5: 实现 CustomerService(含 customer_no 生成 + 行级过滤)
- Priority: high
- Depends On: [Task 1, Task 4]
- Description:
- 新建
server/src/service/customer.service.ts,@Provide() 类 - list(query, ctx):分页 + 关键词模糊查询 Op.or [customer_name, contact_person, contact_phone] + status 筛选;SALESMAN 角色强制追加 where salesman_id = ctx.state.user.userId;ADMIN 不加
- detail(id, ctx):按 id 查询,SALESMAN 校验 salesman_id 归属,否则 403
- generateCustomerNo():查询当日 customer_no 最大序号,规则
KH + YYYYMMDD + 3位序号(001 起始);捕获 UNIQUE 冲突重试 1 次 - create(data, ctx):校验必要字段;若 SALESMAN 创建则 data.salesmanId 强制覆盖为 ctx.state.user.userId;调用 generateCustomerNo();写入后返回完整创建对象
- update(id, data, ctx):归属校验;字段白名单更新(customer_no / salesman_id(非ADMIN)不可改)
- delete(id, ctx):归属校验;先查是否存在 customer_department 或 customer_account 关联,有则返回 {code:400, message:'该客户存在关联部门/账号,无法删除'};否则删除
- updateStatus(id, status, ctx):快捷更新状态
- 新建
- Acceptance Criteria Addressed: FR-1, FR-4, AC-1, AC-2
- Test Requirements:
programmaticTR-5.1: ADMIN 创建 2 个客户后,SALESMAN(id=2) list 返回 total ≤ ADMIN 看到的 total,且返回每条 salesman_id=2programmaticTR-5.2: 同一天连续 create 3 条客户,customer_no 分别为 KH001 / 002 / 003,格式正确programmaticTR-5.3: SALESMAN 尝试传 salesman_id=3 创建,保存后实际 salesman_id=2(被覆盖)programmaticTR-5.4: 删除有关联部门的客户 → 返回 400 禁止
- Notes: 所有查询用 Sequelize 参数化 where 对象,禁止拼接;关联 existence 检查用
findOne({ where: { customerId: id }, limit: 1 })而非 countAll,提高性能
[x] Task 6: 实现 CustomerDepartmentService + CustomerAccountService
- Priority: high
- Depends On: [Task 1, Task 4, Task 5]
- Description:
customer.department.service.ts:list(关键词搜索 + 按 customer_id 过滤;SALESMAN 通过 customer_id JOIN customer.salesman_id 过滤);create(校验 customer_id 归属:SALESMAN 需确保目标 customer.salesman_id = 自己);update / delete(归属校验 + 外键关联提示)customer.account.service.ts:list(按 customer_id / status / keyword phone 模糊;SALESMAN JOIN 过滤;返回 exclude password_hash, wx_openid);create(默认密码=phone 后 6 位 → bcrypt 加密;phone UNIQUE 校验);update(字段白名单;若 resetPassword=true 则重新计算密码 hash;返回值永远不带 password_hash);delete(归属校验);updateStatus(快捷切换)- 复用 hashPassword 工具:
server/src/util/crypto.ts
- Acceptance Criteria Addressed: FR-2, FR-3, AC-3, AC-5
- Test Requirements:
programmaticTR-6.1: SALESMAN 对自己客户创建部门 → 成功;对别人客户创建部门 → 403programmaticTR-6.2: 创建 account phone=13800001234,数据库 password_hash 经过 bcrypt 验证等于 '001234'(可用单元测试对比)programmaticTR-6.3: 同 phone 创建第二个 account → 返回 400 '手机号已存在'programmaticTR-6.4: list 接口响应 JSON 中不含 password_hash / wx_openid 字段
- Notes: bcrypt 比较使用
bcryptjs.compare;密码生成函数单独抽出便于单元测试
[x] Task 7: 实现客户域 3 个 Controller
- Priority: high
- Depends On: [Task 5, Task 6]
- Description:
- 新建
server/src/controller/customer.controller.ts:- @Controller('/api/customers');@Inject ctx + customerService
- GET '/' list(@Query() query) → service.list(query, ctx)
- GET '/:id' detail(@Param('id') id) → service.detail(id, ctx)
- POST '/' create(@Body() body) → service.create(body, ctx)
- PUT '/:id' update(@Param('id') id, @Body() body) → service.update(id, body, ctx)
- DELETE '/:id' delete(@Param('id') id) → service.delete(id, ctx)
- PUT '/:id/status' updateStatus → service.updateStatus
- 新建
customer.department.controller.ts:@Controller('/api/customer-departments'),同上 6 个方法 - 新建
customer.account.controller.ts:@Controller('/api/customer-accounts'),同上 6 个方法 + 可选 POST '/:id/reset-password' - 所有写操作:@UseMiddleware(AuthMiddleware, RbacMiddleware) 或沿用全局注册方式
- 统一返回结构:
{ code, message, data },与现有 user.service 对齐
- 新建
- Acceptance Criteria Addressed: FR-7, AC-8
- Test Requirements:
programmaticTR-7.1: 用 curl 调 ADMIN token GET /api/customers?page=1&pageSize=10 → 返回结构 {code:200,message,data:{list,total,page,pageSize}}programmaticTR-7.2: 无 Authorization 头 → 401programmaticTR-7.3: PUT /api/customers/99999(不存在) → 404
- Notes: 检查全局中间件是否已经自动 apply(auth + rbac),如全局已注册则 Controller 不再重复装饰;沿用现有
user.controller.ts风格
[x] Task 8: 前端 API 封装 + 路由改造
- Priority: high
- Depends On: [Task 7]
- Description:
- 新建
client/src/api/customer.ts:getCustomerList/getCustomerDetail/createCustomer/updateCustomer/deleteCustomer/updateCustomerStatus;类型接口 CustomerInfo / CustomerQuery / CustomerCreate 等 - 新建
client/src/api/customerDepartment.ts:对应 CRUD + 类型 - 新建
client/src/api/customerAccount.ts:CRUD +resetAccountPassword+ 类型 - 更新
client/src/router/index.ts:将原/customerPlaceholder 路由拆为 3 条嵌套子路由:/customer/info→ views/customer/info/index.vue,/customer/department→ views/customer/department/index.vue,/customer/account→ views/customer/account/index.vue;父级 component 仍使用 Layout,权限 customer:list/department/account 分别设置
- 新建
- Acceptance Criteria Addressed: FR-6, FR-7
- Test Requirements:
programmaticTR-8.1: 登录后访问浏览器 /customer/info → 路由正确命中(非 404/403,权限验证通过时)programmaticTR-8.2: 触发 createCustomer 请求 → Network 面板 url/method/payload 正确,拦截器注入 Authorization
- Notes: 所有 API 方法复用
@/utils/request实例(Axios 封装);类型定义 export 供页面使用
[x] Task 9: 前端省市区数据 + 客户信息页面实现
- Priority: high
- Depends On: [Task 8]
- Description:
- 新建
client/src/assets/data/region.ts:轻量省→市→区 3 级 JSON 树(可引入开源数据或手写示例子集至少 10 省+常用市+区),导出regionOptions: CascaderOption[] - 新建
client/src/views/customer/info/index.vue:- 完全对齐现有
system/user/index.vue布局风格 - 搜索栏:关键词输入框 + 状态 a-select(字典 customer_status 1~4)+ 业务员 a-select(拉 sys_user 简化列表)+ 查询/重置按钮
- 表格列:客户编号(ellipsis)、客户名称、省市区(拼接显示)、详细地址、联系人、电话、客户级别、状态(a-switch)、创建时间(formatDateTime)、操作(编辑/删除)
- 弹窗:客户基本信息表单 + 省市区 a-cascader(options=regionOptions, 双向绑定数组 [province,city,district])+ 业务员下拉(仅 ADMIN 可见可改,SALESMAN 登录时隐藏/只读)+ AntD form 校验规则(名称必填、电话 regex、长度限制、XSS 关键字过滤)
- 表格内部滚动:复用
useTableScrollcomposable(wrapperRef, tableHeight) - 增删成功后:关闭弹窗 →
searchForm.page = 1→fetchList()(保留筛选条件)
- 完全对齐现有
- 新建
- Acceptance Criteria Addressed: FR-6, AC-7, AC-9
- Test Requirements:
human-judgementTR-9.1: 页面 UI 与用户管理页风格一致,表格滚动正常,弹窗表单校验生效programmaticTR-9.2: 新增客户后 Network 返回 code=200,列表自动刷新并回到 page=1,搜索栏关键词未清空human-judgementTR-9.3: 输入客户名称<script>alert(1)</script>保存后列表以纯文本显示,脚本不执行
[x] Task 10: 客户部门 + 客户账号前端页面实现
- Priority: high
- Depends On: [Task 9]
- Description:
customer/department/index.vue:- 搜索栏:关键词 + 客户下拉(调用 customerApi.getCustomerList({pageSize: 999}) 取简表,ADMIN 显示全部,SALESMAN 只显示自己客户)+ 查询/重置
- 表格列:所属客户、部门名称、联系人、联系电话、职能描述、创建时间、操作
- 弹窗:客户下拉(必填)+ 部门名称(必填)+ 联系人 + 电话(regex)+ 职能描述 a-textarea
- 行级归属:SALESMAN 新增/编辑时客户下拉只显示自己客户选项
customer/account/index.vue:- 搜索栏:关键词(电话)+ 客户下拉 + 状态 + 查询/重置
- 表格列:所属客户、登录电话、状态(a-switch)、创建时间、操作(编辑/删除/重置密码按钮)
- 弹窗:客户下拉(必填)+ 登录电话(必填 regex)+ 状态;编辑表单提示「默认密码=手机号后6位」;编辑页底部提供「重置为默认密码」a-checkbox 勾选,勾选后请求体传 resetPassword:true
- 列表顶部提示:默认密码规则说明
- Acceptance Criteria Addressed: FR-6, AC-3, AC-5
- Test Requirements:
human-judgementTR-10.1: SALESMAN 登录新增部门,客户下拉只显示归属自己的客户programmaticTR-10.2: 重置默认密码 → 后端更新 hash,用 bcrypt 验证对应密码正确(可后端打临时日志验证或通过 customer_account 登录接口间接测)human-judgementTR-10.3: 删除确认二次弹框正常工作
[x] Task 11: 集成联调 + 权限/隔离验证
- Priority: high
- Depends On: [Task 7, Task 8, Task 9, Task 10]
- Description:
- 启动 server(midway dev)+ client(vite dev)
- 场景 1 ADMIN:客户信息/部门/账号 三模块完整 CRUD 走通一遍(新增2条 → 编辑 → 列表搜索/分页/筛选 → 删除1条 → 状态切换)
- 场景 2 SALESMAN:登录 salesman01 → list 仅见自己客户;尝试通过 Postman 越权 PUT /api/customers/<他人客户ID> → 403
- 场景 3 数据一致性:customer_id 外键删除保护、customer_no UNIQUE、customer_account phone UNIQUE 三项约束均触发成功
- 场景 4 APP 兼容:用 Postman 模拟 APP 请求(同 JWT token 同 header)→ 所有列表/写操作返回结构与浏览器一致
- 场景 5 前端 XSS:在 3 个模块的文本输入字段分别注入
<img src=x onerror=alert(1)>+<script>...</script>→ 保存后列表纯文本展示不执行
- Acceptance Criteria Addressed: AC-1, AC-2, AC-3, AC-7, AC-8
- Test Requirements:
programmaticTR-11.1: 5 个场景全部通过,接口无 500human-judgementTR-11.2: 浏览器 Console 无 JS 报错,XSS 注入脚本不执行
[x] Task 12: SQL 注入安全验证 + 构建/Lint 检查
- Priority: medium
- Depends On: [Task 11]
- Description:
- 用 Postman 对 3 个列表接口发送 keyword =
' OR 1=1 --以及' OR '1'='1→ 返回结果数量与正常模糊匹配一致(可能=0),不出现全表数据泄露 - 验证后端所有 Sequelize 代码均未使用
sequelize.query()拼接字符串 SQL(grep 检查) - 运行
cd server && yarn lint+cd client && yarn build确保无 TS 类型错误和 Lint 告警 - 运行 db_seed.sql 脚本幂等:连续执行 2 次无异常,菜单/角色数据无重复漂移
- 用 Postman 对 3 个列表接口发送 keyword =
- Acceptance Criteria Addressed: AC-6, NFR-1, NFR-2, NFR-6
- Test Requirements:
programmaticTR-12.1: SQL 注入测试返回 total 符合预期(无全表)programmaticTR-12.2: yarn build 两端均通过,0 ErrorprogrammaticTR-12.3: db_seed.sql 第二次执行仍成功,SELECT count(*) FROM sys_menu = 23
- Notes: 若有
sequelize.query()用于 customer_no 生成等场景,强制使用 replacements 参数化,禁止\${var}``` 字符串拼接
Task Dependencies (DAG)
Task1 ─┐
Task2 ─┼─→ Task3 ─┐
Task4 ─┼─→ Task5 ─→ Task7 ─→ Task8 ─→ Task9 ─→ Task10 ─→ Task11 ─→ Task12
└─→ Task6 ──┘