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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline b75ca79fd266283d
|
|
3
|
+
*
|
|
4
|
+
* @framework-modify
|
|
5
|
+
* @reason 断言对齐实现:generator meta 值为 ISR-SSG(isr-renderer.ts:65)
|
|
6
|
+
* @impact 仅测试断言,无运行时影响
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { renderISRPage, escapeHtml } from '@server/core/isr-renderer'
|
|
11
|
+
|
|
12
|
+
describe('ISR Renderer', () => {
|
|
13
|
+
describe('escapeHtml', () => {
|
|
14
|
+
it('should escape & < > "', () => {
|
|
15
|
+
expect(escapeHtml('a&b<c>d"e')).toBe('a&b<c>d"e')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should escape empty string', () => {
|
|
19
|
+
expect(escapeHtml('')).toBe('')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should escape multiple occurrences', () => {
|
|
23
|
+
expect(escapeHtml('<<>>')).toBe('<<>>')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should not alter plain text', () => {
|
|
27
|
+
expect(escapeHtml('hello world')).toBe('hello world')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('renderISRPage — with template', () => {
|
|
32
|
+
const template = `<!DOCTYPE html>
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="UTF-8" />
|
|
36
|
+
<title>Original Title</title>
|
|
37
|
+
<meta name="description" content="original desc" />
|
|
38
|
+
<meta property="og:title" content="original" />
|
|
39
|
+
<meta property="og:description" content="original" />
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<div id="root"></div>
|
|
43
|
+
</body>
|
|
44
|
+
</html>`
|
|
45
|
+
|
|
46
|
+
it('should inject title', () => {
|
|
47
|
+
const html = renderISRPage({
|
|
48
|
+
template,
|
|
49
|
+
meta: { title: 'New Title', description: 'new desc' },
|
|
50
|
+
})
|
|
51
|
+
expect(html).toContain('<title>New Title</title>')
|
|
52
|
+
expect(html).not.toContain('Original Title')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should inject meta description', () => {
|
|
56
|
+
const html = renderISRPage({
|
|
57
|
+
template,
|
|
58
|
+
meta: { title: 'T', description: 'New Description' },
|
|
59
|
+
})
|
|
60
|
+
expect(html).toContain('name="description" content="New Description"')
|
|
61
|
+
expect(html).not.toContain('content="original desc"')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should inject og:title and og:description', () => {
|
|
65
|
+
const html = renderISRPage({
|
|
66
|
+
template,
|
|
67
|
+
meta: { title: 'OG Title', description: 'OG Desc' },
|
|
68
|
+
})
|
|
69
|
+
expect(html).toContain('property="og:title" content="OG Title"')
|
|
70
|
+
expect(html).toContain('property="og:description" content="OG Desc"')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should inject generator=ISR', () => {
|
|
74
|
+
const html = renderISRPage({
|
|
75
|
+
template,
|
|
76
|
+
meta: { title: 'T', description: 'D' },
|
|
77
|
+
})
|
|
78
|
+
expect(html).toContain('name="generator" content="ISR-SSG"')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should keep root div empty (meta-only)', () => {
|
|
82
|
+
const html = renderISRPage({
|
|
83
|
+
template,
|
|
84
|
+
meta: { title: 'T', description: 'D' },
|
|
85
|
+
})
|
|
86
|
+
expect(html).toContain('<div id="root"></div>')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should escape HTML in meta values', () => {
|
|
90
|
+
const html = renderISRPage({
|
|
91
|
+
template,
|
|
92
|
+
meta: { title: '<script>alert(1)</script>', description: '<b>bold</b>' },
|
|
93
|
+
})
|
|
94
|
+
expect(html).not.toContain('<script>alert(1)</script>')
|
|
95
|
+
expect(html).toContain('<script>alert(1)</script>')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('renderISRPage — no template (fallback)', () => {
|
|
100
|
+
it('should generate standalone HTML', () => {
|
|
101
|
+
const html = renderISRPage({
|
|
102
|
+
template: null,
|
|
103
|
+
meta: { title: 'Standalone', description: 'Fallback mode' },
|
|
104
|
+
})
|
|
105
|
+
expect(html).toContain('<!DOCTYPE html>')
|
|
106
|
+
expect(html).toContain('<title>Standalone</title>')
|
|
107
|
+
expect(html).toContain('<div id="root"></div>')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should include all meta tags in fallback', () => {
|
|
111
|
+
const html = renderISRPage({
|
|
112
|
+
template: null,
|
|
113
|
+
meta: { title: 'T', description: 'D' },
|
|
114
|
+
})
|
|
115
|
+
expect(html).toContain('name="description"')
|
|
116
|
+
expect(html).toContain('property="og:title"')
|
|
117
|
+
expect(html).toContain('property="og:description"')
|
|
118
|
+
expect(html).toContain('name="generator" content="ISR-SSG"')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* Supports Cloudflare Cache API and in-memory fallback.
|
|
6
6
|
*
|
|
7
7
|
* @framework-modify
|
|
8
|
-
* @reason
|
|
9
|
-
* @impact
|
|
8
|
+
* @reason 模块化改造:移除硬编码路由,路由判断委托给 isrRegistry
|
|
9
|
+
* @impact isr-invalidation 也需要同步改造
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { isrRegistry } from './isr-registry'
|
|
13
|
+
|
|
12
14
|
export interface ISRCacheEntry {
|
|
13
15
|
html: string
|
|
14
16
|
createdAt: number
|
|
@@ -29,16 +31,8 @@ export interface ISRCacheOptions {
|
|
|
29
31
|
const DEFAULT_MAX_AGE = 60
|
|
30
32
|
const DEFAULT_STALE_WHILE_REVALIDATE = 300
|
|
31
33
|
|
|
32
|
-
const ISR_ROUTES = ['/', '/todos', '/content', '/notifications', '/websocket']
|
|
33
|
-
|
|
34
|
-
const ISR_ROUTE_PREFIXES = ['/content/']
|
|
35
|
-
|
|
36
34
|
export function isISRRoute(pathname: string): boolean {
|
|
37
|
-
|
|
38
|
-
for (const prefix of ISR_ROUTE_PREFIXES) {
|
|
39
|
-
if (pathname.startsWith(prefix)) return true
|
|
40
|
-
}
|
|
41
|
-
return false
|
|
35
|
+
return isrRegistry.isISRRoute(pathname)
|
|
42
36
|
}
|
|
43
37
|
|
|
44
38
|
export function generateCacheKey(pathname: string): string {
|
|
@@ -110,7 +104,7 @@ class MemoryCacheStore implements ISRCacheStore {
|
|
|
110
104
|
|
|
111
105
|
class CloudflareCacheStore implements ISRCacheStore {
|
|
112
106
|
private cache: Cache | null = null
|
|
113
|
-
private origin = 'https://isr.local'
|
|
107
|
+
private origin = 'https://isr-v7.local'
|
|
114
108
|
|
|
115
109
|
private async getCache(): Promise<Cache> {
|
|
116
110
|
if (!this.cache) {
|
|
@@ -136,6 +130,33 @@ class CloudflareCacheStore implements ISRCacheStore {
|
|
|
136
130
|
return { html, createdAt, revalidateAt }
|
|
137
131
|
}
|
|
138
132
|
|
|
133
|
+
// Cache API 没有 list():用专用索引键记录所有已缓存 pathname(JSON 数组),
|
|
134
|
+
// purgePattern 据此清剿。跨 isolate 各自记账,最终一致(各自清理各自的键,
|
|
135
|
+
// 键相同即覆盖,无重复副作用)。
|
|
136
|
+
private static readonly INDEX_KEY = 'isr:__index__'
|
|
137
|
+
|
|
138
|
+
private async readIndex(): Promise<string[]> {
|
|
139
|
+
const cache = await this.getCache()
|
|
140
|
+
const res = await cache.match(this.toUrl(CloudflareCacheStore.INDEX_KEY))
|
|
141
|
+
if (!res) return []
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(await res.text())
|
|
144
|
+
return Array.isArray(parsed) ? parsed : []
|
|
145
|
+
} catch {
|
|
146
|
+
return []
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async writeIndex(keys: string[]): Promise<void> {
|
|
151
|
+
const cache = await this.getCache()
|
|
152
|
+
// 索引不必常驻新鲜——purge 类操作低频,短 TTL 控制体积
|
|
153
|
+
const body = JSON.stringify([...new Set(keys)])
|
|
154
|
+
await cache.put(
|
|
155
|
+
this.toUrl(CloudflareCacheStore.INDEX_KEY),
|
|
156
|
+
new Response(body, { headers: { 'Content-Type': 'application/json' } })
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
139
160
|
async set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void> {
|
|
140
161
|
const cache = await this.getCache()
|
|
141
162
|
const url = this.toUrl(key)
|
|
@@ -153,26 +174,48 @@ class CloudflareCacheStore implements ISRCacheStore {
|
|
|
153
174
|
})
|
|
154
175
|
|
|
155
176
|
await cache.put(url, response)
|
|
177
|
+
|
|
178
|
+
const index = await this.readIndex()
|
|
179
|
+
if (!index.includes(key)) {
|
|
180
|
+
index.push(key)
|
|
181
|
+
await this.writeIndex(index)
|
|
182
|
+
}
|
|
156
183
|
}
|
|
157
184
|
|
|
158
185
|
async purge(key: string): Promise<void> {
|
|
159
186
|
const cache = await this.getCache()
|
|
160
187
|
const url = this.toUrl(key)
|
|
161
188
|
await cache.delete(url)
|
|
189
|
+
|
|
190
|
+
const index = await this.readIndex()
|
|
191
|
+
if (index.includes(key)) {
|
|
192
|
+
await this.writeIndex(index.filter(k => k !== key))
|
|
193
|
+
}
|
|
162
194
|
}
|
|
163
195
|
|
|
164
196
|
async purgePattern(pattern: string): Promise<void> {
|
|
197
|
+
// 此前为永远不执行的死代码(allKeys 恒空)——CF 上内容更新后
|
|
198
|
+
// 陈旧详情页会一直服务到自然过期。现按索引清单真清剿。
|
|
199
|
+
const regex = new RegExp('^' + this.escapeForPattern(pattern).replace(/\*/g, '.*') + '$')
|
|
200
|
+
const index = await this.readIndex()
|
|
165
201
|
const cache = await this.getCache()
|
|
166
|
-
const
|
|
167
|
-
const allKeys: Request[] = []
|
|
202
|
+
const survivors: string[] = []
|
|
168
203
|
|
|
169
|
-
for (const
|
|
170
|
-
const url = new URL(request.url)
|
|
171
|
-
const key = `isr:${url.pathname}`
|
|
204
|
+
for (const key of index) {
|
|
172
205
|
if (regex.test(key)) {
|
|
173
|
-
await cache.delete(
|
|
206
|
+
await cache.delete(this.toUrl(key))
|
|
207
|
+
} else {
|
|
208
|
+
survivors.push(key)
|
|
174
209
|
}
|
|
175
210
|
}
|
|
211
|
+
if (survivors.length !== index.length) {
|
|
212
|
+
await this.writeIndex(survivors)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private escapeForPattern(pattern: string): string {
|
|
217
|
+
// 转义除 * 外的正则元字符,防 'content/1+2' 之类键名误匹配
|
|
218
|
+
return pattern.replace(/[.+?^${}()|[\]]/g, '\\$&')
|
|
176
219
|
}
|
|
177
220
|
}
|
|
178
221
|
|
|
@@ -2,16 +2,12 @@
|
|
|
2
2
|
* @framework-baseline a124c5bce28416ca
|
|
3
3
|
*
|
|
4
4
|
* @framework-modify
|
|
5
|
-
* @reason
|
|
6
|
-
* @impact
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* ISR cache invalidation utilities.
|
|
11
|
-
* Call these from service layer when content changes.
|
|
5
|
+
* @reason 模块化改造:purgeAllPages 改为遍历注册表
|
|
6
|
+
* @impact 不再硬编码路由列表
|
|
12
7
|
*/
|
|
13
8
|
|
|
14
9
|
import type { ISRCache } from './isr-cache'
|
|
10
|
+
import { isrRegistry } from './isr-registry'
|
|
15
11
|
|
|
16
12
|
let _cache: ISRCache | null = null
|
|
17
13
|
|
|
@@ -36,10 +32,8 @@ export async function purgeContentPages(): Promise<void> {
|
|
|
36
32
|
|
|
37
33
|
export async function purgeAllPages(): Promise<void> {
|
|
38
34
|
if (!_cache) return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
await _cache.purge('/notifications')
|
|
43
|
-
await _cache.purge('/websocket')
|
|
35
|
+
for (const path of isrRegistry.getExactPaths()) {
|
|
36
|
+
await _cache.purge(path)
|
|
37
|
+
}
|
|
44
38
|
await _cache.purgePattern('isr:/content/*')
|
|
45
39
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline faa47d8c6e71847d
|
|
3
|
+
*
|
|
4
|
+
*
|
|
5
|
+
* @framework-modify
|
|
6
|
+
* @reason prettier 格式化与注释结构整理(无逻辑改动)
|
|
7
|
+
* @impact 框架文件维护性修改,行为见测试
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ISR Route Registry — modules register their ISR routes here.
|
|
12
|
+
* Full SSG mode: fetches data for meta tags AND pre-populates stores for React SSR.
|
|
13
|
+
* The CF entry and ISR cache use this to discover which routes need ISR.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ISRMatcher = string | ((pathname: string) => boolean)
|
|
17
|
+
|
|
18
|
+
export interface ISRRouterContext {
|
|
19
|
+
/** D1 database binding (Cloudflare) or null (Node.js / test) */
|
|
20
|
+
db?: unknown
|
|
21
|
+
/** Raw env object (for module-specific needs) */
|
|
22
|
+
env?: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ISRRouteEntry {
|
|
26
|
+
/** Module name that owns this route */
|
|
27
|
+
module: string
|
|
28
|
+
/**
|
|
29
|
+
* Path matcher: exact string or function.
|
|
30
|
+
* String starting with '/' and ending with '/' = prefix match.
|
|
31
|
+
* String starting with '/' = exact match.
|
|
32
|
+
* Function = custom matcher.
|
|
33
|
+
*/
|
|
34
|
+
match: ISRMatcher
|
|
35
|
+
/** Fetch data needed for SSR rendering and meta tags */
|
|
36
|
+
fetch: (pathname: string, ctx: ISRRouterContext) => Promise<unknown>
|
|
37
|
+
/** Generate meta tags from fetched data */
|
|
38
|
+
meta: (data: unknown, pathname: string) => { title: string; description: string }
|
|
39
|
+
/** Max age in seconds (optional, per-route override) */
|
|
40
|
+
maxAge?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class ISRRegistry {
|
|
44
|
+
private entries: ISRRouteEntry[] = []
|
|
45
|
+
|
|
46
|
+
register(entry: ISRRouteEntry): void {
|
|
47
|
+
const idx = this.entries.findIndex(e => e.module === entry.module && e.match === entry.match)
|
|
48
|
+
if (idx >= 0) {
|
|
49
|
+
this.entries[idx] = entry
|
|
50
|
+
} else {
|
|
51
|
+
this.entries.push(entry)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
registerMany(entries: ISRRouteEntry[]): void {
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
this.register(entry)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
match(pathname: string): ISRRouteEntry | null {
|
|
62
|
+
for (const entry of this.entries) {
|
|
63
|
+
if (typeof entry.match === 'string' && !entry.match.endsWith('/')) {
|
|
64
|
+
if (pathname === entry.match) return entry
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const entry of this.entries) {
|
|
68
|
+
if (typeof entry.match === 'string' && entry.match.endsWith('/') && entry.match !== '/') {
|
|
69
|
+
if (pathname.startsWith(entry.match)) return entry
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const entry of this.entries) {
|
|
73
|
+
if (typeof entry.match === 'function') {
|
|
74
|
+
if (entry.match(pathname)) return entry
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const entry of this.entries) {
|
|
78
|
+
if (entry.match === '/' && pathname === '/') return entry
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
isISRRoute(pathname: string): boolean {
|
|
84
|
+
return this.match(pathname) !== null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getExactPaths(): string[] {
|
|
88
|
+
return this.entries.filter(e => typeof e.match === 'string').map(e => e.match as string)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getAll(): ISRRouteEntry[] {
|
|
92
|
+
return [...this.entries]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clear(): void {
|
|
96
|
+
this.entries = []
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const isrRegistry = new ISRRegistry()
|
|
101
|
+
|
|
102
|
+
export function createISRRegistry(): ISRRegistry {
|
|
103
|
+
return new ISRRegistry()
|
|
104
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline cec621fae360ba2b
|
|
3
|
+
*
|
|
4
|
+
*
|
|
5
|
+
* @framework-modify
|
|
6
|
+
* @reason prettier 格式化与注释结构整理;body 改为可选字段
|
|
7
|
+
* @impact 框架文件维护性修改,行为见测试
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ISR HTML renderer — injects SSR body content AND meta tags into HTML template.
|
|
12
|
+
* Full SSG mode: combines React SSR output with SEO meta tags.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function escapeHtml(str: string): string {
|
|
16
|
+
return str
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ISRPageMeta {
|
|
24
|
+
title: string
|
|
25
|
+
description: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ISRRenderOptions {
|
|
29
|
+
/** Pre-built HTML template (from ASSETS or readFileSync) */
|
|
30
|
+
template: string | null
|
|
31
|
+
/** SSR body content (React renderToString output); omitted in meta-only mode */
|
|
32
|
+
body?: string
|
|
33
|
+
/** Meta tags */
|
|
34
|
+
meta: ISRPageMeta
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Inject ISR content (SSR body + meta tags) into an HTML template.
|
|
39
|
+
* If template is null, generates a minimal standalone HTML.
|
|
40
|
+
*/
|
|
41
|
+
export function renderISRPage(opts: ISRRenderOptions): string {
|
|
42
|
+
const { template, body = '', meta } = opts
|
|
43
|
+
const safeTitle = escapeHtml(meta.title)
|
|
44
|
+
const safeDesc = escapeHtml(meta.description)
|
|
45
|
+
|
|
46
|
+
if (template) {
|
|
47
|
+
let html = template
|
|
48
|
+
html = html.replace(/<title>[^<]*<\/title>/, `<title>${safeTitle}</title>`)
|
|
49
|
+
html = html.replace(/<meta\s+name="description"[^>]*>/, '')
|
|
50
|
+
html = html.replace(/<meta\s+property="og:title"[^>]*>/, '')
|
|
51
|
+
html = html.replace(/<meta\s+property="og:description"[^>]*>/, '')
|
|
52
|
+
html = html.replace(
|
|
53
|
+
'</head>',
|
|
54
|
+
` <meta name="description" content="${safeDesc}" />\n <meta property="og:title" content="${safeTitle}" />\n <meta property="og:description" content="${safeDesc}" />\n <meta name="generator" content="ISR-SSG" />\n </head>`
|
|
55
|
+
)
|
|
56
|
+
html = html.replace('<div id="root"></div>', `<div id="root">${body}</div>`)
|
|
57
|
+
return html
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `<!DOCTYPE html>
|
|
61
|
+
<html lang="en">
|
|
62
|
+
<head>
|
|
63
|
+
<meta charset="UTF-8" />
|
|
64
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
65
|
+
<title>${safeTitle}</title>
|
|
66
|
+
<meta name="description" content="${safeDesc}" />
|
|
67
|
+
<meta property="og:title" content="${safeTitle}" />
|
|
68
|
+
<meta property="og:description" content="${safeDesc}" />
|
|
69
|
+
<meta name="generator" content="ISR-SSG" />
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<div id="root">${body}</div>
|
|
73
|
+
</body>
|
|
74
|
+
</html>`
|
|
75
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
|
|
1
|
+
import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
|
|
2
2
|
import { sql } from 'drizzle-orm'
|
|
3
3
|
|
|
4
4
|
export const contentStatuses = ['draft', 'published', 'archived'] as const
|
|
@@ -7,25 +7,33 @@ export type ContentStatus = (typeof contentStatuses)[number]
|
|
|
7
7
|
export const contentCategories = ['article', 'announcement', 'tutorial', 'news', 'policy'] as const
|
|
8
8
|
export type ContentCategory = (typeof contentCategories)[number]
|
|
9
9
|
|
|
10
|
-
export const contents = sqliteTable(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
10
|
+
export const contents = sqliteTable(
|
|
11
|
+
'contents',
|
|
12
|
+
{
|
|
13
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
14
|
+
title: text('title').notNull(),
|
|
15
|
+
body: text('body').notNull(),
|
|
16
|
+
excerpt: text('excerpt'),
|
|
17
|
+
category: text('category', { enum: contentCategories }).notNull(),
|
|
18
|
+
tags: text('tags'),
|
|
19
|
+
status: text('status', { enum: contentStatuses }).notNull().default('draft'),
|
|
20
|
+
author: text('author').notNull(),
|
|
21
|
+
viewCount: integer('view_count').notNull().default(0),
|
|
22
|
+
likeCount: integer('like_count').notNull().default(0),
|
|
23
|
+
publishedAt: integer('published_at', { mode: 'timestamp' }),
|
|
24
|
+
createdAt: integer('created_at', { mode: 'timestamp' })
|
|
25
|
+
.notNull()
|
|
26
|
+
.default(sql`(unixepoch() * 1000)`),
|
|
27
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
|
28
|
+
.notNull()
|
|
29
|
+
.default(sql`(unixepoch() * 1000)`),
|
|
30
|
+
},
|
|
31
|
+
table => ({
|
|
32
|
+
statusIdx: index('contents_status_idx').on(table.status),
|
|
33
|
+
categoryIdx: index('contents_category_idx').on(table.category),
|
|
34
|
+
createdAtIdx: index('contents_created_at_idx').on(table.createdAt),
|
|
35
|
+
})
|
|
36
|
+
)
|
|
29
37
|
|
|
30
38
|
export type ContentTable = typeof contents.$inferSelect
|
|
31
39
|
export type NewContent = typeof contents.$inferInsert
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
|
|
1
|
+
import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
|
|
2
2
|
import { sql } from 'drizzle-orm'
|
|
3
3
|
|
|
4
4
|
export const disputeTypes = [
|
|
@@ -13,27 +13,35 @@ export type DisputeType = (typeof disputeTypes)[number]
|
|
|
13
13
|
export const disputeStatuses = ['pending', 'investigating', 'resolved', 'rejected'] as const
|
|
14
14
|
export type DisputeStatus = (typeof disputeStatuses)[number]
|
|
15
15
|
|
|
16
|
-
export const disputes = sqliteTable(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
16
|
+
export const disputes = sqliteTable(
|
|
17
|
+
'disputes',
|
|
18
|
+
{
|
|
19
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
20
|
+
disputeNo: text('dispute_no').notNull(),
|
|
21
|
+
orderId: text('order_id').notNull(),
|
|
22
|
+
orderNo: text('order_no').notNull(),
|
|
23
|
+
customerName: text('customer_name').notNull(),
|
|
24
|
+
customerEmail: text('customer_email').notNull(),
|
|
25
|
+
type: text('type', { enum: disputeTypes }).notNull(),
|
|
26
|
+
status: text('status', { enum: disputeStatuses }).notNull().default('pending'),
|
|
27
|
+
description: text('description').notNull(),
|
|
28
|
+
resolution: text('resolution'),
|
|
29
|
+
amount: integer('amount').notNull(),
|
|
30
|
+
resolvedAt: integer('resolved_at', { mode: 'timestamp' }),
|
|
31
|
+
resolvedBy: text('resolved_by'),
|
|
32
|
+
createdAt: integer('created_at', { mode: 'timestamp' })
|
|
33
|
+
.notNull()
|
|
34
|
+
.default(sql`(unixepoch() * 1000)`),
|
|
35
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
|
36
|
+
.notNull()
|
|
37
|
+
.default(sql`(unixepoch() * 1000)`),
|
|
38
|
+
},
|
|
39
|
+
table => ({
|
|
40
|
+
statusIdx: index('disputes_status_idx').on(table.status),
|
|
41
|
+
orderIdIdx: index('disputes_order_id_idx').on(table.orderId),
|
|
42
|
+
createdAtIdx: index('disputes_created_at_idx').on(table.createdAt),
|
|
43
|
+
})
|
|
44
|
+
)
|
|
37
45
|
|
|
38
46
|
export type DisputeTable = typeof disputes.$inferSelect
|
|
39
47
|
export type NewDispute = typeof disputes.$inferInsert
|
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
import { sql } from 'drizzle-orm'
|
|
1
|
+
import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import { sql } from 'drizzle-orm'
|
|
3
3
|
|
|
4
|
-
export const notifications = sqliteTable(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
.notNull()
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
export const notifications = sqliteTable(
|
|
5
|
+
'notifications',
|
|
6
|
+
{
|
|
7
|
+
id: text('id').primaryKey(),
|
|
8
|
+
type: text('type').notNull(),
|
|
9
|
+
title: text('title').notNull(),
|
|
10
|
+
message: text('message').notNull(),
|
|
11
|
+
read: integer('read', { mode: 'boolean' }).notNull().default(false),
|
|
12
|
+
createdAt: integer('created_at', { mode: 'timestamp' })
|
|
13
|
+
.notNull()
|
|
14
|
+
.default(sql`(unixepoch() * 1000)`),
|
|
15
|
+
},
|
|
16
|
+
table => ({
|
|
17
|
+
createdAtIdx: index('notifications_created_at_idx').on(table.createdAt),
|
|
18
|
+
})
|
|
19
|
+
)
|
|
14
20
|
|
|
15
|
-
export type NotificationTable = typeof notifications.$inferSelect
|
|
16
|
-
export type NewNotification = typeof notifications.$inferInsert
|
|
21
|
+
export type NotificationTable = typeof notifications.$inferSelect
|
|
22
|
+
export type NewNotification = typeof notifications.$inferInsert
|