create-fullstack-scaffold 0.4.25 → 0.5.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 (135) hide show
  1. package/dist/cli/index.js +432 -15
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +10 -13
  4. package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
  5. package/template/eslint-rules/no-cross-module-service-import.js +4 -0
  6. package/template/eslint-rules/no-merged-api-type-export.js +190 -0
  7. package/template/eslint.config.js +3 -0
  8. package/template/package.json +9 -6
  9. package/template/scripts/sync-agent-hooks.mjs +189 -0
  10. package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
  11. package/template/src/admin/pages/ContentPage.tsx +1 -1
  12. package/template/src/admin/pages/DisputesPage.tsx +1 -1
  13. package/template/src/admin/pages/OrdersPage.tsx +1 -1
  14. package/template/src/admin/pages/TicketsPage.tsx +1 -1
  15. package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
  16. package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
  17. package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
  18. package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
  19. package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
  20. package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
  21. package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
  22. package/template/src/admin/services/apiClient.ts +2 -3
  23. package/template/src/cli/modules/content/index.ts +3 -3
  24. package/template/src/cli/modules/dispute/index.ts +3 -3
  25. package/template/src/cli/modules/ticket/index.ts +3 -3
  26. package/template/src/cli/modules/todo/index.ts +1 -1
  27. package/template/src/cli/rpc/client.ts +3 -4
  28. package/template/src/cli/rpc/index.ts +1 -1
  29. package/template/src/client/App.tsx +3 -39
  30. package/template/src/client/AppRoutes.tsx +53 -0
  31. package/template/src/client/entry-server.tsx +75 -0
  32. package/template/src/client/main.tsx +3 -1
  33. package/template/src/client/pages/SearchPage.tsx +1 -1
  34. package/template/src/client/services/apiClient.ts +12 -4
  35. package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
  36. package/template/src/client/stores/entry-stores.ts +36 -0
  37. package/template/src/client/stores/notificationStore.ts +4 -4
  38. package/template/src/client/stores/todoStore.ts +2 -2
  39. package/template/src/merchant/pages/DisputesPage.tsx +3 -3
  40. package/template/src/merchant/pages/OrdersPage.tsx +1 -1
  41. package/template/src/merchant/pages/ProductsPage.tsx +1 -1
  42. package/template/src/merchant/pages/SettingsPage.tsx +1 -1
  43. package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
  44. package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
  45. package/template/src/server/app.ts +6 -4
  46. package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
  47. package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
  48. package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
  49. package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
  50. package/template/src/server/core/isr-cache.ts +6 -12
  51. package/template/src/server/core/isr-invalidation.ts +6 -12
  52. package/template/src/server/core/isr-registry.ts +104 -0
  53. package/template/src/server/core/isr-renderer.ts +75 -0
  54. package/template/src/server/db/schema/contents.ts +28 -20
  55. package/template/src/server/db/schema/disputes.ts +30 -22
  56. package/template/src/server/db/schema/notifications.ts +20 -14
  57. package/template/src/server/db/schema/orders.ts +23 -16
  58. package/template/src/server/db/schema/plugins.ts +56 -38
  59. package/template/src/server/db/schema/products.ts +24 -17
  60. package/template/src/server/db/schema/tickets.ts +44 -31
  61. package/template/src/server/db/schema/todos.ts +26 -18
  62. package/template/src/server/entries/cloudflare.ts +82 -19
  63. package/template/src/server/entries/node.ts +0 -2
  64. package/template/src/server/index.ts +0 -1
  65. package/template/src/server/isr-modules.ts +10 -0
  66. package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
  67. package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
  68. package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
  69. package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
  70. package/template/src/server/module-admin/services/admin-service.ts +57 -13
  71. package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
  72. package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
  73. package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
  74. package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
  75. package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
  76. package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
  77. package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
  78. package/template/src/server/module-content/isr.ts +63 -0
  79. package/template/src/server/module-content/routes/content-routes.ts +10 -10
  80. package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
  81. package/template/src/server/module-content/routes/topics-routes.ts +3 -0
  82. package/template/src/server/module-content/services/content-service.ts +23 -10
  83. package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
  84. package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
  85. package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
  86. package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
  87. package/template/src/server/module-file/routes/file-routes.ts +3 -0
  88. package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
  89. package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
  90. package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
  91. package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
  92. package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
  93. package/template/src/server/module-order/routes/cart-routes.ts +3 -0
  94. package/template/src/server/module-order/routes/order-routes.ts +9 -4
  95. package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
  96. package/template/src/server/module-order/services/order-service.ts +25 -13
  97. package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
  98. package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
  99. package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
  100. package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
  101. package/template/src/server/module-permission/routes/role-routes.ts +3 -0
  102. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
  103. package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
  104. package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
  105. package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
  106. package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
  107. package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
  108. package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
  109. package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
  110. package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
  111. package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
  112. package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
  113. package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
  114. package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
  115. package/template/src/server/module-todos/isr.ts +41 -0
  116. package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
  117. package/template/src/server/module-todos/services/todo-service.ts +31 -10
  118. package/template/src/server/route-registry.ts +0 -4
  119. package/template/src/server/rpc-merge.ts +27 -0
  120. package/template/src/server/rpc-surface.ts +117 -0
  121. package/template/src/server/rpc-type-canary.ts +80 -0
  122. package/template/src/server/test-utils/test-client.ts +7 -9
  123. package/template/src/server/test-utils/test-isr-helper.ts +140 -0
  124. package/template/src/shared/modules/content/schemas.ts +14 -0
  125. package/template/src/shared/modules/dispute/schemas.ts +14 -0
  126. package/template/src/shared/modules/order/schemas.ts +9 -1
  127. package/template/src/shared/modules/ticket/schemas.ts +14 -0
  128. package/template/src/shared/modules/todos/index.ts +4 -0
  129. package/template/src/shared/modules/todos/schemas.ts +14 -0
  130. package/template/src/shared/schemas/index.ts +18 -0
  131. package/template/tsup.config.ts +48 -1
  132. package/template/vitest.config.ts +19 -3
  133. package/template/vitest.setup.ts +68 -5
  134. package/template/patches/typescript+5.9.3.patch +0 -24
  135. /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@server/module-content/services/content-service', () => ({
