create-fullstack-scaffold 0.5.7 → 0.6.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.
Files changed (46) hide show
  1. package/dist/cli/index.js +65 -2
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +12 -12
  4. package/template/package.json +10 -10
  5. package/template/src/cli/modules/tenant/index.ts +89 -0
  6. package/template/src/server/db/init.ts +0 -52
  7. package/template/src/server/db/schema/index.ts +3 -0
  8. package/template/src/server/db/schema/tenant-invitations.ts +33 -0
  9. package/template/src/server/db/schema/tenant-members.ts +38 -0
  10. package/template/src/server/db/schema/tenant-roles.ts +32 -0
  11. package/template/src/server/db/schema/todos.ts +4 -0
  12. package/template/src/server/db/test-setup.ts +128 -0
  13. package/template/src/server/entries/node.ts +13 -0
  14. package/template/src/server/index.ts +12 -0
  15. package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
  16. package/template/src/server/middleware/auth.ts +17 -19
  17. package/template/src/server/middleware/tenant-isolation.ts +4 -2
  18. package/template/src/server/module-auth/module.ts +2 -1
  19. package/template/src/server/module-auth/services/auth-service.ts +39 -0
  20. package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
  21. package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
  22. package/template/src/server/module-tenant/module.ts +1 -1
  23. package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
  24. package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
  25. package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
  26. package/template/src/server/module-todos/services/todo-service.ts +12 -2
  27. package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
  28. package/template/src/server/utils/id-helpers.ts +13 -0
  29. package/template/src/shared/modules/index.ts +29 -6
  30. package/template/src/shared/modules/tenant/index.ts +30 -0
  31. package/template/src/shared/modules/tenant/permissions.ts +54 -0
  32. package/template/src/shared/modules/tenant/role-templates.ts +66 -0
  33. package/template/src/shared/modules/tenant/schemas.ts +113 -1
  34. package/template/src/shared/modules/todos/schemas.ts +1 -0
  35. package/template/src/shared/schemas/index.ts +28 -0
  36. package/template/src/tenant/App.tsx +30 -23
  37. package/template/src/tenant/components/TenantGuard.tsx +12 -2
  38. package/template/src/tenant/layouts/Header.tsx +6 -2
  39. package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
  40. package/template/src/tenant/pages/LoginPage.tsx +82 -0
  41. package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
  42. package/template/src/tenant/pages/UsersPage.tsx +134 -88
  43. package/template/src/tenant/services/tenantApi.ts +61 -0
  44. package/template/src/tenant/stores/tenantStore.ts +172 -49
  45. package/template/src/test/setup-db-path.ts +20 -0
  46. package/template/vitest.config.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-fullstack-scaffold",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-fullstack-scaffold": "dist/cli/index.js"
@@ -108,7 +108,7 @@
108
108
  "dependencies": {
109
109
  "@hono/node-server": "^1.19.15",
110
110
  "@hono/zod-openapi": "1.2.2",
111
- "@hono/zod-validator": "0.7.6",
111
+ "@hono/zod-validator": "0.8.0",
112
112
  "@inquirer/prompts": "^8.4.2",
113
113
  "@libsql/client": "^0.17.0",
114
114
  "chalk": "^5.6.2",
@@ -118,7 +118,7 @@
118
118
  "fs-extra": "^11.3.5",
119
119
  "hono": "4.12.34",
120
120
  "jsonwebtoken": "^9.0.3",
121
- "lucide-react": "^0.576.0",
121
+ "lucide-react": "^1.16.0",
122
122
  "ora": "^9.4.0",
123
123
  "pino": "^9.7.0",
124
124
  "pino-pretty": "^13.0.0",
@@ -131,12 +131,12 @@
131
131
  "zustand": "^5.0.2"
132
132
  },
