mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Refine production compose runtime config
This commit is contained in:
parent
14d86c290a
commit
9ae9b93ca7
17 changed files with 308 additions and 83 deletions
|
|
@ -4,15 +4,48 @@ SKILLHUB_VERSION=edge
|
|||
SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server
|
||||
SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web
|
||||
|
||||
# Public entrypoint seen by browsers/CLI, no trailing slash.
|
||||
SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
|
||||
|
||||
# Frontend usually keeps this empty and proxies to the backend through nginx.
|
||||
SKILLHUB_WEB_API_BASE_URL=
|
||||
SKILLHUB_API_UPSTREAM=http://server:8080
|
||||
|
||||
POSTGRES_BIND_ADDRESS=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=skillhub
|
||||
POSTGRES_USER=skillhub
|
||||
POSTGRES_PASSWORD=skillhub_demo
|
||||
POSTGRES_PASSWORD=change-this-postgres-password
|
||||
|
||||
REDIS_BIND_ADDRESS=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
API_PORT=8080
|
||||
WEB_PORT=80
|
||||
SESSION_COOKIE_SECURE=true
|
||||
|
||||
# Production default is external S3/OSS compatible storage.
|
||||
SKILLHUB_STORAGE_PROVIDER=s3
|
||||
SKILLHUB_STORAGE_S3_ENDPOINT=https://oss-cn-example.aliyuncs.com
|
||||
SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT=
|
||||
SKILLHUB_STORAGE_S3_BUCKET=skillhub-prod
|
||||
SKILLHUB_STORAGE_S3_ACCESS_KEY=replace-me
|
||||
SKILLHUB_STORAGE_S3_SECRET_KEY=replace-me
|
||||
SKILLHUB_STORAGE_S3_REGION=cn-shanghai
|
||||
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
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_USER_ID=docker-admin
|
||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
BOOTSTRAP_ADMIN_PASSWORD=replace-this-admin-password
|
||||
BOOTSTRAP_ADMIN_DISPLAY_NAME=Platform Admin
|
||||
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# Optional override. Defaults to ${SKILLHUB_PUBLIC_BASE_URL}/device.
|
||||
DEVICE_AUTH_VERIFICATION_URI=
|
||||
|
||||
# Optional: configure real GitHub OAuth before exposing the stack to other users.
|
||||
OAUTH2_GITHUB_CLIENT_ID=local-placeholder
|
||||
OAUTH2_GITHUB_CLIENT_SECRET=local-placeholder
|
||||
OAUTH2_GITHUB_CLIENT_ID=
|
||||
OAUTH2_GITHUB_CLIENT_SECRET=
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -127,7 +127,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d
|
|||
|
||||
Then open:
|
||||
|
||||
- Web UI: `http://localhost`
|
||||
- Web UI: `SKILLHUB_PUBLIC_BASE_URL` 对应的地址
|
||||
- Backend API: `http://localhost:8080`
|
||||
|
||||
Stop it with:
|
||||
|
|
@ -139,16 +139,22 @@ docker compose --env-file .env.release -f compose.release.yml down
|
|||
The runtime stack uses its own Compose project name, so it does not
|
||||
collide with containers from `make dev-all`.
|
||||
|
||||
The runtime uses the existing `local,docker` profile combination so it
|
||||
is immediately usable with the same mock-auth flow as local development.
|
||||
Available seeded users:
|
||||
The production Compose stack now defaults to the `docker` profile only.
|
||||
It does not enable local mock auth. Instead, the backend bootstraps a
|
||||
local admin account from environment variables for the first login:
|
||||
|
||||
- `local-user`
|
||||
- `local-admin`
|
||||
- username: `BOOTSTRAP_ADMIN_USERNAME`
|
||||
- password: `BOOTSTRAP_ADMIN_PASSWORD`
|
||||
|
||||
Pass `X-Mock-User-Id` to the backend when you need an authenticated
|
||||
session without configuring GitHub OAuth. If the GHCR package remains
|
||||
private, run `docker login ghcr.io` before `docker compose up -d`.
|
||||
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_*`
|
||||
- rotate or disable the bootstrap admin after initial setup
|
||||
|
||||
If the GHCR package remains private, run `docker login ghcr.io` before
|
||||
`docker compose up -d`.
|
||||
|
||||
### Monitoring
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ services:
|
|||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
- "${POSTGRES_BIND_ADDRESS:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-skillhub}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-skillhub}
|
||||
|
|
@ -20,7 +20,10 @@ services:
|
|||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
- "${REDIS_BIND_ADDRESS:-127.0.0.1}:${REDIS_PORT:-6379}:6379"
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
|
|
@ -33,13 +36,32 @@ services:
|
|||
ports:
|
||||
- "${API_PORT:-8080}:8080"
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: local,docker
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo}
|
||||
SPRING_DATA_REDIS_HOST: redis
|
||||
SPRING_DATA_REDIS_PORT: 6379
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
|
||||
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
|
||||
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}
|
||||
SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3}
|
||||
STORAGE_BASE_PATH: /var/lib/skillhub/storage
|
||||
SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-}
|
||||
SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT: ${SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT:-}
|
||||
SKILLHUB_STORAGE_S3_BUCKET: ${SKILLHUB_STORAGE_S3_BUCKET:-skillhub}
|
||||
SKILLHUB_STORAGE_S3_ACCESS_KEY: ${SKILLHUB_STORAGE_S3_ACCESS_KEY:-}
|
||||
SKILLHUB_STORAGE_S3_SECRET_KEY: ${SKILLHUB_STORAGE_S3_SECRET_KEY:-}
|
||||
SKILLHUB_STORAGE_S3_REGION: ${SKILLHUB_STORAGE_S3_REGION:-us-east-1}
|
||||
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_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin}
|
||||
BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin}
|
||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-ChangeMe!2026}
|
||||
BOOTSTRAP_ADMIN_DISPLAY_NAME: ${BOOTSTRAP_ADMIN_DISPLAY_NAME:-Admin}
|
||||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local}
|
||||
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder}
|
||||
volumes:
|
||||
|
|
@ -61,6 +83,10 @@ services:
|
|||
restart: unless-stopped
|
||||
ports:
|
||||
- "${WEB_PORT:-80}:80"
|
||||
environment:
|
||||
SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080}
|
||||
SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-}
|
||||
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
|
@ -73,4 +99,5 @@ services:
|
|||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
skillhub_storage:
|
||||
|
|
|
|||
|
|
@ -112,7 +112,8 @@ skillhub/
|
|||
│ └── Dockerfile # 后端多阶段构建
|
||||
├── web/ # React 前端
|
||||
│ ├── Dockerfile # 前端多阶段构建
|
||||
│ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理)
|
||||
│ ├── nginx.conf.template # Nginx 运行时模板
|
||||
│ └── runtime-config.js.template # 前端运行时环境变量模板
|
||||
├── docker-compose.yml # 本地开发依赖服务(PostgreSQL/Redis/MinIO)
|
||||
├── compose.release.yml # 单机运行时编排(发布镜像 + PostgreSQL + Redis)
|
||||
├── .env.release.example # 单机运行时环境变量模板
|
||||
|
|
@ -137,9 +138,10 @@ skillhub/
|
|||
- `http://localhost/api/*` → Web 容器反向代理到 Spring Boot
|
||||
- `http://localhost:8080/actuator/health` → 后端健康检查
|
||||
|
||||
单机运行时使用 `local,docker` profile 组合:
|
||||
- `local` 提供 mock 登录和种子账号,保证拉起即用
|
||||
- `docker` 负责将数据库、Redis 地址切换到 Compose 网络
|
||||
单机运行时默认使用 `docker` profile:
|
||||
- `docker` 负责容器运行时初始化,例如首个管理员账户
|
||||
- 数据库、Redis、对象存储、站点公网地址都通过环境变量注入
|
||||
- 生产环境不启用 `local` profile,因此不会暴露 mock 登录旁路
|
||||
|
||||
## 9. 分布式环境要求
|
||||
|
||||
|
|
|
|||
|
|
@ -38,31 +38,33 @@
|
|||
|
||||
说明:
|
||||
- Web 容器提供静态资源,并将 `/api/*`、`/oauth2/*`、`/.well-known/*` 反代到后端
|
||||
- 后端运行 `local,docker` profile 组合
|
||||
- 技能包文件默认落在容器卷 `skillhub_storage`,保证单机环境开箱即用
|
||||
- 后端默认运行 `docker` profile,不再启用本地 mock 登录
|
||||
- PostgreSQL / Redis 默认只绑定 `127.0.0.1`
|
||||
- 对象存储推荐使用外部 S3 / OSS,通过环境变量注入
|
||||
|
||||
## 3 Profile 约定
|
||||
|
||||
| Profile | 用途 | 说明 |
|
||||
|---------|------|------|
|
||||
| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 |
|
||||
| `docker` | 容器网络适配 | 将数据库和 Redis 地址切换到 Compose 内网 |
|
||||
| `docker` | 容器运行时能力 | 启用容器内启动用管理员账号初始化等运行时行为 |
|
||||
|
||||
单机交付环境使用 `SPRING_PROFILES_ACTIVE=local,docker`,原因很明确:
|
||||
单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下:
|
||||
|
||||
- 这是当前唯一能保证“镜像拉起后直接可用”的 profile 组合
|
||||
- 用户无需先配置 GitHub OAuth,先用 mock 身份即可浏览和联调主要流程
|
||||
- 后续如果引入专用 `runtime` / `demo` profile,可以替换这层组合,但当前方案不再新增第三条部署路径
|
||||
- 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力
|
||||
- 容器环境仍然可以通过 `docker` profile 初始化首个管理员账户
|
||||
- 数据库、Redis、OSS、站点公网地址全部改为环境变量优先
|
||||
|
||||
默认可用账号:
|
||||
默认首登账号来源于环境变量:
|
||||
|
||||
- `local-user`
|
||||
- `local-admin`
|
||||
- `BOOTSTRAP_ADMIN_USERNAME`
|
||||
- `BOOTSTRAP_ADMIN_PASSWORD`
|
||||
|
||||
鉴权方式:
|
||||
建议:
|
||||
|
||||
- 向后端请求携带 `X-Mock-User-Id: local-user`
|
||||
- 或 `X-Mock-User-Id: local-admin`
|
||||
- 完成首次登录后立即修改管理员密码
|
||||
- 如果已有外部身份源,可将 `BOOTSTRAP_ADMIN_ENABLED=false`
|
||||
- `SKILLHUB_PUBLIC_BASE_URL` 应配置为最终 HTTPS 域名,避免 OAuth / Cookie / 设备码链接异常
|
||||
|
||||
## 4 开发环境
|
||||
|
||||
|
|
@ -99,7 +101,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d
|
|||
|
||||
默认访问地址:
|
||||
|
||||
- Web UI: `http://localhost`
|
||||
- Web UI: `SKILLHUB_PUBLIC_BASE_URL`
|
||||
- Backend API: `http://localhost:8080`
|
||||
|
||||
### 5.2 关键文件
|
||||
|
|
@ -107,10 +109,11 @@ docker compose --env-file .env.release -f compose.release.yml up -d
|
|||
- `compose.release.yml`
|
||||
- 使用发布镜像,不在用户机器上执行本地构建
|
||||
- 负责拉起 PostgreSQL、Redis、server、web
|
||||
- 使用独立 Compose project name,避免与开发环境容器互相污染
|
||||
- PostgreSQL、Redis 默认只绑定到 `127.0.0.1`
|
||||
- Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像
|
||||
- `.env.release.example`
|
||||
- 运行时变量模板
|
||||
- 包含镜像名、镜像版本、端口和数据库凭证
|
||||
- 包含镜像名、镜像版本、端口、数据库凭证、外部 OSS、站点公网地址和首登管理员参数
|
||||
|
||||
### 5.3 镜像标签约定
|
||||
|
||||
|
|
@ -160,6 +163,9 @@ docker compose --env-file .env.release -f compose.release.yml up -d
|
|||
|
||||
- 使用 `.env.release` 管理 Compose 变量
|
||||
- 如果 GHCR 包保持私有,用户需要先 `docker login ghcr.io`
|
||||
- 推荐将敏感变量放入 CI/CD Secret 或主机上的受控 `.env.release`
|
||||
- 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入
|
||||
- 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入
|
||||
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
|
||||
|
||||
## 8 可观测性
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.bootstrap.admin")
|
||||
public class BootstrapAdminProperties {
|
||||
private boolean enabled = true;
|
||||
private String userId = "docker-admin";
|
||||
private String username = "admin";
|
||||
private String password = "ChangeMe!2026";
|
||||
private String displayName = "Admin";
|
||||
private String email = "admin@skillhub.local";
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public String getUserId() { return userId; }
|
||||
public void setUserId(String userId) { this.userId = userId; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
}
|
||||
|
|
@ -29,13 +29,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
@Component
|
||||
@Profile("docker")
|
||||
public class DockerSeedDataRunner implements ApplicationRunner {
|
||||
|
||||
private static final String ADMIN_USER_ID = "docker-admin";
|
||||
private static final String ADMIN_USERNAME = "admin";
|
||||
private static final String ADMIN_PASSWORD = "Admin@2026";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DockerSeedDataRunner.class);
|
||||
|
||||
private final BootstrapAdminProperties bootstrapAdminProperties;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final LocalCredentialRepository localCredentialRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
|
|
@ -44,13 +40,15 @@ public class DockerSeedDataRunner implements ApplicationRunner {
|
|||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public DockerSeedDataRunner(UserAccountRepository userAccountRepository,
|
||||
public DockerSeedDataRunner(BootstrapAdminProperties bootstrapAdminProperties,
|
||||
UserAccountRepository userAccountRepository,
|
||||
LocalCredentialRepository localCredentialRepository,
|
||||
RoleRepository roleRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.bootstrapAdminProperties = bootstrapAdminProperties;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.localCredentialRepository = localCredentialRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
|
|
@ -63,20 +61,36 @@ public class DockerSeedDataRunner implements ApplicationRunner {
|
|||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
if (localCredentialRepository.existsByUsernameIgnoreCase(ADMIN_USERNAME)) {
|
||||
if (!bootstrapAdminProperties.isEnabled()) {
|
||||
log.info("Docker bootstrap admin is disabled");
|
||||
return;
|
||||
}
|
||||
if (localCredentialRepository.existsByUsernameIgnoreCase(bootstrapAdminProperties.getUsername())) {
|
||||
log.info("Docker seed data already exists, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Create admin user account
|
||||
UserAccount admin = userAccountRepository.findById(ADMIN_USER_ID)
|
||||
UserAccount admin = userAccountRepository.findById(bootstrapAdminProperties.getUserId())
|
||||
.orElseGet(() -> userAccountRepository.save(
|
||||
new UserAccount(ADMIN_USER_ID, "Admin", "admin@skillhub.dev", null)
|
||||
new UserAccount(
|
||||
bootstrapAdminProperties.getUserId(),
|
||||
bootstrapAdminProperties.getDisplayName(),
|
||||
bootstrapAdminProperties.getEmail(),
|
||||
null
|
||||
)
|
||||
));
|
||||
admin.setDisplayName(bootstrapAdminProperties.getDisplayName());
|
||||
admin.setEmail(bootstrapAdminProperties.getEmail());
|
||||
admin = userAccountRepository.save(admin);
|
||||
|
||||
// 2. Create local credential (username/password)
|
||||
localCredentialRepository.save(
|
||||
new LocalCredential(admin.getId(), ADMIN_USERNAME, passwordEncoder.encode(ADMIN_PASSWORD))
|
||||
new LocalCredential(
|
||||
admin.getId(),
|
||||
bootstrapAdminProperties.getUsername(),
|
||||
passwordEncoder.encode(bootstrapAdminProperties.getPassword())
|
||||
)
|
||||
);
|
||||
|
||||
// 3. Assign SUPER_ADMIN role
|
||||
|
|
@ -95,6 +109,6 @@ public class DockerSeedDataRunner implements ApplicationRunner {
|
|||
namespaceMemberRepository.save(new NamespaceMember(globalNs.getId(), admin.getId(), NamespaceRole.OWNER));
|
||||
}
|
||||
|
||||
log.info("Docker seed data initialized — admin account: {} / {}", ADMIN_USERNAME, ADMIN_PASSWORD);
|
||||
log.info("Docker seed data initialized for admin account: {}", bootstrapAdminProperties.getUsername());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
server:
|
||||
port: 8080
|
||||
shutdown: graceful
|
||||
forward-headers-strategy: framework
|
||||
servlet:
|
||||
session:
|
||||
cookie:
|
||||
|
|
@ -27,19 +28,20 @@ spring:
|
|||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/skillhub
|
||||
username: skillhub
|
||||
password: skillhub_dev
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/skillhub}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:skillhub}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:skillhub_dev}
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
maximum-pool-size: ${DB_POOL_MAX_SIZE:10}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
host: ${SPRING_DATA_REDIS_HOST:${REDIS_HOST:localhost}}
|
||||
port: ${SPRING_DATA_REDIS_PORT:${REDIS_PORT:6379}}
|
||||
password: ${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:}}
|
||||
session:
|
||||
store-type: redis
|
||||
redis:
|
||||
namespace: skillhub:session
|
||||
namespace: ${SESSION_REDIS_NAMESPACE:skillhub:session}
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
|
|
@ -57,12 +59,24 @@ spring:
|
|||
max-request-size: 100MB
|
||||
|
||||
skillhub:
|
||||
public:
|
||||
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
|
||||
access-policy:
|
||||
mode: OPEN
|
||||
storage:
|
||||
provider: local
|
||||
provider: ${SKILLHUB_STORAGE_PROVIDER:local}
|
||||
local:
|
||||
base-path: ${STORAGE_BASE_PATH:/tmp/skillhub-storage}
|
||||
s3:
|
||||
endpoint: ${SKILLHUB_STORAGE_S3_ENDPOINT:}
|
||||
public-endpoint: ${SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT:}
|
||||
bucket: ${SKILLHUB_STORAGE_S3_BUCKET:skillhub}
|
||||
access-key: ${SKILLHUB_STORAGE_S3_ACCESS_KEY:}
|
||||
secret-key: ${SKILLHUB_STORAGE_S3_SECRET_KEY:}
|
||||
region: ${SKILLHUB_STORAGE_S3_REGION:us-east-1}
|
||||
force-path-style: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:true}
|
||||
auto-create-bucket: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:false}
|
||||
presign-expiry: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:PT10M}
|
||||
search:
|
||||
engine: postgres
|
||||
rebuild-on-startup: false
|
||||
|
|
@ -71,6 +85,16 @@ skillhub:
|
|||
max-single-file-size: 1048576 # 1MB
|
||||
max-package-size: 104857600 # 100MB
|
||||
allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.js,.ts,.py,.sh,.png,.jpg,.svg
|
||||
device-auth:
|
||||
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device}
|
||||
bootstrap:
|
||||
admin:
|
||||
enabled: ${BOOTSTRAP_ADMIN_ENABLED:true}
|
||||
user-id: ${BOOTSTRAP_ADMIN_USER_ID:docker-admin}
|
||||
username: ${BOOTSTRAP_ADMIN_USERNAME:admin}
|
||||
password: ${BOOTSTRAP_ADMIN_PASSWORD:ChangeMe!2026}
|
||||
display-name: ${BOOTSTRAP_ADMIN_DISPLAY_NAME:Admin}
|
||||
email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
|
@ -86,18 +110,3 @@ management:
|
|||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
|
||||
---
|
||||
# Docker profile
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: docker
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:postgres}:${DB_PORT:5432}/${DB_NAME:skillhub}
|
||||
username: ${DB_USER:skillhub}
|
||||
password: ${DB_PASS:skillhub_dev}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:redis}
|
||||
port: ${REDIS_PORT:6379}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,25 @@ package com.iflytek.skillhub.storage;
|
|||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.storage.s3")
|
||||
public class S3StorageProperties {
|
||||
private String endpoint;
|
||||
private String publicEndpoint;
|
||||
private String bucket = "skillhub";
|
||||
private String accessKey;
|
||||
private String secretKey;
|
||||
private String region = "us-east-1";
|
||||
private boolean forcePathStyle = true;
|
||||
private boolean autoCreateBucket = false;
|
||||
private Duration presignExpiry = Duration.ofMinutes(10);
|
||||
|
||||
public String getEndpoint() { return endpoint; }
|
||||
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
|
||||
public String getPublicEndpoint() { return publicEndpoint; }
|
||||
public void setPublicEndpoint(String publicEndpoint) { this.publicEndpoint = publicEndpoint; }
|
||||
public String getBucket() { return bucket; }
|
||||
public void setBucket(String bucket) { this.bucket = bucket; }
|
||||
public String getAccessKey() { return accessKey; }
|
||||
|
|
@ -22,4 +30,10 @@ public class S3StorageProperties {
|
|||
public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
|
||||
public String getRegion() { return region; }
|
||||
public void setRegion(String region) { this.region = region; }
|
||||
public boolean isForcePathStyle() { return forcePathStyle; }
|
||||
public void setForcePathStyle(boolean forcePathStyle) { this.forcePathStyle = forcePathStyle; }
|
||||
public boolean isAutoCreateBucket() { return autoCreateBucket; }
|
||||
public void setAutoCreateBucket(boolean autoCreateBucket) { this.autoCreateBucket = autoCreateBucket; }
|
||||
public Duration getPresignExpiry() { return presignExpiry; }
|
||||
public void setPresignExpiry(Duration presignExpiry) { this.presignExpiry = presignExpiry; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ public class S3StorageService implements ObjectStorageService {
|
|||
.region(Region.of(properties.getRegion()))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
|
||||
.forcePathStyle(true);
|
||||
.forcePathStyle(properties.isForcePathStyle());
|
||||
if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) {
|
||||
builder.endpointOverride(URI.create(properties.getEndpoint()));
|
||||
}
|
||||
|
|
@ -45,7 +45,9 @@ public class S3StorageService implements ObjectStorageService {
|
|||
.region(Region.of(properties.getRegion()))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())));
|
||||
if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) {
|
||||
if (properties.getPublicEndpoint() != null && !properties.getPublicEndpoint().isBlank()) {
|
||||
presignerBuilder.endpointOverride(URI.create(properties.getPublicEndpoint()));
|
||||
} else if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) {
|
||||
presignerBuilder.endpointOverride(URI.create(properties.getEndpoint()));
|
||||
}
|
||||
this.s3Presigner = presignerBuilder.build();
|
||||
|
|
@ -53,6 +55,10 @@ public class S3StorageService implements ObjectStorageService {
|
|||
}
|
||||
|
||||
private void ensureBucketExists() {
|
||||
if (!properties.isAutoCreateBucket()) {
|
||||
s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build());
|
||||
return;
|
||||
}
|
||||
try { s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); }
|
||||
catch (NoSuchBucketException e) {
|
||||
log.info("Bucket '{}' does not exist, creating...", properties.getBucket());
|
||||
|
|
@ -90,9 +96,10 @@ public class S3StorageService implements ObjectStorageService {
|
|||
|
||||
@Override
|
||||
public String generatePresignedUrl(String key, Duration expiry) {
|
||||
Duration signatureDuration = expiry != null ? expiry : properties.getPresignExpiry();
|
||||
PresignedGetObjectRequest request = s3Presigner.presignGetObject(
|
||||
GetObjectPresignRequest.builder()
|
||||
.signatureDuration(expiry)
|
||||
.signatureDuration(signatureDuration)
|
||||
.getObjectRequest(GetObjectRequest.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.key(key)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ RUN pnpm build
|
|||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY runtime-config.js.template /usr/share/nginx/html/runtime-config.js.template
|
||||
COPY docker-entrypoint.d/30-runtime-config.sh /docker-entrypoint.d/30-runtime-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/30-runtime-config.sh
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=10s --timeout=3s \
|
||||
CMD wget -qO- http://localhost/nginx-health || exit 1
|
||||
|
|
|
|||
9
web/docker-entrypoint.d/30-runtime-config.sh
Normal file
9
web/docker-entrypoint.d/30-runtime-config.sh
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${SKILLHUB_WEB_API_BASE_URL:=}"
|
||||
: "${SKILLHUB_PUBLIC_BASE_URL:=}"
|
||||
|
||||
envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL}' \
|
||||
< /usr/share/nginx/html/runtime-config.js.template \
|
||||
> /usr/share/nginx/html/runtime-config.js
|
||||
|
|
@ -10,6 +10,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/bootstrap.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ server {
|
|||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_pass ${SKILLHUB_API_UPSTREAM};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
|
@ -21,19 +21,23 @@ server {
|
|||
}
|
||||
|
||||
location /oauth2/ {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_pass ${SKILLHUB_API_UPSTREAM};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /login/oauth2/ {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_pass ${SKILLHUB_API_UPSTREAM};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /.well-known/ {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_pass ${SKILLHUB_API_UPSTREAM};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
|
|
@ -41,6 +45,11 @@ server {
|
|||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location = /runtime-config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /nginx-health {
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
4
web/runtime-config.js.template
Normal file
4
web/runtime-config.js.template
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
apiBaseUrl: "${SKILLHUB_WEB_API_BASE_URL}",
|
||||
appBaseUrl: "${SKILLHUB_PUBLIC_BASE_URL}"
|
||||
};
|
||||
|
|
@ -23,7 +23,29 @@ import { ApiError } from '@/shared/lib/api-error'
|
|||
|
||||
export { ApiError }
|
||||
|
||||
const client = createClient<paths>({ baseUrl: '' })
|
||||
type RuntimeConfig = {
|
||||
apiBaseUrl?: string
|
||||
appBaseUrl?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SKILLHUB_RUNTIME_CONFIG__?: RuntimeConfig
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeConfig(): RuntimeConfig {
|
||||
if (typeof window === 'undefined') {
|
||||
return {}
|
||||
}
|
||||
return window.__SKILLHUB_RUNTIME_CONFIG__ ?? {}
|
||||
}
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
return getRuntimeConfig().apiBaseUrl ?? ''
|
||||
}
|
||||
|
||||
const client = createClient<paths>({ baseUrl: getApiBaseUrl() })
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
|
||||
|
|
@ -95,7 +117,7 @@ type ApiEnvelope<T> = {
|
|||
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(input, init)
|
||||
response = await fetch(withBaseUrl(input), init)
|
||||
} catch {
|
||||
throw new ApiError('Network error', 0)
|
||||
}
|
||||
|
|
@ -119,13 +141,25 @@ export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit)
|
|||
}
|
||||
|
||||
export async function fetchText(input: RequestInfo | URL, init?: RequestInit): Promise<string> {
|
||||
const response = await fetch(input, init)
|
||||
const response = await fetch(withBaseUrl(input), init)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return response.text()
|
||||
}
|
||||
|
||||
function withBaseUrl(input: RequestInfo | URL): RequestInfo | URL {
|
||||
const baseUrl = getApiBaseUrl()
|
||||
if (!baseUrl || typeof input !== 'string' || !input.startsWith('/')) {
|
||||
return input
|
||||
}
|
||||
return new URL(input, ensureTrailingSlash(baseUrl))
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
const user = await unwrap<User>(client.GET('/api/v1/auth/me') as never)
|
||||
|
|
|
|||
20
web/src/bootstrap.ts
Normal file
20
web/src/bootstrap.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
async function loadRuntimeConfig() {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = '/runtime-config.js'
|
||||
script.async = false
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('Failed to load runtime config'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await loadRuntimeConfig()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
await import('./main')
|
||||
})()
|
||||
Loading…
Add table
Reference in a new issue