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:
@@ -92,3 +92,7 @@ Dockerfile 提供 test、runtime 两个目标;Gitea Actions 在 main 推送后
|
|||||||
本地可运行 `uv run adcloud-events`,Cassandra 模式还需 `uv run adcloud-profiles`。
|
本地可运行 `uv run adcloud-events`,Cassandra 模式还需 `uv run adcloud-profiles`。
|
||||||
完整目录、API、配置和兼容说明见 [事件注册表](docs/events.md)。
|
完整目录、API、配置和兼容说明见 [事件注册表](docs/events.md)。
|
||||||
开发者控制台已加入事件字典页面;客户账号登录和角色授权尚未接入,目前使用应用范围管理凭据。
|
开发者控制台已加入事件字典页面;客户账号登录和角色授权尚未接入,目前使用应用范围管理凭据。
|
||||||
|
|
||||||
|
## 广告主控制台
|
||||||
|
|
||||||
|
新增独立 `/v1/advertiser` 注册登录、主子账户和项目 API。广告主身份与终端流量用户 identity 分离;详细接口、部署要求和首版边界见 [广告主 API](docs/advertiser-console.md)。广告主认证不依赖 IDENTITY_SECRET,应用启动会初始化共享数据库。
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,7 @@ x-app: &app
|
|||||||
CASSANDRA_HOSTS: cassandra
|
CASSANDRA_HOSTS: cassandra
|
||||||
CASSANDRA_TLS: 'false'
|
CASSANDRA_TLS: 'false'
|
||||||
COOKIE_SECURE: '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
|
depends_on: &app-deps
|
||||||
postgres: {condition: service_healthy}
|
postgres: {condition: service_healthy}
|
||||||
cassandra-init: {condition: service_completed_successfully}
|
cassandra-init: {condition: service_completed_successfully}
|
||||||
@@ -78,6 +78,7 @@ services:
|
|||||||
profiles: [ci]
|
profiles: [ci]
|
||||||
depends_on: *app-deps
|
depends_on: *app-deps
|
||||||
environment:
|
environment:
|
||||||
|
ADCLOUD_REQUIRE_INTEGRATION: 'true'
|
||||||
ADCLOUD_TEST_POSTGRES_URL: postgresql+psycopg://adcloud:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/adcloud
|
ADCLOUD_TEST_POSTGRES_URL: postgresql+psycopg://adcloud:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/adcloud
|
||||||
ADCLOUD_TEST_CASSANDRA_HOSTS: cassandra
|
ADCLOUD_TEST_CASSANDRA_HOSTS: cassandra
|
||||||
ADCLOUD_TEST_CASSANDRA_KEYSPACE: adcloud
|
ADCLOUD_TEST_CASSANDRA_KEYSPACE: adcloud
|
||||||
|
|||||||
@@ -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` 覆盖。
|
||||||
@@ -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"]}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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 = APIRouter(prefix="/v1")
|
||||||
router.include_router(events.router, prefix="/events", tags=["events"])
|
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(identity.router, prefix="/identity", tags=["identity"])
|
||||||
|
|
||||||
router.include_router(registry.router, prefix="/registry", tags=["registry"])
|
router.include_router(registry.router, prefix="/registry", tags=["registry"])
|
||||||
|
|
||||||
|
router.include_router(advertiser.router, prefix="/advertiser", tags=["advertiser"])
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
projection = None
|
projection = None
|
||||||
application.state.identity_service = None
|
application.state.identity_service = None
|
||||||
try:
|
try:
|
||||||
|
database = Database(settings.database_url, settings.tenant_id)
|
||||||
|
database.initialize()
|
||||||
|
application.state.advertiser_database = database
|
||||||
if settings.identity_secret:
|
if settings.identity_secret:
|
||||||
database = Database(settings.database_url, settings.tenant_id)
|
|
||||||
database.initialize()
|
|
||||||
projection = create_profile_store(settings)
|
projection = create_profile_store(settings)
|
||||||
application.state.identity_service = IdentityService(
|
application.state.identity_service = IdentityService(
|
||||||
settings, database, projection
|
settings, database, projection
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -412,7 +412,9 @@ def test_missing_configuration_disables_identity_but_keeps_gateway_available(tmp
|
|||||||
).status_code
|
).status_code
|
||||||
== 503
|
== 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():
|
def test_secure_cookie_configuration():
|
||||||
|
|||||||
Reference in New Issue
Block a user