From 6b68a368f153488029611e2aee9d999713dbb861 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Sat, 31 Jan 2026 13:37:15 +0800 Subject: [PATCH] fix(doc): fix doc --- docs/SCALAR_GUIDE.md | 30 ++++++++++ src/docs.rs | 106 ++++++++++++++++++++++++++++++---- src/handlers/auth.rs | 6 +- src/handlers/authorization.rs | 1 + src/handlers/platform.rs | 5 +- src/handlers/role.rs | 2 + src/handlers/tenant.rs | 5 ++ src/handlers/user.rs | 6 ++ src/services/tenant.rs | 9 +-- tests/enabled_apps_smoke.rs | 5 ++ 10 files changed, 158 insertions(+), 17 deletions(-) diff --git a/docs/SCALAR_GUIDE.md b/docs/SCALAR_GUIDE.md index 62b5102..9c1e797 100644 --- a/docs/SCALAR_GUIDE.md +++ b/docs/SCALAR_GUIDE.md @@ -16,6 +16,18 @@ - `apps`:租户已开通应用列表(如 `["cms","tms"]`) - `apps_version`:租户 enabled_apps 版本号(用于客户端判断是否需要刷新会话) +## OpenAPI/Scalar 可配置参数(环境变量) + +为方便本地调试与演示,服务会在生成 OpenAPI 文档时,将部分 Header 参数的示例值按环境变量动态注入(影响 `/scalar` 展示,不影响服务鉴权逻辑): + +- `IAM_DOCS_DEFAULT_TENANT_ID`:默认租户 ID(用于 `X-Tenant-ID` Header 的 example) + - 未设置时默认:`11111111-1111-1111-1111-111111111111` + - 可选强制:`IAM_DOCS_REQUIRE_TENANT_ID=1`(未设置则启动时直接失败) +- `IAM_DOCS_DEFAULT_TOKEN`:默认 Token(用于 `Authorization` Header 的 example) + - 可填写裸 JWT 或 `Bearer `;裸 JWT 会自动补齐 `Bearer ` + - 未设置时默认:`Bearer ` + - 可选强制:`IAM_DOCS_REQUIRE_TOKEN=1`(未设置则启动时直接失败) + ## 通用响应结构 成功响应: @@ -63,6 +75,7 @@ **POST** `/tenants/register` +- Tag:`Tenant` - Header:无 - Body: @@ -88,6 +101,7 @@ **POST** `/auth/register` +- Tag:`Auth` - Header:`X-Tenant-ID: 00000000-0000-0000-0000-000000000001` - Body: @@ -99,6 +113,7 @@ **POST** `/auth/register` +- Tag:`Auth` - 必需 Header:`X-Tenant-ID: ` - Body: @@ -116,6 +131,7 @@ **POST** `/auth/login` +- Tag:`Auth` - 必需 Header:`X-Tenant-ID: ` - Body: @@ -135,6 +151,7 @@ **GET** `/tenants/me` +- Tag:`Tenant` - 必需 Header:`Authorization: Bearer ` - 可选 Header:`X-Tenant-ID: `(如提供必须与 token tenant_id 一致) @@ -148,6 +165,7 @@ **GET** `/me/permissions` +- Tag:`Me` - 必需 Header:`Authorization: Bearer ` 成功(200): @@ -169,12 +187,14 @@ **GET** `/platform/tenants/{tenant_id}/enabled-apps` +- Tag:`Tenant` - Header:`Authorization: Bearer `(平台租户下登录得到的 token) #### 4.1.2 设置某租户 enabled_apps(全量覆盖,幂等) **PUT** `/platform/tenants/{tenant_id}/enabled-apps` +- Tag:`Tenant` - Header:`Authorization: Bearer ` - Body: @@ -185,11 +205,18 @@ 说明: - `expected_version` 可选,用于并发控制;不匹配会返回 409。 - 登录签发 token 时会自动把 `apps/apps_version` 注入到 JWT,并对 `permissions` 按 enabled_apps 过滤。 +- `enabled_apps` 必须是“应用注册表 apps 表”中存在且 `status=active` 的应用 ID;若传入未知/已下线应用(例如 `dms`),接口会返回 400。 + +enabled_apps 维护建议: +- 新增应用:通过数据库迁移向 `apps(id,name,description,status)` 插入一行(`id` 推荐全小写短标识,如 `cms` / `tms`)。 +- 下线应用:将 `apps.status` 置为非 `active`(例如 `disabled`),之后将无法再被设置进任何租户的 enabled_apps。 +- 查询可用应用(示例 SQL):`SELECT id, name, status FROM apps ORDER BY id;` ### Step 5:列出用户(User) **GET** `/users?page=1&page_size=20` +- Tag:`User` - 必需 Header:`Authorization: Bearer ` - 分页规则: - `page` 默认 1,必须 >= 1 @@ -205,6 +232,7 @@ **GET** `/roles` +- Tag:`Role` - 必需 Header:`Authorization: Bearer ` 成功(200): @@ -221,12 +249,14 @@ **GET** `/users/{id}/roles` +- Tag:`User` - Header:`Authorization: Bearer ` #### 7.2 设置用户角色(全量覆盖,幂等;需要 user:write) **PUT** `/users/{id}/roles` +- Tag:`User` - Header:`Authorization: Bearer ` - Body: diff --git a/src/docs.rs b/src/docs.rs index 0d0e8a6..aff3704 100644 --- a/src/docs.rs +++ b/src/docs.rs @@ -5,11 +5,61 @@ use crate::models::{ UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest, UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse, }; +use serde_json::Value; use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa::{Modify, OpenApi}; struct SecurityAddon; +#[derive(Clone)] +struct DocsEnvConfig { + default_tenant_id: Option, + default_token: Option, +} + +impl DocsEnvConfig { + fn from_env() -> Self { + let require_tenant = std::env::var("IAM_DOCS_REQUIRE_TENANT_ID") + .map(|v| v == "1") + .unwrap_or(false); + let require_token = std::env::var("IAM_DOCS_REQUIRE_TOKEN") + .map(|v| v == "1") + .unwrap_or(false); + + let default_tenant_id = std::env::var("IAM_DOCS_DEFAULT_TENANT_ID").ok(); + let default_token = std::env::var("IAM_DOCS_DEFAULT_TOKEN").ok(); + + if require_tenant && default_tenant_id.is_none() { + panic!("IAM_DOCS_REQUIRE_TENANT_ID=1 but IAM_DOCS_DEFAULT_TENANT_ID is not set"); + } + if require_token && default_token.is_none() { + panic!("IAM_DOCS_REQUIRE_TOKEN=1 but IAM_DOCS_DEFAULT_TOKEN is not set"); + } + + Self { + default_tenant_id, + default_token, + } + } + + fn tenant_example(&self) -> String { + self.default_tenant_id + .clone() + .unwrap_or_else(|| "11111111-1111-1111-1111-111111111111".to_string()) + } + + fn authorization_example(&self) -> String { + let Some(token) = self.default_token.clone() else { + return "Bearer ".to_string(); + }; + if token.to_ascii_lowercase().starts_with("bearer ") { + token + } else { + format!("Bearer {}", token) + } + } +} + impl Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { let components = openapi @@ -24,6 +74,51 @@ impl Modify for SecurityAddon { .build(), ), ); + + let cfg = DocsEnvConfig::from_env(); + apply_header_parameter_examples(openapi, &cfg); + } +} + +fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg: &DocsEnvConfig) { + use utoipa::openapi::path::ParameterBuilder; + use utoipa::openapi::path::ParameterIn; + + let tenant_value = cfg.tenant_example(); + let auth_value = cfg.authorization_example(); + + for (_path, item) in openapi.paths.paths.iter_mut() { + let operations = [ + item.get.as_mut(), + item.post.as_mut(), + item.put.as_mut(), + item.patch.as_mut(), + item.delete.as_mut(), + ]; + + for op in operations.into_iter().flatten() { + let Some(params) = op.parameters.as_mut() else { + continue; + }; + for param in params.iter_mut() { + if param.parameter_in == ParameterIn::Header + && param.name.eq_ignore_ascii_case("X-Tenant-ID") + { + let builder: ParameterBuilder = std::mem::take(param).into(); + *param = builder + .example(Some(Value::String(tenant_value.clone()))) + .build(); + } + if param.parameter_in == ParameterIn::Header + && param.name.eq_ignore_ascii_case("Authorization") + { + let builder: ParameterBuilder = std::mem::take(param).into(); + *param = builder + .example(Some(Value::String(auth_value.clone()))) + .build(); + } + } + } } } @@ -35,16 +130,7 @@ impl Modify for SecurityAddon { version = "0.1.0", description = include_str!("../docs/SCALAR_GUIDE.md") ), - servers( - ( - url = "https://{env}/api", - description = "Environment server", - variables( - ("env" = (default = "dev", enum_values("dev", "staging", "prod"))), - ("port" = (default = "5010")) - ) - ) - ), + paths( handlers::auth::register_handler, handlers::auth::login_handler, diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 17b745d..ea975a4 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -5,7 +5,8 @@ use axum::{Json, extract::State}; use common_telemetry::{AppError, AppResponse}; use tracing::instrument; -/// 注册接口 +/// Register (create user in tenant). +/// 注册接口(在租户下创建用户)。 #[utoipa::path( post, path = "/auth/register", @@ -40,7 +41,8 @@ pub async fn register_handler( Ok(AppResponse::created(response)) } -/// 登录接口 +/// Login (issue access token). +/// 登录接口(签发访问令牌)。 #[utoipa::path( post, path = "/auth/login", diff --git a/src/handlers/authorization.rs b/src/handlers/authorization.rs index 87399e6..2b38a9d 100644 --- a/src/handlers/authorization.rs +++ b/src/handlers/authorization.rs @@ -23,6 +23,7 @@ use tracing::instrument; ) )] #[instrument(skip(state))] +/// List current user's permissions in current tenant. /// 查询当前登录用户在当前租户下的权限编码列表。 /// /// 用途: diff --git a/src/handlers/platform.rs b/src/handlers/platform.rs index 39e93d5..37636d2 100644 --- a/src/handlers/platform.rs +++ b/src/handlers/platform.rs @@ -9,6 +9,8 @@ use common_telemetry::{AppError, AppResponse}; use tracing::instrument; use uuid::Uuid; +/// Get tenant enabled apps (platform scope). +/// 平台层:查询租户已开通应用(enabled_apps)。 #[utoipa::path( get, path = "/platform/tenants/{tenant_id}/enabled-apps", @@ -46,6 +48,8 @@ pub async fn get_tenant_enabled_apps_handler( })) } +/// Set tenant enabled apps (platform scope, full replace). +/// 平台层:设置租户已开通应用(enabled_apps,全量覆盖)。 #[utoipa::path( put, path = "/platform/tenants/{tenant_id}/enabled-apps", @@ -89,4 +93,3 @@ pub async fn set_tenant_enabled_apps_handler( updated_at, })) } - diff --git a/src/handlers/role.rs b/src/handlers/role.rs index 850d5f0..8be4ecb 100644 --- a/src/handlers/role.rs +++ b/src/handlers/role.rs @@ -26,6 +26,7 @@ use tracing::instrument; ) )] #[instrument(skip(state, payload))] +/// Create role in current tenant. /// 在当前租户下创建角色。 /// /// 业务规则: @@ -87,6 +88,7 @@ pub async fn create_role_handler( ) )] #[instrument(skip(state))] +/// List roles in current tenant. /// 查询当前租户下的角色列表。 /// /// 业务规则: diff --git a/src/handlers/tenant.rs b/src/handlers/tenant.rs index 77a9150..a1b3663 100644 --- a/src/handlers/tenant.rs +++ b/src/handlers/tenant.rs @@ -19,6 +19,7 @@ use tracing::instrument; ) )] #[instrument(skip(state, payload))] +/// Create tenant (public endpoint). /// 创建租户(公开接口)。 /// /// 业务规则: @@ -65,6 +66,7 @@ pub async fn create_tenant_handler( ) )] #[instrument(skip(state))] +/// Get current tenant info. /// 获取当前登录用户所属租户的信息。 /// /// 业务规则: @@ -127,6 +129,7 @@ pub async fn get_tenant_handler( ) )] #[instrument(skip(state, payload))] +/// Update current tenant (name / config). /// 更新当前租户的基础信息(名称 / 配置)。 /// /// 业务规则: @@ -194,6 +197,7 @@ pub async fn update_tenant_handler( ) )] #[instrument(skip(state, payload))] +/// Update current tenant status (e.g. active / disabled). /// 更新当前租户状态(如 active / disabled)。 /// /// 业务规则: @@ -260,6 +264,7 @@ pub async fn update_tenant_status_handler( ) )] #[instrument(skip(state))] +/// Delete current tenant. /// 删除当前租户。 /// /// 业务规则: diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 7fcb1ba..629aca7 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -38,6 +38,7 @@ pub struct ListUsersQuery { ) )] #[instrument(skip(state))] +/// List users in tenant with pagination. /// 分页查询当前租户下的用户列表。 /// /// 业务规则: @@ -114,6 +115,7 @@ pub async fn list_users_handler( ) )] #[instrument(skip(state))] +/// Get user by id. /// 根据用户 ID 查询用户详情。 /// /// 业务规则: @@ -181,6 +183,7 @@ pub async fn get_user_handler( ) )] #[instrument(skip(state, payload))] +/// Update user (currently supports updating email). /// 更新指定用户信息(目前支持更新邮箱)。 /// /// 业务规则: @@ -250,6 +253,7 @@ pub async fn update_user_handler( ) )] #[instrument(skip(state))] +/// Delete user. /// 删除指定用户。 /// /// 业务规则: @@ -312,6 +316,7 @@ pub async fn delete_user_handler( ) )] #[instrument(skip(state))] +/// List roles bound to a user. /// 查询用户已绑定的角色列表。 /// /// 业务规则: @@ -380,6 +385,7 @@ pub async fn list_user_roles_handler( ) )] #[instrument(skip(state, payload))] +/// Set user's roles (full replace, idempotent). /// 设置用户的角色绑定(全量覆盖,幂等)。 /// /// 业务规则: diff --git a/src/services/tenant.rs b/src/services/tenant.rs index 5077c2f..a6eb7f0 100644 --- a/src/services/tenant.rs +++ b/src/services/tenant.rs @@ -367,10 +367,11 @@ impl TenantService { if enabled_apps.is_empty() { return Ok(()); } - let rows: Vec = sqlx::query_scalar("SELECT id FROM apps WHERE id = ANY($1)") - .bind(enabled_apps) - .fetch_all(&self.pool) - .await?; + let rows: Vec = + sqlx::query_scalar("SELECT id FROM apps WHERE id = ANY($1) AND status = 'active'") + .bind(enabled_apps) + .fetch_all(&self.pool) + .await?; let found: HashSet = rows.into_iter().collect(); for app in enabled_apps { if !found.contains(app) { diff --git a/tests/enabled_apps_smoke.rs b/tests/enabled_apps_smoke.rs index 8905969..ac8daf9 100644 --- a/tests/enabled_apps_smoke.rs +++ b/tests/enabled_apps_smoke.rs @@ -74,6 +74,11 @@ async fn tenant_enabled_apps_roundtrip_and_version_conflict() .await; assert!(r2.is_err()); + let r3 = tenant_service + .set_enabled_apps(tenant_id, vec!["dms".to_string()], None, actor_user_id) + .await; + assert!(r3.is_err()); + cleanup(&pool, tenant_id, &test_app).await; Ok(()) }