4
+ getContents: vi.fn(),
5
+ getContentById: vi.fn(),
6
+ }))
7
+
8
+ import { getContents, getContentById } from '@server/module-content/services/content-service'
9
+ import type { MockContent } from '@server/test-utils/test-isr-helper'
10
+
11
+ // Side-effect: register ISR routes into global registry
12
+ import '@server/module-content/isr'
13
+
14
+ const mockContentsData: MockContent[] = [
15
+ {
16
+ id: 'content-1',
17
+ title: 'Article One',
18
+ content: 'This is the first article body text that is long enough to be truncated.',
19
+ category: 'article',
20
+ status: 'published',
21
+ author: 'Author A',
22
+ tags: ['tag1', 'tag2'],
23
+ viewCount: 100,
24
+ likeCount: 10,
25
+ createdAt: '2024-01-01T00:00:00.000Z',
26
+ updatedAt: '2024-01-01T00:00:00.000Z',
27
+ },
28
+ {
29
+ id: 'content-2',
30
+ title: 'Tutorial Two',
31
+ content: 'A comprehensive tutorial about testing.',
32
+ category: 'tutorial',
33
+ status: 'published',
34
+ author: 'Author B',
35
+ tags: ['test', 'guide'],
36
+ viewCount: 200,
37
+ likeCount: 30,
38
+ createdAt: '2024-01-02T00:00:00.000Z',
39
+ updatedAt: '2024-01-02T00:00:00.000Z',
40
+ },
41
+ ]
42
+
43
+ describe('Content Module ISR', () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks()
46
+ })
47
+
48
+ describe('route registration', () => {
49
+ it('should register /content route', async () => {
50
+ const { isrRegistry } = await import('@server/core/isr-registry')
51
+ expect(isrRegistry.match('/content')).not.toBeNull()
52
+ expect(isrRegistry.match('/content')!.module).toBe('content')
53
+ })
54
+
55
+ it('should register /content/ prefix', async () => {
56
+ const { isrRegistry } = await import('@server/core/isr-registry')
57
+ expect(isrRegistry.match('/content/content-1')).not.toBeNull()
58
+ expect(isrRegistry.match('/content/content-1')!.module).toBe('content')
59
+ })
60
+
61
+ it('should not match /content for prefix entry', async () => {
62
+ const { isrRegistry } = await import('@server/core/isr-registry')
63
+ // /content should match the exact entry, not prefix
64
+ const exact = isrRegistry.match('/content')
65
+ expect(exact).not.toBeNull()
66
+ // The exact /content entry should fetch list data
67
+ vi.mocked(getContents).mockResolvedValue({
68
+ contents: mockContentsData,
69
+ total: 2,
70
+ page: 1,
71
+ limit: 20,
72
+ })
73
+ const data = await exact!.fetch('/content', {})
74
+ expect(data).toHaveProperty('contents')
75
+ expect(data).toHaveProperty('total')
76
+ })
77
+ })
78
+
79
+ describe('/content — list page', () => {
80
+ it('should fetch content list', async () => {
81
+ vi.mocked(getContents).mockResolvedValue({
82
+ contents: mockContentsData,
83
+ total: 2,
84
+ page: 1,
85
+ limit: 20,
86
+ })
87
+
88
+ const { isrRegistry } = await import('@server/core/isr-registry')
89
+ const entry = isrRegistry.match('/content')!
90
+ const data = (await entry.fetch('/content', {})) as { contents: MockContent[]; total: number }
91
+
92
+ expect(data.contents).toHaveLength(2)
93
+ expect(data.total).toBe(2)
94
+ })
95
+
96
+ it('should return correct list meta', async () => {
97
+ const { isrRegistry } = await import('@server/core/isr-registry')
98
+ const entry = isrRegistry.match('/content')!
99
+ const meta = entry.meta({}, '/content')
100
+ expect(meta.title).toContain('内容中心')
101
+ })
102
+ })
103
+
104
+ describe('/content/:id — detail page', () => {
105
+ it('should fetch content detail', async () => {
106
+ vi.mocked(getContentById).mockResolvedValue(mockContentsData[0])
107
+
108
+ const { isrRegistry } = await import('@server/core/isr-registry')
109
+ const entry = isrRegistry.match('/content/content-1')!
110
+ const data = (await entry.fetch('/content/content-1', {})) as { content: MockContent | null }
111
+
112
+ expect(data.content).not.toBeNull()
113
+ expect(data.content!.title).toBe('Article One')
114
+ })
115
+
116
+ it('should return detail meta with title', async () => {
117
+ vi.mocked(getContentById).mockResolvedValue(mockContentsData[0])
118
+
119
+ const { isrRegistry } = await import('@server/core/isr-registry')
120
+ const entry = isrRegistry.match('/content/content-1')!
121
+ const data = await entry.fetch('/content/content-1', {})
122
+ const meta = entry.meta(data, '/content/content-1')
123
+
124
+ expect(meta.title).toContain('Article One')
125
+ })
126
+
127
+ it('should return not-found meta for missing content', async () => {
128
+ vi.mocked(getContentById).mockResolvedValue(null)
129
+
130
+ const { isrRegistry } = await import('@server/core/isr-registry')
131
+ const entry = isrRegistry.match('/content/missing')!
132
+ const data = await entry.fetch('/content/missing', {})
133
+ const meta = entry.meta(data, '/content/missing')
134
+
135
+ expect(meta.title).toContain('内容不存在')
136
+ })
137
+ })
138
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Content module ISR routes (meta-only).
3
+ * Registered into isrRegistry at import time.
4
+ * Fetches data for SEO meta tags — body rendering handled by React SPA.
5
+ */
6
+
7
+ import { isrRegistry, type ISRRouteEntry } from '@server/core/isr-registry'
8
+ import { getContents, getContentById } from './services/content-service'
9
+
10
+ interface ContentListData {
11
+ contents: Array<{ id: string; title: string; category: string; author: string }>
12
+ total: number
13
+ }
14
+
15
+ interface ContentDetailData {
16
+ content: { id: string; title: string; content: string; author: string } | null
17
+ }
18
+
19
+ async function fetchContentList(): Promise<ContentListData> {
20
+ const { contents, total } = await getContents({ limit: 20 })
21
+ return { contents, total }
22
+ }
23
+
24
+ async function fetchContentDetail(pathname: string): Promise<ContentDetailData> {
25
+ const id = pathname.replace('/content/', '')
26
+ const content = await getContentById(id)
27
+ return { content }
28
+ }
29
+
30
+ function contentListMeta(): { title: string; description: string } {
31
+ return {
32
+ title: '内容中心 - Biomimic App',
33
+ description: 'Content management with categories and search',
34
+ }
35
+ }
36
+
37
+ function contentDetailMeta(data: ContentDetailData): { title: string; description: string } {
38
+ const c = data.content
39
+ if (!c) {
40
+ return { title: '内容不存在 - Biomimic App', description: '请求的内容不存在' }
41
+ }
42
+ return {
43
+ title: `${c.title} - Biomimic App`,
44
+ description: c.content?.substring(0, 160) || '内容详情',
45
+ }
46
+ }
47
+
48
+ const contentEntries: ISRRouteEntry[] = [
49
+ {
50
+ module: 'content',
51
+ match: '/content',
52
+ fetch: () => fetchContentList(),
53
+ meta: () => contentListMeta(),
54
+ },
55
+ {
56
+ module: 'content',
57
+ match: '/content/',
58
+ fetch: pathname => fetchContentDetail(pathname),
59
+ meta: data => contentDetailMeta(data as ContentDetailData),
60
+ },
61
+ ]
62
+
63
+ isrRegistry.registerMany(contentEntries)
@@ -5,12 +5,12 @@ import { successResponse, errorResponse, success, created } from '@server/utils/
5
5
  import { NotFoundError } from '@server/utils/app-error'
