Skip to main content

futu_cache/event_contract/
catalog.rs

1use super::*;
2
3impl EventContractCache {
4    #[must_use]
5    pub fn is_category_ready(&self) -> bool {
6        !self.category.load().categories.is_empty()
7    }
8
9    #[must_use]
10    pub fn has_competition_generation(&self, category_id: u32) -> bool {
11        self.category
12            .load()
13            .competitions_by_category
14            .contains_key(&category_id)
15    }
16
17    pub fn apply_category_response(
18        &self,
19        body: &[u8],
20        app_lang: i32,
21    ) -> Result<(), EventContractCacheError> {
22        let response = GetEventCategoryResponse::decode(body)
23            .map_err(|error| EventContractCacheError::Decode(error.to_string()))?;
24        if response.ret_code != Some(0) {
25            return Err(EventContractCacheError::Validation(
26                "event contract category backend ret_code is not success",
27            ));
28        }
29
30        let _writer = self.category_writer.lock();
31        let previous = self.category_snapshot();
32        let mut next = CategoryGeneration {
33            competitions_by_category: previous.competitions_by_category.clone(),
34            ..CategoryGeneration::default()
35        };
36        let mut category_ids = std::collections::BTreeSet::new();
37        let mut category_values = std::collections::BTreeSet::new();
38
39        for item in response.mix_category_items {
40            let id = required_nonzero(item.category_id, "category_id is missing or zero")?;
41            let value = required_string(item.category, "category is missing or empty")?;
42            if !category_ids.insert(id) || !category_values.insert(value.clone()) {
43                return Err(EventContractCacheError::Validation(
44                    "duplicate event contract category",
45                ));
46            }
47            next.categories.push(Category {
48                id,
49                value,
50                name: localized(item.name.as_ref(), app_lang),
51            });
52
53            let mut tags_by_value = std::collections::BTreeMap::<String, Tag>::new();
54            for mix in item.mix_items {
55                let Some(value) = mix.tag.filter(|value| !value.is_empty()) else {
56                    continue;
57                };
58                let tag_id = mix
59                    .sub_category_item
60                    .as_ref()
61                    .and_then(|tag| tag.sub_category_id)
62                    .or(mix.mix_item_id)
63                    .unwrap_or(0);
64                // Frozen C++ 10.9.6918 treats sub_category_id=0 as an
65                // "All" pseudo category and never publishes it.
66                if tag_id == 0 {
67                    continue;
68                }
69                let entry = tags_by_value.entry(value.clone()).or_insert_with(|| Tag {
70                    id: tag_id,
71                    category_id: id,
72                    value,
73                    name: localized(mix.name.as_ref(), app_lang),
74                    scopes: Vec::new(),
75                });
76                for scope in mix.competition_scopes {
77                    let scope_id =
78                        required_nonzero(scope.id, "competition scope id is missing or zero")?;
79                    let scope_value = required_string(
80                        scope.competition_scope,
81                        "competition scope is missing or empty",
82                    )?;
83                    if entry
84                        .scopes
85                        .iter()
86                        .all(|candidate| candidate.id != scope_id)
87                    {
88                        entry.scopes.push(Scope {
89                            id: scope_id,
90                            value: scope_value,
91                            name: localized(scope.name.as_ref(), app_lang),
92                        });
93                    }
94                }
95            }
96            for sub in item.sub_category_items {
97                let value = required_string(sub.tag, "sub-category tag is missing or empty")?;
98                if tags_by_value.contains_key(&value) {
99                    continue;
100                }
101                let tag_id =
102                    required_nonzero(sub.sub_category_id, "sub-category id is missing or zero")?;
103                tags_by_value.insert(
104                    value.clone(),
105                    Tag {
106                        id: tag_id,
107                        category_id: id,
108                        value,
109                        name: localized(sub.name.as_ref(), app_lang),
110                        scopes: Vec::new(),
111                    },
112                );
113            }
114            next.tags.extend(tags_by_value.into_values());
115        }
116        if next.categories.is_empty() {
117            return Err(EventContractCacheError::Validation(
118                "event contract category response is empty",
119            ));
120        }
121        self.category.store(Arc::new(next));
122        Ok(())
123    }
124
125    pub fn apply_competition_response(
126        &self,
127        category_id: u32,
128        body: &[u8],
129        app_lang: i32,
130    ) -> Result<(), EventContractCacheError> {
131        if category_id == 0 {
132            return Err(EventContractCacheError::Validation(
133                "competition category id is zero",
134            ));
135        }
136        let response = GetCompetitionMixListResponse::decode(body)
137            .map_err(|error| EventContractCacheError::Decode(error.to_string()))?;
138        if response.ret_code != Some(0) {
139            return Err(EventContractCacheError::Validation(
140                "event contract competition backend ret_code is not success",
141            ));
142        }
143        let _writer = self.category_writer.lock();
144        let previous = self.category_snapshot();
145        if previous
146            .categories
147            .iter()
148            .all(|category| category.id != category_id)
149        {
150            return Err(EventContractCacheError::Validation(
151                "competition category is absent from category generation",
152            ));
153        }
154
155        let mut next = (*previous).clone();
156        let mut rows = Vec::new();
157        for group in response.sub_category_groups {
158            let Some(sub) = group.sub_category else {
159                continue;
160            };
161            let tag_id =
162                required_nonzero(sub.sub_category_id, "competition tag id is missing or zero")?;
163            let tag = required_string(sub.tag, "competition tag is missing or empty")?;
164
165            if let Some(cached_tag) = next
166                .tags
167                .iter_mut()
168                .find(|candidate| candidate.category_id == category_id && candidate.value == tag)
169            {
170                cached_tag.id = tag_id;
171            }
172            for content in group.content_items {
173                // Frozen C++ only accepts ContentType::Competition (wire 3);
174                // topic and counters are intentionally ignored.
175                if content.content_type != Some(3) {
176                    continue;
177                }
178                let Some(competition) = content.competition else {
179                    return Err(EventContractCacheError::Validation(
180                        "competition content lacks competition payload",
181                    ));
182                };
183                rows.push(Competition {
184                    id: required_nonzero(competition.id, "competition id is missing or zero")?,
185                    category_id,
186                    tag_id,
187                    tag: tag.clone(),
188                    value: required_string(
189                        competition.competition,
190                        "competition value is missing or empty",
191                    )?,
192                    name: localized(competition.name.as_ref(), app_lang),
193                });
194            }
195        }
196        next.competitions_by_category.insert(category_id, rows);
197        self.category.store(Arc::new(next));
198        Ok(())
199    }
200}
201
202fn required_nonzero(
203    value: Option<u32>,
204    message: &'static str,
205) -> Result<u32, EventContractCacheError> {
206    value
207        .filter(|value| *value != 0)
208        .ok_or(EventContractCacheError::Validation(message))
209}
210
211fn required_string(
212    value: Option<String>,
213    message: &'static str,
214) -> Result<String, EventContractCacheError> {
215    value
216        .filter(|value| !value.is_empty())
217        .ok_or(EventContractCacheError::Validation(message))
218}
219
220pub(super) fn localized(value: Option<&LocaleString>, app_lang: i32) -> String {
221    let Some(value) = value else {
222        return String::new();
223    };
224    // Ref: frozen C++ aec0f6cda1 Src/Common/Common.h language enum:
225    // 2=en-US, 0=zh-CN, 1=zh-HK, 3=ja-JP. Prefer the negotiated app language,
226    // then the same stable product fallback order.
227    [app_lang, 2, 0, 1, 3]
228        .into_iter()
229        .find_map(|language_id| {
230            value.str_context.iter().find_map(|item| {
231                (item.language_id == Some(language_id))
232                    .then(|| item.context.clone())
233                    .flatten()
234                    .filter(|context| !context.is_empty())
235            })
236        })
237        .or_else(|| {
238            value
239                .str_context
240                .iter()
241                .find_map(|item| item.context.clone().filter(|context| !context.is_empty()))
242        })
243        .unwrap_or_default()
244}