create-fullstack-scaffold 0.4.24 → 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.
- package/dist/cli/index.js +439 -15
- package/dist/cli/index.js.map +1 -1
- package/package.json +10 -13
- package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
- package/template/eslint-rules/no-cross-module-service-import.js +4 -0
- package/template/eslint-rules/no-merged-api-type-export.js +190 -0
- package/template/eslint.config.js +3 -0
- package/template/package.json +9 -6
- package/template/scripts/sync-agent-hooks.mjs +189 -0
- package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
- package/template/src/admin/pages/ContentPage.tsx +1 -1
- package/template/src/admin/pages/DisputesPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +1 -1
- package/template/src/admin/pages/TicketsPage.tsx +1 -1
- package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
- package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
- package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
- package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
- package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
- package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
- package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
- package/template/src/admin/services/apiClient.ts +2 -3
- package/template/src/cli/modules/content/index.ts +3 -3
- package/template/src/cli/modules/dispute/index.ts +3 -3
- package/template/src/cli/modules/ticket/index.ts +3 -3
- package/template/src/cli/modules/todo/index.ts +1 -1
- package/template/src/cli/rpc/client.ts +3 -4
- package/template/src/cli/rpc/index.ts +1 -1
- package/template/src/client/App.tsx +3 -39
- package/template/src/client/AppRoutes.tsx +53 -0
- package/template/src/client/entry-server.tsx +75 -0
- package/template/src/client/main.tsx +3 -1
- package/template/src/client/pages/SearchPage.tsx +1 -1
- package/template/src/client/services/apiClient.ts +12 -4
- package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
- package/template/src/client/stores/entry-stores.ts +36 -0
- package/template/src/client/stores/notificationStore.ts +4 -4
- package/template/src/client/stores/todoStore.ts +2 -2
- package/template/src/merchant/pages/DisputesPage.tsx +3 -3
- package/template/src/merchant/pages/OrdersPage.tsx +1 -1
- package/template/src/merchant/pages/ProductsPage.tsx +1 -1
- package/template/src/merchant/pages/SettingsPage.tsx +1 -1
- package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
- package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
- package/template/src/server/app.ts +6 -4
- package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
- package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
- package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
- package/template/src/server/core/isr-cache.ts +6 -12
- package/template/src/server/core/isr-invalidation.ts +6 -12
- package/template/src/server/core/isr-registry.ts +104 -0
- package/template/src/server/core/isr-renderer.ts +75 -0
- package/template/src/server/db/schema/contents.ts +28 -20
- package/template/src/server/db/schema/disputes.ts +30 -22
- package/template/src/server/db/schema/notifications.ts +20 -14
- package/template/src/server/db/schema/orders.ts +23 -16
- package/template/src/server/db/schema/plugins.ts +56 -38
- package/template/src/server/db/schema/products.ts +24 -17
- package/template/src/server/db/schema/tickets.ts +44 -31
- package/template/src/server/db/schema/todos.ts +26 -18
- package/template/src/server/entries/cloudflare.ts +82 -19
- package/template/src/server/entries/node.ts +0 -2
- package/template/src/server/index.ts +0 -1
- package/template/src/server/isr-modules.ts +10 -0
- package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
- package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
- package/template/src/server/module-admin/services/admin-service.ts +57 -13
- package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
- package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
- package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
- package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
- package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
- package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
- package/template/src/server/module-content/isr.ts +63 -0
- package/template/src/server/module-content/routes/content-routes.ts +10 -10
- package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
- package/template/src/server/module-content/routes/topics-routes.ts +3 -0
- package/template/src/server/module-content/services/content-service.ts +23 -10
- package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
- package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
- package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
- package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
- package/template/src/server/module-file/routes/file-routes.ts +3 -0
- package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
- package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
- package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
- package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
- package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
- package/template/src/server/module-order/routes/cart-routes.ts +3 -0
- package/template/src/server/module-order/routes/order-routes.ts +9 -4
- package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
- package/template/src/server/module-order/services/order-service.ts +25 -13
- package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
- package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
- package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
- package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
- package/template/src/server/module-permission/routes/role-routes.ts +3 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
- package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
- package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
- package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
- package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
- package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
- package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
- package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
- package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
- package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
- package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
- package/template/src/server/module-todos/isr.ts +41 -0
- package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
- package/template/src/server/module-todos/services/todo-service.ts +31 -10
- package/template/src/server/route-registry.ts +0 -4
- package/template/src/server/rpc-merge.ts +27 -0
- package/template/src/server/rpc-surface.ts +117 -0
- package/template/src/server/rpc-type-canary.ts +80 -0
- package/template/src/server/test-utils/test-client.ts +7 -9
- package/template/src/server/test-utils/test-isr-helper.ts +140 -0
- package/template/src/shared/modules/content/schemas.ts +14 -0
- package/template/src/shared/modules/dispute/schemas.ts +14 -0
- package/template/src/shared/modules/order/schemas.ts +9 -1
- package/template/src/shared/modules/ticket/schemas.ts +14 -0
- package/template/src/shared/modules/todos/index.ts +4 -0
- package/template/src/shared/modules/todos/schemas.ts +14 -0
- package/template/src/shared/schemas/index.ts +18 -0
- package/template/tsup.config.ts +48 -1
- package/template/vitest.config.ts +19 -3
- package/template/vitest.setup.ts +68 -5
- package/template/drizzle/0000_rainy_boomer.sql +0 -101
- package/template/drizzle/0001_add_todo_attachments.sql +0 -12
- package/template/drizzle/0002_chilly_magneto.sql +0 -89
- package/template/drizzle/0003_ambiguous_magdalene.sql +0 -100
- package/template/drizzle/0004_add_merchants_products.sql +0 -30
- package/template/drizzle/meta/0000_snapshot.json +0 -652
- package/template/drizzle/meta/0001_snapshot.json +0 -735
- package/template/drizzle/meta/0002_snapshot.json +0 -1198
- package/template/drizzle/meta/0003_snapshot.json +0 -1837
- package/template/drizzle/meta/_journal.json +0 -41
- package/template/patches/typescript+5.9.3.patch +0 -24
- package/template/pnpm-lock.yaml +0 -7137
- /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
|
@@ -165,10 +165,14 @@ export const apiRoutes = new OpenAPIHono()
|
|
|
165
165
|
if (!tenant) throw new NotFoundError('Tenant context')
|
|
166
166
|
return c.json(success(tenant), 200)
|
|
167
167
|
})
|
|
168
|
-
|
|
168
|
+
// todos 模块已占用 /docs;tenant 的 OpenAPI 文档挂独立路径避免 shadow
|
|
169
|
+
.doc('/tenant/docs', {
|
|
169
170
|
openapi: '3.0.0',
|
|
170
171
|
info: {
|
|
171
172
|
version: '1.0.0',
|
|
172
173
|
title: 'Tenant Management API',
|
|
173
174
|
},
|
|
174
175
|
})
|
|
176
|
+
|
|
177
|
+
/** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
|
|
178
|
+
export type TenantApiType = typeof apiRoutes
|
|
@@ -22,7 +22,8 @@ describe('Ticket Routes', () => {
|
|
|
22
22
|
const data = await res.json()
|
|
23
23
|
expect(data.success).toBe(true)
|
|
24
24
|
if (data.success) {
|
|
25
|
-
expect(
|
|
25
|
+
expect(data.data.tickets).toBeDefined()
|
|
26
|
+
expect(typeof data.data.total).toBe('number')
|
|
26
27
|
}
|
|
27
28
|
})
|
|
28
29
|
})
|
|
@@ -17,7 +17,8 @@ describe('Ticket Service', () => {
|
|
|
17
17
|
describe('getTickets', () => {
|
|
18
18
|
it('should return all tickets', async () => {
|
|
19
19
|
const result = await service.getTickets()
|
|
20
|
-
expect(
|
|
20
|
+
expect(result.tickets).toBeDefined()
|
|
21
|
+
expect(typeof result.total).toBe('number')
|
|
21
22
|
})
|
|
22
23
|
|
|
23
24
|
it('should filter tickets by status', async () => {
|
|
@@ -33,8 +34,8 @@ describe('Ticket Service', () => {
|
|
|
33
34
|
createdTicketIds.push(created.id)
|
|
34
35
|
|
|
35
36
|
const result = await service.getTickets({ status: 'open' })
|
|
36
|
-
expect(Array.isArray(result)).toBe(true)
|
|
37
|
-
result.forEach(ticket => {
|
|
37
|
+
expect(Array.isArray(result.tickets)).toBe(true)
|
|
38
|
+
result.tickets.forEach(ticket => {
|
|
38
39
|
expect(ticket.status).toBe('open')
|
|
39
40
|
})
|
|
40
41
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRoute
|
|
1
|
+
import { createRoute } from '@hono/zod-openapi'
|
|
2
2
|
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
3
|
import * as ticketService from '../services/ticket-service'
|
|
4
4
|
import { successResponse, errorResponse, success, created } from '@server/utils/route-helpers'
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
TicketSchema,
|
|
10
10
|
CreateTicketSchema,
|
|
11
11
|
UpdateTicketSchema,
|
|
12
|
-
|
|
12
|
+
TicketListResponseSchema,
|
|
13
|
+
TicketListQuerySchema,
|
|
13
14
|
TicketDeleteResultSchema,
|
|
14
15
|
ReplyTicketSchema,
|
|
15
16
|
} from '@shared/modules/ticket'
|
|
@@ -21,13 +22,10 @@ const listRoute = createRoute({
|
|
|
21
22
|
security: [{ Bearer: [] }],
|
|
22
23
|
middleware: [authMiddleware({ requiredPermissions: [Permission.TICKET_VIEW] })],
|
|
23
24
|
request: {
|
|
24
|
-
query:
|
|
25
|
-
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
26
|
-
offset: z.coerce.number().int().min(0).default(0),
|
|
27
|
-
}),
|
|
25
|
+
query: TicketListQuerySchema,
|
|
28
26
|
},
|
|
29
27
|
responses: {
|
|
30
|
-
200: successResponse(
|
|
28
|
+
200: successResponse(TicketListResponseSchema, 'List all tickets'),
|
|
31
29
|
401: errorResponse('Unauthorized'),
|
|
32
30
|
403: errorResponse('Forbidden'),
|
|
33
31
|
500: errorResponse('Internal server error'),
|
|
@@ -167,9 +165,9 @@ const closeRoute = createRoute({
|
|
|
167
165
|
|
|
168
166
|
export const ticketRoutes = new OpenAPIHono()
|
|
169
167
|
.openapi(listRoute, async c => {
|
|
170
|
-
const {
|
|
171
|
-
const result = await ticketService.getTickets()
|
|
172
|
-
return c.json(success(result
|
|
168
|
+
const { page, limit } = c.req.valid('query')
|
|
169
|
+
const result = await ticketService.getTickets({ page, limit })
|
|
170
|
+
return c.json(success(result), 200)
|
|
173
171
|
})
|
|
174
172
|
.openapi(getRoute, async c => {
|
|
175
173
|
const { id } = c.req.valid('param')
|
|
@@ -208,3 +206,6 @@ export const ticketRoutes = new OpenAPIHono()
|
|
|
208
206
|
if (!result) throw new NotFoundError('Ticket', id)
|
|
209
207
|
return c.json(success(result), 200)
|
|
210
208
|
})
|
|
209
|
+
|
|
210
|
+
/** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
|
|
211
|
+
export type TicketsApiType = typeof ticketRoutes
|
|
@@ -154,8 +154,13 @@ export async function getTickets(filters?: {
|
|
|
154
154
|
status?: TicketStatus
|
|
155
155
|
priority?: TicketPriority
|
|
156
156
|
category?: TicketCategory
|
|
157
|
-
|
|
157
|
+
page?: number
|
|
158
|
+
limit?: number
|
|
159
|
+
}): Promise<{ tickets: Ticket[]; total: number; page: number; limit: number }> {
|
|
158
160
|
const db = await getDb()
|
|
161
|
+
const page = filters?.page ?? 1
|
|
162
|
+
const limit = filters?.limit ?? 20
|
|
163
|
+
const offset = (page - 1) * limit
|
|
159
164
|
const conditions = []
|
|
160
165
|
|
|
161
166
|
if (filters?.status) {
|
|
@@ -169,7 +174,7 @@ export async function getTickets(filters?: {
|
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 }
|
|
172
|
-
const
|
|
177
|
+
const allRows =
|
|
173
178
|
conditions.length > 0
|
|
174
179
|
? await db
|
|
175
180
|
.select()
|
|
@@ -177,14 +182,20 @@ export async function getTickets(filters?: {
|
|
|
177
182
|
.where(and(...conditions))
|
|
178
183
|
: await db.select().from(tickets)
|
|
179
184
|
|
|
180
|
-
const sorted =
|
|
185
|
+
const sorted = allRows.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
|
186
|
+
const rows = sorted.slice(offset, offset + limit)
|
|
181
187
|
|
|
182
188
|
const result: Ticket[] = []
|
|
183
|
-
for (const row of
|
|
189
|
+
for (const row of rows) {
|
|
184
190
|
result.push(await loadTicketWithReplies(row))
|
|
185
191
|
}
|
|
186
192
|
|
|
187
|
-
return
|
|
193
|
+
return {
|
|
194
|
+
tickets: result,
|
|
195
|
+
total: sorted.length,
|
|
196
|
+
page,
|
|
197
|
+
limit,
|
|
198
|
+
}
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
export async function getTicketById(id: string): Promise<Ticket | null> {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Mock the todo service BEFORE importing isr.ts
|
|
4
|
+
vi.mock('@server/module-todos/services/todo-service', () => ({
|
|
5
|
+
listTodos: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import { listTodos } from '@server/module-todos/services/todo-service'
|
|
9
|
+
import type { MockTodo } from '@server/test-utils/test-isr-helper'
|
|
10
|
+
|
|
11
|
+
// Side-effect: register ISR routes into global registry
|
|
12
|
+
import '@server/module-todos/isr'
|
|
13
|
+
|
|
14
|
+
const mockTodosData: MockTodo[] = [
|
|
15
|
+
{
|
|
16
|
+
id: 1,
|
|
17
|
+
title: 'Task A',
|
|
18
|
+
status: 'completed',
|
|
19
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
20
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 2,
|
|
24
|
+
title: 'Task B',
|
|
25
|
+
status: 'pending',
|
|
26
|
+
createdAt: '2024-01-02T00:00:00.000Z',
|
|
27
|
+
updatedAt: '2024-01-02T00:00:00.000Z',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 3,
|
|
31
|
+
title: 'Task C',
|
|
32
|
+
status: 'in_progress',
|
|
33
|
+
createdAt: '2024-01-03T00:00:00.000Z',
|
|
34
|
+
updatedAt: '2024-01-03T00:00:00.000Z',
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
describe('Todos Module ISR', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('route registration', () => {
|
|
44
|
+
it('should register /todos route', async () => {
|
|
45
|
+
vi.mocked(listTodos).mockResolvedValue({ todos: mockTodosData, total: 3, page: 1, limit: 20 })
|
|
46
|
+
|
|
47
|
+
// Dynamic import to trigger registration into fresh registry
|
|
48
|
+
// Since isr.ts registers into the global registry, we test the global one
|
|
49
|
+
const { isrRegistry } = await import('@server/core/isr-registry')
|
|
50
|
+
// The global registry already has todos from the side-effect import
|
|
51
|
+
expect(isrRegistry.match('/todos')).not.toBeNull()
|
|
52
|
+
expect(isrRegistry.match('/todos')!.module).toBe('todos')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should register / route', async () => {
|
|
56
|
+
const { isrRegistry } = await import('@server/core/isr-registry')
|
|
57
|
+
const entry = isrRegistry.match('/')
|
|
58
|
+
expect(entry).not.toBeNull()
|
|
59
|
+
expect(entry!.module).toBe('todos')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('data fetching', () => {
|
|
64
|
+
it('should fetch todos from service', async () => {
|
|
65
|
+
vi.mocked(listTodos).mockResolvedValue({ todos: mockTodosData, total: 3, page: 1, limit: 20 })
|
|
66
|
+
|
|
67
|
+
const { isrRegistry } = await import('@server/core/isr-registry')
|
|
68
|
+
const entry = isrRegistry.match('/todos')!
|
|
69
|
+
const data = (await entry.fetch('/todos', {})) as { todos: MockTodo[] }
|
|
70
|
+
|
|
71
|
+
expect(data.todos).toHaveLength(3)
|
|
72
|
+
expect(data.todos[0].title).toBe('Task A')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should handle empty todos', async () => {
|
|
76
|
+
vi.mocked(listTodos).mockResolvedValue({ todos: [], total: 0, page: 1, limit: 20 })
|
|
77
|
+
|
|
78
|
+
const { isrRegistry } = await import('@server/core/isr-registry')
|
|
79
|
+
const entry = isrRegistry.match('/todos')!
|
|
80
|
+
const data = (await entry.fetch('/todos', {})) as { todos: MockTodo[] }
|
|
81
|
+
|
|
82
|
+
expect(data.todos).toHaveLength(0)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should handle DB error gracefully', async () => {
|
|
86
|
+
vi.mocked(listTodos).mockRejectedValue(new Error('DB connection failed'))
|
|
87
|
+
|
|
88
|
+
const { isrRegistry } = await import('@server/core/isr-registry')
|
|
89
|
+
const entry = isrRegistry.match('/todos')!
|
|
90
|
+
|
|
91
|
+
await expect(entry.fetch('/todos', {})).rejects.toThrow('DB connection failed')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('meta tags', () => {
|
|
96
|
+
it('should return correct meta', async () => {
|
|
97
|
+
const { isrRegistry } = await import('@server/core/isr-registry')
|
|
98
|
+
const entry = isrRegistry.match('/todos')!
|
|
99
|
+
const meta = entry.meta({}, '/todos')
|
|
100
|
+
|
|
101
|
+
expect(meta.title).toContain('Todo List')
|
|
102
|
+
expect(meta.description).toContain('todos')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -33,9 +33,12 @@ describe('Todo Service', () => {
|
|
|
33
33
|
it('should return empty array when no todos exist', async () => {
|
|
34
34
|
const result = await todoService.listTodos()
|
|
35
35
|
|
|
36
|
-
expect(result).toEqual([])
|
|
37
|
-
expect(Array.isArray(result)).toBe(true)
|
|
38
|
-
expect(result.length).toBe(0)
|
|
36
|
+
expect(result.todos).toEqual([])
|
|
37
|
+
expect(Array.isArray(result.todos)).toBe(true)
|
|
38
|
+
expect(result.todos.length).toBe(0)
|
|
39
|
+
expect(result.total).toBe(0)
|
|
40
|
+
expect(result.page).toBe(1)
|
|
41
|
+
expect(result.limit).toBe(20)
|
|
39
42
|
})
|
|
40
43
|
|
|
41
44
|
it('should return all todos ordered by created_at DESC', async () => {
|
|
@@ -55,11 +58,11 @@ describe('Todo Service', () => {
|
|
|
55
58
|
|
|
56
59
|
const result = await todoService.listTodos()
|
|
57
60
|
|
|
58
|
-
expect(result).toHaveLength(2)
|
|
59
|
-
expect(result[0].title).toBe('Todo 2')
|
|
60
|
-
expect(result[0].status).toBe('completed')
|
|
61
|
-
expect(result[1].title).toBe('Todo 1')
|
|
62
|
-
expect(result[1].status).toBe('pending')
|
|
61
|
+
expect(result.todos).toHaveLength(2)
|
|
62
|
+
expect(result.todos[0].title).toBe('Todo 2')
|
|
63
|
+
expect(result.todos[0].status).toBe('completed')
|
|
64
|
+
expect(result.todos[1].title).toBe('Todo 1')
|
|
65
|
+
expect(result.todos[1].status).toBe('pending')
|
|
63
66
|
})
|
|
64
67
|
})
|
|
65
68
|
|
|
@@ -183,7 +186,7 @@ describe('Todo Service', () => {
|
|
|
183
186
|
expect(checkResult.rows.length).toBe(0)
|
|
184
187
|
|
|
185
188
|
const allTodos = await todoService.listTodos()
|
|
186
|
-
expect(allTodos.
|
|
189
|
+
expect(allTodos.total).toBe(0)
|
|
187
190
|
}
|
|
188
191
|
})
|
|
189
192
|
|
|
@@ -438,13 +438,13 @@ describe('Todo Routes - Business Logic Tests', () => {
|
|
|
438
438
|
await createMultipleTodos(3)
|
|
439
439
|
const client = createTestClient(undefined, { headers: authHeaders })
|
|
440
440
|
|
|
441
|
-
const res = await client.api.todos.$get({
|
|
441
|
+
const res = await client.api.todos.$get({ query: {} })
|
|
442
442
|
const data = await res.json()
|
|
443
443
|
|
|
444
444
|
expect(data.success).toBe(true)
|
|
445
|
-
if (data.success &&
|
|
446
|
-
expect(data.data).toHaveLength(3)
|
|
447
|
-
const timestamps = data.data.map((t: { createdAt: string }) =>
|
|
445
|
+
if (data.success && 'data' in data) {
|
|
446
|
+
expect(data.data.todos).toHaveLength(3)
|
|
447
|
+
const timestamps = data.data.todos.map((t: { createdAt: string }) =>
|
|
448
448
|
new Date(t.createdAt).getTime()
|
|
449
449
|
)
|
|
450
450
|
for (let i = 0; i < timestamps.length - 1; i++) {
|
|
@@ -456,12 +456,12 @@ describe('Todo Routes - Business Logic Tests', () => {
|
|
|
456
456
|
it('should return empty array when no todos exist', async () => {
|
|
457
457
|
const client = createTestClient(undefined, { headers: authHeaders })
|
|
458
458
|
|
|
459
|
-
const res = await client.api.todos.$get({
|
|
459
|
+
const res = await client.api.todos.$get({ query: {} })
|
|
460
460
|
const data = await res.json()
|
|
461
461
|
|
|
462
462
|
expect(data.success).toBe(true)
|
|
463
463
|
if (data.success) {
|
|
464
|
-
expect(data.data).toEqual([])
|
|
464
|
+
expect(data.data.todos).toEqual([])
|
|
465
465
|
}
|
|
466
466
|
})
|
|
467
467
|
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todos 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 { listTodos } from './services/todo-service'
|
|
9
|
+
|
|
10
|
+
interface TodoData {
|
|
11
|
+
todos: Array<{ id: number; title: string; status: string }>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchTodos(_pathname: string): Promise<TodoData> {
|
|
15
|
+
const { todos } = await listTodos({ limit: 20 })
|
|
16
|
+
return { todos }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function todoMeta(): { title: string; description: string } {
|
|
20
|
+
return {
|
|
21
|
+
title: 'Todo List - Biomimic App',
|
|
22
|
+
description: 'Manage your todos with real-time updates',
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const todosEntries: ISRRouteEntry[] = [
|
|
27
|
+
{
|
|
28
|
+
module: 'todos',
|
|
29
|
+
match: '/todos',
|
|
30
|
+
fetch: fetchTodos,
|
|
31
|
+
meta: () => todoMeta(),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
module: 'todos',
|
|
35
|
+
match: '/',
|
|
36
|
+
fetch: fetchTodos,
|
|
37
|
+
meta: () => todoMeta(),
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
isrRegistry.registerMany(todosEntries)
|
|
@@ -11,20 +11,23 @@ import {
|
|
|
11
11
|
TodoWithAttachmentsSchema,
|
|
12
12
|
UploadFileSchema,
|
|
13
13
|
AttachmentIdResponseSchema,
|
|
14
|
+
TodoListResponseSchema,
|
|
15
|
+
TodoListQuerySchema,
|
|
14
16
|
} from '@shared/schemas'
|
|
15
17
|
import { successResponse, errorResponse, success, created } from '@server/utils/route-helpers'
|
|
16
18
|
import { getAuthUser } from '@server/utils/auth'
|
|
17
19
|
import { NotFoundError, ValidationError } from '@server/utils/app-error'
|
|
18
20
|
import { authMiddleware } from '@server/middleware/auth'
|
|
19
21
|
|
|
20
|
-
const TodoListSchema = z.array(TodoSchema)
|
|
21
|
-
|
|
22
22
|
const listRoute = createRoute({
|
|
23
23
|
method: 'get',
|
|
24
24
|
path: '/todos',
|
|
25
25
|
tags: ['todos'],
|
|
26
|
+
request: {
|
|
27
|
+
query: TodoListQuerySchema,
|
|
28
|
+
},
|
|
26
29
|
responses: {
|
|
27
|
-
200: successResponse(
|
|
30
|
+
200: successResponse(TodoListResponseSchema, 'List all todos'),
|
|
28
31
|
500: errorResponse('Internal server error'),
|
|
29
32
|
},
|
|
30
33
|
})
|
|
@@ -166,8 +169,9 @@ const deleteAttachmentRoute = createRoute({
|
|
|
166
169
|
|
|
167
170
|
export const apiRoutes = new OpenAPIHono()
|
|
168
171
|
.openapi(listRoute, async c => {
|
|
169
|
-
const
|
|
170
|
-
|
|
172
|
+
const { page, limit } = c.req.valid('query')
|
|
173
|
+
const result = await todoService.listTodos({ page, limit })
|
|
174
|
+
return c.json(success(result), 200)
|
|
171
175
|
})
|
|
172
176
|
.openapi(getRoute, async c => {
|
|
173
177
|
const { id } = c.req.valid('param')
|
|
@@ -254,3 +258,6 @@ export const apiRoutes = new OpenAPIHono()
|
|
|
254
258
|
title: 'Todo API',
|
|
255
259
|
},
|
|
256
260
|
})
|
|
261
|
+
|
|
262
|
+
/** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
|
|
263
|
+
export type TodosApiType = typeof apiRoutes
|
|
@@ -58,17 +58,38 @@ export async function seedTodosIfEmpty(): Promise<void> {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export async function listTodos(
|
|
61
|
+
export async function listTodos(options?: {
|
|
62
|
+
page?: number
|
|
63
|
+
limit?: number
|
|
64
|
+
}): Promise<{ todos: Todo[]; total: number; page: number; limit: number }> {
|
|
62
65
|
const db = await getDb()
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
const page = options?.page ?? 1
|
|
67
|
+
const limit = options?.limit ?? 20
|
|
68
|
+
const offset = (page - 1) * limit
|
|
69
|
+
|
|
70
|
+
// SQL 分页(全表扫 + 内存 slice 在数据量增长后是性能反模式)
|
|
71
|
+
const rows = await db
|
|
72
|
+
.select()
|
|
73
|
+
.from(todos)
|
|
74
|
+
.orderBy(desc(todos.createdAt))
|
|
75
|
+
.limit(limit)
|
|
76
|
+
.offset(offset)
|
|
77
|
+
// db 为 LibSQL|D1 联合类型:select(config) 会重载坍缩,$count 两驱动同签名
|
|
78
|
+
const total = await db.$count(todos)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
todos: rows.map((row: TodoTable) => ({
|
|
82
|
+
id: row.id,
|
|
83
|
+
title: row.title,
|
|
84
|
+
description: row.description ?? undefined,
|
|
85
|
+
status: row.status,
|
|
86
|
+
createdAt: toISOString(row.createdAt),
|
|
87
|
+
updatedAt: toISOString(row.updatedAt),
|
|
88
|
+
})),
|
|
89
|
+
total,
|
|
90
|
+
page,
|
|
91
|
+
limit,
|
|
92
|
+
}
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
export async function getTodo(id: number): Promise<Todo | null> {
|
|
@@ -60,7 +60,3 @@ export const adminApiRoutes = new OpenAPIHono()
|
|
|
60
60
|
.route('/api', pluginAdminRoutes)
|
|
61
61
|
.route('/api', dashboardRoutes)
|
|
62
62
|
.route('/api', tenantRoutes)
|
|
63
|
-
|
|
64
|
-
// 导出类型
|
|
65
|
-
export type ClientApiRoutes = typeof clientApiRoutes
|
|
66
|
-
export type AdminApiRoutes = typeof adminApiRoutes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC 客户端对象合并(get-fallback Proxy)
|
|
3
|
+
*
|
|
4
|
+
* 用途:当同一路径段(如 'admin')由多个模块路由文件共同提供时,
|
|
5
|
+
* 把两个窄客户端的子对象合并成一个类型交集,避免在类型层面重新
|
|
6
|
+
* 链式 merge 触发 TS2589。
|
|
7
|
+
*
|
|
8
|
+
* 语义:primary 优先,secondary 兜底。两个来源的子路径约定互不重叠
|
|
9
|
+
* (重叠时取 primary 的),因此 get-fallback 足够且无需枚举键。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function mergeRpcObjects<T extends object, U extends object>(
|
|
13
|
+
primary: T,
|
|
14
|
+
secondary: U
|
|
15
|
+
): T & U {
|
|
16
|
+
return new Proxy({} as T & U, {
|
|
17
|
+
get(_target, prop) {
|
|
18
|
+
if (prop in primary) {
|
|
19
|
+
return Reflect.get(primary as object, prop, primary)
|
|
20
|
+
}
|
|
21
|
+
return Reflect.get(secondary as object, prop, secondary)
|
|
22
|
+
},
|
|
23
|
+
has(_target, prop) {
|
|
24
|
+
return prop in primary || prop in secondary
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline rpc-surface-v1
|
|
3
|
+
*
|
|
4
|
+
* 按模块拆分的类型安全 RPC 门面(generator 管理文件)。
|
|
5
|
+
*
|
|
6
|
+
* 背景:此前客户端通过 `hc<ClientApiType>`(route-registry 里 11-13 个模块
|
|
7
|
+
* 链式 merge 后的类型)调用 API,类型实例化深度随模块数线性增长,触发
|
|
8
|
+
* TS2589,历史上甚至 patch 过 TypeScript 编译器(已被 no-ts-patch validator
|
|
9
|
+
* 禁止)。
|
|
10
|
+
*
|
|
11
|
+
* 本文件是替代方案:每个模块单独实例化一个窄客户端(深度 = 1 个模块),
|
|
12
|
+
* 组装成与旧 `apiClient.api.<segment>` 完全同形的门面对象。调用方
|
|
13
|
+
* (client / admin / test-utils / CLI)全部通过 createApiFacade() 获取实例,
|
|
14
|
+
* 运行时 URL 与旧实现一字不差(baseUrl + '/api' + 模块内部路径)。
|
|
15
|
+
*
|
|
16
|
+
* preset 裁剪时由 CLI generator 重新生成本文件(见 src/generators/rpc-surface.ts),
|
|
17
|
+
* 只包含所选模块的导入与门面条目。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { hc } from 'hono/client'
|
|
21
|
+
import { mergeRpcObjects } from './rpc-merge'
|
|
22
|
+
import type { AuthApiType } from './module-auth/routes/auth-routes'
|
|
23
|
+
import type { ChatApiType } from './module-chat/routes/chat-routes'
|
|
24
|
+
import type { NotificationsApiType } from './module-notifications/routes/notification-routes'
|
|
25
|
+
import type { TodosApiType } from './module-todos/routes/todos-routes'
|
|
26
|
+
import type { PluginsApiType } from './module-plugin/routes/plugin-routes'
|
|
27
|
+
import type { PublicContentApiType } from './module-content/routes/public-content-routes'
|
|
28
|
+
import type { CartApiType } from './module-order/routes/cart-routes'
|
|
29
|
+
import type { OrdersMockApiType } from './module-order/routes/orders-mock-routes'
|
|
30
|
+
import type { TopicsApiType } from './module-content/routes/topics-routes'
|
|
31
|
+
import type { MerchantApiType } from './module-merchant/routes/merchant-routes'
|
|
32
|
+
import type { OrdersApiType } from './module-order/routes/order-routes'
|
|
33
|
+
import type { TicketsApiType } from './module-ticket/routes/ticket-routes'
|
|
34
|
+
import type { DisputesApiType } from './module-dispute/routes/dispute-routes'
|
|
35
|
+
import type { ContentsApiType } from './module-content/routes/content-routes'
|
|
36
|
+
import type { CaptchaApiType } from './module-captcha/routes/captcha-routes'
|
|
37
|
+
import type { PermissionsApiType } from './module-permission/routes/permission-routes'
|
|
38
|
+
import type { RolesApiType } from './module-permission/routes/role-routes'
|
|
39
|
+
import type { AuditLogsApiType } from './module-permission/routes/audit-log-routes'
|
|
40
|
+
import type { AdminRoutesApiType } from './module-admin/routes/admin-routes'
|
|
41
|
+
import type { DashboardApiType } from './module-admin/routes/dashboard-routes'
|
|
42
|
+
import type { TenantApiType } from './module-tenant/routes/tenant-routes'
|
|
43
|
+
import type { PluginAdminApiType } from './module-plugin/routes/plugin-admin-routes'
|
|
44
|
+
import type { FilesApiType } from './module-file/routes/file-routes'
|
|
45
|
+
|
|
46
|
+
/** hono hc 的选项类型(fetch / webSocket / sse / headers 等) */
|
|
47
|
+
export type RpcClientOptions = NonNullable<Parameters<typeof hc>[1]>
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 创建按模块拆分的 RPC 门面。
|
|
51
|
+
* 返回对象与旧 `hc<MergedApiType>(baseUrl)` 的调用形态完全兼容:
|
|
52
|
+
* `facade.api.todos.$get()` 等价于旧的 `apiClient.api.todos.$get()`。
|
|
53
|
+
*/
|
|
54
|
+
export function createApiFacade(baseUrl: string, options: RpcClientOptions = {}) {
|
|
55
|
+
const api = `${baseUrl.replace(/\/$/, '')}/api`
|
|
56
|
+
|
|
57
|
+
const authClient = hc<AuthApiType>(api, options)
|
|
58
|
+
const chatClient = hc<ChatApiType>(api, options)
|
|
59
|
+
const notificationsClient = hc<NotificationsApiType>(api, options)
|
|
60
|
+
const todosClient = hc<TodosApiType>(api, options)
|
|
61
|
+
const pluginsClient = hc<PluginsApiType>(api, options)
|
|
62
|
+
const publicContentClient = hc<PublicContentApiType>(api, options)
|
|
63
|
+
const cartClient = hc<CartApiType>(api, options)
|
|
64
|
+
const ordersMockClient = hc<OrdersMockApiType>(api, options)
|
|
65
|
+
const topicsClient = hc<TopicsApiType>(api, options)
|
|
66
|
+
const merchantClient = hc<MerchantApiType>(api, options)
|
|
67
|
+
const ordersClient = hc<OrdersApiType>(api, options)
|
|
68
|
+
const ticketsClient = hc<TicketsApiType>(api, options)
|
|
69
|
+
const disputesClient = hc<DisputesApiType>(api, options)
|
|
70
|
+
const contentsClient = hc<ContentsApiType>(api, options)
|
|
71
|
+
const captchaClient = hc<CaptchaApiType>(api, options)
|
|
72
|
+
const permissionsClient = hc<PermissionsApiType>(api, options)
|
|
73
|
+
const rolesClient = hc<RolesApiType>(api, options)
|
|
74
|
+
const auditLogsClient = hc<AuditLogsApiType>(api, options)
|
|
75
|
+
const adminRoutesClient = hc<AdminRoutesApiType>(api, options)
|
|
76
|
+
const dashboardClient = hc<DashboardApiType>(api, options)
|
|
77
|
+
const tenantClient = hc<TenantApiType>(api, options)
|
|
78
|
+
const pluginAdminClient = hc<PluginAdminApiType>(api, options)
|
|
79
|
+
const filesClient = hc<FilesApiType>(api, options)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
api: {
|
|
83
|
+
auth: authClient.auth,
|
|
84
|
+
chat: chatClient.chat,
|
|
85
|
+
notifications: notificationsClient.notifications,
|
|
86
|
+
todos: todosClient.todos,
|
|
87
|
+
plugins: mergeRpcObjects(pluginsClient.plugins, pluginAdminClient.plugins),
|
|
88
|
+
categories: mergeRpcObjects(pluginsClient.categories, pluginAdminClient.categories),
|
|
89
|
+
stats: mergeRpcObjects(pluginsClient.stats, pluginAdminClient.stats),
|
|
90
|
+
public: mergeRpcObjects(publicContentClient.public, filesClient.public),
|
|
91
|
+
'generate-url': filesClient['generate-url'],
|
|
92
|
+
upload: filesClient['upload'],
|
|
93
|
+
private: filesClient['private'],
|
|
94
|
+
cart: cartClient.cart,
|
|
95
|
+
'orders-mock': ordersMockClient['orders-mock'],
|
|
96
|
+
profile: topicsClient.profile,
|
|
97
|
+
topics: topicsClient.topics,
|
|
98
|
+
merchant: merchantClient.merchant,
|
|
99
|
+
orders: ordersClient.orders,
|
|
100
|
+
tickets: ticketsClient.tickets,
|
|
101
|
+
disputes: disputesClient.disputes,
|
|
102
|
+
contents: contentsClient.contents,
|
|
103
|
+
captcha: captchaClient.captcha,
|
|
104
|
+
'verify-captcha': captchaClient['verify-captcha'],
|
|
105
|
+
permissions: permissionsClient.permissions,
|
|
106
|
+
roles: rolesClient.roles,
|
|
107
|
+
'audit-logs': auditLogsClient['audit-logs'],
|
|
108
|
+
// 'admin' 段由 dashboard-routes 与 admin-routes(内部再嵌 6 个子路由)共同提供,
|
|
109
|
+
// 用运行时合并避免类型层 merge
|
|
110
|
+
admin: mergeRpcObjects(dashboardClient.admin, adminRoutesClient.admin),
|
|
111
|
+
tenants: tenantClient.tenants,
|
|
112
|
+
tenant: tenantClient.tenant,
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type ApiFacade = ReturnType<typeof createApiFacade>
|