6
6
  import { authMiddleware } from '@server/middleware/auth'
7
7
  import { Permission } from '@shared/modules/permission'
8
- import { z } from '@hono/zod-openapi'
9
8
  import {
10
9
  ContentSchema,
11
10
  CreateContentSchema,
12
11
  UpdateContentSchema,
13
- ContentListSchema,
12
+ ContentListResponseSchema,
13
+ ContentListQuerySchema,
14
14
  ContentDeleteResultSchema,
15
15
  } from '@shared/modules/content'
16
16
  import { BusinessError } from '@server/utils/app-error'
@@ -22,13 +22,10 @@ const listRoute = createRoute({
22
22
  security: [{ Bearer: [] }],
23
23
  middleware: [authMiddleware({ requiredPermissions: [Permission.CONTENT_VIEW] })],
24
24
  request: {
25
- query: z.object({
26
- limit: z.coerce.number().int().positive().max(100).default(20),
27
- offset: z.coerce.number().int().min(0).default(0),
28
- }),
25
+ query: ContentListQuerySchema,
29
26
  },
30
27
  responses: {
31
- 200: successResponse(ContentListSchema, 'List all contents'),
28
+ 200: successResponse(ContentListResponseSchema, 'List all contents'),
32
29
  },
33
30
  })
34
31
 
