create-windy 0.2.18 → 0.2.20

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.
Files changed (84) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +752 -186
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/apps/server/src/application-services.ts +2 -2
  6. package/template/apps/server/src/audit/search-summary.ts +15 -8
  7. package/template/apps/server/src/example-modules.ts +58 -70
  8. package/template/apps/server/src/foundation.ts +8 -4
  9. package/template/apps/server/src/guards.test.ts +13 -0
  10. package/template/apps/server/src/guards.ts +12 -2
  11. package/template/apps/server/src/index.ts +48 -91
  12. package/template/apps/server/src/installed-business-modules.ts +23 -4
  13. package/template/apps/server/src/module-bootstrap.ts +83 -0
  14. package/template/apps/server/src/module-composition.test.ts +45 -1
  15. package/template/apps/server/src/module-composition.ts +45 -0
  16. package/template/apps/server/src/module-host/initialize.test.ts +123 -0
  17. package/template/apps/server/src/module-host/initialize.ts +331 -0
  18. package/template/apps/server/src/module-host/request-context.test.ts +66 -0
  19. package/template/apps/server/src/module-host/request-context.ts +156 -0
  20. package/template/apps/server/src/module-host/role-presets.test.ts +94 -0
  21. package/template/apps/server/src/module-host/role-presets.ts +76 -0
  22. package/template/apps/server/src/module-host/route-installer.test.ts +317 -0
  23. package/template/apps/server/src/module-host/route-installer.ts +97 -0
  24. package/template/apps/server/src/module-host/route-registration.test.ts +160 -0
  25. package/template/apps/server/src/module-host/search-providers.ts +31 -0
  26. package/template/apps/server/src/module-host/storage-port.test.ts +165 -0
  27. package/template/apps/server/src/module-host/storage-port.ts +59 -0
  28. package/template/apps/server/src/module-host/task-definitions.ts +41 -0
  29. package/template/apps/server/src/module-host/test-fixtures.ts +26 -0
  30. package/template/apps/server/src/module-host/tool-handlers.ts +25 -0
  31. package/template/apps/server/src/module-host.test.ts +202 -58
  32. package/template/apps/server/src/module-host.ts +34 -113
  33. package/template/apps/server/src/route-guards.test.ts +112 -1
  34. package/template/apps/server/src/runtime-feature.ts +66 -31
  35. package/template/apps/server/src/runtime.test.ts +1 -0
  36. package/template/apps/server/src/runtime.ts +3 -1
  37. package/template/apps/server/src/system/built-in-roles.ts +1 -1
  38. package/template/apps/server/src/work-order/foundation-modules.test.ts +30 -10
  39. package/template/apps/server/src/work-order/routes.test.ts +30 -18
  40. package/template/apps/server/src/work-order/routes.ts +104 -135
  41. package/template/apps/server/src/work-order/search-api.test.ts +14 -3
  42. package/template/apps/server/src/work-order/search-provider.test.ts +23 -15
  43. package/template/apps/server/src/work-order/search-provider.ts +17 -12
  44. package/template/apps/server/src/work-order/task.test.ts +16 -11
  45. package/template/apps/web/Dockerfile +4 -1
  46. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +4 -1
  47. package/template/apps/web/src/layout/GlobalWatermark.vue +2 -0
  48. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +4 -1
  49. package/template/docs/architecture/object-storage.md +12 -0
  50. package/template/packages/config/index.test.ts +57 -0
  51. package/template/packages/config/package.json +1 -1
  52. package/template/packages/config/src/platform.ts +28 -1
  53. package/template/packages/database/package.json +1 -1
  54. package/template/packages/database/src/runner.ts +7 -9
  55. package/template/packages/example-work-order/index.ts +1 -0
  56. package/template/packages/example-work-order/package.json +2 -0
  57. package/template/packages/example-work-order/src/server-module.ts +61 -0
  58. package/template/packages/jobs/package.json +1 -1
  59. package/template/packages/jobs/src/types.ts +7 -1
  60. package/template/packages/modules/package.json +1 -1
  61. package/template/packages/modules/src/catalog-validation.test.ts +212 -0
  62. package/template/packages/modules/src/catalog-validation.ts +19 -2
  63. package/template/packages/modules/src/migration-bundle-validation.test.ts +191 -0
  64. package/template/packages/server-sdk/index.ts +42 -0
  65. package/template/packages/server-sdk/package.json +16 -3
  66. package/template/packages/server-sdk/src/actor.ts +15 -0
  67. package/template/packages/server-sdk/src/audit-port.ts +22 -0
  68. package/template/packages/server-sdk/src/jobs-port.ts +24 -0
  69. package/template/packages/server-sdk/src/module-host.ts +36 -1
  70. package/template/packages/server-sdk/src/namespace.test.ts +26 -0
  71. package/template/packages/server-sdk/src/namespace.ts +13 -0
  72. package/template/packages/server-sdk/src/request-context.ts +21 -0
  73. package/template/packages/server-sdk/src/role-preset.ts +18 -0
  74. package/template/packages/server-sdk/src/route-definition.ts +26 -0
  75. package/template/packages/server-sdk/src/search-provider-port.ts +48 -0
  76. package/template/packages/server-sdk/src/storage-port.ts +25 -0
  77. package/template/packages/server-sdk/src/task-registration.ts +18 -0
  78. package/template/packages/server-sdk/src/tool-registration.ts +29 -0
  79. package/template/packages/shared/index.ts +1 -0
  80. package/template/packages/shared/package.json +1 -1
  81. package/template/packages/shared/src/license-catalog.test.ts +73 -1
  82. package/template/packages/shared/src/license-catalog.ts +95 -0
  83. package/template/packages/{database/src/bundle-validation.ts → shared/src/migration-bundle-validation.ts} +2 -1
  84. package/template/apps/server/src/work-order/task.ts +0 -30
