This commit is contained in:
owenchen committed 2026-10-13 10:47:39 +08:00
1 parent 6a8258ac4b
commit a7b25d49c4
29 files changed
+1960 -649

No files matched your search

@@ -0,0 +1,281 @@
/**
* 权限判定内核:纯函数 + 预建索引,不碰 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;
}