@@ -140,9 +137,9 @@ const archiveRoute = createRoute({
140
137
 
141
138
  export const contentRoutes = new OpenAPIHono()
142
139
  .openapi(listRoute, async c => {
143
- const { limit, offset } = c.req.valid('query')
144
- const result = await contentService.getContents()
145
- return c.json(success(result.slice(offset, offset + limit)), 200)
140
+ const { page, limit } = c.req.valid('query')
141
+ const result = await contentService.getContents({ page, limit })
142
+ return c.json(success(result), 200)
146
143
  })
147
144
  .openapi(getRoute, async c => {
148
145
  const { id } = c.req.valid('param')
@@ -180,3 +177,6 @@ export const contentRoutes = new OpenAPIHono()
180
177
  if (!result) throw new BusinessError('Content cannot be archived (must be in published state)')
181
178
  return c.json(success(result), 200)
182
179
  })
180
+
181
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
182
+ export type ContentsApiType = typeof contentRoutes
@@ -15,8 +15,8 @@ const listPublicRoute = createRoute({
15
15
  query: z.object({
16
16
  category: ContentCategorySchema.optional(),
17
17
  search: z.string().optional(),
18
+ page: z.coerce.number().int().positive().default(1),
18
19
  limit: z.coerce.number().int().positive().max(50).default(20),
19
- offset: z.coerce.number().int().min(0).default(0),
20
20
  }),
21
21
  },
22
22
  responses: {
@@ -39,14 +39,15 @@ const getPublicRoute = createRoute({
39
39
 
40
40
  export const publicContentRoutes = new OpenAPIHono()
41
41
  .openapi(listPublicRoute, async c => {
42
- const { category, search, limit, offset } = c.req.valid('query')
42
+ const { category, search, page, limit } = c.req.valid('query')
43
43
  const result = await contentService.getContents({
44
44
  status: 'published',
45
45
  category,
46
46
  search,
47
+ page,
48
+ limit,
47
49
  })
48
- const items = result.slice(offset, offset + limit)
49
- return c.json(success(items), 200)
50
+ return c.json(success(result), 200)
50
51
  })
51
52
  .openapi(getPublicRoute, async c => {
52
53
  const { id } = c.req.valid('param')
@@ -56,3 +57,6 @@ export const publicContentRoutes = new OpenAPIHono()
56
57
  }
57
58
  return c.json(success(result), 200)
58
59
  })
60
+
61
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
62
+ export type PublicContentApiType = typeof publicContentRoutes
@@ -453,3 +453,6 @@ export const topicsRoutes = new OpenAPIHono()
453
453
  },
454
454
  })
455
455
  })
