feat(ci): normalize shared environment semantics

This commit is contained in:
Your Name
2026-04-21 09:34:29 +08:00
parent 3f509d1a6c
commit b3e34c6e36
11 changed files with 326 additions and 22 deletions

View File

@@ -0,0 +1,98 @@
# 2026-04-21 Env Normalization Checklist
## P2-C-01 三服务环境枚举与别名盘点
1. `gateway`
- 当前输入源是 `GATEWAY_ENV`
- 共享环境判定历史上同时接受 `production``prod``online`
- `staging` 也被视为非开发共享环境,禁止 `inmemory` token runtime。
2. `supply-api`
- `ResolveEnv` 只接受 `dev``staging``prod`
- 默认配置路径按 `config.<env>.yaml` 组装。
- 现有 devtest 脚本曾存在 `-env=staging -config=config.dev.yaml` 的错配。
3. `platform-token-runtime`
- `TOKEN_RUNTIME_ENV` 只接受 `dev``staging``prod`
- `staging/prod` 需要显式 PostgreSQL store`dev` 才允许内存 store。
4. 本次盘点覆盖的枚举全集:
- 规范值:`dev``staging``prod`
- 兼容别名:`production``online`
## P2-C-02 统一枚举
仓库内部只保留三种规范值:
1. `dev`
2. `staging`
3. `prod`
统一规则:
1. `production``online` 只允许作为兼容输入存在。
2. 兼容输入一进入服务边界就必须立即折叠为 `prod`
3. 文档、模板、脚本、报告、CI 产物里不再引入新的别名。
## P2-C-03 gateway 归一化入口
唯一入口定在 `gateway/internal/config/config.go`
1. `LoadConfig` 读取 `GATEWAY_ENV` 后立刻归一化。
2. `ValidateAuthConfig` 与启动安全校验只消费归一化后的值。
3. `bootstrap.go` 不再维护独立的 `production/online` 判定分支。
## P2-C-04 supply-api 错配拒绝规则
拒绝条件:
1.`-env``staging``prod` 时,`-config` 不得指向 `config.dev.yaml``config.dev.yml`
错误信息草稿:
`config path "<path>" is a dev template and cannot be used with -env=<env>; use config.<env>.yaml or a <env> example template instead`
## P2-C-05 staging 模板骨架
`supply-api/config/config.staging.example.yaml` 至少保留以下段落:
1. `server`
2. `database`
3. `redis`
4. `token`
5. `audit`
## P2-C-06 prod 模板骨架
`supply-api/config/config.prod.example.yaml` 必须额外显式包含:
1. `server.default_supplier_id: 0`
2. `token.algorithm: RS256`
3. `token.public_key`
4. `settlement.withdraw_enabled`
5. `sms` 占位字段
## P2-C-07 devtest 参数设计
`scripts/devtest/start_dev_stack.sh` 改为:
1. 使用 `LIJIAOQIAO_DEVTEST_SUPPLY_CONFIG` 作为 `supply-api` 配置路径参数。
2. 默认值改为 `./config/config.staging.example.yaml`
3. 不再把 `config.dev.yaml` 硬编码进 staging 启动链路。
## P2-C-08 环境归一化检查清单
### 启动前检查
1. 所有新文档和样例文件只出现 `dev``staging``prod` 三种规范值。
2. 兼容别名只存在于输入归一化代码,不存在于模板与脚本默认值。
3. `supply-api``staging/prod` 启动命令不引用 `config.dev.yaml`
### 启动后检查
1. `gateway``production``online` 输入下对外表现为 `prod` 语义。
2. `supply-api``-env=staging -config=config.dev.yaml` 时必须 fail fast。
3. `platform-token-runtime` 仍然只接受 `dev/staging/prod`,不新增额外别名。
### CI 检查
1. `gateway` 运行环境归一化相关单测。
2. `supply-api` 运行命令行错配拒绝测试。
3. `scripts/devtest/start_dev_stack.sh` 至少通过 `bash -n` 语法检查。

View File

