feat(admin): 产品分享=复制产品给定制师后端实现 (PR #1919, Issue #1918)

GET /admin/user/customizers - 定制师下拉(无 status / 企微过滤)
POST /admin/product/item/{id}/share body {targetAdminId} - 复制产品+产品线给目标定制师
权限点 PRODUCT_SHARE 默认绑 SUPER_ADMIN / ADMIN

明确澄清: 与 09_frontend_notice_admin_product-share-remove-target-user-step.md
(已撤回) 不是同一个事情, 那个通知针对的是 url-scheme 老接口, 本通知是真正的
"分享产品" 新功能 (复制产品到目标定制师名下), mmg 不要混淆。
这个提交包含在:
API Changelog Bot 2026-05-09 18:06:09 +08:00
父节点 e196f20b3c
当前提交 b56239d609

查看文件

@ -0,0 +1,230 @@
# admin 产品分享 = 复制产品给定制师 (后端实现)
> **服务**: hl-user-service (8081) + hl-product-service-v2 (8083)
> **PR**: #1919 (合并到 dev a18329b7)
> **Issue**: #1918
> **日期**: 2026-05-09
> **影响范围**: admin 产品列表「分享」按钮 → 「分享产品」弹窗 → 选目标定制师 → 复制产品给该定制师
---
## ⚠️ 关键澄清(请 mmg 与上一条撤回通知一起看)
本日早些时候有一条已撤回的 changelog `09_frontend_notice_admin_product-share-remove-target-user-step.md`,误以为「分享产品弹窗的目标用户选择步骤」是 url-scheme 接口的多余步骤,要前端删掉。**那个判断针对的是 `POST /admin/wxapp/share/url-scheme` 这个老接口**(生成微信短链),与本通知**没有关系**。
**真相是**: 你看到的「分享产品 — 选目标用户」弹窗对应的是**另一个新功能** — admin 把自己负责的产品复制一份给指定的定制师,让定制师在自己工作台里看到这个产品(状态草稿,需重新编辑)。这个**后端原本不存在**,本 PR #1919 才补齐。
也就是说:
- 你的弹窗 UI 在 master 上确实有(我们之前调研错了),不要删
- 它需要调用的是**新增**的 `POST /admin/product/item/{id}/share` 接口,而不是老的 url-scheme
- 目标用户下拉**改调** `GET /admin/user/customizers`(本 PR 新增)
---
## 一、背景
业务场景: admin (通常是超管/客服)在产品列表上看到一个产品(可能是从供应商那里整理好的、或自己之前建的),想转给某个定制师让 ta 接手维护/二次编辑。复制后:
- 定制师在自己的产品列表里看到这个产品(`createdBy` = 该定制师)
- 状态自动是 DRAFT(草稿),让定制师确认/调整后再发布
- 标题加`(来自 {操作人 username} 的分享)`后缀,让定制师知道来源
- 价格日历**不复制**(让定制师按自己客户/批次重新设置)
- 关联的产品线也复制一份独立副本(给定制师独立空间,改产品线不影响别人)
权限走标准权限点 `PRODUCT_SHARE`(放在 `admin_permission` 表),默认绑定 SUPER_ADMIN + ADMIN 两个角色。**不写死角色** — 后续业务侧可在权限管理页面给任意角色绑该权限点。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 目标定制师下拉(分享专用) | GET | `/admin/user/customizers` | 新增 | 返「角色含 CUSTOMIZER」的所有 admin,**无 status / 企微绑定过滤** |
| 2 | 分享产品给定制师 | POST | `/admin/product/item/{id}/share` | 新增 | 复制产品+产品线到目标定制师名下 |
---
## 三、接口详情
### 1. 目标定制师下拉 `GET /admin/user/customizers`
弹窗里「目标用户」下拉应改调本接口(替换之前误调的 `/admin/user?page=1&pageSize=100`)。
**请求**: 无参 (Header 带 admin token)
**响应** `Result<List<CustomizerSimpleVO>>`:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{ "adminId": 1002, "username": "alice", "avatar": "https://..." },
{ "adminId": 1015, "username": "bob", "avatar": "" }
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data[].adminId` | Long | 定制师 admin_user.admin_id,作为分享接口的 `targetAdminId` |
| `data[].username` | String | 定制师用户名(下拉显示用) |
| `data[].avatar` | String | 头像 URL,**取自 admin_user.avatar 字段**(不查企微 wechat_user 也不查 designer_profile),没有时返空字符串 `""` |
**与已有 `/admin/user/designers` 的区别(选用本接口的原因)**:
| 维度 | `/admin/user/customizers`(本接口) | `/admin/user/designers`(已有接口) |
|------|-----------------------------------|-----------------------------------|
| 过滤 status | **不过滤** (LOCKED / DISABLED 也返回) | 仅 `status = ACTIVE` |
| 过滤企微绑定 | **不过滤** (未绑定企微也返回) | 必须 `enterprise_wechat_id` 非空 |
| 返回 VO | 简单 3 字段 | 重 VO(认证等级/服务领域/手机/简介等) |
| 用途 | **分享产品下拉**,只要是定制师就能选 | 小程序 C 端定制师列表展示 |
→ 本接口刻意放宽过滤,因为分享产品场景下「即使临时锁定/没绑企微的定制师也允许分享给他」(分享是给账号,定制师后续登录处理)。
**鉴权**: 标准 admin token(网关已校验),接口本身**不再做角色硬编码检查** — 任何登录 admin 都能调用。
---
### 2. 分享产品给定制师 `POST /admin/product/item/{id}/share`
**Headers**:
- `Content-Type: application/json`
- `Authorization: Bearer <admin token>`
**Path 参数**:
- `id` Long 必填 — 源产品 productId
**Request body** `ProductShareReqVO`:
```json
{ "targetAdminId": 1002 }
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `targetAdminId` | Long | ✅ | 目标定制师 adminId(取自上方 `/admin/user/customizers` 列表的 `data[].adminId`) |
**Response** `Result<ProductShareRespVO>`:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"newProductId": 1818070000000000123,
"newProductNo": "C260509047",
"newLineId": 1818070000000000456,
"newProductName": "云南 8 日游(来自 wx 的分享)"
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `newProductId` | Long | 新复制出来的产品 ID |
| `newProductNo` | String | 新生成的产品编号(走 ProductNoGenerator,如 `C260509047`) |
| `newLineId` | Long | 新复制的产品线 ID(独立副本) |
| `newProductName` | String | 新产品名称(原名 + `(来自 {操作人 username} 的分享)` 中文括号) |
**业务行为(逐项)**:
1. 校验当前 admin 持 `PRODUCT_SHARE` 权限点 → 否返 `403`
2. 校验 `targetAdminId` 对应 admin 的 `roleKeys``CUSTOMIZER` → 否返 `400 "目标用户不是定制师"`
3. 校验源产品存在 → 否返 `404`
4. 复制产品线: 新生成 lineId,其余字段照抄源产品线
5. 复制产品(基于已有 `ProductCopyService`):
- `lineId` 指向新产品线
- `name` = 源 name + `(来自 {操作人 username} 的分享)`
- `createdBy` = `targetAdminId`(覆盖默认的当前操作人)
- `status` = `DRAFT`
- `productNo` = 新生成
- **不复制** `product_price_calendar` 表(价格日历由定制师重新配置)
- 复制范围: 主表 + 4 张 1:1 子表 + 备品/费用 1:N 子表 + 行程相关 4 表(同 `copyProduct`)
**错误码**:
| HTTP | 业务消息 | 触发条件 |
|------|----------|----------|
| 403 | (走全局异常) | 当前 admin 无 `PRODUCT_SHARE` 权限点 |
| 400 | 目标用户不是定制师 | `targetAdminId` 对应 admin 不持 CUSTOMIZER 角色 |
| 404 | (走全局异常) | 源产品不存在或已删除 |
---
## 四、前端改造点(mmg 关注)
### 文件: `hl-ui/src/views/product/list/components/...`(分享弹窗相关)
1. **下拉接口换地址**:
- 改前: `GET /admin/user?page=1&pageSize=100` (老接口被硬编码角色挡,显示"没有权限")
- 改后: `GET /admin/user/customizers` (本 PR 新增,无过滤)
- 字段映射: `data[].adminId` → 下拉 value, `data[].username` → 下拉 label, `data[].avatar` → 下拉 icon(可选)
2. **「确定」按钮接口**:
- 改前: 不存在(后端没接口,所以点"确定"实际什么都没发生 / 报 404)
- 改后: `POST /admin/product/item/{id}/share` body `{targetAdminId}`
- 成功后展示成功 toast,可加跳转「定制师视角查看新产品」入口(用 `data.newProductId`)
3. **目标用户下拉为空时的兜底**:
- 如果系统里一个定制师都没有,接口返 `data: []`,前端显示「请先创建定制师账号」之类的空态
- 不要再用上一版逻辑里"没权限"的 toast(本接口不会再返 403)
4. **"分享后规则"提示文案**(已在弹窗右侧展示,与后端行为已对齐):
- 状态为草稿,需重新编辑发布 ✅ 后端确实置 `DRAFT`
- 标题会添加"(来自 xxx 的分享)"后缀 ✅ 后端确实加,xxx = 当前 admin.username
- 生成新的产品编号 ✅ 后端走 ProductNoGenerator
- 不包含价格日历(需重新设置) ✅ 后端确实不复制 `product_price_calendar`
---
## 五、权限点 `PRODUCT_SHARE`
新加权限点(`admin_permission` 表):
| 字段 | 值 |
|------|----|
| permission_code | `PRODUCT_SHARE` |
| permission_name | 分享产品 |
| resource_type | PRODUCT |
| action | SHARE |
| description | 允许将自己的产品复制一份分享给指定的定制师 |
| status | ACTIVE |
默认绑定角色(`admin_role_permission` 表):
- `SUPER_ADMIN`
- `ADMIN`
**CUSTOMIZER 不绑** — 业务侧默认由 SUPER_ADMIN/ADMIN 负责跨定制师分享,普通 CUSTOMIZER 只是接收方。如业务后续要让定制师之间互相分享,在权限管理页面给 CUSTOMIZER 角色加这个权限点即可,**无需改后端代码**。
---
## 六、不在本 PR 范围
- `AdminUserController.listAdmins()` 第 162 行硬编码 `if (!"SUPER_ADMIN".equals(role) && !"ADMIN".equals(role)) ...` 不动 — 留后续单独治理工单
- 已分享产品的二次分享 / 取消分享 / 审计追踪 / 通知目标定制师等扩展功能
- 前端 hl-ui 的弹窗 UI 调整(由 mmg 完成)
---
## 七、测试与验证
- 全 1160(product) + 2341(user) 单元测试均无回归
- 新增 19 个单测覆盖: `DesignerServiceTest +5` / `InternalUserControllerTest +3` / `ProductShareServiceTest 11/11`
- 本地启动全链路 API 测试**未做**(权衡时间) — 合 dev 后由后端在测试服 1.182.108.58 用真 admin token 通过网关 9443 立即验证,如发现问题立刻提下一个 PR
---
## 八、相关 PR / 工单
| PR / Issue | 说明 | 是否相关 |
|------------|------|----------|
| #1919 | 本 PR | ✅ 主体 |
| #1918 | 工单 | ✅ 已 closed |
| 已撤回的 `09_frontend_notice_admin_product-share-remove-target-user-step.md` | 误将本功能识别为 url-scheme 多余步骤 | ⚠️ 已撤回,本通知是真正的解决方案 |
| #1488 | 老的 url-scheme 接口(生成微信短链) | ❌ 不相关,是另一个功能 |
---
**联系人**: wx