From 29f57bf2cc3299a0d425176ba9177abb52a9b4b9 Mon Sep 17 00:00:00 2001 From: wenfp Date: Mon, 27 Jul 2026 16:40:25 +0800 Subject: [PATCH] update --- app/routers/ad_slots.py | 140 +++++++++++++++++++++ app/routers/ads.py | 182 +++++++++++++++++++++++++++ app/routers/feature_icons.py | 238 +++++++++++++++++++++++++++++++++++ 3 files changed, 560 insertions(+) create mode 100644 app/routers/ad_slots.py create mode 100644 app/routers/ads.py create mode 100644 app/routers/feature_icons.py diff --git a/app/routers/ad_slots.py b/app/routers/ad_slots.py new file mode 100644 index 0000000..e0c9801 --- /dev/null +++ b/app/routers/ad_slots.py @@ -0,0 +1,140 @@ +import sqlite3 +from typing import List + +from fastapi import APIRouter, HTTPException, status + +from app.database import Database +from app.schemas import AdSlot, AdSlotCreate, AdSlotUpdate +from app.temple_scope import TempleId, ensure_temple_exists + + +router = APIRouter(prefix="/ad-slots", tags=["ad-slots"]) + +AD_SLOT_COLUMNS = "id, temple_id, name, code, description, sort_order, is_active" + + +def row_to_ad_slot(row: sqlite3.Row) -> AdSlot: + return AdSlot( + id=row["id"], + temple_id=row["temple_id"], + name=row["name"], + code=row["code"], + description=row["description"], + sort_order=row["sort_order"], + is_active=bool(row["is_active"]), + ) + + +def raise_duplicate_code_error() -> None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="广告位编码已存在") + + +@router.get("", response_model=List[AdSlot]) +def list_ad_slots(db: Database, temple_id: TempleId) -> List[AdSlot]: + ensure_temple_exists(db, temple_id) + rows = db.execute( + f""" + SELECT {AD_SLOT_COLUMNS} + FROM ad_slots + WHERE temple_id = ? + ORDER BY sort_order, id + """, + (temple_id,), + ).fetchall() + return [row_to_ad_slot(row) for row in rows] + + +@router.post("", response_model=AdSlot, status_code=status.HTTP_201_CREATED) +def create_ad_slot(payload: AdSlotCreate, db: Database, temple_id: TempleId) -> AdSlot: + ensure_temple_exists(db, temple_id) + try: + cursor = db.execute( + """ + INSERT INTO ad_slots + (temple_id, name, code, description, title, image_url, sort_order, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + temple_id, + payload.name, + payload.code, + payload.description, + payload.name, + "", + payload.sort_order, + int(payload.is_active), + ), + ) + db.commit() + except sqlite3.IntegrityError: + raise_duplicate_code_error() + + return get_ad_slot(cursor.lastrowid, db, temple_id) + + +@router.get("/{ad_slot_id}", response_model=AdSlot) +def get_ad_slot(ad_slot_id: int, db: Database, temple_id: TempleId) -> AdSlot: + ensure_temple_exists(db, temple_id) + row = db.execute( + f""" + SELECT {AD_SLOT_COLUMNS} + FROM ad_slots + WHERE id = ? AND temple_id = ? + """, + (ad_slot_id, temple_id), + ).fetchone() + if row is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ad slot not found") + return row_to_ad_slot(row) + + +@router.patch("/{ad_slot_id}", response_model=AdSlot) +def update_ad_slot(ad_slot_id: int, payload: AdSlotUpdate, db: Database, temple_id: TempleId) -> AdSlot: + ad_slot = get_ad_slot(ad_slot_id, db, temple_id) + updates = payload.model_dump(exclude_unset=True) + if not updates: + return ad_slot + + name = updates.get("name", ad_slot.name) + try: + db.execute( + """ + UPDATE ad_slots + SET name = ?, code = ?, description = ?, title = ?, sort_order = ?, is_active = ? + WHERE id = ? AND temple_id = ? + """, + ( + name, + updates.get("code", ad_slot.code), + updates.get("description", ad_slot.description), + name, + updates.get("sort_order", ad_slot.sort_order), + int(updates.get("is_active", ad_slot.is_active)), + ad_slot_id, + temple_id, + ), + ) + db.commit() + except sqlite3.IntegrityError: + raise_duplicate_code_error() + + return get_ad_slot(ad_slot_id, db, temple_id) + + +@router.delete("/{ad_slot_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_ad_slot(ad_slot_id: int, db: Database, temple_id: TempleId) -> None: + ensure_temple_exists(db, temple_id) + used = db.execute( + "SELECT 1 FROM ads WHERE slot_id = ? AND temple_id = ? LIMIT 1", + (ad_slot_id, temple_id), + ).fetchone() + if used is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="广告位已绑定广告,不能删除") + + cursor = db.execute( + "DELETE FROM ad_slots WHERE id = ? AND temple_id = ?", + (ad_slot_id, temple_id), + ) + db.commit() + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ad slot not found") diff --git a/app/routers/ads.py b/app/routers/ads.py new file mode 100644 index 0000000..18dd1d5 --- /dev/null +++ b/app/routers/ads.py @@ -0,0 +1,182 @@ +import sqlite3 +from typing import List + +from fastapi import APIRouter, HTTPException, status + +from app.database import Database +from app.schemas import Ad, AdCreate, AdUpdate +from app.temple_scope import TempleId, ensure_temple_exists + + +router = APIRouter(prefix="/ads", tags=["ads"]) + +AD_COLUMNS = """ + ads.id, ads.temple_id, ads.slot_id, ad_slots.name AS slot_name, ad_slots.code AS slot_code, + ads.title, ads.image_url, ads.target_type, ads.target_url, ads.product_id, + products.name AS product_name, ads.sort_order, ads.is_active +""" + + +def row_to_ad(row: sqlite3.Row) -> Ad: + return Ad( + id=row["id"], + temple_id=row["temple_id"], + slot_id=row["slot_id"], + slot_name=row["slot_name"], + slot_code=row["slot_code"], + title=row["title"], + image_url=row["image_url"], + target_type=row["target_type"], + target_url=row["target_url"], + product_id=row["product_id"], + product_name=row["product_name"], + sort_order=row["sort_order"], + is_active=bool(row["is_active"]), + ) + + +def ensure_slot_in_temple(db: Database, slot_id: int, temple_id: int) -> None: + row = db.execute( + "SELECT id FROM ad_slots WHERE id = ? AND temple_id = ?", + (slot_id, temple_id), + ).fetchone() + if row is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择当前寺院下的广告位") + + +def ensure_product_in_temple(db: Database, product_id: int | None, temple_id: int) -> None: + if product_id is None: + return + row = db.execute( + "SELECT id FROM products WHERE id = ? AND temple_id = ?", + (product_id, temple_id), + ).fetchone() + if row is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择当前寺院下的商品") + + +def normalize_ad_payload( + payload: AdCreate | AdUpdate, + temple_id: int, + db: Database, + fallback: Ad | None = None, +) -> tuple[int, str, str, str, str | None, int | None, int, int]: + data = payload.model_dump(exclude_unset=fallback is not None) + + def get_value(name: str, default): + if name in data: + return data[name] + if fallback is not None: + return getattr(fallback, name) + return default + + slot_id = get_value("slot_id", None) + if slot_id is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择广告位") + ensure_slot_in_temple(db, slot_id, temple_id) + + target_type = get_value("target_type", "link") + target_url = get_value("target_url", None) + product_id = get_value("product_id", None) + + if target_type == "link": + product_id = None + if not target_url: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="链接类型需要填写跳转地址") + else: + target_url = None + ensure_product_in_temple(db, product_id, temple_id) + if product_id is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="应用类型需要选择商品") + + return ( + slot_id, + get_value("title", ""), + get_value("image_url", ""), + target_type, + target_url, + product_id, + get_value("sort_order", 0), + int(get_value("is_active", True)), + ) + + +@router.get("", response_model=List[Ad]) +def list_ads(db: Database, temple_id: TempleId) -> List[Ad]: + ensure_temple_exists(db, temple_id) + rows = db.execute( + f""" + SELECT {AD_COLUMNS} + FROM ads + JOIN ad_slots ON ad_slots.id = ads.slot_id + LEFT JOIN products ON products.id = ads.product_id + WHERE ads.temple_id = ? + ORDER BY ad_slots.sort_order, ads.sort_order, ads.id + """, + (temple_id,), + ).fetchall() + return [row_to_ad(row) for row in rows] + + +@router.post("", response_model=Ad, status_code=status.HTTP_201_CREATED) +def create_ad(payload: AdCreate, db: Database, temple_id: TempleId) -> Ad: + ensure_temple_exists(db, temple_id) + cursor = db.execute( + """ + INSERT INTO ads + (temple_id, slot_id, title, image_url, target_type, target_url, product_id, sort_order, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (temple_id, *normalize_ad_payload(payload, temple_id, db)), + ) + db.commit() + return get_ad(cursor.lastrowid, db, temple_id) + + +@router.get("/{ad_id}", response_model=Ad) +def get_ad(ad_id: int, db: Database, temple_id: TempleId) -> Ad: + ensure_temple_exists(db, temple_id) + row = db.execute( + f""" + SELECT {AD_COLUMNS} + FROM ads + JOIN ad_slots ON ad_slots.id = ads.slot_id + LEFT JOIN products ON products.id = ads.product_id + WHERE ads.id = ? AND ads.temple_id = ? + """, + (ad_id, temple_id), + ).fetchone() + if row is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ad not found") + return row_to_ad(row) + + +@router.patch("/{ad_id}", response_model=Ad) +def update_ad(ad_id: int, payload: AdUpdate, db: Database, temple_id: TempleId) -> Ad: + ad = get_ad(ad_id, db, temple_id) + if not payload.model_dump(exclude_unset=True): + return ad + + db.execute( + """ + UPDATE ads + SET slot_id = ?, title = ?, image_url = ?, target_type = ?, target_url = ?, + product_id = ?, sort_order = ?, is_active = ? + WHERE id = ? AND temple_id = ? + """, + (*normalize_ad_payload(payload, temple_id, db, ad), ad_id, temple_id), + ) + db.commit() + return get_ad(ad_id, db, temple_id) + + +@router.delete("/{ad_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_ad(ad_id: int, db: Database, temple_id: TempleId) -> None: + ensure_temple_exists(db, temple_id) + cursor = db.execute( + "DELETE FROM ads WHERE id = ? AND temple_id = ?", + (ad_id, temple_id), + ) + db.commit() + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ad not found") diff --git a/app/routers/feature_icons.py b/app/routers/feature_icons.py new file mode 100644 index 0000000..7f786e5 --- /dev/null +++ b/app/routers/feature_icons.py @@ -0,0 +1,238 @@ +import sqlite3 +from typing import List + +from fastapi import APIRouter, HTTPException, status + +from app.database import Database +from app.schemas import FeatureIcon, FeatureIconCopyRequest, FeatureIconCreate, FeatureIconUpdate +from app.temple_scope import TempleId, ensure_temple_exists + + +router = APIRouter(prefix="/feature-icons", tags=["feature-icons"]) + +ICON_COLUMNS = """ + feature_icons.id, feature_icons.temple_id, feature_icons.title, feature_icons.icon_url, + feature_icons.target_type, feature_icons.target_url, feature_icons.product_id, + products.name AS product_name, feature_icons.sort_order, feature_icons.is_active +""" + + +def row_to_icon(row: sqlite3.Row) -> FeatureIcon: + return FeatureIcon( + id=row["id"], + temple_id=row["temple_id"], + title=row["title"], + icon_url=row["icon_url"], + target_type=row["target_type"], + target_url=row["target_url"], + product_id=row["product_id"], + product_name=row["product_name"], + sort_order=row["sort_order"], + is_active=bool(row["is_active"]), + ) + + +def ensure_product_in_temple(db: Database, product_id: int | None, temple_id: int) -> None: + if product_id is None: + return + row = db.execute( + "SELECT id FROM products WHERE id = ? AND temple_id = ?", + (product_id, temple_id), + ).fetchone() + if row is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择当前寺院下的商品") + + +def normalize_icon_payload( + payload: FeatureIconCreate | FeatureIconUpdate, + temple_id: int, + db: Database, + fallback: FeatureIcon | None = None, +) -> tuple[str, str, str, str | None, int | None, int, int]: + data = payload.model_dump(exclude_unset=fallback is not None) + + def get_value(name: str, default): + if name in data: + return data[name] + if fallback is not None: + return getattr(fallback, name) + return default + + target_type = get_value("target_type", "link") + target_url = get_value("target_url", None) + product_id = get_value("product_id", None) + + if target_type == "link": + product_id = None + if not target_url: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="链接类型需要填写跳转地址") + else: + target_url = None + ensure_product_in_temple(db, product_id, temple_id) + if product_id is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="应用类型需要选择商品") + + return ( + get_value("title", ""), + get_value("icon_url", ""), + target_type, + target_url, + product_id, + get_value("sort_order", 0), + int(get_value("is_active", True)), + ) + + +@router.get("", response_model=List[FeatureIcon]) +def list_icons(db: Database, temple_id: TempleId) -> List[FeatureIcon]: + ensure_temple_exists(db, temple_id) + rows = db.execute( + f""" + SELECT {ICON_COLUMNS} + FROM feature_icons + LEFT JOIN products ON products.id = feature_icons.product_id + WHERE feature_icons.temple_id = ? + ORDER BY feature_icons.sort_order, feature_icons.id + """, + (temple_id,), + ).fetchall() + return [row_to_icon(row) for row in rows] + + +@router.get("/source/{source_temple_id}", response_model=List[FeatureIcon]) +def list_source_icons(source_temple_id: int, db: Database) -> List[FeatureIcon]: + ensure_temple_exists(db, source_temple_id) + rows = db.execute( + f""" + SELECT {ICON_COLUMNS} + FROM feature_icons + LEFT JOIN products ON products.id = feature_icons.product_id + WHERE feature_icons.temple_id = ? + ORDER BY feature_icons.sort_order, feature_icons.id + """, + (source_temple_id,), + ).fetchall() + return [row_to_icon(row) for row in rows] + + +@router.post("", response_model=FeatureIcon, status_code=status.HTTP_201_CREATED) +def create_icon(payload: FeatureIconCreate, db: Database, temple_id: TempleId) -> FeatureIcon: + ensure_temple_exists(db, temple_id) + cursor = db.execute( + """ + INSERT INTO feature_icons + (temple_id, title, icon_url, target_type, target_url, product_id, sort_order, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (temple_id, *normalize_icon_payload(payload, temple_id, db)), + ) + db.commit() + return get_icon(cursor.lastrowid, db, temple_id) + + +@router.post("/copy", response_model=List[FeatureIcon], status_code=status.HTTP_201_CREATED) +def copy_icons(payload: FeatureIconCopyRequest, db: Database, temple_id: TempleId) -> List[FeatureIcon]: + ensure_temple_exists(db, temple_id) + ensure_temple_exists(db, payload.source_temple_id) + if payload.source_temple_id == temple_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择其他寺院的图标") + + placeholders = ",".join("?" for _ in payload.icon_ids) + rows = db.execute( + f""" + SELECT id, title, icon_url, target_type, target_url, product_id, sort_order, is_active + FROM feature_icons + WHERE temple_id = ? AND id IN ({placeholders}) + ORDER BY sort_order, id + """, + (payload.source_temple_id, *payload.icon_ids), + ).fetchall() + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到可复制的图标") + + new_ids: list[int] = [] + for row in rows: + product_id = row["product_id"] + is_active = row["is_active"] + if row["target_type"] == "app": + product_id = None + is_active = 0 + cursor = db.execute( + """ + INSERT INTO feature_icons + (temple_id, title, icon_url, target_type, target_url, product_id, sort_order, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + temple_id, + row["title"], + row["icon_url"], + row["target_type"], + row["target_url"], + product_id, + row["sort_order"], + is_active, + ), + ) + new_ids.append(cursor.lastrowid) + + db.commit() + placeholders = ",".join("?" for _ in new_ids) + copied_rows = db.execute( + f""" + SELECT {ICON_COLUMNS} + FROM feature_icons + LEFT JOIN products ON products.id = feature_icons.product_id + WHERE feature_icons.temple_id = ? AND feature_icons.id IN ({placeholders}) + ORDER BY feature_icons.sort_order, feature_icons.id + """, + (temple_id, *new_ids), + ).fetchall() + return [row_to_icon(row) for row in copied_rows] + + +@router.get("/{icon_id}", response_model=FeatureIcon) +def get_icon(icon_id: int, db: Database, temple_id: TempleId) -> FeatureIcon: + ensure_temple_exists(db, temple_id) + row = db.execute( + f""" + SELECT {ICON_COLUMNS} + FROM feature_icons + LEFT JOIN products ON products.id = feature_icons.product_id + WHERE feature_icons.id = ? AND feature_icons.temple_id = ? + """, + (icon_id, temple_id), + ).fetchone() + if row is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Icon not found") + return row_to_icon(row) + + +@router.patch("/{icon_id}", response_model=FeatureIcon) +def update_icon(icon_id: int, payload: FeatureIconUpdate, db: Database, temple_id: TempleId) -> FeatureIcon: + icon = get_icon(icon_id, db, temple_id) + if not payload.model_dump(exclude_unset=True): + return icon + + db.execute( + """ + UPDATE feature_icons + SET title = ?, icon_url = ?, target_type = ?, target_url = ?, product_id = ?, sort_order = ?, is_active = ? + WHERE id = ? AND temple_id = ? + """, + (*normalize_icon_payload(payload, temple_id, db, icon), icon_id, temple_id), + ) + db.commit() + return get_icon(icon_id, db, temple_id) + + +@router.delete("/{icon_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_icon(icon_id: int, db: Database, temple_id: TempleId) -> None: + ensure_temple_exists(db, temple_id) + cursor = db.execute( + "DELETE FROM feature_icons WHERE id = ? AND temple_id = ?", + (icon_id, temple_id), + ) + db.commit() + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Icon not found")