133
133
  "devDependencies": {
134
- "@cloudflare/workers-types": "^4.20260503.1",
135
- "@commitlint/cli": "^20.4.3",
136
- "@commitlint/config-conventional": "^20.4.3",
134
+ "@cloudflare/workers-types": "^5.20260910.1",
135
+ "@commitlint/cli": "^21.0.0",
136
+ "@commitlint/config-conventional": "^21.0.0",
137
137
  "@eslint/js": "^9.39.4",
138
138
  "@hono/vite-dev-server": "^0.23.0",
139
- "@playwright/test": "^1.50.0",
139
+ "@playwright/test": "^1.60.0",
140
140
  "@tailwindcss/postcss": "^4.2.1",
141
141
  "@testing-library/dom": "^10.4.1",
142
142
  "@testing-library/jest-dom": "^6.8.0",
@@ -161,20 +161,20 @@
161
161
  "globals": "^16.0.0",
162
162
  "husky": "^9.1.7",
163
163
  "jsdom": "^26.0.3",
164
- "lint-staged": "^15.5.0",
164
+ "lint-staged": "^17.0.5",
165
165
  "minimatch": "^10.2.4",
166
166
  "patch-package": "^8.0.1",
167
- "playwright": "^1.50.0",
167
+ "playwright": "^1.60.0",
168
168
  "postcss": "^8.5.6",
169
169
  "prettier": "^3.5.3",
170
170
  "tailwindcss": "^4.2.1",
171
171
  "ts-import": "^5.0.0-beta.1",
172
172
  "tsup": "^8.5.0",
173
173
  "typescript": "^5.8.3",
174
- "typescript-eslint": "^8.25.0",
174
+ "typescript-eslint": "^8.59.4",
175
175
  "vite": "^6.2.3",
176
176
  "vitest": "^4.0.16",
177
- "wrangler": "^4.75.0"
177
+ "wrangler": "^4.131.0"
178
178
  },
179
179
  "lint-staged": {
180
180
  "*.{ts,tsx,js,jsx}": [
@@ -185,7 +185,7 @@
185
185
  "*.{json,md,css}": "prettier --write"
186
186
  },
187
187
  "engines": {
188
- "node": ">=18.0.0"
188
+ "node": ">=22.0.0"
189
189
  },
190
190
  "overrides": {
191
191
  "esbuild": "^0.25.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "biomimic-todo-app",
3
3
  "private": true,
4
- "version": "0.5.7",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "biomimic": "./dist/cli/index.js"
@@ -75,7 +75,7 @@
75
75
  "@dyyz1993/xcli-core": "^0.7.4",
76
76
  "@hono/node-server": "^1.19.15",
77
77
  "@hono/zod-openapi": "1.2.2",
78
- "@hono/zod-validator": "0.7.6",
78
+ "@hono/zod-validator": "0.8.0",
79
79
  "@ant-design/icons": "^5.6.1",
80
80
  "@libsql/client": "^0.17.0",
81
81
  "antd": "^5.24.0",
@@ -85,7 +85,7 @@
85
85
  "drizzle-orm": "^0.45.1",
86
86
  "hono": "4.12.34",
87
87
  "jsonwebtoken": "^9.0.3",
88
- "lucide-react": "^0.576.0",
88
+ "lucide-react": "^1.16.0",
89
89
  "pino": "^9.7.0",
90
90
  "pino-pretty": "^13.0.0",
91
91
  "react": "^18.3.1",
@@ -99,9 +99,9 @@
99
99
  "zustand": "^5.0.2"
100
100
  },
101
101
  "devDependencies": {
102
- "@cloudflare/workers-types": "^4.20260503.1",
103
- "@commitlint/cli": "^20.4.3",
104
- "@commitlint/config-conventional": "^20.4.3",
102
+ "@cloudflare/workers-types": "^5.20260910.1",
103
+ "@commitlint/cli": "^21.0.0",
104
+ "@commitlint/config-conventional": "^21.0.0",
105
105
  "@eslint/js": "^9.39.4",
106
106
  "@hono/vite-dev-server": "^0.23.0",
107
107
  "@playwright/test": "^1.60.0",
@@ -129,7 +129,7 @@
129
129
  "globals": "^16.0.0",
130
130
  "husky": "^9.1.7",
131
131
  "jsdom": "^26.0.3",
132
- "lint-staged": "^15.5.0",
132
+ "lint-staged": "^17.0.5",
133
133
  "minimatch": "^10.2.4",
134
134
  "patch-package": "^8.0.0",
135
135
  "playwright": "^1.60.0",
@@ -139,10 +139,10 @@
139
139
  "tsup": "^8.5.0",
140
140
  "tsx": "^4.21.0",
141
141
  "typescript": "^5.8.3",
142
- "typescript-eslint": "^8.25.0",
142
+ "typescript-eslint": "^8.59.4",
143
143
  "vite": "^6.2.3",
144
144
  "vitest": "^4.0.16",
145
- "wrangler": "^4.75.0"
145
+ "wrangler": "^4.131.0"
146
146
  },
147
147
  "lint-staged": {
148
148
  "*.{ts,tsx,js,jsx}": [
@@ -155,7 +155,7 @@
155
155
  ]
156
156
  },
157
157
  "engines": {
158
- "node": ">=18.0.0"
158
+ "node": ">=22.0.0"
159
159
  },
160
160
  "overrides": {
161
161
  "esbuild": "^0.25.0"
@@ -139,4 +139,93 @@ export function registerTenantCommands(site: SiteInstance) {
139
139
  }
140
140
  },
141
141
  })
