Skip to main content

tdm_server_rust/service/
gray_release_service.rs

1//! 灰度发布服务 (Gray Release Service)
2//!
3//! 提供灰度命中判断、管理端配置保存和一键回退能力。
4
5use crate::{
6    app::AppState,
7    entity::{
8        gray_release::{
9            GrayCurrentDecisionResponse, GrayFeatureAdminItem, GrayFeatureDecision,
10            GrayFeatureRecord, GrayFeatureUpdateRequest,
11        },
12        member::Member,
13    },
14    error::{ApiResult, AppError},
15    repository::gray_release_repo::GrayReleaseRepository,
16};
17use std::collections::BTreeMap;
18
19/// 灰度发布服务。
20pub struct GrayReleaseService;
21
22impl GrayReleaseService {
23    /// 判断当前组员是否命中指定灰度功能。
24    pub async fn is_enabled(state: &AppState, feature_key: &str, member_id: Option<i32>) -> bool {
25        match GrayReleaseRepository::new(state.db.clone())
26            .get_feature(feature_key)
27            .await
28        {
29            Ok(feature) => decide_feature(feature.as_ref(), feature_key, member_id).enabled,
30            Err(e) => {
31                tracing::warn!("灰度配置读取失败 feature_key={feature_key}: {e:?}");
32                false
33            }
34        }
35    }
36
37    /// 查询当前组员的全部灰度命中结果。
38    pub async fn current_decisions(
39        state: &AppState,
40        member: Option<Member>,
41    ) -> ApiResult<GrayCurrentDecisionResponse> {
42        let member_id = member.map(|m| m.id);
43        let features = match GrayReleaseRepository::new(state.db.clone())
44            .list_features()
45            .await
46        {
47            Ok(features) => features,
48            Err(e) => {
49                tracing::warn!("灰度配置列表读取失败: {e:?}");
50                return Ok(GrayCurrentDecisionResponse {
51                    features: BTreeMap::new(),
52                });
53            }
54        };
55        let mut decisions = BTreeMap::new();
56        for feature in features {
57            let enabled = decide_feature(Some(&feature), &feature.feature_key, member_id).enabled;
58            decisions.insert(feature.feature_key, enabled);
59        }
60        Ok(GrayCurrentDecisionResponse {
61            features: decisions,
62        })
63    }
64
65    /// 查询管理端灰度功能配置列表。
66    pub async fn list_admin(state: &AppState) -> ApiResult<Vec<GrayFeatureAdminItem>> {
67        let list = GrayReleaseRepository::new(state.db.clone())
68            .list_features()
69            .await?
70            .into_iter()
71            .map(GrayFeatureAdminItem::from)
72            .collect();
73        Ok(list)
74    }
75
76    /// 新增或更新灰度功能配置。
77    pub async fn update_feature(
78        state: &AppState,
79        feature_key: &str,
80        mut req: GrayFeatureUpdateRequest,
81    ) -> ApiResult<()> {
82        validate_feature_key(feature_key)?;
83        validate_rollout_rate(req.rollout_rate)?;
84        req.feature_name = req.feature_name.trim().to_string();
85        req.description = req.description.trim().to_string();
86        GrayReleaseRepository::new(state.db.clone())
87            .upsert_feature(feature_key, req)
88            .await
89    }
90
91    /// 回退单个灰度功能到旧逻辑。
92    pub async fn rollback_feature(state: &AppState, feature_key: &str) -> ApiResult<()> {
93        validate_feature_key(feature_key)?;
94        GrayReleaseRepository::new(state.db.clone())
95            .rollback_feature(feature_key)
96            .await
97    }
98
99    /// 回退全部灰度功能到旧逻辑。
100    pub async fn rollback_all(state: &AppState) -> ApiResult<()> {
101        GrayReleaseRepository::new(state.db.clone())
102            .rollback_all()
103            .await
104    }
105
106    /// 校验当前组员是否具备灰度管理权限。
107    pub fn ensure_admin(member: Option<Member>) -> ApiResult<()> {
108        let is_admin = member.as_ref().is_some_and(Member::has_reviewer_permission);
109        if is_admin {
110            Ok(())
111        } else {
112            Err(AppError::business_code(403, "权限不足"))
113        }
114    }
115}
116
117/// 判断指定配置是否命中灰度。
118pub fn decide_feature(
119    feature: Option<&GrayFeatureRecord>,
120    feature_key: &str,
121    member_id: Option<i32>,
122) -> GrayFeatureDecision {
123    let Some(feature) = feature else {
124        return GrayFeatureDecision {
125            enabled: false,
126            bucket: None,
127            reason: "missing",
128        };
129    };
130    if !feature.enabled {
131        return GrayFeatureDecision {
132            enabled: false,
133            bucket: None,
134            reason: "disabled",
135        };
136    }
137    let Some(member_id) = member_id else {
138        return GrayFeatureDecision {
139            enabled: false,
140            bucket: None,
141            reason: "anonymous",
142        };
143    };
144    if feature.tester_member_ids.contains(&member_id) {
145        return GrayFeatureDecision {
146            enabled: true,
147            bucket: None,
148            reason: "tester",
149        };
150    }
151    let bucket = gray_bucket(feature_key, member_id);
152    let enabled = bucket < feature.rollout_rate as u32;
153    GrayFeatureDecision {
154        enabled,
155        bucket: Some(bucket),
156        reason: if enabled { "rollout" } else { "bucket_miss" },
157    }
158}
159
160/// 计算组员在指定功能下的灰度桶。
161pub fn gray_bucket(feature_key: &str, member_id: i32) -> u32 {
162    let hash_key = format!("{feature_key}:{member_id}");
163    fnv1a_32(hash_key.as_bytes()) % 10000
164}
165
166/// 计算 FNV-1a 32-bit 哈希。
167fn fnv1a_32(bytes: &[u8]) -> u32 {
168    let mut hash = 2_166_136_261_u32;
169    for byte in bytes {
170        hash ^= u32::from(*byte);
171        hash = hash.wrapping_mul(16_777_619);
172    }
173    hash
174}
175
176/// 校验灰度功能标识。
177fn validate_feature_key(feature_key: &str) -> ApiResult<()> {
178    let valid = !feature_key.trim().is_empty()
179        && feature_key.len() <= 64
180        && feature_key
181            .bytes()
182            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
183    if valid {
184        Ok(())
185    } else {
186        Err(AppError::business(
187            "灰度功能标识只能包含字母、数字、点、下划线和短横线",
188        ))
189    }
190}
191
192/// 校验灰度比例。
193fn validate_rollout_rate(rate: i32) -> ApiResult<()> {
194    if (0..=10000).contains(&rate) {
195        Ok(())
196    } else {
197        Err(AppError::business("灰度比例必须在 0 到 10000 之间"))
198    }
199}