diff --git a/docs/微信登录整合方案.md b/docs/微信登录整合方案.md index 3dfc826..a357c0a 100644 --- a/docs/微信登录整合方案.md +++ b/docs/微信登录整合方案.md @@ -1,304 +1,267 @@ -# OMS 微信登录整合方案 +# OMS 微信扫码登录 -> 版本:v1.0 -> 日期:2026-06-24 -> 策略:**B — 公众号回调独占 OMS**(不与 WMS 共用同一套公众号服务器配置,WMS 微信能力逐步下线或改用独立公众号) +> 版本:v1.1 +> 日期:2026-07-01 +> 范围:**PC 端客户门户微信扫码登录**(`/` 登录页 → 微信 Tab) --- -## 1. 背景与目标 +## 1. 概述 -### 1.1 背景 +OMS 客户门户通过**微信公众号临时二维码 + 扫码事件回调 + 缓存桥接 + 前端轮询**完成登录。 -WMS 已实现基于**微信公众号临时二维码 + 扫码事件回调 + Redis 桥接 + 前端轮询**的登录方案。OMS 客户门户登录页已预留微信入口(`wechat-login.vue`),但当前为 UI 占位,后端无微信相关接口。 +核心思路: -OMS 后端为 C#(`SimpleAdmin.Api` + JWT),客户实体 `t_customer` 已预留 `WechatOpenid` 等字段,仓储层已有 `GetByWechatOpenidAsync`,具备约 40% 基础能力。 +1. PC 端向后端申请带 **loginKey** 的二维码; +2. 用户微信扫码后,公众号向 OMS 推送 `SCAN` / `subscribe` 事件; +3. 后端根据扫码微信的 **openId** 查找已绑定客户,将账号信息写入缓存(key = loginKey,TTL 10 分钟); +4. PC 端轮询该 key,有值则调用登录接口签发 JWT。 -### 1.2 目标 +**前提:** 微信 openId 须已在 `t_customer.WechatOpenid` 中绑定(通过 H5 注册/绑定页完成,本文不展开)。 -| 目标 | 说明 | -|------|------| -| PC 客户扫码登录 | 客户门户 `/` 切换微信登录,扫码后自动签发 JWT | -| 微信授权注册 | 未绑定用户通过公众号菜单进入 H5 注册页 | -| 微信授权绑定 | 已有 OMS 客户账号,绑定当前微信 openId | -| 沿用同一公众号 | AppId / Secret / Token 与现网 WMS 使用同一公众号,但**服务器 URL 改指向 OMS** | -| 仅客户门户 | 管理端 `/admin` 不提供微信扫码登录 | - -### 1.3 策略 B 说明 - -``` -微信公众平台服务器配置 URL - 旧:WMS → https://{wms}/apiwechat/{appId} - 新:OMS → https://api.nanafa.com/admin/wechat/mp/{appId} -``` - -- 同一公众号的 AppId、Secret、Token、EncodingAESKey 配置迁移到 OMS(Consul / appsettings) -- 微信服务器回调**只由 OMS 处理**,无需 `system=wms/oms` 分流 -- WMS 侧微信扫码登录、模板消息等能力需单独下线或改用其他公众号(不在本方案范围) +**门户限制:** 仅客户门户 `/` 提供微信扫码;管理端 `/admin` 不提供。 --- -## 2. 总体架构 +## 2. 扫码登录流程 -### 2.1 登录时序(PC 扫码) +### 2.1 时序图 ```mermaid sequenceDiagram participant PC as 客户浏览器 participant FE as OMS 前端 - participant API as OMS API (C#) - participant Redis as Redis + participant API as OMS API + participant Cache as 分布式缓存 participant WX as 微信公众号 participant DB as MySQL - PC->>FE: 客户登录页 → 微信扫码 - FE->>FE: 生成 loginUuid (UUID v4) - FE->>API: GET /admin/wechat/mp/login-qrcode?loginUuid=xxx - API->>WX: 创建临时二维码 ticket (scene, 600s) - API-->>FE: { qrCodeUrl } - FE->>FE: 展示二维码,每 3s 轮询 + PC->>FE: 打开登录页 → 切换微信登录 + FE->>FE: 生成 loginKey(32 位无横线 UUID) + FE->>API: GET /admin/wechat/mp/login-qrcode?loginUuid={loginKey} + API->>WX: POST qrcode/create(scene_str = loginKey,600s) + API-->>FE: { loginKey, qrCodeUrl, expiresIn: 600 } + FE->>FE: 展示二维码,启动轮询(每 3s) Note over WX: 用户微信扫码 - WX->>API: POST /admin/wechat/mp/{appId} (SCAN / SUBSCRIBE) - API->>DB: 按 openId 查 t_customer.WechatOpenid - alt 未绑定 - API-->>WX: 回复文本「请注册或绑定账号」 + WX->>API: POST /api/apiwechat/verification(SCAN 或 subscribe) + API->>DB: GetByWechatOpenidAsync(openId) + alt 未绑定微信 + API-->>WX: 文本「未绑定账号,请注册或绑定」 else 已绑定 - API->>Redis: SET oms:wechat:login:{loginUuid} = openId, TTL 600s + API->>Cache: SET oms:wechat:login:{loginKey} = 账号 JSON,TTL 600s + API-->>WX: 文本「扫码成功,请返回电脑端完成登录」 end - FE->>API: POST /admin/rbac/auth/wx-login { loginUuid } - API->>Redis: GET openId - API->>DB: 查客户 + 校验状态 - API->>API: 签发 JWT (OmsLoginType.Customer) + loop 每 3 秒 + FE->>API: GET /admin/rbac/auth/wx-login/status?loginUuid={loginKey} + API->>Cache: GET session + API-->>FE: { ready: false } 或 { ready: true, account: "C001" } + end + + FE->>API: POST /admin/rbac/auth/wx-login { loginUuid: loginKey } + API->>Cache: GET + DELETE session + API->>DB: 校验客户状态、更新最后登录 + API->>API: 签发 JWT(OmsLoginType.Customer) API-->>FE: LoginResultDto - FE->>PC: 存 Token,跳转 /home/workplace + FE->>PC: 存 Token,跳转首页 ``` -### 2.2 模块划分 +### 2.2 前端轮询流程 -``` -┌─────────────────────────────────────────────────────────┐ -│ OMS 前端 (Vue3 + Arco Design) │ -│ login/wechat-login.vue │ wechat/register │ bind │ -└──────────────────────────┬──────────────────────────────┘ - │ HTTPS -┌──────────────────────────▼──────────────────────────────┐ -│ SimpleAdmin.Api │ -│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │ -│ │ AuthService │ │ WeChatMpController│ │WeChatOAuth │ │ -│ │ + wx-login │ │ 回调/二维码 │ │ 注册/绑定 │ │ -│ └──────┬──────┘ └────────┬─────────┘ └─────┬──────┘ │ -│ │ │ │ │ -│ ┌──────▼───────────────────▼───────────────────▼──────┐ │ -│ │ WeChatMpService / ScanHandler / SubscribeHandler │ │ -│ └──────┬───────────────────────────────┬──────────────┘ │ -│ │ │ │ -│ ┌──────▼──────┐ ┌──────▼──────┐ │ -│ │ Redis │ │ MySQL │ │ -│ │ 扫码会话桥接 │ │ t_customer │ │ -│ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ▲ - │ 微信服务器推送 - ┌──────┴──────┐ - │ 微信公众号 │ - └─────────────┘ +```mermaid +flowchart TD + A[onMounted] --> B[生成 loginKey] + B --> C[GET login-qrcode] + C --> D{qrCodeUrl 有效?} + D -- 否 --> E[提示加载失败] + D -- 是 --> F[展示二维码] + F --> G[启动 3s 轮询 + 10min 过期定时器] + G --> H[GET wx-login/status] + H --> I{ready === true?} + I -- 否 --> J[等待 3s] + J --> H + I -- 是 --> K[POST wx-login] + K --> L{accessToken 有效?} + L -- 否 --> J + L -- 是 --> M[loginByWxResult + 跳转首页] + G --> N[10 分钟到] --> O[停止轮询] + P[点击刷新] --> B ``` -### 2.3 技术选型 +### 2.3 后端扫码事件流程 -| 组件 | 选型 | -|------|------| -| 微信 SDK | `Senparc.Weixin.MP` 或 `SKIT.FlurlHttpClient.Wechat.Api` | -| 扫码会话 | `IDistributedCache` + Redis(`StackExchange.Redis`) | -| 认证 | 复用现有 JWT(`AuthService` / `SimpleJwtService`) | -| 配置 | Consul `idwms/admin` 增加 `WeChat:Mp` 节点 | +```mermaid +flowchart TD + A[POST /apiwechat/verification] --> B[验签 signature] + B -- 失败 --> C[返回 error] + B -- 成功 --> D[解析 XML 消息] + D --> E{MsgType = event?} + E -- 否 --> F[空 XML 响应] + E -- 是 --> G{Event = SCAN 或 subscribe?} + G -- 否 --> F + G -- 是 --> H[WeChatQrSceneParser 解析 EventKey → loginKey] + H --> I{loginKey 存在?} + I -- 否 --> J[subscribe 回复「感谢关注」] + I -- 是 --> K[openId 查 t_customer] + K --> L{客户已绑定?} + L -- 否 --> M[回复「未绑定账号…」] + L -- 是 --> N[写入缓存 session JSON] + N --> O[回复「扫码成功,请返回电脑端」] +``` + +### 2.4 模块关系 + +``` +┌──────────────────────────────────────────────────────────┐ +│ OMS 前端 │ +│ src/views/login/components/wechat-login.vue │ +│ src/api/wechat/index.ts │ +└────────────────────────────┬─────────────────────────────┘ + │ HTTPS +┌────────────────────────────▼─────────────────────────────┐ +│ SimpleAdmin.Api │ +│ AuthService(AuthService.WeChatMp.cs + AuthService.cs)│ +│ · GET /wechat/mp/login-qrcode 获取二维码 │ +│ · GET /rbac/auth/wx-login/status 轮询 key 状态 │ +│ · POST /rbac/auth/wx-login 完成登录 │ +│ · GET/POST /apiwechat/verification 公众号回调 │ +│ WeChatEventHandler 处理 SCAN / subscribe │ +│ WeChatLoginSessionStore loginKey → 账号缓存 │ +│ WeChatMpApiClient 调微信 qrcode/create │ +└────────────┬───────────────────────────────┬─────────────┘ + │ │ + ┌────────▼────────┐ ┌────────▼────────┐ + │ IDistributedCache│ │ MySQL │ + │ (Redis 推荐) │ │ t_customer │ + └─────────────────┘ └─────────────────┘ + ▲ + │ 微信服务器推送 + ┌──────┴──────┐ + │ 微信公众号 │ + └─────────────┘ +``` --- -## 3. 数据库设计 +## 3. 关键概念 -### 3.1 主表:`t_customer`(扩展现有表) +### 3.1 loginKey(扫码会话标识) -OMS 不单独建 `sys_user_wechat`,微信绑定信息直接落在客户表(与领域模型 `Customer` 一致)。 +| 项 | 说明 | +|----|------| +| 别名 | 接口参数名 `loginUuid`,语义上即扫码 key | +| 生成 | 前端优先生成 32 位 UUID;也可不传,由后端 `Guid.NewGuid().ToString("N")` 生成 | +| 用途 | 写入二维码 `scene_str`;轮询与登录时作为查询 key | +| 有效期 | 与二维码一致,**600 秒(10 分钟)** | -#### 现有字段(已存在) +### 3.2 二维码 scene -| 字段 | 类型 | 说明 | -|------|------|------| -| `WechatOpenid` | `varchar(64)` NULL | 公众号 openId,登录主键 | -| `WechatNickname` | `varchar(64)` NULL | 微信昵称 | -| `WechatAvatar` | `varchar(255)` NULL | 微信头像 URL | - -#### 建议新增字段(可选,便于扩展) - -| 字段 | 类型 | 说明 | -|------|------|------| -| `WechatUnionid` | `varchar(64)` NULL | 开放平台 unionId(若后续接开放平台) | -| `WechatBindTime` | `datetime` NULL | 微信绑定时间 | - -#### 建议索引 - -```sql --- openId 唯一(允许多条 NULL) -CREATE UNIQUE INDEX uk_customer_wechat_openid - ON t_customer (WechatOpenid); - --- 辅助查询(若尚未存在) -CREATE INDEX idx_customer_phone ON t_customer (Phone); -CREATE INDEX idx_customer_email ON t_customer (Email); -``` - -#### 业务约束 - -| 规则 | 说明 | -|------|------| -| 一微信绑一客户 | `WechatOpenid` 全局唯一(非 NULL 时) | -| 一客户绑一微信 | 更新绑定时校验该客户 `WechatOpenid` 为空 | -| 仅主客户可微信登录 | `ParentCustomerId IS NULL`;子客户拒绝扫码登录 | -| 子客户 | 不走微信扫码,继续账号密码登录 | - -### 3.2 Redis 键设计(非持久化表) - -| Key | Value | TTL | 说明 | -|-----|-------|-----|------| -| `oms:wechat:login:{loginUuid}` | `openId` | 600s | PC 扫码登录桥接 | -| `oms:wechat:oauth:state:{state}` | 随机 nonce | 300s | OAuth state 防 CSRF(可选) | - -### 3.3 与 WMS `sys_user_wechat` 的映射关系 - -| WMS | OMS | 迁移说明 | -|-----|-----|----------| -| `officialaccounts_openid` | `t_customer.WechatOpenid` | 按手机号/邮箱/客户编码对齐后一次性导入 | -| `nickname` | `WechatNickname` | 同步 | -| `headimgurl` | `WechatAvatar` | 同步 | -| `vip_id` → `sys_user` | `Customer.UserId` → RBAC `user` | 需业务侧建立客户映射,非本方案自动完成 | - ---- - -## 4. 后端方案 - -### 4.1 目录结构(建议) - -``` -src/SimpleAdmin.Api/ -├── WeChat/ -│ ├── Options/ -│ │ └── WeChatMpOptions.cs -│ ├── Controllers/ -│ │ ├── WeChatMpController.cs # 公众号回调 + 二维码 -│ │ └── WeChatCustomerController.cs # 注册 / 绑定 -│ ├── Handlers/ -│ │ ├── IWeChatMessageHandler.cs -│ │ ├── WeChatScanHandler.cs -│ │ └── WeChatSubscribeHandler.cs -│ ├── Services/ -│ │ ├── IWeChatMpService.cs -│ │ ├── WeChatMpService.cs -│ │ ├── IWeChatLoginSessionStore.cs -│ │ └── RedisWeChatLoginSessionStore.cs -│ └── Dtos/ -│ ├── WxLoginInput.cs -│ ├── WxLoginQrcodeOutput.cs -│ ├── WeChatOAuthUserInfoOutput.cs -│ ├── WeChatCustomerRegisterInput.cs -│ └── WeChatCustomerBindInput.cs -└── Auth/ - └── AuthService.cs # 新增 WxLoginAsync -``` - -### 4.2 配置项 - -Consul / `appsettings` 示例: +调用微信 `cgi-bin/qrcode/create`,参数: ```json { - "WeChat": { - "Mp": { - "AppId": "wxd5141ba46c856e1f", - "Secret": "********", - "Token": "********", - "EncodingAesKey": "********", - "OAuthRedirectBase": "https://oms.nanafa.com" - } - }, - "Redis": { - "ConnectionString": "localhost:6379,defaultDatabase=0" + "expire_seconds": 600, + "action_name": "QR_STR_SCENE", + "action_info": { + "scene": { "scene_str": "{loginKey}" } } } ``` -### 4.3 接口清单 +`scene_str` 直接使用 **loginKey 字符串**(≤64 字符)。 -所有接口前缀与现网 OMS API 一致:`https://api.nanafa.com/admin`(`ASPNETCORE_PATHBASE=/admin`)。 +`EventKey` 解析(`WeChatQrSceneParser`)支持: -#### 4.3.1 公众号服务器回调 +| 场景 | EventKey 示例 | 解析结果 | +|------|---------------|----------| +| 已关注扫码 | `abc123...` | loginKey | +| 未关注扫码后关注 | `qrscene_abc123...` | 去掉 `qrscene_` 前缀 | +| JSON 格式(兼容) | `{"type":"login","loginName":"..."}` | 取 `loginName` 或 `key` 字段 | -| 方法 | 路径 | 鉴权 | 说明 | -|------|------|------|------| -| GET | `/wechat/mp/{appId}` | 匿名 | 微信接入验签,返回 `echostr` | -| POST | `/wechat/mp/{appId}` | 匿名 | 接收消息/事件(SCAN、SUBSCRIBE 等) | +### 3.3 缓存 session -**GET 参数:** `signature`, `timestamp`, `nonce`, `echostr` - -**POST 参数:** 同微信文档;Body 为 XML(明文或 AES 加密) - -**路由分发:** - -| 事件 | Handler | 行为 | -|------|---------|------| -| `SCAN` | `WeChatScanHandler` | 已关注用户扫码 | -| `subscribe` | `WeChatSubscribeHandler` | 未关注用户扫码关注(需处理 `qrscene_` 前缀) | -| 其他 | 默认空响应或客服消息 | 按需扩展 | - ---- - -#### 4.3.2 获取登录二维码 - -| 方法 | 路径 | 鉴权 | -|------|------|------| -| GET | `/wechat/mp/login-qrcode` | 匿名 | - -**Query:** - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `loginUuid` | string | 是 | 前端生成的 UUID,标识一次登录会话 | - -**Response `200`:** +| 项 | 值 | +|----|-----| +| Key | `oms:wechat:login:{loginKey}` | +| TTL | 600 秒 | +| Value(JSON) | 见下表 | ```json { + "customerId": 42, + "customerCode": "C001", + "userId": 100, + "openId": "oXXXX" +} +``` + +扫码成功后写入;`wx-login` 成功后 **删除**,防止重复使用。 + +--- + +## 4. 接口说明 + +API 前缀:`https://api.nanafa.com/admin`(`ASPNETCORE_PATHBASE=/admin`) + +### 4.1 获取登录二维码 + +``` +GET /wechat/mp/login-qrcode?loginUuid={loginKey} +``` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `loginUuid` | 否 | 不传则后端自动生成 loginKey | + +**Response:** + +```json +{ + "loginKey": "a1b2c3d4e5f6...", "qrCodeUrl": "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=xxx", "expiresIn": 600 } ``` -**后端逻辑:** - -1. 校验 `loginUuid` 非空且格式合法(UUID) -2. 构造 scene JSON:`{"type":"login","loginUuid":"{loginUuid}"}` -3. 调用微信 `qrCodeCreateTmpTicket(scene, 600)` -4. 返回二维码图片 URL +**实现:** `AuthService.GetWeChatLoginQrcodeAsync` → `WeChatMpApiClient.CreateLoginQrCodeUrlAsync` --- -#### 4.3.3 微信扫码登录(轮询) +### 4.2 轮询扫码状态 -| 方法 | 路径 | 鉴权 | -|------|------|------| -| POST | `/rbac/auth/wx-login` | 匿名 | - -**Request:** - -```json -{ - "loginUuid": "550e8400-e29b-41d4-a716-446655440000" -} +``` +GET /rbac/auth/wx-login/status?loginUuid={loginKey} ``` -**Response `200`(`LoginResultDto`,与密码登录一致):** +**Response(未扫码):** + +```json +{ "ready": false } +``` + +**Response(已扫码且已绑定):** + +```json +{ "ready": true, "account": "C001" } +``` + +> 此接口不抛「请扫码登录」异常,专供前端静默轮询。 + +**实现:** `AuthService.WxLoginStatusAsync` + +--- + +### 4.3 完成登录 + +``` +POST /rbac/auth/wx-login +Content-Type: application/json + +{ "loginUuid": "{loginKey}" } +``` + +**Response(与密码登录一致 `LoginResultDto`):** ```json { @@ -310,483 +273,202 @@ Consul / `appsettings` 示例: } ``` -**错误码(业务异常 message):** +**错误 message:** | 场景 | message | |------|---------| -| Redis 无值 | `请扫码登录` | -| openId 未绑定客户 | `未绑定微信,请先注册或绑定账号` | -| 客户已禁用/冻结 | `客户已禁用` / `客户已冻结` | +| 缓存无值 | `请扫码登录` | +| 客户不存在 | `未绑定微信,请先注册或绑定账号` | | 子客户 | `子客户不支持微信扫码登录` | -| 未关联 RBAC 用户 | `客户未关联用户` | +| 禁用/冻结 | `客户已禁用` / `客户已冻结` | +| 未关联 RBAC | `客户未关联用户` | -**核心逻辑(`AuthService.WxLoginAsync`):** +**核心步骤(`AuthService.WxLoginAsync`):** -``` -1. openId = Redis.Get("oms:wechat:login:{loginUuid}") -2. customer = CustomerRepository.GetByWechatOpenidAsync(openId) -3. 校验:customer != null && ParentCustomerId == null -4. 校验:Status 非 Disabled / Frozen -5. 更新 LastLoginIp、LastLoginTime -6. userGuid = GetRbacUserGuidByMapIdAsync(customer.UserId) -7. claims = BuildClaimsAsync(userGuid, OmsLoginType.Customer) -8. return BuildLoginResult(userGuid, claims) -9. (可选)Redis.Delete(loginUuid) 防止重复使用 -``` - -与 `LoginCustomerAsync` 的差异:**跳过密码校验**,JWT 签发流程相同。 +1. 从缓存读取 session; +2. 按 `customerId` 或 `openId` 加载客户; +3. 校验主客户、状态正常; +4. 更新 `LastLoginIp`、`LastLoginTime`; +5. `GetRbacUserGuidByMapIdAsync(customer.UserId)` 取 RBAC 用户; +6. `BuildClaimsAsync(userGuid, OmsLoginType.Customer)` 签发 JWT; +7. 删除缓存 session。 --- -#### 4.3.4 OAuth 获取微信用户信息 +### 4.4 公众号回调(扫码事件入口) -| 方法 | 路径 | 鉴权 | +**现网配置 URL:** + +``` +https://oms.nanafa.com/api/apiwechat/verification +``` + +Nginx 反代至 `https://api.nanafa.com/admin/apiwechat/verification`。 + +| 方法 | 路径 | 说明 | |------|------|------| -| POST | `/wechat/oauth/user-info` | 匿名 | +| GET | `/apiwechat/verification` | 验签,返回 `echostr` | +| HEAD | `/apiwechat/verification` | 探活,返回 200 | +| POST | `/apiwechat/verification` | 接收扫码事件 XML | -**Request:** +备用路径(等价): -```json -{ - "code": "微信 OAuth 回调 code" -} +``` +GET/POST /wechat/mp/{appId} ``` -**Response `200`:** +**POST 处理链:** -```json -{ - "openid": "oXXXX", - "nickname": "张三", - "sex": 1, - "province": "广东", - "city": "深圳", - "country": "中国", - "headimgurl": "https://...", - "unionid": "uXXXX" -} -``` +1. `WeChatMpCallbackSupport.HandleMessageAsync` 验签; +2. `WeChatXmlMessageParser` 解析 XML; +3. `WeChatEventHandler.HandleAsync` 处理 `SCAN` / `subscribe`; +4. 返回 XML 文本回复或空响应。 -**逻辑:** - -1. `code` → `access_token` + `openid`(`sns/oauth2/access_token`) -2. `access_token` + `openid` → 用户信息(`sns/userinfo`) -3. 若该 `openid` 已绑定客户,返回业务提示码供前端展示「已绑定」 +**当前限制:** 仅支持**明文模式**;若公众平台开启 AES 加密,需后续扩展。 --- -#### 4.3.5 微信授权注册客户 - -| 方法 | 路径 | 鉴权 | -|------|------|------| -| POST | `/wechat/customer/register` | 匿名 | - -**Request:** - -```json -{ - "customerCode": "C20260001", - "customerName": "张三", - "phone": "13800138000", - "email": "a@example.com", - "password": "Abc@12345", - "wechat": { - "openid": "oXXXX", - "nickname": "张三", - "avatar": "https://...", - "unionid": "uXXXX" - } -} -``` - -**Response `200`:** 空体或 `{ "customerId": 1 }` - -**校验:** - -| 项 | 规则 | -|----|------| -| openid | 必填;`GetByWechatOpenidAsync` 不得已存在 | -| customerCode / phone / email | 复用 `CustomerService` 唯一性校验 | -| password | 强度校验(与现网一致) | -| 角色 | 自动分配 `Customer` 角色 | - -**逻辑:** - -1. 创建 RBAC User(Account 默认用手机号) -2. 创建 `t_customer`,写入 `WechatOpenid`、`WechatNickname`、`WechatAvatar`、`WechatBindTime` -3. 事务:User + Customer 同成功同回滚 - ---- - -#### 4.3.6 微信授权绑定已有客户 - -| 方法 | 路径 | 鉴权 | -|------|------|------| -| POST | `/wechat/customer/bind` | 匿名 | - -**Request:** - -```json -{ - "account": "13800138000", - "password": "******", - "wechat": { - "openid": "oXXXX", - "nickname": "张三", - "avatar": "https://...", - "unionid": "uXXXX" - } -} -``` - -**Response `200`:** 空体 - -**校验:** - -| 项 | 规则 | -|----|------| -| 账号密码 | 等同客户密码登录校验 | -| openid | 不得已被其他客户绑定 | -| 目标客户 | 主客户;`WechatOpenid` 必须为空 | -| 状态 | 客户非禁用/冻结 | - -**逻辑:** - -1. `FindCustomerByAccountAsync(account)` + 密码 MD5 校验 -2. 更新 `WechatOpenid`、`WechatNickname`、`WechatAvatar`、`WechatBindTime` -3. 可选同步 RBAC User 头像 - ---- - -#### 4.3.7 微信公众号内免密登录(可选,二期) - -| 方法 | 路径 | 鉴权 | -|------|------|------| -| POST | `/rbac/auth/wx-public-login` | 匿名 | - -**Request:** `{ "openid": "oXXXX" }` - -**说明:** 用于微信内 H5 页(如充值审核),Token 短时效(24h),非持久 Cookie。一期可不做。 - ---- - -### 4.4 事件处理逻辑 - -#### 4.4.1 SCAN(已关注用户扫码) +## 5. 后端代码结构 ``` -输入:FromUserName = openId, EventKey = scene JSON 字符串 -1. customer = GetByWechatOpenidAsync(openId) -2. if customer == null → 回复「未绑定账号,请通过菜单注册或绑定」 -3. 解析 EventKey JSON:type == "login" -4. loginUuid = json.loginUuid -5. Redis.Set("oms:wechat:login:{loginUuid}", openId, 600s) -6. 可选回复「登录成功,请返回电脑端」 -``` - -#### 4.4.2 SUBSCRIBE(未关注用户扫码后关注) - -``` -输入:EventKey = "qrscene_{sceneJson}" -1. content = EventKey.Replace("qrscene_", "") -2. 其余逻辑同 SCAN -``` - -> WMS 已知缺陷:未处理 `qrscene_` 前缀导致未关注用户扫码失败。OMS 必须实现。 - -#### 4.4.3 二维码 scene 格式 - -```json -{ - "type": "login", - "loginUuid": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -策略 B 下**不需要** `system` 字段。 - ---- - -### 4.5 鉴权白名单 - -以下路径 `[AllowAnonymous]`,且 JWT 中间件不拦截: - -``` -GET /wechat/mp/{appId} -POST /wechat/mp/{appId} -GET /wechat/mp/login-qrcode -POST /rbac/auth/wx-login -POST /wechat/oauth/user-info -POST /wechat/customer/register -POST /wechat/customer/bind +src/SimpleAdmin.Api/ +├── Auth/ +│ ├── AuthService.cs # WxLoginStatusAsync、WxLoginAsync +│ └── AuthService.WeChatMp.cs # 回调验签、login-qrcode +└── WeChat/ + ├── Dtos/WeChatDtos.cs # WxLoginQrcodeOutput、WxLoginStatusOutput + ├── WeChatMpCallbackSupport.cs # 验签、消息分发 + └── Services/ + ├── WeChatMpApiClient.cs # qrcode/create、OAuth + ├── WeChatEventHandler.cs # SCAN / subscribe 扫码登录 + ├── WeChatQrSceneParser.cs # EventKey → loginKey + ├── WeChatLoginSessionPayload.cs + ├── IWeChatLoginSessionStore.cs + └── WeChatLoginSessionStore.cs ``` --- -### 4.6 领域层改动清单 +## 6. 前端实现 -| 文件 | 改动 | +### 6.1 相关文件 + +| 文件 | 职责 | |------|------| -| `Customer.cs` | 可选增加 `WechatUnionid`、`WechatBindTime` | -| `ICustomerRepository` | 已有 `GetByWechatOpenidAsync`,无需改 | -| `CustomerRepository.cs` | 已有实现 | -| `CustomerService.cs` | 抽取 `BuildCustomerEntity` 支持写入 `WechatOpenid`;或注册逻辑放 `WeChatCustomerController` 专用 Service | -| `AuthService.cs` | 新增 `WxLoginAsync` | -| `CreateCustomerInput` | 微信注册走独立 DTO,不污染管理端创建接口 | +| `src/views/login/components/wechat-login.vue` | 二维码展示、轮询、登录跳转 | +| `src/api/wechat/index.ts` | `getLoginQrcode`、`getWxLoginStatus`、`wxLogin` | +| `src/api/wechat/types.ts` | 类型定义 | +| `src/store/modules/rbac-user.ts` | `loginByWxResult` 存 Token 并拉用户信息 | ---- +### 6.2 轮询参数 -## 5. 前端方案 +| 常量 | 值 | +|------|-----| +| 轮询间隔 | 3000 ms | +| 二维码有效期 | 600000 ms(10 分钟) | -### 5.1 页面与路由 +### 6.3 前端逻辑摘要 -| 路由 | 组件 | 门户 | 说明 | -|------|------|------|------| -| `/` | `views/login/index.vue` | 客户 | 已有;微信 Tab 对接真实接口 | -| `/admin` | 同上 | 管理端 | 不展示微信入口(已实现 `isCustomerLogin`) | -| `/wechat/register` | `views/wechat/register/index.vue` | 公开 H5 | 公众号菜单「注册」 | -| `/wechat/bind` | `views/wechat/bind/index.vue` | 公开 H5 | 公众号菜单「绑定账号」 | - -**路由注册(`src/router/index.ts`):** - -```typescript -{ - path: '/wechat/register', - name: 'WechatRegister', - component: () => import('@/views/wechat/register/index.vue'), - meta: { requiresAuth: false }, -}, -{ - path: '/wechat/bind', - name: 'WechatBind', - component: () => import('@/views/wechat/bind/index.vue'), - meta: { requiresAuth: false }, -}, -``` - -### 5.2 API 封装 - -新建 `src/api/wechat/index.ts`: - -| 方法 | 请求 | 说明 | -|------|------|------| -| `getLoginQrcode(loginUuid)` | `GET /wechat/mp/login-qrcode?loginUuid=` | 获取二维码 | -| `wxLogin(loginUuid)` | `POST /rbac/auth/wx-login` | 轮询登录 | -| `getOAuthUserInfo(code)` | `POST /wechat/oauth/user-info` | H5 授权 | -| `registerByWechat(data)` | `POST /wechat/customer/register` | 注册 | -| `bindByWechat(data)` | `POST /wechat/customer/bind` | 绑定 | - -请求走现有 RBAC axios 实例(`OpenAPI.BASE` = admin API),微信登录相关接口需 `skipAuth: true`(不携带 Token)。 - -### 5.3 `wechat-login.vue` 改造要点 - -参考 WMS `wms0414/src/views/Login/components/wechat-login.vue`,适配 OMS: - -``` +```text 1. onMounted → loadQrCode() -2. loginUuid = uuidv4() -3. GET login-qrcode → 展示 qrCodeUrl -4. setInterval 每 3s → POST wx-login -5. 成功 → rbacUserStore 存 Token + setLoginPortalType(Customer) + getCurrentUser() + 跳转首页 -6. setTimeout 600000 → 停止轮询(二维码过期) -7. 刷新按钮 → 重新 loadQrCode() +2. loginKey = crypto.randomUUID().replace(/-/g, '') +3. GET login-qrcode(loginKey) → 展示 qrCodeUrl,使用返回的 loginKey +4. setInterval 3s: + a. GET wx-login/status → ready=false 则继续 + b. ready=true → POST wx-login → loginByWxResult → 跳转首页 +5. setTimeout 600s → 停止轮询 +6. 刷新按钮 → 重新 loadQrCode() ``` -**`rbacUserStore` 扩展:** +--- -```typescript -async function loginByWxResult(result: LoginResultDto) { - saveTokens(result); - setLoginPortalType(OmsLoginType.Customer); - await getCurrentUser(); +## 7. 数据与配置 + +### 7.1 客户表 `t_customer` + +扫码登录依赖字段: + +| 字段 | 说明 | +|------|------| +| `WechatOpenid` | 公众号 openId,扫码时匹配微信用户 | +| `UserId` | 关联 RBAC 用户,用于签发 JWT | +| `CustomerCode` | 写入缓存,status 接口返回给前端 | +| `ParentCustomerId` | 非 NULL 时拒绝扫码登录(仅主客户) | +| `Status` | Disabled / Frozen 时拒绝登录 | + +### 7.2 公众号配置 + +配置来源:`t_system_other_config`(`config_code = wechat_mp`)或 `appsettings` + +| 配置项 | 说明 | +|--------|------| +| AppKey | 公众号 AppId | +| AppSecret | 公众号 Secret | +| Remark | 服务器 Token | + +**公众平台服务器配置:** + +| 项 | 值 | +|----|-----| +| URL | `https://oms.nanafa.com/api/apiwechat/verification` | +| Token | 与数据库/AppSettings 一致 | +| 消息加解密 | **明文**(当前实现) | + +### 7.3 Nginx + +`nginx-default.conf` 关键规则: + +```nginx +# 微信回调 +location ^~ /api/apiwechat/ { + rewrite ^/api/apiwechat/(.*)$ /admin/apiwechat/$1 break; + proxy_pass https://api.nanafa.com; } ``` -### 5.4 H5 注册页流程 - -``` -公众号菜单 → 微信 OAuth 授权 - → 回调 https://oms.nanafa.com/wechat/register?code=xxx - → POST /wechat/oauth/user-info { code } - → 若 openid 已绑定 → 提示已注册 - → 否则展示表单(客户编码、名称、手机、邮箱、密码) - → POST /wechat/customer/register - → 成功提示「可返回 PC 扫码登录」 -``` - -### 5.5 H5 绑定页流程 - -``` -公众号菜单 → OAuth → /wechat/bind?code=xxx - → getOAuthUserInfo - → 表单:账号 + 密码 - → POST /wechat/customer/bind - → 成功提示 -``` - -### 5.6 国际化 - -在 `src/views/login/locale/zh-CN.ts` / `en-US.ts` 补充: - -| Key | 中文 | -|-----|------| -| `login.wechat.qrValid` | 二维码十分钟有效 | -| `login.wechat.scanPending` | 请使用微信扫一扫 | -| `login.wechat.notBound` | 未绑定微信,请先在公众号注册或绑定 | - -### 5.7 前端文件清单 - -| 操作 | 路径 | -|------|------| -| 改造 | `src/views/login/components/wechat-login.vue` | -| 新增 | `src/api/wechat/index.ts` | -| 新增 | `src/views/wechat/register/index.vue` | -| 新增 | `src/views/wechat/bind/index.vue` | -| 修改 | `src/store/modules/rbac-user.ts`(`loginByWxResult`) | -| 修改 | `src/router/index.ts`(H5 路由) | -| 修改 | `src/views/login/locale/*.ts` | - --- -## 6. 微信公众号配置 - -### 6.1 服务器配置(迁移到 OMS) - -| 配置项 | 值 | -|--------|-----| -| URL | `https://api.nanafa.com/admin/wechat/mp/{AppId}` | -| Token | 与 `WeChat:Mp:Token` 一致 | -| EncodingAESKey | 与配置一致 | -| 消息加解密方式 | 安全模式(推荐)或明文 | - -### 6.2 网页授权域名 - -``` -oms.nanafa.com -``` - -### 6.3 自定义菜单(示例) - -| 菜单 | 类型 | 链接 | -|------|------|------| -| 注册账号 | 网页授权 | `https://open.weixin.qq.com/connect/oauth2/authorize?appid={AppId}&redirect_uri={encode(oms.nanafa.com/wechat/register)}&response_type=code&scope=snsapi_userinfo&state=register#wechat_redirect` | -| 绑定账号 | 网页授权 | 同上,`redirect_uri` 指向 `/wechat/bind` | - -### 6.4 迁移检查清单 - -- [ ] 微信公众平台服务器 URL 从 WMS 改为 OMS -- [ ] 点击「启用」后 OMS 验签接口返回正确 `echostr` -- [ ] WMS 侧公众号相关定时任务/模板消息评估停用或迁移 -- [ ] 确认 AppId / Secret 写入 OMS Consul - ---- - -## 7. 部署与 Nginx - -### 7.1 API 路径 - -现网 OMS API:`https://api.nanafa.com/admin/*` - -微信回调完整 URL: - -``` -https://api.nanafa.com/admin/wechat/mp/wxd5141ba46c856e1f -``` - -无需额外 nginx 规则(已代理到 admin 服务),需确认: - -- `proxy_read_timeout` ≥ 30s(微信回调一般足够) -- 支持 POST `application/xml` 或 `text/xml` - -### 7.2 前端 H5 - -`oms.nanafa.com` 静态站点已存在;新增 `/wechat/*` 走路由回退 `index.html`(与 SPA 一致)。 - ---- - -## 8. 实施阶段 - -### 阶段 1:后端基础(2 天) - -- [ ] 引入微信 SDK + Redis -- [ ] `WeChatMpOptions` + Consul 配置 -- [ ] `WeChatMpController` 验签 + 消息入口 -- [ ] `WeChatScanHandler` / `WeChatSubscribeHandler` -- [ ] `RedisWeChatLoginSessionStore` -- [ ] 数据库索引脚本 - -### 阶段 2:扫码登录闭环(2 天) - -- [ ] `GET /wechat/mp/login-qrcode` -- [ ] `POST /rbac/auth/wx-login` -- [ ] 前端 `wechat-login.vue` 对接 -- [ ] `rbacUserStore.loginByWxResult` - -### 阶段 3:注册 / 绑定(2 天) - -- [ ] `POST /wechat/oauth/user-info` -- [ ] `POST /wechat/customer/register` -- [ ] `POST /wechat/customer/bind` -- [ ] H5 页面 + 路由 -- [ ] 公众号菜单配置 - -### 阶段 4:切流与验收(1 天) - -- [ ] 公众号服务器 URL 切换到 OMS -- [ ] 全链路测试(见第 9 节) -- [ ] WMS 微信登录入口下线或隐藏 -- [ ] (可选)WMS openId 数据迁移脚本 - ---- - -## 9. 测试用例 +## 8. 测试用例(扫码登录) | # | 场景 | 预期 | |---|------|------| -| 1 | 已绑定客户 PC 扫码 | 3s 内轮询成功,跳转首页,JWT 有效 | -| 2 | 未绑定客户扫码 | 公众号收到提示;PC 轮询返回「请扫码登录」或超时 | -| 3 | 二维码 10 分钟过期 | 前端停止轮询;刷新可重新获取 | -| 4 | 未关注公众号用户扫码 | SUBSCRIBE 事件正确处理 `qrscene_` | -| 5 | 微信注册新客 | 客户表写入 openId,随后可扫码登录 | -| 6 | 微信绑定已有账号 | 绑定后扫码可登录 | -| 7 | 重复绑定同一 openId | 拒绝 | -| 8 | 子客户 openId 绑定主客户流程外 | 子客户扫码登录被拒绝 | -| 9 | 禁用/冻结客户扫码 | 登录失败,明确提示 | -| 10 | 管理端 `/admin` | 不显示微信入口 | -| 11 | 公众号验签 GET | 返回 echostr | -| 12 | Token 刷新 | 微信登录后 refresh-token 正常 | +| 1 | 已绑定客户 PC 扫码 | status 返回 ready=true;wx-login 成功;跳转首页 | +| 2 | 未绑定客户扫码 | 手机收到「未绑定账号」;PC status 一直 ready=false | +| 3 | 二维码 10 分钟过期 | 前端停止轮询;点刷新重新获取 | +| 4 | 未关注公众号用户扫码 | subscribe 事件正确处理 `qrscene_` 前缀 | +| 5 | 子客户扫码 | wx-login 返回「子客户不支持微信扫码登录」 | +| 6 | 禁用/冻结客户扫码 | wx-login 返回对应状态提示 | +| 7 | 公众号验签 GET | 合法签名返回 echostr | +| 8 | 登录成功后重复 wx-login | 同一 key 已删除,返回「请扫码登录」 | +| 9 | 管理端 `/admin` | 不显示微信入口 | --- -## 10. 风险与回滚 +## 9. 注意事项 -| 风险 | 缓解 | -|------|------| -| 公众号 URL 切换后 WMS 微信能力立即失效 | 选低峰期切换;提前通知;准备回滚 URL | -| Redis 不可用 | 扫码登录不可用;密码登录不受影响 | -| 老客户无 openId | 提供绑定 H5 + 可选批量迁移脚本 | -| 多实例 API | 必须使用 Redis,禁止 `IMemoryCache` 做单机桥接 | - -**回滚:** 微信公众平台服务器 URL 改回 WMS 地址,OMS 前端隐藏微信入口。 +| 项 | 说明 | +|----|------| +| 多实例部署 | 必须使用 **Redis** 作为 `IDistributedCache`;单机 MemoryCache 在负载均衡下扫码与轮询可能打到不同节点导致失败 | +| 缓存与二维码 TTL | 均为 600 秒,保持一致 | +| 密码登录不受影响 | 缓存或微信异常时,客户仍可用账号密码登录 | +| 集成测试 | `WeChatScanLoginIntegrationTests` 覆盖扫码事件 → 写缓存 → status 返回 ready | --- -## 11. 附录:与 WMS 接口对照 +## 10. 接口速查 -| 能力 | WMS (Java) | OMS (C#) | -|------|------------|----------| -| 二维码 | `GET /apiwechat/loginqrcode` | `GET /wechat/mp/login-qrcode` | -| 轮询登录 | `POST /wxlogin` | `POST /rbac/auth/wx-login` | -| 公众号回调 | `GET/POST /apiwechat/{appId}` | `GET/POST /wechat/mp/{appId}` | -| OAuth 用户信息 | `POST /wechat/officialAccounts/getWxUserinfo` | `POST /wechat/oauth/user-info` | -| 注册 | `POST /wechat/wxUserAdd` | `POST /wechat/customer/register` | -| 绑定 | `POST /wechat/wxUserBinding` | `POST /wechat/customer/bind` | -| 绑定存储 | `sys_user_wechat` | `t_customer.WechatOpenid` | -| 会话桥接 | Redis `loginuuid→openId` | Redis `oms:wechat:login:{loginUuid}` | -| 认证 | Sa-Token | JWT `LoginResultDto` | +| 步骤 | 方法 | 路径 | +|------|------|------| +| 1. 获取二维码 | GET | `/wechat/mp/login-qrcode` | +| 2. 轮询状态 | GET | `/rbac/auth/wx-login/status` | +| 3. 完成登录 | POST | `/rbac/auth/wx-login` | +| 4. 扫码回调 | POST | `/apiwechat/verification` | ---- +完整 URL 示例: -## 12. 总结 - -本方案采用**策略 B**:同一公众号的服务器配置**独占 OMS**,不做 WMS/OMS 双系统回调分流。OMS 复用 WMS 已验证的「二维码 + 事件 + Redis + 轮询」模式,结合现有 `t_customer.WechatOpenid` 与 JWT 认证体系,实现客户门户微信扫码登录及 H5 注册绑定。实施顺序建议:**Redis + 回调 → 扫码登录 → 注册绑定 → 公众号切流**。 +``` +GET https://api.nanafa.com/admin/wechat/mp/login-qrcode?loginUuid=abc123 +GET https://api.nanafa.com/admin/rbac/auth/wx-login/status?loginUuid=abc123 +POST https://api.nanafa.com/admin/rbac/auth/wx-login +POST https://oms.nanafa.com/api/apiwechat/verification +``` diff --git a/docs/数据权限设计.md b/docs/数据权限设计.md new file mode 100644 index 0000000..8bd852a --- /dev/null +++ b/docs/数据权限设计.md @@ -0,0 +1,1048 @@ +# 多维度组合数据权限设计方案 PRD + +## 文档基础信息 + +| 项目 | 内容 | +| ---- | ---- | +| 文档名称 | 多维度交集式数据权限标准方案 | +| 适用系统 | OMS 业务系统 + RBAC 管理端 | +| 关联文档 | [`rbac系统设计-060801.md`](./rbac系统设计-060801.md)、[`oms系统设计-050801.md`](./oms系统设计-050801.md) | +| 权限架构 | **身份认证** → **功能权限(RBAC PermissionCode)** → **数据权限(本方案)**,三层解耦 | +| 版本 | V1.2 | +| 设计目标 | 在现有 RBAC 功能权限之上,为**内部用户**补齐多维数据过滤;与客户门户 **CustomerId 硬隔离** 并存、互不替代 | + +--- + +## 导读:与 RBAC / OMS 的分工 + +### 三层权限模型 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 身份认证(Authentication) │ +│ JWT:sub(GUID) + mapId(INT) + loginType(0内部/1客户) │ +│ 详见 rbac §4 │ +├─────────────────────────────────────────────────────────────┤ +│ 功能权限(Authorization · 能做什么) │ +│ 用户角色 → role_permission → PermissionCode 并集 P │ +│ 控制:菜单、按钮、API 是否可调用 │ +│ 详见 rbac §5 │ +├─────────────────────────────────────────────────────────────┤ +│ 数据权限(Data Scope · 能看到哪些行) ← 本文 │ +│ 五维交集过滤,仅后端注入查询条件 │ +│ 控制:列表/详情/导出/统计 返回的数据行范围 │ +└─────────────────────────────────────────────────────────────┘ +``` + +**关键原则**: + +1. 有 `Track.Orders.Query` 功能权限,不代表能看全部订单;还须通过数据权限过滤。 +2. 数据权限**不能**替代功能权限;二者 AND 关系:**先鉴权码、再滤数据**。 +3. **客户门户(`loginType=1`)不走本文五维模型**,走 OMS 既有 **CustomerId 硬隔离**(见 oms §4.4.0、`OmsSessionHelper`)。 +4. **内部用户(`loginType=0`)** 才启用本文多维数据权限;超管白名单豁免。 + +### 双登录主体与数据隔离策略 + +| 主体 | JWT `loginType` | 数据隔离机制 | 配置入口 | +| ---- | --------------- | ------------ | -------- | +| **客户 / 子客户** | 1 | `CustomerId = effectiveCustomerId`(会话解析,不可绕过) | 无需数据权限配置;子客户功能范围由 RBAC 专属角色 `CustomerSub_{userId}` 控制 | +| **内部用户** | 0 | 本文五维交集 + 可选 `CustomerId` 等业务维度 | RBAC 管理端「数据权限」Tab / 权限模板 | +| **超级管理员** | 0 | 角色 `Code=Admin` → **跳过全部数据维度** | 内置,不可关闭 | + +### 双 ID 对齐(必须与 RBAC 一致) + +| 标识 | 类型 | 数据权限表 / 业务表用法 | +| ---- | ---- | ------------------------ | +| `user.Id` | CHAR(36) GUID | **数据权限配置表** 外键 `UserId`;组织权限 `OrganizationId` | +| `user.userid` | INT 自增 | JWT Claim **`mapId`**;OMS 审计字段 **`CreateUserId` / `UpdateUserId`** | +| `organization.Id` | CHAR(36) GUID | 组织维度过滤;**禁止**使用自增 bigint 作为 org_id | +| `t_customer.Id` | BIGINT | OMS 业务 **`CustomerId`**;销售负责范围过滤时的业务键 | + +> **修正(相对 V1.0)**:V1.0 中 `user_id bigint`、`org_id bigint` 与现网 RBAC 不一致,统一改为 GUID;OMS 侧负责人仍用 `CreateUserId`(INT mapId)。 + +--- + +## 一、需求概述 + +### 1.1 业务痛点 + +原有仅依靠 `user.OrganizationId`(单组织归属)+ 管理员无过滤(`ResolveListCustomerIdAsync` 返回 null)存在以下问题: + +1. 同部门不同岗位(销售 vs 客服 vs 财务)数据可见范围无法区分; +2. 产品、运营、总部职能岗需要**跨分公司**查看指定产品线/区域数据; +3. 营收、客户隐私、成本等敏感字段无法独立管控与脱敏; +4. 不支持时间、金额、单据状态等**岗位级**动态范围限制; +5. 临时授权无时效回收;内部用户查单/查客无审计追溯; +6. 客户门户已有 CustomerId 隔离,但**运营后台**仍「登录即全量」,与合规要求不符。 + +### 1.2 核心设计原则 + +1. **功能权限与数据权限解耦**(见导读三层模型)。 +2. **多维度取交集**(内部用户): + ``` + 可见数据 = 组织过滤 ∩ 数据范围 ∩ 业务标签 ∩ 密级 ∩ 自定义条件 + ``` +3. **密级最高优先级**:无密级权限的行直接过滤;有权限但字段级敏感则**脱敏展示**(仍返回行)。 +4. **最小权限原则**:新建内部用户默认**无扩展数据权限**(仅组织默认范围,见 §2.1.4);须管理员分配或套用模板。 +5. **超级管理员白名单**:角色 `Admin` 跳过五维过滤。 +6. **后端唯一可信**:全部过滤在 Service/Repository 层注入;前端不做数据拦截。 +7. **分模块接入**:订单、客户、充值、渠道配置等按 `DataResourceCode` 注册,统一走 `IDataScopeApplier`。 + +### 1.3 五大权限控制维度(仅内部用户) + +| 维度 | 回答的问题 | 典型 OMS 落点 | +| ---- | ---------- | ------------- | +| 1. 组织 | 哪个分公司/部门的数据? | 订单扩展字段 `OrganizationId`;客户列表按销售组织 | +| 2. 数据范围 | 本人 / 本组 / 本部门 / 组织树 / 全局? | `CreateUserId`、`t_customer.Salesman` | +| 3. 业务标签 | 哪条产品线/区域/KPI 分区? | 销售产品、KPI 分区、客户分组 `CustomerGroup` | +| 4. 数据密级 | 能否看机密/绝密字段? | 费用明细、成本、利润、客户手机号 | +| 5. 自定义过滤 | 时间/金额/状态等附加条件? | 本年度订单、金额>1万、排除作废 | + +--- + +## 二、详细功能需求 + +### 2.1 维度 1:组织数据权限 + +#### 2.1.1 组织架构模型(对齐 RBAC `organization`) + +复用 RBAC 组织树,**不另建组织主数据**: + +``` +公司 → 事业部 → 分公司 → 部门 → 小组 +``` + +| RBAC 字段 | 说明 | +| --------- | ---- | +| `organization.Id` | GUID,数据权限过滤键 | +| `organization.ParentId` | 树形父节点 | +| `organization.Type` | `OrganizationType`:1/2/3(与 RBAC 枚举一致) | +| `user.OrganizationId` | 用户**主归属**组织,作默认数据范围起点 | + +#### 2.1.2 组织权限范围枚举 + +| 编码 | 名称 | 规则说明 | +| ---- | ---- | -------- | +| 1 | 仅本级组织 | 仅 `OrganizationId = 授权组织`,不含下级 | +| 2 | 本级 + 所有下级 | 授权组织及其子树(管理岗默认) | + +#### 2.1.3 业务规则 + +1. 平级组织默认互相隔离; +2. 支持**一人多组织**授权(多条 `user_org_perm`); +3. 支持**跨组织白名单**(总部财务、审计勾选多个分公司); +4. 用户主归属 `user.OrganizationId` 在未单独配置时,作为**默认组织范围**(本级+下级)。 + +#### 2.1.4 与 OMS 业务表映射 + +| 业务实体 | 组织字段策略 | 说明 | +| -------- | ------------ | ---- | +| `t_order` | **新增** `OrganizationId CHAR(36) NULL` | 创建时取自客户归属组织或操作人主组织;历史数据可批量回填 | +| `t_customer` | **新增** `OrganizationId CHAR(36) NULL` | 客户归属销售组织;与 `Salesman` 联动维护 | +| `t_recharge_record` | 继承 `CustomerId → t_customer.OrganizationId` | 查询时 JOIN,不必冗余 | +| 渠道/系统主数据 | 默认**不受组织维限制** | 通过功能权限 + 密级控制;若需按组织隔离再扩展 | + +### 2.2 维度 2:角色数据范围 + +#### 2.2.1 数据范围枚举 + +| 编码 | 名称 | SQL 语义(以订单为例) | +| ---- | ---- | ---------------------- | +| 1 | 个人级 | `CreateUserId = 当前 mapId` 或 `t_customer.Salesman = 当前用户账号` | +| 2 | 小组级 | 同 `user.OrganizationId` 且组织 Type=小组 的全体 mapId | +| 3 | 部门级 | 同部门组织子树内所有用户的 mapId | +| 4 | 组织树级 | 以授权组织为根的子树内全部数据 | +| 5 | 全局级 | 不追加负责人过滤(仍受组织/业务/密级/自定义约束) | + +#### 2.2.2 与 RBAC 角色的关系 + +| 配置方式 | 优先级 | 说明 | +| -------- | ------ | ---- | +| **角色级默认** `role_data_scope` | 低 | 角色绑定数据范围,用户继承其所有角色的**最大值**(取最宽) | +| **用户级覆盖** `user_data_scope` | 高 | 显式配置则覆盖角色默认 | + +> 示例:用户同时有「销售专员」(个人级) 和「销售主管」(部门级) 两角色 → 有效范围为 **部门级**(取 max)。 + +#### 2.2.3 OMS 业务配套字段 + +| 字段 | 表 | 用途 | +| ---- | -- | ---- | +| `CreateUserId` | 各 OMS 业务表 | INT,= `user.userid`;个人级过滤 | +| `Salesman` | `t_customer` | VARCHAR,可与 `user.Account` 对应;销售看自己的客户 | +| `CustomerService` | `t_customer` | 客服负责范围(可选启用) | +| `CustomerId` | `t_order` 等 | 先按客户归属,再按客户负责人收窄 | + +**内部用户查订单推荐链路**: + +``` +t_order + → CustomerId → t_customer(Salesman / OrganizationId) + → CreateUserId(下单操作人) +按 data_scope 选用 CreateUserId 或 Salesman 或组织子树 +``` + +### 2.3 维度 3:业务维度权限(跨组织) + +#### 2.3.1 业务分类类型(OMS 落地) + +| biz_type 编码 | 名称 | OMS 数据源 | +| ------------- | ---- | ---------- | +| `product_line` | 产品线 | `t_sales_product` / 产品分类 | +| `kpi_area` | KPI 分区 | `t_kpi_area` | +| `customer_group` | 客户分层 | `t_customer.CustomerGroup`(0 普通,1~5 VIP) | +| `warehouse` | 仓库 | `t_warehouse_address.Id` | +| `region` | 区域 | 字典或 KPI 分区上级(可扩展) | + +#### 2.3.2 控制规则 + +1. 独立于组织架构,支持多选;**OR** 语义:命中任一已授权标签即可; +2. 与组织维度 **AND**:须同时满足组织范围 **且** 业务标签; +3. 用户未配置任何业务标签 = **不限制业务标签**(与 V1.0「无标签=无权限」不同,避免过度收紧;若需强制可 per-module 配置 `RequireBizTag=true`); +4. 业务表映射: + +| 表 | 过滤字段 | +| -- | -------- | +| `t_order` | `ProductCode` → 产品 → 产品线;`ConsigneeCountry` + KPI 分区 | +| `t_customer` | `CustomerGroup` | +| 渠道配置 | 直接按产品/分区 Id | + +### 2.4 维度 4:数据密级权限(行级 + 字段级) + +#### 2.4.1 密级分级 + +| 编码 | 名称 | 典型数据 | +| ---- | ---- | -------- | +| 1 | 公开级 | 运单号、状态、路由 | +| 2 | 内部级 | 应收费用、客户名称 | +| 3 | 机密级 | 应付/成本、毛利、完整地址 | +| 4 | 绝密级 | 手机号、身份证、银行信息、完整利润 | + +#### 2.4.2 两级控制 + +| 级别 | 行为 | +| ---- | ---- | +| **行级** | `secret_level` 高于用户授权 → **整行不返回** | +| **字段级** | 行可见但字段密级超限 → **脱敏**(如 `138****1234`),接口仍 200 | + +#### 2.4.3 业务表字段 + +| 表 | 策略 | +| -- | ---- | +| `t_order` | 表级默认密级 2;`t_order_fee` 应付行密级 3 | +| `t_customer` | 表级密级 3;Phone/Email 字段密级 4 | +| `t_recharge_record` | 密级 3(含金额) | + +新增通用字段(按需,非全表强制): + +- `SecretLevel TINYINT NOT NULL DEFAULT 2` + +### 2.5 维度 5:自定义行过滤条件 + +#### 2.5.1 支持的条件类型 + +| 类型 | 示例 | 适用表 | +| ---- | ---- | ------ | +| 时间 | 本年度、近 3 个月、自定义区间 | `t_order.CreateTime` | +| 数值 | 应收合计 > 10000 | `t_order_fee` 聚合 | +| 枚举 | `OrderStatus IN (...)` | `t_order` | +| 渠道 | 指定 `ProductCode` / `ChannelCode` | `t_order` | + +#### 2.5.2 安全实现 + +1. 可视化表单 → AST → SqlSugar 表达式,**禁止**管理员输入原生 SQL; +2. 按 `DataResourceCode`(如 `Order`、`Customer`、`Recharge`)区分规则; +3. 条件仅 **AND** 追加到交集结果,不改变业务接口原有筛选参数语义。 + +### 2.6 特殊权限:临时数据权限 + +| 项 | 说明 | +| -- | ---- | +| 场景 | 审计、跨部门对账、临时顶岗 | +| 配置 | 关联用户 GUID、全套五维副本、生效/过期时间 | +| 存储 | 各 `user_*_perm` 表 `ExpireTime` 非空;或独立 `user_data_perm_grant` 快照 | +| 生效 | `EffectiveTime <= NOW() < ExpireTime` 的记录参与计算;过期自动失效 | +| 审计 | 标记 `IsTemporary=1`;访问日志单独索引 | + +### 2.7 权限模板 + +预设岗位模板,一键写入角色或用户的数据权限配置: + +| 模板 | 组织 | 数据范围 | 业务 | 密级 | +| ---- | ---- | -------- | ---- | ---- | +| 普通销售 | 主归属、仅本级 | 个人级 | 指定产品线 | 1、2 | +| 销售主管 | 本级+下级 | 部门级 | 全部产品线 | 1、2、3 | +| 分公司经理 | 本级+下级 | 组织树级 | 全部 | 1、2、3 | +| 总部产品经理 | 多组织白名单 | 全局级 | 单一产品线 | 1、2 | +| 财务专员 | 本级+下级 | 组织树级 | 全部 | 含 3、4(字段脱敏策略另配) | +| 客服专员 | 本级 | 个人级 | — | 1、2(客户手机号脱敏) | + +支持自定义模板 CRUD;模板可绑定 RBAC **角色 Code**,新用户分配角色时自动继承。 + +--- + +## 三、后端核心权限计算逻辑 + +### 3.1 通用计算公式 + +记当前内部用户有效配置(已合并多角色、已过滤过期临时授权): + +- `OrgFilter`:组织维度 SQL 条件 +- `ScopeFilter`:数据范围条件 +- `BizFilter`:业务标签条件(空配置 = 恒真) +- `SecretFilter`:密级条件 +- `CustomFilter`:自定义条件 + +**最终查询条件**: + +``` +WHERE IsDeleted = 0 + AND (Admin ? TRUE : ( + OrgFilter + AND ScopeFilter + AND BizFilter + AND SecretFilter + AND CustomFilter + )) + AND <业务接口原有筛选> +``` + +**伪代码**: + +```csharp +async Task>> BuildDataScopeAsync( + DataResourceCode resource, + ICurrentUser user) +{ + if (user.IsInRole("Admin")) return _ => true; + if (OmsSessionHelper.IsCustomerLogin(user)) + throw new InvalidOperationException("客户门户走 CustomerId 隔离"); + + var ctx = await _dataPermCache.GetOrLoadAsync(user.Id); // GUID + var parts = new List>>(); + + parts.Add(BuildOrgFilter(ctx, resource)); + parts.Add(BuildScopeFilter(ctx, user.GetId(), resource)); + parts.Add(BuildBizFilter(ctx, resource)); + parts.Add(BuildSecretFilter(ctx)); + parts.Add(BuildCustomFilter(ctx, resource)); + + return parts.Aggregate((a, b) => a.And(b)); +} +``` + +### 3.2 超级管理员豁免 + +满足任一条件跳过五维过滤: + +1. 用户角色包含 `Code = Admin`(RBAC 种子超管); +2. 或显式 `user.IsSuperAdmin = 1`(扩展字段,可选)。 + +仍受 **功能权限码** 约束(若无 `Track.Orders.Query` 仍不可调用接口)。 + +### 3.3 与客户门户的并存规则 + +**同一套路由 `/admin/order/*`(服务内 `/order/*`)**,不按门户拆 URL;在 `OrderService` 入口按 `loginType` 分支: + +| loginType | 隔离方式 | +| --------- | -------- | +| 1 客户 | `OmsSessionHelper` → `CustomerId = effectiveCustomerId` | +| 0 内部 + Admin | 不加五维 | +| 0 内部 + 非 Admin | `DataScopeApplier` 五维交集 | + +```mermaid +flowchart TD + A["POST /admin/order/list"] --> B{loginType?} + B -->|1 客户| C[CustomerId 硬隔离] + B -->|0 内部| D{Admin?} + D -->|是| E[无五维过滤] + D -->|否| F[DataScopeApplier] + C --> G[查询] + E --> G + F --> G +``` + +### 3.4 演算示例 + +**用户**:华南分公司销售经理(非 Admin) + +| 维度 | 配置 | +| ---- | ---- | +| 组织 | 华南分公司,本级+下级 | +| 数据范围 | 组织树级 | +| 业务 | 线上零售产品线 | +| 密级 | 1、2、3 | +| 自定义 | 2026 年、已付款 | + +**订单列表 SQL 语义**: + +```sql +WHERE t_order.IsDeleted = 0 + AND t_order.OrganizationId IN (华南分公司子树 GUID 列表) + AND t_order.ProductCode IN (线上零售产品 Code 列表) + AND t_order.SecretLevel <= 3 + AND YEAR(t_order.CreateTime) = 2026 + AND t_order.OrderStatus = 'pendingPayment' -- 已付款态按业务枚举调整 + -- 不含 CustomerId 全量;内部运营后台不按单客户隔离 +``` + +--- + +## 四、数据库设计 + +> RBAC 库(EF)与 OMS 库(SqlSugar)可同实例不同表;**数据权限配置表建议放 RBAC 库**,与 `user`/`role`/`organization` 同库 FK。 + +### 4.1 用户组织权限 `user_org_perm` + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| Id | CHAR(36) | PK | +| UserId | CHAR(36) | FK → `user.Id` | +| OrganizationId | CHAR(36) | FK → `organization.Id` | +| OrgScope | TINYINT | 1 仅本级 / 2 本级+下级 | +| EffectiveTime | DATETIME | 生效时间,默认创建时 | +| ExpireTime | DATETIME | NULL=永久 | +| IsTemporary | TINYINT | 0/1 | +| CreateTime | DATETIME | | + +### 4.2 角色 / 用户数据范围 + +**`role_data_scope`** + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| Id | CHAR(36) | PK | +| RoleId | CHAR(36) | FK → `role.Id` | +| DataScope | TINYINT | 1~5 | +| DataResourceCode | VARCHAR(32) | 可选,空=全局默认 | + +**`user_data_scope`**(用户级覆盖) + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| Id | CHAR(36) | PK | +| UserId | CHAR(36) | FK → `user.Id` | +| DataScope | TINYINT | 1~5 | +| DataResourceCode | VARCHAR(32) | 可选 | + +### 4.3 用户业务维度 `user_biz_perm` + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| Id | CHAR(36) | PK | +| UserId | CHAR(36) | | +| BizType | VARCHAR(32) | product_line / kpi_area / customer_group / warehouse | +| BizId | VARCHAR(64) | 业务主键(兼容 GUID 与 INT 字符串) | +| ExpireTime | DATETIME | 可空 | + +### 4.4 用户密级 `user_secret_perm` + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| Id | CHAR(36) | PK | +| UserId | CHAR(36) | | +| SecretLevel | TINYINT | 1~4 | +| DataResourceCode | VARCHAR(32) | 可选,按模块细分 | + +### 4.5 用户自定义过滤 `user_filter_rule` + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| Id | CHAR(36) | PK | +| UserId | CHAR(36) | | +| DataResourceCode | VARCHAR(32) | Order / Customer / Recharge | +| FilterContent | JSON | 前端配置 AST | +| FilterHash | VARCHAR(64) | 内容哈希,便于缓存失效 | +| CreateTime | DATETIME | | + +> **不再存储 `filter_snippet` 原生 SQL 文本**;运行时由 AST 生成表达式,避免 SQL 注入与方言耦合。 + +### 4.6 权限模板 `data_perm_template` / `data_perm_template_item` + +模板头 + 明细行(`DimensionType` + `ItemContent` JSON),支持绑定 `RoleCode`。完整 DDL 见 **§十三** 与 `sql/install_data_permission.sql`。 + +**角色级五维表**(与用户对偶,供模板套用与角色继承): + +- `role_org_perm` · `role_data_scope` · `role_biz_perm` · `role_secret_perm` · `role_filter_rule` + +### 4.7 数据权限审计 `data_perm_access_log` + +| 字段 | 说明 | +| ---- | ---- | +| UserId、DataResourceCode、ApiPath | 谁访问了什么 | +| AppliedScopeJson | 本次生效的五维快照 | +| QueryTime、RowCount | 性能与合规 | + +### 4.8 OMS 业务表扩展(分期) + +| 阶段 | 表 | 新增字段 | +| ---- | -- | -------- | +| P1 | `t_customer` | `OrganizationId CHAR(36)` | +| P1 | `t_order` | `OrganizationId CHAR(36)`、`SecretLevel TINYINT DEFAULT 2` | +| P2 | `t_order_fee` | 应付行 `SecretLevel`、字段级脱敏配置 | +| P2 | `t_recharge_record` | `SecretLevel` | + +**不强制**全表统一 `biz_type/biz_id/owner_user_id`;改用 OMS 已有字段 + JOIN 映射(见 §2.3、§2.2.3)。 + +--- + +## 五、接口与执行流程 + +### 5.1 服务组件 + +| 组件 | 职责 | +| ---- | ---- | +| `IDataPermissionRepository` | 读配置表 | +| `IDataScopeContextFactory` | 合并角色+用户配置 → `DataScopeContext` | +| `IDataScopeApplier` | 按 `DataResourceCode` 注入 SqlSugar `Queryable` | +| `IFieldMaskingService` | 密级字段脱敏 | +| `DataPermissionCache` | Redis:`data_perm:{userGuid}`,TTL 30min | + +### 5.2 请求链路 + +1. JWT 校验 → 解析 `sub`(GUID)、`mapId`、`loginType`; +2. `[Authorize(Permission = "...")]` 功能权限校验(RBAC 完善后启用); +3. `loginType=1` → `OmsSessionHelper` CustomerId 分支; +4. `loginType=0` → 加载 `DataScopeContext` → Applier 注入条件; +5. 查询结果 → `IFieldMaskingService` 按密级脱敏; +6. 写 `data_perm_access_log`(异步,可采样)。 + +### 5.3 权限变更与缓存 + +| 事件 | 动作 | +| ---- | ---- | +| 用户数据权限 CRUD | 删除 `data_perm:{userId}` | +| 用户角色变更 | 同上 | +| 组织树移动 | 批量失效该组织下用户缓存 | +| 模板绑定角色更新 | 失效该角色所有用户 | + +### 5.4 管理端 API(建议前缀 `/rbac/data-perm/*`) + +| 方法 | 路径 | 说明 | +| ---- | ---- | ---- | +| GET | `/rbac/data-perm/get-by-user` | 查询用户五维配置 | +| PUT | `/rbac/data-perm/save-user` | 保存用户五维 | +| GET | `/rbac/data-perm/template/list` | 模板列表 | +| POST | `/rbac/data-perm/template/apply` | 模板套用到用户/角色 | +| GET | `/rbac/data-perm/access-log` | 审计分页 | + +权限码建议:`Rbac.DataPerm.Query`、`Rbac.DataPerm.Update`(在 `PermissionDefinitionProvider` 注册)。 + +### 5.5 登录响应扩展(可选) + +`GET /rbac/auth/get-current-user` 增加只读摘要(不含 SQL): + +```json +{ + "dataScopeSummary": { + "isAdmin": false, + "orgNames": ["华南分公司"], + "dataScope": 4, + "secretLevels": [1, 2, 3] + } +} +``` + +供前端展示「当前数据范围」提示,**不可替代**后端过滤。 + +--- + +## 六、分模块接入清单(OMS) + +| DataResourceCode | 服务 | 主表 | 组织字段 | 范围字段 | 备注 | +| ---------------- | ---- | ---- | -------- | -------- | ---- | +| `Order` | `OrderService` | `t_order` | OrganizationId | CreateUserId + Customer.Salesman | 同 `/admin/order/list`,内部用户走 Applier | +| `Customer` | `CustomerService` | `t_customer` | OrganizationId | Salesman / CustomerService | `/customer/list` 内部查询 | +| `Recharge` | `RechargeService` | `t_recharge_record` | via Customer | CreateUserId | 审核列表 | +| `ChannelProduct` | `SalesProductService` | `t_sales_product` | — | 业务标签 product_line | 配置类,密级偏低 | +| `Xhs` | `Xhs*Service` | 各 t_xhs_* | — | CustomerId(已有) | 内部用户走五维;客户走 Xhs CustomerId | + +**客户门户模块**(`/admin/order/*`、Xhs 客户侧):**不接入**五维 Applier,保持现有 `OmsSessionHelper`。 + +--- + +## 七、前端功能需求 + +### 7.1 用户数据权限编辑(`/system/users` 或 RBAC 用户详情) + +- Tab:组织 / 数据范围 / 业务标签 / 密级 / 自定义过滤; +- 组织树勾选:复用 `GET /rbac/organization/get-tree`; +- 权限模板一键填充; +- 临时权限:EffectiveTime + ExpireTime。 + +### 7.2 权限模板管理 + +- CRUD + 绑定 RoleCode; +- 预览「有效 SQL 语义」只读说明(不给 SQL 编辑)。 + +### 7.3 权限审计日志 + +- TablePro:用户、模块、时间、AppliedScopeJson 展开; +- 导出 Excel。 + +### 7.4 密级与脱敏配置 + +- 维护 `DataResourceCode + 字段名 → SecretLevel` 映射表; +- 与接口响应 DTO 字段对齐。 + +### 7.5 前端约束 + +- `hasPermission()` 恢复真实校验(rbac §9.4)后,按钮与数据范围提示分离; +- **禁止**前端按组织/密级过滤列表数据。 + +--- + +## 八、非功能性需求 + +| 类别 | 要求 | +| ---- | ---- | +| 安全 | 后端过滤;AST 白名单运算符;字段脱敏;临时权限强制过期 | +| 性能 | Redis 缓存 Context;`(OrganizationId, CreateTime)`、`(CustomerId)` 索引 | +| 扩展 | 新模块注册 `DataResourceCode` + Applier 策略类 | +| 易用 | 模板 + 角色继承;组织树可视化 | +| 审计 | 权限变更日志 + 访问采样日志 | + +--- + +## 九、典型岗位配置示例 + +### 示例 1:普通销售(内部) + +- 组织:主归属销售部,仅本级 +- 数据范围:个人级(Salesman = 本人账号) +- 业务:负责产品线 +- 密级:1、2 +- 接口:`Track.Orders.Query` + 五维过滤 + +### 示例 2:分公司销售经理 + +- 组织:本分公司,本级+下级 +- 数据范围:组织树级 +- 业务:全部产品线 +- 密级:1、2、3 +- 自定义:本年度 + +### 示例 3:总部产品经理(跨组织) + +- 组织:多分公司白名单 +- 数据范围:全局级 +- 业务:单一产品线 +- 密级:1、2 + +### 示例 4:客户主账号(门户) + +- **不配置五维** +- `loginType=1`,`CustomerId` 硬隔离 +- 功能权限:角色 `Customer` + 分配的 PermissionCode + +### 示例 5:子客户 + +- **不配置五维** +- 订单归属主客户 `CustomerId`(推荐策略,oms §1.4) +- 功能权限:角色 `CustomerSub_{userId}` ⊆ 主客户权限 + +### 示例 6:超级管理员 + +- 角色 `Admin`:跳过五维 +- 仍配置功能权限码(默认全开) + +--- + +## 十、实施分期 + +| 阶段 | 内容 | 依赖 | +| ---- | ---- | ---- | +| **P0** | `OrderService` 同路由按 `loginType` 分支;内部非 Admin 接 `DataScopeApplier`(替换管理员无过滤) | 无路由变更 | +| **P1** | 建 RBAC 数据权限表;`OrganizationId` 落库客户/订单;组织+数据范围两维;Order/Customer 接入 | RBAC 迁移 | +| **P2** | 业务标签、密级、字段脱敏 | 产品/KPI 主数据 | +| **P3** | 自定义过滤 AST、临时权限、审计页 | P1 稳定 | +| **P4** | 权限模板与角色联动;`[Authorize(Permission)]` 全量启用 | RBAC 包升级 | + +--- + +## 十一、与现有实现的差异对照 + +| 项 | 本文目标 | 当前 oms0414-back | +| -- | -------- | ----------------- | +| 客户门户隔离 | CustomerId 强制 | ✅ `OmsSessionHelper` 已实现 | +| 内部用户查订单 | 五维过滤 | ⚠️ `ResolveListCustomerIdAsync` 管理员返回 null,**无过滤** | +| 组织主数据 | 复用 RBAC `organization` | ✅ 已有,未用于数据过滤 | +| 功能权限 | PermissionCode + 数据权限 | ⚠️ 细粒度 Permission 拦截待实现 | +| 用户 ID | GUID 配置 + INT 审计 | ✅ 与 RBAC 一致 | + +--- + +## 十二、交付说明 + +1. 本文档与 **`rbac系统设计-060801.md`**、**`oms系统设计-050801.md`** 配套使用,冲突时以**双登录主体分工**(导读)为准; +2. 建表 SQL 见 **`oms0414-back/sql/install_data_permission.sql`**、**`migrate_oms_data_permission_columns.sql`**; +3. 新业务模块在 §六 注册 `DataResourceCode` 后接入 `IDataScopeApplier`; +4. 客户门户 **禁止** 接入五维 Applier,继续用 `CustomerId` 隔离; +5. V1.0 中「所有业务表统一五字段」改为 **分期扩展 + 字段映射**,降低 OMS 全表改造风险。 + +--- + +## 十三、表结构详设 + +### 13.1 ER 关系 + +```mermaid +erDiagram + user ||--o{ user_org_perm : has + user ||--o{ user_data_scope : has + user ||--o{ user_biz_perm : has + user ||--o{ user_secret_perm : has + user ||--o{ user_filter_rule : has + role ||--o{ role_org_perm : has + role ||--o{ role_data_scope : has + role ||--o{ role_biz_perm : has + role ||--o{ role_secret_perm : has + role ||--o{ role_filter_rule : has + organization ||--o{ user_org_perm : scoped + data_perm_template ||--o{ data_perm_template_item : contains + data_resource ||--o{ data_perm_field_mask : masks +``` + +### 13.2 表清单 + +| 表名 | 库 | 说明 | +| ---- | -- | ---- | +| `user_org_perm` | RBAC | 用户组织权限 | +| `role_org_perm` | RBAC | 角色组织权限 | +| `user_data_scope` | RBAC | 用户数据范围(覆盖角色) | +| `role_data_scope` | RBAC | 角色数据范围 | +| `user_biz_perm` | RBAC | 用户业务标签 | +| `role_biz_perm` | RBAC | 角色业务标签 | +| `user_secret_perm` | RBAC | 用户密级 | +| `role_secret_perm` | RBAC | 角色密级 | +| `user_filter_rule` | RBAC | 用户自定义过滤 AST | +| `role_filter_rule` | RBAC | 角色自定义过滤 AST | +| `data_resource` | RBAC | 模块注册(Order/Customer…) | +| `data_perm_field_mask` | RBAC | 字段脱敏规则 | +| `data_perm_template` | RBAC | 权限模板头 | +| `data_perm_template_item` | RBAC | 模板明细 | +| `data_perm_change_log` | RBAC | 配置变更审计 | +| `data_perm_access_log` | RBAC | 访问审计(采样) | +| `t_customer.OrganizationId` | OMS | P1 扩展 | +| `t_order.OrganizationId/SecretLevel` | OMS | P1 扩展 | + +### 13.3 有效权限合并规则(运行时唯一标准) + +> **配置页**(`get-by-user`)展示库表原值;**运行时**(`get-effective` / 列表过滤)按下表合并。二者不可混用。 + +| 维度 | 角色侧 | 用户直配 | 合并算法 | 空配置默认 | +| ---- | ------ | -------- | -------- | ---------- | +| 组织 | 多角色 **UNION** | **UNION** 到角色结果 | 展开子树后 `OrganizationId IN (...)` | 无任一条时回退 `user.OrganizationId`(scope=2) | +| 数据范围 | 多角色取 **MAX** | **按资源覆盖**角色结果 | 见 §14.1 步骤 5 | 无配置 → **Personal(1)** | +| 业务标签 | 多角色 **UNION** | **UNION** | 同类型 `bizId` 并集;有过滤则 `OR` 命中 | **空=不限制** | +| 密级 | **UNION** | **UNION** | `SecretLevel IN (授权集合)` | 无配置 → **[1,2]**(公开+内部) | +| 自定义过滤 | 多规则 **AND** | **AND** | AST 逐条 AND | 无规则 → 恒真 | + +**过期**:运行时排除 `EffectiveTime > NOW()` 或 `ExpireTime <= NOW()` 的行。 +**Admin**:角色 `Code=Admin` → 跳过五维,但仍走功能 PermissionCode。 +**客户**:`loginType=1` → 不算五维,走 `CustomerId`(与本文无关)。 + +### 13.4 枚举常量 + +**OrgScope** + +| 值 | 名称 | +| -- | ---- | +| 1 | SelfOnly | +| 2 | SelfAndChildren | + +**DataScope** + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 1 | Personal | `CreateUserId=mapId` 或 `Salesman=Account` | +| 2 | Team | 同小组组织下用户 | +| 3 | Department | 同部门子树 | +| 4 | OrgTree | 授权组织子树 | +| 5 | Global | 不追加负责人条件 | + +**BizType** + +| 值 | OMS 映射 | +| -- | -------- | +| `product_line` | `t_sales_product` 分类 / ProductCode 前缀 | +| `kpi_area` | `t_kpi_area.Id` | +| `customer_group` | `t_customer.CustomerGroup` | +| `warehouse` | `t_warehouse_address.Id` / `t_order.WarehouseId` | +| `region` | 字典扩展 | + +**SecretLevel** + +| 值 | 名称 | +| -- | ---- | +| 1 | Public | +| 2 | Internal | +| 3 | Confidential | +| 4 | TopSecret | + +**DataResourceCode**(见 `data_resource.Code`) + +`Order` · `Customer` · `Recharge` · `ChannelProduct` · `Xhs` + +**Filter AST 运算符**(白名单) + +`eq` · `ne` · `in` · `notIn` · `gt` · `gte` · `lt` · `lte` · `between` · `like` · `isNull` · `isNotNull` + +**Filter AST 示例** + +```json +{ + "logic": "and", + "conditions": [ + { "field": "CreateTime", "op": "between", "value": ["2026-01-01", "2026-12-31"] }, + { "field": "OrderStatus", "op": "in", "value": ["pendingPayment", "pendingShip"] }, + { "field": "DeclaredSum", "op": "gte", "value": 10000 } + ] +} +``` + +**模板 ItemContent 按 DimensionType** + +| DimensionType | JSON 结构 | +| ------------- | --------- | +| `org` | `{ "organizationId": "guid", "orgScope": 2 }` | +| `data_scope` | `{ "dataScope": 3, "dataResourceCode": "Order" }` | +| `biz` | `{ "bizType": "product_line", "bizId": "US_AIR", "bizName": "美线空运" }` | +| `secret` | `{ "secretLevels": [1,2,3], "dataResourceCode": null }` | +| `filter` | `{ "dataResourceCode": "Order", "filterContent": { ...AST } }` | + +--- + +## 十四、接口与核心逻辑详设 + +> 路由 `/rbac/data-perm/*`;分页 `{ total, items }`;写操作单事务、软删旧行+插新行。 + +### 14.0 产品决策(已确认) + +| # | 决策 | 结论 | +| - | ---- | ---- | +| 1 | 订单路由 | **不分离**;`/admin/order/*` 内按 `loginType` 分支 | +| 2 | 新员工默认 dataScope | **Personal(1)** | +| 3 | 改自己的数据权限 | **禁止**(403) | +| 4 | 模板批量套用 | **部分失败继续**,返回 `failures[]` | +| 5 | access-log 自动写入 | **P1 不做**;前端审计页已预留 | + +### 14.0.1 前端页面(oms0414) + +| 路由 | 组件 | 说明 | +| ---- | ---- | ---- | +| `/system/dataPerm/users` | `views/system/dataPerm/users/index.vue` | 用户列表 + 五维配置抽屉 | +| `/system/dataPerm/templates` | `views/system/dataPerm/templates/index.vue` | 模板 CRUD + 批量套用 | +| `/system/dataPerm/audit` | `views/system/dataPerm/audit/index.vue` | 变更日志 / 访问日志 Tab | + +API 客户端:`src/api/rbac/data-perm.ts`(后端未就绪时会 404,页面已 catch)。 + +--- + +### 14.1 三个核心类(必须先实现) + +| 类 | 职责 | +| -- | ---- | +| `EffectiveScopeBuilder` | 角色+用户+默认 → `DataScopeContext` | +| `DataScopeApplier` | Context → SqlSugar 条件(`/order/list` 等) | +| `FilterAstValidator` | 校验/编译 filterContent | + +```csharp +record DataScopeContext { + bool IsAdmin; + Guid UserId; int MapId; string Account; + string DataResourceCode; + IReadOnlySet OrganizationIds; + int DataScope; // 1~5 + IReadOnlyList ResolvedOwnerIds; // Team/Dept 预解析 mapId + IReadOnlyDictionary BizFilters; // null=不限制 + IReadOnlySet SecretLevels; + IReadOnlyList CustomFilters; +} +``` + +缓存:`data_perm:ctx:{userId}:{resourceCode}` TTL 30min;配置变更 DEL 该 user 全部 ctx。 + +--- + +### 14.2 运行时合并规则 + +| 维度 | 算法 | +| ---- | ---- | +| 组织 | `role_org_perm` ∪ `user_org_perm` → OrgScope 展开子树 → `IN`;全无 → `user.OrganizationId`(scope=2) | +| 数据范围 | 用户专码 > 用户全局 > 角色专码 MAX > 角色全局 MAX;全无 → **Personal(1)** | +| 业务 | 并集;**空=不限制** | +| 密级 | 并集;空 → **[1,2]** | +| 自定义 | 全部 **AND** | +| 过期 | 运行时排除;`get-by-user` 编辑页仍展示过期行并标 `isExpired` | + +**`get-by-user`** 仅 user_* 直配;**`get-effective-by-user`** 走 Builder 合并结果。 + +--- + +### 14.3 `EffectiveScopeBuilder.BuildAsync` + +入参:`userId`, `dataResourceCode`(如 `Order`) + +1. 查 user,不存在 → 404 +2. 角色含 `Admin` → `{ IsAdmin=true }` +3. 组织:role∪user 行 → FilterEffective → 展开子树 → `OrganizationIds` +4. 数据范围:用户专码/全局优先,否则角色 MAX,否则 1 +5. 业务:role∪user → 分组;空 → null +6. 密级:并集;空 → [1,2] +7. 过滤:role∪user 同 resource → Compile → AND +8. dataScope∈{2,3} 时预解析 `ResolvedOwnerIds`;无 `user.OrganizationId` → 业务异常 + +--- + +### 14.4 `DataScopeApplier`(`/admin/order/*` 同路由) + +在 `OrderService` 列表/详情/导出等读路径入口: + +1. `loginType=1` → 仅 `CustomerId`(现有 `OmsSessionHelper`) +2. `loginType=0` + Admin → 不加五维 +3. `loginType=0` + 非 Admin → `BuildAsync(currentUser, "Order")` 后: + - `OrganizationId IN ctx.OrganizationIds` + - `SecretLevel IN ctx.SecretLevels` + - BizFilters 按注册表翻译 + - dataScope:Personal → `CreateUserId=mapId OR Salesman=Account`;Team/Dept → `CreateUserId IN ResolvedOwnerIds`;OrgTree/Global → 不加 Owner + - CustomFilters AND +4. DTO 映射阶段 `FieldMaskingService` 脱敏 +5. **P1 不写 access-log** + +--- + +### 14.5 HTTP 共性 + +- 鉴权:Bearer + PermissionCode +- 可配置主体:内部用户;纯 Customer/CustomerSub → save 拒绝 +- **`save-user`:`targetUserId == 操作者 userId` → 403「不能修改自己的数据权限」** +- 双库:RBAC EF + OMS SqlSugar(`biz-options` 读 OMS) + +--- + +### 14.6 接口逐步逻辑 + +#### `GET /meta` — `Rbac.DataPerm.Query` + +代码常量枚举 + `data_resource` 表 + 每 resource 的 `filterableFields` 字典;不读当前用户权限。 + +#### `GET /biz-options` — Query + +`bizType` 必填。分支:`product_line`→`t_sales_product`;`kpi_area`→`t_kpi_area`;`customer_group`→0~5 常量;`warehouse`→`t_warehouse_address`。keyword 模糊分页。 + +#### `GET /get-by-user` — Query `userId` + +查 user + 五张 `user_*`(IsDeleted=0,含过期);纯客户 → 400;JOIN 组织名;**不调 Builder**。 + +#### `GET /get-effective-by-user` — Query `userId`, `dataResourceCode` + +`BuildAsync` → 组织名 → `summaryText`;Admin 短路。 + +#### `PUT /save-user` — Query `userId` + +校验(组织启用、枚举、去重、AST、**禁止 self-edit**)→ 事务:软删旧 user_* → 插新 → change_log → 失效缓存 → 返回 get-by-user。 + +#### `GET/PUT get-by-role / save-role` + +对称读写 `role_*`;save-role 后失效该角色所有 user 的 ctx 缓存。 + +#### 模板 `list/get/create/update/delete` + +create/update 校验 item JSON Schema;内置模板不可删、不可改 code。 + +#### `POST /template/apply` + +加载模板 → `PermBundle`;foreach targetId:失败则 **failures+=reason, continue**(部分失败继续);replace/merge 规则:org/biz/secret Union,scope Max,filter 追加 AND;调内部 save;返回 `{ successCount, failures[] }`。 + +#### `field-mask/list|save` + +按 resource 全量覆盖脱敏规则。 + +#### `change-log/list` + +配置变更只读分页(**P1 必做**)。 + +#### `access-log/list` + +只读分页;**P1 无自动写入**,表结构预留。 + +#### `POST /invalidate-cache` + +运维 DEL Redis ctx。 + +#### `get-current-user` 扩展 + +`loginType=1` → null;Admin → summary;否则 Builder 仅取 summaryText + secretLevels。 + +--- + +### 14.7 路径速查 + +| 方法 | 路径 | PermissionCode | +| ---- | ---- | -------------- | +| GET | `/rbac/data-perm/meta` | Rbac.DataPerm.Query | +| GET | `/rbac/data-perm/biz-options` | Rbac.DataPerm.Query | +| GET | `/rbac/data-perm/get-by-user` | Rbac.DataPerm.Query | +| GET | `/rbac/data-perm/get-effective-by-user` | Rbac.DataPerm.Query | +| PUT | `/rbac/data-perm/save-user` | Rbac.DataPerm.Update | +| GET | `/rbac/data-perm/get-by-role` | Rbac.DataPerm.Query | +| PUT | `/rbac/data-perm/save-role` | Rbac.DataPerm.Update | +| GET | `/rbac/data-perm/template/list` | Rbac.DataPerm.Template | +| GET | `/rbac/data-perm/template/get` | Rbac.DataPerm.Template | +| POST | `/rbac/data-perm/template/create` | Rbac.DataPerm.Template | +| PUT | `/rbac/data-perm/template/update` | Rbac.DataPerm.Template | +| DELETE | `/rbac/data-perm/template/delete` | Rbac.DataPerm.Template | +| POST | `/rbac/data-perm/template/apply` | Rbac.DataPerm.Update | +| GET | `/rbac/data-perm/field-mask/list` | Rbac.DataPerm.Query | +| PUT | `/rbac/data-perm/field-mask/save` | Rbac.DataPerm.Update | +| GET | `/rbac/data-perm/change-log/list` | Rbac.DataPerm.Audit | +| GET | `/rbac/data-perm/access-log/list` | Rbac.DataPerm.Audit | +| POST | `/rbac/data-perm/invalidate-cache` | Rbac.DataPerm.Update | + +### 14.8 权限码 + +`Rbac.DataPerm` · `.Query` · `.Update` · `.Template` · `.Audit` + +### 14.9 错误码补充 + +| 场景 | 消息 | +| ---- | ---- | +| 修改自己的数据权限 | 不能修改自己的数据权限 | +| 纯客户账号 save | 客户账号不支持数据权限配置 | +| template apply 单条失败 | 记入 failures[].reason,不中断批次 | + +### 14.10 实施顺序 + +1. EffectiveScopeBuilder + 单测 +2. meta / get-by-user / get-effective +3. save-user / save-role + change-log +4. Applier 接入 `/order/list`(同路由 loginType 分支) +5. template / biz-options +6. field-mask;(access-log 写入 Phase 2) + +### 14.11 JSON 契约摘要 + +**UserDataPermDto**:userId、orgPerms[]、dataScopes[]、bizPerms[]、secretPerms[]、filterRules[](含 isExpired)。 + +**SaveUserDataPermInput**:同上五数组,全量覆盖;缺省数组=清空该维。 + +**EffectiveDataScopeDto**:isAdmin、organizationIds[]、dataScope、secretLevels[]、bizFilters[]、summaryText。 + +**template/apply 响应**:`{ successCount, failures: [{ targetId, reason }] }`。 + +--- + +## 十五、SQL 执行顺序 + +```bash +# 1. RBAC 数据权限表 + 种子 +mysql ... < sql/install_data_permission.sql + +# 2. OMS 业务表扩展列 +mysql ... < sql/migrate_oms_data_permission_columns.sql + +# 3. 菜单与 Admin 权限码 +mysql ... < sql/seed_data_perm_rbac_menu.sql +``` + +> `PermissionDefinitionProvider` 中注册 §14.9 权限码后,需重新分配角色或执行 seed 脚本。 diff --git a/generate-menu-excel.js b/generate-menu-excel.js index ebaf004..c362a3d 100644 --- a/generate-menu-excel.js +++ b/generate-menu-excel.js @@ -508,16 +508,51 @@ const menuData = [ hideInMenu: true, parentRoute: '/user', }, + + // 一级菜单 - 财务 + { + level: 1, + name: '财务', + permissionCode: 'Finance', + route: '/finance', + component: null, + icon: 'icon-storage', + order: 6, + hideInMenu: false, + }, + // 二级菜单 - 财务 { level: 2, name: '充值记录', - permissionCode: 'User.RechargeRecord', - route: '/user/rechargeRecord', - component: 'views/user/recharge/rechargeRecord', + permissionCode: 'Finance.RechargeRecord', + route: '/finance/rechargeRecord', + component: 'views/finance/rechargeRecord/index', + icon: null, + order: 1, + hideInMenu: false, + parentRoute: '/finance', + }, + { + level: 2, + name: '资金流水', + permissionCode: 'Finance.AccountFlow', + route: '/finance/accountFlow', + component: 'views/finance/accountFlow/index', icon: null, order: 2, hideInMenu: false, - parentRoute: '/user', + parentRoute: '/finance', + }, + { + level: 2, + name: '应收应付', + permissionCode: 'Finance.ReceivablePayable', + route: '/finance/receivablePayable', + component: 'views/finance/receivablePayable/index', + icon: null, + order: 3, + hideInMenu: false, + parentRoute: '/finance', }, // 一级菜单 - 系统 diff --git a/nginx-default.conf b/nginx-default.conf index bd8bf65..957d4c9 100644 --- a/nginx-default.conf +++ b/nginx-default.conf @@ -103,6 +103,42 @@ server { try_files /index.html =200; } + # ============ 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先)============ + # 页面路径为 camelCase;API 为 kebab(如 /system/dict/list、/system/batch-email/…),勿用宽泛前缀匹配 dict + location = /system/dict { + try_files /index.html =200; + } + location = /system/warehouseAddress { + try_files /index.html =200; + } + location = /system/announcement { + try_files /index.html =200; + } + location ^~ /system/users { + try_files /index.html =200; + } + location ^~ /system/roles { + try_files /index.html =200; + } + location ^~ /system/menus { + try_files /index.html =200; + } + location ^~ /system/organizations { + try_files /index.html =200; + } + location ^~ /system/batchEmail { + try_files /index.html =200; + } + location ^~ /system/otherConfig { + try_files /index.html =200; + } + location ^~ /system/xhsAiAssistant { + try_files /index.html =200; + } + location ^~ /system/dataPerm/ { + try_files /index.html =200; + } + # ============ 微信公众号服务器回调(admin API,非 itfx)============ location ^~ /api/apiwechat/ { rewrite ^/api/apiwechat/(.*)$ /admin/apiwechat/$1 break; diff --git a/nginx-docker.conf b/nginx-docker.conf index 64e942e..c0db43c 100644 --- a/nginx-docker.conf +++ b/nginx-docker.conf @@ -39,6 +39,19 @@ server { location ^~ /operation/outboundScan { try_files /index.html =200; } location ^~ /operation/printLabel { try_files /index.html =200; } + # 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先) + location = /system/dict { try_files /index.html =200; } + location = /system/warehouseAddress { try_files /index.html =200; } + location = /system/announcement { try_files /index.html =200; } + location ^~ /system/users { try_files /index.html =200; } + location ^~ /system/roles { try_files /index.html =200; } + location ^~ /system/menus { try_files /index.html =200; } + location ^~ /system/organizations { try_files /index.html =200; } + location ^~ /system/batchEmail { try_files /index.html =200; } + location ^~ /system/otherConfig { try_files /index.html =200; } + location ^~ /system/xhsAiAssistant { try_files /index.html =200; } + location ^~ /system/dataPerm/ { try_files /index.html =200; } + location ^~ /api/apiwechat/ { rewrite ^/api/apiwechat/(.*)$ /admin/apiwechat/$1 break; proxy_pass http://api.nanafa.com; diff --git a/src/api/finance/receivablePayable.ts b/src/api/finance/receivablePayable.ts new file mode 100644 index 0000000..af40934 --- /dev/null +++ b/src/api/finance/receivablePayable.ts @@ -0,0 +1,57 @@ +import { axiosInstance } from '@/api/rbac' +import { omsUrl, parseListPayload } from '@/api/oms/common' + +export interface ReceivablePayableListParams { + page: number + pageSize: number + keyword?: string + feeType?: string + costItem?: string + chargingStage?: string + customerId?: number + startTime?: string + endTime?: string +} + +export interface ReceivablePayableRow { + serviceNo: string + clientNo?: string + trackNo?: string + customerCode?: string + orderStatus: string + orderStatusName?: string + isPaid: number + payTime?: string + receiveTime?: string + shipTime?: string + productCode?: string + channelCode?: string + consigneeCountryName?: string + feeType: string + feeTypeName?: string + costItem: string + amount: number + currency: string + downstreamProvider?: string + priceNo?: string + priceEffectiveTime?: string + chargingStage?: string + chargingStageName?: string +} + +export async function getReceivablePayableList(params: ReceivablePayableListParams) { + const res = await axiosInstance.get(omsUrl('/finance/receivable-payable/list'), { + params: { + page: params.page, + pageSize: params.pageSize, + keyword: params.keyword || undefined, + feeType: params.feeType && params.feeType !== 'all' ? params.feeType : undefined, + costItem: params.costItem || undefined, + chargingStage: params.chargingStage && params.chargingStage !== 'all' ? params.chargingStage : undefined, + customerId: params.customerId || undefined, + startTime: params.startTime || undefined, + endTime: params.endTime || undefined, + }, + }) + return parseListPayload(res.data) +} diff --git a/src/components/CustomLeftMenu/index.vue b/src/components/CustomLeftMenu/index.vue index a726c8e..d8844da 100644 --- a/src/components/CustomLeftMenu/index.vue +++ b/src/components/CustomLeftMenu/index.vue @@ -36,6 +36,8 @@ showPagination?: boolean; pageSize?: number; currentPage?: number; + /** 树形层级展示(保留缩进/展开箭头,适用于组织等多级树) */ + hierarchical?: boolean; }>(), { selectedKeys: () => [], @@ -62,6 +64,7 @@ showPagination: true, pageSize: 10, currentPage: 1, + hierarchical: false, } ); @@ -268,6 +271,14 @@ if (nodeData) emit('nodeClick', key, nodeData); } + function selectTreeNode(key: string) { + if (!key) return; + selectedKeysModel.value = [key]; + const nodeData = getNodeData(key); + emit('select', [key], { node: nodeData }); + if (nodeData) emit('nodeClick', key, nodeData); + } + defineExpose({ getNodeData, getNodeIndex, @@ -317,7 +328,10 @@ -
+

{{ loadingText }}

@@ -335,14 +349,20 @@