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
@@ -1,9 +1,33 @@
1
- import { eq, desc, like } from 'drizzle-orm'
2
- import type { Tenant, CreateTenantInput, UpdateTenantInput } from '@shared/schemas'
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 { tenants, type TenantTable, type NewTenant } from '@server/db/schema'
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
- const result = await db
164
- .insert(tenants)
165
- .values({
166
- name: input.name,
167
- slug: input.slug,
168
- status: 'trial',
169
- plan: input.plan,
170
- maxUsers: input.maxUsers,
171
- settings: input.settings ? JSON.stringify(input.settings) : null,
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
- const row = result[0]
280
+ return tenantRow
281
+ })
178
282
 
179
- log.info({ tenantId: row.id, slug: row.slug }, 'Tenant created')
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 => {