/** * 权限判定内核:纯函数 + 预建索引,不碰 uni / pinia / 请求层 * * 语义以服务端为准,逐条对齐 CloudBuilder.Security.Service/Authorization/PermissionContext.cs: * - view / create / amend / delete 各自要求对应的 Indc 为 'Y'; * - none 只要求权限名挂过(后端 CanNone 同款,用于"有授权行就允许,具体动作交给接口判"); * - 传 dependentId 时只在 PermissionDependents 里找,不回退到 Permissions——回退等于比后端宽; * - 不做 '*' 通配、不做大小写宽松匹配:后端是精确字符串比较,前端放宽只会带来 * "前端放行、后端 403" 的错觉,那正是本层要消除的东西。 * * 与旧 WebVue 的一处行为差异(有意的修复): * 旧 hooks/PermissionContext.ts 的 canView 只判断权限名存不存在,漏检 ViewIndc。 * 于是 view_indc='N'、create_indc='Y' 的授权行会让用户进得来页面, * 但页面的列表接口走 Find -> 后端 CanView -> 403,表现为"打开就一片红"。 * 这里按后端补齐后,进不来就直接跳 403 页,原因写在页面上。 * * 之所以做成纯内核:判定逻辑要能被脚本直接跑起来验证(不依赖设备、不依赖后端), * 也方便日后单测;pinia/uni 的取值与响应式都留在 permission-context.ts 那层。 */ import { YesNoPolicy } from "@/data/custom/yesNoPolicy"; /** 四种可判定的动作,none 表示"不细分动作,只要求有授权行" */ export type PermissionAction = "none" | "view" | "create" | "amend" | "delete"; /** * 权限名入参:单个或数组(数组为 OR,任一命中即通过)。 * 放在内核里是因为 routes.ts 的 PAGE_PERMISSIONS 也要用同一份定义, * 而内核不 import 任何模块,不会形成 routes <-> permission 的循环依赖。 */ export type PermissionNames = string | string[]; /** * 权限行的结构快照:只声明内核要用的字段,不 import 生成的实体类。 * 生成器(data/auto/*)改字段名时不该牵连判定逻辑,只要后端返回的 JSON 形状不变就照样工作。 */ export interface PermissionRowLike { PermissionName: string; ViewIndc?: string; CreateIndc?: string; AmendIndc?: string; DeleteIndc?: string; } /** 数据级(dependent)权限行:比功能级多一个 DependentDetailName,如"某个家族/某个机构" */ export interface DependentRowLike extends PermissionRowLike { DependentDetailName: string; } /** 内核需要的登录档案权限信息子集,与 SecurityUserPermissionInfo 结构兼容 */ export interface PermissionInfoLike { // 功能级授权行;DependentRowLike 是其超集,故生成实体可直接传入 Permissions?: PermissionRowLike[] | null; // 数据级授权行 PermissionDependents?: DependentRowLike[] | null; } /** 某个权限名上四种动作的合并结果(多组授权会 OR) */ export interface ActionFlags { none: boolean; view: boolean; create: boolean; amend: boolean; delete: boolean; } /** * 预建索引:把旧实现"每次判定都 Array.some 线性扫一遍"换成一次构建、多次 O(1) 查询。 * 旧版一个页面要调 canView/canCreate/canAmend/canDelete 四次,扫四遍; * 相册页权限行可能上百,加上小程序 setData 频率敏感,索引这层不亏。 */ export interface PermissionIndex { /** key = PermissionName */ byName: Map; /** key = dependentKey(permission, dependent) */ byDependent: Map; } /** 空索引:登录档案缺失时用,配合 context 层的"清单为空即放行"策略 */ const EMPTY_INDEX: PermissionIndex = { byName: new Map(), byDependent: new Map(), }; /** 数据级权限的复合键;用 \u0000 分隔,权限名与明细名都不可能出现控制字符 */ export function dependentKey(permission: string, dependent: string): string { return `${permission}\u0000${dependent}`; } function emptyFlags(): ActionFlags { return { none: false, view: false, create: false, amend: false, delete: false, }; } function isYes(value?: string): boolean { return value === YesNoPolicy.YES; } /** 同一权限名可能被多个用户组授予,合并成 OR:任一组给了某个动作就算有 */ function merge( target: Map, key: string, row: PermissionRowLike, ): void { let flags = target.get(key); if (!flags) { flags = emptyFlags(); target.set(key, flags); } flags.none = true; flags.view ||= isYes(row.ViewIndc); flags.create ||= isYes(row.CreateIndc); flags.amend ||= isYes(row.AmendIndc); flags.delete ||= isYes(row.DeleteIndc); } /** * 建索引。传 null/undefined/空清单会得到空索引,不抛异常—— * 登录档案是从本地缓存水合的,损坏或半截是正常状态,判定层不该让页面崩。 */ export function buildPermissionIndex( info?: PermissionInfoLike | null, ): PermissionIndex { if (!info) { return EMPTY_INDEX; } const byName = new Map(); const byDependent = new Map(); for (const row of info.Permissions ?? []) { if (row?.PermissionName) { merge(byName, row.PermissionName, row); } } for (const row of info.PermissionDependents ?? []) { if (row?.PermissionName && row.DependentDetailName) { merge( byDependent, dependentKey(row.PermissionName, row.DependentDetailName), row, ); } } return { byName, byDependent }; } function lookup( index: PermissionIndex, permission: string, dependent?: string, ): ActionFlags | undefined { if (dependent) { return index.byDependent.get(dependentKey(permission, dependent)); } return index.byName.get(permission); } /** * 单条判定。 * @param index 预建索引 * @param action 要判定的动作 * @param permission 权限名,须与后端 security_function.permission_name 逐字一致 * @param dependent 数据级授权明细名;给了它就只查 dependent 集合,与后端一致 */ export function can( index: PermissionIndex, action: PermissionAction, permission: string, dependent?: string, ): boolean { const flags = lookup(index, permission, dependent); if (!flags) { return false; } return action === "none" ? flags.none : flags[action]; } /** * 批量判定:任一权限名通过即通过(OR)。 * 一个页面被多个功能权限共享时用得上,例如相册浏览页同时挂在 * PublishPhotoEnquiry 与 FamilyGroupShare 之下。 */ export function canAny( index: PermissionIndex, action: PermissionAction, permissions: PermissionNames, dependent?: string, ): boolean { const list = Array.isArray(permissions) ? permissions : [permissions]; // 刻意不写成 list.some(name => ...):单参数箭头的括号,prettier 要加、 // ESLint 要去,改回去就来回拉锯;for-of 在两个工具下都稳定 for (const name of list) { if (name && can(index, action, name, dependent)) { return true; } } return false; } /** * 后端 [PermissionOperate(name, Operate.X)] 的枚举值,逐字照抄 * CloudBuilder.Core/Authorization/PermissionOperateAttribute.cs 的 Operate。 * 用 as const 数组而不是多行字面量联合:联合类型的换行位置 prettier 与 * operator-linebreak 各执一词,只有这种写法在两个工具下都不被改写 */ export const PERMISSION_OPERATES = [ "None", "Find", "Print", "Insert", "Update", "Delete", "Submit", "Approval", ] as const; /** 后端 Operate 枚举的字符串取值 */ export type PermissionOperate = (typeof PERMISSION_OPERATES)[number]; /** * 按后端的鉴权口径判一个操作:与 PermissionHandler.HandleRequirementAsync 的 switch 一一对应。 * Find/Print -> view,Insert -> create,Update -> amend,Delete -> delete; * 其余(Submit/Approval)走 default 分支——后端把 operate 字符串当成 DependentDetailName * 去数据级授权里查"有没有挂过这个操作",所以这里同样是 none + dependent。 * * 用意:前端按钮的可用性判定和接口能不能调,用的是同一份映射,不会出现 * "提交按钮亮着、请求过去被 403"。新增操作类型时只改这一处。 */ export function canOperate( index: PermissionIndex, operate: PermissionOperate, permission: string, ): boolean { switch (operate) { case "None": { return can(index, "none", permission); } case "Find": case "Print": { return can(index, "view", permission); } case "Insert": { return can(index, "create", permission); } case "Update": { return can(index, "amend", permission); } case "Delete": { return can(index, "delete", permission); } default: { return can(index, "none", permission, operate); } } } /** 取某权限名(或某条数据级授权)的全部动作,供 UI 一次性渲染按钮可用性 */ export function actionsOf( index: PermissionIndex, permission: string, dependent?: string, ): ActionFlags { return lookup(index, permission, dependent) ?? emptyFlags(); } /** 已授权的动作清单,调试面板与"我到底有什么权限"排查用 */ export function grantedPermissions( index: PermissionIndex, action: PermissionAction, ): string[] { const names: string[] = []; index.byName.forEach((flags, name) => { if (action === "none" ? flags.none : flags[action]) { names.push(name); } }); return names; }