update
This commit is contained in:
@@ -10,6 +10,9 @@
|
||||
- `routers/todos.py`:Todo 管理接口。
|
||||
- `routers/temples.py`:寺院管理接口。
|
||||
- `routers/products.py`:商品管理接口,按寺院隔离。
|
||||
- `routers/feature_icons.py`:图标管理接口,按寺院隔离,支持跨寺院复制图标。
|
||||
- `routers/ad_slots.py`:广告位管理接口,按寺院隔离,只维护广告位置定义。
|
||||
- `routers/ads.py`:广告管理接口,按寺院隔离,广告内容绑定到广告位。
|
||||
- `routers/orders.py`:订单管理接口,按寺院隔离。
|
||||
- `routers/payments.py`:微信支付接口,按寺院隔离发起 JSAPI 预下单并处理支付通知。
|
||||
- `routers/deploy.py`:自动部署 webhook 接口,校验 Gitea 签名或部署 token 后触发前端构建脚本。
|
||||
@@ -68,6 +71,14 @@ total_spent 总消费
|
||||
名称、单价、库存、排序、是否超度
|
||||
```
|
||||
|
||||
## 图标与广告位模型
|
||||
|
||||
- 图标存储在 `feature_icons` 表,字段包含图标名称、图标地址、跳转类型、跳转链接、关联商品、排序号、是否启用,并按 `temple_id` 隔离。
|
||||
- 图标跳转类型为 `link` 或 `app`;`link` 必须填写跳转地址,`app` 必须选择当前寺院商品。
|
||||
- 图标支持从其他寺院复制到当前寺院;应用类型图标复制后不直接跨寺院关联来源商品,需要重新选择当前寺院商品。
|
||||
- 广告位存储在 `ad_slots` 表,表示首页广告、详情页广告、支付页广告等位置定义,字段包含名称、编码、说明、排序号、是否启用,并按 `temple_id` 隔离。
|
||||
- 广告内容存储在 `ads` 表,字段包含广告位、广告名称、广告图、跳转类型、跳转链接、关联商品、排序号、是否启用,并按 `temple_id` 隔离。
|
||||
|
||||
## 上传与 OSS 配置
|
||||
|
||||
图片上传接口为 `/uploads/images`,用于商品封面、商品图片等图片资源。OSS 配置预留在 `config.py`,可直接填写或通过同名环境变量覆盖:
|
||||
@@ -114,3 +125,5 @@ OSS_ENDPOINT、OSS_BUCKET_NAME、OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS
|
||||
- 2026-07-23:新增微信支付 JSAPI 预下单和支付通知处理,增加 `payment_transactions` 支付流水表、微信支付 API v3 签名/通知验签/回调解密工具和配置占位;`requirements.txt` 新增 `cryptography`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。
|
||||
- 2026-07-27:新增自动部署 webhook 模块,`/deploy/webhook` 支持 Gitea HMAC 签名校验、部署分支过滤、并发锁和构建脚本超时控制,`scripts/deploy_front.sh` 负责拉取代码并构建前端。验证:后端编译通过,webhook 密钥与 Gitea 签名校验测试通过。
|
||||
- 2026-07-27:自动部署 webhook 改为后台执行构建,接口在签名校验、分支检查和加锁后立即返回 `202 accepted`,后台任务将构建结果写入部署日志,避免 Gitea Delivery 因等待响应超时。验证:后端编译通过,webhook 后台任务触发测试通过。
|
||||
- 2026-07-27:新增图标管理和广告位管理后端模块,增加 `feature_icons`、`ad_slots` 表和 `/feature-icons`、`/ad-slots` 寺院隔离 CRUD 接口,图标支持从其他寺院复制。验证:后端编译通过。
|
||||
- 2026-07-27:广告模型拆分为广告位和广告,`ad_slots` 改为位置定义,新增 `ads` 表和 `/ads` CRUD 接口,广告内容必须绑定当前寺院广告位。验证:后端编译通过,广告位与广告接口冒烟测试通过。
|
||||
|
||||
@@ -150,6 +150,41 @@ def ensure_product_schema(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(f"ALTER TABLE products ADD COLUMN {column_name} {definition}")
|
||||
|
||||
|
||||
def ensure_ad_slot_schema(connection: sqlite3.Connection) -> None:
|
||||
columns = {
|
||||
row["name"] if isinstance(row, sqlite3.Row) else row[1]
|
||||
for row in connection.execute("PRAGMA table_info(ad_slots)").fetchall()
|
||||
}
|
||||
additions = {
|
||||
"name": "TEXT",
|
||||
"code": "TEXT",
|
||||
"description": "TEXT",
|
||||
"title": "TEXT",
|
||||
"image_url": "TEXT",
|
||||
"target_type": "TEXT NOT NULL DEFAULT 'link'",
|
||||
"target_url": "TEXT",
|
||||
"product_id": "INTEGER",
|
||||
"sort_order": "INTEGER NOT NULL DEFAULT 0",
|
||||
"is_active": "INTEGER NOT NULL DEFAULT 1",
|
||||
}
|
||||
for column_name, definition in additions.items():
|
||||
if column_name not in columns:
|
||||
connection.execute(f"ALTER TABLE ad_slots ADD COLUMN {column_name} {definition}")
|
||||
|
||||
connection.execute("UPDATE ad_slots SET name = title WHERE (name IS NULL OR name = '') AND title IS NOT NULL")
|
||||
connection.execute("UPDATE ad_slots SET name = '广告位' || id WHERE name IS NULL OR name = ''")
|
||||
connection.execute("UPDATE ad_slots SET code = 'slot_' || id WHERE code IS NULL OR code = ''")
|
||||
connection.execute("UPDATE ad_slots SET sort_order = 0 WHERE sort_order IS NULL")
|
||||
connection.execute("UPDATE ad_slots SET is_active = 1 WHERE is_active IS NULL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_slots_temple_code_unique
|
||||
ON ad_slots(temple_id, code)
|
||||
WHERE code IS NOT NULL AND code != ''
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -191,6 +226,63 @@ def init_db() -> None:
|
||||
"""
|
||||
)
|
||||
ensure_product_schema(connection)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS feature_icons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
temple_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
icon_url TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL DEFAULT 'link',
|
||||
target_url TEXT,
|
||||
product_id INTEGER,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (temple_id) REFERENCES temples(id),
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ad_slots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
temple_id INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
code TEXT,
|
||||
description TEXT,
|
||||
title TEXT,
|
||||
image_url TEXT,
|
||||
target_type TEXT NOT NULL DEFAULT 'link',
|
||||
target_url TEXT,
|
||||
product_id INTEGER,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (temple_id) REFERENCES temples(id),
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
ensure_ad_slot_schema(connection)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
temple_id INTEGER NOT NULL,
|
||||
slot_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL DEFAULT 'link',
|
||||
target_url TEXT,
|
||||
product_id INTEGER,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (temple_id) REFERENCES temples(id),
|
||||
FOREIGN KEY (slot_id) REFERENCES ad_slots(id),
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ritual_services (
|
||||
|
||||
+4
-1
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import lifespan
|
||||
from app.routers import auth, deploy, orders, payments, products, rituals, temples, todos, uploads, users
|
||||
from app.routers import ad_slots, ads, auth, deploy, feature_icons, orders, payments, products, rituals, temples, todos, uploads, users
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -39,6 +39,9 @@ app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(temples.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(feature_icons.router)
|
||||
app.include_router(ad_slots.router)
|
||||
app.include_router(ads.router)
|
||||
app.include_router(orders.router)
|
||||
app.include_router(payments.router)
|
||||
app.include_router(rituals.router)
|
||||
|
||||
+146
@@ -212,6 +212,152 @@ class UploadResponse(BaseModel):
|
||||
object_key: str
|
||||
|
||||
|
||||
class FeatureIconBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=80)
|
||||
icon_url: str = Field(..., min_length=1, max_length=500)
|
||||
target_type: str = Field(default="link", pattern=r"^(link|app)$")
|
||||
target_url: str | None = Field(default=None, max_length=500)
|
||||
product_id: int | None = Field(default=None, ge=1)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator("target_url")
|
||||
@classmethod
|
||||
def trim_target_url(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class FeatureIconCreate(FeatureIconBase):
|
||||
pass
|
||||
|
||||
|
||||
class FeatureIconUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
icon_url: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
target_type: str | None = Field(default=None, pattern=r"^(link|app)$")
|
||||
target_url: str | None = Field(default=None, max_length=500)
|
||||
product_id: int | None = Field(default=None, ge=1)
|
||||
sort_order: int | None = Field(default=None, ge=0)
|
||||
is_active: bool | None = None
|
||||
|
||||
@field_validator("target_url")
|
||||
@classmethod
|
||||
def trim_target_url(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class FeatureIcon(BaseModel):
|
||||
id: int
|
||||
temple_id: int
|
||||
title: str
|
||||
icon_url: str
|
||||
target_type: str
|
||||
target_url: str | None = None
|
||||
product_id: int | None = None
|
||||
product_name: str | None = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FeatureIconCopyRequest(BaseModel):
|
||||
source_temple_id: int = Field(..., ge=1)
|
||||
icon_ids: list[int] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class AdSlotBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
code: str = Field(..., min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
description: str | None = Field(default=None, max_length=300)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AdSlotCreate(AdSlotBase):
|
||||
pass
|
||||
|
||||
|
||||
class AdSlotUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
code: str | None = Field(default=None, min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
description: str | None = Field(default=None, max_length=300)
|
||||
sort_order: int | None = Field(default=None, ge=0)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdSlot(BaseModel):
|
||||
id: int
|
||||
temple_id: int
|
||||
name: str
|
||||
code: str
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AdBase(BaseModel):
|
||||
slot_id: int = Field(..., ge=1)
|
||||
title: str = Field(..., min_length=1, max_length=100)
|
||||
image_url: str = Field(..., min_length=1, max_length=500)
|
||||
target_type: str = Field(default="link", pattern=r"^(link|app)$")
|
||||
target_url: str | None = Field(default=None, max_length=500)
|
||||
product_id: int | None = Field(default=None, ge=1)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator("target_url")
|
||||
@classmethod
|
||||
def trim_target_url(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class AdCreate(AdBase):
|
||||
pass
|
||||
|
||||
|
||||
class AdUpdate(BaseModel):
|
||||
slot_id: int | None = Field(default=None, ge=1)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
image_url: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
target_type: str | None = Field(default=None, pattern=r"^(link|app)$")
|
||||
target_url: str | None = Field(default=None, max_length=500)
|
||||
product_id: int | None = Field(default=None, ge=1)
|
||||
sort_order: int | None = Field(default=None, ge=0)
|
||||
is_active: bool | None = None
|
||||
|
||||
@field_validator("target_url")
|
||||
@classmethod
|
||||
def trim_target_url(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class Ad(BaseModel):
|
||||
id: int
|
||||
temple_id: int
|
||||
slot_id: int
|
||||
slot_name: str | None = None
|
||||
slot_code: str | None = None
|
||||
title: str
|
||||
image_url: str
|
||||
target_type: str
|
||||
target_url: str | None = None
|
||||
product_id: int | None = None
|
||||
product_name: str | None = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class RitualServiceCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=120)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
|
||||
Reference in New Issue
Block a user