@southwind-ai/shared 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rbac.ts ADDED
@@ -0,0 +1,117 @@
1
+ import type {
2
+ EntityAuditFields,
3
+ EntityId,
4
+ LifecycleStatus,
5
+ } from "./primitives.js";
6
+
7
+ export type HttpMethod =
8
+ | "GET"
9
+ | "POST"
10
+ | "PUT"
11
+ | "PATCH"
12
+ | "DELETE"
13
+ | "OPTIONS";
14
+
15
+ export type PermissionAction =
16
+ | "access"
17
+ | "read"
18
+ | "create"
19
+ | "update"
20
+ | "delete"
21
+ | "import"
22
+ | "export"
23
+ | "reveal"
24
+ | "approve"
25
+ | "execute"
26
+ | "manage";
27
+
28
+ export type PermissionAccessRisk =
29
+ | "standard"
30
+ | "sensitive-disclosure"
31
+ | "governed-export";
32
+
33
+ export type MenuNodeType = "catalog" | "menu" | "button";
34
+
35
+ export type DataScope =
36
+ | "all"
37
+ | "tenant"
38
+ | "department"
39
+ | "department-and-children"
40
+ | "self"
41
+ | "custom";
42
+
43
+ export type CredentialState = "active" | "must-change-password";
44
+
45
+ export interface PermissionDefinition {
46
+ key: string;
47
+ label: string;
48
+ module: string;
49
+ moduleLabel: string;
50
+ resource: string;
51
+ resourceLabel: string;
52
+ action: PermissionAction;
53
+ accessRisk?: PermissionAccessRisk;
54
+ description?: string;
55
+ }
56
+
57
+ export interface ApiPermissionBinding {
58
+ method: HttpMethod;
59
+ path: string;
60
+ permissionKey: string;
61
+ anonymous?: boolean;
62
+ featureKey?: string;
63
+ }
64
+
65
+ export interface MenuNode {
66
+ key: string;
67
+ title: string;
68
+ type: MenuNodeType;
69
+ order: number;
70
+ visible: boolean;
71
+ path?: string;
72
+ component?: string;
73
+ icon?: string;
74
+ permissionKey?: string;
75
+ featureKey?: string;
76
+ children?: MenuNode[];
77
+ }
78
+
79
+ export interface RoleDefinition extends EntityAuditFields {
80
+ id: EntityId;
81
+ code: string;
82
+ name: string;
83
+ status: LifecycleStatus;
84
+ dataScope: DataScope;
85
+ permissionKeys: string[];
86
+ menuKeys: string[];
87
+ departmentIds?: EntityId[];
88
+ }
89
+
90
+ export interface UserAccount extends EntityAuditFields {
91
+ id: EntityId;
92
+ username: string;
93
+ displayName: string;
94
+ email?: string;
95
+ phone?: string;
96
+ status: LifecycleStatus;
97
+ roleIds: EntityId[];
98
+ departmentId?: EntityId;
99
+ postIds?: EntityId[];
100
+ }
101
+
102
+ export interface DepartmentNode extends EntityAuditFields {
103
+ id: EntityId;
104
+ parentId?: EntityId;
105
+ name: string;
106
+ code: string;
107
+ order: number;
108
+ status: LifecycleStatus;
109
+ }
110
+
111
+ export interface PostDefinition extends EntityAuditFields {
112
+ id: EntityId;
113
+ code: string;
114
+ name: string;
115
+ order: number;
116
+ status: LifecycleStatus;
117
+ }
package/src/ruoyi.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type { AuditEvent, AuditLogWriter } from "./audit.js";
2
+ import type { LegacyTableMapping } from "./database.js";
3
+ import type { EntityId, ISODateTime } from "./primitives.js";
4
+ import type {
5
+ MenuNode,
6
+ PermissionDefinition,
7
+ RoleDefinition,
8
+ UserAccount,
9
+ } from "./rbac.js";
10
+
11
+ export type RuoYiCoreTable =
12
+ | "sys_user"
13
+ | "sys_role"
14
+ | "sys_user_role"
15
+ | "sys_dept"
16
+ | "sys_post"
17
+ | "sys_user_post"
18
+ | "sys_menu"
19
+ | "sys_role_menu"
20
+ | "sys_role_dept"
21
+ | "sys_dict_type"
22
+ | "sys_dict_data"
23
+ | "sys_config"
24
+ | "sys_notice"
25
+ | "sys_logininfor"
26
+ | "sys_oper_log"
27
+ | "sys_job"
28
+ | "sys_job_log";
29
+
30
+ export interface RuoYiConnectionConfig {
31
+ mysqlDsn: string;
32
+ redisDsn?: string;
33
+ readonly: boolean;
34
+ }
35
+
36
+ export interface RuoYiMigrationPlan {
37
+ id: EntityId;
38
+ createdAt: ISODateTime;
39
+ tables: RuoYiCoreTable[];
40
+ dryRun: boolean;
41
+ resetPasswords: boolean;
42
+ }
43
+
44
+ export interface RuoYiMigrationReport {
45
+ planId: EntityId;
46
+ startedAt: ISODateTime;
47
+ finishedAt?: ISODateTime;
48
+ importedRows: Record<RuoYiCoreTable, number>;
49
+ failedRows: Record<RuoYiCoreTable, number>;
50
+ warnings: string[];
51
+ auditEvents: AuditEvent[];
52
+ }
53
+
54
+ export interface RuoYiPermissionSnapshot {
55
+ user: UserAccount;
56
+ roles: RoleDefinition[];
57
+ menus: MenuNode[];
58
+ permissions: PermissionDefinition[];
59
+ }
60
+
61
+ export interface RuoYiImportAdapter {
62
+ dryRun(plan: RuoYiMigrationPlan): Promise<RuoYiMigrationReport>;
63
+ import(
64
+ plan: RuoYiMigrationPlan,
65
+ audit: AuditLogWriter,
66
+ ): Promise<RuoYiMigrationReport>;
67
+ }
68
+
69
+ export interface RuoYiLegacyRuntimeAdapter {
70
+ connect(config: RuoYiConnectionConfig): Promise<void>;
71
+ disconnect(): Promise<void>;
72
+ readPermissionSnapshot(userId: EntityId): Promise<RuoYiPermissionSnapshot>;
73
+ }
74
+
75
+ export interface RuoYiReportAdapter {
76
+ compareUserPermissions(userId: EntityId): Promise<RuoYiMigrationReport>;
77
+ tableMappings(): LegacyTableMapping[];
78
+ }
79
+
80
+ export const ruoyiCoreTables: RuoYiCoreTable[] = [
81
+ "sys_user",
82
+ "sys_role",
83
+ "sys_user_role",
84
+ "sys_dept",
85
+ "sys_post",
86
+ "sys_user_post",
87
+ "sys_menu",
88
+ "sys_role_menu",
89
+ "sys_role_dept",
90
+ "sys_dict_type",
91
+ "sys_dict_data",
92
+ "sys_config",
93
+ "sys_notice",
94
+ "sys_logininfor",
95
+ "sys_oper_log",
96
+ "sys_job",
97
+ "sys_job_log",
98
+ ];
@@ -0,0 +1,108 @@
1
+ import type { ISODateTime } from "./primitives.js";
2
+
3
+ export type SchedulerRunTrigger = "scheduled" | "manual" | "retry";
4
+ export type SchedulerRunStatus =
5
+ | "running"
6
+ | "succeeded"
7
+ | "retry-pending"
8
+ | "failed"
9
+ | "blocked";
10
+
11
+ export type SchedulerObservedState =
12
+ | "idle"
13
+ | "running"
14
+ | "stale"
15
+ | "retry-pending"
16
+ | "succeeded"
17
+ | "failed"
18
+ | "blocked";
19
+
20
+ export interface SchedulerRunSummary {
21
+ id: string;
22
+ taskKey: string;
23
+ trigger: SchedulerRunTrigger;
24
+ status: SchedulerRunStatus;
25
+ attempt: number;
26
+ requestedBy: string;
27
+ scheduledFor: ISODateTime;
28
+ startedAt: ISODateTime;
29
+ finishedAt?: ISODateTime;
30
+ nextAttemptAt?: ISODateTime;
31
+ errorSummary?: string;
32
+ }
33
+
34
+ export interface SchedulerRunHistoryItem extends SchedulerRunSummary {
35
+ taskLabel: string;
36
+ }
37
+
38
+ export interface SchedulerOperationsTask {
39
+ definition: {
40
+ source: "code-registered";
41
+ key: string;
42
+ label: string;
43
+ description?: string;
44
+ featureKey: string;
45
+ permissionKey: string;
46
+ intervalSeconds: number;
47
+ maxAttempts: number;
48
+ retryDelaySeconds: number;
49
+ };
50
+ persistence: {
51
+ enabled: boolean;
52
+ status: "active" | "archived";
53
+ nextRunAt: ISODateTime;
54
+ lastRunAt?: ISODateTime;
55
+ };
56
+ observability: {
57
+ state: SchedulerObservedState;
58
+ runningSeconds?: number;
59
+ stale: boolean;
60
+ staleThresholdSeconds: number;
61
+ retryPendingCount: number;
62
+ nextRetryAt?: ISODateTime;
63
+ };
64
+ latestRun?: SchedulerRunSummary;
65
+ }
66
+
67
+ export interface SchedulerOperationsSnapshot {
68
+ generatedAt: ISODateTime;
69
+ source: "scheduler";
70
+ stalePolicy: {
71
+ mode: "observe-only";
72
+ thresholdSeconds: number;
73
+ action: "manual-intervention";
74
+ };
75
+ summary: {
76
+ total: number;
77
+ running: number;
78
+ stale: number;
79
+ retryPending: number;
80
+ failed: number;
81
+ };
82
+ tasks: SchedulerOperationsTask[];
83
+ recentRuns: SchedulerRunSummary[];
84
+ }
85
+
86
+ export interface SchedulerManualTriggerResponse {
87
+ id: string;
88
+ taskKey: string;
89
+ trigger: SchedulerRunTrigger;
90
+ status: SchedulerRunStatus;
91
+ attempt: number;
92
+ requestedBy: string;
93
+ scheduledFor: ISODateTime;
94
+ startedAt: ISODateTime;
95
+ finishedAt?: ISODateTime;
96
+ nextAttemptAt?: ISODateTime;
97
+ error?: string;
98
+ }
99
+
100
+ export interface SchedulerStaleRecoveryRequest {
101
+ expectedStartedAt: ISODateTime;
102
+ reason: string;
103
+ }
104
+
105
+ export interface SchedulerStaleRecoveryResponse {
106
+ recoveryId: string;
107
+ run: SchedulerManualTriggerResponse;
108
+ }
@@ -0,0 +1,24 @@
1
+ export const SEARCH_PROVIDER_CONTRACT_VERSION = "1" as const;
2
+
3
+ export interface SearchProviderManifestDefinition {
4
+ key: string;
5
+ kind: "search";
6
+ contractVersion: typeof SEARCH_PROVIDER_CONTRACT_VERSION;
7
+ providerVersion: string;
8
+ label: string;
9
+ enabledByDefault: boolean;
10
+ permissionKey: string;
11
+ featureKey: string;
12
+ deepLink: {
13
+ surface: "business";
14
+ routeTemplate: string;
15
+ idParameter: string;
16
+ };
17
+ }
18
+
19
+ /** 让业务模块在注册点得到完整的 SearchProvider Manifest 类型校验。 */
20
+ export function defineSearchProvider(
21
+ definition: SearchProviderManifestDefinition,
22
+ ): SearchProviderManifestDefinition {
23
+ return definition;
24
+ }
@@ -0,0 +1,307 @@
1
+ import type { ISODateTime } from "./primitives.js";
2
+
3
+ export type WorkItemStatus =
4
+ | "pending"
5
+ | "claimed"
6
+ | "approved"
7
+ | "rejected"
8
+ | "cancelled"
9
+ | "timed-out";
10
+
11
+ export interface CandidateRole {
12
+ code: string;
13
+ name?: string;
14
+ }
15
+
16
+ export interface Assignee {
17
+ id: string;
18
+ name: string;
19
+ roleCodes: string[];
20
+ }
21
+
22
+ export type DueAt = ISODateTime;
23
+
24
+ export interface Reason {
25
+ text: string;
26
+ }
27
+
28
+ export interface Decision {
29
+ outcome: "approved" | "rejected";
30
+ reason: Reason;
31
+ }
32
+
33
+ export interface TargetRef {
34
+ type: string;
35
+ id: string;
36
+ /** 仅允许站内绝对路径,供通知等通用入口跳转。 */
37
+ route: string;
38
+ }
39
+
40
+ export interface WorkItem {
41
+ id: string;
42
+ title: string;
43
+ status: WorkItemStatus;
44
+ requesterId: string;
45
+ candidateRoles: CandidateRole[];
46
+ assignee?: Assignee;
47
+ dueAt: DueAt;
48
+ target: TargetRef;
49
+ createdAt: ISODateTime;
50
+ updatedAt: ISODateTime;
51
+ version: number;
52
+ }
53
+
54
+ export interface WorkflowActor {
55
+ id: string;
56
+ name: string;
57
+ type: "user" | "service";
58
+ roleCodes: string[];
59
+ canManage?: boolean;
60
+ }
61
+
62
+ export type WorkItemCommand =
63
+ | { type: "claim"; actor: WorkflowActor; at: ISODateTime }
64
+ | {
65
+ type: "transfer";
66
+ actor: WorkflowActor;
67
+ assignee: Assignee;
68
+ reason: Reason;
69
+ at: ISODateTime;
70
+ }
71
+ | { type: "approve"; actor: WorkflowActor; reason: Reason; at: ISODateTime }
72
+ | { type: "reject"; actor: WorkflowActor; reason: Reason; at: ISODateTime }
73
+ | { type: "cancel"; actor: WorkflowActor; reason: Reason; at: ISODateTime }
74
+ | { type: "timeout"; actor: WorkflowActor; at: ISODateTime };
75
+
76
+ export interface WorkItemTransition {
77
+ workItem: WorkItem;
78
+ fact: {
79
+ command: WorkItemCommand["type"];
80
+ beforeStatus: WorkItemStatus;
81
+ afterStatus: WorkItemStatus;
82
+ actorId: string;
83
+ occurredAt: ISODateTime;
84
+ target: TargetRef;
85
+ decision?: Decision;
86
+ reason?: Reason;
87
+ previousAssigneeId?: string;
88
+ assigneeId?: string;
89
+ };
90
+ }
91
+
92
+ export type WorkflowInvariantCode =
93
+ | "INVALID_WORK_ITEM"
94
+ | "INVALID_REASON"
95
+ | "INVALID_TARGET"
96
+ | "INVALID_DUE_AT"
97
+ | "TERMINAL_WORK_ITEM"
98
+ | "WORK_ITEM_OVERDUE"
99
+ | "ROLE_NOT_CANDIDATE"
100
+ | "ASSIGNEE_REQUIRED"
101
+ | "ASSIGNEE_MISMATCH"
102
+ | "MANAGE_REQUIRED"
103
+ | "SERVICE_REQUIRED"
104
+ | "DUE_AT_NOT_REACHED";
105
+
106
+ export class WorkflowInvariantError extends Error {
107
+ constructor(
108
+ readonly code: WorkflowInvariantCode,
109
+ message: string,
110
+ ) {
111
+ super(message);
112
+ this.name = "WorkflowInvariantError";
113
+ }
114
+ }
115
+
116
+ export interface CreateWorkItemInput {
117
+ id: string;
118
+ title: string;
119
+ requesterId: string;
120
+ candidateRoles: CandidateRole[];
121
+ dueAt: DueAt;
122
+ target: TargetRef;
123
+ createdAt: ISODateTime;
124
+ }
125
+
126
+ export function createWorkItem(input: CreateWorkItemInput): WorkItem {
127
+ if (!input.id.trim() || !input.title.trim() || !input.requesterId.trim()) {
128
+ fail("INVALID_WORK_ITEM", "审批任务标识、标题和发起人不能为空");
129
+ }
130
+ const roles = uniqueRoles(input.candidateRoles);
131
+ if (!roles.length) fail("INVALID_WORK_ITEM", "审批任务至少需要一个候选角色");
132
+ assertTarget(input.target);
133
+ if (dateValue(input.dueAt) <= dateValue(input.createdAt)) {
134
+ fail("INVALID_DUE_AT", "审批截止时间必须晚于创建时间");
135
+ }
136
+ return {
137
+ ...input,
138
+ title: input.title.trim(),
139
+ candidateRoles: roles,
140
+ status: "pending",
141
+ version: 1,
142
+ updatedAt: input.createdAt,
143
+ };
144
+ }
145
+
146
+ export function transitionWorkItem(
147
+ item: WorkItem,
148
+ command: WorkItemCommand,
149
+ ): WorkItemTransition {
150
+ assertMutable(item);
151
+ assertChronology(item, command.at);
152
+ const beforeStatus = item.status;
153
+ const previousAssigneeId = item.assignee?.id;
154
+ let status = item.status;
155
+ let assignee = item.assignee;
156
+ let decision: Decision | undefined;
157
+ let reason: Reason | undefined;
158
+
159
+ switch (command.type) {
160
+ case "claim":
161
+ assertPending(item);
162
+ assertCandidate(item, command.actor.roleCodes);
163
+ assignee = actorAssignee(command.actor);
164
+ status = "claimed";
165
+ break;
166
+ case "transfer":
167
+ assertClaimed(item);
168
+ if (item.assignee?.id !== command.actor.id && !command.actor.canManage) {
169
+ fail("MANAGE_REQUIRED", "仅当前领取人或管理员可以转交审批任务");
170
+ }
171
+ assertCandidate(item, command.assignee.roleCodes);
172
+ reason = validReason(command.reason);
173
+ assignee = command.assignee;
174
+ break;
175
+ case "approve":
176
+ case "reject":
177
+ assertClaimedBy(item, command.actor.id);
178
+ assertBeforeDue(item, command.at);
179
+ reason = validReason(command.reason);
180
+ status = command.type === "approve" ? "approved" : "rejected";
181
+ decision = { outcome: status, reason };
182
+ break;
183
+ case "cancel":
184
+ if (command.actor.id !== item.requesterId && !command.actor.canManage) {
185
+ fail("MANAGE_REQUIRED", "仅发起人或管理员可以取消审批任务");
186
+ }
187
+ reason = validReason(command.reason);
188
+ status = "cancelled";
189
+ break;
190
+ case "timeout":
191
+ if (command.actor.type !== "service") {
192
+ fail("SERVICE_REQUIRED", "仅后台服务可以执行审批超时");
193
+ }
194
+ if (dateValue(command.at) < dateValue(item.dueAt)) {
195
+ fail("DUE_AT_NOT_REACHED", "尚未到达审批截止时间");
196
+ }
197
+ status = "timed-out";
198
+ break;
199
+ }
200
+
201
+ const workItem = {
202
+ ...item,
203
+ status,
204
+ assignee,
205
+ updatedAt: command.at,
206
+ version: item.version + 1,
207
+ };
208
+ return {
209
+ workItem,
210
+ fact: {
211
+ command: command.type,
212
+ beforeStatus,
213
+ afterStatus: status,
214
+ actorId: command.actor.id,
215
+ occurredAt: command.at,
216
+ target: item.target,
217
+ decision,
218
+ reason,
219
+ previousAssigneeId,
220
+ assigneeId: assignee?.id,
221
+ },
222
+ };
223
+ }
224
+
225
+ function assertMutable(item: WorkItem) {
226
+ if (!["pending", "claimed"].includes(item.status)) {
227
+ fail("TERMINAL_WORK_ITEM", "终态审批任务不能再次变更");
228
+ }
229
+ }
230
+
231
+ function assertChronology(item: WorkItem, at: ISODateTime) {
232
+ if (dateValue(at) < dateValue(item.updatedAt)) {
233
+ fail("INVALID_WORK_ITEM", "命令时间不能早于审批任务最后更新时间");
234
+ }
235
+ }
236
+
237
+ function assertPending(item: WorkItem) {
238
+ if (item.status !== "pending")
239
+ fail("INVALID_WORK_ITEM", "仅待领取任务可以领取");
240
+ }
241
+
242
+ function assertClaimed(item: WorkItem) {
243
+ if (item.status !== "claimed" || !item.assignee) {
244
+ fail("ASSIGNEE_REQUIRED", "审批任务尚未领取");
245
+ }
246
+ }
247
+
248
+ function assertClaimedBy(item: WorkItem, actorId: string) {
249
+ assertClaimed(item);
250
+ if (item.assignee?.id !== actorId) {
251
+ fail("ASSIGNEE_MISMATCH", "仅当前领取人可以提交审批决定");
252
+ }
253
+ }
254
+
255
+ function assertCandidate(item: WorkItem, roleCodes: string[]) {
256
+ if (!item.candidateRoles.some(({ code }) => roleCodes.includes(code))) {
257
+ fail("ROLE_NOT_CANDIDATE", "领取人不属于候选角色");
258
+ }
259
+ }
260
+
261
+ function assertBeforeDue(item: WorkItem, at: ISODateTime) {
262
+ if (dateValue(at) >= dateValue(item.dueAt)) {
263
+ fail("WORK_ITEM_OVERDUE", "审批任务已到期,不能再提交决定");
264
+ }
265
+ }
266
+
267
+ function actorAssignee(actor: WorkflowActor): Assignee {
268
+ return { id: actor.id, name: actor.name, roleCodes: [...actor.roleCodes] };
269
+ }
270
+
271
+ function validReason(reason: Reason) {
272
+ const text = reason.text.trim();
273
+ if (!text || text.length > 500) {
274
+ fail("INVALID_REASON", "审批理由必须为 1 至 500 个字符");
275
+ }
276
+ return { text };
277
+ }
278
+
279
+ function uniqueRoles(roles: CandidateRole[]) {
280
+ const byCode = new Map<string, CandidateRole>();
281
+ for (const role of roles) {
282
+ const code = role.code.trim();
283
+ if (code) byCode.set(code, { ...role, code });
284
+ }
285
+ return [...byCode.values()];
286
+ }
287
+
288
+ function assertTarget(target: TargetRef) {
289
+ if (
290
+ !target.type.trim() ||
291
+ !target.id.trim() ||
292
+ !target.route.startsWith("/") ||
293
+ target.route.startsWith("//")
294
+ ) {
295
+ fail("INVALID_TARGET", "审批目标必须包含合法的站内引用");
296
+ }
297
+ }
298
+
299
+ function dateValue(value: ISODateTime) {
300
+ const result = Date.parse(value);
301
+ if (!Number.isFinite(result)) fail("INVALID_WORK_ITEM", "时间格式无效");
302
+ return result;
303
+ }
304
+
305
+ function fail(code: WorkflowInvariantCode, message: string): never {
306
+ throw new WorkflowInvariantError(code, message);
307
+ }