create-fullstack-scaffold 0.1.1 → 0.2.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 +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- package/template/package-lock.json +0 -14554
|
@@ -3,7 +3,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'
|
|
|
3
3
|
import * as contentService from '../services/content-service'
|
|
4
4
|
import { successResponse, errorResponse } from '@server/utils/route-helpers'
|
|
5
5
|
import { NotFoundError } from '@server/utils/app-error'
|
|
6
|
-
import { success
|
|
6
|
+
import { success } from '@server/utils/response'
|
|
7
7
|
import { z } from '@hono/zod-openapi'
|
|
8
8
|
import { ContentSchema, ContentCategorySchema } from '@shared/modules/content'
|
|
9
9
|
|
|
@@ -46,7 +46,7 @@ export const publicContentRoutes = new OpenAPIHono()
|
|
|
46
46
|
search,
|
|
47
47
|
})
|
|
48
48
|
const items = result.slice(offset, offset + limit)
|
|
49
|
-
return c.json(
|
|
49
|
+
return c.json(success(items), 200)
|
|
50
50
|
})
|
|
51
51
|
.openapi(getPublicRoute, async c => {
|
|
52
52
|
const { id } = c.req.valid('param')
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createRoute } from '@hono/zod-openapi'
|
|
2
|
+
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
|
+
import { successResponse } from '@server/utils/route-helpers'
|
|
4
|
+
import { TopicsResponseSchema, ProfileResponseSchema } from '@shared/schemas'
|
|
5
|
+
|
|
6
|
+
const MOCK_TOPICS = [
|
|
7
|
+
{
|
|
8
|
+
id: '1',
|
|
9
|
+
title: 'How to implement real-time notifications with SSE?',
|
|
10
|
+
excerpt:
|
|
11
|
+
'I am trying to set up server-sent events for my community app. The connection keeps dropping after a few minutes. Has anyone dealt with this issue before?',
|
|
12
|
+
votes: 24,
|
|
13
|
+
replyCount: 8,
|
|
14
|
+
viewCount: 342,
|
|
15
|
+
status: 'solved' as const,
|
|
16
|
+
tags: [
|
|
17
|
+
{ label: 'SSE', color: 'bg-emerald-100 text-emerald-700' },
|
|
18
|
+
{ label: 'Real-time', color: 'bg-blue-100 text-blue-700' },
|
|
19
|
+
],
|
|
20
|
+
author: { name: 'Sarah Chen', initials: 'SC' },
|
|
21
|
+
createdAt: '1h ago',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: '2',
|
|
25
|
+
title: 'Best practices for WebSocket reconnection logic',
|
|
26
|
+
excerpt:
|
|
27
|
+
'My WebSocket client disconnects when the user switches tabs. What reconnection strategies do you recommend for a production environment?',
|
|
28
|
+
votes: 18,
|
|
29
|
+
replyCount: 0,
|
|
30
|
+
viewCount: 156,
|
|
31
|
+
status: 'unanswered' as const,
|
|
32
|
+
tags: [
|
|
33
|
+
{ label: 'WebSocket', color: 'bg-purple-100 text-purple-700' },
|
|
34
|
+
{ label: 'Architecture', color: 'bg-orange-100 text-orange-700' },
|
|
35
|
+
],
|
|
36
|
+
author: { name: 'Alex Rivera', initials: 'AR' },
|
|
37
|
+
createdAt: '2h ago',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: '3',
|
|
41
|
+
title: 'Type-safe API routes with Hono RPC — a complete guide',
|
|
42
|
+
excerpt:
|
|
43
|
+
'After months of using Hono RPC in production, here is my comprehensive guide to achieving full end-to-end type safety across your stack.',
|
|
44
|
+
votes: 56,
|
|
45
|
+
replyCount: 12,
|
|
46
|
+
viewCount: 891,
|
|
47
|
+
status: 'hot' as const,
|
|
48
|
+
tags: [
|
|
49
|
+
{ label: 'Hono', color: 'bg-sky-100 text-sky-700' },
|
|
50
|
+
{ label: 'TypeScript', color: 'bg-blue-100 text-blue-700' },
|
|
51
|
+
{ label: 'Guide', color: 'bg-emerald-100 text-emerald-700' },
|
|
52
|
+
],
|
|
53
|
+
author: { name: 'Jordan Park', initials: 'JP' },
|
|
54
|
+
createdAt: '3h ago',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: '4',
|
|
58
|
+
title: 'Deploying Hono apps to Cloudflare Workers with D1',
|
|
59
|
+
excerpt:
|
|
60
|
+
'Step-by-step walkthrough of deploying a full-stack Hono application to Cloudflare Workers, including database setup with D1 and R2 storage.',
|
|
61
|
+
votes: 31,
|
|
62
|
+
replyCount: 5,
|
|
63
|
+
viewCount: 478,
|
|
64
|
+
status: 'solved' as const,
|
|
65
|
+
tags: [
|
|
66
|
+
{ label: 'Cloudflare', color: 'bg-amber-100 text-amber-700' },
|
|
67
|
+
{ label: 'Deployment', color: 'bg-rose-100 text-rose-700' },
|
|
68
|
+
],
|
|
69
|
+
author: { name: 'Mika Tanaka', initials: 'MT' },
|
|
70
|
+
createdAt: '5h ago',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: '5',
|
|
74
|
+
title: 'Zustand vs Jotai — which state manager for community apps?',
|
|
75
|
+
excerpt:
|
|
76
|
+
'Comparing Zustand and Jotai for a medium-complexity community application. Performance benchmarks and developer experience included.',
|
|
77
|
+
votes: 42,
|
|
78
|
+
replyCount: 0,
|
|
79
|
+
viewCount: 267,
|
|
80
|
+
status: 'unanswered' as const,
|
|
81
|
+
tags: [
|
|
82
|
+
{ label: 'React', color: 'bg-cyan-100 text-cyan-700' },
|
|
83
|
+
{ label: 'State Management', color: 'bg-violet-100 text-violet-700' },
|
|
84
|
+
],
|
|
85
|
+
author: { name: 'Liam Nguyen', initials: 'LN' },
|
|
86
|
+
createdAt: '8h ago',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: '6',
|
|
90
|
+
title: 'Building a plugin system with module manifests',
|
|
91
|
+
excerpt:
|
|
92
|
+
'How we designed a declarative module manifest system that lets users scaffold apps with only the features they need. Patterns and lessons learned.',
|
|
93
|
+
votes: 67,
|
|
94
|
+
replyCount: 19,
|
|
95
|
+
viewCount: 1204,
|
|
96
|
+
status: 'hot' as const,
|
|
97
|
+
tags: [
|
|
98
|
+
{ label: 'Architecture', color: 'bg-orange-100 text-orange-700' },
|
|
99
|
+
{ label: 'Plugins', color: 'bg-pink-100 text-pink-700' },
|
|
100
|
+
],
|
|
101
|
+
author: { name: 'Emma Wilson', initials: 'EW' },
|
|
102
|
+
createdAt: '12h ago',
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
const MOCK_PROFILE_ACTIVITY = [
|
|
107
|
+
{
|
|
108
|
+
id: '1',
|
|
109
|
+
type: 'reply' as const,
|
|
110
|
+
text: 'Replied to',
|
|
111
|
+
target: 'How to implement real-time notifications with SSE?',
|
|
112
|
+
time: '2h ago',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: '2',
|
|
116
|
+
type: 'topic' as const,
|
|
117
|
+
text: 'Created topic',
|
|
118
|
+
target: 'Best practices for WebSocket reconnection logic',
|
|
119
|
+
time: '5h ago',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: '3',
|
|
123
|
+
type: 'like' as const,
|
|
124
|
+
text: 'Liked',
|
|
125
|
+
target: 'Type-safe API routes with Hono RPC — a complete guide',
|
|
126
|
+
time: '8h ago',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: '4',
|
|
130
|
+
type: 'reply' as const,
|
|
131
|
+
text: 'Replied to',
|
|
132
|
+
target: 'Deploying Hono apps to Cloudflare Workers with D1',
|
|
133
|
+
time: '1d ago',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: '5',
|
|
137
|
+
type: 'topic' as const,
|
|
138
|
+
text: 'Created topic',
|
|
139
|
+
target: 'Zustand vs Jotai — which state manager for community apps?',
|
|
140
|
+
time: '2d ago',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: '6',
|
|
144
|
+
type: 'like' as const,
|
|
145
|
+
text: 'Liked',
|
|
146
|
+
target: 'Building a plugin system with module manifests',
|
|
147
|
+
time: '3d ago',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: '7',
|
|
151
|
+
type: 'reply' as const,
|
|
152
|
+
text: 'Replied to',
|
|
153
|
+
target: 'How to set up CI/CD for monorepo projects',
|
|
154
|
+
time: '4d ago',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: '8',
|
|
158
|
+
type: 'topic' as const,
|
|
159
|
+
text: 'Created topic',
|
|
160
|
+
target: 'Tailwind CSS v4 migration guide and tips',
|
|
161
|
+
time: '5d ago',
|
|
162
|
+
},
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
const getTopicsRoute = createRoute({
|
|
166
|
+
method: 'get',
|
|
167
|
+
path: '/topics',
|
|
168
|
+
responses: {
|
|
169
|
+
200: successResponse(TopicsResponseSchema, 'List community topics'),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const getPopularTopicsRoute = createRoute({
|
|
174
|
+
method: 'get',
|
|
175
|
+
path: '/topics/popular',
|
|
176
|
+
responses: {
|
|
177
|
+
200: successResponse(TopicsResponseSchema, 'Popular topics'),
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const getProfileRoute = createRoute({
|
|
182
|
+
method: 'get',
|
|
183
|
+
path: '/profile',
|
|
184
|
+
responses: {
|
|
185
|
+
200: successResponse(ProfileResponseSchema, 'User profile'),
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
export const topicsRoutes = new OpenAPIHono()
|
|
190
|
+
.openapi(getTopicsRoute, async c => {
|
|
191
|
+
return c.json({ success: true as const, data: MOCK_TOPICS })
|
|
192
|
+
})
|
|
193
|
+
.openapi(getPopularTopicsRoute, async c => {
|
|
194
|
+
const popular = [...MOCK_TOPICS].sort((a, b) => b.votes - a.votes)
|
|
195
|
+
return c.json({ success: true as const, data: popular })
|
|
196
|
+
})
|
|
197
|
+
.openapi(getProfileRoute, async c => {
|
|
198
|
+
return c.json({
|
|
199
|
+
success: true as const,
|
|
200
|
+
data: {
|
|
201
|
+
stats: { topics: 12, replies: 48, likes: 156 },
|
|
202
|
+
activity: MOCK_PROFILE_ACTIVITY,
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -11,10 +11,12 @@ import { contents, type ContentTable } from '@server/db/schema'
|
|
|
11
11
|
import { toISOString } from '@server/utils/date'
|
|
12
12
|
import { randomDate, randomElement } from '@server/utils/generate'
|
|
13
13
|
import { parseModuleId } from '@server/utils/id-helpers'
|
|
14
|
+
// @framework-import ISR 缓存失效,内容变更时清除页面缓存
|
|
15
|
+
import { purgeContentPages } from '@server/core/isr-invalidation'
|
|
14
16
|
|
|
15
17
|
export async function seedContentsIfEmpty(): Promise<void> {
|
|
16
18
|
const db = await getDb()
|
|
17
|
-
const existing = await db.select().from(contents)
|
|
19
|
+
const existing = await db.select().from(contents)
|
|
18
20
|
if (existing.length === 0) {
|
|
19
21
|
const CATEGORIES: ContentCategory[] = ['article', 'announcement', 'tutorial', 'news', 'policy']
|
|
20
22
|
const STATUSES: ContentStatus[] = ['draft', 'published', 'archived']
|
|
@@ -48,7 +50,9 @@ export async function seedContentsIfEmpty(): Promise<void> {
|
|
|
48
50
|
|
|
49
51
|
await db.insert(contents).values({
|
|
50
52
|
title: TITLES[i % TITLES.length],
|
|
51
|
-
body: `这是${
|
|
53
|
+
body: `这是${
|
|
54
|
+
TITLES[i % TITLES.length]
|
|
55
|
+
}的详细内容。这里包含了完整的文章内容,用户可以阅读和学习相关知识。`,
|
|
52
56
|
category,
|
|
53
57
|
status,
|
|
54
58
|
author: randomElement(AUTHORS),
|
|
@@ -173,6 +177,9 @@ export async function updateContent(id: string, data: UpdateContentInput): Promi
|
|
|
173
177
|
const result = await db.update(contents).set(updateData).where(eq(contents.id, numId)).returning()
|
|
174
178
|
|
|
175
179
|
if (result.length === 0) return null
|
|
180
|
+
purgeContentPages().catch(e => {
|
|
181
|
+
console.warn('ISR content pages purge failed after update:', e)
|
|
182
|
+
})
|
|
176
183
|
return mapContentRow(result[0])
|
|
177
184
|
}
|
|
178
185
|
|
|
@@ -183,6 +190,9 @@ export async function deleteContent(id: string): Promise<{ success: boolean; mes
|
|
|
183
190
|
|
|
184
191
|
const result = await db.delete(contents).where(eq(contents.id, numId)).returning()
|
|
185
192
|
if (result.length === 0) return { success: false, message: '内容不存在' }
|
|
193
|
+
purgeContentPages().catch(e => {
|
|
194
|
+
console.warn('ISR content pages purge failed after delete:', e)
|
|
195
|
+
})
|
|
186
196
|
return { success: true, message: '内容已删除' }
|
|
187
197
|
}
|
|
188
198
|
|
|
@@ -202,6 +212,9 @@ export async function publishContent(id: string): Promise<Content | null> {
|
|
|
202
212
|
.where(eq(contents.id, numId))
|
|
203
213
|
.returning()
|
|
204
214
|
|
|
215
|
+
purgeContentPages().catch(e => {
|
|
216
|
+
console.warn('ISR content pages purge failed after publish:', e)
|
|
217
|
+
})
|
|
205
218
|
return mapContentRow(result[0])
|
|
206
219
|
}
|
|
207
220
|
|
|
@@ -220,5 +233,8 @@ export async function archiveContent(id: string): Promise<Content | null> {
|
|
|
220
233
|
.where(eq(contents.id, numId))
|
|
221
234
|
.returning()
|
|
222
235
|
|
|
236
|
+
purgeContentPages().catch(e => {
|
|
237
|
+
console.warn('ISR content pages purge failed after archive:', e)
|
|
238
|
+
})
|
|
223
239
|
return mapContentRow(result[0])
|
|
224
240
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
UpdateDisputeSchema,
|
|
18
18
|
DisputeListSchema,
|
|
19
19
|
ResolveDisputeSchema,
|
|
20
|
+
DeleteResultSchema,
|
|
20
21
|
} from '@shared/modules/dispute'
|
|
21
22
|
import { NotFoundError, BusinessError } from '@server/utils/app-error'
|
|
22
23
|
|
|
@@ -91,7 +92,7 @@ const deleteRoute = createRoute({
|
|
|
91
92
|
middleware: [authMiddleware({ requiredPermissions: [Permission.DISPUTE_DELETE] })],
|
|
92
93
|
request: idRequest,
|
|
93
94
|
responses: {
|
|
94
|
-
200: successResponse(
|
|
95
|
+
200: successResponse(DeleteResultSchema, 'Delete dispute'),
|
|
95
96
|
401: errorResponse('Unauthorized'),
|
|
96
97
|
403: errorResponse('Forbidden'),
|
|
97
98
|
404: errorResponse('Dispute not found'),
|
|
@@ -15,7 +15,7 @@ import { parseModuleId } from '@server/utils/id-helpers'
|
|
|
15
15
|
|
|
16
16
|
export async function seedDisputesIfEmpty(): Promise<void> {
|
|
17
17
|
const db = await getDb()
|
|
18
|
-
const existing = await db.select().from(disputes)
|
|
18
|
+
const existing = await db.select().from(disputes)
|
|
19
19
|
if (existing.length === 0) {
|
|
20
20
|
const DISPUTE_TYPES: DisputeType[] = [
|
|
21
21
|
'refund',
|
|
@@ -13,8 +13,8 @@ setRuntimeAdapter(getNodeRuntimeAdapter())
|
|
|
13
13
|
let sseTestContext: { port: number; close: () => Promise<void> }
|
|
14
14
|
let client: ReturnType<typeof createTestClient>
|
|
15
15
|
|
|
16
|
-
function connectSSE() {
|
|
17
|
-
const conn = client.api.notifications.stream.$sse()
|
|
16
|
+
async function connectSSE() {
|
|
17
|
+
const conn = await client.api.notifications.stream.$sse()
|
|
18
18
|
return conn as unknown as SSEClientImpl<AppSSEProtocol>
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -44,7 +44,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
44
44
|
|
|
45
45
|
describe('GET /api/notifications/stream via $sse()', () => {
|
|
46
46
|
it('should use patched $sse() method for type-safe SSE connection', async () => {
|
|
47
|
-
const conn = connectSSE()
|
|
47
|
+
const conn = await connectSSE()
|
|
48
48
|
|
|
49
49
|
expect(['connecting', 'open', 'closed']).toContain(conn.status)
|
|
50
50
|
|
|
@@ -83,7 +83,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
83
83
|
})
|
|
84
84
|
|
|
85
85
|
it('should receive typed notification events', async () => {
|
|
86
|
-
const conn = connectSSE()
|
|
86
|
+
const conn = await connectSSE()
|
|
87
87
|
|
|
88
88
|
const receivedNotifications: AppSSEProtocol['events']['notification'][] = []
|
|
89
89
|
|
|
@@ -110,7 +110,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
110
110
|
})
|
|
111
111
|
|
|
112
112
|
it('should receive typed ping events', async () => {
|
|
113
|
-
const conn = connectSSE()
|
|
113
|
+
const conn = await connectSSE()
|
|
114
114
|
|
|
115
115
|
const receivedPings: AppSSEProtocol['events']['ping'][] = []
|
|
116
116
|
|
|
@@ -130,7 +130,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
130
130
|
})
|
|
131
131
|
|
|
132
132
|
it('should receive typed connected events', async () => {
|
|
133
|
-
const conn = connectSSE()
|
|
133
|
+
const conn = await connectSSE()
|
|
134
134
|
|
|
135
135
|
const receivedConnected: AppSSEProtocol['events']['connected'][] = []
|
|
136
136
|
|
|
@@ -150,7 +150,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
150
150
|
})
|
|
151
151
|
|
|
152
152
|
it('should handle connection status changes', async () => {
|
|
153
|
-
const conn = connectSSE()
|
|
153
|
+
const conn = await connectSSE()
|
|
154
154
|
|
|
155
155
|
const statusHistory: ('connecting' | 'open' | 'closed')[] = []
|
|
156
156
|
|
|
@@ -170,7 +170,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
170
170
|
})
|
|
171
171
|
|
|
172
172
|
it('should handle errors gracefully', async () => {
|
|
173
|
-
const conn = connectSSE()
|
|
173
|
+
const conn = await connectSSE()
|
|
174
174
|
|
|
175
175
|
const unsubscribe = conn.onError((error: Error) => {
|
|
176
176
|
console.error('SSE error:', error)
|
|
@@ -187,7 +187,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
187
187
|
|
|
188
188
|
describe('Error Scenarios', () => {
|
|
189
189
|
it('should handle connection abort during event reception', async () => {
|
|
190
|
-
const conn = connectSSE()
|
|
190
|
+
const conn = await connectSSE()
|
|
191
191
|
|
|
192
192
|
const receivedEvents: unknown[] = []
|
|
193
193
|
const unsubscribe = conn.on('notification', (event: unknown) => {
|
|
@@ -206,7 +206,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
206
206
|
})
|
|
207
207
|
|
|
208
208
|
it('should handle multiple abort calls gracefully', async () => {
|
|
209
|
-
const conn = connectSSE()
|
|
209
|
+
const conn = await connectSSE()
|
|
210
210
|
|
|
211
211
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
212
212
|
|
|
@@ -218,7 +218,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
218
218
|
})
|
|
219
219
|
|
|
220
220
|
it('should handle unsubscribe before connection close', async () => {
|
|
221
|
-
const conn = connectSSE()
|
|
221
|
+
const conn = await connectSSE()
|
|
222
222
|
|
|
223
223
|
const unsubscribe = conn.on('notification', () => {})
|
|
224
224
|
|
|
@@ -232,7 +232,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
232
232
|
})
|
|
233
233
|
|
|
234
234
|
it('should handle error events', async () => {
|
|
235
|
-
const conn = connectSSE()
|
|
235
|
+
const conn = await connectSSE()
|
|
236
236
|
|
|
237
237
|
let errorReceived = false
|
|
238
238
|
const unsubscribe = conn.onError((error: Error) => {
|
|
@@ -253,7 +253,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
253
253
|
})
|
|
254
254
|
|
|
255
255
|
it('should handle non-existent event type gracefully', async () => {
|
|
256
|
-
const conn = connectSSE()
|
|
256
|
+
const conn = await connectSSE()
|
|
257
257
|
|
|
258
258
|
let received = false
|
|
259
259
|
const unsubscribe = conn.on('nonExistentEvent' as 'notification', () => {
|
|
@@ -273,7 +273,7 @@ describe('SSE Routes with Patched $sse() RPC Method', () => {
|
|
|
273
273
|
})
|
|
274
274
|
|
|
275
275
|
it('should test error assertion pattern', async () => {
|
|
276
|
-
const conn = connectSSE()
|
|
276
|
+
const conn = await connectSSE()
|
|
277
277
|
|
|
278
278
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
279
279
|
|
|
@@ -18,6 +18,30 @@ import {
|
|
|
18
18
|
import { NotFoundError } from '@server/utils/app-error'
|
|
19
19
|
import { getRuntimeAdapter } from '@server/core/runtime'
|
|
20
20
|
|
|
21
|
+
function createFallbackSSEResponse(): Response {
|
|
22
|
+
const stream = new ReadableStream({
|
|
23
|
+
start(controller) {
|
|
24
|
+
const encoder = new TextEncoder()
|
|
25
|
+
const sendPing = () => {
|
|
26
|
+
controller.enqueue(encoder.encode(`event: ping\ndata: {"timestamp":${Date.now()}}\n\n`))
|
|
27
|
+
}
|
|
28
|
+
sendPing()
|
|
29
|
+
const interval = setInterval(sendPing, 30000)
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
clearInterval(interval)
|
|
32
|
+
controller.close()
|
|
33
|
+
}, 300000)
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
return new Response(stream, {
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'text/event-stream',
|
|
39
|
+
'Cache-Control': 'no-cache',
|
|
40
|
+
Connection: 'keep-alive',
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
const streamRoute = createRoute({
|
|
22
46
|
method: 'get',
|
|
23
47
|
path: '/notifications/stream',
|
|
@@ -126,12 +150,10 @@ const deleteRoute = createRoute({
|
|
|
126
150
|
|
|
127
151
|
export const notificationRoutes = new OpenAPIHono()
|
|
128
152
|
.openapi(streamRoute, async c => {
|
|
129
|
-
// In Cloudflare environment, route SSE to Durable Object for proper broadcast support
|
|
130
153
|
const env = c.env as { REALTIME_DO?: DurableObjectNamespace } | undefined
|
|
131
154
|
if (env?.REALTIME_DO) {
|
|
132
155
|
const id = env.REALTIME_DO.idFromName('global')
|
|
133
156
|
const stub = env.REALTIME_DO.get(id)
|
|
134
|
-
// Forward the SSE request to the Durable Object
|
|
135
157
|
const doRequest = new Request(c.req.url, {
|
|
136
158
|
method: c.req.method,
|
|
137
159
|
headers: c.req.raw.headers,
|
|
@@ -139,13 +161,17 @@ export const notificationRoutes = new OpenAPIHono()
|
|
|
139
161
|
return stub.fetch(doRequest)
|
|
140
162
|
}
|
|
141
163
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
164
|
+
try {
|
|
165
|
+
const adapter = getRuntimeAdapter()
|
|
166
|
+
if (adapter.handleSSERequest) {
|
|
167
|
+
const response = await adapter.handleSSERequest()
|
|
168
|
+
return response
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return createFallbackSSEResponse()
|
|
147
172
|
}
|
|
148
|
-
|
|
173
|
+
|
|
174
|
+
return createFallbackSSEResponse()
|
|
149
175
|
})
|
|
150
176
|
.openapi(listRoute, async c => {
|
|
151
177
|
const query = c.req.valid('query')
|
|
@@ -65,8 +65,18 @@ describe('Order Routes', () => {
|
|
|
65
65
|
it('should filter orders by both status and customerName', async () => {
|
|
66
66
|
const client = createTestClient(undefined, { headers: authHeaders })
|
|
67
67
|
|
|
68
|
+
const createRes = await client.api['orders'].$post({
|
|
69
|
+
json: {
|
|
70
|
+
customerName: 'Filter Test',
|
|
71
|
+
customerEmail: 'filter@example.com',
|
|
72
|
+
productName: 'Filter Product',
|
|
73
|
+
amount: 100,
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
expect(createRes.status).toBe(201)
|
|
77
|
+
|
|
68
78
|
const res = await client.api['orders'].$get({
|
|
69
|
-
query: { status: 'pending', customerName: '
|
|
79
|
+
query: { status: 'pending', customerName: 'Filter Test' },
|
|
70
80
|
})
|
|
71
81
|
expect(res.status).toBe(200)
|
|
72
82
|
|
|
@@ -76,7 +86,7 @@ describe('Order Routes', () => {
|
|
|
76
86
|
expect(Array.isArray(data.data)).toBe(true)
|
|
77
87
|
data.data.forEach((order: { status: string; customerName: string }) => {
|
|
78
88
|
expect(order.status).toBe('pending')
|
|
79
|
-
expect(order.customerName).toContain('
|
|
89
|
+
expect(order.customerName).toContain('Filter Test')
|
|
80
90
|
})
|
|
81
91
|
}
|
|
82
92
|
})
|
|
@@ -84,6 +94,16 @@ describe('Order Routes', () => {
|
|
|
84
94
|
it('should return empty array when no orders match filter', async () => {
|
|
85
95
|
const client = createTestClient(undefined, { headers: authHeaders })
|
|
86
96
|
|
|
97
|
+
const createRes = await client.api['orders'].$post({
|
|
98
|
+
json: {
|
|
99
|
+
customerName: 'Exists Test',
|
|
100
|
+
customerEmail: 'exists@example.com',
|
|
101
|
+
productName: 'Exists Product',
|
|
102
|
+
amount: 100,
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
expect(createRes.status).toBe(201)
|
|
106
|
+
|
|
87
107
|
const res = await client.api['orders'].$get({
|
|
88
108
|
query: { customerName: 'NonExistentCustomer12345' },
|
|
89
109
|
})
|
|
@@ -7,6 +7,16 @@ const orderManifest: ModuleManifest = {
|
|
|
7
7
|
dependsOn: ['permission'],
|
|
8
8
|
|
|
9
9
|
routes: {
|
|
10
|
+
client: [
|
|
11
|
+
{
|
|
12
|
+
importPath: './routes/cart-routes',
|
|
13
|
+
exportName: 'cartRoutes',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
importPath: './routes/orders-mock-routes',
|
|
17
|
+
exportName: 'ordersMockRoutes',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
10
20
|
admin: [
|
|
11
21
|
{
|
|
12
22
|
importPath: './routes/order-routes',
|
|
@@ -19,6 +29,11 @@ const orderManifest: ModuleManifest = {
|
|
|
19
29
|
path: 'order',
|
|
20
30
|
},
|
|
21
31
|
|
|
32
|
+
clientPages: [
|
|
33
|
+
{ name: 'CartPage', route: '/cart' },
|
|
34
|
+
{ name: 'OrdersPage', route: '/orders' },
|
|
35
|
+
],
|
|
36
|
+
|
|
22
37
|
adminPages: [{ name: 'OrdersPage', route: '/orders', requiredPermission: 'ORDER_VIEW' }],
|
|
23
38
|
|
|
24
39
|
dbSchemas: {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createRoute } from '@hono/zod-openapi'
|
|
2
|
+
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
|
+
import { successResponse, errorResponse } from '@server/utils/route-helpers'
|
|
4
|
+
import { z } from '@hono/zod-openapi'
|
|
5
|
+
import {
|
|
6
|
+
CartItemSchema,
|
|
7
|
+
CartResponseSchema,
|
|
8
|
+
AddCartItemSchema,
|
|
9
|
+
RemoveCartItemResponseSchema,
|
|
10
|
+
} from '@shared/schemas'
|
|
11
|
+
|
|
12
|
+
const SHIPPING_THRESHOLD = 50
|
|
13
|
+
const TAX_RATE = 0.08
|
|
14
|
+
|
|
15
|
+
const MOCK_CART_ITEMS = [
|
|
16
|
+
{
|
|
17
|
+
id: 1,
|
|
18
|
+
name: 'Wireless Headphones',
|
|
19
|
+
variant: 'Black',
|
|
20
|
+
price: 79.99,
|
|
21
|
+
quantity: 1,
|
|
22
|
+
color: '#374151',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 2,
|
|
26
|
+
name: 'Organic Cotton T-Shirt',
|
|
27
|
+
variant: 'Medium / Sage',
|
|
28
|
+
price: 34.99,
|
|
29
|
+
quantity: 2,
|
|
30
|
+
color: '#6ee7b7',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 3,
|
|
34
|
+
name: 'Ceramic Travel Mug',
|
|
35
|
+
variant: 'Amber / 350ml',
|
|
36
|
+
price: 24.99,
|
|
37
|
+
quantity: 1,
|
|
38
|
+
color: '#f59e0b',
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
function computeSummary(items: typeof MOCK_CART_ITEMS) {
|
|
43
|
+
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
|
44
|
+
const shipping = subtotal >= SHIPPING_THRESHOLD ? 0 : 5.99
|
|
45
|
+
const tax = subtotal * TAX_RATE
|
|
46
|
+
const total = subtotal + shipping + tax
|
|
47
|
+
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0)
|
|
48
|
+
return { subtotal, shipping, tax, total, totalItems }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const getCartRoute = createRoute({
|
|
52
|
+
method: 'get',
|
|
53
|
+
path: '/cart',
|
|
54
|
+
responses: {
|
|
55
|
+
200: successResponse(CartResponseSchema, 'Get cart'),
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const addCartItemRoute = createRoute({
|
|
60
|
+
method: 'post',
|
|
61
|
+
path: '/cart/items',
|
|
62
|
+
request: {
|
|
63
|
+
body: {
|
|
64
|
+
content: {
|
|
65
|
+
'application/json': { schema: AddCartItemSchema },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
responses: {
|
|
70
|
+
201: successResponse(CartItemSchema, 'Item added to cart'),
|
|
71
|
+
400: errorResponse('Invalid input'),
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const removeCartItemRoute = createRoute({
|
|
76
|
+
method: 'delete',
|
|
77
|
+
path: '/cart/items/{id}',
|
|
78
|
+
request: {
|
|
79
|
+
params: z.object({ id: z.string() }),
|
|
80
|
+
},
|
|
81
|
+
responses: {
|
|
82
|
+
200: successResponse(RemoveCartItemResponseSchema, 'Item removed'),
|
|
83
|
+
404: errorResponse('Item not found'),
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
export const cartRoutes = new OpenAPIHono()
|
|
88
|
+
.openapi(getCartRoute, async c => {
|
|
89
|
+
const summary = computeSummary(MOCK_CART_ITEMS)
|
|
90
|
+
return c.json({ success: true as const, data: { items: MOCK_CART_ITEMS, summary } })
|
|
91
|
+
})
|
|
92
|
+
.openapi(addCartItemRoute, async c => {
|
|
93
|
+
const body = c.req.valid('json')
|
|
94
|
+
return c.json({ success: true as const, data: body }, 201)
|
|
95
|
+
})
|
|
96
|
+
.openapi(removeCartItemRoute, async c => {
|
|
97
|
+
const { id } = c.req.valid('param')
|
|
98
|
+
const exists = MOCK_CART_ITEMS.some(item => String(item.id) === id)
|
|
99
|
+
if (!exists) {
|
|
100
|
+
return c.json({ success: false as const, error: 'Item not found' }, 404)
|
|
101
|
+
}
|
|
102
|
+
return c.json({ success: true as const, data: { removedId: id } })
|
|
103
|
+
})
|