tdm_server_rust/service/
gray_release_service.rs1use 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
19pub struct GrayReleaseService;
21
22impl GrayReleaseService {
23 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 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 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 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 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 pub async fn rollback_all(state: &AppState) -> ApiResult<()> {
101 GrayReleaseRepository::new(state.db.clone())
102 .rollback_all()
103 .await
104 }
105
106 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
117pub 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
160pub 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
166fn 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
176fn 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
192fn validate_rollout_rate(rate: i32) -> ApiResult<()> {
194 if (0..=10000).contains(&rate) {
195 Ok(())
196 } else {
197 Err(AppError::business("灰度比例必须在 0 到 10000 之间"))
198 }
199}