142
+
143
+ site.command('members', {
144
+ description: 'List members of a tenant',
145
+ parameters: z.object({
146
+ id: z.string().describe('Tenant ID'),
147
+ }),
148
+ handler: async (params: unknown) => {
149
+ const p = params as { id: string }
150
+ try {
151
+ const client = getClient()
152
+ const res = await client.api.tenants[':tenantId'].members.$get({
153
+ param: { tenantId: p.id },
154
+ })
155
+ const data = await res.json()
156
+ return ok(data)
157
+ } catch (err) {
158
+ return fail(err instanceof Error ? err.message : 'Failed to list members')
159
+ }
160
+ },
161
+ })
162
+
163
+ site.command('roles', {
164
+ description: 'List roles of a tenant',
165
+ parameters: z.object({
166
+ id: z.string().describe('Tenant ID'),
167
+ }),
168
+ handler: async (params: unknown) => {
169
+ const p = params as { id: string }
170
+ try {
171
+ const client = getClient()
172
+ const res = await client.api.tenants[':tenantId'].roles.$get({
173
+ param: { tenantId: p.id },
174
+ })
175
+ const data = await res.json()
176
+ return ok(data)
177
+ } catch (err) {
178
+ return fail(err instanceof Error ? err.message : 'Failed to list roles')
179
+ }
180
+ },
181
+ })
182
+
183
+ site.command('invite', {
184
+ description: 'Invite a member to a tenant (returns a 7-day invitation link)',
185
+ parameters: z.object({
186
+ id: z.string().describe('Tenant ID'),
187
+ email: z.string().describe('Invitee email'),
188
+ 'role-id': z.string().describe('Role ID (see tenant roles command)'),
189
+ }),
190
+ handler: async (params: unknown) => {
191
+ const p = params as { id: string; email: string; 'role-id': string }
192
+ try {
193
+ const client = getClient()
194
+ const res = await client.api.tenants[':tenantId'].members.invite.$post({
195
+ param: { tenantId: p.id },
196
+ json: { email: p.email, roleId: p['role-id'] },
197
+ })
198
+ const data = await res.json()
199
+ if (data.success && data.data?.token) {
200
+ return ok({
201
+ ...data,
202
+ data: { ...data.data, inviteLink: `/tenant/invite/${data.data.token}` },
203
+ })
204
+ }
205
+ return ok(data)
206
+ } catch (err) {
207
+ return fail(err instanceof Error ? err.message : 'Failed to invite member')
208
+ }
209
+ },
210
+ })
211
+
212
+ site.command('invitation', {
213
+ description: 'Show invitation detail by token (public)',
214
+ parameters: z.object({
215
+ token: z.string().describe('Invitation token'),
216
+ }),
217
+ handler: async (params: unknown) => {
218
+ const p = params as { token: string }
219
+ try {
220
+ const client = getClient()
221
+ const res = await client.api.tenants.invitations[':token'].$get({
222
+ param: { token: p.token },
223
+ })
224
+ const data = await res.json()
225
+ return ok(data)
226
+ } catch (err) {
227
+ return fail(err instanceof Error ? err.message : 'Failed to get invitation')
228
+ }
229
+ },
230
+ })
142
231
  }
