@h-ai/cli 0.1.0-alpha.57 → 0.1.0-alpha.58
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/README.en.md +37 -3
- package/README.ja.md +37 -3
- package/README.md +37 -3
- package/dist/chunk-2H7UOFLK.js +0 -0
- package/dist/{chunk-MJGUBOAV.js → chunk-F4IBPVZ5.js} +6 -42
- package/dist/chunk-F4IBPVZ5.js.map +1 -0
- package/dist/{chunk-O3ANVICQ.js → chunk-WUSFJFW5.js} +13 -7
- package/dist/chunk-WUSFJFW5.js.map +1 -0
- package/dist/cli-config-templates-6XA7V6CC.js +5 -0
- package/dist/{cli-config-templates-OWBEGBZQ.js.map → cli-config-templates-6XA7V6CC.js.map} +1 -1
- package/dist/{cli-skill-templates-ZYXES3NT.js → cli-skill-templates-45CK4RQ2.js} +3 -3
- package/dist/{cli-skill-templates-ZYXES3NT.js.map → cli-skill-templates-45CK4RQ2.js.map} +1 -1
- package/dist/index.js +107 -61
- package/dist/index.js.map +1 -1
- package/dist/{src-TBIET7YU.js → src-DUGLX4YB.js} +7270 -2119
- package/dist/src-DUGLX4YB.js.map +1 -0
- package/package.json +17 -17
- package/templates/README.md +4 -4
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +7 -0
- package/templates/apps/admin/src/routes/+page.svelte.hbs +5 -4
- package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +2 -2
- package/templates/apps/api/.npmrc.hbs +1 -2
- package/templates/apps/api/Dockerfile.hbs +6 -5
- package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +2 -0
- package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +8 -0
- package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +7 -8
- package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +4 -3
- package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +0 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +4 -0
- package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +3 -1
- package/templates/apps/api/e2e/api-service.spec.ts.hbs +3 -1
- package/templates/apps/api/package.json.hbs +2 -2
- package/templates/apps/api/pnpm-workspace.yaml.hbs +26 -0
- package/templates/apps/api/tsconfig.base.json +0 -1
- package/templates/apps/fullstack/.dockerignore.hbs +3 -0
- package/templates/apps/fullstack/.npmrc.hbs +1 -2
- package/templates/apps/fullstack/Dockerfile.hbs +6 -5
- package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +7 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +1 -34
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +1 -34
- package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +30 -170
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +6 -5
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +48 -22
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +6 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -4
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +10 -141
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +5 -5
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +3 -3
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +21 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +6 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +5 -143
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +48 -22
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +206 -16
- package/templates/apps/fullstack/package.json.hbs +2 -2
- package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +66 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +96 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +3 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +5 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +13 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +21 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +63 -28
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +26 -11
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +276 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +29 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +94 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +6 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +5 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +98 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +446 -33
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +48 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +19 -26
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +78 -15
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +78 -15
- package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +4 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +3 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +53 -27
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +73 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +55 -45
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AuditManagement.svelte.hbs +61 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +57 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/RoleManagement.svelte.hbs +49 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +174 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +399 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +7 -39
- package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +151 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +101 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
- package/templates/apps/fullstack/playwright.config.ts.hbs +6 -0
- package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +24 -0
- package/templates/apps/fullstack/tsconfig.base.json +0 -1
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +4 -0
- package/templates/apps/h5/src/routes/+page.svelte.hbs +3 -2
- package/templates/apps/mobile-app/index.html.hbs +2 -2
- package/templates/apps/mobile-app/src/App.svelte.hbs +6 -2
- package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +13 -0
- package/templates/apps/website/src/routes/+layout.svelte.hbs +8 -2
- package/templates/apps/website/src/routes/+page.svelte.hbs +4 -3
- package/templates/base/.npmrc.hbs +1 -15
- package/templates/base/Dockerfile.hbs +9 -8
- package/templates/base/e2e/app.spec.ts.hbs +1 -0
- package/templates/base/package.json.hbs +39 -42
- package/templates/base/pnpm-workspace.yaml.hbs +26 -0
- package/templates/base/src/app.d.ts.hbs +4 -0
- package/templates/base/src/hooks.server.ts.hbs +0 -8
- package/templates/base/src/lib/server/init.ts.hbs +26 -5
- package/templates/base/src/routes/+layout.server.ts.hbs +18 -0
- package/templates/base/tests/scaffold.test.ts.hbs +21 -1
- package/templates/base/vite.config.ts.hbs +21 -0
- package/templates/features/ai/routes/api/chat/+server.ts +6 -7
- package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +1 -1
- package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +1 -1
- package/templates/features/iam/routes-shared/api/auth/login/+server.ts +14 -10
- package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +6 -4
- package/templates/features/storage/routes/api/upload/+server.ts +10 -8
- package/templates/skills/CLAUDE.md +0 -14
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +13 -2
- package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +3 -0
- package/templates/skills/hai-api-client/SKILL.md +1 -1
- package/templates/skills/hai-app-create/development.md +6 -0
- package/templates/skills/hai-build/reference.md +4 -2
- package/templates/skills/hai-core/SKILL.md +2 -0
- package/templates/skills/hai-core/reference.md +6 -4
- package/templates/skills/hai-framework-sync/SKILL.md +1 -2
- package/templates/skills/hai-serv/SKILL.md +1 -0
- package/templates/skills/hai-serv/reference.md +3 -1
- package/templates/skills/hai-ui/SKILL.md +6 -0
- package/templates/skills/hai-ui/reference.md +7 -4
- package/templates/skills/hai-vecdb/reference.md +53 -49
- package/dist/chunk-MJGUBOAV.js.map +0 -1
- package/dist/chunk-O3ANVICQ.js.map +0 -1
- package/dist/cli-config-templates-OWBEGBZQ.js +0 -5
- package/dist/src-TBIET7YU.js.map +0 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/tests/api.test.ts.hbs +0 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/tests/api.test.ts.hbs +0 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +0 -55
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/LanguageSwitcher.svelte.hbs +0 -27
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ThemeSwitcher.svelte.hbs +0 -18
- package/templates/skills/bridges/admin/CLAUDE.md +0 -15
- package/templates/skills/bridges/api/CLAUDE.md +0 -15
- package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +0 -9
- package/templates/skills/bridges/h5/CLAUDE.md +0 -15
- package/templates/skills/bridges/mobile-app/CLAUDE.md +0 -15
- package/templates/skills/bridges/website/CLAUDE.md +0 -15
- package/templates/skills/claude-skill.md.hbs +0 -7
|
@@ -1,33 +1,223 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import process from 'node:process'
|
|
1
5
|
import { apiContract } from '@h-ai/api-contract'
|
|
2
6
|
import { expect, test } from '@playwright/test'
|
|
3
7
|
import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
|
|
4
8
|
|
|
5
|
-
const
|
|
6
|
-
const apiBaseUrl = process.env.SERVICE_BASE_URL || DEFAULT_SERVICE_BASE_URL
|
|
9
|
+
const apiBaseUrl = process.env.SERVICE_BASE_URL || 'http://127.0.0.1:3000'
|
|
7
10
|
const apiPrefix = '/api/v1'
|
|
11
|
+
const adminPassword = process.env.E2E_ADMIN_PASSWORD || 'Scaffold-test-Admin123!'
|
|
12
|
+
const configuredName = process.env.HAI_CORE_NAME || 'Configured scaffold'
|
|
13
|
+
const configuredVersion = process.env.HAI_CORE_VERSION || '9.8.7'
|
|
14
|
+
const configuredEnv = process.env.HAI_CORE_ENV || 'test'
|
|
15
|
+
|
|
16
|
+
// 测试专用的一像素 PNG;上传仍使用完整前后端加密链路。
|
|
17
|
+
const avatar = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aSxkAAAAASUVORK5CYII=', 'base64')
|
|
18
|
+
|
|
19
|
+
async function login(page: Page, username: string, password: string) {
|
|
20
|
+
await expect(page.getByTestId('login-form')).toBeVisible()
|
|
21
|
+
await page.locator('#login-username').fill(username)
|
|
22
|
+
await page.getByTestId('login-form').locator('input[type="password"]').fill(password)
|
|
23
|
+
await page.getByTestId('login-form').getByRole('button', { name: /登录|Sign in|Login|Log in/i }).click()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function userEditor(page: Page, username: string) {
|
|
27
|
+
await page.getByTestId('users-search').locator('input').fill(username)
|
|
28
|
+
await page.getByTestId('users-page').getByRole('button', { name: /查询|Search/ }).click()
|
|
29
|
+
const row = page.getByRole('row').filter({ hasText: username })
|
|
30
|
+
await expect(row).toBeVisible()
|
|
31
|
+
await row.getByRole('button', { name: /编辑|Edit/, exact: true }).click()
|
|
32
|
+
return row
|
|
33
|
+
}
|
|
8
34
|
|
|
9
35
|
test('service health endpoint is ready', async ({ request }) => {
|
|
10
36
|
const response = await request.get(`${apiBaseUrl}/health`)
|
|
11
|
-
expect(response.ok()).toBe(true)
|
|
12
37
|
await expect(response).toBeOK()
|
|
13
38
|
})
|
|
14
39
|
|
|
15
|
-
test('service
|
|
16
|
-
const response = await request.post(`${apiBaseUrl}${apiPrefix}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
|
|
17
|
-
|
|
18
|
-
})
|
|
19
|
-
expect(response.ok()).toBe(true)
|
|
20
|
-
|
|
21
|
-
const body = await response.json()
|
|
22
|
-
expect(body.success).toBe(true)
|
|
23
|
-
expect(body.data.message).toBe('hello e2e')
|
|
24
|
-
expect(body.data.requestId).toBeTruthy()
|
|
40
|
+
test('service rejects plaintext business requests', async ({ request }) => {
|
|
41
|
+
const response = await request.post(`${apiBaseUrl}${apiPrefix}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, { data: { message: 'hello e2e' } })
|
|
42
|
+
expect(response.status()).toBe(400)
|
|
25
43
|
})
|
|
44
|
+
|
|
26
45
|
{{#if fullstack.e2eFrontend}}
|
|
27
46
|
|
|
28
|
-
test('frontend
|
|
47
|
+
test('frontend displays configured core metadata and restores the administrator session', async ({ page }) => {
|
|
48
|
+
await page.goto('/')
|
|
49
|
+
await expect(page.getByTestId('app-name')).toHaveText(configuredName)
|
|
50
|
+
await expect(page).toHaveTitle(configuredName)
|
|
51
|
+
await expect(page.getByTestId('app-version')).toHaveText(`v${configuredVersion}`)
|
|
52
|
+
await expect(page.getByTestId('app-environment')).toHaveText(configuredEnv)
|
|
53
|
+
await login(page, 'admin', adminPassword)
|
|
54
|
+
await expect(page.getByTestId('users-page')).toBeVisible()
|
|
55
|
+
await page.reload()
|
|
56
|
+
await expect(page.getByTestId('users-page')).toBeVisible()
|
|
57
|
+
await expect(page.getByTestId('nav-roles')).toBeVisible()
|
|
58
|
+
await expect(page.getByTestId('nav-audit')).toBeVisible()
|
|
59
|
+
await page.getByTestId('logout').click()
|
|
60
|
+
await expect(page.getByTestId('login-form')).toBeVisible()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('audit filters align and fit desktop, tablet and narrow mobile screens', async ({ page }) => {
|
|
29
64
|
await page.goto('/')
|
|
30
|
-
await
|
|
31
|
-
await
|
|
65
|
+
await login(page, 'admin', adminPassword)
|
|
66
|
+
await page.getByTestId('nav-audit').click()
|
|
67
|
+
await expect(page.getByTestId('audit-page')).toBeVisible()
|
|
68
|
+
for (const width of [1440, 960, 390, 320]) {
|
|
69
|
+
await page.setViewportSize({ width, height: 900 })
|
|
70
|
+
const controls = await page.getByTestId('audit-page').locator('form').evaluate(form => [
|
|
71
|
+
form.querySelector('#audit-action')?.parentElement,
|
|
72
|
+
form.querySelector('#audit-resource')?.parentElement,
|
|
73
|
+
form.querySelector('#audit-start'),
|
|
74
|
+
form.querySelector('#audit-end'),
|
|
75
|
+
form.querySelector('button[type="submit"]'),
|
|
76
|
+
].map((element) => {
|
|
77
|
+
if (!element)
|
|
78
|
+
throw new Error('Audit filter control is missing')
|
|
79
|
+
const rect = element.getBoundingClientRect()
|
|
80
|
+
return { top: rect.top, bottom: rect.bottom, height: rect.height }
|
|
81
|
+
}))
|
|
82
|
+
for (const control of controls)
|
|
83
|
+
expect(control.height).toBeCloseTo(40, 0)
|
|
84
|
+
if (width >= 1280) {
|
|
85
|
+
expect(Math.max(...controls.map(control => control.top)) - Math.min(...controls.map(control => control.top))).toBeLessThan(1)
|
|
86
|
+
expect(Math.max(...controls.map(control => control.bottom)) - Math.min(...controls.map(control => control.bottom))).toBeLessThan(1)
|
|
87
|
+
}
|
|
88
|
+
else if (width >= 640) {
|
|
89
|
+
expect(controls[0]!.top).toBeCloseTo(controls[1]!.top, 0)
|
|
90
|
+
expect(controls[2]!.top).toBeCloseTo(controls[3]!.top, 0)
|
|
91
|
+
}
|
|
92
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('administrator and user complete account, avatar, preference, password and audit workflows', async ({ page, browser }) => {
|
|
97
|
+
test.setTimeout(120_000)
|
|
98
|
+
const username = `member-${randomUUID().slice(0, 8)}`
|
|
99
|
+
const password = 'User-test-Pass123!'
|
|
100
|
+
const newPassword = 'Changed-test-Pass123!'
|
|
101
|
+
const encryptedRequests: string[] = []
|
|
102
|
+
const encryptedResponses: string[] = []
|
|
103
|
+
page.on('request', (request) => {
|
|
104
|
+
if (request.url().includes(`${apiPrefix}/`) && !request.url().includes('/_hai/') && request.postData()) {
|
|
105
|
+
expect(request.headers()['x-encrypted']).toBe('true')
|
|
106
|
+
expect(request.postData()).not.toContain('"password"')
|
|
107
|
+
expect(request.postData()).not.toContain(adminPassword)
|
|
108
|
+
encryptedRequests.push(request.url())
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
page.on('response', (response) => {
|
|
112
|
+
if (response.request().method() !== 'OPTIONS' && response.url().includes(`${apiPrefix}/`) && !response.url().includes('/_hai/') && !response.url().includes('/assets/')) {
|
|
113
|
+
expect(response.headers()['x-encrypted']).toBe('true')
|
|
114
|
+
encryptedResponses.push(response.url())
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
await page.goto('/')
|
|
118
|
+
await login(page, 'admin', adminPassword)
|
|
119
|
+
await expect(page.getByTestId('users-page')).toBeVisible()
|
|
120
|
+
await page.getByTestId('create-user').click()
|
|
121
|
+
await expect(page.getByRole('dialog', { name: /新增用户|New user|Create user|Add user/ })).toBeVisible()
|
|
122
|
+
await expect(page.getByTestId('user-username').locator('input')).toBeFocused()
|
|
123
|
+
await page.getByTestId('user-username').locator('input').fill(username)
|
|
124
|
+
await page.getByTestId('user-display-name').locator('input').fill('Test member')
|
|
125
|
+
await page.getByTestId('user-email').locator('input').fill(`${username}@example.com`)
|
|
126
|
+
await page.getByTestId('user-password').locator('input').fill(password)
|
|
127
|
+
await page.getByTestId('user-save').click()
|
|
128
|
+
await expect(page.getByTestId('user-editor')).not.toBeVisible()
|
|
129
|
+
const userRow = page.getByRole('row').filter({ hasText: username })
|
|
130
|
+
await expect(userRow).toBeVisible()
|
|
131
|
+
await userRow.getByTestId('user-avatar-input').setInputFiles({ name: 'avatar.png', mimeType: 'image/png', buffer: avatar })
|
|
132
|
+
await expect(userRow.locator('img')).toHaveAttribute('src', /\/assets\/avatars\//)
|
|
133
|
+
await expect.poll(() => userRow.locator('img').evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
|
|
134
|
+
await page.getByTestId('nav-roles').click()
|
|
135
|
+
await expect(page.getByTestId('roles-page')).toBeVisible()
|
|
136
|
+
await page.getByTestId('roles-page').getByRole('button', { name: /编辑说明|Edit description/ }).first().click()
|
|
137
|
+
await page.getByTestId('role-description').locator('textarea').fill('Workspace administration')
|
|
138
|
+
await page.getByRole('dialog').getByRole('button', { name: /保存|Save/, exact: true }).focus()
|
|
139
|
+
await page.keyboard.press('Tab')
|
|
140
|
+
await expect(page.getByRole('dialog').getByRole('button', { name: /关闭|Close/, exact: true })).toBeFocused()
|
|
141
|
+
await page.keyboard.press('Shift+Tab')
|
|
142
|
+
await expect(page.getByRole('dialog').getByRole('button', { name: /保存|Save/, exact: true })).toBeFocused()
|
|
143
|
+
await page.getByRole('dialog').getByRole('button', { name: /保存|Save/, exact: true }).click()
|
|
144
|
+
await expect(page.getByTestId('roles-page')).toContainText('Workspace administration')
|
|
145
|
+
|
|
146
|
+
const userContext = await browser.newContext()
|
|
147
|
+
const userPage = await userContext.newPage()
|
|
148
|
+
try {
|
|
149
|
+
await userPage.goto('/')
|
|
150
|
+
await login(userPage, username, password)
|
|
151
|
+
await expect(userPage.getByTestId('settings-page')).toBeVisible()
|
|
152
|
+
await expect(userPage.getByTestId('nav-users')).toHaveCount(0)
|
|
153
|
+
await expect(userPage.getByTestId('nav-roles')).toHaveCount(0)
|
|
154
|
+
await expect(userPage.getByTestId('nav-audit')).toHaveCount(0)
|
|
155
|
+
await userPage.getByTestId('profile-form').locator('input[name="displayName"]').fill('Updated member')
|
|
156
|
+
await userPage.getByTestId('profile-form').locator('input[name="email"]').fill('')
|
|
157
|
+
await userPage.getByTestId('profile-form').getByRole('button', { name: /保存|Save/, exact: true }).click()
|
|
158
|
+
await expect(userPage.getByTestId('notice')).toBeVisible()
|
|
159
|
+
await userPage.getByTestId('profile-form').locator('input[type="file"]').setInputFiles({ name: 'personal.png', mimeType: 'image/png', buffer: avatar })
|
|
160
|
+
await expect(userPage.getByTestId('notice')).toHaveText(/头像已更新|Avatar updated/)
|
|
161
|
+
await userPage.getByTestId('profile-language').click()
|
|
162
|
+
await userPage.getByRole('option', { name: 'English', exact: true }).click()
|
|
163
|
+
await expect(userPage.getByTestId('nav-settings')).toHaveText('Personal settings')
|
|
164
|
+
await expect(userPage.locator('html')).toHaveAttribute('lang', 'en-US')
|
|
165
|
+
const themes = [{{#each fullstack.theme.themes}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]
|
|
166
|
+
const selectedTheme = themes.find(theme => theme !== '{{fullstack.theme.defaultTheme}}') ?? themes[0]
|
|
167
|
+
await userPage.getByTestId('profile-theme').click()
|
|
168
|
+
await userPage.getByRole('option').nth(themes.indexOf(selectedTheme)).click()
|
|
169
|
+
await expect(userPage.locator('html')).toHaveAttribute('data-theme', selectedTheme)
|
|
170
|
+
await userPage.reload()
|
|
171
|
+
await expect(userPage.getByTestId('settings-page')).toBeVisible()
|
|
172
|
+
await expect(userPage.getByTestId('nav-settings')).toHaveText('Personal settings')
|
|
173
|
+
await expect(userPage.locator('html')).toHaveAttribute('data-theme', selectedTheme)
|
|
174
|
+
await expect(userPage.getByTestId('profile-form').locator('input[name="displayName"]')).toHaveValue('Updated member')
|
|
175
|
+
await expect(userPage.getByTestId('profile-form').locator('input[name="email"]')).toHaveValue('')
|
|
176
|
+
await expect.poll(() => userPage.getByTestId('profile-form').locator('img').evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
|
|
177
|
+
await userPage.setViewportSize({ width: 390, height: 844 })
|
|
178
|
+
await expect.poll(() => userPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
|
179
|
+
await userPage.getByTestId('mobile-menu').click()
|
|
180
|
+
await expect(userPage.getByTestId('nav-settings')).toBeVisible()
|
|
181
|
+
await userPage.getByTestId('nav-settings').click()
|
|
182
|
+
const passwordInputs = userPage.getByTestId('password-form').locator('input[type="password"]')
|
|
183
|
+
await passwordInputs.nth(0).fill(password)
|
|
184
|
+
await passwordInputs.nth(1).fill(password)
|
|
185
|
+
await passwordInputs.nth(2).fill(password)
|
|
186
|
+
await userPage.getByTestId('password-form').getByRole('button', { name: /Change password/i }).click()
|
|
187
|
+
await expect(userPage.getByTestId('settings-page')).toBeVisible()
|
|
188
|
+
await expect(userPage.getByTestId('error')).toContainText(/检查|check/i)
|
|
189
|
+
await passwordInputs.nth(1).fill(newPassword)
|
|
190
|
+
await passwordInputs.nth(2).fill(newPassword)
|
|
191
|
+
await userPage.getByTestId('password-form').getByRole('button', { name: /Change password/i }).click()
|
|
192
|
+
await expect(userPage.getByTestId('login-form')).toBeVisible()
|
|
193
|
+
await login(userPage, username, password)
|
|
194
|
+
await expect(userPage.getByTestId('login-form')).toContainText(/不正确|incorrect/)
|
|
195
|
+
await login(userPage, username, newPassword)
|
|
196
|
+
await expect(userPage.getByTestId('settings-page')).toBeVisible()
|
|
197
|
+
|
|
198
|
+
await page.getByTestId('nav-users').click()
|
|
199
|
+
await userEditor(page, username)
|
|
200
|
+
await page.getByTestId('user-editor').locator('input[type="checkbox"]').uncheck()
|
|
201
|
+
await page.getByTestId('user-save').click()
|
|
202
|
+
await expect(page.getByTestId('user-editor')).not.toBeVisible()
|
|
203
|
+
await userPage.getByTestId('profile-form').getByRole('button', { name: 'Save', exact: true }).click()
|
|
204
|
+
await expect(userPage.getByTestId('login-form')).toBeVisible()
|
|
205
|
+
await expect(userPage.getByTestId('login-form')).toContainText(/expired|过期/)
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await userContext.close()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await page.getByRole('row').filter({ hasText: username }).getByRole('button', { name: /删除|Delete/, exact: true }).click()
|
|
212
|
+
await page.getByTestId('confirm-delete-user').click()
|
|
213
|
+
await expect(page.getByRole('row').filter({ hasText: username })).toHaveCount(0)
|
|
214
|
+
await page.getByTestId('nav-audit').click()
|
|
215
|
+
await page.getByTestId('audit-resource').locator('input').fill('users')
|
|
216
|
+
await page.getByTestId('audit-page').getByRole('button', { name: /查询|Search/ }).click()
|
|
217
|
+
await expect(page.getByTestId('audit-page')).toContainText('delete')
|
|
218
|
+
await page.getByTestId('audit-page').getByRole('button', { name: /详情|Details/ }).first().click()
|
|
219
|
+
await expect(page.getByRole('dialog')).toContainText(/users/)
|
|
220
|
+
expect(encryptedRequests.length).toBeGreaterThan(5)
|
|
221
|
+
expect(encryptedResponses.length).toBeGreaterThan(5)
|
|
32
222
|
})
|
|
33
223
|
{{/if}}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"engines": { "node": "
|
|
2
|
+
"engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" },
|
|
3
3
|
"name": "{{projectName}}",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"version": "0.1.0",
|
|
6
6
|
"private": true,
|
|
7
|
-
"packageManager": "pnpm@
|
|
7
|
+
"packageManager": "pnpm@12.11.2",
|
|
8
8
|
"description": "Full-stack hai workspace with separated contract, service and frontends.",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"i18n:compile": "pnpm --filter {{fullstack.sharedPackageName}} paraglide:compile{{#each fullstack.frontendApps}} && pnpm --filter {{packageName}} paraglide:compile{{/each}}",
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
- `src/app-schemas.ts`:跨接口、跨层复用的输入/数据 Schema 与类型。
|
|
8
8
|
- `src/app-contract.ts`:app 领域 contract;每个业务 path 直接写在对应接口定义中。
|
|
9
|
+
- `src/admin-schemas.ts` / `src/admin-contract.ts`:管理员用户、固定角色、分页审计、个人设置、加密 JSON 头像上传 Schema/类型/路由;认证复用框架的 IAM auth contract,未开放自助注册和其它认证方式。
|
|
9
10
|
- `src/{{projectName}}-contract.ts`:工程级 contract,供 `@h-ai/serv` 与 `@h-ai/api-client` 共用。
|
|
10
11
|
|
|
11
12
|
只使用一次的输出 Schema 直接内联到 `.output(...)`;同一 contract 文件内重复使用时才保留私有常量。
|
|
12
13
|
API 前缀、健康检查与跨端 Header 等传输配置位于 serv 包的 `config/_serv.yml`。
|
|
14
|
+
`SUPPORTED_LOCALES` 为生成时实际编译的语言列表;`app.info` 中名称、版本、环境、默认语言来自后端 core 配置。更新公开契约时同时更新 serv、shared typed API client、各端 UI 与测试。
|
|
13
15
|
|
|
14
16
|
## 开发
|
|
15
17
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import {
|
|
4
|
+
AdminAuditInputSchema,
|
|
5
|
+
AdminAuditSchema,
|
|
6
|
+
AdminCreateUserSchema,
|
|
7
|
+
AdminRoleSchema,
|
|
8
|
+
AdminUpdateUserSchema,
|
|
9
|
+
AdminUserSchema,
|
|
10
|
+
AdminUsersInputSchema,
|
|
11
|
+
AdminUsersSchema,
|
|
12
|
+
AvatarOutputSchema,
|
|
13
|
+
AvatarUploadSchema,
|
|
14
|
+
ProfileUpdateSchema,
|
|
15
|
+
} from './admin-schemas.js'
|
|
16
|
+
|
|
17
|
+
/** mini 管理业务路径统一定义;仅开放两种固定角色。 */
|
|
18
|
+
export const adminContract = {
|
|
19
|
+
users: {
|
|
20
|
+
list: apiContract.route({ method: 'POST', path: '/admin/users/list', tags: ['admin'] })
|
|
21
|
+
.input(AdminUsersInputSchema)
|
|
22
|
+
.output(apiContract.haiResultSchema(AdminUsersSchema)),
|
|
23
|
+
create: apiContract.route({ method: 'POST', path: '/admin/users/create', tags: ['admin'] })
|
|
24
|
+
.input(AdminCreateUserSchema)
|
|
25
|
+
.output(apiContract.haiResultSchema(AdminUserSchema)),
|
|
26
|
+
update: apiContract.route({ method: 'POST', path: '/admin/users/update', tags: ['admin'] })
|
|
27
|
+
.input(AdminUpdateUserSchema)
|
|
28
|
+
.output(apiContract.haiResultSchema(AdminUserSchema)),
|
|
29
|
+
delete: apiContract.route({ method: 'POST', path: '/admin/users/delete', tags: ['admin'] })
|
|
30
|
+
.input(z.object({ id: z.string().min(1) }).strict())
|
|
31
|
+
.output(apiContract.haiResultSchema(z.object({ deleted: z.literal(true) }))),
|
|
32
|
+
avatar: apiContract.route({ method: 'POST', path: '/admin/users/avatar', tags: ['admin'] })
|
|
33
|
+
.input(AvatarUploadSchema.extend({ id: z.string().min(1) }))
|
|
34
|
+
.output(apiContract.haiResultSchema(AvatarOutputSchema)),
|
|
35
|
+
},
|
|
36
|
+
roles: {
|
|
37
|
+
list: apiContract.route({ method: 'POST', path: '/admin/roles/list', tags: ['admin'] })
|
|
38
|
+
.output(apiContract.haiResultSchema(z.object({ items: z.array(AdminRoleSchema) }))),
|
|
39
|
+
update: apiContract.route({ method: 'POST', path: '/admin/roles/update', tags: ['admin'] })
|
|
40
|
+
.input(z.object({ id: z.string().min(1), description: z.string().max(300) }).strict())
|
|
41
|
+
.output(apiContract.haiResultSchema(AdminRoleSchema)),
|
|
42
|
+
},
|
|
43
|
+
audit: {
|
|
44
|
+
list: apiContract.route({ method: 'POST', path: '/admin/audit/list', tags: ['admin'] })
|
|
45
|
+
.input(AdminAuditInputSchema)
|
|
46
|
+
.output(apiContract.haiResultSchema(AdminAuditSchema)),
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const profileContract = {
|
|
51
|
+
update: apiContract.route({ method: 'POST', path: '/profile/update', tags: ['profile'] })
|
|
52
|
+
.input(ProfileUpdateSchema)
|
|
53
|
+
.output(apiContract.haiResultSchema(AdminUserSchema)),
|
|
54
|
+
avatar: apiContract.route({ method: 'POST', path: '/profile/avatar', tags: ['profile'] })
|
|
55
|
+
.input(AvatarUploadSchema)
|
|
56
|
+
.output(apiContract.haiResultSchema(AvatarOutputSchema)),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 复用框架认证契约,避免额外开放自助注册和未配置的认证方式。 */
|
|
60
|
+
export const authContract = {
|
|
61
|
+
login: apiContract.iam.auth.login,
|
|
62
|
+
logout: apiContract.iam.auth.logout,
|
|
63
|
+
currentUser: apiContract.iam.auth.currentUser,
|
|
64
|
+
refresh: apiContract.iam.auth.refresh,
|
|
65
|
+
changePassword: apiContract.iam.auth.changePassword,
|
|
66
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const AdminRoleCodeSchema = z.enum(['admin', 'user'])
|
|
4
|
+
export const AdminPageInputSchema = z.object({
|
|
5
|
+
page: z.number().int().min(1).default(1),
|
|
6
|
+
pageSize: z.number().int().min(1).max(100).default(20),
|
|
7
|
+
})
|
|
8
|
+
export const AdminUserSchema = z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
username: z.string(),
|
|
11
|
+
email: z.string().optional(),
|
|
12
|
+
displayName: z.string().optional(),
|
|
13
|
+
avatarUrl: z.string().optional(),
|
|
14
|
+
enabled: z.boolean(),
|
|
15
|
+
roles: z.array(AdminRoleCodeSchema),
|
|
16
|
+
createdAt: z.string().datetime(),
|
|
17
|
+
updatedAt: z.string().datetime(),
|
|
18
|
+
})
|
|
19
|
+
export const AdminUsersInputSchema = AdminPageInputSchema.extend({
|
|
20
|
+
search: z.string().max(100).optional(),
|
|
21
|
+
enabled: z.boolean().optional(),
|
|
22
|
+
})
|
|
23
|
+
export const AdminUsersSchema = z.object({
|
|
24
|
+
items: z.array(AdminUserSchema),
|
|
25
|
+
total: z.number(),
|
|
26
|
+
page: z.number(),
|
|
27
|
+
pageSize: z.number(),
|
|
28
|
+
})
|
|
29
|
+
export const AdminCreateUserSchema = z.object({
|
|
30
|
+
username: z.string().min(3).max(64).regex(/^[\w-]+$/),
|
|
31
|
+
password: z.string().min(8).max(128),
|
|
32
|
+
email: z.email().optional(),
|
|
33
|
+
displayName: z.string().max(100).optional(),
|
|
34
|
+
role: AdminRoleCodeSchema.default('user'),
|
|
35
|
+
enabled: z.boolean().default(true),
|
|
36
|
+
}).strict()
|
|
37
|
+
export const AdminUpdateUserSchema = z.object({
|
|
38
|
+
id: z.string().min(1),
|
|
39
|
+
email: z.union([z.email(), z.literal('')]).optional(),
|
|
40
|
+
displayName: z.string().max(100).optional(),
|
|
41
|
+
enabled: z.boolean().optional(),
|
|
42
|
+
role: AdminRoleCodeSchema.optional(),
|
|
43
|
+
}).strict()
|
|
44
|
+
export const AdminRoleSchema = z.object({
|
|
45
|
+
id: z.string(),
|
|
46
|
+
code: AdminRoleCodeSchema,
|
|
47
|
+
name: z.string(),
|
|
48
|
+
description: z.string().optional(),
|
|
49
|
+
userCount: z.number(),
|
|
50
|
+
})
|
|
51
|
+
export const AdminAuditInputSchema = AdminPageInputSchema.extend({
|
|
52
|
+
userId: z.string().max(100).optional(),
|
|
53
|
+
action: z.string().max(50).optional(),
|
|
54
|
+
resource: z.string().max(50).optional(),
|
|
55
|
+
startDate: z.string().datetime().optional(),
|
|
56
|
+
endDate: z.string().datetime().optional(),
|
|
57
|
+
})
|
|
58
|
+
export const AdminAuditLogSchema = z.object({
|
|
59
|
+
id: z.string(),
|
|
60
|
+
userId: z.string().nullable(),
|
|
61
|
+
username: z.string().nullable(),
|
|
62
|
+
action: z.string(),
|
|
63
|
+
resource: z.string(),
|
|
64
|
+
resourceId: z.string().nullable(),
|
|
65
|
+
details: z.string().nullable(),
|
|
66
|
+
ipAddress: z.string().nullable(),
|
|
67
|
+
userAgent: z.string().nullable(),
|
|
68
|
+
createdAt: z.string().datetime(),
|
|
69
|
+
})
|
|
70
|
+
export const AdminAuditSchema = z.object({
|
|
71
|
+
items: z.array(AdminAuditLogSchema),
|
|
72
|
+
total: z.number(),
|
|
73
|
+
page: z.number(),
|
|
74
|
+
pageSize: z.number(),
|
|
75
|
+
})
|
|
76
|
+
export const ProfileUpdateSchema = z.object({
|
|
77
|
+
displayName: z.string().max(100).optional(),
|
|
78
|
+
email: z.union([z.email(), z.literal('')]).optional(),
|
|
79
|
+
locale: z.enum(['zh-CN', 'en-US']).optional(),
|
|
80
|
+
theme: z.string().min(1).max(40).regex(/^[\w-]+$/).optional(),
|
|
81
|
+
}).strict()
|
|
82
|
+
/** 512 KiB 头像以 JSON 传输,上传及响应均经过统一加密。 */
|
|
83
|
+
export const AvatarUploadSchema = z.object({
|
|
84
|
+
contentType: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
|
85
|
+
base64: z.string().min(1).max(699052).regex(/^[A-Z\d+/]+={0,2}$/i),
|
|
86
|
+
}).strict()
|
|
87
|
+
export const AvatarOutputSchema = z.object({ avatarUrl: z.string().describe('Generated relative asset path') })
|
|
88
|
+
|
|
89
|
+
export type AdminUser = z.infer<typeof AdminUserSchema>
|
|
90
|
+
export type AdminRole = z.infer<typeof AdminRoleSchema>
|
|
91
|
+
export type AdminRoleCode = z.infer<typeof AdminRoleCodeSchema>
|
|
92
|
+
export type AdminAuditLog = z.infer<typeof AdminAuditLogSchema>
|
|
93
|
+
export type AdminCreateUser = z.infer<typeof AdminCreateUserSchema>
|
|
94
|
+
export type AdminUpdateUser = z.infer<typeof AdminUpdateUserSchema>
|
|
95
|
+
export type ProfileUpdate = z.infer<typeof ProfileUpdateSchema>
|
|
96
|
+
export type AvatarUpload = z.infer<typeof AvatarUploadSchema>
|
|
@@ -7,6 +7,9 @@ export const AppFrontendTargetSchema = z.enum(['web', 'app', 'miniapp', 'desktop
|
|
|
7
7
|
export const AppInfoOutputDataSchema = z.object({
|
|
8
8
|
name: z.string(),
|
|
9
9
|
version: z.string(),
|
|
10
|
+
env: z.string(),
|
|
11
|
+
defaultLocale: z.string(),
|
|
12
|
+
supportedLocales: z.array(z.string()),
|
|
10
13
|
startedAt: z.string().datetime(),
|
|
11
14
|
frontends: z.array(AppFrontendTargetSchema),
|
|
12
15
|
})
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { apiContract } from '@h-ai/api-contract'
|
|
2
|
+
import { adminContract, authContract, profileContract } from './admin-contract.js'
|
|
2
3
|
import { appContract } from './app-contract.js'
|
|
3
4
|
|
|
4
5
|
/** 当前工程启用的前端目标。 */
|
|
5
6
|
export const ENABLED_FRONTENDS = {{{fullstack.selectedFrontendsJson}}} as const
|
|
7
|
+
export const SUPPORTED_LOCALES = [{{#each fullstack.i18n.locales}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}] as const
|
|
6
8
|
|
|
7
9
|
/** 工程级 API contract,前端 typed client 与后端 service 共用。 */
|
|
8
10
|
export const {{fullstack.contractExportName}} = apiContract.create({
|
|
9
11
|
app: appContract,
|
|
12
|
+
iam: { auth: authContract },
|
|
13
|
+
admin: adminContract,
|
|
14
|
+
profile: profileContract,
|
|
10
15
|
})
|
|
11
16
|
|
|
12
17
|
export type FullstackContract = typeof {{fullstack.contractExportName}}
|
package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs
CHANGED
|
@@ -19,6 +19,9 @@ describe('{{fullstack.contractPackageName}}', () => {
|
|
|
19
19
|
const result = AppInfoOutputDataSchema.safeParse({
|
|
20
20
|
name: '{{fullstack.servPackageName}}',
|
|
21
21
|
version: '0.1.0',
|
|
22
|
+
env: 'test',
|
|
23
|
+
defaultLocale: 'zh-CN',
|
|
24
|
+
supportedLocales: ['zh-CN', 'en-US'],
|
|
22
25
|
startedAt: '2026-01-01T00:00:00.000Z',
|
|
23
26
|
frontends: [...ENABLED_FRONTENDS],
|
|
24
27
|
})
|
|
@@ -2,14 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
后端 API Service,基于 `@h-ai/serv` 将共享 contract 装配为最小 HTTP App 抽象,不在应用代码中直接暴露 Hono。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 默认功能与配置
|
|
6
6
|
|
|
7
7
|
- `config/_core.yml`:应用元信息(名称 / 版本 / 环境 / 语言),启动时由 `core.config` 加载。
|
|
8
8
|
- `config/_serv.yml`:服务端运行时设置(CORS 来源等)。
|
|
9
|
-
- `
|
|
9
|
+
- `config/_db.yml` / `_cache.yml` / `_iam.yml` / `_storage.yml` / `_audit.yml`:mini 管理所需模块配置,初始化前使用模块公开 Schema 验证。
|
|
10
|
+
- `src/index.ts`:进程入口,先 `core.init({ configDir: './config' })`,再初始化 reldb → cache → crypto → storage → iam → audit;启动失败及退出反序释放。
|
|
10
11
|
- `src/app-server.ts`:服务装配入口,挂载健康检查、OpenAPI、docs 与 procedures;应用名称 / 版本与 CORS 来源均取自上述配置。
|
|
11
12
|
- `src/server/procedures/app-procedures.ts`:app 领域 procedures,对应 contract 中的 `app.info` / `app.echo` 路由。
|
|
12
|
-
- `
|
|
13
|
+
- `src/server/procedures/admin-procedures.ts`:认证、管理员用户/角色/审计管理和个人设置,使用 `.auth()` / `.role('admin')` 守卫。每个请求读取用户最新启用状态、角色和权限。
|
|
14
|
+
- 当前用户与刷新 procedures 直接选用 `@h-ai/serv/features/iam` 的默认实现;仅覆写登录、退出、修改密码以追加业务审计。未选的注册/OTP/通用管理接口不挂载,HTTP App 类型直接复用公开 `ServHttpApp`。
|
|
15
|
+
- `src/server/admin-service.ts`:角色分配、头像上传和审计编排,全部底层操作复用 hai 模块。固定 admin/user 两种角色;锁内重新鉴权,禁止管理员自删、自禁用、自降级,并补偿用户更新失败后的角色变更。
|
|
16
|
+
- `tests/app-server.test.ts`:真实加密 HTTP 测试,覆盖 Cookie/受信原生刷新、RBAC、旧会话权限、并发管理操作、补偿、头像安全、偏好、审计及会话失效。
|
|
17
|
+
|
|
18
|
+
CLI 已生成根目录 `.env`,`HAI_ADMIN_DEFAULT_PASSWORD` 为每个项目独立的随机强密码;`dev`/`start` 自动读取,首次登录使用用户名 `admin` 和该密码。仅在没有用户时创建管理员,不重设已有用户密码。密码不符合 `_iam.yml` 策略时,启动日志显示具体原因及错误码。`.env` 被 Git 和容器构建忽略,`.env.example` 不含真实密码;部署时设置目标环境自己的密码。所有业务 JSON 请求和响应强制加密;健康检查和随机路径的只读头像图片可直接访问。头像支持 PNG/JPEG/WebP,最大 512 KiB,校验内容特征和大小后由服务端生成 key,不能指定任意文件路径。
|
|
19
|
+
|
|
20
|
+
默认 SQLite + 本地 storage + memory cache 适合单实例,管理写入仅支持单写实例:进程内保护无过期时间,共享 cache 锁定期续租,丢失锁或释放失败会记录错误。多实例读取可使用共享数据库、Redis cache 和 S3 storage,但管理写请求必须统一路由到同一写实例;仅更换 Redis 不提供任意多写副本的最后管理员原子保证。另需配置传输会话粘性路由:服务端私钥驻留单个进程,服务重启后客户端需重新协商。Web 使用 HttpOnly refresh Cookie + 内存 access token;仅同时满足客户端标识、body 通道头与受信原生 Origin 的 App/Desktop 请求返回 refresh token body。
|
|
21
|
+
|
|
22
|
+
业务修改提交后的审计记录采用最大努力策略:写入失败输出包含操作及资源的结构化错误日志,业务仍返回原操作结果,避免将已经完成的变更误报为失败;应用启动时 audit 初始化失败则停止启动。
|
|
13
23
|
|
|
14
24
|
## 开发
|
|
15
25
|
|
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
# 每个叶子项都可由约定式 HAI 环境变量覆盖,敏感值不要写入文件。
|
|
7
7
|
# =============================================================================
|
|
8
8
|
|
|
9
|
-
name
|
|
9
|
+
{{yaml 'name' fullstack.appName}}
|
|
10
10
|
version: 0.1.0
|
|
11
|
-
env:
|
|
12
|
-
debug:
|
|
11
|
+
env: development
|
|
12
|
+
debug: false
|
|
13
13
|
|
|
14
14
|
defaultLocale: {{fullstack.i18n.baseLocale}}
|
|
15
|
+
logging:
|
|
16
|
+
level: info
|
|
17
|
+
format: json
|
|
18
|
+
redact:
|
|
19
|
+
- password
|
|
20
|
+
- token
|
|
21
|
+
- secret
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
login:
|
|
2
|
+
password: true
|
|
3
|
+
otp: false
|
|
4
|
+
ldap: false
|
|
5
|
+
apikey: false
|
|
6
|
+
password:
|
|
7
|
+
minLength: 8
|
|
8
|
+
maxLength: 128
|
|
9
|
+
requireUppercase: false
|
|
10
|
+
requireLowercase: false
|
|
11
|
+
requireNumber: false
|
|
12
|
+
requireSpecialChar: false
|
|
13
|
+
session:
|
|
14
|
+
maxAge: 86400
|
|
15
|
+
sliding: true
|
|
16
|
+
singleDevice: false
|
|
17
|
+
refreshTokenMaxAge: 604800
|
|
18
|
+
rbac:
|
|
19
|
+
defaultRole: user
|
|
20
|
+
# 仅由应用创建 admin/user,避免生成无业务用途的默认角色与权限。
|
|
21
|
+
seedDefaultData: false
|
|
@@ -22,14 +22,21 @@ http:
|
|
|
22
22
|
|
|
23
23
|
cors:
|
|
24
24
|
origin: ${HAI_CORS_ORIGIN:*}
|
|
25
|
-
nativeOrigins: ${HAI_CORS_NATIVEORIGINS:http://localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
|
|
26
|
-
credentials: ${HAI_CORS_CREDENTIALS:
|
|
25
|
+
nativeOrigins: ${HAI_CORS_NATIVEORIGINS:http://localhost,https://localhost,http://tauri.localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
|
|
26
|
+
credentials: ${HAI_CORS_CREDENTIALS:true}
|
|
27
27
|
allowedHeaders:
|
|
28
28
|
- Authorization
|
|
29
29
|
- Content-Type
|
|
30
30
|
- X-Request-Id
|
|
31
31
|
- X-Requested-With
|
|
32
|
+
- X-Request-Client
|
|
33
|
+
- X-HAI-Token-Transport
|
|
34
|
+
- X-Client-Id
|
|
35
|
+
- X-Encrypted
|
|
32
36
|
exposedHeaders:
|
|
33
37
|
- X-Request-Id
|
|
38
|
+
- X-Encrypted
|
|
34
39
|
|
|
35
|
-
transport:
|
|
40
|
+
transport:
|
|
41
|
+
keyExchangePath: /_hai/key-exchange
|
|
42
|
+
maxClients: 10000
|