@@ -113,3 +113,22 @@ git diff --check
1. 已创建 `docs/plans/2026-04-21-real-staging-gate-rules.md`,逐项覆盖 `P2-B-01``P2-B-08`,写死 rehearsal / real staging 术语边界、`PASS_REAL|PASS_REHEARSAL|FAIL` 状态枚举、override 约束和完成率口径。
2. 已在 `scripts/ci/superpowers_stage_validate.sh``scripts/ci/staging_real_readiness_check.sh``scripts/ci/superpowers_release_pipeline.sh``scripts/ci/final_decision_consistency_check.sh` 补入迁移设计注释,明确真实 staging 是唯一 release 硬门禁,且 `DEFERRED` / rehearsal 不得计入 release pass。
3. 四个脚本 `bash -n` 通过,且 `git diff --check` 无格式错误;本批次仅落设计规则与迁移约束,没有伪装成已完成实现。
## P2-C 环境归一化与模板骨架完成
执行命令:
```bash
bash -n scripts/devtest/start_dev_stack.sh
go test ./internal/config ./internal/app
go test ./cmd/supply-api ./internal/app ./internal/config
git diff --check
```
执行结果:
1. 已在 `docs/plans/2026-04-21-env-normalization-checklist.md` 盘点 `gateway``supply-api``platform-token-runtime` 的环境枚举,统一仓库内规范值为 `dev``staging``prod`并补全启动前、启动后、CI 三类检查项。
2. 已在 `gateway/internal/config/config.go` 增加单一 `NormalizeEnv` 入口,并让 `gateway/internal/app/bootstrap.go` 的生产安全判定复用该入口,收敛 `production/online -> prod` 兼容逻辑。
3. 已在 `supply-api/cmd/supply-api/main.go` 增加 `staging/prod + config.dev.yaml` 的 fail-fast 拒绝规则,并补充命令行测试覆盖;同时新增 `supply-api/config/config.staging.example.yaml``supply-api/config/config.prod.example.yaml` 模板骨架。
4. 已在 `scripts/devtest/start_dev_stack.sh` 引入 `LIJIAOQIAO_DEVTEST_SUPPLY_CONFIG` 参数,默认改用 `./config/config.staging.example.yaml`,去除 staging 启动链路里的 `config.dev.yaml` 硬编码。
5. `bash -n scripts/devtest/start_dev_stack.sh``go test ./internal/config ./internal/app``go test ./cmd/supply-api ./internal/app ./internal/config` 均通过,且 `git diff --check` 无格式错误。

View File

@@ -357,21 +357,21 @@
- Create: `supply-api/config/config.prod.example.yaml`
- Create: `docs/plans/2026-04-21-env-normalization-checklist.md`
- [ ] `P2-C-01` 盘点三服务支持的环境枚举和值别名。
- [x] `P2-C-01` 盘点三服务支持的环境枚举和值别名。
完成标准:清单覆盖 `dev``staging``prod``production``online`
- [ ] `P2-C-02` 定义统一枚举。
- [x] `P2-C-02` 定义统一枚举。
完成标准:计划文档只保留 `dev``staging``prod`
- [ ] `P2-C-03` 设计 `gateway``production/online -> prod` 的归一化入口。
- [x] `P2-C-03` 设计 `gateway``production/online -> prod` 的归一化入口。
完成标准:只保留一个真正规则。
- [ ] `P2-C-04` 设计 `supply-api``-env=staging -config=config.dev.yaml` 的拒绝规则。
- [x] `P2-C-04` 设计 `supply-api``-env=staging -config=config.dev.yaml` 的拒绝规则。
完成标准:规则中包含错误信息草稿。
- [ ] `P2-C-05` 复制 `config.dev.yaml` 所需段落,生成 staging 模板骨架。
- [x] `P2-C-05` 复制 `config.dev.yaml` 所需段落,生成 staging 模板骨架。
完成标准:`config.staging.example.yaml` 包含必要配置段。
- [ ] `P2-C-06` 复制 prod 模板骨架。
- [x] `P2-C-06` 复制 prod 模板骨架。
完成标准:`config.prod.example.yaml` 包含关键安全配置占位。
- [ ] `P2-C-07``scripts/devtest/start_dev_stack.sh` 设计改用 staging 模板的参数。
- [x] `P2-C-07``scripts/devtest/start_dev_stack.sh` 设计改用 staging 模板的参数。
完成标准:脚本不再硬编码 `config.dev.yaml`
- [ ] `P2-C-08` 写环境归一化检查清单。
- [x] `P2-C-08` 写环境归一化检查清单。
完成标准包括启动前检查、启动后检查、CI 检查三类项。
### Task P2-D: 重构测试分类,补真实跨服务 smoke