@@ -1,9 +1,6 @@
1
1
  import { getDb } from './driver'
2
2
  import { permissions, roles, rolePermissions } from './schema'
3
- import { developers } from './schema/developers'
4
3
  import { logger } from '../utils/logger'
5
- import { generateUUID } from '../utils/uuid'
6
- import { hash } from 'bcryptjs'
7
4
 
8
5
  const log = logger.db()
9
6
 
@@ -354,53 +351,6 @@ const initialRolePermissions = [
354
351
  { roleId: 'role_user', permissionId: 'perm_order_view' },
355
352
  ]
356
353
 
357
- async function seedDevelopersIfEmpty() {
358
- try {
359
- const db = await getDb()
360
-
361
- const existing = await db.select().from(developers)
362
- if (existing.length > 0) return
363
-
364
- log.info({}, 'Seeding developers...')
365
-
366
- const demoPasswordHash = await hash('demo123', 10)
367
- const adminPasswordHash = await hash('admin123', 10)
368
-
369
- const sampleDevelopers = [
370
- {
371
- id: generateUUID(),
372
- username: 'demo',
373
- email: 'demo@biomimic.app',
374
- passwordHash: demoPasswordHash,
375
- role: 'developer' as const,
376
- apiKey: generateUUID(),
377
- },
378
- {
379
- id: generateUUID(),
380
- username: 'superadmin',
381
- email: 'admin@biomimic.app',
382
- passwordHash: adminPasswordHash,
383
- role: 'admin' as const,
384
- apiKey: generateUUID(),
385
- },
386
- ]
387
-
388
- await db.insert(developers).values(sampleDevelopers)
389
-
390
- log.info({}, 'Developers seeding complete!')
391
- } catch (error) {
392
- // 如果表不存在,忽略错误(可能在某些模板中不需要 developers 表)
393
- if (
394
- error instanceof Error &&
395
- (error.message.includes('no such table') || error.message.includes('does not exist'))
396
- ) {
397
- log.debug({}, 'Developers table not found, skipping seed')
398
- return
399
- }
400
- throw error
401
- }
402
- }
403
-
404
354
  export async function initializeDatabase() {
405
355
  const db = await getDb()
406
356
 
@@ -445,8 +395,6 @@ export async function initializeDatabase() {
445
395
  )
446
396
  }
447
397
 
448
- await seedDevelopersIfEmpty()
449
-
450
398
  log.info({}, 'Database initialization complete!')
451
399
 
452
400
  log.info({}, 'Seeding module data...')
@@ -15,5 +15,8 @@ export * from './contents'
15
15
  export * from './developers'
16
16
  export * from './plugins'
17
17
  export * from './tenants'
18
+ export * from './tenant-roles'
19
+ export * from './tenant-members'
20
+ export * from './tenant-invitations'
18
21
  export * from './merchants'
19
22
  export * from './products'
