Files
Docker/uniapp/youjiala/src/permission/permission-kernel.ts
T
2026-10-13 10:47:39 +08:00

282 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 权限判定内核:纯函数 + 预建索引,不碰 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<string, ActionFlags>;
/** key = dependentKey(permission, dependent) */
byDependent: Map<string, ActionFlags>;
}
/** 空索引:登录档案缺失时用,配合 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<string, ActionFlags>,
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<string, ActionFlags>();
const byDependent = new Map<string, ActionFlags>();
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;
}