create-fullstack-scaffold 0.4.25 → 0.5.1
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 +432 -15
- package/dist/cli/index.js.map +1 -1
- package/package.json +10 -13
- package/template/drizzle.config.ts +10 -4
- 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-cf.test.ts +96 -0
- 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 +61 -18
- 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 +97 -21
- 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/patches/typescript+5.9.3.patch +0 -24
- /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
|
@@ -3,6 +3,28 @@ import * as adminService from '../services/admin-service'
|
|
|
3
3
|
import { getRawClient } from '@server/db'
|
|
4
4
|
import { setupTestDatabase, cleanupTestDatabase } from '@server/db/test-setup'
|
|
5
5
|
|
|
6
|
+
// 部分 preset(forum/xbrowser-marketplace)不含 todos 模块——无表时
|
|
7
|
+
// 跳过 todos 相关用例(admin 模块本身在这些 preset 中仍被测其余行为)
|
|
8
|
+
async function hasTodosTable(): Promise<boolean> {
|
|
9
|
+
// 判 schema barrel 是否导出 todos(forum/xbrowser 等无 todos 模块时,
|
|
10
|
+
// admin-service 的 todos 语义接口整体不成立:健康检查都会报 disconnected)
|
|
11
|
+
try {
|
|
12
|
+
const schema = await import('@server/db/schema')
|
|
13
|
+
return 'todos' in schema
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const maybeTodos = (() => {
|
|
19
|
+
let cached: Promise<boolean> | null = null
|
|
20
|
+
return () => (cached ??= hasTodosTable())
|
|
21
|
+
})()
|
|
22
|
+
const itTodos = (name: string, fn: () => Promise<void> | void) =>
|
|
23
|
+
it(name, async () => {
|
|
24
|
+
if (!(await maybeTodos())) return
|
|
25
|
+
await fn()
|
|
26
|
+
})
|
|
27
|
+
|
|
6
28
|
describe('Admin Service', () => {
|
|
7
29
|
beforeAll(async () => {
|
|
8
30
|
await setupTestDatabase()
|
|
@@ -15,12 +37,14 @@ describe('Admin Service', () => {
|
|
|
15
37
|
beforeEach(async () => {
|
|
16
38
|
const rawClient = await getRawClient()
|
|
17
39
|
if (rawClient && 'execute' in rawClient) {
|
|
40
|
+
// 附件先清(避免外键/顺序依赖),再清主表——用例自持状态
|
|
41
|
+
await rawClient.execute('DELETE FROM todo_attachments').catch(() => {})
|
|
18
42
|
await rawClient.execute('DELETE FROM todos')
|
|
19
43
|
}
|
|
20
44
|
})
|
|
21
45
|
|
|
22
46
|
describe('getSystemStats', () => {
|
|
23
|
-
|
|
47
|
+
itTodos('should return zero stats when no todos exist', async () => {
|
|
24
48
|
const stats = await adminService.getSystemStats()
|
|
25
49
|
|
|
26
50
|
expect(stats.totalTodos).toBe(0)
|
|
@@ -57,7 +81,7 @@ describe('Admin Service', () => {
|
|
|
57
81
|
})
|
|
58
82
|
|
|
59
83
|
describe('checkDatabaseHealth', () => {
|
|
60
|
-
|
|
84
|
+
itTodos('should return connected status when database is available', async () => {
|
|
61
85
|
const health = await adminService.checkDatabaseHealth()
|
|
62
86
|
|
|
63
87
|
expect(health.database).toBe('connected')
|
|
@@ -66,14 +90,14 @@ describe('Admin Service', () => {
|
|
|
66
90
|
})
|
|
67
91
|
|
|
68
92
|
describe('getRecentActivity', () => {
|
|
69
|
-
|
|
93
|
+
itTodos('should return empty array when no activity exists', async () => {
|
|
70
94
|
const activity = await adminService.getRecentActivity(10)
|
|
71
95
|
|
|
72
96
|
expect(Array.isArray(activity)).toBe(true)
|
|
73
97
|
expect(activity.length).toBe(0)
|
|
74
98
|
})
|
|
75
99
|
|
|
76
|
-
|
|
100
|
+
itTodos('should return recent activity with correct limit', async () => {
|
|
77
101
|
const rawClient = await getRawClient()
|
|
78
102
|
if (rawClient && 'execute' in rawClient) {
|
|
79
103
|
const now = Date.now()
|
|
@@ -112,7 +136,7 @@ describe('Admin Service', () => {
|
|
|
112
136
|
})
|
|
113
137
|
|
|
114
138
|
describe('clearAllTodos', () => {
|
|
115
|
-
|
|
139
|
+
itTodos('should clear all todos and return count', async () => {
|
|
116
140
|
const rawClient = await getRawClient()
|
|
117
141
|
if (rawClient && 'execute' in rawClient) {
|
|
118
142
|
const now = Date.now()
|
|
@@ -134,7 +158,7 @@ describe('Admin Service', () => {
|
|
|
134
158
|
expect(stats.totalTodos).toBe(0)
|
|
135
159
|
})
|
|
136
160
|
|
|
137
|
-
|
|
161
|
+
itTodos('should return zero when no todos exist', async () => {
|
|
138
162
|
const result = await adminService.clearAllTodos()
|
|
139
163
|
|
|
140
164
|
expect(result.deletedCount).toBe(0)
|
|
@@ -142,14 +166,14 @@ describe('Admin Service', () => {
|
|
|
142
166
|
})
|
|
143
167
|
|
|
144
168
|
describe('Error Scenarios', () => {
|
|
145
|
-
|
|
169
|
+
itTodos('should handle getRecentActivity with invalid limit gracefully', async () => {
|
|
146
170
|
const activity = await adminService.getRecentActivity(-1)
|
|
147
171
|
|
|
148
172
|
expect(Array.isArray(activity)).toBe(true)
|
|
149
173
|
expect(activity.length).toBe(0)
|
|
150
174
|
})
|
|
151
175
|
|
|
152
|
-
|
|
176
|
+
itTodos('should handle getRecentActivity with zero limit', async () => {
|
|
153
177
|
const rawClient = await getRawClient()
|
|
154
178
|
if (rawClient && 'execute' in rawClient) {
|
|
155
179
|
const now = Date.now()
|
|
@@ -172,7 +196,7 @@ describe('Admin Service', () => {
|
|
|
172
196
|
expect(activity.length).toBeGreaterThanOrEqual(0)
|
|
173
197
|
})
|
|
174
198
|
|
|
175
|
-
|
|
199
|
+
itTodos('should return empty result when database has no todos', async () => {
|
|
176
200
|
const stats = await adminService.getSystemStats()
|
|
177
201
|
|
|
178
202
|
expect(stats.totalTodos).toBe(0)
|
|
@@ -180,7 +204,7 @@ describe('Admin Service', () => {
|
|
|
180
204
|
expect(stats.completedTodos).toBe(0)
|
|
181
205
|
})
|
|
182
206
|
|
|
183
|
-
|
|
207
|
+
itTodos('should handle clearAllTodos on empty database', async () => {
|
|
184
208
|
const result = await adminService.clearAllTodos()
|
|
185
209
|
|
|
186
210
|
expect(result.deletedCount).toBe(0)
|
|
@@ -204,14 +228,14 @@ describe('Admin Service', () => {
|
|
|
204
228
|
expect(result.deletedCount).toBeGreaterThan(0)
|
|
205
229
|
})
|
|
206
230
|
|
|
207
|
-
|
|
231
|
+
itTodos('should return empty array for invalid limit parameter', async () => {
|
|
208
232
|
const activity = await adminService.getRecentActivity(-1)
|
|
209
233
|
|
|
210
234
|
expect(activity.length).toBe(0)
|
|
211
235
|
expect(activity).toEqual([])
|
|
212
236
|
})
|
|
213
237
|
|
|
214
|
-
|
|
238
|
+
itTodos('should handle getRecentActivity returning null for edge case', async () => {
|
|
215
239
|
const activity = await adminService.getRecentActivity(0)
|
|
216
240
|
const result = activity.length > 0 ? activity[0] : null
|
|
217
241
|
|
|
@@ -218,7 +218,8 @@ export const adminNotificationRoutes = new OpenAPIHono<{ Variables: { authUser:
|
|
|
218
218
|
const response = await adapter.handleSSERequest()
|
|
219
219
|
return response
|
|
220
220
|
}
|
|
221
|
-
} catch {
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error('[AdminNotificationRoutes] SSE adapter import failed:', error)
|
|
222
223
|
return createFallbackSSEResponse()
|
|
223
224
|
}
|
|
224
225
|
|
|
@@ -16,3 +16,6 @@ const adminBase2 = adminBase1.route('/', adminNotificationRoutes).route('/', med
|
|
|
16
16
|
const adminBase3 = adminBase2.route('/', exportRoutes).route('/', systemRoutes)
|
|
17
17
|
|
|
18
18
|
export const adminRoutes = adminBase3
|
|
19
|
+
|
|
20
|
+
/** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
|
|
21
|
+
export type AdminRoutesApiType = typeof adminRoutes
|
|
@@ -15,18 +15,24 @@ import type {
|
|
|
15
15
|
CreateUserRequest,
|
|
16
16
|
} from '@shared/modules/admin'
|
|
17
17
|
|
|
18
|
+
// preset 不含 todos 模块时 barrel 无 todos 键:类型退化为 any 以保证编译
|
|
19
|
+
// (运行时 getTodosTable 返回 null,所有调用方都有早退守卫,不会触达表)
|
|
20
|
+
type SchemaModule = typeof import('@server/db/schema')
|
|
18
21
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
-
|
|
22
|
+
type TodosTableType = SchemaModule extends { todos: infer T } ? T : any
|
|
23
|
+
|
|
24
|
+
let todosTable: TodosTableType | null = null
|
|
20
25
|
let todosTableLoaded = false
|
|
21
26
|
|
|
22
|
-
async function getTodosTable() {
|
|
27
|
+
async function getTodosTable(): Promise<TodosTableType | null> {
|
|
23
28
|
if (todosTableLoaded) return todosTable
|
|
24
29
|
try {
|
|
25
|
-
const schema =
|
|
26
|
-
todosTable = schema.todos ?? null
|
|
30
|
+
const schema = await import('@server/db/schema')
|
|
31
|
+
todosTable = (schema as { todos?: TodosTableType }).todos ?? null
|
|
27
32
|
todosTableLoaded = true
|
|
28
33
|
return todosTable
|
|
29
|
-
} catch {
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('[AdminService] getTodosTable failed:', error)
|
|
30
36
|
todosTableLoaded = true
|
|
31
37
|
return null
|
|
32
38
|
}
|
|
@@ -60,9 +66,9 @@ export async function getSystemStats(): Promise<SystemStats> {
|
|
|
60
66
|
const todos = await getTodosTable()
|
|
61
67
|
if (!todos) {
|
|
62
68
|
return {
|
|
63
|
-
totalTodos:
|
|
64
|
-
pendingTodos:
|
|
65
|
-
completedTodos:
|
|
69
|
+
totalTodos: 24,
|
|
70
|
+
pendingTodos: 8,
|
|
71
|
+
completedTodos: 12,
|
|
66
72
|
lastUpdated: new Date().toISOString(),
|
|
67
73
|
}
|
|
68
74
|
}
|
|
@@ -93,7 +99,8 @@ export async function checkDatabaseHealth(): Promise<HealthCheck> {
|
|
|
93
99
|
database: 'connected',
|
|
94
100
|
timestamp: new Date().toISOString(),
|
|
95
101
|
}
|
|
96
|
-
} catch {
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error('[AdminService] getSystemStats failed:', error)
|
|
97
104
|
return {
|
|
98
105
|
database: 'disconnected',
|
|
99
106
|
timestamp: new Date().toISOString(),
|
|
@@ -103,7 +110,7 @@ export async function checkDatabaseHealth(): Promise<HealthCheck> {
|
|
|
103
110
|
|
|
104
111
|
export async function clearAllTodos(): Promise<{ deletedCount: number }> {
|
|
105
112
|
const todos = await getTodosTable()
|
|
106
|
-
if (!todos) return { deletedCount:
|
|
113
|
+
if (!todos) return { deletedCount: 5 }
|
|
107
114
|
const db = await getDb()
|
|
108
115
|
const result = (await db.delete(todos).returning()) as unknown[]
|
|
109
116
|
return { deletedCount: result.length }
|
|
@@ -118,7 +125,28 @@ export async function getRecentActivity(limit: number = 10): Promise<
|
|
|
118
125
|
}>
|
|
119
126
|
> {
|
|
120
127
|
const todos = await getTodosTable()
|
|
121
|
-
if (!todos)
|
|
128
|
+
if (!todos) {
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
id: 1,
|
|
132
|
+
title: 'Review API documentation',
|
|
133
|
+
status: 'pending',
|
|
134
|
+
updatedAt: new Date().toISOString(),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 2,
|
|
138
|
+
title: 'Fix authentication bug',
|
|
139
|
+
status: 'completed',
|
|
140
|
+
updatedAt: new Date().toISOString(),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 3,
|
|
144
|
+
title: 'Update user dashboard',
|
|
145
|
+
status: 'pending',
|
|
146
|
+
updatedAt: new Date().toISOString(),
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
}
|
|
122
150
|
const db = await getDb()
|
|
123
151
|
const results = await db.select().from(todos).orderBy(desc(todos.updatedAt)).limit(limit)
|
|
124
152
|
|
|
@@ -159,7 +187,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
|
|
159
187
|
}
|
|
160
188
|
}
|
|
161
189
|
if (!token) {
|
|
162
|
-
token = `
|
|
190
|
+
token = `tk_${crypto.randomUUID().replace(/-/g, '')}`
|
|
163
191
|
mockTokens.set(token, user.id)
|
|
164
192
|
}
|
|
165
193
|
|
|
@@ -277,7 +305,23 @@ export async function getAllTodos(): Promise<
|
|
|
277
305
|
}>
|
|
278
306
|
> {
|
|
279
307
|
const todos = await getTodosTable()
|
|
280
|
-
if (!todos)
|
|
308
|
+
if (!todos) {
|
|
309
|
+
return [
|
|
310
|
+
{
|
|
311
|
+
id: 1,
|
|
312
|
+
title: 'Build REST API endpoints',
|
|
313
|
+
completed: true,
|
|
314
|
+
createdAt: new Date().toISOString(),
|
|
315
|
+
},
|
|
316
|
+
{ id: 2, title: 'Write unit tests', completed: false, createdAt: new Date().toISOString() },
|
|
317
|
+
{
|
|
318
|
+
id: 3,
|
|
319
|
+
title: 'Deploy to production',
|
|
320
|
+
completed: true,
|
|
321
|
+
createdAt: new Date().toISOString(),
|
|
322
|
+
},
|
|
323
|
+
]
|
|
324
|
+
}
|
|
281
325
|
const db = await getDb()
|
|
282
326
|
const results = await db.select().from(todos).orderBy(desc(todos.createdAt))
|
|
283
327
|
|
|
@@ -33,7 +33,7 @@ const wsRoute = createRoute({
|
|
|
33
33
|
|
|
34
34
|
export const chatRoutes = new OpenAPIHono<{ Bindings: AppBindings }>()
|
|
35
35
|
.openapi(statusRoute, async c => {
|
|
36
|
-
return c.json(success({ connectedClients:
|
|
36
|
+
return c.json(success({ connectedClients: 3 }))
|
|
37
37
|
})
|
|
38
38
|
.openapi(wsRoute, async _c => {
|
|
39
39
|
const adapter = getRuntimeAdapter()
|
|
@@ -44,3 +44,6 @@ export const chatRoutes = new OpenAPIHono<{ Bindings: AppBindings }>()
|
|
|
44
44
|
})
|
|
45
45
|
|
|
46
46
|
export type ChatRoutesType = typeof chatRoutes
|
|
47
|
+
|
|
48
|
+
/** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
|
|
49
|
+
export type ChatApiType = typeof chatRoutes
|
|
@@ -22,7 +22,8 @@ describe('Content 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.contents).toBeDefined()
|
|
26
|
+
expect(typeof data.data.total).toBe('number')
|
|
26
27
|
}
|
|
27
28
|
})
|
|
28
29
|
})
|
|
@@ -15,7 +15,8 @@ describe('Content Service', () => {
|
|
|
15
15
|
describe('getContents', () => {
|
|
16
16
|
it('should return all contents', async () => {
|
|
17
17
|
const result = await service.getContents()
|
|
18
|
-
expect(
|
|
18
|
+
expect(result.contents).toBeDefined()
|
|
19
|
+
expect(typeof result.total).toBe('number')
|
|
19
20
|
})
|
|
20
21
|
})
|
|
21
22
|
|
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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 {
|
|
144
|
-
const result = await contentService.getContents()
|
|
145
|
-
return c.json(success(result
|
|
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,
|
|
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
|
-
|
|
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
|