Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de707ec0a9 | |||
| ddfe955e9c | |||
| 98cd915e2c | |||
| bf35a45793 | |||
| 452d7fef8f | |||
| 6682f9d0d4 | |||
| 674b8c5589 | |||
| 4d730169bf | |||
| cdfe66dcee | |||
| 8d08986c00 | |||
| be77ffa721 | |||
| 0bed761c00 | |||
| 7fd84ce48f | |||
| 8fd2c5ee96 | |||
| 9ab4dfc6a4 | |||
| 9484768321 | |||
| 4be9848235 | |||
| 109aebaa16 | |||
| 84ae950ea7 | |||
| 122766c911 | |||
| cee4f3e0a7 | |||
| 4fb8f6447c | |||
| 46c650ecb8 | |||
| b622f76a02 | |||
| c0b67fd879 |
@@ -117,3 +117,16 @@ PANGLE_REWARD_SECRET=
|
||||
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
|
||||
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
|
||||
AD_REWARD_TEST_GRANT_ENABLED=false
|
||||
|
||||
# ===== 穿山甲 GroMore 数据 API(按天拉收益报表,供后台广告收益报表的「穿山甲后台收益」)=====
|
||||
# ⚠️ 与上面发奖回调的 m-key 是【两套不同凭证】:这三样在穿山甲后台「接入中心 → GroMore-API →
|
||||
# 聚合数据报告 API」文档页领取。只读拉取 GroMore 天级 revenue(预估)/ api_revenue(收益Api),
|
||||
# 不参与发奖。三样齐全才生效;留空 = scripts/sync_pangle_revenue 直接 no-op。
|
||||
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到收益(接口 118);
|
||||
# role_id 填成 = user_id 即查主账号数据。同步:线上每天 ~10:30 由 timer 跑 python -m scripts.sync_pangle_revenue。
|
||||
PANGLE_REPORT_USER_ID=0
|
||||
PANGLE_REPORT_ROLE_ID=0
|
||||
PANGLE_REPORT_SECURITY_KEY=
|
||||
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
|
||||
PANGLE_REPORT_SITE_ID_PROD=5830519
|
||||
PANGLE_REPORT_SITE_ID_TEST=5832303
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""ad_pangle_daily_revenue: 穿山甲 GroMore 天级收益报表(后台结算口径)
|
||||
|
||||
新建表存放从 GroMore 数据 API 按天拉取的收益(revenue 预估 + api_revenue 收益Api),
|
||||
粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)。供广告收益报表的
|
||||
汇总/趋势级展示「穿山甲后台收益」,与客户端自报 eCPM 折算的预估互为对照。
|
||||
|
||||
Revision ID: ad_pangle_daily_revenue
|
||||
Revises: 7db22acee504
|
||||
Create Date: 2026-06-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "ad_pangle_daily_revenue"
|
||||
down_revision = "7db22acee504"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ad_pangle_daily_revenue",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("report_date", sa.String(length=10), nullable=False),
|
||||
sa.Column("app_env", sa.String(length=16), nullable=False),
|
||||
sa.Column("site_id", sa.String(length=32), nullable=True),
|
||||
sa.Column("our_code_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("adn", sa.String(length=16), nullable=False, server_default=""),
|
||||
sa.Column("revenue_yuan", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("api_revenue_yuan", sa.Float(), nullable=True),
|
||||
sa.Column("ecpm", sa.String(length=32), nullable=True),
|
||||
sa.Column("impressions", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("currency", sa.String(length=8), nullable=False, server_default="cny"),
|
||||
sa.Column(
|
||||
"synced_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"report_date", "app_env", "our_code_id", "adn", name="uq_ad_pangle_daily"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ad_pangle_daily_revenue_report_date",
|
||||
"ad_pangle_daily_revenue",
|
||||
["report_date"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ad_pangle_daily_revenue_our_code_id",
|
||||
"ad_pangle_daily_revenue",
|
||||
["our_code_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ad_pangle_daily_revenue_our_code_id", table_name="ad_pangle_daily_revenue")
|
||||
op.drop_index("ix_ad_pangle_daily_revenue_report_date", table_name="ad_pangle_daily_revenue")
|
||||
op.drop_table("ad_pangle_daily_revenue")
|
||||
@@ -31,6 +31,7 @@ from app.admin.repositories import stats as admin_stats
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_pangle_revenue
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -255,6 +256,25 @@ def ad_revenue_report(
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
# 穿山甲后台收益(GroMore 数据 API,T+1 入库 ad_pangle_daily_revenue):汇总 + 按天趋势级展示,
|
||||
# 与上面客户端自报 eCPM 折算的预估并列对照(看 gap)。穿山甲数据**无用户/场景/类型维度**,故仅在
|
||||
# 「全量视图」(未按 user_id / ad_type / feed_scene 过滤)给值;一旦带这些过滤,穿山甲数无法对应口径
|
||||
# → 置 None,前端显示「-」并提示。逐条事件行不动(仍是客户端预估)。
|
||||
pangle_filterable = user_id is None and ad_type is None and feed_scene is None
|
||||
total_pangle_revenue_yuan: float | None = None
|
||||
total_pangle_api_revenue_yuan: float | None = None
|
||||
if pangle_filterable:
|
||||
pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to)
|
||||
if pangle_aggs:
|
||||
by_date = {a["date"]: a for a in pangle_aggs}
|
||||
for d in daily:
|
||||
pa = by_date.get(d["date"])
|
||||
d["pangle_revenue_yuan"] = pa["revenue_yuan"] if pa else None
|
||||
d["pangle_api_revenue_yuan"] = pa["api_revenue_yuan"] if pa else None
|
||||
total_pangle_revenue_yuan = round(sum(a["revenue_yuan"] for a in pangle_aggs), 6)
|
||||
api_vals = [a["api_revenue_yuan"] for a in pangle_aggs if a["api_revenue_yuan"] is not None]
|
||||
total_pangle_api_revenue_yuan = round(sum(api_vals), 6) if api_vals else None
|
||||
|
||||
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
|
||||
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
|
||||
hourly: list[dict] = []
|
||||
@@ -303,6 +323,10 @@ def ad_revenue_report(
|
||||
"truncated": len(events) > offset + limit,
|
||||
"total_impressions": total_impressions,
|
||||
"total_revenue_yuan": total_revenue_yuan,
|
||||
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
|
||||
"total_pangle_revenue_yuan": total_pangle_revenue_yuan,
|
||||
"total_pangle_api_revenue_yuan": total_pangle_api_revenue_yuan,
|
||||
"pangle_revenue_available": total_pangle_revenue_yuan is not None,
|
||||
"total_expected_coin": total_expected_coin,
|
||||
"total_actual_coin": total_actual_coin,
|
||||
"mismatch_count": mismatch_count,
|
||||
|
||||
@@ -89,6 +89,9 @@ def get_ad_revenue_report(
|
||||
truncated=result["truncated"],
|
||||
total_impressions=result["total_impressions"],
|
||||
total_revenue_yuan=result["total_revenue_yuan"],
|
||||
total_pangle_revenue_yuan=result["total_pangle_revenue_yuan"],
|
||||
total_pangle_api_revenue_yuan=result["total_pangle_api_revenue_yuan"],
|
||||
pangle_revenue_available=result["pangle_revenue_available"],
|
||||
total_expected_coin=result["total_expected_coin"],
|
||||
total_actual_coin=result["total_actual_coin"],
|
||||
mismatch_count=result["mismatch_count"],
|
||||
|
||||
@@ -44,7 +44,13 @@ class AdRevenueDaily(BaseModel):
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
impressions: int = Field(..., description="当天展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
|
||||
revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)")
|
||||
pangle_revenue_yuan: float | None = Field(
|
||||
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
|
||||
)
|
||||
pangle_api_revenue_yuan: float | None = Field(
|
||||
None, description="当天穿山甲收益Api(元;GroMore api_revenue,更接近结算);未配/当天/无数据为空"
|
||||
)
|
||||
expected_coin: int = Field(..., description="当天应发金币合计")
|
||||
actual_coin: int = Field(..., description="当天实发金币合计")
|
||||
|
||||
@@ -123,7 +129,21 @@ class AdRevenueReportOut(BaseModel):
|
||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)")
|
||||
total_pangle_revenue_yuan: float | None = Field(
|
||||
None,
|
||||
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
|
||||
"仅「全量视图」(未按 user_id/ad_type/feed_scene 过滤)时有值,否则为 null",
|
||||
)
|
||||
total_pangle_api_revenue_yuan: float | None = Field(
|
||||
None,
|
||||
description="全量穿山甲收益Api合计(元;GroMore api_revenue,各 ADN 回传、更接近结算);"
|
||||
"未配 Reporting / 查当天 / 非全量视图 时为 null",
|
||||
)
|
||||
pangle_revenue_available: bool = Field(
|
||||
False,
|
||||
description="本次结果是否带穿山甲后台收益(=全量视图且已同步到数据)。false 时前端「穿山甲收益」显示「-」",
|
||||
)
|
||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
@@ -21,6 +22,7 @@ from app.repositories import launch_confirm_sample as repo
|
||||
from app.schemas.launch_confirm_sample import (
|
||||
LaunchConfirmSampleIn,
|
||||
LaunchConfirmSampleOut,
|
||||
LaunchConfirmSampleRow,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.launch_confirm")
|
||||
@@ -61,3 +63,34 @@ def report_launch_confirm_sample(
|
||||
payload.exec_success, payload.trace_id,
|
||||
)
|
||||
return LaunchConfirmSampleOut(id=sid)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/launch-confirm-samples",
|
||||
response_model=list[LaunchConfirmSampleRow],
|
||||
summary="启动确认窗兜底样本列表(沉淀脚本 distill 读; server→server, 不走 JWT)",
|
||||
)
|
||||
def list_launch_confirm_samples(
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
exec_success: bool | None = None,
|
||||
host_package: str | None = None,
|
||||
since_days: int | None = None,
|
||||
limit: int = 1000,
|
||||
) -> list[LaunchConfirmSampleRow]:
|
||||
"""读样本供 pricebot 的 distill_launch_confirm.py 聚合沉淀回 PROFILES。
|
||||
|
||||
与上报端点同一把共享密钥;exec_success/host_package/since_days 均可选,limit 默认 1000。
|
||||
"""
|
||||
_check_secret(x_internal_secret)
|
||||
since = None
|
||||
if since_days and since_days > 0:
|
||||
since = datetime.now(timezone.utc) - timedelta(days=since_days)
|
||||
rows = repo.list_samples(
|
||||
db,
|
||||
exec_success=exec_success,
|
||||
host_package=host_package,
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
return [LaunchConfirmSampleRow.model_validate(r) for r in rows]
|
||||
|
||||
@@ -211,6 +211,41 @@ class Settings(BaseSettings):
|
||||
"""回调开关打开且至少配了一个验签密钥,才接受发奖回调。"""
|
||||
return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets)
|
||||
|
||||
# ===== 穿山甲 GroMore 数据 API(报表收益拉取,T+1)=====
|
||||
# ⚠️ 与上面发奖回调的 m-key 是【两套完全不同的凭证】:这三样在穿山甲后台
|
||||
# 「接入中心 → GroMore-API → 聚合数据报告 API」文档页领取(user_id / role_id / Security Key),
|
||||
# 仅用于按天拉 GroMore 收益报表(revenue 预估收益 + api_revenue 收益Api),不参与发奖。
|
||||
# 该 API 只能查【GroMore 聚合代码位】的数据(=我们 useMediation 的口径),非穿山甲 SDK 数据;
|
||||
# 且不提供用户/设备维度(官方明确),故收益只能落到 日期×代码位 汇总,不能挂到逐条事件。
|
||||
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到
|
||||
# ecpm/revenue(接口返回 118);role_id 填成与 user_id 一致 = 查主账号数据。
|
||||
PANGLE_REPORT_USER_ID: int = 0 # 媒体账号 user_id
|
||||
PANGLE_REPORT_ROLE_ID: int = 0 # 子账号 role_id(=user_id 时查主账号)
|
||||
PANGLE_REPORT_SECURITY_KEY: str = "" # 该账号的 Security Key(secure_key,≠ 发奖 m-key)
|
||||
# GroMore 聚合 AppId(报表 site_id 维度)→ 我们的应用环境。报表按 site_id 区分两个穿山甲应用,
|
||||
# 用它把每行归到 prod(傻瓜比价正式)/ test(测试应用)。默认值取自现网两个应用,部署时按需覆盖。
|
||||
PANGLE_REPORT_SITE_ID_PROD: str = "5830519" # 傻瓜比价正式应用 AppId
|
||||
PANGLE_REPORT_SITE_ID_TEST: str = "5832303" # 测试应用 AppId
|
||||
|
||||
@property
|
||||
def pangle_report_configured(self) -> bool:
|
||||
"""三样齐全(user_id / role_id / security_key)才能拉 GroMore 报表;缺任一 → 同步脚本 no-op。"""
|
||||
return bool(
|
||||
self.PANGLE_REPORT_USER_ID
|
||||
and self.PANGLE_REPORT_ROLE_ID
|
||||
and self.PANGLE_REPORT_SECURITY_KEY
|
||||
)
|
||||
|
||||
@property
|
||||
def pangle_report_site_id_to_env(self) -> dict[str, str]:
|
||||
"""site_id(穿山甲 AppId)→ app_env(prod/test);供同步脚本把报表行归到我们的应用。留空项忽略。"""
|
||||
out: dict[str, str] = {}
|
||||
if self.PANGLE_REPORT_SITE_ID_PROD.strip():
|
||||
out[self.PANGLE_REPORT_SITE_ID_PROD.strip()] = "prod"
|
||||
if self.PANGLE_REPORT_SITE_ID_TEST.strip():
|
||||
out[self.PANGLE_REPORT_SITE_ID_TEST.strip()] = "test"
|
||||
return out
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
|
||||
PRICEBOT_BASE_URL: str = "http://localhost:8000"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""穿山甲 GroMore「聚合数据报告 API」客户端 —— 按天拉取收益报表(只读、T+1)。
|
||||
|
||||
⚠️ 这是 **GroMore 数据拉取 API**,与发奖回调验签(integrations/pangle.py 的 m-key)是
|
||||
两套完全不同的凭证与用途。凭证在后台「接入中心 → GroMore-API → 聚合数据报告 API」领取:
|
||||
媒体账号 user_id、子账号 role_id、Security Key(secure_key)。
|
||||
|
||||
鉴权(文档 v2.x「方法一」):
|
||||
1. 去掉请求参数里的 `sign` 字段与值为空的字段;
|
||||
2. 其余参数按 key 字典序升序,拼成 `k1=v1&k2=v2&...&kn=vn`;
|
||||
3. 末尾直接拼接 security_key(无分隔符),对整串做 MD5,取 32 位小写十六进制 = sign。
|
||||
签名有 3 分钟过期(天级用 `timestamp` 秒级时间戳;小时级才用 `current_time` 字符串)。
|
||||
|
||||
数据口径要点(来自官方文档):
|
||||
- 只返回【GroMore 聚合代码位】在 GroMore 内的数据(=我们 useMediation 的口径),
|
||||
查不到穿山甲 SDK 自身的数据;
|
||||
- **不提供分用户/设备维度**(官方 FAQ 明确拒绝),最细到 日期×应用×代码位×广告源;
|
||||
- `revenue` = 预估收益(元,所有 ADN 都有);`api_revenue` = 收益Api(各 ADN 经 Reporting
|
||||
回传、按实时汇率折算账号币种,更接近结算),需后台为该 ADN 配置 Reporting 才有、且不支持当天;
|
||||
- 「今天」与「今天以前」必须分开查;天级跨度 ≤ 1 个月、不早于 12 个月。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.pangle_report")
|
||||
|
||||
HOST = "https://www.csjplatform.com"
|
||||
# 天级收益报表(路径与文档代码示例一致;另有小时级 get_hour_report_data,本服务只用天级)。
|
||||
DAILY_PATH = "/union_media/open/api/mediation/get_daily_income_report_data"
|
||||
VERSION = "2.0"
|
||||
SIGN_TYPE = "MD5"
|
||||
PAGE_LIMIT = 5000 # 文档上限;一次尽量多取,减少翻页
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
_MAX_PAGES = 200 # 翻页安全阀(5000×200=100w 行,远超我们规模),防异常 has_next 死循环
|
||||
|
||||
|
||||
class PangleReportError(Exception):
|
||||
"""GroMore 报表接口调用失败(未配置 / 网络 / 业务码非 100)。"""
|
||||
|
||||
|
||||
def build_sign(params: Mapping[str, Any], security_key: str) -> str:
|
||||
"""按文档「方法一」生成 sign:去 sign/空值 → key 升序 → k=v& 拼接 → 末尾接 secure_key → MD5(32 位小写)。
|
||||
|
||||
与官方 Python/Java 示例逐字节一致(见 tests/test_pangle_report.py 的两个测试向量)。
|
||||
"""
|
||||
items = [
|
||||
(str(k), str(v))
|
||||
for k, v in params.items()
|
||||
if k != "sign" and v is not None and str(v) != ""
|
||||
]
|
||||
items.sort(key=lambda kv: kv[0])
|
||||
raw = "&".join(f"{k}={v}" for k, v in items)
|
||||
return hashlib.md5((raw + security_key).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def fetch_daily_report(
|
||||
*,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
# ⚠️ 用 ad_unit_id(GroMore 广告位ID = 我们的 104xxx),不要用 code_id(底层各 ADN 代码位,对不上)
|
||||
dimensions: str = "date,site_id,ad_unit_id",
|
||||
metrics: str = "revenue,api_revenue,ecpm,imp_cnt",
|
||||
site_ids: str | None = None,
|
||||
code_ids: str | None = None,
|
||||
os: str | None = None,
|
||||
network: str | None = None,
|
||||
time_zone: int = 8,
|
||||
limit: int = PAGE_LIMIT,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""拉一个日期区间(北京时间,time_zone=8)的天级收益报表,自动翻页,返回 report_list 全量行。
|
||||
|
||||
每行是字符串字典(接口原值),形如
|
||||
{"start_date": "2026-06-27", "site_id": "5830519", "ad_unit_id": "104142227",
|
||||
"revenue": "1.23", "api_revenue": "1.05", "ecpm": "0.80", "imp_cnt": "1537", ...}。
|
||||
业务码非 100 直接抛 PangleReportError(由调用方/脚本兜底,不静默吞)。
|
||||
"""
|
||||
if not settings.pangle_report_configured:
|
||||
raise PangleReportError(
|
||||
"PANGLE_REPORT_USER_ID / ROLE_ID / SECURITY_KEY 未配置,无法拉取 GroMore 报表"
|
||||
)
|
||||
|
||||
base_params: dict[str, Any] = {
|
||||
"user_id": settings.PANGLE_REPORT_USER_ID,
|
||||
"role_id": settings.PANGLE_REPORT_ROLE_ID,
|
||||
"version": VERSION,
|
||||
"sign_type": SIGN_TYPE,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"dimensions": dimensions,
|
||||
"metrics": metrics,
|
||||
"time_zone": time_zone,
|
||||
"limit": limit,
|
||||
}
|
||||
# 可选过滤(空则不传,避免进签名串)
|
||||
for key, val in (
|
||||
("site_ids", site_ids), ("code_ids", code_ids),
|
||||
("os", os), ("network", network),
|
||||
):
|
||||
if val:
|
||||
base_params[key] = val
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
for _ in range(_MAX_PAGES):
|
||||
params = dict(base_params)
|
||||
params["offset"] = offset
|
||||
# timestamp 每页临请求时取最新(3 分钟过期),并参与签名
|
||||
params["timestamp"] = int(time.time())
|
||||
params["sign"] = build_sign(params, settings.PANGLE_REPORT_SECURITY_KEY)
|
||||
|
||||
resp = client.get(HOST + DAILY_PATH, params=params)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
|
||||
code = str(body.get("code"))
|
||||
if code != "100":
|
||||
raise PangleReportError(
|
||||
f"GroMore 报表业务失败 code={code} message={body.get('message')!r} "
|
||||
f"(101=验签失败/102=userid无效/107=无权限/118=无收益查看权限,详见文档状态码)"
|
||||
)
|
||||
|
||||
data = body.get("data") or {}
|
||||
page = data.get("report_list") or []
|
||||
rows.extend(page)
|
||||
|
||||
# has_next=1 还有下一页;无该字段则按本页是否取满判断
|
||||
has_next = str(data.get("has_next", "")) == "1"
|
||||
if not page or not has_next:
|
||||
break
|
||||
offset += len(page)
|
||||
except httpx.HTTPError as e:
|
||||
raise PangleReportError(f"GroMore 报表请求异常: {e}") from e
|
||||
|
||||
logger.info(
|
||||
"GroMore 天级报表拉取完成 %s~%s dims=%s 行数=%d", start_date, end_date, dimensions, len(rows)
|
||||
)
|
||||
return rows
|
||||
@@ -142,3 +142,22 @@ app.mount(
|
||||
StaticFiles(directory=str(_media_root)),
|
||||
name="media",
|
||||
)
|
||||
|
||||
# 业务 H5 同源托管(/h5)。与 /api/v1 同 host → H5 用相对路径调后端,免 CORS。
|
||||
# .html 加 no-cache:后端改完用户重开页即拉新(远程热更核心);js/图片走默认缓存。
|
||||
class _NoCacheHTMLStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
resp = await super().get_response(path, scope)
|
||||
# 按响应 content-type 判定,而非请求 path:html=True 时目录式 URL(/h5/x/)
|
||||
# 经 Starlette 内部回落到 index.html,此刻 path 仍是 "x/"(不带 .html)。
|
||||
if resp.headers.get("content-type", "").startswith("text/html"):
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
|
||||
_h5_root = Path(__file__).resolve().parent.parent / "h5"
|
||||
app.mount(
|
||||
"/h5",
|
||||
_NoCacheHTMLStaticFiles(directory=str(_h5_root), html=True),
|
||||
name="h5",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401
|
||||
from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
@@ -19,7 +20,6 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
)
|
||||
from app.models.cps_order import CpsOrder # noqa: F401
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""穿山甲 GroMore 天级收益报表(后台结算口径,定时拉取入库)。
|
||||
|
||||
每行 = GroMore 数据 API 返回的一条「日期 × 应用 × 代码位」聚合收益(`integrations/pangle_report`
|
||||
+ `scripts/sync_pangle_revenue` 落库)。**权威/预估收益的来源**,与 `ad_ecpm_record`(客户端自报
|
||||
eCPM 折算的预估)互为对照:
|
||||
|
||||
- `revenue_yuan` ← 接口 `revenue`(预估收益,元;排序价×展示/1000,所有 ADN 都有);
|
||||
- `api_revenue_yuan` ← 接口 `api_revenue`(收益Api,元;各 ADN 经 Reporting 回传、更接近结算;
|
||||
未配置该 ADN 的 Reporting 或查当天时为空)。
|
||||
|
||||
⚠️ 穿山甲不提供分用户/设备维度,故本表最细只到 日期×应用×代码位,**无法挂到逐条广告事件**;
|
||||
广告收益报表里只用于汇总/趋势级的「穿山甲后台收益」,不改逐条行的客户端预估。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AdPangleDailyRevenue(Base):
|
||||
__tablename__ = "ad_pangle_daily_revenue"
|
||||
__table_args__ = (
|
||||
# 一行 = (日期, 应用, 代码位, 广告源);adn 用 "" 表示「未分广告源、该代码位汇总」,
|
||||
# 避免 NULL 在唯一约束里被视为各不相同导致 upsert 重复(SQLite/PG 行为一致)。
|
||||
UniqueConstraint(
|
||||
"report_date", "app_env", "our_code_id", "adn",
|
||||
name="uq_ad_pangle_daily",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 北京时间日期串 'YYYY-MM-DD'(拉取时 time_zone=8),与 ad_ecpm_record.report_date 同口径,可直接 join。
|
||||
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
# 我们的应用环境:prod(傻瓜比价正式)/ test(测试);由 site_id 经 settings 映射而来。
|
||||
app_env: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# 原始 GroMore AppId(site_id),留痕便于排查映射。
|
||||
site_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 广告位 ID(= 接口 ad_unit_id = 我们客户端配的 104xxx = ad_ecpm_record.our_code_id),join key。
|
||||
# ⚠️ 不是接口的 code_id —— 那是底层各 ADN 的代码位(如 983674557),对不上我们的口径。
|
||||
our_code_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
# 广告源(接口 network 数字→名,如 pangle/gdt);"" = 未分广告源的代码位汇总行(当前默认口径)。
|
||||
adn: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
||||
# 预估收益(元)← 接口 revenue。
|
||||
revenue_yuan: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
# 收益Api(元)← 接口 api_revenue;未配 Reporting / 当天 等情况接口不返回 → NULL。
|
||||
api_revenue_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# 预估 eCPM 原值(接口 ecpm,单位元/千次,**与客户端 getEcpm 的「分」不同**),参考用原样存。
|
||||
ecpm: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 展示次数 ← 接口 imp_cnt。
|
||||
impressions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 货币类型(接口 currency,正常为 cny)。
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="cny")
|
||||
# 最近一次同步写入时间(同一行可被多次回补覆盖;T+1 数据穿山甲会订正)。
|
||||
synced_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<AdPangleDailyRevenue {self.report_date} {self.app_env} "
|
||||
f"code={self.our_code_id} revenue={self.revenue_yuan}>"
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""穿山甲 GroMore 天级收益 读写(`ad_pangle_daily_revenue` 表)。
|
||||
|
||||
`scripts/sync_pangle_revenue` 拉数后调 `upsert_daily_rows` 落库(同一(日期×应用×代码位×广告源)
|
||||
幂等覆盖,T+1 订正可重跑);admin 广告收益报表调 `aggregate_by_date` 取「穿山甲后台收益」做
|
||||
汇总/趋势级展示。穿山甲无用户维度,故这里不涉及 user_id。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ad_pangle_revenue import AdPangleDailyRevenue
|
||||
|
||||
# upsert 时可被覆盖更新的列(唯一键之外的业务列)。
|
||||
_UPDATABLE = ("site_id", "revenue_yuan", "api_revenue_yuan", "ecpm", "impressions", "currency")
|
||||
|
||||
|
||||
class PangleDateAgg(TypedDict):
|
||||
date: str
|
||||
revenue_yuan: float
|
||||
api_revenue_yuan: float | None
|
||||
impressions: int
|
||||
|
||||
|
||||
def upsert_daily_rows(db: Session, rows: list[dict[str, Any]]) -> dict[str, int]:
|
||||
"""按唯一键 (report_date, app_env, our_code_id, adn) 逐行 upsert。
|
||||
|
||||
每个 row 须含:report_date, app_env, our_code_id;可选 adn(默认"")、site_id、
|
||||
revenue_yuan、api_revenue_yuan、ecpm、impressions、currency。返回 {inserted, updated}。
|
||||
规模很小(代码位数×天数),逐行 select-then-write 足够,且 SQLite/PG 通用。
|
||||
"""
|
||||
inserted = updated = 0
|
||||
for row in rows:
|
||||
adn = row.get("adn") or ""
|
||||
existing = db.execute(
|
||||
select(AdPangleDailyRevenue).where(
|
||||
AdPangleDailyRevenue.report_date == row["report_date"],
|
||||
AdPangleDailyRevenue.app_env == row["app_env"],
|
||||
AdPangleDailyRevenue.our_code_id == row["our_code_id"],
|
||||
AdPangleDailyRevenue.adn == adn,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
db.add(AdPangleDailyRevenue(
|
||||
report_date=row["report_date"],
|
||||
app_env=row["app_env"],
|
||||
site_id=row.get("site_id"),
|
||||
our_code_id=row["our_code_id"],
|
||||
adn=adn,
|
||||
revenue_yuan=float(row.get("revenue_yuan") or 0.0),
|
||||
api_revenue_yuan=row.get("api_revenue_yuan"),
|
||||
ecpm=row.get("ecpm"),
|
||||
impressions=int(row.get("impressions") or 0),
|
||||
currency=row.get("currency") or "cny",
|
||||
))
|
||||
inserted += 1
|
||||
else:
|
||||
for col in _UPDATABLE:
|
||||
if col in row:
|
||||
setattr(existing, col, row[col])
|
||||
updated += 1
|
||||
db.commit()
|
||||
return {"inserted": inserted, "updated": updated}
|
||||
|
||||
|
||||
def aggregate_by_date(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
) -> list[PangleDateAgg]:
|
||||
"""按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。
|
||||
|
||||
revenue_yuan = Σrevenue;api_revenue_yuan = Σapi_revenue(SQL SUM 忽略 NULL,
|
||||
全为空则返回 None,前端显示「-」)。可选按应用 / 代码位过滤。返回按日期升序。
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
AdPangleDailyRevenue.report_date,
|
||||
func.sum(AdPangleDailyRevenue.revenue_yuan),
|
||||
func.sum(AdPangleDailyRevenue.api_revenue_yuan),
|
||||
func.sum(AdPangleDailyRevenue.impressions),
|
||||
)
|
||||
.where(
|
||||
AdPangleDailyRevenue.report_date >= date_from,
|
||||
AdPangleDailyRevenue.report_date <= date_to,
|
||||
)
|
||||
.group_by(AdPangleDailyRevenue.report_date)
|
||||
.order_by(AdPangleDailyRevenue.report_date)
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env)
|
||||
if our_code_id is not None:
|
||||
stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id)
|
||||
|
||||
out: list[PangleDateAgg] = []
|
||||
for report_date, rev, api_rev, imp in db.execute(stmt).all():
|
||||
out.append(PangleDateAgg(
|
||||
date=report_date,
|
||||
revenue_yuan=round(float(rev or 0.0), 6),
|
||||
api_revenue_yuan=(round(float(api_rev), 6) if api_rev is not None else None),
|
||||
impressions=int(imp or 0),
|
||||
))
|
||||
return out
|
||||
@@ -2,8 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample
|
||||
@@ -33,3 +35,27 @@ def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row.id
|
||||
|
||||
|
||||
def list_samples(
|
||||
db: Session,
|
||||
*,
|
||||
exec_success: Optional[bool] = None,
|
||||
host_package: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
limit: int = 1000,
|
||||
) -> list[LaunchConfirmSample]:
|
||||
"""按条件查样本(pricebot 的 distill_launch_confirm.py 沉淀工具读)。created_at 升序。
|
||||
|
||||
过滤项都可空:exec_success(只看放行成功的)/ host_package(只看某宿主包)/
|
||||
since(>= created_at)。limit 兜底防一次拉爆全表。
|
||||
"""
|
||||
stmt = select(LaunchConfirmSample)
|
||||
if exec_success is not None:
|
||||
stmt = stmt.where(LaunchConfirmSample.exec_success == exec_success)
|
||||
if host_package:
|
||||
stmt = stmt.where(LaunchConfirmSample.host_package == host_package)
|
||||
if since is not None:
|
||||
stmt = stmt.where(LaunchConfirmSample.created_at >= since)
|
||||
stmt = stmt.order_by(LaunchConfirmSample.created_at.asc()).limit(limit)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class LaunchConfirmSampleIn(BaseModel):
|
||||
@@ -24,3 +26,20 @@ class LaunchConfirmSampleOut(BaseModel):
|
||||
"""落库结果。"""
|
||||
|
||||
id: int
|
||||
|
||||
|
||||
class LaunchConfirmSampleRow(BaseModel):
|
||||
"""单条样本(列表读出;沉淀脚本 distill 用,payload 原样带出)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
trace_id: str | None = None
|
||||
device_id: str | None = None
|
||||
host_package: str | None = None
|
||||
target_app: str | None = None
|
||||
system_locale: str | None = None
|
||||
exec_success: bool = False
|
||||
dialog_title: str | None = None
|
||||
payload: dict | None = None
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# H5 / WebView 改造方案(app-server 端)
|
||||
|
||||
> 仓库:`shaguabijia-app-server` 分支:`feat/h5-sgbridge` 日期:2026-06-29
|
||||
> 配套文档:`shaguabijia-app-android` 仓库 `docs/H5-WebView改造方案.md`(原生壳 + SGBridge.kt)
|
||||
> 本方案 = **方案1:以本仓已有 `SGBridge` 为统一基准**,安卓退役其 `NativeBridge`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与现状
|
||||
|
||||
项目里同时存在**两套互不兼容**的 H5 方案,尚未收敛:
|
||||
|
||||
| | 本仓那套(已在 `main`,PR #89) | 安卓那套(`feat/records-h5`,未进 main) |
|
||||
|---|---|---|
|
||||
| 桥 | `SGBridge`(JS)↔ `SGBridgeNative`(原生),协议丰富 + 事件总线 | `NativeBridge`,仅 4 法 |
|
||||
| 托管 | **设计为**后端同源托管(相对 `/api/v1`、免 CORS) | 本地 assets `file://`(跨域 + 放行 CORS) |
|
||||
| 页面 | `h5/mine/index.html`(7771)+ `h5/shared/bridge.js`+`api.js` | `records`(1778)、`reports`(603) |
|
||||
| 落地 | **半成品**:安卓从无 `SGBridge.kt`;且 `h5/` 实际没挂(StaticFiles 只挂了 `data/media` 头像上传) | 已接进安卓导航但只在分支上 |
|
||||
|
||||
**结论(已对齐):** 目标是「远程后端托管 + 四 tab 全 H5」,该方向天然契合本仓 SGBridge(同源相对路径、`mine` 已成型、`shared/` 已就绪)。故统一到 SGBridge,把安卓的 records/reports 页**迁入本仓** `h5/`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与范围
|
||||
|
||||
**目标:** 由 app-server 同源托管全部业务 H5,配齐 `shared/` 桥与接口封装,支持安卓「原生壳 + 远程 H5」一套桥贯通,可不发版热更。
|
||||
|
||||
**本仓(app-server)范围:**
|
||||
- H5 托管:新增 **FastAPI StaticFiles 挂载 `/h5`** → 指向仓库 `h5/` 目录(同源、免 CORS)
|
||||
- 迁入 records / reports 两页(从安卓 `feat/records-h5` 移植),改用 `SGBridge` / `SGApi`
|
||||
- `shared/bridge.js` 补 **`closePage`(返回)**;`shared/api.js` 已同源就绪(必要时补 multipart 说明)
|
||||
- 远程开关配置接口(返回 per-page `native`/`h5` 标志)
|
||||
- 缓存 / 热更策略(cache-control + 版本)
|
||||
|
||||
**不在本仓(见 android 文档):** `SGBridge.kt`、WebView 宿主、原生/H5 切换接线。
|
||||
|
||||
**后端业务接口:零改动** —— H5 复用现有 `/api/v1/...`(`/compare/records`、`/report`、`/wallet/account`、`/coupon/stats` 等),只是请求方从 Kotlin 改成网页 fetch。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键决策(已锁定)
|
||||
|
||||
| # | 决策 | 选定 | 理由 |
|
||||
|---|---|---|---|
|
||||
| D1 | 桥基准 | **SGBridge**(安卓退役 NativeBridge) | 协议齐、mine 成型、三端对齐 |
|
||||
| D2 | 托管 | **FastAPI StaticFiles 挂 `/h5`** | 同源免 CORS、复用现有 `/media` 方式、nginx 已反代 `/`→uvicorn,dev+prod 都能跑 |
|
||||
| D3 | 离线兜底 | 纯远程 + 安卓侧重试页 | YAGNI;后端不需为离线做特殊处理 |
|
||||
| D4 | 地址 | 安卓用 `BackendUrl.current` 推导 H5 地址(同源) | H5 与 `/api/v1` 同 host,自动跟随环境 |
|
||||
| D5 | 迁移顺序 | records → reports → 首页/福利;mine 最后校准 | 用小页验证桥,风险分散 |
|
||||
|
||||
---
|
||||
|
||||
## 4. H5 托管(D2)
|
||||
|
||||
`app/main.py` 现有挂载(仅头像):
|
||||
|
||||
```python
|
||||
app.mount(settings.MEDIA_URL_PREFIX, StaticFiles(directory=str(_media_root)), name="media")
|
||||
```
|
||||
|
||||
**新增** H5 静态挂载(指向仓库根 `h5/`,`html=True` 让目录路径回落到 `index.html`):
|
||||
|
||||
```python
|
||||
_h5_root = Path(__file__).resolve().parent.parent / "h5" # 仓库根 h5/
|
||||
app.mount("/h5", StaticFiles(directory=str(_h5_root), html=True), name="h5")
|
||||
```
|
||||
|
||||
- 访问:`https://app-api.shaguabijia.com/h5/records/index.html`(prod)/ `http://<dev-host>:8770/h5/...`(debug)。
|
||||
- 与 `/api/v1` **同 host → 同源**:H5 内 `SGApi` 用相对 `/api/v1`,免 CORS、免 `getApiBase`。
|
||||
- prod 可选改由 nginx 直发 `h5/`(绕过 uvicorn)以提性能——本期先用 StaticFiles,nginx 优化留后。
|
||||
|
||||
---
|
||||
|
||||
## 5. H5 工程结构
|
||||
|
||||
```
|
||||
h5/
|
||||
├── shared/
|
||||
│ ├── bridge.js # SGBridge:H5↔原生桥(本期补 closePage)
|
||||
│ └── api.js # SGApi:同源相对 /api/v1 封装(已就绪)
|
||||
├── mine/index.html # 我的页(已成型,M4 按真实 SGBridge.kt 校准)
|
||||
├── records/index.html # ← 从安卓迁入(M1)
|
||||
└── reports/index.html # ← 从安卓迁入(M2)
|
||||
```
|
||||
|
||||
各页头部引入:`<script src="../shared/bridge.js"></script>` + `<script src="../shared/api.js"></script>`。
|
||||
|
||||
---
|
||||
|
||||
## 6. records / reports 页迁入(从安卓移植)
|
||||
|
||||
**搬运 + 改桥(迁移、非重写,UI/CSS/逻辑占绝大部分原样保留):**
|
||||
|
||||
| 安卓原写法(NativeBridge) | 迁入后(SGBridge / SGApi) |
|
||||
|---|---|
|
||||
| `NativeBridge.getApiBase()` 拼绝对 URL(8 处) | **删除**,改相对 `/api/v1/...`(同源) |
|
||||
| `NativeBridge.getToken()`(7 处) | `SGBridge.getToken()`(同名) |
|
||||
| `NativeBridge.onUnauthorized()`(6 处) | 走 `SGApi` 内置 401 → `SGBridge.requestLogin()` |
|
||||
| `NativeBridge.closePage()`(5 处) | `SGBridge.closePage()`(**bridge.js 本期新增**) |
|
||||
| 普通 GET/POST | 改用 `SGApi.get/post('/...')`(自动带 token、处理 401) |
|
||||
|
||||
**上报传图(records)特别说明:** 走 `<input type="file">` + 客户端压缩 + **`FormData` multipart** POST `/api/v1/report`。`SGApi` 只发 JSON,**不覆盖 multipart**,故这段**保留自定义 `fetch`**:URL 改相对 `/api/v1/report`、`Authorization` 头取 `SGBridge.getToken()`。文件选择依赖**安卓宿主 `onShowFileChooser`**(见 android 文档),后端无需改动。
|
||||
|
||||
---
|
||||
|
||||
## 7. `shared/bridge.js` 改动
|
||||
|
||||
- **新增 `closePage()`**:`SGBridge.closePage()` → 调 `SGBridgeNative.closePage()`(无桥时 mock `console.log`)。records/reports 顶栏返回箭头、`?from=profile` 返回都用它。
|
||||
- 其余方法(`getToken`/`requestLogin`/`navigate`/`getAuthState`/事件总线…)已就绪,保持签名,供 mine 及后续 tab 使用。
|
||||
- 浏览器无 `SGBridgeNative` 时走 MOCK,便于本地 `python -m http.server` 或 StaticFiles 直接调样式。
|
||||
|
||||
---
|
||||
|
||||
## 8. SGBridge 接口契约(三端共享)
|
||||
|
||||
> 与 android 文档同表;`shared/bridge.js` 为权威。新增方法三端同步。
|
||||
|
||||
**查询类(同步返回 String):** `getAuthState` / `getToken` ✓ / `getDeviceId` / `getAppVersion` / `getInstalledApps` / `getCouponClaimedToday`
|
||||
**动作类(无返回):** `navigate` / `requestLogin` ✓ / `toast` / `openMeituan` / `startCompare` / `startCouponClaim` / `launchApp` / **`closePage`【新增】✓**
|
||||
**事件类(原生→H5,`_emit`):** `onAuthChange` / `onBalanceChange` / `onSigninChange` / `onResume`
|
||||
(✓ = records 阶段 M1 最小子集)
|
||||
|
||||
---
|
||||
|
||||
## 9. 远程开关配置接口
|
||||
|
||||
- 提供一个轻量配置:返回各页渲染方式 `{ records: "h5"|"native", reports: ..., home: ..., welfare: ..., mine: ... }`。
|
||||
- 安卓 `AppNavHost` 据此决定加载 H5 还是原生页,**不发版即可灰度 / 一键回退**。
|
||||
- 复用现有远程配置/开关基建(参考安卓 `ad-config-remote-delivery`、`comparing-ad-remote-flag` 的下发方式),无则新增一个 `GET /api/v1/app/ui-flags` 之类端点。
|
||||
|
||||
---
|
||||
|
||||
## 10. 缓存与热更
|
||||
|
||||
- H5 是后端托管 → **改完即生效**(用户重开页即拉新),这是远程方案的核心收益。
|
||||
- `index.html` 用 `Cache-Control: no-cache`(或短 TTL)保证及时性;`shared/*.js`、图片等带版本/指纹便于长缓存。StaticFiles 可配响应头或交 nginx 处理。
|
||||
|
||||
---
|
||||
|
||||
## 11. 迁移顺序与风险
|
||||
|
||||
| 里程碑 | server 侧动作 |
|
||||
|---|---|
|
||||
| **M1 records** | 挂 `/h5`;`bridge.js` 加 `closePage`;records 迁入 + 改桥;配 `index.html` no-cache |
|
||||
| **M2 reports** | reports 迁入 + 改桥 |
|
||||
| **M3 首页/福利** | 首页/福利页 H5 化(需 SGBridge P2+ 协议,配合安卓) |
|
||||
| **M4 mine + 收尾** | mine 按真实 SGBridge.kt 校准;远程开关接口上线 |
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| SGBridge 原生侧未验证 | M1 用 records 小页验证(详见 android 文档) |
|
||||
| multipart 上传被误套进 SGApi | 文档明确:上报保留自定义 multipart fetch,不走 SGApi |
|
||||
| 同源被破坏(H5 与 API 不同 host) | 坚持 StaticFiles 同源托管;若改独立前端域名,需回评 SGApi 相对路径策略 |
|
||||
| 缓存导致改了不生效 | `index.html` no-cache + 资源指纹 |
|
||||
@@ -46,7 +46,10 @@
|
||||
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
|
||||
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
|
||||
| `total_impressions` | int | 全量展示条数合计 |
|
||||
| `total_revenue_yuan` | float | 全量收益合计(元) |
|
||||
| `total_revenue_yuan` | float | 全量**客户端预估**收益合计(元;eCPM 折算) |
|
||||
| `total_pangle_revenue_yuan` | float \| null | 全量**穿山甲后台预估**收益合计(元;GroMore `revenue`)。穿山甲无用户/类型/场景维度,**仅全量视图**(未按 `user_id`/`ad_type`/`feed_scene` 过滤)有值,否则 `null` |
|
||||
| `total_pangle_api_revenue_yuan` | float \| null | 全量**穿山甲收益Api**合计(元;GroMore `api_revenue`,各 ADN 回传更接近结算);未配 Reporting / 查当天 / 非全量视图为 `null` |
|
||||
| `pangle_revenue_available` | bool | 本次是否带穿山甲后台收益(=全量视图且对应日期已同步到 `ad_pangle_daily_revenue`) |
|
||||
| `total_expected_coin` | int | 全量应发金币合计 |
|
||||
| `total_actual_coin` | int | 全量实发金币合计 |
|
||||
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
|
||||
@@ -57,7 +60,9 @@
|
||||
|---|---|---|
|
||||
| `date` | string | 北京时间 `YYYY-MM-DD` |
|
||||
| `impressions` | int | 当天展示条数合计 |
|
||||
| `revenue_yuan` | float | 当天预估收益合计(元) |
|
||||
| `revenue_yuan` | float | 当天客户端预估收益合计(元;eCPM 折算) |
|
||||
| `pangle_revenue_yuan` | float \| null | 当天穿山甲后台预估收益(元;GroMore `revenue`);非全量视图 / 无数据为 `null` |
|
||||
| `pangle_api_revenue_yuan` | float \| null | 当天穿山甲收益Api(元;GroMore `api_revenue`);未配 / 当天 / 无数据为 `null` |
|
||||
| `expected_coin` | int | 当天应发金币合计 |
|
||||
| `actual_coin` | int | 当天实发金币合计 |
|
||||
|
||||
@@ -125,5 +130,6 @@
|
||||
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
|
||||
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
|
||||
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
|
||||
- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。
|
||||
- **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。
|
||||
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env` 的 `PANGLE_REPORT_*`。
|
||||
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# ad_pangle_daily_revenue — 穿山甲 GroMore 天级收益
|
||||
|
||||
> 模型:[app/models/ad_pangle_revenue.py](../../app/models/ad_pangle_revenue.py) | 读写:[app/repositories/ad_pangle_revenue.py](../../app/repositories/ad_pangle_revenue.py) | 拉取:[app/integrations/pangle_report.py](../../app/integrations/pangle_report.py) + [scripts/sync_pangle_revenue.py](../../scripts/sync_pangle_revenue.py)
|
||||
|
||||
存放从穿山甲 **GroMore 数据 API**(聚合数据报告 API,天级、T+1)按天拉取的收益,供 admin
|
||||
[广告收益报表](../api/admin-ad-revenue-report.md) 的「穿山甲后台收益」做汇总/趋势级展示,
|
||||
与客户端自报 eCPM 折算的预估互为对照(看 gap)。
|
||||
|
||||
**粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)**。穿山甲**不提供分用户/设备维度**
|
||||
(官方明确),故本表无 `user_id`,也无法挂到逐条广告事件;报表逐条行仍用客户端预估。
|
||||
|
||||
## 数据流
|
||||
|
||||
- **写**:`scripts/sync_pangle_revenue`(线上每天 ~10:30 由 systemd timer 跑)调
|
||||
`pangle_report.fetch_daily_report` 拉昨天(可 `--days N` 回补),维度 `date,site_id,ad_unit_id`,
|
||||
映射 `site_id→app_env`、`ad_unit_id→our_code_id` 后 `upsert_daily_rows` 落库。按唯一键幂等,T+1 订正可重跑。
|
||||
⚠️ 用 `ad_unit_id`(广告位ID=104xxx)而非 `code_id`:实测 `code_id` 返回底层各 ADN 代码位(如 `983674557`),对不上我们的口径。
|
||||
- **读**:`ad_pangle_revenue.aggregate_by_date` 按日期汇总 → admin 报表
|
||||
`total_pangle_revenue_yuan` / `total_pangle_api_revenue_yuan` / `daily[].pangle_*`。
|
||||
|
||||
## 字段
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `report_date` | str(10) idx | 北京时间 `YYYY-MM-DD`(拉取 `time_zone=8`),与 `ad_ecpm_record.report_date` 同口径可 join |
|
||||
| `app_env` | str(16) | `prod`(傻瓜比价正式)/ `test`(测试);由 `site_id` 经 `settings.pangle_report_site_id_to_env` 映射;未映射记 `site:<id>` |
|
||||
| `site_id` | str(32) \| null | 原始 GroMore AppId(留痕) |
|
||||
| `our_code_id` | str(64) idx | 广告位 ID(= 接口 `ad_unit_id` = 客户端配的 104xxx = `ad_ecpm_record.our_code_id`),join key。⚠️ **非** `code_id`(那是底层各 ADN 代码位,对不上);`-1` 为 GroMore 未归因桶 |
|
||||
| `adn` | str(16) | 广告源(接口 `network`);当前默认口径不分广告源(按 `ad_unit_id` 汇总),统一 `""` |
|
||||
| `revenue_yuan` | float | 预估收益(元)← 接口 `revenue`(排序价×展示/1000,所有 ADN) |
|
||||
| `api_revenue_yuan` | float \| null | 收益Api(元)← 接口 `api_revenue`(各 ADN 经 Reporting 回传、更接近结算);未配 / 当天为 `null` |
|
||||
| `ecpm` | str(32) \| null | 预估 eCPM 原值(接口 `ecpm`,单位**元/千次**,与客户端 getEcpm 的「分」不同) |
|
||||
| `impressions` | int | 展示次数 ← 接口 `imp_cnt` |
|
||||
| `currency` | str(8) | 货币(接口 `currency`,正常 `cny`) |
|
||||
| `synced_at` | datetime | 最近同步写入时间(可被回补覆盖) |
|
||||
|
||||
唯一约束 `uq_ad_pangle_daily (report_date, app_env, our_code_id, adn)`;`adn` 用 `""` 而非
|
||||
NULL,避免 NULL 在唯一约束里互不相同导致 upsert 重复。
|
||||
|
||||
## 局限
|
||||
|
||||
- **T+1**:当天数据穿山甲不出 `api_revenue`,`revenue` 也次日才稳;报表「今天」此两列多为空。
|
||||
- **`revenue` vs `api_revenue`**:前者所有 ADN 都有(预估口径);后者需后台为该 ADN 配置 Reporting
|
||||
才回传,部分 ADN 可能长期为空。结算仍以各 ADN 结算单 / 穿山甲后台为准。
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 664 KiB |
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 773 KiB |
|
After Width: | Height: | Size: 695 KiB |
|
After Width: | Height: | Size: 732 KiB |
|
After Width: | Height: | Size: 778 KiB |
|
After Width: | Height: | Size: 960 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 975 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,34 @@
|
||||
<svg width="172" height="148" viewBox="0 0 172 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="buttonShadow" x="0" y="0" width="172" height="148" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="7" flood-color="#D79A1E" flood-opacity=".22"/>
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#FFFFFF" flood-opacity=".88"/>
|
||||
</filter>
|
||||
<linearGradient id="buttonFill" x1="43" y1="16" x2="126" y2="135" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFCE3"/>
|
||||
<stop offset=".38" stop-color="#FFF2A8"/>
|
||||
<stop offset=".72" stop-color="#FFE27A"/>
|
||||
<stop offset="1" stop-color="#FFD15A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="buttonStroke" x1="35" y1="11" x2="136" y2="139" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFF7C2"/>
|
||||
<stop offset=".48" stop-color="#FFE484"/>
|
||||
<stop offset="1" stop-color="#EAB237"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="buttonGlow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(70 44) rotate(62) scale(86 66)">
|
||||
<stop stop-color="#FFFFFF" stop-opacity=".86"/>
|
||||
<stop offset=".58" stop-color="#FFFFFF" stop-opacity=".24"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="arrowStroke" x1="50" y1="49" x2="125" y2="98" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFAE16"/>
|
||||
<stop offset="1" stop-color="#F08300"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g filter="url(#buttonShadow)">
|
||||
<path d="M45 13H127C151.8 13 166 35.8 166 62V86C166 112.2 151.8 135 127 135H45C20.2 135 6 112.2 6 86V62C6 35.8 20.2 13 45 13Z" fill="url(#buttonFill)" stroke="url(#buttonStroke)" stroke-width="3"/>
|
||||
<path d="M48 19H124C146.1 19 159 38.7 159 63V84C159 108.3 146.1 128 124 128H48C25.9 128 13 108.3 13 84V63C13 38.7 25.9 19 48 19Z" fill="url(#buttonGlow)"/>
|
||||
<path d="M58 51L81 74L58 97" stroke="url(#arrowStroke)" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M93 51L116 74L93 97" stroke="url(#arrowStroke)" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 621 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 917 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 899 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 899 KiB |
|
After Width: | Height: | Size: 482 KiB |
@@ -0,0 +1,43 @@
|
||||
# Shared Icons
|
||||
|
||||
通用图标的单一事实源(canonical source)。每个图标都是独立 SVG 文件,但**实际页面里必须内联 SVG markup**(不用 `<img src>` 或 `<svg src>` 引用),符合 prototypes 仓库"每个 HTML 自包含"原则。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 改图标 → 改这里的 SVG 文件
|
||||
2. 在所有用到的 HTML 里同步替换内联 SVG markup
|
||||
3. DESIGN.md 引用本目录路径作为基准
|
||||
|
||||
## 现有图标
|
||||
|
||||
| 文件 | 用途 | 推荐内联尺寸 |
|
||||
|---|---|---|
|
||||
| `back-chevron.svg` | 顶部导航返回按钮 | 24×24 |
|
||||
| `tab-home-outline.svg` | 底 tab "首页" 未选中态 | 24×24 |
|
||||
| `tab-home-filled.svg` | 底 tab "首页" 选中态(黄填充)| 24×24 |
|
||||
| `tab-welfare-outline.svg` | 底 tab "福利" 未选中态 | 24×24 |
|
||||
| `tab-welfare-filled.svg` | 底 tab "福利" 选中态(黄填充)| 24×24 |
|
||||
| `tab-profile-outline.svg` | 底 tab "我的" 未选中态 | 24×24 |
|
||||
| `tab-profile-filled.svg` | 底 tab "我的" 选中态(黄填充)| 24×24 |
|
||||
|
||||
## 标准 button 包装
|
||||
|
||||
```html
|
||||
<button class="btn-back" onclick="goBack()" aria-label="返回">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 6L9 12L15 18" stroke="#1A1A1A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
```css
|
||||
.btn-back {
|
||||
width: 44px; height: 44px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: none; padding: 0;
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-back:active { opacity: 0.5; }
|
||||
.btn-back svg { width: 24px; height: 24px; display: block; }
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 6L9 12L15 18" stroke="#1A1A1A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12l9-8 9 8" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9" fill="#FFD600" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 368 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12l9-8 9 8"/>
|
||||
<path d="M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="8" r="4" fill="#FFD600" stroke="#1A1A1A" stroke-width="2"/>
|
||||
<path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="#FFD600" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="8" r="4"/>
|
||||
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="8" width="18" height="4" rx="1" fill="#FFD600" stroke="#1A1A1A" stroke-width="2"/>
|
||||
<path d="M5 12v7a1 1 0 001 1h12a1 1 0 001-1v-7" fill="#FFD600" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 8v12" stroke="#1A1A1A" stroke-width="2"/>
|
||||
<path d="M7.5 8C6 8 5 6.5 6 5.5S9 4 9.5 5.5c.4 1.2 2.5 2.5 2.5 2.5s2.1-1.3 2.5-2.5C15 4 17 4.5 18 5.5s-1 2.5-2.5 2.5" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 611 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="8" width="18" height="4" rx="1"/>
|
||||
<path d="M12 8v12"/>
|
||||
<path d="M5 12v7a1 1 0 001 1h12a1 1 0 001-1v-7"/>
|
||||
<path d="M7.5 8C6 8 5 6.5 6 5.5S9 4 9.5 5.5c.4 1.2 2.5 2.5 2.5 2.5s2.1-1.3 2.5-2.5C15 4 17 4.5 18 5.5s-1 2.5-2.5 2.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 432 B |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 627 KiB |
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<clipPath id="appIconClip">
|
||||
<rect x="0" y="0" width="512" height="512" rx="105" ry="105"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="512" height="512" rx="105" ry="105" fill="rgb(255, 217, 61)"/>
|
||||
<image href="sb-brand.png" x="0" y="0" width="512" height="512" preserveAspectRatio="none" clip-path="url(#appIconClip)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 912 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1009 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 515 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 183 KiB |