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
+4
View File
@@ -92,3 +92,7 @@ Dockerfile 提供 test、runtime 两个目标;Gitea Actions 在 main 推送后
本地可运行 `uv run adcloud-events`,Cassandra 模式还需 `uv run adcloud-profiles`。
完整目录、API、配置和兼容说明见 [事件注册表](docs/events.md)。
开发者控制台已加入事件字典页面;客户账号登录和角色授权尚未接入,目前使用应用范围管理凭据。
## 广告主控制台
新增独立 `/v1/advertiser` 注册登录、主子账户和项目 API。广告主身份与终端流量用户 identity 分离;详细接口、部署要求和首版边界见 [广告主 API](docs/advertiser-console.md)。广告主认证不依赖 IDENTITY_SECRET,应用启动会初始化共享数据库。
+2 -1
View File
@@ -14,7 +14,7 @@ x-app: &app
CASSANDRA_HOSTS: cassandra
CASSANDRA_TLS: 'false'
COOKIE_SECURE: 'false'
CORS_ORIGINS: ${DEPLOY_CORS_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:5174}
CORS_ORIGINS: ${DEPLOY_CORS_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:5174,http://47.76.92.159:18081}
depends_on: &app-deps
postgres: {condition: service_healthy}
cassandra-init: {condition: service_completed_successfully}
@@ -78,6 +78,7 @@ services:
profiles: [ci]
depends_on: *app-deps
environment:
ADCLOUD_REQUIRE_INTEGRATION: 'true'
ADCLOUD_TEST_POSTGRES_URL: postgresql+psycopg://adcloud:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/adcloud
ADCLOUD_TEST_CASSANDRA_HOSTS: cassandra
ADCLOUD_TEST_CASSANDRA_KEYSPACE: adcloud
+28
View File
@@ -0,0 +1,28 @@
# 广告主控制台 API
访客 `identity` 代表产生广告流量的产品终端用户。广告主使用独立 `advertiser_*` 表、密码与会话,不执行 stitch,不依赖 IDENTITY_SECRET。
- `POST /v1/advertiser/register`:email、password(10–128 字符)、name、company;原子创建用户、主账户、默认子账户。
- `POST /v1/advertiser/login`、`POST /logout`、`GET /me`。
- `GET/POST /v1/advertiser/accounts`:创建时 parent_id 为空表示主账户,否则必须是本人主账户。
- `PUT /v1/advertiser/accounts/{id}`:修改 name、company、industry,不允许换归属。
- `GET/POST /v1/advertiser/accounts/{id}/projects`:只允许子账户创建项目。
- `PUT /v1/advertiser/accounts/{id}/projects/{project_id}`:校验账户所有权和项目归属。
主账户 → 子账户 → 项目。没有第三层账户。当前每个主子账户只有注册用户本人有权限;成员与角色授权后续实现。
密码使用独立随机盐 + scrypt,会话为随机 256-bit token,数据库仅保存 SHA-256 摘要,24 小时失效,退出立即撤销。浏览器 HttpOnly / SameSite=Lax Cookie;Secure 由 COOKIE_SECURE 控制。写操作复用显式 Origin 校验,生产应仅允许实际控制台来源。登录 / 注册按客户端 IP + 邮箱在数据库中限频(十分钟十次);生产还应配合入口级全局限频,防止轮换邮箱/IP 绕过。
广告主接口共用 DATABASE_URL 数据库和事务锁,但身份完全独立。即使没有配置访客身份密钥,启动也会初始化数据库;访客接口仍返回 503。新增表采用现有 additive create_all 初始化,不修改已存在的 identity 表结构。
当前不提供邮箱验证、密码找回、成员协作、资质审批、预算扣款或广告投放。项目状态只有草稿 / 已暂停。所有金额表示配置值,不是资金账本。上线前必须补齐业务审核和资金域。
验证:`uv run pytest tests/test_advertiser.py` 覆盖会话、身份隔离、主子层级、跨用户访问、项目修改、日期预算校验、Origin 和限频。
## URL 导航与服务器 CI
新增 `GET /v1/advertiser/accounts/{account_id}/projects/{project_id}`,支持项目详情直达和刷新;校验登录用户、投放子账户和项目归属。项目配置包含推广产品、转化目标和可选监测链接,六个页面步骤共用一个持久化项目。
广告主 API 测试分别在 SQLite 和真实 PostgreSQL 上执行;PostgreSQL 使用每个测试独立的随机 schema,结束后只清理该 schema。CI integration 容器设置 `ADCLOUD_REQUIRE_INTEGRATION=true`,任何跳过的测试都会令集成阶段失败,避免数据库缺失时仍显示成功。
测试环境默认 CORS 增加 `http://47.76.92.159:18081`,用于同 runner 部署的广告主前端。保持既有开发者端来源;生产地址通过 `DEPLOY_CORS_ORIGINS` 覆盖。
+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"]}
+3 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from adcloud_server.api.v1 import ads, events, identity, registry, users
from adcloud_server.api.v1 import ads, advertiser, events, identity, registry, users
router = APIRouter(prefix="/v1")
router.include_router(events.router, prefix="/events", tags=["events"])
@@ -9,3 +9,5 @@ router.include_router(ads.router, prefix="/ads", tags=["ads"])
router.include_router(identity.router, prefix="/identity", tags=["identity"])
router.include_router(registry.router, prefix="/registry", tags=["registry"])
router.include_router(advertiser.router, prefix="/advertiser", tags=["advertiser"])
+3 -2
View File
@@ -20,9 +20,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
projection = None
application.state.identity_service = None
try:
database = Database(settings.database_url, settings.tenant_id)
database.initialize()
application.state.advertiser_database = database
if settings.identity_secret:
database = Database(settings.database_url, settings.tenant_id)
database.initialize()
projection = create_profile_store(settings)
application.state.identity_service = IdentityService(
settings, database, projection
+15
View File
@@ -0,0 +1,15 @@
"""The CI integration phase must never silently skip unavailable databases."""
import os
import pytest
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(session, exitstatus):
if os.getenv("ADCLOUD_REQUIRE_INTEGRATION") != "true":
return
reporter = session.config.pluginmanager.get_plugin("terminalreporter")
if reporter and reporter.stats.get("skipped"):
reporter.write_sep("=", "Integration CI forbids skipped tests")
session.exitstatus = pytest.ExitCode.TESTS_FAILED
+187
View File
@@ -0,0 +1,187 @@
import os
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select, text
from sqlalchemy.engine import make_url
from adcloud_server.database import profiles
from adcloud_server.main import create_app
from adcloud_server.settings import Settings
PREFIX = "/v1/advertiser"
@pytest.fixture(params=["sqlite", "postgres"])
def client(tmp_path, request):
engine = None
schema = None
url = f"sqlite:///{tmp_path}/app.db"
if request.param == "postgres":
base = os.getenv("ADCLOUD_TEST_POSTGRES_URL")
if not base:
pytest.skip("No PostgreSQL integration URL configured")
schema = "advertiser_test_" + uuid4().hex
engine = create_engine(base)
with engine.begin() as conn:
conn.execute(text(f'CREATE SCHEMA "{schema}"'))
url = (
make_url(base)
.update_query_dict({"options": f"-csearch_path={schema}"})
.render_as_string(hide_password=False)
)
try:
# Advertiser authentication also works without the visitor identity secret.
with TestClient(
create_app(Settings(database_url=url, cookie_secure=False))
) as test_client:
yield test_client
finally:
if engine is not None:
# Only this fixture's random schema in the disposable CI database.
with engine.begin() as conn:
conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
engine.dispose()
def register(client, email="owner@example.com"):
response = client.post(
PREFIX + "/register",
json={
"email": email,
"password": "long-password-123",
"name": "测试广告主",
"company": "测试企业",
},
)
assert response.status_code == 201, response.text
return response
def project():
return {"name": "秋季推广", "budget": 300, "bid": 30, "start_date": "2026-09-16"}
def test_registration_session_and_identity_separation(client):
response = register(client)
assert "HttpOnly" in response.headers["set-cookie"]
assert "password" not in response.json()
assert client.get(PREFIX + "/me").status_code == 200
with client.app.state.advertiser_database.transaction() as conn:
assert conn.execute(select(profiles)).first() is None
assert client.post(PREFIX + "/logout").status_code == 204
assert client.get(PREFIX + "/me").status_code == 401
assert (
client.post(
PREFIX + "/login",
json={"email": "owner@example.com", "password": "wrong-password"},
).status_code
== 401
)
assert (
client.post(
PREFIX + "/login",
json={"email": "OWNER@example.com", "password": "long-password-123"},
).status_code
== 200
)
assert (
client.post(
PREFIX + "/register",
json={
"email": "owner@example.com",
"password": "long-password-123",
"name": "重复",
"company": "重复",
},
).status_code
== 409
)
def test_hierarchy_projects_and_isolation(client):
register(client)
accounts = client.get(PREFIX + "/accounts").json()
parent = next(a for a in accounts if not a["parent_id"])
child = next(a for a in accounts if a["parent_id"])
assert child["parent_id"] == parent["id"]
root_path = PREFIX + "/accounts/" + parent["id"]
path = PREFIX + "/accounts/" + child["id"]
assert client.post(root_path + "/projects", json=project()).status_code == 422
assert (
client.post(
PREFIX + "/accounts",
json={"parent_id": child["id"], "name": "三级账户", "company": "公司"},
).status_code
== 422
)
created = client.post(path + "/projects", json=project())
assert created.status_code == 201
detail = client.get(path + "/projects/" + created.json()["id"])
assert detail.status_code == 200
assert detail.json()["name"] == project()["name"]
item = created.json()
item.pop("id")
item["name"] = "修改后的项目"
project_path = path + "/projects/" + created.json()["id"]
assert client.put(project_path, json=item).status_code == 200
assert client.get(path + "/projects").json()[0]["name"] == "修改后的项目"
assert (
client.post(path + "/projects", json={**project(), "budget": 99}).status_code
== 422
)
assert (
client.post(
path + "/projects", json={**project(), "end_date": "2026-01-01"}
).status_code
== 422
)
assert (
client.post(
path + "/projects", json={**project(), "landing_url": "javascript:alert(1)"}
).status_code
== 422
)
client.post(PREFIX + "/logout")
register(client, "other@example.com")
assert client.get(path + "/projects").status_code == 404
assert client.get(project_path).status_code == 404
assert client.put(project_path, json=item).status_code == 404
assert (
client.put(path, json={"name": "越权修改", "company": "公司"}).status_code
== 404
)
assert (
client.post(
PREFIX + "/accounts",
json={"name": "越权子账户", "company": "公司", "parent_id": parent["id"]},
).status_code
== 404
)
def test_origin_and_throttle(client):
assert (
client.post(
PREFIX + "/login",
headers={"Origin": "https://evil.example"},
json={"email": "owner@example.com", "password": "long-password"},
).status_code
== 403
)
for _ in range(10):
assert (
client.post(
PREFIX + "/login",
json={"email": "unknown@example.com", "password": "wrong-password"},
).status_code
== 401
)
assert (
client.post(
PREFIX + "/login",
json={"email": "unknown@example.com", "password": "wrong-password"},
).status_code
== 429
)
+3 -1
View File
@@ -412,7 +412,9 @@ def test_missing_configuration_disables_identity_but_keeps_gateway_available(tmp
).status_code
== 503
)
assert not (tmp_path / "unused.db").exists()
# Advertiser auth initializes storage independently of visitor identity.
assert (tmp_path / "unused.db").exists()
assert client.get("/v1/advertiser/me").status_code == 401
def test_secure_cookie_configuration():