feat(admin,shop-admin): content/brand management + merchant shop profile (add-content-admin-ui)

This commit is contained in:
Chengdong Zhang
2026-09-23 14:27:00 +08:00
parent 9a749e2551
commit 93e5a05d48
22 changed files with 930 additions and 22 deletions
+11 -2
View File
@@ -13,11 +13,11 @@ use crate::error::ApiResult;
use crate::models::{Shop, ShopStatus};
use crate::state::AppState;
use super::service::{self, ProfileBody, ShopProfileView};
use super::service::{self, MerchantProfileBody, ProfileBody, ShopProfileView};
pub fn router() -> Router<AppState> {
Router::new()
.route("/shop/profile", get(my_shop))
.route("/shop/profile", get(my_shop).put(update_my_profile))
.route("/shops", get(list_shops))
.route("/shops/{slug}", get(get_shop))
.route("/admin/shops", get(admin_list_shops).post(create_shop))
@@ -30,6 +30,15 @@ async fn my_shop(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Jso
Ok(Json(service::get_by_id(&state, shop_id).await?))
}
async fn update_my_profile(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<MerchantProfileBody>,
) -> ApiResult<Json<ShopProfileView>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::set_my_profile(&state, shop_id, body).await?))
}
async fn list_shops(State(state): State<AppState>) -> ApiResult<Json<Vec<ShopProfileView>>> {
Ok(Json(service::list_active_profiles(&state).await?))
}
+70
View File
@@ -111,6 +111,19 @@ pub struct ProfileBody {
pub score_speed: Option<f64>,
}
/// Merchant self-service write: same fields as the admin body minus the
/// platform-owned scores; serde ignores any score values merchants send.
#[derive(Debug, Deserialize)]
pub struct MerchantProfileBody {
pub logo: Option<String>,
pub banner: Option<String>,
pub company: Option<String>,
pub region: Option<String>,
pub address: Option<Value>,
pub notice: Option<Value>,
pub after_sale: Option<Value>,
}
fn bilingual(label: &Value, field: &str) -> ApiResult<()> {
let ok = ["en", "zh"].iter().all(|code| {
label
@@ -189,3 +202,60 @@ pub async fn set_profile(
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))
}
/// Merchant upsert of their own shop profile. Scores are platform-owned, so
/// this statement never touches the score columns on insert or update.
pub async fn set_my_profile(
state: &AppState,
id: Uuid,
body: MerchantProfileBody,
) -> ApiResult<ShopProfileView> {
for (value, field) in [
(&body.address, "address"),
(&body.notice, "notice"),
(&body.after_sale, "after_sale"),
] {
if let Some(label) = value {
bilingual(label, field)?;
}
}
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shops WHERE id = $1)")
.bind(id)
.fetch_one(&state.db)
.await?;
if !exists {
return Err(ApiError::NotFound("shop".into()));
}
sqlx::query(
"INSERT INTO shop_profiles (shop_id, logo, banner, company, region, address, notice,
after_sale, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (shop_id) DO UPDATE SET
logo = EXCLUDED.logo,
banner = EXCLUDED.banner,
company = EXCLUDED.company,
region = EXCLUDED.region,
address = EXCLUDED.address,
notice = EXCLUDED.notice,
after_sale = EXCLUDED.after_sale,
updated_at = now()",
)
.bind(id)
.bind(&body.logo)
.bind(&body.banner)
.bind(&body.company)
.bind(&body.region)
.bind(&body.address)
.bind(&body.notice)
.bind(&body.after_sale)
.execute(&state.db)
.await?;
sqlx::query_as::<_, ShopProfileView>(&format!("{SELECT_PROFILE} WHERE s.id = $1"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::NotFound("shop".into()))
}
+113
View File
@@ -182,3 +182,116 @@ async fn profile_writes_require_a_platform_admin() {
);
}
}
async fn set_my_profile(
app: &common::TestApp,
token: &str,
body: serde_json::Value,
) -> reqwest::Response {
client()
.put(app.url("/api/shop/profile"))
.bearer_auth(token)
.json(&body)
.send()
.await
.unwrap()
}
#[tokio::test]
#[serial]
async fn merchant_profile_upsert_round_trips_to_the_public_read() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_id = create_shop(&app, &admin, "shop-self-profile").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let body = serde_json::json!({
"logo": "/mock/store-self.svg",
"company": "Self Co.",
"region": "California",
"address": {"en": "2 Mission Street", "zh": "米申街 2 号"},
"notice": {"en": "Self-service notice", "zh": "自助公告"},
"after_sale": {"en": "Self returns.", "zh": "自助退货。"}
});
let res = set_my_profile(&app, &owner, body).await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
assert_eq!(shop["company"], "Self Co.");
assert_eq!(shop["address"]["zh"], "米申街 2 号");
}
#[tokio::test]
#[serial]
async fn merchant_profile_refuses_incomplete_bilingual_without_writing() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_id = create_shop(&app, &admin, "shop-self-bilingual").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let good = serde_json::json!({
"company": "Merchant Kept Co.",
"notice": {"en": "Merchant notice", "zh": "商家公告"}
});
assert_eq!(set_my_profile(&app, &owner, good).await.status(), 200);
let bad = serde_json::json!({
"company": "Merchant Changed Co.",
"notice": {"en": "Only English"}
});
let res = set_my_profile(&app, &owner, bad).await;
assert_eq!(res.status(), 400, "a label missing zh must be refused");
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
assert_eq!(shop["company"], "Merchant Kept Co.");
}
#[tokio::test]
#[serial]
async fn merchant_profile_never_stores_scores() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_id = create_shop(&app, &admin, "shop-self-scores").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let admin_body = serde_json::json!({
"company": "Scored Co.",
"score_rating": 4.9,
"score_service": 4.7
});
assert_eq!(set_profile(&app, &admin, &shop_id, admin_body).await.status(), 200);
let merchant_body = serde_json::json!({
"company": "Scored Co. Renamed",
"score_rating": 1.0,
"score_service": 1.0
});
assert_eq!(set_my_profile(&app, &owner, merchant_body).await.status(), 200);
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
assert_eq!(shop["company"], "Scored Co. Renamed");
assert_eq!(shop["score_rating"], 4.9, "merchant writes must not touch scores");
assert_eq!(shop["score_service"], 4.7);
}
#[tokio::test]
#[serial]
async fn merchant_profile_requires_a_shop_and_scopes_to_it() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_a = create_shop(&app, &admin, "shop-self-scope-a").await;
let shop_b = create_shop(&app, &admin, "shop-self-scope-b").await;
let owner_a = make_shop_owner(&app, &admin, &shop_a).await;
let (customer, _) = register_customer(&app, "shop-self-cust").await;
let body = serde_json::json!({ "company": "Scoped Co." });
let res = set_my_profile(&app, &customer, body.clone()).await;
assert_eq!(res.status(), 403, "a user without a shop must be refused");
assert_eq!(set_my_profile(&app, &owner_a, body).await.status(), 200);
let shop_b_view = find(&list_shops(&app).await, &shop_b).unwrap().clone();
assert!(
shop_b_view["company"].is_null(),
"another shop's profile must stay untouched"
);
}