View File

@@ -273,12 +273,9 @@ func validateStartupSecurity(cfg config.Config) error {
}
func isProductionEnv(env string) bool {
switch strings.ToLower(strings.TrimSpace(env)) {
case "production", "prod", "online":
return true
default:
return false
}
// 共享环境别名归一化只允许在 config.NormalizeEnv 一处定义,
// 启动安全校验只消费归一化后的 prod 结果,避免多处规则漂移。
return config.NormalizeEnv(env) == "prod"
}
func isDefaultEncryptionKey() bool {

View File

@@ -38,11 +38,11 @@ type ServerConfig struct {
// AuthConfig 鉴权运行时配置
type AuthConfig struct {
Env string
TokenRuntimeMode string
TokenRuntimeURL string
TrustedProxies []string // 可信的代理IP列表用于IP伪造防护
CORSAllowOrigins []string // 允许的CORS来源为空则使用默认通配符
Env string
TokenRuntimeMode string
TokenRuntimeURL string
TrustedProxies []string // 可信的代理IP列表用于IP伪造防护
CORSAllowOrigins []string // 允许的CORS来源为空则使用默认通配符
}
// DatabaseConfig 数据库配置
@@ -166,7 +166,7 @@ func LoadConfig(path string) (*Config, error) {
IdleTimeout: 120 * time.Second,
},
Auth: AuthConfig{
Env: strings.ToLower(getEnv("GATEWAY_ENV", "dev")),
Env: NormalizeEnv(getEnv("GATEWAY_ENV", "dev")),
TokenRuntimeMode: strings.ToLower(getEnv("GATEWAY_TOKEN_RUNTIME_MODE", "inmemory")),
TokenRuntimeURL: strings.TrimSpace(getEnv("GATEWAY_TOKEN_RUNTIME_URL", "")),
},
@@ -222,9 +222,24 @@ func LoadConfig(path string) (*Config, error) {
return cfg, nil
}
// NormalizeEnv 将兼容别名统一折叠到仓库内的唯一环境枚举。
// Phase P2-C 之后,代码与文档内部只保留 dev/staging/prod。
func NormalizeEnv(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "dev":
return "dev"
case "staging":
return "staging"
case "production", "online", "prod":
return "prod"
default:
return strings.ToLower(strings.TrimSpace(raw))
}
}
func ValidateAuthConfig(cfg AuthConfig) error {
mode := strings.ToLower(strings.TrimSpace(cfg.TokenRuntimeMode))
env := strings.ToLower(strings.TrimSpace(cfg.Env))
env := NormalizeEnv(cfg.Env)
switch mode {
case "inmemory", "remote_introspection":

View File

@@ -444,6 +444,36 @@ func TestValidateAuthConfig_ProdRequiresRemoteIntrospection(t *testing.T) {
}
}
func TestValidateAuthConfig_OnlineAliasRequiresRemoteIntrospection(t *testing.T) {
cfg := AuthConfig{Env: "online", TokenRuntimeMode: "inmemory"}
if err := ValidateAuthConfig(cfg); err == nil {
t.Fatal("expected online alias to be normalized to prod and rejected")
}
}
func TestLoadConfig_NormalizesProductionAliases(t *testing.T) {
t.Setenv("GATEWAY_TOKEN_RUNTIME_MODE", "remote_introspection")
t.Setenv("GATEWAY_TOKEN_RUNTIME_URL", "http://127.0.0.1:18081")
t.Setenv("GATEWAY_ENV", "production")
cfg, err := LoadConfig("")
if err != nil {
t.Fatalf("unexpected error loading production alias: %v", err)
}
if cfg.Auth.Env != "prod" {
t.Fatalf("expected production alias to normalize to prod, got %q", cfg.Auth.Env)
}
t.Setenv("GATEWAY_ENV", "online")
cfg, err = LoadConfig("")
if err != nil {
t.Fatalf("unexpected error loading online alias: %v", err)
}
if cfg.Auth.Env != "prod" {
t.Fatalf("expected online alias to normalize to prod, got %q", cfg.Auth.Env)
}
}
func TestLoadConfig_DefaultProvider(t *testing.T) {
t.Setenv("OPENAI_BASE_URL", "https://api.openai.com")
t.Setenv("OPENAI_MODELS", "gpt-4o-mini,gpt-4o")

View File

@@ -25,6 +25,7 @@ GATEWAY_PORT="${LIJIAOQIAO_DEVTEST_GATEWAY_PORT:-18080}"
SUPPLY_TOKEN_SECRET_KEY="${LIJIAOQIAO_DEVTEST_SUPPLY_TOKEN_SECRET_KEY:-devtest-secret-key-12345678901234567890}"
SUPPLY_TOKEN_ISSUER="${LIJIAOQIAO_DEVTEST_SUPPLY_TOKEN_ISSUER:-lijiaoqiao/supply-api}"
SUPPLY_CONFIG_PATH="${LIJIAOQIAO_DEVTEST_SUPPLY_CONFIG:-./config/config.staging.example.yaml}"
OPENAI_MODELS="${LIJIAOQIAO_DEVTEST_OPENAI_MODELS:-gpt-4o-mini,gpt-4o,gpt-4.1,o3-mini,claude-3-5-sonnet,claude-3-7-sonnet,gemini-2.0-flash,deepseek-chat}"
@@ -132,6 +133,7 @@ export LIJIAOQIAO_DEVTEST_GATEWAY_PORT="${GATEWAY_PORT}"
export LIJIAOQIAO_DEVTEST_OPENAI_MODELS="${OPENAI_MODELS}"
export LIJIAOQIAO_DEVTEST_SUPPLY_TOKEN_SECRET_KEY="${SUPPLY_TOKEN_SECRET_KEY}"
export LIJIAOQIAO_DEVTEST_SUPPLY_TOKEN_ISSUER="${SUPPLY_TOKEN_ISSUER}"
export LIJIAOQIAO_DEVTEST_SUPPLY_CONFIG="${SUPPLY_CONFIG_PATH}"
EOF
}
@@ -187,7 +189,7 @@ start_process "platform-token-runtime" \
wait_http "platform-token-runtime" "http://${TOKEN_RUNTIME_ADDR}/actuator/health"
start_process "supply-api" \
"cd \"${ROOT_DIR}/supply-api\" && SUPPLY_API_ADDR=\"${SUPPLY_API_ADDR}\" SUPPLY_DB_HOST=\"${PG_HOST}\" SUPPLY_DB_PORT=\"${PG_PORT}\" SUPPLY_DB_USER=\"${PG_USER}\" SUPPLY_DB_PASSWORD=\"${PG_PASSWORD}\" SUPPLY_DB_NAME=\"${SUPPLY_DB}\" SUPPLY_API_IAM_ENABLED=\"true\" SUPPLY_TOKEN_SECRET_KEY=\"${SUPPLY_TOKEN_SECRET_KEY}\" SUPPLY_TOKEN_ISSUER=\"${SUPPLY_TOKEN_ISSUER}\" GOCACHE=\"${STATE_DIR}/go-cache/supply-api\" go run ./cmd/supply-api -env=staging -config ./config/config.dev.yaml"
"cd \"${ROOT_DIR}/supply-api\" && SUPPLY_API_ADDR=\"${SUPPLY_API_ADDR}\" SUPPLY_DB_HOST=\"${PG_HOST}\" SUPPLY_DB_PORT=\"${PG_PORT}\" SUPPLY_DB_USER=\"${PG_USER}\" SUPPLY_DB_PASSWORD=\"${PG_PASSWORD}\" SUPPLY_DB_NAME=\"${SUPPLY_DB}\" SUPPLY_API_IAM_ENABLED=\"true\" SUPPLY_TOKEN_SECRET_KEY=\"${SUPPLY_TOKEN_SECRET_KEY}\" SUPPLY_TOKEN_ISSUER=\"${SUPPLY_TOKEN_ISSUER}\" GOCACHE=\"${STATE_DIR}/go-cache/supply-api\" go run ./cmd/supply-api -env=staging -config \"${SUPPLY_CONFIG_PATH}\""
wait_http "supply-api" "http://127.0.0.1${SUPPLY_API_ADDR#:}/actuator/health"
start_process "gateway" \

View File

@@ -3,9 +3,12 @@ package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -30,6 +33,9 @@ func main() {
if *configPath == "" {
*configPath = "./config/config." + *env + ".yaml"
}
if err := validateEnvConfigPath(*env, *configPath); err != nil {
logging.NewLogger("supply-api", logging.LogLevelInfo).Fatalf("%v", err)
}
// P1-010修复: 初始化结构化日志
jsonLogger := logging.NewLogger("supply-api", logging.LogLevelInfo)
@@ -105,3 +111,23 @@ func main() {
jsonLogger.Info("shutdown complete")
}
func validateEnvConfigPath(envName, configPath string) error {
if envName == "dev" {
return nil
}
base := strings.ToLower(strings.TrimSpace(filepath.Base(configPath)))
switch base {
case "config.dev.yaml", "config.dev.yml":
return fmt.Errorf(
"config path %q is a dev template and cannot be used with -env=%s; use config.%s.yaml or a %s example template instead",
configPath,
envName,
envName,
envName,
)
default:
return nil
}
}

View File

@@ -78,6 +78,27 @@ func TestMain_RejectsUnsupportedEnvBeforeLoadingConfig(t *testing.T) {
}
}
func TestMain_RejectsDevConfigPathForStaging(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.dev.yaml")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestMainHelperProcess", "--", "-env", "staging", "-config", configPath)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
t.Fatalf("expected staging + dev config mismatch to fail fast, but process timed out. output=%s", string(output))
}
if err == nil {
t.Fatalf("expected staging + dev config mismatch to fail, but process exited successfully. output=%s", string(output))
}
if !strings.Contains(string(output), "dev template") {
t.Fatalf("expected output to mention dev template mismatch, got: %s", string(output))
}
}
func TestMainHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return

