mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix: make bootstrap admin opt-in and stabilize promotion errors
This commit is contained in:
parent
2b0bb25240
commit
5168e866da
16 changed files with 79 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
README.md
10
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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ SkillHub 通过环境变量进行配置,主要配置项如下:
|
|||
|
||||
| 环境变量 | 说明 | 默认值 |
|
||||
|---------|------|--------|
|
||||
| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `true` |
|
||||
| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `false` |
|
||||
| `BOOTSTRAP_ADMIN_USERNAME` | 首登管理员用户名 | - |
|
||||
| `BOOTSTRAP_ADMIN_PASSWORD` | 首登管理员密码 | - |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | - |
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<UserAccount> 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<LocalCredential> 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]));
|
||||
|
|
|
|||
|
|
@ -120,17 +120,17 @@ async function unwrap<T>(promise: Promise<{ data?: T; error?: unknown; response:
|
|||
const envelope = isApiEnvelope<T>(data) ? data : isApiEnvelope<T>(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<T>(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<T>(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<void>(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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "举报技能",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue