420 lines
13 KiB
Python
420 lines
13 KiB
Python
"""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"]}
|