456
+
457
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
458
+ export type TopicsApiType = typeof topicsRoutes
@@ -88,8 +88,13 @@ export async function getContents(filters?: {
88
88
  category?: ContentCategory
89
89
  status?: ContentStatus
90
90
  search?: string
91
- }): Promise<Content[]> {
91
+ page?: number
92
+ limit?: number
93
+ }): Promise<{ contents: Content[]; total: number; page: number; limit: number }> {
92
94
  const db = await getDb()
95
+ const page = filters?.page ?? 1
96
+ const limit = filters?.limit ?? 20
97
+ const offset = (page - 1) * limit
93
98
  const conditions = []
94
99
 
95
100
  if (filters?.category) {
@@ -105,16 +110,24 @@ export async function getContents(filters?: {
105
110
  )
106
111
  }
107
112
 
108
- const rows =
109
- conditions.length > 0
110
- ? await db
111
- .select()
112
- .from(contents)
113
- .where(and(...conditions))
114
- .orderBy(desc(contents.createdAt))
115
- : await db.select().from(contents).orderBy(desc(contents.createdAt))
113
+ const baseQuery = conditions.length > 0 ? and(...conditions) : undefined
116
114
 
117
- return rows.map(mapContentRow)
115
+ // SQL 分页(避免全表扫 + 内存 slice)
116
+ const rows = await db
117
+ .select()
118
+ .from(contents)
119
+ .where(baseQuery)
120
+ .orderBy(desc(contents.createdAt))
121
+ .limit(limit)
122
+ .offset(offset)
123
+ const total = await db.$count(contents, baseQuery)
124
+
125
+ return {
126
+ contents: rows.map(mapContentRow),
127
+ total,
128
+ page,
129
+ limit,
130
+ }
118
131
  }
119
132
 
120
133
  export async function getContentById(id: string): Promise<Content | null> {
@@ -22,7 +22,8 @@ describe('Dispute Routes', () => {
22
22
  const data = await res.json()
23
23
  expect(data.success).toBe(true)
24
24
  if (data.success) {
25
- expect(Array.isArray(data.data)).toBe(true)
25
+ expect(data.data.disputes).toBeDefined()
26
+ expect(typeof data.data.total).toBe('number')
26
27
  }
27
28
  })
28
29
  })
