Add isolated advertiser authentication, account hierarchy and project APIs
Docker test deployment / test-build-deploy (push) Successful in 4m48s
Docker test deployment / test-build-deploy (push) Successful in 4m48s
This commit is contained in:
@@ -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"]}
|
||||
Reference in New Issue
Block a user