models.rs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. use chrono::TimeZone;
  2. use serde::Deserialize;
  3. /// CFC 统一响应包装 Result<T>(code=200 视为成功)
  4. #[derive(Debug, Clone, Deserialize)]
  5. #[serde(rename_all = "camelCase")]
  6. pub struct ApiResult<T> {
  7. pub code: i32,
  8. pub message: Option<String>,
  9. pub data: Option<T>,
  10. }
  11. impl<T> ApiResult<T> {
  12. pub fn is_success(&self) -> bool {
  13. self.code == 200
  14. }
  15. }
  16. /// 兼容后端可能返回的日期格式:ISO 8601 字符串 或 epoch 毫秒数字。
  17. ///
  18. /// Spring Boot 默认把 `java.util.Date` 序列化为 epoch 毫秒(数字);项目内个别
  19. /// 字段用 `@JsonFormat` 输出字符串。两种都接受,统一归一化为本地日期。
  20. #[derive(Debug, Clone, Deserialize)]
  21. #[serde(untagged)]
  22. pub enum FlexDate {
  23. Iso(String),
  24. Millis(i64),
  25. }
  26. impl FlexDate {
  27. /// 归一化为本地时区日期(yyyy-MM-dd)。
  28. pub fn to_local_date(&self) -> Option<chrono::NaiveDate> {
  29. match self {
  30. FlexDate::Iso(s) => {
  31. if s.len() < 10 {
  32. return None;
  33. }
  34. let head = &s[..10];
  35. chrono::NaiveDate::parse_from_str(head, "%Y-%m-%d").ok()
  36. }
  37. FlexDate::Millis(ms) => chrono::Local
  38. .timestamp_millis_opt(*ms)
  39. .single()
  40. .map(|dt| dt.date_naive()),
  41. }
  42. }
  43. }
  44. /// POST /api/tasks/today 返回的 Task(`tasks` 表)
  45. #[derive(Debug, Clone, Deserialize)]
  46. #[serde(rename_all = "camelCase")]
  47. pub struct CfcTask {
  48. pub id: i64,
  49. pub title: Option<String>,
  50. pub status: Option<String>, // PENDING / IN_PROGRESS / COMPLETED / REJECTED
  51. pub deadline: Option<FlexDate>,
  52. }
  53. /// /api/energy/overview 五维维度项:{code,name,icon,element,energy,healthIndex}
  54. #[derive(Debug, Clone, Deserialize)]
  55. #[serde(rename_all = "camelCase")]
  56. pub struct DimensionValue {
  57. pub code: Option<String>, // body/mind/wisdom/action/wealth
  58. pub name: Option<String>, // 身/心/智/行/富
  59. pub icon: Option<String>,
  60. pub element: Option<String>,
  61. #[serde(default)]
  62. pub energy: i32,
  63. #[serde(default)]
  64. pub health_index: i32,
  65. }
  66. #[derive(Debug, Clone, Deserialize)]
  67. #[serde(rename_all = "camelCase")]
  68. pub struct EnergyOverview {
  69. #[serde(default)]
  70. pub dimensions: Vec<DimensionValue>,
  71. #[serde(default)]
  72. pub total_energy: i32,
  73. #[serde(default)]
  74. pub total_health_index: i32,
  75. }
  76. /// /api/user/mascot/get → {"mascot": "xibao"|"fubao"|null}
  77. #[derive(Debug, Clone, Deserialize)]
  78. #[serde(rename_all = "camelCase")]
  79. pub struct MascotInfo {
  80. pub mascot: Option<String>,
  81. }
  82. /// /api/health/water/list|create(`water_record` 表)
  83. #[derive(Debug, Clone, Deserialize)]
  84. #[serde(rename_all = "camelCase")]
  85. pub struct WaterRecord {
  86. pub id: Option<i64>,
  87. pub member_id: Option<i64>,
  88. pub checkin_id: Option<i64>,
  89. pub amount_ml: Option<i32>,
  90. pub water_type: Option<String>,
  91. pub drunk_at: Option<FlexDate>,
  92. pub created_at: Option<FlexDate>,
  93. }
  94. /// /api/media/upload 返回的媒体记录(仅取所需字段,其余忽略)
  95. #[derive(Debug, Clone, Deserialize)]
  96. #[serde(rename_all = "camelCase")]
  97. pub struct MediaRecord {
  98. #[allow(dead_code)]
  99. pub id: Option<i64>,
  100. pub url: Option<String>,
  101. }
  102. /// POST /api/auth/wechat-web/qr → {state, qrImage}
  103. /// qrImage 为小程序码的 data:image/png;base64,... 数据 URI(测试模式为空串)
  104. #[derive(Debug, Clone, Deserialize)]
  105. #[serde(rename_all = "camelCase")]
  106. pub struct WechatQrResp {
  107. pub state: String,
  108. #[serde(default)]
  109. pub qr_image: Option<String>,
  110. }
  111. /// POST /api/auth/wechat-web/poll → {status, token?, userId?, role?, nickname?, message?}
  112. /// status: pending(等待扫码) / success / notfound(微信未绑定小程序账号) / expired / error
  113. #[derive(Debug, Clone, Deserialize)]
  114. #[serde(rename_all = "camelCase")]
  115. pub struct WechatPollResp {
  116. pub status: String,
  117. pub token: Option<String>,
  118. pub user_id: Option<i64>,
  119. pub role: Option<String>,
  120. pub nickname: Option<String>,
  121. pub message: Option<String>,
  122. }
  123. #[cfg(test)]
  124. mod tests {
  125. use super::*;
  126. #[test]
  127. fn parses_media_record_upload_response() {
  128. let json = r#"{"code":200,"message":"success","data":{"id":9,"type":"image","url":"https://cdn/x.png","creatorId":null}}"#;
  129. let r: ApiResult<MediaRecord> = serde_json::from_str(json).unwrap();
  130. assert!(r.is_success());
  131. let rec = r.data.unwrap();
  132. assert_eq!(rec.id, Some(9));
  133. assert_eq!(rec.url.as_deref(), Some("https://cdn/x.png"));
  134. }
  135. #[test]
  136. fn parses_task_with_iso_deadline() {
  137. let json = r#"{"id":3,"title":"喝水","status":"PENDING","deadline":"2026-08-15T10:00:00"}"#;
  138. let task: CfcTask = serde_json::from_str(json).unwrap();
  139. assert_eq!(task.id, 3);
  140. assert_eq!(task.status.as_deref(), Some("PENDING"));
  141. assert_eq!(
  142. task.deadline.as_ref().unwrap().to_local_date(),
  143. chrono::NaiveDate::from_ymd_opt(2026, 8, 15)
  144. );
  145. }
  146. #[test]
  147. fn parses_task_with_millis_deadline() {
  148. let json = r#"{"id":4,"title":"t","status":"PENDING","deadline":1786752000000}"#;
  149. let task: CfcTask = serde_json::from_str(json).unwrap();
  150. assert!(task.deadline.as_ref().unwrap().to_local_date().is_some());
  151. }
  152. #[test]
  153. fn parses_task_with_null_deadline() {
  154. let json = r#"{"id":5,"title":"t","status":"PENDING","deadline":null}"#;
  155. let task: CfcTask = serde_json::from_str(json).unwrap();
  156. assert!(task.deadline.is_none());
  157. }
  158. #[test]
  159. fn flex_date_millis_zero_is_a_valid_date() {
  160. let d: FlexDate = serde_json::from_str("0").unwrap();
  161. assert!(d.to_local_date().is_some());
  162. }
  163. #[test]
  164. fn parses_energy_overview_and_defaults_to_empty() {
  165. let json = r##"{"dimensions":[{"code":"body","name":"身","energy":65,"healthIndex":0}],"totalEnergy":65,"totalHealthIndex":0}"##;
  166. let e: EnergyOverview = serde_json::from_str(json).unwrap();
  167. assert_eq!(e.dimensions.len(), 1);
  168. assert_eq!(e.dimensions[0].energy, 65);
  169. assert_eq!(e.dimensions[0].code.as_deref(), Some("body"));
  170. let empty: EnergyOverview = serde_json::from_str(r#"{}"#).unwrap();
  171. assert!(empty.dimensions.is_empty());
  172. assert_eq!(empty.total_energy, 0);
  173. }
  174. #[test]
  175. fn parses_mascot_response() {
  176. let json = r#"{"code":200,"message":"success","data":{"mascot":"xibao"}}"#;
  177. let r: ApiResult<MascotInfo> = serde_json::from_str(json).unwrap();
  178. assert!(r.is_success());
  179. assert_eq!(r.data.unwrap().mascot.as_deref(), Some("xibao"));
  180. }
  181. #[test]
  182. fn parses_mascot_null() {
  183. let json = r#"{"code":200,"message":"success","data":{"mascot":null}}"#;
  184. let r: ApiResult<MascotInfo> = serde_json::from_str(json).unwrap();
  185. assert!(r.is_success());
  186. assert!(r.data.unwrap().mascot.is_none());
  187. }
  188. #[test]
  189. fn parses_wechat_qr_response() {
  190. let json = r#"{"code":200,"message":"success","data":{"state":"abc123","qrImage":"data:image/png;base64,AAAA"}}"#;
  191. let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
  192. assert!(r.is_success());
  193. let q = r.data.unwrap();
  194. assert_eq!(q.state, "abc123");
  195. assert!(q
  196. .qr_image
  197. .as_deref()
  198. .unwrap_or("")
  199. .starts_with("data:image/png;base64,"));
  200. }
  201. #[test]
  202. fn parses_wechat_qr_response_empty_image() {
  203. // 测试模式后端返回 qrImage="":字段存在但为空
  204. let json = r#"{"code":200,"data":{"state":"abc123","qrImage":""}}"#;
  205. let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
  206. let q = r.data.unwrap();
  207. assert_eq!(q.state, "abc123");
  208. assert_eq!(q.qr_image.as_deref(), Some(""));
  209. }
  210. #[test]
  211. fn parses_wechat_poll_success_and_pending() {
  212. let ok: ApiResult<WechatPollResp> = serde_json::from_str(
  213. r#"{"code":200,"data":{"status":"success","token":"jwt","userId":7,"role":"parent","nickname":"牛"}}"#,
  214. )
  215. .unwrap();
  216. let p = ok.data.unwrap();
  217. assert_eq!(p.status, "success");
  218. assert_eq!(p.token.as_deref(), Some("jwt"));
  219. assert_eq!(p.user_id, Some(7));
  220. let pending: ApiResult<WechatPollResp> =
  221. serde_json::from_str(r#"{"code":200,"data":{"status":"pending"}}"#).unwrap();
  222. let p2 = pending.data.unwrap();
  223. assert_eq!(p2.status, "pending");
  224. assert!(p2.token.is_none());
  225. }
  226. }