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
|
@@ -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
|
|
@@ -88,8 +88,13 @@ export async function getContents(filters?: {
|
|
|
88
88
|
category?: ContentCategory
|
|
89
89
|
status?: ContentStatus
|
|
90
90
|
search?: string
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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 {
|
|
121
|
-
const result = await disputeService.getDisputes()
|
|
122
|
-
return c.json(success(result
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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> {
|
|
@@ -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 = `
|
|
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 (
|
|
125
|
-
const totalOrders =
|
|
126
|
-
const totalRevenue =
|
|
127
|
-
const pendingOrders =
|
|
128
|
-
const thisMonthRevenue =
|
|
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: '
|
|
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: '
|
|
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(
|
|
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
|
})
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
OrderSchema,
|
|
10
10
|
CreateOrderSchema,
|
|
11
11
|
UpdateOrderSchema,
|
|
12
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|