This commit is contained in:
Ember Moth
2026-07-05 20:27:58 +08:00
commit 2744c70c5c
837 changed files with 53059 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "result"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
axum = "0.8"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
+120
View File
@@ -0,0 +1,120 @@
// Code-carrying error type.
//
// Ported from the Go package `xerr` (errors.go). A `CodeError` carries a
// numeric error code (shown to the front end) and a human-readable message,
// and can travel through an error chain so handlers can recover the code via
// [`find_code_error`].
use std::fmt;
use std::sync::LazyLock;
use crate::error_code::{map_err_msg, ERROR};
/// An error that carries a machine-readable code and a message.
#[derive(Debug, Clone)]
pub struct CodeError {
err_code: u32,
err_msg: String,
}
impl CodeError {
/// Creates a `CodeError` whose message is looked up from [`map_err_msg`].
///
/// Mirrors Go `xerr.NewErrCode`.
pub fn new_err_code(err_code: u32) -> Self {
Self {
err_code,
err_msg: map_err_msg(err_code).to_string(),
}
}
/// Creates a `CodeError` with an explicit code and message.
///
/// Mirrors Go `xerr.NewErrCodeMsg`.
pub fn new_err_code_msg(err_code: u32, err_msg: impl Into<String>) -> Self {
Self {
err_code,
err_msg: err_msg.into(),
}
}
/// Creates a `CodeError` for an unspecified failure (`ERROR` code).
///
/// Mirrors Go `xerr.NewErrMsg`.
pub fn new_err_msg(err_msg: impl Into<String>) -> Self {
Self {
err_code: ERROR,
err_msg: err_msg.into(),
}
}
/// Returns the error code shown to the front end.
pub fn get_err_code(&self) -> u32 {
self.err_code
}
/// Returns the error message shown to the front end.
pub fn get_err_msg(&self) -> &str {
&self.err_msg
}
}
impl fmt::Display for CodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Preserve the original Go format (note the full-width comma).
write!(f, "ErrCode:{}ErrMsg:{}", self.err_code, self.err_msg)
}
}
impl std::error::Error for CodeError {}
/// Sentinel error for "304 Not Modified".
///
/// Mirrors Go `xerr.StatusNotModified`.
pub static STATUS_NOT_MODIFIED: LazyLock<SimpleError> =
LazyLock::new(|| SimpleError("304 Not Modified".to_string()));
/// A plain string-backed error, used for sentinel values such as
/// [`STATUS_NOT_MODIFIED`].
#[derive(Debug, Clone)]
pub struct SimpleError(pub String);
impl fmt::Display for SimpleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for SimpleError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_err_code_maps_message() {
let e = CodeError::new_err_code(crate::error_code::INVALID_PARAMS);
assert_eq!(e.get_err_code(), 400);
assert_eq!(e.get_err_msg(), "Param Error");
}
#[test]
fn new_err_code_msg_is_explicit() {
let e = CodeError::new_err_code_msg(123, "custom");
assert_eq!(e.get_err_code(), 123);
assert_eq!(e.get_err_msg(), "custom");
}
#[test]
fn new_err_msg_uses_error_code() {
let e = CodeError::new_err_msg("boom");
assert_eq!(e.get_err_code(), ERROR);
assert_eq!(e.get_err_msg(), "boom");
}
#[test]
fn display_preserves_go_format() {
let e = CodeError::new_err_code_msg(400, "Param Error");
assert_eq!(e.to_string(), "ErrCode:400ErrMsg:Param Error");
}
}
+230
View File
@@ -0,0 +1,230 @@
// Error codes and their human-readable messages.
//
// Ported from the Go package `xerr` (errCode.go + errMsg.go). The first three
// digits identify the business area; the last three identify the specific
// error within that area.
use std::collections::HashMap;
use std::sync::LazyLock;
/// General error codes.
pub const SUCCESS: u32 = 200;
pub const ERROR: u32 = 500;
/// Database errors.
pub const DATABASE_QUERY_ERROR: u32 = 10001;
pub const DATABASE_UPDATE_ERROR: u32 = 10002;
pub const DATABASE_INSERT_ERROR: u32 = 10003;
pub const DATABASE_DELETED_ERROR: u32 = 10004;
/// User errors.
pub const USER_EXIST: u32 = 20001;
pub const USER_NOT_EXIST: u32 = 20002;
pub const USER_PASSWORD_ERROR: u32 = 20003;
pub const USER_DISABLED: u32 = 20004;
pub const INSUFFICIENT_BALANCE: u32 = 20005;
pub const STOP_REGISTER: u32 = 20006;
pub const TELEGRAM_NOT_BOUND: u32 = 20007;
pub const USER_NOT_BIND_OAUTH: u32 = 20008;
pub const INVITE_CODE_ERROR: u32 = 20009;
pub const USER_COMMISSION_NOT_ENOUGH: u32 = 20010;
/// Node errors.
pub const NODE_EXIST: u32 = 30001;
pub const NODE_NOT_EXIST: u32 = 30002;
pub const NODE_GROUP_EXIST: u32 = 30003;
pub const NODE_GROUP_NOT_EXIST: u32 = 30004;
pub const NODE_GROUP_NOT_EMPTY: u32 = 30005;
/// Request errors.
pub const INVALID_PARAMS: u32 = 400;
pub const TOO_MANY_REQUESTS: u32 = 401;
pub const ERROR_TOKEN_EMPTY: u32 = 40002;
pub const ERROR_TOKEN_INVALID: u32 = 40003;
pub const ERROR_TOKEN_EXPIRE: u32 = 40004;
pub const INVALID_ACCESS: u32 = 40005;
pub const INVALID_CIPHERTEXT: u32 = 40006;
pub const SECRET_IS_EMPTY: u32 = 40007;
/// Coupon errors.
pub const COUPON_NOT_EXIST: u32 = 50001;
pub const COUPON_ALREADY_USED: u32 = 50002;
pub const COUPON_NOT_APPLICABLE: u32 = 50003;
pub const COUPON_INSUFFICIENT_USAGE: u32 = 50004;
pub const COUPON_EXPIRED: u32 = 50005;
pub const COUPON_DISABLED: u32 = 50006;
/// Subscribe errors.
pub const SUBSCRIBE_EXPIRED: u32 = 60001;
pub const SUBSCRIBE_NOT_AVAILABLE: u32 = 60002;
pub const USER_SUBSCRIBE_EXIST: u32 = 60003;
pub const SUBSCRIBE_IS_USED_ERROR: u32 = 60004;
pub const SINGLE_SUBSCRIBE_MODE_EXCEEDS_LIMIT: u32 = 60005;
pub const SUBSCRIBE_QUOTA_LIMIT: u32 = 60006;
pub const SUBSCRIBE_OUT_OF_STOCK: u32 = 60007;
/// Order errors.
pub const ORDER_NOT_EXIST: u32 = 61001;
pub const PAYMENT_METHOD_NOT_FOUND: u32 = 61002;
pub const ORDER_STATUS_ERROR: u32 = 61003;
pub const INSUFFICIENT_OF_PERIOD: u32 = 61004;
pub const EXIST_AVAILABLE_TRAFFIC: u32 = 61005;
/// Auth errors.
pub const VERIFY_CODE_ERROR: u32 = 70001;
/// Equipment errors.
pub const QUEUE_ENQUEUE_ERROR: u32 = 80001;
/// System errors.
pub const DEBUG_MODE_ERROR: u32 = 90001;
pub const SEND_SMS_ERROR: u32 = 90002;
pub const SMS_NOT_ENABLED: u32 = 90003;
pub const EMAIL_NOT_ENABLED: u32 = 90004;
pub const GET_AUTHENTICATOR_ERROR: u32 = 90005;
pub const AUTHENTICATOR_NOT_SUPPORTED_ERROR: u32 = 90006;
pub const TELEPHONE_AREA_CODE_IS_EMPTY: u32 = 90007;
pub const TODAY_SEND_COUNT_EXCEEDS_LIMIT: u32 = 90015;
pub const PASSWORD_IS_EMPTY: u32 = 90008;
pub const AREA_CODE_IS_EMPTY: u32 = 90009;
pub const PASSWORD_OR_VERIFICATION_CODE_REQUIRED: u32 = 90010;
pub const EMAIL_EXIST: u32 = 90011;
pub const TELEPHONE_EXIST: u32 = 90012;
pub const DEVICE_EXIST: u32 = 90013;
pub const TELEPHONE_ERROR: u32 = 90014;
pub const DEVICE_NOT_EXIST: u32 = 90017;
pub const USERID_NOT_MATCH: u32 = 90018;
/// Mapping of error code -> default message.
static MESSAGES: LazyLock<HashMap<u32, &'static str>> = LazyLock::new(|| {
let mut m = HashMap::new();
// General
m.insert(SUCCESS, "Success");
m.insert(ERROR, "Internal Server Error");
// Request / parameter
m.insert(TOO_MANY_REQUESTS, "Too Many Requests");
m.insert(INVALID_PARAMS, "Param Error");
m.insert(ERROR_TOKEN_EMPTY, "User token is empty");
m.insert(ERROR_TOKEN_INVALID, "User token is invalid");
m.insert(ERROR_TOKEN_EXPIRE, "User token is expired");
m.insert(SECRET_IS_EMPTY, "Secret is empty");
m.insert(INVALID_ACCESS, "Invalid access");
m.insert(INVALID_CIPHERTEXT, "Invalid ciphertext");
// Database
m.insert(DATABASE_QUERY_ERROR, "Database query error");
m.insert(DATABASE_UPDATE_ERROR, "Database update error");
m.insert(DATABASE_INSERT_ERROR, "Database insert error");
m.insert(DATABASE_DELETED_ERROR, "Database deleted error");
// User
m.insert(USER_EXIST, "User already exists");
m.insert(USER_NOT_EXIST, "User does not exist");
m.insert(USER_PASSWORD_ERROR, "User password error");
m.insert(USER_DISABLED, "User disabled");
m.insert(INSUFFICIENT_BALANCE, "Insufficient balance");
m.insert(STOP_REGISTER, "Stop register");
m.insert(TELEGRAM_NOT_BOUND, "Telegram not bound ");
m.insert(USER_NOT_BIND_OAUTH, "User not bind oauth method");
m.insert(INVITE_CODE_ERROR, "Invite code error");
// Node
m.insert(NODE_EXIST, "Node already exists");
m.insert(NODE_NOT_EXIST, "Node does not exist");
m.insert(NODE_GROUP_EXIST, "Node group already exists");
m.insert(NODE_GROUP_NOT_EXIST, "Node group does not exist");
m.insert(NODE_GROUP_NOT_EMPTY, "Node group is not empty");
// Coupon
m.insert(COUPON_NOT_EXIST, "Coupon does not exist");
m.insert(COUPON_ALREADY_USED, "Coupon has already been used");
m.insert(COUPON_NOT_APPLICABLE, "Coupon does not match the order or conditions");
m.insert(COUPON_INSUFFICIENT_USAGE, "Coupon has insufficient remaining uses");
m.insert(COUPON_EXPIRED, "Coupon is expired");
m.insert(COUPON_DISABLED, "Coupon is disabled");
// Subscribe
m.insert(SUBSCRIBE_EXPIRED, "Subscribe is expired");
m.insert(SUBSCRIBE_NOT_AVAILABLE, "Subscribe is not available");
m.insert(USER_SUBSCRIBE_EXIST, "User has subscription");
m.insert(SUBSCRIBE_IS_USED_ERROR, "Subscribe is used");
m.insert(
SINGLE_SUBSCRIBE_MODE_EXCEEDS_LIMIT,
"Single subscribe mode exceeds limit",
);
m.insert(SUBSCRIBE_QUOTA_LIMIT, "Subscribe quota limit");
m.insert(SUBSCRIBE_OUT_OF_STOCK, "Subscribe out of stock");
// Auth
m.insert(VERIFY_CODE_ERROR, "Verify code error");
// Equipment
m.insert(QUEUE_ENQUEUE_ERROR, " Queue enqueue error");
// System
m.insert(DEBUG_MODE_ERROR, "Debug mode is enabled");
m.insert(GET_AUTHENTICATOR_ERROR, "Unsupported login method");
m.insert(
AUTHENTICATOR_NOT_SUPPORTED_ERROR,
"The authenticator does not support this method",
);
m.insert(TELEPHONE_AREA_CODE_IS_EMPTY, "Telephone area code is empty");
m.insert(
TODAY_SEND_COUNT_EXCEEDS_LIMIT,
"This account has reached the limit of sending times today",
);
m.insert(SMS_NOT_ENABLED, "Telephone login is not enabled");
m.insert(EMAIL_NOT_ENABLED, "Email function is not enabled yet");
m.insert(
PASSWORD_OR_VERIFICATION_CODE_REQUIRED,
"Password or verification code required",
);
m.insert(EMAIL_EXIST, "Email already exists");
m.insert(TELEPHONE_EXIST, "Telephone already exists");
m.insert(DEVICE_EXIST, "device exists");
m.insert(PASSWORD_IS_EMPTY, "password is empty");
m.insert(TELEPHONE_ERROR, "telephone number error");
m.insert(DEVICE_NOT_EXIST, "Device does not exist");
m.insert(USERID_NOT_MATCH, "Userid not match");
// Order
m.insert(ORDER_NOT_EXIST, "Order does not exist");
m.insert(PAYMENT_METHOD_NOT_FOUND, "Payment method not found");
m.insert(ORDER_STATUS_ERROR, "Order status error");
m.insert(INSUFFICIENT_OF_PERIOD, "Insufficient number of period");
m
});
/// Returns the default message for an error code.
///
/// Mirrors Go `xerr.MapErrMsg`: falls back to `"Internal Server Error"` when the
/// code is unknown.
pub fn map_err_msg(err_code: u32) -> &'static str {
MESSAGES
.get(&err_code)
.copied()
.unwrap_or("Internal Server Error")
}
/// Returns `true` when the code is a known, registered error code.
///
/// Mirrors Go `xerr.IsCodeErr`.
pub fn is_code_err(err_code: u32) -> bool {
MESSAGES.contains_key(&err_code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_known_codes() {
assert_eq!(map_err_msg(SUCCESS), "Success");
assert_eq!(map_err_msg(ERROR), "Internal Server Error");
assert_eq!(map_err_msg(INVALID_PARAMS), "Param Error");
assert_eq!(map_err_msg(ORDER_NOT_EXIST), "Order does not exist");
}
#[test]
fn unknown_code_falls_back() {
assert_eq!(map_err_msg(999_999), "Internal Server Error");
}
#[test]
fn is_code_err_recognizes_registered_codes() {
assert!(is_code_err(SUCCESS));
assert!(is_code_err(INVALID_PARAMS));
assert!(!is_code_err(999_999));
}
}
+236
View File
@@ -0,0 +1,236 @@
// Response envelopes and HTTP result construction.
//
// Ported from the Go package `result` (responseBean.go + httpResult.go). The Go
// package wraps responses in `ResponseSuccessBean` / `ResponseErrorBean` and
// writes them through a Hertz `*Context`; in axum the equivalent is producing a
// type that implements `IntoResponse`, so those beans are exposed directly.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use std::error::Error as StdError;
use crate::code_error::CodeError;
use crate::error_code;
/// Envelope returned for a successful request.
///
/// Mirrors Go `result.ResponseSuccessBean`. `data` is omitted from the JSON
/// payload when `None` (the `omitempty`-style `skip_serializing_if`).
#[derive(Debug, Serialize)]
pub struct ResponseSuccessBean<T = serde_json::Value> {
pub code: u32,
pub msg: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
}
impl<T> ResponseSuccessBean<T> {
pub fn new(data: Option<T>) -> Self {
Self {
code: 200,
msg: "success".to_string(),
data,
}
}
}
/// Marker for an empty (null) data payload, kept for parity with the Go
/// `NullJson` type.
pub struct NullJson;
/// Envelope returned for a failed request.
///
/// Mirrors Go `result.ResponseErrorBean`.
#[derive(Debug, Serialize)]
pub struct ResponseErrorBean {
pub code: u32,
pub msg: String,
}
/// Builds a success envelope wrapping `data`.
///
/// Mirrors Go `result.Success`.
pub fn success<T>(data: T) -> ResponseSuccessBean<T> {
ResponseSuccessBean::new(Some(data))
}
/// Builds an error envelope for the given code and message.
///
/// Mirrors Go `result.Error`.
pub fn error(err_code: u32, err_msg: impl Into<String>) -> ResponseErrorBean {
ResponseErrorBean {
code: err_code,
msg: err_msg.into(),
}
}
/// A structured HTTP response, identical in intent to the Go `HTTPResult`:
/// an HTTP status code paired with the serialized body. Axum's `Response`
/// subsumes the original `(StatusCode, body)` pair.
#[derive(Debug)]
pub struct HttpResult {
pub status_code: StatusCode,
pub body: Response,
}
/// Constructs an `HttpResult` from a fallible response, normalizing the body
/// into a success or error envelope.
///
/// Mirrors Go `result.BuildHTTPResult`:
/// - On success: HTTP 200 with a `ResponseSuccessBean` body.
/// - On error: HTTP 200 with a `ResponseErrorBean` body, the code/message
/// recovered from any nested `CodeError`, defaulting to `ERROR` /
/// `"Internal Server Error"` for plain errors.
pub fn build_http_result<T>(resp: Option<T>, err: Option<anyhow::Error>) -> HttpResult
where
T: Serialize,
{
if let Some(err) = err {
let (code, msg) = recover_code_and_msg(&err);
return HttpResult {
status_code: StatusCode::OK,
body: error(code, msg).into_response_body(),
};
}
HttpResult {
status_code: StatusCode::OK,
body: Json(ResponseSuccessBean::new(resp)).into_response_body(),
}
}
/// Constructs an `HttpResult` for a parameter-validation failure.
///
/// Mirrors Go `result.BuildParamErrorResult`: HTTP 200 with a `ResponseErrorBean`
/// whose code is `INVALID_PARAMS` and whose message is the raw error text.
pub fn build_param_error_result(err: &dyn StdError) -> HttpResult {
HttpResult {
status_code: StatusCode::OK,
body: error(error_code::INVALID_PARAMS, err.to_string()).into_response_body(),
}
}
/// Emits an HTTP response built from a fallible result.
///
/// Mirrors Go `result.HttpResult(ctx, resp, err)` (which wrote the body via the
/// Hertz context). Here it simply renders the constructed `HttpResult`.
pub fn http_result<T>(resp: Option<T>, err: Option<anyhow::Error>) -> Response
where
T: Serialize,
{
build_http_result(resp, err).into_response()
}
/// Emits an HTTP response for a parameter-validation failure.
///
/// Mirrors Go `result.ParamErrorResult(ctx, err)`. The Go version also logged the
/// error onto the Hertz error chain (`ctx.Error`); in axum that side effect is
/// the caller's responsibility (e.g. a tracing call), so only the response is
/// produced here.
pub fn param_error_result(err: &dyn StdError) -> Response {
build_param_error_result(err).into_response()
}
impl IntoResponse for HttpResult {
fn into_response(self) -> Response {
self.body
}
}
// ---- helpers --------------------------------------------------------------
/// Re-implementation of Go's `errors.As(errors.Cause(err), &e)` chain walk: the
/// first `CodeError` found while unwrapping `anyhow::Error` wins.
fn recover_code_and_msg(err: &anyhow::Error) -> (u32, String) {
for cause in err.chain() {
if let Some(code_err) = cause.downcast_ref::<CodeError>() {
return (code_err.get_err_code(), code_err.get_err_msg().to_string());
}
}
(error_code::ERROR, "Internal Server Error".to_string())
}
/// Extension so the `(StatusCode, Json<T>)` rendering can be captured as the
/// inner `Response` body of an `HttpResult`.
trait IntoResponseBody {
fn into_response_body(self) -> Response;
}
impl<T: Serialize> IntoResponseBody for Json<T> {
fn into_response_body(self) -> Response {
(StatusCode::OK, self).into_response()
}
}
impl IntoResponseBody for ResponseErrorBean {
fn into_response_body(self) -> Response {
(StatusCode::OK, Json(self)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use crate::code_error::CodeError;
use crate::error_code;
#[tokio::test]
async fn build_http_result_success() {
let result = build_http_result(Some("ok"), None);
assert_eq!(result.status_code, StatusCode::OK);
let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["code"], 200);
assert_eq!(v["msg"], "success");
assert_eq!(v["data"], "ok");
}
#[tokio::test]
async fn build_http_result_code_error() {
let err = anyhow::Error::new(CodeError::new_err_code(error_code::INVALID_PARAMS));
let result = build_http_result::<()>(None, Some(err));
let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["code"], 400);
assert_eq!(v["msg"], "Param Error");
assert!(v.get("data").is_none());
}
#[tokio::test]
async fn build_http_result_generic_error() {
let err = anyhow::Error::msg("boom");
let result = build_http_result::<()>(None, Some(err));
let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["code"], 500);
assert_eq!(v["msg"], "Internal Server Error");
}
#[tokio::test]
async fn build_param_error_result_works() {
let err = anyhow::Error::msg("bad param");
let result = build_param_error_result(err.as_ref());
let bytes = to_bytes(result.body.into_body(), usize::MAX).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["code"], 400);
assert_eq!(v["msg"], "bad param");
}
#[test]
fn success_envelope_shape() {
let bean = success(serde_json::json!({"x": 1}));
assert_eq!(bean.code, 200);
assert_eq!(bean.msg, "success");
assert_eq!(bean.data.as_ref().unwrap()["x"], 1);
}
#[test]
fn error_envelope_shape() {
let bean = error(401, "Too Many Requests");
assert_eq!(bean.code, 401);
assert_eq!(bean.msg, "Too Many Requests");
}
}
+9
View File
@@ -0,0 +1,9 @@
// Response envelopes and HTTP result construction.
//
// Ported from the Go package `result` together with its `xerr` dependency:
// error codes live in [`error_code`], the code-carrying error in
// [`code_error`], and the response beans / `HttpResult` in [`http_result`].
pub mod code_error;
pub mod error_code;
pub mod http_result;