View File

@@ -0,0 +1,56 @@
# Supply API Production Example Configuration
# Production rules:
# - keep server.default_supplier_id at 0
# - do not use HS256/HS384/HS512
# - provide token.public_key for RSA verification
server:
addr: ":18082"
read_timeout: 10s
write_timeout: 15s
idle_timeout: 30s
shutdown_timeout: 5s
default_supplier_id: 0
database:
host: "prod-postgres.internal"
port: 5432
user: "supply_api"
password: "${SUPPLY_DB_PASSWORD}"
database: "supply_prod"
max_open_conns: 50
max_idle_conns: 10
conn_max_lifetime: 1h
conn_max_idle_time: 10m
redis:
host: "prod-redis.internal"
port: 6379
password: "${SUPPLY_REDIS_PASSWORD}"
db: 0
pool_size: 20
token:
algorithm: "RS256"
public_key: |
${SUPPLY_TOKEN_PUBLIC_KEY}
issuer: "lijiaoqiao/supply-api"
access_token_ttl: 1h
refresh_token_ttl: 168h
revocation_cache_ttl: 30s
settlement:
withdraw_enabled: false
sms:
enabled: false
provider: ""
app_id: ""
app_secret: ""
sign_name: ""
template_code: ""
audit:
buffer_size: 1000
flush_interval: 5s
export_timeout: 30s

View File

@@ -0,0 +1,40 @@
# Supply API Staging Example Configuration
# Use with:
# go run ./cmd/supply-api -env=staging -config ./config/config.staging.example.yaml
server:
addr: ":18082"
read_timeout: 10s
write_timeout: 15s
idle_timeout: 30s
shutdown_timeout: 5s
database:
host: "staging-postgres.internal"
port: 5432
user: "supply_api"
password: "${SUPPLY_DB_PASSWORD}"
database: "supply_staging"
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 1h
conn_max_idle_time: 10m
redis:
host: "staging-redis.internal"
port: 6379
password: "${SUPPLY_REDIS_PASSWORD}"
db: 0
pool_size: 10
token:
secret_key: "${SUPPLY_TOKEN_SECRET_KEY}"
issuer: "lijiaoqiao/supply-api-staging"
access_token_ttl: 1h
refresh_token_ttl: 168h
revocation_cache_ttl: 30s
audit:
buffer_size: 1000
flush_interval: 5s
export_timeout: 30s