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.
- package/dist/cli/index.js +65 -2
- package/dist/cli/index.js.map +1 -1
- package/package.json +12 -12
- package/template/package.json +10 -10
- package/template/src/cli/modules/tenant/index.ts +89 -0
- package/template/src/server/db/init.ts +0 -52
- package/template/src/server/db/schema/index.ts +3 -0
- package/template/src/server/db/schema/tenant-invitations.ts +33 -0
- package/template/src/server/db/schema/tenant-members.ts +38 -0
- package/template/src/server/db/schema/tenant-roles.ts +32 -0
- package/template/src/server/db/schema/todos.ts +4 -0
- package/template/src/server/db/test-setup.ts +128 -0
- package/template/src/server/entries/node.ts +13 -0
- package/template/src/server/index.ts +12 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
- package/template/src/server/middleware/auth.ts +17 -19
- package/template/src/server/middleware/tenant-isolation.ts +4 -2
- package/template/src/server/module-auth/module.ts +2 -1
- package/template/src/server/module-auth/services/auth-service.ts +39 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
- package/template/src/server/module-tenant/module.ts +1 -1
- package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
- package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
- package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
- package/template/src/server/module-todos/services/todo-service.ts +12 -2
- package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
- package/template/src/server/utils/id-helpers.ts +13 -0
- package/template/src/shared/modules/index.ts +29 -6
- package/template/src/shared/modules/tenant/index.ts +30 -0
- package/template/src/shared/modules/tenant/permissions.ts +54 -0
- package/template/src/shared/modules/tenant/role-templates.ts +66 -0
- package/template/src/shared/modules/tenant/schemas.ts +113 -1
- package/template/src/shared/modules/todos/schemas.ts +1 -0
- package/template/src/shared/schemas/index.ts +28 -0
- package/template/src/tenant/App.tsx +30 -23
- package/template/src/tenant/components/TenantGuard.tsx +12 -2
- package/template/src/tenant/layouts/Header.tsx +6 -2
- package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
- package/template/src/tenant/pages/LoginPage.tsx +82 -0
- package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
- package/template/src/tenant/pages/UsersPage.tsx +134 -88
- package/template/src/tenant/services/tenantApi.ts +61 -0
- package/template/src/tenant/stores/tenantStore.ts +172 -49
- package/template/src/test/setup-db-path.ts +20 -0
- package/template/vitest.config.ts +3 -1
|
@@ -1,9 +1,33 @@
|
|
|
1
|
-
import { eq, desc, like } from 'drizzle-orm'
|
|
2
|
-
import type {
|
|
1
|
+
import { eq, desc, like, and, inArray } from 'drizzle-orm'
|
|
2
|
+
import type {
|
|
3
|
+
Tenant,
|
|
4
|
+
CreateTenantInput,
|
|
5
|
+
UpdateTenantInput,
|
|
6
|
+
TenantRole,
|
|
7
|
+
TenantMember,
|
|
8
|
+
TenantInvitation,
|
|
9
|
+
} from '@shared/schemas'
|
|
10
|
+
import {
|
|
11
|
+
TenantPermission,
|
|
12
|
+
TenantRoleCode,
|
|
13
|
+
TENANT_ROLE_TEMPLATES,
|
|
14
|
+
PLAN_ROLE_LIMITS,
|
|
15
|
+
} from '@shared/schemas'
|
|
3
16
|
import { getDb } from '@server/db'
|
|
4
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
tenants,
|
|
19
|
+
tenantRoles,
|
|
20
|
+
tenantMembers,
|
|
21
|
+
tenantInvitations,
|
|
22
|
+
developers,
|
|
23
|
+
type TenantTable,
|
|
24
|
+
type TenantRoleTable,
|
|
25
|
+
type TenantMemberTable,
|
|
26
|
+
type NewTenant,
|
|
27
|
+
} from '@server/db/schema'
|
|
28
|
+
import { generateId, generateToken } from '@server/utils/id-helpers'
|
|
5
29
|
import { createModuleLoggerSync } from '../../utils/logger'
|
|
6
|
-
import { ValidationError } from '../../utils/app-error'
|
|
30
|
+
import { ValidationError, AuthorizationError, NotFoundError } from '../../utils/app-error'
|
|
7
31
|
|
|
8
32
|
const log = createModuleLoggerSync('tenant-service')
|
|
9
33
|
|
|
@@ -40,6 +64,44 @@ export async function seedTenantsIfEmpty(): Promise<void> {
|
|
|
40
64
|
|
|
41
65
|
await db.insert(tenants).values(sampleTenants)
|
|
42
66
|
|
|
67
|
+
// demo 租户配套播种角色模板 + owner 成员(dev 超管 token 直接可演示)
|
|
68
|
+
const demoRow = await db.select().from(tenants).where(eq(tenants.slug, 'demo'))
|
|
69
|
+
if (demoRow.length > 0) {
|
|
70
|
+
const demoId = demoRow[0].id
|
|
71
|
+
const roleValues = TENANT_ROLE_TEMPLATES.map(template => ({
|
|
72
|
+
id: generateId('tr'),
|
|
73
|
+
tenantId: demoId,
|
|
74
|
+
code: template.code,
|
|
75
|
+
name: template.name,
|
|
76
|
+
label: template.label,
|
|
77
|
+
description: template.description,
|
|
78
|
+
permissions: JSON.stringify(template.permissions),
|
|
79
|
+
isSystem: true,
|
|
80
|
+
isActive: true,
|
|
81
|
+
sortOrder:
|
|
82
|
+
template.code === TenantRoleCode.ADMIN
|
|
83
|
+
? 0
|
|
84
|
+
: template.code === TenantRoleCode.MEMBER
|
|
85
|
+
? 1
|
|
86
|
+
: 2,
|
|
87
|
+
createdAt: now,
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
}))
|
|
90
|
+
await db.insert(tenantRoles).values(roleValues)
|
|
91
|
+
const adminRole = roleValues.find(r => r.code === TenantRoleCode.ADMIN)!
|
|
92
|
+
await db.insert(tenantMembers).values({
|
|
93
|
+
id: generateId('tm'),
|
|
94
|
+
tenantId: demoId,
|
|
95
|
+
userId: 'test-super-admin-1',
|
|
96
|
+
roleId: adminRole.id,
|
|
97
|
+
status: 'active',
|
|
98
|
+
invitedBy: null,
|
|
99
|
+
invitedAt: null,
|
|
100
|
+
joinedAt: now,
|
|
101
|
+
lastActiveAt: now,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
43
105
|
log.info({}, 'Tenants seeding complete!')
|
|
44
106
|
}
|
|
45
107
|
}
|
|
@@ -150,7 +212,7 @@ export async function getTenantById(id: number): Promise<Tenant | null> {
|
|
|
150
212
|
}
|
|
151
213
|
}
|
|
152
214
|
|
|
153
|
-
export async function createTenant(input: CreateTenantInput): Promise<Tenant> {
|
|
215
|
+
export async function createTenant(input: CreateTenantInput, ownerId: string): Promise<Tenant> {
|
|
154
216
|
const db = await getDb()
|
|
155
217
|
|
|
156
218
|
const existing = await db.select().from(tenants).where(eq(tenants.slug, input.slug))
|
|
@@ -160,23 +222,65 @@ export async function createTenant(input: CreateTenantInput): Promise<Tenant> {
|
|
|
160
222
|
}
|
|
161
223
|
|
|
162
224
|
const now = new Date().toISOString()
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
225
|
+
|
|
226
|
+
// 开通事务:租户 + 3 个系统角色模板 + owner 以 tenant_admin 身份入组。
|
|
227
|
+
// 三步原子完成——"注册→建租户→可用",否则新租户是无角色的空壳
|
|
228
|
+
const row = await db.transaction(async tx => {
|
|
229
|
+
const inserted = await tx
|
|
230
|
+
.insert(tenants)
|
|
231
|
+
.values({
|
|
232
|
+
name: input.name,
|
|
233
|
+
slug: input.slug,
|
|
234
|
+
status: 'trial',
|
|
235
|
+
plan: input.plan,
|
|
236
|
+
maxUsers: input.maxUsers,
|
|
237
|
+
settings: input.settings ? JSON.stringify(input.settings) : null,
|
|
238
|
+
createdAt: now,
|
|
239
|
+
updatedAt: now,
|
|
240
|
+
})
|
|
241
|
+
.returning()
|
|
242
|
+
|
|
243
|
+
const tenantRow = inserted[0]
|
|
244
|
+
|
|
245
|
+
const roleValues = TENANT_ROLE_TEMPLATES.map(template => ({
|
|
246
|
+
id: generateId('tr'),
|
|
247
|
+
tenantId: tenantRow.id,
|
|
248
|
+
code: template.code,
|
|
249
|
+
name: template.name,
|
|
250
|
+
label: template.label,
|
|
251
|
+
description: template.description,
|
|
252
|
+
permissions: JSON.stringify(template.permissions),
|
|
253
|
+
isSystem: true,
|
|
254
|
+
isActive: true,
|
|
255
|
+
sortOrder:
|
|
256
|
+
template.code === TenantRoleCode.ADMIN
|
|
257
|
+
? 0
|
|
258
|
+
: template.code === TenantRoleCode.MEMBER
|
|
259
|
+
? 1
|
|
260
|
+
: 2,
|
|
172
261
|
createdAt: now,
|
|
173
262
|
updatedAt: now,
|
|
263
|
+
}))
|
|
264
|
+
|
|
265
|
+
await tx.insert(tenantRoles).values(roleValues)
|
|
266
|
+
|
|
267
|
+
const adminRole = roleValues.find(r => r.code === TenantRoleCode.ADMIN)!
|
|
268
|
+
await tx.insert(tenantMembers).values({
|
|
269
|
+
id: generateId('tm'),
|
|
270
|
+
tenantId: tenantRow.id,
|
|
271
|
+
userId: ownerId,
|
|
272
|
+
roleId: adminRole.id,
|
|
273
|
+
status: 'active',
|
|
274
|
+
invitedBy: null,
|
|
275
|
+
invitedAt: null,
|
|
276
|
+
joinedAt: now,
|
|
277
|
+
lastActiveAt: now,
|
|
174
278
|
})
|
|
175
|
-
.returning()
|
|
176
279
|
|
|
177
|
-
|
|
280
|
+
return tenantRow
|
|
281
|
+
})
|
|
178
282
|
|
|
179
|
-
log.info({ tenantId: row.id, slug: row.slug }, 'Tenant
|
|
283
|
+
log.info({ tenantId: row.id, slug: row.slug, ownerId }, 'Tenant provisioned')
|
|
180
284
|
|
|
181
285
|
return {
|
|
182
286
|
id: row.id,
|
|
@@ -255,3 +359,463 @@ export async function deleteTenant(id: number): Promise<boolean> {
|
|
|
255
359
|
|
|
256
360
|
return false
|
|
257
361
|
}
|
|
362
|
+
|
|
363
|
+
// ============ 租户角色 ============
|
|
364
|
+
|
|
365
|
+
function rowToRole(row: TenantRoleTable): TenantRole {
|
|
366
|
+
return {
|
|
367
|
+
id: row.id,
|
|
368
|
+
tenantId: row.tenantId,
|
|
369
|
+
code: row.code,
|
|
370
|
+
name: row.name,
|
|
371
|
+
label: row.label,
|
|
372
|
+
description: row.description,
|
|
373
|
+
permissions: JSON.parse(row.permissions) as string[],
|
|
374
|
+
isSystem: row.isSystem,
|
|
375
|
+
isActive: row.isActive,
|
|
376
|
+
sortOrder: row.sortOrder,
|
|
377
|
+
createdAt: row.createdAt,
|
|
378
|
+
updatedAt: row.updatedAt,
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function getTenantRoles(tenantId: number): Promise<TenantRole[]> {
|
|
383
|
+
const db = await getDb()
|
|
384
|
+
const rows = await db
|
|
385
|
+
.select()
|
|
386
|
+
.from(tenantRoles)
|
|
387
|
+
.where(and(eq(tenantRoles.tenantId, tenantId), eq(tenantRoles.isActive, true)))
|
|
388
|
+
return rows.map(rowToRole)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function createTenantRole(
|
|
392
|
+
tenantId: number,
|
|
393
|
+
input: {
|
|
394
|
+
code: string
|
|
395
|
+
name: string
|
|
396
|
+
label: string
|
|
397
|
+
description?: string | null
|
|
398
|
+
permissions: string[]
|
|
399
|
+
}
|
|
400
|
+
): Promise<TenantRole> {
|
|
401
|
+
const db = await getDb()
|
|
402
|
+
|
|
403
|
+
const tenant = await getTenantById(tenantId)
|
|
404
|
+
if (!tenant) throw NotFoundError.tenant(String(tenantId))
|
|
405
|
+
|
|
406
|
+
// 套餐角色数配额:超限真实拦截(enterprise 不限)
|
|
407
|
+
const maxRoles = PLAN_ROLE_LIMITS[tenant.plan] ?? 3
|
|
408
|
+
if (maxRoles >= 0) {
|
|
409
|
+
const existing = await getTenantRoles(tenantId)
|
|
410
|
+
if (existing.length >= maxRoles) {
|
|
411
|
+
throw new ValidationError(`已达到当前套餐的角色数量上限(${maxRoles} 个),请升级套餐`)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const dup = await db
|
|
416
|
+
.select()
|
|
417
|
+
.from(tenantRoles)
|
|
418
|
+
.where(and(eq(tenantRoles.tenantId, tenantId), eq(tenantRoles.code, input.code)))
|
|
419
|
+
if (dup.length > 0) {
|
|
420
|
+
throw new ValidationError(`角色 code '${input.code}' 在本租户已存在`)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const now = new Date().toISOString()
|
|
424
|
+
const [row] = await db
|
|
425
|
+
.insert(tenantRoles)
|
|
426
|
+
.values({
|
|
427
|
+
id: generateId('tr'),
|
|
428
|
+
tenantId,
|
|
429
|
+
code: input.code,
|
|
430
|
+
name: input.name,
|
|
431
|
+
label: input.label,
|
|
432
|
+
description: input.description ?? null,
|
|
433
|
+
permissions: JSON.stringify(input.permissions),
|
|
434
|
+
isSystem: false,
|
|
435
|
+
isActive: true,
|
|
436
|
+
sortOrder: 9,
|
|
437
|
+
createdAt: now,
|
|
438
|
+
updatedAt: now,
|
|
439
|
+
})
|
|
440
|
+
.returning()
|
|
441
|
+
|
|
442
|
+
return rowToRole(row)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function updateTenantRole(
|
|
446
|
+
roleId: string,
|
|
447
|
+
input: { label?: string | null; description?: string | null; permissions?: string[] }
|
|
448
|
+
): Promise<TenantRole | null> {
|
|
449
|
+
const db = await getDb()
|
|
450
|
+
const [row] = await db
|
|
451
|
+
.update(tenantRoles)
|
|
452
|
+
.set({
|
|
453
|
+
...(input.label !== undefined && input.label !== null ? { label: input.label } : {}),
|
|
454
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
455
|
+
...(input.permissions !== undefined && input.permissions !== null
|
|
456
|
+
? { permissions: JSON.stringify(input.permissions) }
|
|
457
|
+
: {}),
|
|
458
|
+
updatedAt: new Date().toISOString(),
|
|
459
|
+
})
|
|
460
|
+
.where(eq(tenantRoles.id, roleId))
|
|
461
|
+
.returning()
|
|
462
|
+
return row ? rowToRole(row) : null
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 系统角色(isSystem)不可删;自定义角色软删(isActive=false) */
|
|
466
|
+
export async function deleteTenantRole(roleId: string): Promise<boolean> {
|
|
467
|
+
const db = await getDb()
|
|
468
|
+
const [row] = await db.select().from(tenantRoles).where(eq(tenantRoles.id, roleId))
|
|
469
|
+
if (!row) return false
|
|
470
|
+
if (row.isSystem) {
|
|
471
|
+
throw new ValidationError('系统角色不可删除')
|
|
472
|
+
}
|
|
473
|
+
await db
|
|
474
|
+
.update(tenantRoles)
|
|
475
|
+
.set({ isActive: false, updatedAt: new Date().toISOString() })
|
|
476
|
+
.where(eq(tenantRoles.id, roleId))
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ============ 租户成员 ============
|
|
481
|
+
|
|
482
|
+
function rowToMember(row: TenantMemberTable, role: TenantRole | null): TenantMember {
|
|
483
|
+
return {
|
|
484
|
+
id: row.id,
|
|
485
|
+
tenantId: row.tenantId,
|
|
486
|
+
userId: row.userId,
|
|
487
|
+
roleId: row.roleId,
|
|
488
|
+
username: row.userId,
|
|
489
|
+
role,
|
|
490
|
+
status: row.status as 'active' | 'pending' | 'suspended' | 'left',
|
|
491
|
+
invitedBy: row.invitedBy,
|
|
492
|
+
invitedAt: row.invitedAt,
|
|
493
|
+
joinedAt: row.joinedAt,
|
|
494
|
+
lastActiveAt: row.lastActiveAt,
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function getTenantMembers(tenantId: number): Promise<TenantMember[]> {
|
|
499
|
+
const db = await getDb()
|
|
500
|
+
const memberRows = await db
|
|
501
|
+
.select()
|
|
502
|
+
.from(tenantMembers)
|
|
503
|
+
.where(eq(tenantMembers.tenantId, tenantId))
|
|
504
|
+
|
|
505
|
+
if (memberRows.length === 0) return []
|
|
506
|
+
|
|
507
|
+
const roleIds = [...new Set(memberRows.map(m => m.roleId))]
|
|
508
|
+
const roleRows = await db.select().from(tenantRoles).where(inArray(tenantRoles.id, roleIds))
|
|
509
|
+
const roleMap = new Map(roleRows.map(r => [r.id, rowToRole(r)]))
|
|
510
|
+
|
|
511
|
+
// 联认证体系取用户名(dev token 用户不在 developers 表,回退 userId)
|
|
512
|
+
const members = memberRows.map(m => rowToMember(m, roleMap.get(m.roleId) ?? null))
|
|
513
|
+
return enrichUsernames(members)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 批量补用户名:命中 developers 表用 username,否则保留 userId */
|
|
517
|
+
async function enrichUsernames(members: TenantMember[]): Promise<TenantMember[]> {
|
|
518
|
+
try {
|
|
519
|
+
const db = await getDb()
|
|
520
|
+
// 联合驱动下 partial-select 重载坍缩,用全量 select 后映射(同文件惯例)
|
|
521
|
+
const rows = await db.select().from(developers)
|
|
522
|
+
const nameMap = new Map(rows.map(r => [r.id as string, r.username as string]))
|
|
523
|
+
return members.map(m => ({ ...m, username: nameMap.get(m.userId) ?? m.userId }))
|
|
524
|
+
} catch {
|
|
525
|
+
// developers 表不存在(无 auth 模块 preset)——全部回退 userId
|
|
526
|
+
return members.map(m => ({ ...m, username: m.userId }))
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export async function getActiveMemberCount(tenantId: number): Promise<number> {
|
|
531
|
+
const db = await getDb()
|
|
532
|
+
const rows = await db
|
|
533
|
+
.select()
|
|
534
|
+
.from(tenantMembers)
|
|
535
|
+
.where(and(eq(tenantMembers.tenantId, tenantId), eq(tenantMembers.status, 'active')))
|
|
536
|
+
return rows.length
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function getMembership(
|
|
540
|
+
userId: string,
|
|
541
|
+
tenantId: number
|
|
542
|
+
): Promise<TenantMemberTable | null> {
|
|
543
|
+
const db = await getDb()
|
|
544
|
+
const rows = await db
|
|
545
|
+
.select()
|
|
546
|
+
.from(tenantMembers)
|
|
547
|
+
.where(and(eq(tenantMembers.userId, userId), eq(tenantMembers.tenantId, tenantId)))
|
|
548
|
+
return rows[0] ?? null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export async function updateMemberRole(
|
|
552
|
+
tenantId: number,
|
|
553
|
+
memberId: string,
|
|
554
|
+
roleId: string
|
|
555
|
+
): Promise<TenantMember | null> {
|
|
556
|
+
const db = await getDb()
|
|
557
|
+
|
|
558
|
+
const role = await db
|
|
559
|
+
.select()
|
|
560
|
+
.from(tenantRoles)
|
|
561
|
+
.where(and(eq(tenantRoles.id, roleId), eq(tenantRoles.tenantId, tenantId)))
|
|
562
|
+
if (role.length === 0) {
|
|
563
|
+
throw new ValidationError('目标角色不属于本租户')
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const [row] = await db
|
|
567
|
+
.update(tenantMembers)
|
|
568
|
+
.set({ roleId })
|
|
569
|
+
.where(and(eq(tenantMembers.id, memberId), eq(tenantMembers.tenantId, tenantId)))
|
|
570
|
+
.returning()
|
|
571
|
+
if (!row) return null
|
|
572
|
+
|
|
573
|
+
const members = await getTenantMembers(tenantId)
|
|
574
|
+
return members.find(m => m.id === memberId) ?? rowToMember(row, null)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** 软删(status='left'):保留邀请/审计轨迹,成员立即失去访问权 */
|
|
578
|
+
export async function removeMember(tenantId: number, memberId: string): Promise<boolean> {
|
|
579
|
+
const db = await getDb()
|
|
580
|
+
const [row] = await db
|
|
581
|
+
.update(tenantMembers)
|
|
582
|
+
.set({ status: 'left' })
|
|
583
|
+
.where(and(eq(tenantMembers.id, memberId), eq(tenantMembers.tenantId, tenantId)))
|
|
584
|
+
.returning()
|
|
585
|
+
return !!row
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export async function getUserTenants(userId: string): Promise<Tenant[]> {
|
|
589
|
+
const db = await getDb()
|
|
590
|
+
const memberRows = await db
|
|
591
|
+
.select()
|
|
592
|
+
.from(tenantMembers)
|
|
593
|
+
.where(and(eq(tenantMembers.userId, userId), eq(tenantMembers.status, 'active')))
|
|
594
|
+
|
|
595
|
+
if (memberRows.length === 0) return []
|
|
596
|
+
|
|
597
|
+
const tenantIds = memberRows.map(m => m.tenantId)
|
|
598
|
+
const tenantRows = await db.select().from(tenants).where(inArray(tenants.id, tenantIds))
|
|
599
|
+
|
|
600
|
+
return tenantRows.map(row => ({
|
|
601
|
+
id: row.id,
|
|
602
|
+
name: row.name,
|
|
603
|
+
slug: row.slug,
|
|
604
|
+
status: row.status as 'active' | 'suspended' | 'trial',
|
|
605
|
+
plan: row.plan as 'free' | 'starter' | 'pro' | 'enterprise',
|
|
606
|
+
maxUsers: row.maxUsers,
|
|
607
|
+
settings: row.settings ? (JSON.parse(row.settings) as Record<string, unknown>) : null,
|
|
608
|
+
createdAt: row.createdAt,
|
|
609
|
+
updatedAt: row.updatedAt,
|
|
610
|
+
}))
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** 成员归属 + 角色权限点判定(P2 隔离中间件的数据基础) */
|
|
614
|
+
export async function getUserTenantPermissions(
|
|
615
|
+
userId: string,
|
|
616
|
+
tenantId: number
|
|
617
|
+
): Promise<TenantPermission[]> {
|
|
618
|
+
const membership = await getMembership(userId, tenantId)
|
|
619
|
+
if (!membership || membership.status !== 'active') return []
|
|
620
|
+
|
|
621
|
+
const db = await getDb()
|
|
622
|
+
const [role] = await db.select().from(tenantRoles).where(eq(tenantRoles.id, membership.roleId))
|
|
623
|
+
if (!role) return []
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
return JSON.parse(role.permissions) as TenantPermission[]
|
|
627
|
+
} catch {
|
|
628
|
+
return []
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export async function hasTenantPermission(
|
|
633
|
+
userId: string,
|
|
634
|
+
tenantId: number,
|
|
635
|
+
permission: TenantPermission
|
|
636
|
+
): Promise<boolean> {
|
|
637
|
+
const permissions = await getUserTenantPermissions(userId, tenantId)
|
|
638
|
+
return permissions.includes(permission)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ============ 租户邀请 ============
|
|
642
|
+
|
|
643
|
+
export async function inviteMember(
|
|
644
|
+
tenantId: number,
|
|
645
|
+
email: string,
|
|
646
|
+
roleId: string,
|
|
647
|
+
inviterId: string
|
|
648
|
+
): Promise<TenantInvitation> {
|
|
649
|
+
const db = await getDb()
|
|
650
|
+
|
|
651
|
+
const tenant = await getTenantById(tenantId)
|
|
652
|
+
if (!tenant) throw NotFoundError.tenant(String(tenantId))
|
|
653
|
+
|
|
654
|
+
const role = await db
|
|
655
|
+
.select()
|
|
656
|
+
.from(tenantRoles)
|
|
657
|
+
.where(and(eq(tenantRoles.id, roleId), eq(tenantRoles.tenantId, tenantId)))
|
|
658
|
+
if (role.length === 0) {
|
|
659
|
+
throw new ValidationError('邀请指定的角色不属于本租户')
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// 套餐成员数配额:邀请时拦截(接受时二次校验,防邀请后配额被占满)
|
|
663
|
+
const activeCount = await getActiveMemberCount(tenantId)
|
|
664
|
+
if (activeCount >= tenant.maxUsers) {
|
|
665
|
+
throw new ValidationError(`成员数已达套餐上限(${tenant.maxUsers} 人),请升级套餐后再邀请`)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const now = new Date().toISOString()
|
|
669
|
+
const [row] = await db
|
|
670
|
+
.insert(tenantInvitations)
|
|
671
|
+
.values({
|
|
672
|
+
id: generateId('inv'),
|
|
673
|
+
tenantId,
|
|
674
|
+
email,
|
|
675
|
+
roleId,
|
|
676
|
+
inviterId,
|
|
677
|
+
token: generateToken(),
|
|
678
|
+
status: 'pending',
|
|
679
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
680
|
+
acceptedAt: null,
|
|
681
|
+
createdAt: now,
|
|
682
|
+
})
|
|
683
|
+
.returning()
|
|
684
|
+
|
|
685
|
+
log.info({ tenantId, email }, 'Invitation created')
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
id: row.id,
|
|
689
|
+
tenantId: row.tenantId,
|
|
690
|
+
email: row.email,
|
|
691
|
+
roleId: row.roleId,
|
|
692
|
+
inviterId: row.inviterId,
|
|
693
|
+
token: row.token,
|
|
694
|
+
status: row.status as 'pending',
|
|
695
|
+
expiresAt: row.expiresAt,
|
|
696
|
+
acceptedAt: row.acceptedAt,
|
|
697
|
+
createdAt: row.createdAt,
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export async function getInvitationByToken(token: string): Promise<{
|
|
702
|
+
invitation: TenantInvitation
|
|
703
|
+
tenantName: string
|
|
704
|
+
tenantSlug: string
|
|
705
|
+
roleLabel: string
|
|
706
|
+
} | null> {
|
|
707
|
+
const db = await getDb()
|
|
708
|
+
const [row] = await db.select().from(tenantInvitations).where(eq(tenantInvitations.token, token))
|
|
709
|
+
if (!row) return null
|
|
710
|
+
|
|
711
|
+
// 过期未处理的邀请惰性标记
|
|
712
|
+
if (row.status === 'pending' && row.expiresAt < new Date().toISOString()) {
|
|
713
|
+
await db
|
|
714
|
+
.update(tenantInvitations)
|
|
715
|
+
.set({ status: 'expired' })
|
|
716
|
+
.where(eq(tenantInvitations.id, row.id))
|
|
717
|
+
row.status = 'expired'
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, row.tenantId))
|
|
721
|
+
const [role] = await db.select().from(tenantRoles).where(eq(tenantRoles.id, row.roleId))
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
invitation: {
|
|
725
|
+
id: row.id,
|
|
726
|
+
tenantId: row.tenantId,
|
|
727
|
+
email: row.email,
|
|
728
|
+
roleId: row.roleId,
|
|
729
|
+
inviterId: row.inviterId,
|
|
730
|
+
token: row.token,
|
|
731
|
+
status: row.status as 'pending',
|
|
732
|
+
expiresAt: row.expiresAt,
|
|
733
|
+
acceptedAt: row.acceptedAt,
|
|
734
|
+
createdAt: row.createdAt,
|
|
735
|
+
},
|
|
736
|
+
tenantName: tenant?.name ?? '',
|
|
737
|
+
tenantSlug: tenant?.slug ?? '',
|
|
738
|
+
roleLabel: role?.label ?? '',
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** 接受邀请:事务内标记 accepted + 建成员(已存在成员则幂等返回) */
|
|
743
|
+
export async function acceptInvitation(
|
|
744
|
+
token: string,
|
|
745
|
+
userId: string
|
|
746
|
+
): Promise<TenantMember | null> {
|
|
747
|
+
const db = await getDb()
|
|
748
|
+
const detail = await getInvitationByToken(token)
|
|
749
|
+
if (!detail) return null
|
|
750
|
+
|
|
751
|
+
const { invitation } = detail
|
|
752
|
+
if (invitation.status !== 'pending') return null
|
|
753
|
+
|
|
754
|
+
// 接受时二次校验配额(邀请发出后名额可能已被占满/套餐降级)
|
|
755
|
+
const tenantRow = await getTenantById(invitation.tenantId)
|
|
756
|
+
if (tenantRow) {
|
|
757
|
+
const activeCount = await getActiveMemberCount(invitation.tenantId)
|
|
758
|
+
if (activeCount >= tenantRow.maxUsers) {
|
|
759
|
+
throw new ValidationError(
|
|
760
|
+
`该租户成员数已达套餐上限(${tenantRow.maxUsers} 人),请联系租户管理员升级套餐`
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const existing = await getMembership(userId, invitation.tenantId)
|
|
766
|
+
const now = new Date().toISOString()
|
|
767
|
+
|
|
768
|
+
const memberRow = await db.transaction(async tx => {
|
|
769
|
+
await tx
|
|
770
|
+
.update(tenantInvitations)
|
|
771
|
+
.set({ status: 'accepted', acceptedAt: now })
|
|
772
|
+
.where(eq(tenantInvitations.id, invitation.id))
|
|
773
|
+
|
|
774
|
+
if (existing) {
|
|
775
|
+
// 曾离开的成员重新激活并按邀请角色更新
|
|
776
|
+
const [row] = await tx
|
|
777
|
+
.update(tenantMembers)
|
|
778
|
+
.set({ status: 'active', roleId: invitation.roleId, joinedAt: now, lastActiveAt: now })
|
|
779
|
+
.where(eq(tenantMembers.id, existing.id))
|
|
780
|
+
.returning()
|
|
781
|
+
return row
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const [row] = await tx
|
|
785
|
+
.insert(tenantMembers)
|
|
786
|
+
.values({
|
|
787
|
+
id: generateId('tm'),
|
|
788
|
+
tenantId: invitation.tenantId,
|
|
789
|
+
userId,
|
|
790
|
+
roleId: invitation.roleId,
|
|
791
|
+
status: 'active',
|
|
792
|
+
invitedBy: invitation.inviterId,
|
|
793
|
+
invitedAt: invitation.createdAt,
|
|
794
|
+
joinedAt: now,
|
|
795
|
+
lastActiveAt: now,
|
|
796
|
+
})
|
|
797
|
+
.returning()
|
|
798
|
+
return row
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
const members = await getTenantMembers(invitation.tenantId)
|
|
802
|
+
return members.find(m => m.id === memberRow.id) ?? rowToMember(memberRow, null)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export async function cancelInvitation(tenantId: number, invitationId: string): Promise<boolean> {
|
|
806
|
+
const db = await getDb()
|
|
807
|
+
const [row] = await db
|
|
808
|
+
.update(tenantInvitations)
|
|
809
|
+
.set({ status: 'cancelled' })
|
|
810
|
+
.where(and(eq(tenantInvitations.id, invitationId), eq(tenantInvitations.tenantId, tenantId)))
|
|
811
|
+
.returning()
|
|
812
|
+
return !!row
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** 供路由层鉴权用:非成员 403 / 权限不足 403 */
|
|
816
|
+
export async function requireTenantMembership(userId: string, tenantId: number): Promise<void> {
|
|
817
|
+
const membership = await getMembership(userId, tenantId)
|
|
818
|
+
if (!membership || membership.status !== 'active') {
|
|
819
|
+
throw AuthorizationError.permissionDenied('访问本租户')
|
|
820
|
+
}
|
|
821
|
+
}
|
|
@@ -19,6 +19,14 @@ import { getAuthUser } from '@server/utils/auth'
|
|
|
19
19
|
import { NotFoundError, ValidationError } from '@server/utils/app-error'
|
|
20
20
|
import { authMiddleware } from '@server/middleware/auth'
|
|
21
21
|
|
|
22
|
+
// 租户上下文:仅含 tenant 模块的 preset 由隔离中间件注入 c.tenant。
|
|
23
|
+
// 松散读取避免依赖各 preset 差异化的 AppBindings 类型声明
|
|
24
|
+
type TenantContext = { id: number } | undefined
|
|
25
|
+
function tenantIdFrom(c: { get?: (key: string) => unknown }): number | undefined {
|
|
26
|
+
const tenant = c.get?.('tenant') as TenantContext
|
|
27
|
+
return tenant?.id
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
const listRoute = createRoute({
|
|
23
31
|
method: 'get',
|
|
24
32
|
path: '/todos',
|
|
@@ -170,7 +178,7 @@ const deleteAttachmentRoute = createRoute({
|
|
|
170
178
|
export const apiRoutes = new OpenAPIHono()
|
|
171
179
|
.openapi(listRoute, async c => {
|
|
172
180
|
const { page, limit } = c.req.valid('query')
|
|
173
|
-
const result = await todoService.listTodos({ page, limit })
|
|
181
|
+
const result = await todoService.listTodos({ page, limit, tenantId: tenantIdFrom(c) })
|
|
174
182
|
return c.json(success(result), 200)
|
|
175
183
|
})
|
|
176
184
|
.openapi(getRoute, async c => {
|
|
@@ -181,7 +189,7 @@ export const apiRoutes = new OpenAPIHono()
|
|
|
181
189
|
})
|
|
182
190
|
.openapi(createRouteDef, async c => {
|
|
183
191
|
const data = c.req.valid('json')
|
|
184
|
-
const todo = await todoService.createTodo(data)
|
|
192
|
+
const todo = await todoService.createTodo(data, tenantIdFrom(c))
|
|
185
193
|
return c.json(created(todo), 201)
|
|
186
194
|
})
|
|
187
195
|
.openapi(updateRoute, async c => {
|