diff --git a/.env.release.example b/.env.release.example index 2b659ef1..dd1ccbab 100644 --- a/.env.release.example +++ b/.env.release.example @@ -40,7 +40,7 @@ SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY=PT10M # Bootstrap local admin account for first login. Rotate or disable after initial setup. -BOOTSTRAP_ADMIN_ENABLED=true +BOOTSTRAP_ADMIN_ENABLED=false BOOTSTRAP_ADMIN_USER_ID=docker-admin BOOTSTRAP_ADMIN_USERNAME=admin BOOTSTRAP_ADMIN_PASSWORD=replace-this-admin-password diff --git a/README.md b/README.md index a7bade3d..d14af8b6 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,8 @@ Local profile seeds two mock-auth users automatically: Use them with the `X-Mock-User-Id` header in local development. -The backend also bootstraps a local-login super admin for first-time access -in any runtime when `BOOTSTRAP_ADMIN_ENABLED=true`: +The backend can bootstrap a local-login super admin for first-time access +when you explicitly set `BOOTSTRAP_ADMIN_ENABLED=true`: - username: `BOOTSTRAP_ADMIN_USERNAME` (`admin` by default) - password: `BOOTSTRAP_ADMIN_PASSWORD` (`ChangeMe!2026` by default) @@ -179,8 +179,9 @@ The runtime stack uses its own Compose project name, so it does not collide with containers from `make dev-all`. The production Compose stack now defaults to the `docker` profile only. -It does not enable local mock auth. The backend bootstraps a local admin -account from environment variables for the first login: +It does not enable local mock auth. Bootstrap admin is disabled by default; +if you turn it on explicitly, the backend seeds a local admin account from +environment variables for the first login: - username: `BOOTSTRAP_ADMIN_USERNAME` - password: `BOOTSTRAP_ADMIN_PASSWORD` @@ -190,6 +191,7 @@ Recommended production baseline: - set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint - keep PostgreSQL / Redis bound to `127.0.0.1` - use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` +- keep `BOOTSTRAP_ADMIN_ENABLED=false` unless you intentionally need bootstrap login - rotate or disable the bootstrap admin after initial setup - run `make validate-release-config` before `docker compose up -d` diff --git a/compose.release.yml b/compose.release.yml index 99f0934d..8a9aa4ca 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -56,7 +56,7 @@ services: SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:-false} SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:-false} SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:-PT10M} - BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-true} + BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false} BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin} BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-ChangeMe!2026} diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 20bf73c6..f5798cec 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -47,23 +47,25 @@ | Profile | 用途 | 说明 | |---------|------|------| | `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | -| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不影响通用首登管理员初始化 | +| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不会自动打开首登管理员 | 单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下: - 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力 -- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile +- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile,且默认关闭 - 数据库、Redis、OSS、站点公网地址全部改为环境变量优先 -默认首登账号来源于环境变量: +如需启用首登管理员,来源于以下环境变量: +- `BOOTSTRAP_ADMIN_ENABLED=true` - `BOOTSTRAP_ADMIN_USERNAME` - `BOOTSTRAP_ADMIN_PASSWORD` 建议: +- 默认保持 `BOOTSTRAP_ADMIN_ENABLED=false` - 完成首次登录后立即修改管理员密码 -- 如果已有外部身份源,可将 `BOOTSTRAP_ADMIN_ENABLED=false` +- 如果已有外部身份源,通常不需要启用 bootstrap admin - `SKILLHUB_PUBLIC_BASE_URL` 应配置为最终 HTTPS 域名,避免 OAuth / Cookie / 设备码链接异常 ## 4 开发环境 @@ -206,7 +208,8 @@ docker compose --env-file .env.release -f compose.release.yml up -d - `SKILLHUB_PUBLIC_BASE_URL` 填最终 HTTPS 域名,且不要带尾部 `/` - `SKILLHUB_STORAGE_PROVIDER=s3` - 按云厂商 OSS / S3 兼容参数填写 `SKILLHUB_STORAGE_S3_*` - - 设置非默认的 `POSTGRES_PASSWORD` 与 `BOOTSTRAP_ADMIN_PASSWORD` + - 设置非默认的 `POSTGRES_PASSWORD` + - 如果要启用首登管理员,再额外设置 `BOOTSTRAP_ADMIN_ENABLED=true` 与非默认的 `BOOTSTRAP_ADMIN_PASSWORD` 3. 启动前校验 - 运行 `make validate-release-config` - 确认没有 `replace-me`、`change-this-*`、`ChangeMe!2026` 之类的占位值 @@ -215,7 +218,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 检查 `docker compose --env-file .env.release -f compose.release.yml ps` - 检查 `curl -i http://127.0.0.1:8080/actuator/health` 5. 首登收尾 - - 使用 `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` 登录 + - 仅在启用了 `BOOTSTRAP_ADMIN_ENABLED=true` 时,使用 `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` 登录 - 立即修改管理员密码 - 如果后续完全走 OAuth,可将 `BOOTSTRAP_ADMIN_ENABLED=false` diff --git a/document/docs/02-administration/deployment/configuration.md b/document/docs/02-administration/deployment/configuration.md index fca63288..e89b3389 100644 --- a/document/docs/02-administration/deployment/configuration.md +++ b/document/docs/02-administration/deployment/configuration.md @@ -56,7 +56,7 @@ SkillHub 通过环境变量进行配置,主要配置项如下: | 环境变量 | 说明 | 默认值 | |---------|------|--------| -| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `true` | +| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `false` | | `BOOTSTRAP_ADMIN_USERNAME` | 首登管理员用户名 | - | | `BOOTSTRAP_ADMIN_PASSWORD` | 首登管理员密码 | - | diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md index fd5f3951..5e0e502b 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md @@ -56,7 +56,7 @@ SkillHub is configured through environment variables. The main configuration ite | Environment Variable | Description | Default Value | |---------------------|-------------|---------------| -| `BOOTSTRAP_ADMIN_ENABLED` | Enable bootstrap admin | `true` | +| `BOOTSTRAP_ADMIN_ENABLED` | Enable bootstrap admin | `false` | | `BOOTSTRAP_ADMIN_USERNAME` | Bootstrap admin username | - | | `BOOTSTRAP_ADMIN_PASSWORD` | Bootstrap admin password | - | diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java index 1cf615a6..11aadcdf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; @Component @ConfigurationProperties(prefix = "skillhub.bootstrap.admin") public class BootstrapAdminProperties { - private boolean enabled = true; + private boolean enabled = false; private String userId = "docker-admin"; private String username = "admin"; private String password = "ChangeMe!2026"; diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index f999efe3..b8dbc110 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -101,7 +101,7 @@ skillhub: verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device} bootstrap: admin: - enabled: ${BOOTSTRAP_ADMIN_ENABLED:true} + enabled: ${BOOTSTRAP_ADMIN_ENABLED:false} user-id: ${BOOTSTRAP_ADMIN_USER_ID:docker-admin} username: ${BOOTSTRAP_ADMIN_USERNAME:admin} password: ${BOOTSTRAP_ADMIN_PASSWORD:ChangeMe!2026} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java index 4615019d..a151d2a8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java @@ -29,6 +29,7 @@ import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -64,6 +65,7 @@ class BootstrapAdminInitializerTest { @Test void shouldSeedBootstrapAdminWithCredentialRoleAndMembership() throws Exception { + bootstrapAdminProperties.setEnabled(true); Namespace global = new Namespace("global", "Global", "system"); setField(global, "id", 1L); @@ -83,10 +85,11 @@ class BootstrapAdminInitializerTest { initializer.run(new DefaultApplicationArguments(new String[0])); ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); - verify(userAccountRepository).save(userCaptor.capture()); - assertEquals("docker-admin", userCaptor.getValue().getId()); - assertEquals("Admin", userCaptor.getValue().getDisplayName()); - assertEquals("admin@skillhub.local", userCaptor.getValue().getEmail()); + verify(userAccountRepository, atLeastOnce()).save(userCaptor.capture()); + UserAccount savedUser = userCaptor.getAllValues().getLast(); + assertEquals("docker-admin", savedUser.getId()); + assertEquals("Admin", savedUser.getDisplayName()); + assertEquals("admin@skillhub.local", savedUser.getEmail()); ArgumentCaptor credentialCaptor = ArgumentCaptor.forClass(LocalCredential.class); verify(localCredentialRepository).save(credentialCaptor.capture()); @@ -107,6 +110,7 @@ class BootstrapAdminInitializerTest { @Test void shouldSkipWhenBootstrapAdminCredentialAlreadyExists() { + bootstrapAdminProperties.setEnabled(true); when(localCredentialRepository.existsByUsernameIgnoreCase("admin")).thenReturn(true); initializer.run(new DefaultApplicationArguments(new String[0])); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index fb0b5b5b..4e08c3fa 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -120,17 +120,17 @@ async function unwrap(promise: Promise<{ data?: T; error?: unknown; response: const envelope = isApiEnvelope(data) ? data : isApiEnvelope(error) ? error : null if (!response.ok) { - throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) } if (error) { - throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) } if (data === undefined) { throw new ApiError(`HTTP ${response.status}`, response.status) } if (isApiEnvelope(data)) { if (data.code !== 0) { - throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg) + throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg, data.msg) } return data.data } @@ -244,7 +244,7 @@ export async function fetchJson(input: RequestInfo | URL, init?: RequestWithT } if (!response.ok || json.code !== 0) { - throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg) + throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg, json.msg) } return json.data @@ -648,7 +648,7 @@ export const tokenApi = { const envelope = (error && isApiEnvelope(error) ? error : null) as { msg?: string } | null if (!response.ok || error) { - throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) } }, } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 363aceda..150a292c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -263,6 +263,8 @@ "promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.", "promotionDuplicateTitle": "Promotion already pending", "promotionDuplicateDescription": "This version already has a pending promotion request.", + "promotionAlreadyPromotedTitle": "Skill already promoted", + "promotionAlreadyPromotedDescription": "This skill has already been promoted to the global namespace.", "promotionErrorTitle": "Failed to submit promotion request", "emptyTitle": "No skills yet", "emptyDescription": "Start publishing your first skill", @@ -593,6 +595,8 @@ "promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.", "promotionDuplicateTitle": "Promotion already pending", "promotionDuplicateDescription": "This version already has a pending promotion request.", + "promotionAlreadyPromotedTitle": "Skill already promoted", + "promotionAlreadyPromotedDescription": "This skill has already been promoted to the global namespace.", "promotionErrorTitle": "Failed to submit promotion request", "reportSkill": "Report Skill", "reportDialogTitle": "Report skill", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 04327b33..6602a769 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -263,6 +263,8 @@ "promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。", "promotionDuplicateTitle": "已存在待处理提升申请", "promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。", + "promotionAlreadyPromotedTitle": "技能已完成提升", + "promotionAlreadyPromotedDescription": "这个技能已经提升到全局命名空间,无需重复提交。", "promotionErrorTitle": "提交提升申请失败", "emptyTitle": "还没有技能", "emptyDescription": "开始发布你的第一个技能吧", @@ -593,6 +595,8 @@ "promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。", "promotionDuplicateTitle": "已存在待处理提升申请", "promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。", + "promotionAlreadyPromotedTitle": "技能已完成提升", + "promotionAlreadyPromotedDescription": "这个技能已经提升到全局命名空间,无需重复提交。", "promotionErrorTitle": "提交提升申请失败", "reportSkill": "举报技能", "reportDialogTitle": "举报技能", diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 2507918a..60cf8bcf 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -11,14 +11,14 @@ import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' import { ApiError } from '@/api/client' -function isDuplicatePromotionMessage(message?: string): boolean { - if (!message) { - return false +function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { + if (error.serverMessageKey === 'promotion.duplicate_pending') { + return 'promotion.duplicate_pending' } - - return message.includes('promotion.duplicate_pending') - || message.includes('已有待处理的提升申请') - || message.includes('Duplicate pending promotion') + if (error.serverMessageKey === 'promotion.already_promoted') { + return 'promotion.already_promoted' + } + return null } export function MySkillsPage() { @@ -140,9 +140,16 @@ export function MySkillsPage() { ) setPromotionTarget(null) } catch (error) { - if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) { - toast.error(t('mySkills.promotionDuplicateTitle'), t('mySkills.promotionDuplicateDescription')) - return + if (error instanceof ApiError) { + const conflictKey = getPromotionConflictKey(error) + if (conflictKey === 'promotion.duplicate_pending') { + toast.error(t('mySkills.promotionDuplicateTitle'), t('mySkills.promotionDuplicateDescription')) + return + } + if (conflictKey === 'promotion.already_promoted') { + toast.error(t('mySkills.promotionAlreadyPromotedTitle'), t('mySkills.promotionAlreadyPromotedDescription')) + return + } } toast.error(t('mySkills.promotionErrorTitle'), error instanceof Error ? error.message : '') throw error diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 8f8d5219..3e5e19c8 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -58,14 +58,14 @@ function parseMetadataJson(parsed?: string) { } } -function isDuplicatePromotionMessage(message?: string): boolean { - if (!message) { - return false +function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { + if (error.serverMessageKey === 'promotion.duplicate_pending') { + return 'promotion.duplicate_pending' } - - return message.includes('promotion.duplicate_pending') - || message.includes('已有待处理的提升申请') - || message.includes('Duplicate pending promotion') + if (error.serverMessageKey === 'promotion.already_promoted') { + return 'promotion.already_promoted' + } + return null } export function SkillDetailPage() { @@ -363,9 +363,16 @@ export function SkillDetailPage() { ) setPromotionConfirmOpen(false) } catch (error) { - if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) { - toast.error(t('skillDetail.promotionDuplicateTitle'), t('skillDetail.promotionDuplicateDescription')) - return + if (error instanceof ApiError) { + const conflictKey = getPromotionConflictKey(error) + if (conflictKey === 'promotion.duplicate_pending') { + toast.error(t('skillDetail.promotionDuplicateTitle'), t('skillDetail.promotionDuplicateDescription')) + return + } + if (conflictKey === 'promotion.already_promoted') { + toast.error(t('skillDetail.promotionAlreadyPromotedTitle'), t('skillDetail.promotionAlreadyPromotedDescription')) + return + } } toast.error(t('skillDetail.promotionErrorTitle'), error instanceof Error ? error.message : '') throw error diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index eab25453..2e6304dd 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -223,7 +223,7 @@ export function useSubmitPromotion() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['promotions'] }) queryClient.invalidateQueries({ queryKey: ['governance'] }) - queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) }, }) } diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index af937d8a..ff6afc5b 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -14,10 +14,12 @@ export class ApiError extends Error { message: string, public status: number, public serverMessage?: string, + public serverMessageKey?: string, ) { super(resolveLocalizedMessage(message) || message) this.name = 'ApiError' this.serverMessage = resolveLocalizedMessage(serverMessage) || serverMessage + this.serverMessageKey = serverMessage } }