@@ -17,7 +17,8 @@ describe('Dispute Service', () => {
17
17
  describe('getDisputes', () => {
18
18
  it('should return all disputes', async () => {
19
19
  const result = await service.getDisputes()
20
- expect(Array.isArray(result)).toBe(true)
20
+ expect(result.disputes).toBeDefined()
21
+ expect(typeof result.total).toBe('number')
21
22
  })
22
23
  })
23
24
 
@@ -1,4 +1,4 @@
1
- import { createRoute, z } from '@hono/zod-openapi'
1
+ import { createRoute } from '@hono/zod-openapi'
2
2
  import { OpenAPIHono } from '@hono/zod-openapi'
3
3
  import * as disputeService from '../services/dispute-service'
4
4
  import {
@@ -15,7 +15,8 @@ import {
15
15
  DisputeSchema,
16
16
  CreateDisputeSchema,
17
17
  UpdateDisputeSchema,
18
- DisputeListSchema,
18
+ DisputeListResponseSchema,
19
+ DisputeListQuerySchema,
19
20
  ResolveDisputeSchema,
20
21
  DisputeDeleteResultSchema,
21
22
  } from '@shared/modules/dispute'
@@ -28,13 +29,10 @@ const listRoute = createRoute({
28
29
  security: [{ Bearer: [] }],
29
30
  middleware: [authMiddleware({ requiredPermissions: [Permission.DISPUTE_VIEW] })],
30
31
  request: {
31
- query: z.object({
32
- limit: z.coerce.number().int().positive().max(100).default(20),
33
- offset: z.coerce.number().int().min(0).default(0),
34
- }),
32
+ query: DisputeListQuerySchema,
35
33
  },
36
34
  responses: {
37
- 200: successResponse(DisputeListSchema, 'List all disputes'),
35
+ 200: successResponse(DisputeListResponseSchema, 'List all disputes'),
38
36
  401: errorResponse('Unauthorized'),
39
37
  403: errorResponse('Forbidden'),
40
38
  },
@@ -117,9 +115,9 @@ const resolveRoute = createRoute({
117
115
 
118
116
  export const disputeRoutes = new OpenAPIHono()
119
117
  .openapi(listRoute, async c => {
120
- const { limit, offset } = c.req.valid('query')
121
- const result = await disputeService.getDisputes()
122
- return c.json(success(result.slice(offset, offset + limit)), 200)
118
+ const { page, limit } = c.req.valid('query')
119
+ const result = await disputeService.getDisputes({ page, limit })
120
+ return c.json(success(result), 200)
123
121
  })
124
122
  .openapi(getRoute, async c => {
125
123
  const { id } = c.req.valid('param')
@@ -152,3 +150,6 @@ export const disputeRoutes = new OpenAPIHono()
152
150
  if (!result) throw new BusinessError('Cannot resolve dispute in current state')
153
151
  return c.json(success(result), 200)
154
152
  })
153
+
154
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
155
+ export type DisputesApiType = typeof disputeRoutes
@@ -91,8 +91,13 @@ function mapDisputeRow(row: DisputeTable): Dispute {
91
91
  export async function getDisputes(filters?: {
92
92
  status?: DisputeStatus
93
93
  type?: DisputeType
94
- }): Promise<Dispute[]> {
94
+ page?: number
95
+ limit?: number
96
+ }): Promise<{ disputes: Dispute[]; total: number; page: number; limit: number }> {
95
97
  const db = await getDb()
98
+ const page = filters?.page ?? 1
99
+ const limit = filters?.limit ?? 20
100
+ const offset = (page - 1) * limit
96
101
  const conditions = []
97
102
 
98
103
  if (filters?.status) {
@@ -102,7 +107,7 @@ export async function getDisputes(filters?: {
102
107
  conditions.push(eq(disputes.type, filters.type))
103
108
  }
104
109
 
105
- const rows =
110
+ const allRows =
106
111
  conditions.length > 0
107
112
  ? await db
108
113
  .select()
@@ -111,7 +116,14 @@ export async function getDisputes(filters?: {
111
116
  .orderBy(desc(disputes.createdAt))
112
117
  : await db.select().from(disputes).orderBy(desc(disputes.createdAt))
113
118
 
114
- return rows.map(mapDisputeRow)
119
+ const rows = allRows.slice(offset, offset + limit)
120
+
121
+ return {
122
+ disputes: rows.map(mapDisputeRow),
123
+ total: allRows.length,
124
+ page,
125
+ limit,
126
+ }
115
127
  }
116
128
 
117
129
  export async function getDisputeById(id: string): Promise<Dispute | null> {
@@ -286,3 +286,6 @@ export const fileRoutes = new OpenAPIHono()
286
286
  200
287
287
  )
288
288
  })
289
+
290
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
291
+ export type FilesApiType = typeof fileRoutes
@@ -136,3 +136,6 @@ export const apiRoutes = new OpenAPIHono()
136
136
  const product = await merchantService.createProduct(merchant.id, input)
137
137
  return c.json(success(product), 201)
138
138
  })
139
+
140
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
141
+ export type MerchantApiType = typeof apiRoutes
@@ -92,7 +92,7 @@ export async function merchantLogin(
92
92
  }
93
93
 
94
94
  // Generate token (simplified for demo - use JWT in production)
95
- const token = `merchant-token-${merchant.id}-${Date.now()}`
95
+ const token = `mt_${crypto.randomUUID().replace(/-/g, '')}`
96
96
 
97
97
  log.info({ merchantId: merchant.id }, 'Merchant logged in')
98
98
 
@@ -121,11 +121,11 @@ export async function getMerchantStats(merchantId: number): Promise<MerchantStat
121
121
  const allProducts = await db.select().from(products).where(eq(products.merchantId, merchantId))
122
122
  const activeProducts = allProducts.filter(p => p.status === 'active').length
123
123
 
124
- // Get order stats (simplified - in production, query orders table)
125
- const totalOrders = 0 // TODO: Implement order stats
126
- const totalRevenue = 0 // TODO: Implement revenue stats
127
- const pendingOrders = 0 // TODO: Implement pending order count
128
- const thisMonthRevenue = 0 // TODO: Implement monthly revenue
124
+ // Get order stats (mock data with realistic values)
125
+ const totalOrders = 156
126
+ const totalRevenue = 45890.5
127
+ const pendingOrders = 12
128
+ const thisMonthRevenue = 8750.25
129
129
 
130
130
  return {
131
131
  totalOrders,
@@ -254,7 +254,7 @@ const seedMerchants = [
254
254
  phone: '13800138000',
255
255
  email: 'merchant@example.com',
256
256
  address: '123 Main St',
257
- password: 'password123',
257
+ password: 'Demo@2024!',
258
258
  },
259
259
  {
260
260
  userId: 'merchant-2',
@@ -266,7 +266,7 @@ const seedMerchants = [
266
266
  phone: null,
267
267
  email: null,
268
268
  address: null,
269
- password: 'password123',
269
+ password: 'Demo@2024!',
270
270
  },
271
271
  ]
272
272
 
@@ -167,7 +167,8 @@ export const notificationRoutes = new OpenAPIHono()
167
167
  const response = await adapter.handleSSERequest()
168
168
  return response
169
169
  }
170
- } catch {
170
+ } catch (error) {
171
+ console.error('[NotificationRoutes] handleSSERequest failed:', error)
171
172
  return createFallbackSSEResponse()
172
173
  }
173
174
 
@@ -214,3 +215,6 @@ export const notificationRoutes = new OpenAPIHono()
214
215
  if (!deleted) throw new NotFoundError('Notification', id)
215
216
  return c.json(success({ id }), 200)
216
217
  })
218
+
219
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
220
+ export type NotificationsApiType = typeof notificationRoutes
@@ -22,7 +22,7 @@ describe('Order Routes', () => {
22
22
  const data = await res.json()
23
23
  expect(data.success).toBe(true)
24
24
  if (data.success) {
25
- expect(Array.isArray(data.data)).toBe(true)
25
+ expect(Array.isArray(data.data.orders)).toBe(true)
26
26
  }
27
27
  })
28
28
 
@@ -37,8 +37,8 @@ describe('Order Routes', () => {
37
37
  const data = await res.json()
38
38
  expect(data.success).toBe(true)
39
39
  if (data.success) {
40
- expect(Array.isArray(data.data)).toBe(true)
41
- data.data.forEach((order: { status: string }) => {
40
+ expect(Array.isArray(data.data.orders)).toBe(true)
41
+ data.data.orders.forEach((order: { status: string }) => {
42
42
  expect(order.status).toBe('completed')
43
43
  })
44
44
  }
@@ -55,8 +55,8 @@ describe('Order Routes', () => {
55
55
  const data = await res.json()
56
56
  expect(data.success).toBe(true)
57
57
  if (data.success) {
58
- expect(Array.isArray(data.data)).toBe(true)
59
- data.data.forEach((order: { customerName: string }) => {
58
+ expect(Array.isArray(data.data.orders)).toBe(true)
59
+ data.data.orders.forEach((order: { customerName: string }) => {
60
60
  expect(order.customerName).toContain('张三')
61
61
  })
62
62
  }
@@ -83,8 +83,8 @@ describe('Order Routes', () => {
83
83
  const data = await res.json()
84
84
  expect(data.success).toBe(true)
85
85
  if (data.success) {
86
- expect(Array.isArray(data.data)).toBe(true)
87
- data.data.forEach((order: { status: string; customerName: string }) => {
86
+ expect(Array.isArray(data.data.orders)).toBe(true)
87
+ data.data.orders.forEach((order: { status: string; customerName: string }) => {
88
88
  expect(order.status).toBe('pending')
89
89
  expect(order.customerName).toContain('Filter Test')
90
90
  })
@@ -112,7 +112,7 @@ describe('Order Routes', () => {
112
112
  const data = await res.json()
113
113
  expect(data.success).toBe(true)
114
114
  if (data.success) {
115
- expect(data.data).toHaveLength(0)
115
+ expect(data.data.orders).toHaveLength(0)
116
116
  }
117
117
  })
118
118
  })
@@ -21,7 +21,8 @@ describe('Order Service', () => {
21
21
  describe('getOrders', () => {
22
22
  it('should return all orders when no filters provided', async () => {
23
23
  const result = await service.getOrders()
24
- expect(Array.isArray(result)).toBe(true)
24
+ expect(result.orders).toBeDefined()
25
+ expect(typeof result.total).toBe('number')
25
26
  })
26
27
 
27
28
  it('should filter orders by status', async () => {
@@ -37,8 +38,8 @@ describe('Order Service', () => {
37
38
  await service.updateOrder(created.id, { status: 'processing' })
38
39
 
39
40
  const result = await service.getOrders({ status: 'processing' })
40
- expect(Array.isArray(result)).toBe(true)
41
- result.forEach(order => {
41
+ expect(Array.isArray(result.orders)).toBe(true)
42
+ result.orders.forEach(order => {
42
43
  expect(order.status).toBe('processing')
43
44
  })
44
45
  })
@@ -101,3 +101,6 @@ export const cartRoutes = new OpenAPIHono()
101
101
  }
102
102
  return c.json({ success: true as const, data: { removedId: id } })
103
103
  })
104
+
105
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
106
+ export type CartApiType = typeof cartRoutes
@@ -9,7 +9,7 @@ import {
9
9
  OrderSchema,
10
10
  CreateOrderSchema,
11
11
  UpdateOrderSchema,
12
- OrderListSchema,
12
+ OrderListResponseSchema,
13
13
  OrderDeleteResultSchema,
14
14
  OrderQuerySchema,
15
15
  } from '@shared/modules/order'
@@ -24,7 +24,7 @@ const listRoute = createRoute({
24
24
  query: OrderQuerySchema,
25
25
  },
26
26
  responses: {
27
- 200: successResponse(OrderListSchema, 'List all orders'),
27
+ 200: successResponse(OrderListResponseSchema, 'List all orders'),
28
28
  401: errorResponse('Unauthorized'),
29
29
  403: errorResponse('Forbidden'),
30
30
  500: errorResponse('Internal server error'),
@@ -157,12 +157,14 @@ const cancelRoute = createRoute({
157
157
 
158
158
  export const orderRoutes = new OpenAPIHono()
159
159
  .openapi(listRoute, async c => {
160
- const { status, customerName, limit, offset } = c.req.valid('query')
160
+ const { status, customerName, page, limit } = c.req.valid('query')
161
161
  const result = await orderService.getOrders({
162
162
  status: status ?? undefined,
163
163
  customerName: customerName ?? undefined,
164
+ page,
165
+ limit,
164
166
  })
165
- return c.json(success(result.slice(offset, offset + limit)), 200)
167
+ return c.json(success(result), 200)
166
168
  })
167
169
  .openapi(getRoute, async c => {
168
170
  const { id } = c.req.valid('param')
@@ -200,3 +202,6 @@ export const orderRoutes = new OpenAPIHono()
200
202
  if (!result) throw new NotFoundError('Order', id)
201
203
  return c.json(success(result), 200)
202
204
  })
205
+
206
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
207
+ export type OrdersApiType = typeof orderRoutes
@@ -65,3 +65,6 @@ const getOrdersRoute = createRoute({
65
65
  export const ordersMockRoutes = new OpenAPIHono().openapi(getOrdersRoute, async c => {
66
66
  return c.json({ success: true as const, data: MOCK_ORDERS })
67
67
  })
68
+
69
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
70
+ export type OrdersMockApiType = typeof ordersMockRoutes