@@ -0,0 +1,33 @@
1
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
2
+ import { tenants } from './tenants'
3
+ import { tenantRoles } from './tenant-roles'
4
+
5
+ /**
6
+ * 租户邀请(email + 一次性 token,默认 7 天过期)。
7
+ * token 随邀请链接分发:/invite/:token 前端页凭 token 查详情并接受。
8
+ */
9
+ export const tenantInvitations = sqliteTable('tenant_invitations', {
10
+ id: text('id').primaryKey(),
11
+ tenantId: integer('tenant_id')
12
+ .notNull()
13
+ .references(() => tenants.id, { onDelete: 'cascade' }),
14
+ email: text('email').notNull(),
15
+ roleId: text('role_id')
16
+ .notNull()
17
+ .references(() => tenantRoles.id, { onDelete: 'cascade' }),
18
+ inviterId: text('inviter_id').notNull(),
19
+ token: text('token').notNull().unique(),
20
+ status: text('status', {
21
+ enum: ['pending', 'accepted', 'declined', 'expired', 'cancelled'],
22
+ })
23
+ .notNull()
24
+ .default('pending'),
25
+ expiresAt: text('expires_at').notNull(),
26
+ acceptedAt: text('accepted_at'),
27
+ createdAt: text('created_at').notNull(),
28
+ })
29
+
30
+ export type TenantInvitationTable = typeof tenantInvitations.$inferSelect
31
+ export type NewTenantInvitation = typeof tenantInvitations.$inferInsert
32
+
33
+ export type InvitationStatus = 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled'
@@ -0,0 +1,38 @@
1
+ import { sqliteTable, text, integer, unique } from 'drizzle-orm/sqlite-core'
2
+ import { tenants } from './tenants'
3
+ import { tenantRoles } from './tenant-roles'
4
+
5
+ /**
6
+ * 租户成员(用户与租户的归属关系)。userId 指向认证体系(developers.id /
7
+ * dev token 用户 id),删除为软删(status='left')保留审计轨迹。
8
+ */
9
+ export const tenantMembers = sqliteTable(
10
+ 'tenant_members',
11
+ {
12
+ id: text('id').primaryKey(),
13
+ tenantId: integer('tenant_id')
14
+ .notNull()
15
+ .references(() => tenants.id, { onDelete: 'cascade' }),
16
+ userId: text('user_id').notNull(),
17
+ roleId: text('role_id')
18
+ .notNull()
19
+ .references(() => tenantRoles.id, { onDelete: 'cascade' }),
20
+ status: text('status', {
21
+ enum: ['active', 'pending', 'suspended', 'left'],
22
+ })
23
+ .notNull()
24
+ .default('active'),
25
+ invitedBy: text('invited_by'),
26
+ invitedAt: text('invited_at'),
27
+ joinedAt: text('joined_at').notNull(),
28
+ lastActiveAt: text('last_active_at'),
29
+ },
30
+ table => ({
31
+ userTenantUnique: unique().on(table.userId, table.tenantId),
32
+ })
33
+ )
34
+
35
+ export type TenantMemberTable = typeof tenantMembers.$inferSelect
36
+ export type NewTenantMember = typeof tenantMembers.$inferInsert
37
+
38
+ export type TenantMemberStatus = 'active' | 'pending' | 'suspended' | 'left'
@@ -0,0 +1,32 @@
1
+ import { sqliteTable, text, integer, unique } from 'drizzle-orm/sqlite-core'
2
+ import { tenants } from './tenants'
3
+
4
+ /**
5
+ * 租户内自定义角色(每租户独立,与平台级 roles 表区分)。
6
+ * permissions 存 TenantPermission 枚举数组的 JSON 序列化。
7
+ */
8
+ export const tenantRoles = sqliteTable(
9
+ 'tenant_roles',
10
+ {
11
+ id: text('id').primaryKey(),
12
+ tenantId: integer('tenant_id')
13
+ .notNull()
14
+ .references(() => tenants.id, { onDelete: 'cascade' }),
15
+ code: text('code').notNull(),
16
+ name: text('name').notNull(),
17
+ label: text('label').notNull(),
18
+ description: text('description'),
19
+ permissions: text('permissions').notNull(),
20
+ isSystem: integer('is_system', { mode: 'boolean' }).notNull().default(false),
21
+ isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
22
+ sortOrder: integer('sort_order').notNull().default(0),
23
+ createdAt: text('created_at').notNull(),
24
+ updatedAt: text('updated_at').notNull(),
25
+ },
26
+ table => ({
27
+ tenantCodeUnique: unique().on(table.tenantId, table.code),
28
+ })
29
+ )
30
+
31
+ export type TenantRoleTable = typeof tenantRoles.$inferSelect
32
+ export type NewTenantRole = typeof tenantRoles.$inferInsert
@@ -8,6 +8,9 @@ export const todos = sqliteTable(
8
8
  'todos',
9
9
  {
10
10
  id: integer('id').primaryKey({ autoIncrement: true }),
11
+ // 租户归属(saas 等含 tenant 模块的 preset 由隔离中间件填充;其他
12
+ // preset 恒为 null,列对它们无害)
13
+ tenantId: integer('tenant_id'),
11
14
  title: text('title').notNull(),
12
15
  description: text('description'),
13
16
  status: text('status', { enum: todoStatus }).notNull().default('pending'),
@@ -20,6 +23,7 @@ export const todos = sqliteTable(
20
23
  },
21
24
  table => ({
22
25
  statusIdx: index('todos_status_idx').on(table.status),
26
+ tenantIdx: index('todos_tenant_idx').on(table.tenantId),
23
27
  createdAtIdx: index('todos_created_at_idx').on(table.createdAt),
24
28
  updatedAtIdx: index('todos_updated_at_idx').on(table.updatedAt),
25
29
  })
@@ -11,6 +11,7 @@ export async function setupTestDatabase(): Promise<void> {
11
11
  const migrationSQL = `
12
12
  CREATE TABLE IF NOT EXISTS todos (
13
13
  id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
14
+ tenant_id INTEGER,
14
15
  title TEXT NOT NULL,
15
16
  description TEXT,
16
17
  status TEXT DEFAULT 'pending' NOT NULL,
@@ -298,6 +299,43 @@ export async function setupTestDatabase(): Promise<void> {
298
299
  updated_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL
299
300
  );
300
301
 
302
+ CREATE TABLE IF NOT EXISTS tenant_roles (
303
+ id TEXT PRIMARY KEY NOT NULL,
304
+ tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
305
+ code TEXT NOT NULL,
306
+ name TEXT NOT NULL,
307
+ label TEXT NOT NULL,
308
+ description TEXT,
309
+ permissions TEXT NOT NULL,
310
+ is_system INTEGER NOT NULL DEFAULT 0,
311
+ is_active INTEGER NOT NULL DEFAULT 1,
312
+ sort_order INTEGER NOT NULL DEFAULT 0,
313
+ created_at TEXT NOT NULL,
314
+ updated_at TEXT NOT NULL
315
+ );
316
+ CREATE TABLE IF NOT EXISTS tenant_members (
317
+ id TEXT PRIMARY KEY NOT NULL,
318
+ tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
319
+ user_id TEXT NOT NULL,
320
+ role_id TEXT NOT NULL REFERENCES tenant_roles(id) ON DELETE CASCADE,
321
+ status TEXT NOT NULL DEFAULT 'active',
322
+ invited_by TEXT,
323
+ invited_at TEXT,
324
+ joined_at TEXT NOT NULL,
325
+ last_active_at TEXT
326
+ );
327
+ CREATE TABLE IF NOT EXISTS tenant_invitations (
328
+ id TEXT PRIMARY KEY NOT NULL,
329
+ tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
330
+ email TEXT NOT NULL,
331
+ role_id TEXT NOT NULL REFERENCES tenant_roles(id) ON DELETE CASCADE,
332
+ inviter_id TEXT NOT NULL,
333
+ token TEXT NOT NULL UNIQUE,
334
+ status TEXT NOT NULL DEFAULT 'pending',
335
+ expires_at TEXT NOT NULL,
336
+ accepted_at TEXT,
337
+ created_at TEXT NOT NULL
338
+ );
301
339
  CREATE TABLE IF NOT EXISTS merchants (
302
340
  id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
303
341
  user_id TEXT NOT NULL,
@@ -799,6 +837,93 @@ async function seedTestData(client: Client): Promise<void> {
799
837
  }
800
838
  }
801
839
 
840
+ // Seed tenant roles + owner member(与 app 启动种子 seedTenantsIfEmpty 对齐,demo 租户 id=1)
841
+ const tenantNowIso = new Date().toISOString()
842
+ const tenantRoleSeed = [
843
+ {
844
+ id: 'tr_seed_admin',
845
+ code: 'tenant_admin',
846
+ label: '租户管理员',
847
+ perms: JSON.stringify([
848
+ 'tenant:member:view',
849
+ 'tenant:member:invite',
850
+ 'tenant:member:remove',
851
+ 'tenant:member:role:assign',
852
+ 'tenant:role:view',
853
+ 'tenant:role:create',
854
+ 'tenant:role:edit',
855
+ 'tenant:role:delete',
856
+ 'tenant:settings:view',
857
+ 'tenant:settings:edit',
858
+ 'tenant:data:view',
859
+ 'tenant:data:create',
860
+ 'tenant:data:edit',
861
+ 'tenant:data:delete',
862
+ 'tenant:data:export',
863
+ 'tenant:data:import',
864
+ 'tenant:billing:view',
865
+ 'tenant:billing:manage',
866
+ 'tenant:audit:view',
867
+ ]),
868
+ sortOrder: 0,
869
+ },
870
+ {
871
+ id: 'tr_seed_member',
872
+ code: 'tenant_member',
873
+ label: '普通成员',
874
+ perms: JSON.stringify([
875
+ 'tenant:member:view',
876
+ 'tenant:role:view',
877
+ 'tenant:settings:view',
878
+ 'tenant:data:view',
879
+ 'tenant:data:create',
880
+ 'tenant:data:edit',
881
+ 'tenant:data:delete',
882
+ 'tenant:data:export',
883
+ ]),
884
+ sortOrder: 1,
885
+ },
886
+ {
887
+ id: 'tr_seed_guest',
888
+ code: 'tenant_guest',
889
+ label: '访客',
890
+ perms: JSON.stringify([
891
+ 'tenant:member:view',
892
+ 'tenant:role:view',
893
+ 'tenant:settings:view',
894
+ 'tenant:data:view',
895
+ ]),
896
+ sortOrder: 2,
897
+ },
898
+ ]
899
+ for (const role of tenantRoleSeed) {
900
+ try {
901
+ await client.execute({
902
+ sql: `INSERT OR IGNORE INTO tenant_roles (id, tenant_id, code, name, label, description, permissions, is_system, is_active, sort_order, created_at, updated_at) VALUES (?, 1, ?, ?, ?, NULL, ?, 1, 1, ?, ?, ?)`,
903
+ args: [
904
+ role.id,
905
+ role.code,
906
+ role.code,
907
+ role.label,
908
+ role.perms,
909
+ role.sortOrder,
910
+ tenantNowIso,
911
+ tenantNowIso,
912
+ ],
913
+ })
914
+ } catch {
915
+ // Ignore duplicate errors
916
+ }
917
+ }
918
+ try {
919
+ await client.execute({
920
+ sql: `INSERT OR IGNORE INTO tenant_members (id, tenant_id, user_id, role_id, status, invited_by, invited_at, joined_at, last_active_at) VALUES ('tm_seed_owner', 1, 'test-super-admin-1', 'tr_seed_admin', 'active', NULL, NULL, ?, ?)`,
921
+ args: [tenantNowIso, tenantNowIso],
922
+ })
923
+ } catch {
924
+ // Ignore duplicate errors
925
+ }
926
+
802
927
  // Seed products for testing
803
928
  const products = [
804
929
  {
@@ -968,6 +1093,9 @@ export async function cleanupTestDatabase(): Promise<void> {
968
1093
  } catch {
969
1094
  // Plugin tables may not exist in all test environments
970
1095
  }
1096
+ await client.execute('DELETE FROM tenant_invitations')
1097
+ await client.execute('DELETE FROM tenant_members')
1098
+ await client.execute('DELETE FROM tenant_roles')
971
1099
  await client.execute('DELETE FROM tenants')
972
1100
  await client.execute('DELETE FROM merchants')
973
1101
  await client.execute('DELETE FROM products')
@@ -38,6 +38,9 @@ const indexHtmlPath = hasDist
38
38
  const adminHtmlPath = hasDist
39
39
  ? resolve(distPath, 'admin.html')
40
40
  : resolve(process.cwd(), 'admin.html')
41
+ const tenantHtmlPath = hasDist
42
+ ? resolve(distPath, 'tenant.html')
43
+ : resolve(process.cwd(), 'tenant.html')
41
44
 
42
45
  const indexHtml = existsSync(indexHtmlPath)
43
46
  ? readFileSync(indexHtmlPath, 'utf-8')
@@ -45,6 +48,8 @@ const indexHtml = existsSync(indexHtmlPath)
45
48
  const adminHtml = existsSync(adminHtmlPath)
46
49
  ? readFileSync(adminHtmlPath, 'utf-8')
47
50
  : '<html><body>admin.html not found</body></html>'
51
+ // saas 等含 tenant 模块的 preset 才有 tenant.html;缺失回落 index
52
+ const tenantHtml = existsSync(tenantHtmlPath) ? readFileSync(tenantHtmlPath, 'utf-8') : indexHtml
48
53
 
49
54
  const log = logger.api()
50
55
 
@@ -118,6 +123,14 @@ app.get('/admin/*', c => {
118
123
  return c.html(adminHtml)
119
124
  })
120
125
 
126
+ // 租户控制台独立 SPA(basename=/tenant)
127
+ app.get('/tenant', c => {
128
+ return c.html(tenantHtml)
129
+ })
130
+ app.get('/tenant/*', c => {
131
+ return c.html(tenantHtml)
132
+ })
133
+
121
134
  // 其他非 API 路由返回 index.html(ISR 路由尝试缓存)
122
135
  app.get('*', async c => {
123
136
  if (c.req.path.startsWith('/api/') || c.req.path.startsWith('/files/')) {
@@ -24,6 +24,7 @@ setRuntimeAdapter(runtimeAdapter)
24
24
  // HTML 文件路径(开发环境直接读取根目录)
25
25
  const indexHtmlPath = resolve(process.cwd(), 'index.html')
26
26
  const adminHtmlPath = resolve(process.cwd(), 'admin.html')
27
+ const tenantHtmlPath = resolve(process.cwd(), 'tenant.html')
27
28
 
28
29
  const indexHtml = existsSync(indexHtmlPath)
29
30
  ? readFileSync(indexHtmlPath, 'utf-8')
@@ -31,6 +32,8 @@ const indexHtml = existsSync(indexHtmlPath)
31
32
  const adminHtml = existsSync(adminHtmlPath)
32
33
  ? readFileSync(adminHtmlPath, 'utf-8')
33
34
  : '<html><body>admin.html not found</body></html>'
35
+ // saas 等含 tenant 模块的 preset 才有 tenant.html;缺失时回落 index
36
+ const tenantHtml = existsSync(tenantHtmlPath) ? readFileSync(tenantHtmlPath, 'utf-8') : indexHtml
34
37
 
35
38
  // 创建 Hono 应用
36
39
  const app = createApp()
@@ -40,6 +43,15 @@ app.get('/admin/*', c => {
40
43
  return c.html(adminHtml)
41
44
  })
42
45
 
46
+ // 租户控制台(basename=/tenant 的独立 SPA)——无此入口时 /tenant/* 会
47
+ // 落进客户端 SPA fallback 渲染成 404
48
+ app.get('/tenant/*', c => {
49
+ return c.html(tenantHtml)
50
+ })
51
+ app.get('/tenant', c => {
52
+ return c.html(tenantHtml)
53
+ })
54
+
43
55
  // SPA fallback - 使用中间件方式,确保 API 路由已经注册后才添加
44
56
  app.use('*', async (c, next) => {
45
57
  await next()