Add isolated advertiser authentication, account hierarchy and project APIs
Docker test deployment / test-build-deploy (push) Successful in 4m48s

This commit is contained in:
2026-09-16 14:56:21 +08:00
parent b43043255c
commit b13e7acbf7
9 changed files with 664 additions and 5 deletions
+419
View File
@@ -0,0 +1,419 @@
"""Advertiser console accounts are separate from tracked visitor identities."""
import hashlib
import hmac
import secrets
import time
from typing import Annotated, Literal
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
from sqlalchemy import Column, Integer, String, Table, select
from adcloud_server.database import Database, document_type, metadata
from adcloud_server.dependencies import allowed_browser_origin
def private_response(response: Response):
response.headers["Cache-Control"] = "no-store"
router = APIRouter(
dependencies=[Depends(allowed_browser_origin), Depends(private_response)]
)
users = Table(
"advertiser_users",
metadata,
Column("id", String(36), primary_key=True),
Column("email", String(320), nullable=False, unique=True),
Column("password", String(256), nullable=False),
Column("name", String(100), nullable=False),
)
sessions = Table(
"advertiser_sessions",
metadata,
Column("digest", String(64), primary_key=True),
Column("user_id", String(36), nullable=False),
Column("expires", Integer, nullable=False),
)
accounts = Table(
"advertiser_accounts",
metadata,
Column("id", String(36), primary_key=True),
Column("owner_id", String(36), nullable=False),
Column("parent_id", String(36), nullable=True),
Column("document", document_type, nullable=False),
)
projects = Table(
"advertiser_projects",
metadata,
Column("id", String(36), primary_key=True),
Column("account_id", String(36), nullable=False),
Column("document", document_type, nullable=False),
)
attempts = Table(
"advertiser_auth_attempts",
metadata,
Column("key", String(64), primary_key=True),
Column("count", Integer, nullable=False),
Column("expires", Integer, nullable=False),
)
COOKIE = "adcloud_advertiser_session"
class Input(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class Login(Input):
email: EmailStr
password: str = Field(min_length=10, max_length=128)
class Register(Login):
name: str = Field(min_length=1, max_length=100)
company: str = Field(min_length=2, max_length=100)
class Account(Input):
name: str = Field(min_length=2, max_length=100)
company: str = Field(min_length=2, max_length=100)
industry: str = Field(default="其他", min_length=1, max_length=50)
class CreateAccount(Account):
parent_id: str | None = None
class Project(Input):
name: str = Field(min_length=2, max_length=100)
objective: Literal["销售线索", "应用推广", "电商", "商品", "原生互动"] = "销售线索"
ad_type: Literal["通投广告", "搜索广告"] = "通投广告"
scene: Literal["短视频与图文", "直播"] = "短视频与图文"
budget: int = Field(ge=100, le=10000000)
bid: float = Field(gt=0, le=100000, allow_inf_nan=False)
audience: Literal["智能推荐", "自定义"] = "智能推荐"
region: str = Field(default="全国", min_length=1, max_length=100)
start_date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
end_date: str = Field(default="", max_length=10)
product_name: str = Field(default="", max_length=100)
conversion_goal: Literal["获取线索", "完成购买", "应用安装", "内容互动"] = (
"获取线索"
)
tracking_url: str = Field(default="", max_length=2048)
landing_url: str = Field(default="", max_length=2048)
status: Literal["草稿", "已暂停"] = "草稿"
@model_validator(mode="after")
def valid_schedule(self):
from datetime import date
from urllib.parse import urlsplit
start = date.fromisoformat(self.start_date)
if self.end_date and date.fromisoformat(self.end_date) < start:
raise ValueError("结束日期不能早于开始日期")
for value in (self.landing_url, self.tracking_url):
if value:
url = urlsplit(value)
if url.scheme not in ("http", "https") or not url.netloc:
raise ValueError("链接须为完整的 HTTP/HTTPS 地址")
return self
def password_hash(password, salt):
return hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
).hex()
def throttle(db, request, email):
# Database-backed limits work across workers; never trust forwarded IP headers.
key = hashlib.sha256(
f"{request.client.host if request.client else 'unknown'}:{email}".encode()
).hexdigest()
now = int(time.time())
blocked = False
with db.transaction() as conn:
conn.execute(attempts.delete().where(attempts.c.expires < now))
row = (
conn.execute(select(attempts).where(attempts.c.key == key))
.mappings()
.first()
)
if row and row["count"] >= 10:
blocked = True
elif row:
conn.execute(
attempts.update()
.where(attempts.c.key == key)
.values(count=row["count"] + 1)
)
else:
conn.execute(attempts.insert().values(key=key, count=1, expires=now + 600))
if blocked:
raise HTTPException(429, "尝试次数过多,请十分钟后重试")
def advertiser_database(request: Request):
return request.app.state.advertiser_database
DatabaseDependency = Annotated[Database, Depends(advertiser_database)]
def current_user(request: Request, db: DatabaseDependency):
digest = hashlib.sha256(request.cookies.get(COOKIE, "").encode()).hexdigest()
with db.transaction() as conn:
row = (
conn.execute(
select(users.c.id, users.c.name, users.c.email)
.join(sessions, sessions.c.user_id == users.c.id)
.where(
sessions.c.digest == digest, sessions.c.expires > int(time.time())
)
)
.mappings()
.first()
)
if not row:
raise HTTPException(401, "请登录广告主账户")
return dict(row)
CurrentUser = Annotated[dict, Depends(current_user)]
def session(conn, user_id, response, request):
token = secrets.token_urlsafe(32)
conn.execute(sessions.delete().where(sessions.c.expires < int(time.time())))
conn.execute(
sessions.insert().values(
digest=hashlib.sha256(token.encode()).hexdigest(),
user_id=user_id,
expires=int(time.time()) + 86400,
)
)
response.set_cookie(
COOKIE,
token,
httponly=True,
secure=request.app.state.settings.cookie_secure,
samesite="lax",
max_age=86400,
path="/v1/advertiser",
)
response.headers["Cache-Control"] = "no-store"
def owned(conn, account_id, user):
row = (
conn.execute(
select(accounts).where(
accounts.c.id == account_id, accounts.c.owner_id == user["id"]
)
)
.mappings()
.first()
)
if not row:
raise HTTPException(404, "广告账户不存在")
return row
@router.post("/register", status_code=201)
def register(
body: Register, request: Request, response: Response, db: DatabaseDependency
):
email = str(body.email).lower()
throttle(db, request, email)
salt = secrets.token_hex(16)
hashed = salt + ":" + password_hash(body.password, salt)
user_id = str(uuid4())
with db.transaction() as conn:
if conn.execute(select(users.c.id).where(users.c.email == email)).first():
raise HTTPException(409, "该邮箱已注册")
conn.execute(
users.insert().values(
id=user_id, email=email, name=body.name, password=hashed
)
)
parent_id = str(uuid4())
conn.execute(
accounts.insert().values(
id=parent_id,
owner_id=user_id,
document={
"name": body.company,
"company": body.company,
"industry": "其他",
},
)
)
conn.execute(
accounts.insert().values(
id=str(uuid4()),
owner_id=user_id,
parent_id=parent_id,
document={
"name": body.company + " · 默认投放账户",
"company": body.company,
"industry": "其他",
},
)
)
session(conn, user_id, response, request)
return {"id": user_id, "name": body.name, "email": email}
@router.post("/login")
def login(body: Login, request: Request, response: Response, db: DatabaseDependency):
email = str(body.email).lower()
throttle(db, request, email)
with db.transaction() as conn:
row = (
conn.execute(select(users).where(users.c.email == email)).mappings().first()
)
salt, expected = row["password"].split(":") if row else ("00" * 16, "00" * 64)
valid = hmac.compare_digest(password_hash(body.password, salt), expected)
if not row or not valid:
raise HTTPException(401, "邮箱或密码不正确")
session(conn, row["id"], response, request)
return {key: row[key] for key in ("id", "name", "email")}
@router.post("/logout", status_code=204)
def logout(request: Request, response: Response, db: DatabaseDependency):
with db.transaction() as conn:
conn.execute(
sessions.delete().where(
sessions.c.digest
== hashlib.sha256(request.cookies.get(COOKIE, "").encode()).hexdigest()
)
)
response.delete_cookie(COOKIE, path="/v1/advertiser")
@router.get("/me")
def me(response: Response, user: CurrentUser):
response.headers["Cache-Control"] = "no-store"
return user
@router.get("/accounts")
def list_accounts(db: DatabaseDependency, user: CurrentUser):
with db.transaction() as conn:
return [
{"id": row.id, "parent_id": row.parent_id, **row.document}
for row in conn.execute(
select(accounts).where(accounts.c.owner_id == user["id"])
)
]
@router.post("/accounts", status_code=201)
def create_account(body: CreateAccount, db: DatabaseDependency, user: CurrentUser):
account_id = str(uuid4())
with db.transaction() as conn:
if body.parent_id:
parent = owned(conn, body.parent_id, user)
if parent["parent_id"]:
raise HTTPException(422, "子账户下不能继续创建账户")
conn.execute(
accounts.insert().values(
id=account_id,
owner_id=user["id"],
parent_id=body.parent_id,
document=body.model_dump(exclude={"parent_id"}),
)
)
return {"id": account_id, **body.model_dump()}
@router.put("/accounts/{account_id}")
def update_account(
account_id: str,
body: Account,
db: DatabaseDependency,
user: CurrentUser,
):
with db.transaction() as conn:
owned(conn, account_id, user)
conn.execute(
accounts.update()
.where(accounts.c.id == account_id)
.values(document=body.model_dump())
)
return {"id": account_id, **body.model_dump()}
@router.get("/accounts/{account_id}/projects")
def list_projects(account_id: str, db: DatabaseDependency, user: CurrentUser):
with db.transaction() as conn:
owned(conn, account_id, user)
return [
{"id": row.id, **row.document}
for row in conn.execute(
select(projects).where(projects.c.account_id == account_id)
)
]
@router.post("/accounts/{account_id}/projects", status_code=201)
def create_project(
account_id: str,
body: Project,
db: DatabaseDependency,
user: CurrentUser,
):
project_id = str(uuid4())
with db.transaction() as conn:
account = owned(conn, account_id, user)
if not account["parent_id"]:
raise HTTPException(422, "请在子账户中创建项目")
conn.execute(
projects.insert().values(
id=project_id, account_id=account_id, document=body.model_dump()
)
)
return {"id": project_id, **body.model_dump()}
@router.put("/accounts/{account_id}/projects/{project_id}")
def update_project(
account_id: str,
project_id: str,
body: Project,
db: DatabaseDependency,
user: CurrentUser,
):
with db.transaction() as conn:
owned(conn, account_id, user)
result = conn.execute(
projects.update()
.where(projects.c.id == project_id, projects.c.account_id == account_id)
.values(document=body.model_dump())
)
if not result.rowcount:
raise HTTPException(404, "项目不存在")
return {"id": project_id, **body.model_dump()}
@router.get("/accounts/{account_id}/projects/{project_id}")
def get_project(
account_id: str, project_id: str, db: DatabaseDependency, user: CurrentUser
):
with db.transaction() as conn:
owned(conn, account_id, user)
row = (
conn.execute(
select(projects).where(
projects.c.id == project_id, projects.c.account_id == account_id
)
)
.mappings()
.first()
)
if not row:
raise HTTPException(404, "项目不存在或没有访问权限")
return {"id": row["id"], **row["document"]}