@@ -1,4 +1,10 @@
1
- import type { FeatureFlagDefinition } from "@southwind-ai/shared";
1
+ import {
2
+ isLicenseVersionAllowed,
3
+ resolveFeatureState,
4
+ type FeatureFlagDefinition,
5
+ type FeatureFlagState,
6
+ } from "@southwind-ai/shared";
7
+ import type { RequestGuardContext } from "./guards.js";
2
8
 
3
9
  export interface FeatureFlagRuntimeResolver {
4
10
  resolve(
@@ -16,11 +22,16 @@ export interface FeatureFlagRuntimeState {
16
22
  allowedLicenseVersions?: FeatureFlagDefinition["allowedLicenseVersions"];
17
23
  }
18
24
 
25
+ /** 运行时解析后的 Feature;disabledReason 复用 shared resolveFeatureState 语义。 */
26
+ export interface ResolvedFeatureFlag extends FeatureFlagDefinition {
27
+ disabledReason?: FeatureFlagState["reason"];
28
+ }
29
+
19
30
  export class RuntimeFeatureCatalog {
20
31
  private readonly definitions = new Map<string, FeatureFlagDefinition>();
21
32
  private readonly requestCache = new WeakMap<
22
33
  object,
23
- Map<string, FeatureFlagDefinition>
34
+ Map<string, ResolvedFeatureFlag>
24
35
  >();
25
36
 
26
37
  constructor(
@@ -34,18 +45,18 @@ export class RuntimeFeatureCatalog {
34
45
  }
35
46
 
36
47
  resolve(
37
- context: object,
48
+ context: RequestGuardContext,
38
49
  manifestFeature: FeatureFlagDefinition,
39
- ): Promise<FeatureFlagDefinition> {
50
+ ): Promise<ResolvedFeatureFlag> {
40
51
  this.definitions.set(manifestFeature.key, manifestFeature);
41
52
  return this.resolveInternal(context, manifestFeature, new Set());
42
53
  }
43
54
 
44
55
  private async resolveInternal(
45
- context: object,
56
+ context: RequestGuardContext,
46
57
  manifestFeature: FeatureFlagDefinition,
47
58
  visiting: ReadonlySet<string>,
48
- ): Promise<FeatureFlagDefinition> {
59
+ ): Promise<ResolvedFeatureFlag> {
49
60
  const cache = this.cacheFor(context);
50
61
  const cached = cache.get(manifestFeature.key);
51
62
  if (cached) return cached;
@@ -53,33 +64,42 @@ export class RuntimeFeatureCatalog {
53
64
  return disabledFeature(manifestFeature);
54
65
  }
55
66
 
56
- const resolved = await this.resolveOwnState(manifestFeature);
57
- if (!resolved.enabled || !manifestFeature.dependencies?.length) {
58
- cache.set(manifestFeature.key, resolved);
59
- return resolved;
60
- }
61
-
62
- const nextVisiting = new Set(visiting);
63
- nextVisiting.add(manifestFeature.key);
64
- for (const dependencyKey of manifestFeature.dependencies) {
65
- const dependency = this.definitions.get(dependencyKey);
66
- if (!dependency) {
67
- const disabled = disabledFeature(resolved);
68
- cache.set(manifestFeature.key, disabled);
69
- return disabled;
70
- }
71
- const dependencyState = await this.resolveInternal(
72
- context,
73
- dependency,
74
- nextVisiting,
75
- );
76
- if (!dependencyState.enabled || dependencyState.visible !== "visible") {
77
- const disabled = { ...resolved, enabled: false };
78
- cache.set(manifestFeature.key, disabled);
79
- return disabled;
67
+ const own = await this.resolveOwnState(manifestFeature);
68
+ // dependencies 来自 manifest;启用集合取运行时解析后的依赖状态,
69
+ // 最终判定统一交给 shared resolveFeatureState,避免第二套漂移逻辑。
70
+ const enabledDependencies = new Set<string>();
71
+ if (
72
+ own.enabled &&
73
+ own.visible === "visible" &&
74
+ manifestFeature.dependencies?.length
75
+ ) {
76
+ const nextVisiting = new Set(visiting);
77
+ nextVisiting.add(manifestFeature.key);
78
+ for (const dependencyKey of manifestFeature.dependencies) {
79
+ const dependency = this.definitions.get(dependencyKey);
80
+ if (!dependency) continue;
81
+ const dependencyState = await this.resolveInternal(
82
+ context,
83
+ dependency,
84
+ nextVisiting,
85
+ );
86
+ if (
87
+ dependencyState.enabled &&
88
+ dependencyState.visible === "visible" &&
89
+ isFeatureLicensedForContext(dependencyState, context)
90
+ ) {
91
+ enabledDependencies.add(dependencyKey);
92
+ }
80
93
  }
81
94
  }
82
95
 
96
+ const state = resolveFeatureState(own, enabledDependencies, true);
97
+ const resolved: ResolvedFeatureFlag = {
98
+ ...own,
99
+ enabled: state.enabled,
100
+ visible: state.visible,
101
+ ...(state.reason ? { disabledReason: state.reason } : {}),
102
+ };
83
103
  cache.set(manifestFeature.key, resolved);
84
104
  return resolved;
85
105
  }
@@ -108,7 +128,9 @@ export class RuntimeFeatureCatalog {
108
128
  }
109
129
  }
110
130
 
111
- private cacheFor(context: object): Map<string, FeatureFlagDefinition> {
131
+ private cacheFor(
132
+ context: RequestGuardContext,
133
+ ): Map<string, ResolvedFeatureFlag> {
112
134
  let cache = this.requestCache.get(context);
113
135
  if (!cache) {
114
136
  cache = new Map();
@@ -118,6 +140,19 @@ export class RuntimeFeatureCatalog {
118
140
  }
119
141
  }
120
142
 
143
+ function isFeatureLicensedForContext(
144
+ feature: FeatureFlagDefinition,
145
+ context: RequestGuardContext,
146
+ ): boolean {
147
+ return (
148
+ isLicenseVersionAllowed(feature, context.license?.versionKey) &&
149
+ (!feature.requiredEntitlement ||
150
+ Boolean(
151
+ context.license?.entitlements?.includes(feature.requiredEntitlement),
152
+ ))
153
+ );
154
+ }
155
+
121
156
  function disabledFeature(
122
157
  feature: FeatureFlagDefinition,
123
158
  ): FeatureFlagDefinition {
@@ -273,6 +273,7 @@ describe("server runtime", () => {
273
273
  expect(await runtime.resolveFeature(context, capability)).toMatchObject({
274
274
  enabled: false,
275
275
  visible: "visible",
276
+ disabledReason: "dependency-missing",
276
277
  });
277
278
  });
278
279
 
@@ -25,11 +25,13 @@ import { developmentAdminActor } from "./runtime-development.js";
25
25
  import {
26
26
  RuntimeFeatureCatalog,
27
27
  type FeatureFlagRuntimeResolver,
28
+ type ResolvedFeatureFlag,
28
29
  } from "./runtime-feature.js";
29
30
 
30
31
  export type {
31
32
  FeatureFlagRuntimeResolver,
32
33
  FeatureFlagRuntimeState,
34
+ ResolvedFeatureFlag,
33
35
  } from "./runtime-feature.js";
34
36
 
35
37
  export interface SessionRecord {
@@ -230,7 +232,7 @@ export function createServerRuntime(
230
232
  async resolveFeature(
231
233
  context: ReturnType<typeof createRequestGuardContext>,
232
234
  manifestFeature: FeatureFlagDefinition,
233
- ): Promise<FeatureFlagDefinition> {
235
+ ): Promise<ResolvedFeatureFlag> {
234
236
  return featureCatalog.resolve(context, manifestFeature);
235
237
  },
236
238
  recordDecisions(decisions: Array<GuardDecision | undefined>) {
@@ -90,7 +90,7 @@ function role(
90
90
  };
91
91
  }
92
92
 
93
- function menuKeysForPermissions(
93
+ export function menuKeysForPermissions(
94
94
  snapshot: FoundationSnapshot,
95
95
  permissions: string[],
96
96
  ): string[] {
@@ -1,12 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { featureKey } from "@southwind-ai/shared";
3
+ import { validateCapabilityCatalogs } from "@southwind-ai/modules";
3
4
  import {
4
5
  createFoundationSnapshot,
5
6
  loadFoundationModules,
6
7
  } from "../foundation.js";
7
- import { createExampleModuleRuntime } from "../example-modules.js";
8
- import type { ManagedDepartment } from "../system/entities.js";
9
- import { InMemoryEntityRepository } from "../system/repository.js";
8
+ import { createWorkOrderModule } from "../example-modules.js";
9
+ import {
10
+ adaptModuleSearchProviders,
11
+ initializeModuleHosts,
12
+ } from "../module-host.js";
10
13
 
11
14
  describe("平台模块 composition root", () => {
12
15
  test("默认安装平台能力模块与可删除的工单模块", () => {
@@ -47,6 +50,21 @@ describe("平台模块 composition root", () => {
47
50
  );
48
51
  });
49
52
 
53
+ test("示例工单的合法 Migration Bundle 通过启动校验", () => {
54
+ const modules = loadFoundationModules();
55
+ const workOrder = modules.find(({ name }) => name === "work-order");
56
+
57
+ expect(() => validateCapabilityCatalogs(modules)).not.toThrow();
58
+ expect(workOrder?.migrationBundle?.migrations.map(({ id }) => id)).toEqual([
59
+ "work-order.0001.create-work-orders",
60
+ ]);
61
+ expect(
62
+ workOrder?.migrationBundle?.migrations.every(({ checksum }) =>
63
+ checksum.startsWith("sha256:"),
64
+ ),
65
+ ).toBe(true);
66
+ });
67
+
50
68
  test("业务 API 的 package 依赖图不经过 Windy Web 或 Vue", async () => {
51
69
  const serverPackage = await packageJson(
52
70
  new URL("../../package.json", import.meta.url),
@@ -66,21 +84,23 @@ describe("平台模块 composition root", () => {
66
84
  });
67
85
  expect(serverPackage.dependencies).not.toHaveProperty("@southwind-ai/web");
68
86
  expect(serverPackage.dependencies).not.toHaveProperty("vue");
69
- expect(businessPackage.dependencies).not.toHaveProperty("@southwind-ai/web");
87
+ expect(businessPackage.dependencies).not.toHaveProperty(
88
+ "@southwind-ai/web",
89
+ );
70
90
  expect(businessPackage.dependencies).not.toHaveProperty("vue");
71
91
  expect(webPackage.dependencies).not.toHaveProperty(
72
92
  "@southwind-ai/example-work-order",
73
93
  );
74
94
  });
75
95
 
76
- test("示例模块运行时从同一 composition seam 提供搜索 Handler", () => {
77
- const snapshot = createFoundationSnapshot();
78
- const runtime = createExampleModuleRuntime({
79
- features: snapshot.features,
80
- departments: new InMemoryEntityRepository<ManagedDepartment>(),
96
+ test("示例模块经 Host 声明式装配提供搜索 Provider", async () => {
97
+ const module = createWorkOrderModule();
98
+ const hosts = await initializeModuleHosts({
99
+ modules: [module.manifest],
100
+ installations: [module],
81
101
  });
82
102
 
83
- expect(runtime?.providers.map(({ key }) => key)).toEqual([
103
+ expect(adaptModuleSearchProviders(hosts).map(({ key }) => key)).toEqual([
84
104
  "work-order.ticket.search",
85
105
  ]);
86
106
  });
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { WorkOrder } from "@southwind-ai/example-work-order";
2
+ import {
3
+ workOrderModule,
4
+ type WorkOrder,
5
+ } from "@southwind-ai/example-work-order";
3
6
  import { licenseVersionKey } from "@southwind-ai/shared";
7
+ import { initializeModuleHosts, installModuleRoutes } from "../module-host.js";
8
+ import { createWorkOrderModule } from "../example-modules.js";
4
9
  import { InMemoryEntityRepository } from "../system/repository.js";
5
10
  import { InMemoryScopedEntityRepository } from "../data-access/scoped-repository.js";
6
11
  import { buildScopeContext } from "../data-access/context.js";
@@ -8,8 +13,6 @@ import type { ManagedDepartment } from "../system/entities.js";
8
13
  import { FakeRouteApp } from "../system/route-test-app.js";
9
14
  import { createServerRuntime } from "../runtime.js";
10
15
  import type { RequestActor } from "../guards.js";
11
- import { registerWorkOrderRoutes } from "./routes.js";
12
- import { WorkOrderService } from "./service.js";
13
16
  import { workOrderDataScopeBinding } from "./repository.js";
14
17
 
15
18
  describe("示例工单 routes", () => {
@@ -39,6 +42,9 @@ describe("示例工单 routes", () => {
39
42
  expect(await reason(rbacDenied.invoke("GET", "/work-orders"))).toBe(
40
43
  "missing-permission",
41
44
  );
45
+ expect(
46
+ rbacDenied.runtime.auditSink.list().map(({ action }) => action),
47
+ ).toContain("security.denied");
42
48
 
43
49
  const licenseDenied = await createFixture({ licenseVersion: "basic" });
44
50
  expect(await reason(licenseDenied.invoke("GET", "/work-orders"))).toBe(
@@ -100,23 +106,29 @@ async function createFixture(options: FixtureOptions = {}) {
100
106
  },
101
107
  },
102
108
  );
109
+ const module = createWorkOrderModule();
110
+ const hosts = await initializeModuleHosts({
111
+ modules: [module.manifest],
112
+ installations: [module],
113
+ });
114
+ module.attach({
115
+ departments: new InMemoryEntityRepository<ManagedDepartment>([
116
+ department("dept_a"),
117
+ department("dept_b"),
118
+ ]),
119
+ repository: new InMemoryScopedEntityRepository<WorkOrder>(
120
+ [
121
+ ticket("ticket_self", "user_self", "dept_a"),
122
+ ticket("ticket_other", "user_other", "dept_b"),
123
+ ],
124
+ workOrderDataScopeBinding,
125
+ ),
126
+ });
103
127
  const app = new FakeRouteApp();
104
- const workOrders = new InMemoryScopedEntityRepository<WorkOrder>(
105
- [
106
- ticket("ticket_self", "user_self", "dept_a"),
107
- ticket("ticket_other", "user_other", "dept_b"),
108
- ],
109
- workOrderDataScopeBinding,
110
- );
111
- const departments = new InMemoryEntityRepository<ManagedDepartment>([
112
- department("dept_a"),
113
- department("dept_b"),
114
- ]);
115
- registerWorkOrderRoutes(
116
- app,
117
- new WorkOrderService(workOrders, departments),
128
+ installModuleRoutes(hosts, app, {
118
129
  runtime,
119
- );
130
+ features: workOrderModule().features,
131
+ });
120
132
  const guardContext = await runtime.createGuardContext(
121
133
  new Request("http://localhost/api/work-orders"),
122
134
  );
@@ -1,141 +1,123 @@
1
1
  import {
2
2
  createWorkOrderSchema,
3
3
  updateWorkOrderSchema,
4
- workOrderModule,
5
4
  } from "@southwind-ai/example-work-order";
6
- import type { ApiPermissionBinding } from "@southwind-ai/shared";
7
- import { recordMutationAudit } from "../audit/policy.js";
8
- import { accessCall } from "../system/resource-access-response.js";
9
- import { evaluateRouteGuards } from "../route-guards.js";
10
- import type { createServerRuntime } from "../runtime.js";
11
- import {
12
- forbidden,
13
- notFound,
14
- resourceEvent,
15
- routeContext,
16
- } from "../system/route-helpers.js";
17
- import type { RouteApp } from "../system/route-types.js";
5
+ import type { ModuleRouteDefinition } from "@southwind-ai/server-sdk";
6
+ import { searchAuditMetadata } from "../audit/search-summary.js";
7
+ import { requestActorFromModule } from "../module-host/request-context.js";
18
8
  import { parsePagination } from "../pagination.js";
9
+ import { accessCall } from "../system/resource-access-response.js";
10
+ import { notFound } from "../system/route-helpers.js";
19
11
  import type { WorkOrderService } from "./service.js";
20
- import { searchAuditEvent } from "../audit/search-summary.js";
21
12
 
22
- type ServerRuntime = ReturnType<typeof createServerRuntime>;
23
- const manifest = workOrderModule();
24
- const feature = manifest.features[0];
25
-
26
- export function registerWorkOrderRoutes(
27
- app: RouteApp,
28
- service: WorkOrderService,
29
- runtime: ServerRuntime,
30
- ) {
31
- app.get("/work-orders", (raw) =>
32
- guarded(raw, binding("GET", "/api/work-orders"), runtime, async () => {
33
- const context = routeContext(raw);
34
- const pagination = parsePagination(context.query);
35
- if (pagination instanceof Response) return pagination;
36
- const result = await accessCall(() =>
37
- service.list(
38
- { ...pagination, q: context.query.q },
39
- context.guardContext.actor,
40
- ),
41
- );
42
- if (!(result instanceof Response) && context.query.q?.trim()) {
43
- runtime.auditSink.recordEvent(
44
- searchAuditEvent(
45
- context.guardContext,
46
- "work-order",
47
- context.query.q,
48
- result.total,
13
+ /**
14
+ * 示例工单的 Guarded Route 定义。
15
+ *
16
+ * 只声明 method/path/permissionKey 与 Handler;Guard、审计上下文与受限 Port
17
+ * 由宿主在 installModuleRoutes 时统一绑定,不再手工查询 Manifest 绑定。
18
+ * `requireService` 延迟解析宿主装配完成后才存在的模块服务。
19
+ */
20
+ export function createWorkOrderRouteDefinitions(
21
+ requireService: () => WorkOrderService,
22
+ ): ModuleRouteDefinition[] {
23
+ return [
24
+ {
25
+ method: "GET",
26
+ path: "/api/work-orders",
27
+ permissionKey: "work-order.ticket.read",
28
+ handler: async (context) => {
29
+ const pagination = parsePagination(context.query);
30
+ if (pagination instanceof Response) return pagination;
31
+ const result = await accessCall(() =>
32
+ requireService().list(
33
+ { ...pagination, q: context.query.q },
34
+ requestActorFromModule(context.actor),
49
35
  ),
50
36
  );
51
- }
52
- return result;
53
- }),
54
- );
55
-
56
- app.get("/work-orders/:id", (raw) =>
57
- guarded(raw, binding("GET", "/api/work-orders/:id"), runtime, async () => {
58
- const context = routeContext(raw);
59
- const id = context.params.id || "";
60
- const item = await accessCall(() =>
61
- service.get(id, context.guardContext.actor),
62
- );
63
- return item instanceof Response
64
- ? item
65
- : item || notFound("work-order", id);
66
- }),
67
- );
68
-
69
- app.post("/work-orders", (raw) =>
70
- guarded(raw, binding("POST", "/api/work-orders"), runtime, async () => {
71
- const context = routeContext(raw);
72
- const parsed = createWorkOrderSchema.safeParse(context.body);
73
- if (!parsed.success) return invalid(parsed.error.issues[0]?.message);
74
- const item = await accessCall(() =>
75
- service.create(parsed.data, context.guardContext.actor),
76
- );
77
- if (item instanceof Response) return item;
78
- await audit(runtime, context.guardContext, "crud.create", item.id);
79
- return item;
80
- }),
81
- );
82
-
83
- app.put("/work-orders/:id", (raw) =>
84
- guarded(raw, binding("PUT", "/api/work-orders/:id"), runtime, async () => {
85
- const context = routeContext(raw);
86
- const id = context.params.id || "";
87
- const parsed = updateWorkOrderSchema.safeParse(context.body);
88
- if (!parsed.success) return invalid(parsed.error.issues[0]?.message);
89
- const item = await accessCall(() =>
90
- service.update(id, parsed.data, context.guardContext.actor),
91
- );
92
- if (item instanceof Response) return item;
93
- if (!item) return notFound("work-order", id);
94
- await audit(runtime, context.guardContext, "crud.update", id);
95
- return item;
96
- }),
97
- );
98
-
99
- app.delete("/work-orders/:id", (raw) =>
100
- guarded(
101
- raw,
102
- binding("DELETE", "/api/work-orders/:id"),
103
- runtime,
104
- async () => {
105
- const context = routeContext(raw);
37
+ if (!(result instanceof Response) && context.query.q?.trim()) {
38
+ await context.audit.emit("data.search", {
39
+ target: { type: "work-order" },
40
+ metadata: searchAuditMetadata(context.query.q, result.total),
41
+ });
42
+ }
43
+ return result;
44
+ },
45
+ },
46
+ {
47
+ method: "GET",
48
+ path: "/api/work-orders/:id",
49
+ permissionKey: "work-order.ticket.read",
50
+ handler: async (context) => {
106
51
  const id = context.params.id || "";
107
52
  const item = await accessCall(() =>
108
- service.archive(id, context.guardContext.actor),
53
+ requireService().get(id, requestActorFromModule(context.actor)),
54
+ );
55
+ return item instanceof Response
56
+ ? item
57
+ : item || notFound("work-order", id);
58
+ },
59
+ },
60
+ {
61
+ method: "POST",
62
+ path: "/api/work-orders",
63
+ permissionKey: "work-order.ticket.create",
64
+ handler: async (context) => {
65
+ const parsed = createWorkOrderSchema.safeParse(context.body);
66
+ if (!parsed.success) return invalid(parsed.error.issues[0]?.message);
67
+ const item = await accessCall(() =>
68
+ requireService().create(
69
+ parsed.data,
70
+ requestActorFromModule(context.actor),
71
+ ),
72
+ );
73
+ if (item instanceof Response) return item;
74
+ await context.audit.emit("crud.create", {
75
+ target: { type: "work-order", id: item.id },
76
+ });
77
+ return item;
78
+ },
79
+ },
80
+ {
81
+ method: "PUT",
82
+ path: "/api/work-orders/:id",
83
+ permissionKey: "work-order.ticket.manage",
84
+ handler: async (context) => {
85
+ const id = context.params.id || "";
86
+ const parsed = updateWorkOrderSchema.safeParse(context.body);
87
+ if (!parsed.success) return invalid(parsed.error.issues[0]?.message);
88
+ const item = await accessCall(() =>
89
+ requireService().update(
90
+ id,
91
+ parsed.data,
92
+ requestActorFromModule(context.actor),
93
+ ),
109
94
  );
110
95
  if (item instanceof Response) return item;
111
96
  if (!item) return notFound("work-order", id);
112
- await audit(runtime, context.guardContext, "crud.delete", id);
97
+ await context.audit.emit("crud.update", {
98
+ target: { type: "work-order", id },
99
+ });
113
100
  return item;
114
101
  },
115
- ),
116
- );
117
- }
118
-
119
- async function guarded(
120
- raw: unknown,
121
- permission: ApiPermissionBinding,
122
- runtime: ServerRuntime,
123
- handler: () => unknown,
124
- ) {
125
- const decision = await evaluateRouteGuards(
126
- runtime,
127
- routeContext(raw).guardContext,
128
- { permission, feature },
129
- );
130
- return decision.allowed ? handler() : forbidden(decision.reason);
131
- }
132
-
133
- function binding(method: string, path: string): ApiPermissionBinding {
134
- const result = manifest.apiPermissions.find(
135
- (item) => item.method === method && item.path === path,
136
- );
137
- if (!result) throw new Error(`示例工单缺少 API 绑定:${method} ${path}`);
138
- return result;
102
+ },
103
+ {
104
+ method: "DELETE",
105
+ path: "/api/work-orders/:id",
106
+ permissionKey: "work-order.ticket.manage",
107
+ handler: async (context) => {
108
+ const id = context.params.id || "";
109
+ const item = await accessCall(() =>
110
+ requireService().archive(id, requestActorFromModule(context.actor)),
111
+ );
112
+ if (item instanceof Response) return item;
113
+ if (!item) return notFound("work-order", id);
114
+ await context.audit.emit("crud.delete", {
115
+ target: { type: "work-order", id },
116
+ });
117
+ return item;
118
+ },
119
+ },
120
+ ];
139
121
  }
140
122
 
141
123
  function invalid(message = "工单内容无效") {
@@ -144,16 +126,3 @@ function invalid(message = "工单内容无效") {
144
126
  { status: 400 },
145
127
  );
146
128
  }
147
-
148
- function audit(
149
- runtime: ServerRuntime,
150
- context: ReturnType<typeof routeContext>["guardContext"],
151
- action: "crud.create" | "crud.update" | "crud.delete",
152
- id: string,
153
- ) {
154
- return recordMutationAudit(
155
- runtime,
156
- "work-orders",
157
- resourceEvent(context, action, "work-order", id),
158
- );
159
- }
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { workOrderModule, type WorkOrder } from "@southwind-ai/example-work-order";
3
- import { systemFoundationModule, type ModuleManifest } from "@southwind-ai/modules";
2
+ import {
3
+ workOrderModule,
4
+ type WorkOrder,
5
+ } from "@southwind-ai/example-work-order";
6
+ import {
7
+ systemFoundationModule,
8
+ type ModuleManifest,
9
+ } from "@southwind-ai/modules";
4
10
  import { licenseVersionKey } from "@southwind-ai/shared";
5
11
  import { buildScopeContext } from "../data-access/context.js";
6
12
  import { InMemoryScopedEntityRepository } from "../data-access/scoped-repository.js";
@@ -11,6 +17,7 @@ import { searchTestContext } from "../search/test-support.js";
11
17
  import type { ManagedDepartment } from "../system/entities.js";
12
18
  import { InMemoryEntityRepository } from "../system/repository.js";
13
19
  import type { RouteApp } from "../system/route-types.js";
20
+ import { adaptModuleSearchProvider } from "../module-host.js";
14
21
  import { workOrderDataScopeBinding } from "./repository.js";
15
22
  import { WorkOrderSearchProvider } from "./search-provider.js";
16
23
  import { WorkOrderService } from "./service.js";
@@ -45,7 +52,11 @@ describe("搜索 API 数据范围", () => {
45
52
  );
46
53
  const service = new SearchService(
47
54
  modules,
48
- [new WorkOrderSearchProvider(workOrders)],
55
+ [
56
+ adaptModuleSearchProvider(
57
+ new WorkOrderSearchProvider(() => workOrders),
58
+ ),
59
+ ],
49
60
  runtime,
50
61
  );
51
62
  const context = searchTestContext([