Compare commits

...

27 Commits

Author SHA1 Message Date
陈世睿 25c9f6aaaf Merge branch 'feat/h5-sgbridge' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat/h5-sgbridge 2026-07-02 20:28:20 +08:00
guke 0db5a798cd fix(dl): 落地页底图+logo 纳入 git → 修生产 /media 404 毛坯 (#97) (#106)
Co-authored-by: guke <guke@autohome.com.cn>
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Reviewed-on: #106
2026-07-02 17:44:56 +08:00
陈世睿 ea0c563680 福利模块 H5:赚钱中心壳(资产卡/签到/任务行+welfareTask桥接契约)+ 收益明细 + 活动规则,素材取自原型
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:02:49 +08:00
陈世睿 3057955ea4 修复 H5 顶栏莫名下移22px:清除 head 里字体注入残留的 \x01 控制字符(5页),并统一去掉 min-height:100vh
:
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:02:25 +08:00
陈世睿 43f6f16a61 Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat/h5-sgbridge 2026-07-01 10:26:07 +08:00
zhuzihao 357b2312af chore(deploy): 穿山甲收益拉取 systemd 定时器(每天10:30)+ 运维手册 (#100)
#92 的 scripts/sync_pangle_revenue.py 补 systemd 部署单元,让穿山甲 GroMore T+1 后台收益每天自动拉取入库,免人工敲命令。纯部署/文档文件,不改运行时代码。

- service:oneshot,跑 `--days 3` 回补近 3 天(幂等 upsert,扛偶发漏跑与穿山甲历史订正);10min 硬超时;加固块对齐主服务。
- timer:每天 10:30 触发(穿山甲约 10:00 出数留余量),Persistent 宕机后补跑。
- md:运维手册——上线前置(.env 填 PANGLE_REPORT_* 凭证、子账号需授权否则接口 118)、部署/健康检查/参数/注意事项(join key 用 ad_unit_id 非 code_id 等)。
- 风格对齐现有 deploy/daily-exchange.* 与 deploy/meituan-etl.*。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #100
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 22:59:41 +08:00
zhuzihao 78d970f420 feat: 领券数据看板 — coupon_session 流水表 + admin 聚合接口 (#99)
- 新表 coupon_session(一次领券一行,trace_id 唯一):POST /api/v1/coupon/session
  两段 upsert(发起 started / 收尾 completed·failed·abandoned),记全程耗时、各平台耗时、
  机型/ROM、app_env、trace_url、origin_package(发起来源)。
- admin GET /admin/api/coupon-data:发起/完成数 + 平均耗时 + P5/P50/P95/P99(Python 算分位,
  SQLite 无 percentile)+ 按天/小时趋势 + 明细分页 + join 用户手机号/昵称,app_env 默认 prod;
  另加 GET /admin/api/coupon-data/user-records 供「点手机号看该用户全部领券」抽屉。
- 迁移拆 3 个:建表 coupon_session_table + trace_url 加列 + origin_package 加列
  (建表迁移已被某环境应用后改它不重跑,故新列单独加列迁移)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #99
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 22:59:36 +08:00
chenshuobo a3a14b484a feat: 接入京东联盟订单到数据大盘 (#90)
改了什么:新增京东联盟订单查询客户端、cps_order 京东字段迁移、CPS 对账接口 platform=all/jd、数据大盘京东订单与佣金聚合。
为什么改:数据大盘需要展示京东 CPS 真实订单佣金,不再保持占位。
验证方式:python -m pytest tests\\test_admin_read.py::test_dashboard_overview tests\\test_cps_admin.py::test_jd_reconcile_updates_dashboard -q;本地真实拉取 2026-06-27 京东订单 3 行,页面显示京东有效 1 单/佣金 ¥0.13。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #90
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-30 21:56:58 +08:00
liujiahui 208112ff24 fix(dl): 落地页底图+logo 纳入 git → 修生产 /media 404 毛坯 (#97)
## 背景
用户反馈浏览器落地页仍是「毛坯」(裸黄底、缺优惠券卡/价格卡/logo)。

## 真因(实测线上)
`https://app-api.shaguabijia.com/media/dl.html` 的 **HTML 早已是完善版且已部署**,但它引用的两张静态图被 `.gitignore` 的 `data/media/*` 规则挡在库外、从未随部署进生产 `/media`:

| 资源 | 线上 | 本地/git |
|---|---|---|
| `/media/coupon-page-bg.png`(底图:优惠券卡+跨平台比价价格卡) | **404**(22B `{"detail":"Not Found"}`) | 之前被 gitignore |
| `/media/sb-brand.png`(logo) | **404** | 之前被 gitignore |
| `/media/shaguabijia.apk`(对照) | 200 ✓ | — |

→ 两图加载失败 → 页面回退裸黄底 = 毛坯。

## 改动
照 `dl.html` / `taobao_landing.jpg` 既有白名单写法,把这两张图加进 `.gitignore` 例外并入库。
- `.gitignore`:`!data/media/coupon-page-bg.png`、`!data/media/sb-brand.png`
- 入库两图,md5 与原型一致(coupon-page-bg `f2adf28a` / sb-brand `e5b6e004`)

**无代码/HTML 改动**(dl.html 本就对齐原型,见上一 commit `6d4cbc8`)。

## 部署后生效
合并并部署本分支后,生产 `/media/` 即含这两图,落地页恢复完整。对应 6-29 UI 对齐 #10。

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #97
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-30 20:48:31 +08:00
陈世睿 667cbdf8ad Merge remote-tracking branch 'origin/feat/h5-sgbridge' into feat/h5-sgbridge 2026-06-30 20:05:47 +08:00
陈世睿 7ac3ca8fd6 新增设置页与相关协议 H5(头像底部 sheet+裁剪、昵称/退出/注销弹窗、页面不滚动、手势条避让)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:02:49 +08:00
陈世睿 b81ba70235 新增意见反馈、反馈历史 H5 页(去框 + 接 /api/v1/feedback,真机对齐)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:02:49 +08:00
陈世睿 85523a99d2 全 H5 页中文字体改用阿里普惠体真字模,移除 text-shadow 伪粗体兜底
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:02:48 +08:00
陈世睿 a076bc9a2c Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat/h5-sgbridge 2026-06-30 15:07:46 +08:00
zhuzihao 32f300b5a2 调现金接口支持目标账户(金币现金 / 邀请奖励金)+ overview 带邀请余额 (#95)
- GrantCashRequest 加 account(coin_cash / invite_cash,默认 coin_cash 兼容旧调用)
- 调现金接口按 account 分支:邀请账户走 grant_invite_cash,余额读取/扣负保护/审计
  detail 都按对应账户走;两本账物理隔离不可串
- 用户 360 overview 返回 invite_cash_balance_cents(admin 调现金弹窗展示邀请余额)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #95
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 12:02:56 +08:00
guke 8e3b282c79 feat(h5): 首页 H5 壳从定稿原型抽取(#home 满屏,静态 mock,零桥) (#96)
首页webview改造合并

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #96
2026-06-30 11:57:47 +08:00
陈世睿 4d730169bf 真机验收修复:我的页去框沉浸式 + 三页字体加粗 + 卡片细节
我的页(h5/mine)整页去框满屏 + 顶部状态栏沉浸式(云朵铺到状态栏下/隐藏假状态栏/内容 safe-area/头像对齐原型);三页统一注入 faux-bold(安卓 WebView 中文回退字体不合成粗体兜底);上报最低价选记录态列表间距去框对齐(top 198→154);上报记录卡平台改主名+主logo(美团/京东/淘宝)+ 菜品名 2 行截断

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:52:07 +08:00
陈世睿 cdfe66dcee 接入上报记录列表 H5(第三个 session 的孤儿页,补提交整合)
h5/reports/index.html(审核中/已通过/未通过三态+计数,真接 /api/v1/report/records)+ logos;android REPORT_RECORDS 路由稍后接 H5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:13:21 +08:00
陈世睿 8d08986c00 合并:上报最低价 H5 2026-06-29 17:12:53 +08:00
陈世睿 be77ffa721 我的页迁远程 H5
h5/mine/index.html改用SGBridge.getAuthState/navigate(同源相对API);拷入页面静态资源h5/mine/assets/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:12:08 +08:00
陈世睿 0bed761c00 上报最低价流程对齐原型/原生 + 接桥
toast改感谢您的反馈/D校验文案/提交失败解析后端detail/模拟键盘补小数点后2位上限/取消改closePage真返回/上报记录入口接SGBridge.navigate(reportRecords)/头部未读红点默认隐藏

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:12:00 +08:00
guke 109aebaa16 chore(scripts): records 页比价记录 mock 数据脚本(真机验收用,幂等)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:33:35 +08:00
guke 84ae950ea7 feat(h5): records 页迁入 server 改用 SGBridge(同源相对 API,保留 multipart 上传)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:14:18 +08:00
guke 122766c911 feat(h5): SGBridge 新增 closePage(返回上一页)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:05:33 +08:00
guke cee4f3e0a7 fix(h5): no-cache 按响应 content-type 判定,覆盖目录式 URL(/h5/x/)
html=True 时 /h5/x/ 经 Starlette 回落 index.html,但 path 仍为 x/ 不带 .html,旧判定漏设 no-cache。改用响应 content-type=text/html 判定,并加目录式 URL 回归测试。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:03:36 +08:00
guke 4fb8f6447c feat(h5): FastAPI StaticFiles 同源托管 /h5(.html no-cache)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:51:42 +08:00
guke 46c650ecb8 docs(h5): 新增 H5/WebView 改造方案(方案1 SGBridge,服务端托管)
由 app-server 同源托管业务 H5(StaticFiles 挂 /h5);records/reports 从安卓迁入改用 SGBridge/SGApi;bridge.js 补 closePage;后端业务接口零改动;含远程开关、缓存热更、M1-M4 里程碑。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:14:15 +08:00
191 changed files with 17714 additions and 189 deletions
+7
View File
@@ -58,6 +58,13 @@ MT_CPS_DEFAULT_SID=sgbjia
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
MT_CPS_PROXY=
# ===== 京东联盟 CPS =====
# 京东联盟/京东宙斯开放平台创建应用后填写。AUTH_KEY 是工具商授权 key,自有应用可留空。
JD_UNION_APP_KEY=
JD_UNION_APP_SECRET=
JD_UNION_SITE_ID=
JD_UNION_AUTH_KEY=
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
+5 -2
View File
@@ -23,14 +23,17 @@ dist/
*.db-journal
*.db-shm
*.db-wal
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html 及其
# 引用的静态插画(coupon-page-bg.png 底图 + sb-brand.png logo)——既是生产落地页资产
# 又是本地测试资产,纳入 git 便于同事一致测试、且随部署进生产 /media(否则线上 404→落地页毛坯)。
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
data/*
!data/media/
data/media/*
!data/media/dl.html
!data/media/taobao_landing.jpg
!data/media/coupon-page-bg.png
!data/media/sb-brand.png
secrets/*
!secrets/.gitkeep
@@ -0,0 +1,26 @@
"""merge jd_cps_order_fields and coupon_session_origin_package heads
Revision ID: 761ef181ce7c
Revises: coupon_session_origin_package, jd_cps_order_fields
Create Date: 2026-07-01 13:52:16.068808
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '761ef181ce7c'
down_revision: Union[str, Sequence[str], None] = ('coupon_session_origin_package', 'jd_cps_order_fields')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,32 @@
"""coupon_session 加 origin_package 列(发起来源 App 包名 → admin「发起平台」)
null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。与 trace_url 同理单独成迁移,
已建表环境靠它补列、全新环境顺序应用,不重复加列。
Revision ID: coupon_session_origin_package
Revises: coupon_session_trace_url
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_origin_package"
down_revision: str | Sequence[str] | None = "coupon_session_trace_url"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("origin_package", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("origin_package")
+81
View File
@@ -0,0 +1,81 @@
"""coupon session table(领券任务全程流水 → admin「领券数据」看板数据源)
一次领券一行(trace_id 唯一):客户端 POST /api/v1/coupon/session 两段上报 —— 发起建行
(status=started)、收尾(completed/failed/abandoned)按 trace_id 更新同一行。记全程耗时
elapsed_ms + 各平台耗时 platform_elapsed + 机型/ROM,供 admin 算发起/完成数、耗时分位、机型维度。
Revision ID: coupon_session_table
Revises: feedback_submit_env
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_table"
down_revision: str | Sequence[str] | None = "feedback_submit_env"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant)。
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
op.create_table(
"coupon_session",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("app_env", sa.String(length=16), nullable=True),
sa.Column("platforms", _JSON, nullable=True),
sa.Column("device_model", sa.String(length=128), nullable=True),
sa.Column("rom", sa.String(length=64), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("started_date", sa.Date(), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("elapsed_ms", sa.Integer(), nullable=True),
sa.Column("platform_elapsed", _JSON, nullable=True),
sa.Column("claimed_count", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
)
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_session_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_session_app_env"), ["app_env"], unique=False
)
# admin 主聚合/筛选:按上海自然日 + 环境。
batch_op.create_index(
"ix_coupon_session_date_env", ["started_date", "app_env"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_index("ix_coupon_session_date_env")
batch_op.drop_index(batch_op.f("ix_coupon_session_app_env"))
batch_op.drop_index(batch_op.f("ix_coupon_session_user_id"))
op.drop_table("coupon_session")
@@ -0,0 +1,32 @@
"""coupon_session 加 trace_url 列(pricebot done 帧公网调试链接)
建表迁移 coupon_session_table 落地后才追加本列,故单独成一个迁移:已建表的环境(本地/已 upgrade 过)
靠它补列,全新环境则「建表(无 trace_url)→ 本迁移加列」,两条路一致、不重复加列。
Revision ID: coupon_session_trace_url
Revises: coupon_session_table
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_trace_url"
down_revision: str | Sequence[str] | None = "coupon_session_table"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("trace_url", sa.String(length=512), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("trace_url")
+56
View File
@@ -0,0 +1,56 @@
"""add jd cps order fields
Revision ID: jd_cps_order_fields
Revises: 7db22acee504
Create Date: 2026-06-28 20:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "jd_cps_order_fields"
down_revision = "7db22acee504"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.add_column(
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan")
)
batch_op.add_column(sa.Column("external_order_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("external_row_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("estimated_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("actual_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("jd_valid_code", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("settle_month", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("site_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("position_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("pid", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("sub_union_id", sa.String(length=128), nullable=True))
batch_op.create_index("ix_cps_order_platform", ["platform"])
batch_op.create_index("ix_cps_order_external_order_id", ["external_order_id"])
batch_op.create_index("ix_cps_order_external_row_id", ["external_row_id"])
batch_op.create_index("ix_cps_order_jd_valid_code", ["jd_valid_code"])
def downgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.drop_index("ix_cps_order_jd_valid_code")
batch_op.drop_index("ix_cps_order_external_row_id")
batch_op.drop_index("ix_cps_order_external_order_id")
batch_op.drop_index("ix_cps_order_platform")
batch_op.drop_column("sub_union_id")
batch_op.drop_column("pid")
batch_op.drop_column("position_id")
batch_op.drop_column("site_id")
batch_op.drop_column("settle_month")
batch_op.drop_column("jd_valid_code")
batch_op.drop_column("actual_commission_cents")
batch_op.drop_column("estimated_commission_cents")
batch_op.drop_column("external_row_id")
batch_op.drop_column("external_order_id")
batch_op.drop_column("platform")
+2
View File
@@ -21,6 +21,7 @@ from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
from app.admin.routers.comparison import router as comparison_router
from app.admin.routers.config import router as config_router
from app.admin.routers.coupon_data import router as coupon_data_router
from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.device_liveness import router as device_liveness_router
@@ -101,6 +102,7 @@ admin_app.include_router(audit_router)
admin_app.include_router(config_router)
admin_app.include_router(comparison_router)
admin_app.include_router(cps_router)
admin_app.include_router(coupon_data_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_config_router)
admin_app.include_router(ad_revenue_router)
+7 -1
View File
@@ -136,12 +136,16 @@ def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
"""该信息流记录是否落入请求的展示筛选 scene。
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
- scene=="draw":ad_type=="draw"
- scene=="feed_all":所有信息流(feed/draw/NULL 都要)——业务已全切 Draw 信息流,收益报表把「Draw 信息流」
当作整个信息流口径(含历史误标 feed/NULL),用它避免筛选漏历史。
- scene 为 None:不筛(两类都要)。
"""
if scene == "feed":
return rec.ad_type in (None, "feed")
if scene == "draw":
return rec.ad_type == "draw"
if scene == "feed_all":
return True
return True
@@ -184,6 +188,7 @@ def _feed_rows(
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -209,6 +214,7 @@ def _feed_rows(
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -241,7 +247,7 @@ def audit_rows(
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed", "draw"):
if scene in (None, "feed", "draw", "feed_all"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
+105 -35
View File
@@ -3,9 +3,11 @@
只读。每行 = 一次广告事件(不再按用户聚合):
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
与逐条展示无法对应,单独成「纯发奖」行。
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
@@ -58,14 +60,6 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
def _event_ad_type(row: dict) -> str:
"""纯发奖事件行的 ad_type:信息流行用 audit 带回的真实 ad_type(feed/draw),回退 feed;
激励视频行恒 reward_video。不再用 scene 硬映射,避免把 draw 丢成 feed。"""
if row["scene"] == "reward_video":
return "reward_video"
return row.get("ad_type") or "feed"
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
@@ -108,9 +102,14 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
# 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被
# 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed
audit_scene = ad_type if ad_type in _AUDIT_SCENES else None
# 报表 ad_type audit scene:reward_video/feed 直传;**draw(前端「Draw 信息流」)映射成 feed_all**
# ——业务已全切 Draw,把「Draw 信息流」当作整个信息流口径(含历史误标 feed/NULL),否则筛选会漏历史
if ad_type == "draw":
audit_scene = "feed_all"
elif ad_type in _AUDIT_SCENES:
audit_scene = ad_type
else:
audit_scene = None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
@@ -140,7 +139,10 @@ def ad_revenue_report(
)
if user_id is not None:
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
if ad_type is not None:
if ad_type == "draw":
# draw = 所有信息流展示(业务已全 Draw,含历史误标 feed);展示行只进统计,不占主表行
stmt = stmt.where(AdEcpmRecord.ad_type.in_(["draw", "feed"]))
elif ad_type is not None:
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
for rec in db.execute(stmt).scalars():
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
@@ -165,6 +167,8 @@ def ad_revenue_report(
),
"adn": rec.adn,
"slot_id": rec.slot_id,
"sub_rewards": [],
"sub_count": 1,
}
if rwd is not None:
ev.update({
@@ -184,33 +188,88 @@ def ad_revenue_report(
})
events.append(ev)
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
# 3) 未被展示合并的发奖行 → 事件:
# - 激励视频(reward_video):逐条成「纯发奖」事件(每次一个 ad_session_id;有发奖无展示等)。
# - 信息流(feed/draw):同一次比价/领券的多条广告共享**整场 ad_session_id**(客户端整场复用),
# 按 (user_id, ad_session_id) 聚成**一次比价 / 一次领券**父事件;sub_rewards 为组内逐条明细,
# 应发/实发取组内合计;业务已全 Draw → 类型统一 "draw"。session 缺失(极少旧数据)各自单独成组。
feed_groups: dict[tuple[int, str], list[dict]] = {}
for row in all_reward_rows:
if row["record_id"] in used_reward_ids:
continue
if row["scene"] == "reward_video":
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": "reward_video",
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"revenue_yuan": 0.0,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
"sub_rewards": [],
"sub_count": 1,
})
else:
# 聚合单位 = 一次完整比价/领券流程:优先用 trace_id(比价带 comparisonTraceId、领券带 sessionTraceId,
# 整个流程不变;即使中途点广告致浮层关闭重弹、ad_session_id 变了,trace_id 仍不变 → 全流程聚成一行)。
# 无 trace_id(历史领券未上报 / 旧数据)回退整场 ad_session_id;再无则 record_id 各自成组、不误并。
grp_key = row.get("trace_id") or row.get("ad_session_id") or f"_rid-{row['record_id']}"
feed_groups.setdefault((row["user_id"], grp_key), []).append(row)
# 信息流分组 → 「一次比价 / 一次领券」父事件(收益恒 0:收益只算展示侧,避免与展示行重复计)。
for (uid, grp_key), group in feed_groups.items():
group.sort(key=lambda r: (r["created_at"], r["record_id"]))
rep = group[-1] # 代表条(最新一条):时间/场景/应用/代码位取它
expected_sum = sum(int(g["expected_coin"]) for g in group)
actual_sum = sum(int(g["actual_coin"]) for g in group)
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算,钳顶同展示侧)。只放进
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
row_revenue = round(sum(
min(rewards.parse_ecpm_yuan(g["ecpm"]), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0
for g in group if g.get("ecpm")
), 6)
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": _event_ad_type(row),
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"event_key": f"feedgrp-{uid}-{grp_key}",
"report_date": rep["_report_date"],
"user_id": uid,
"ad_type": "draw", # 业务已全切 Draw 信息流,聚合行统一 draw
"feed_scene": rep.get("feed_scene"),
"app_env": rep.get("app_env"),
"our_code_id": rep.get("our_code_id"),
"created_at": rep["created_at"],
"hour": _cn_hour(rep["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"ecpm": avg_ecpm,
"revenue_yuan": 0.0,
"row_revenue_yuan": row_revenue,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
"status": rep["status"], # 代表状态(逐条见展开)
"expected_coin": expected_sum,
"actual_coin": actual_sum,
"matched": all(bool(g["matched"]) for g in group),
"reward_detail": None,
"sub_rewards": [_reward_detail(g) for g in group],
"sub_count": len(group),
})
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
@@ -331,9 +390,20 @@ def ad_revenue_report(
is_today = date_from == date_to == rewards.cn_today().isoformat()
dau = admin_stats.today_dau(db) if is_today else None
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
# 信息流(draw/feed)的逐条展示(ad_ecpm,impressionId 各自独立、与整场发奖无公共键)不再单独占行
# ——其展示数 / eCPM / 预估收益已计入上面的全量统计(total_*、daily / hourly、type_stats、穿山甲对照),
# 只是主表不逐条铺开;逐条明细在父行展开里看(sub_rewards)。合计 / 趋势 / 分类大盘均基于全量 events,
# 不受此过滤影响;total / 分页只作用于主表行。
main_rows = [
e for e in events
if not (e["ad_type"] in ("draw", "feed") and e["has_impression"] and not e["has_reward"])
]
return {
"total": len(events),
"truncated": len(events) > offset + limit,
"total": len(main_rows),
"truncated": len(main_rows) > offset + limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
@@ -347,5 +417,5 @@ def ad_revenue_report(
"hourly": hourly,
"type_stats": type_stats,
"dau": dau,
"items": events[offset:offset + limit],
"items": main_rows[offset:offset + limit],
}
+225
View File
@@ -0,0 +1,225 @@
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
"""
from __future__ import annotations
from datetime import UTC, date as _date, datetime
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponSession
from app.models.user import User
def _cn_hour(dt: datetime) -> int:
"""started_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).hour
def _percentile(sorted_vals: list[int], q: float) -> int | None:
"""线性插值分位(q=0..100,numpy 默认法)。sorted_vals 须已升序;空返回 None。"""
if not sorted_vals:
return None
if len(sorted_vals) == 1:
return sorted_vals[0]
idx = (len(sorted_vals) - 1) * q / 100.0
lo = int(idx)
hi = min(lo + 1, len(sorted_vals) - 1)
frac = idx - lo
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
def _avg(vals: list[int]) -> int | None:
return round(sum(vals) / len(vals)) if vals else None
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
"id": r.id,
"trace_id": r.trace_id,
"user_id": r.user_id,
"user_phone": phone,
"user_nickname": nickname,
"status": r.status,
"platforms": r.platforms,
"origin_package": r.origin_package,
"elapsed_ms": r.elapsed_ms,
"platform_elapsed": r.platform_elapsed,
"device_model": r.device_model,
"rom": r.rom,
"app_env": r.app_env,
"started_at": r.started_at,
"claimed_count": r.claimed_count,
"trace_url": r.trace_url,
}
def _empty_result() -> dict:
return {
"summary": {
"started_count": 0, "completed_count": 0, "avg_elapsed_ms": None,
"p5_ms": None, "p50_ms": None, "p95_ms": None, "p99_ms": None,
},
"daily": [],
"hourly": [],
"total": 0,
"items": [],
}
def coupon_data_report(
db: Session,
*,
date_from: str,
date_to: str,
user: str | None = None,
app_env: str | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
sort: str = "time",
) -> dict:
"""日期区间(北京自然日 started_date,闭区间)领券数据:汇总卡 + 趋势 + 逐条明细。
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
- app_env:prod/dev 精确;None=全部。
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
"""
by_hour = granularity == "hour"
d_from = _date.fromisoformat(date_from)
d_to = _date.fromisoformat(date_to)
# user 模糊 → 先定位匹配用户 id;匹配不到直接空结果(不全表扫)。
user_ids: set[int] | None = None
if user:
like = f"%{user}%"
user_ids = set(db.execute(
select(User.id).where(or_(User.phone.like(like), User.nickname.like(like)))
).scalars().all())
if not user_ids:
return _empty_result()
stmt = select(CouponSession).where(
CouponSession.started_date >= d_from,
CouponSession.started_date <= d_to,
)
if app_env is not None:
stmt = stmt.where(CouponSession.app_env == app_env)
if user_ids is not None:
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
rows = list(db.execute(stmt).scalars())
# ── 汇总卡 ──
completed_elapsed = sorted(
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
)
summary = {
"started_count": len(rows),
"completed_count": sum(1 for r in rows if r.status == "completed"),
"avg_elapsed_ms": _avg(completed_elapsed),
"p5_ms": _percentile(completed_elapsed, 5),
"p50_ms": _percentile(completed_elapsed, 50),
"p95_ms": _percentile(completed_elapsed, 95),
"p99_ms": _percentile(completed_elapsed, 99),
}
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
daily_map: dict[str, dict] = {}
for r in rows:
d = r.started_date.isoformat()
b = daily_map.get(d)
if b is None:
b = {"date": d, "started_count": 0, "completed_count": 0, "_elapsed": []}
daily_map[d] = b
b["started_count"] += 1
if r.status == "completed":
b["completed_count"] += 1
if r.elapsed_ms is not None:
b["_elapsed"].append(r.elapsed_ms)
daily = [
{
"date": b["date"],
"started_count": b["started_count"],
"completed_count": b["completed_count"],
"avg_elapsed_ms": _avg(b["_elapsed"]),
}
for b in sorted(daily_map.values(), key=lambda x: x["date"])
]
# ── 按小时趋势(单日 hour 粒度)──
hourly: list[dict] = []
if by_hour:
hour_map: dict[int, dict] = {}
for r in rows:
h = _cn_hour(r.started_at)
b = hour_map.get(h)
if b is None:
b = {"hour": h, "started_count": 0, "completed_count": 0, "_elapsed": []}
hour_map[h] = b
b["started_count"] += 1
if r.status == "completed":
b["completed_count"] += 1
if r.elapsed_ms is not None:
b["_elapsed"].append(r.elapsed_ms)
hourly = [
{
"hour": b["hour"],
"started_count": b["started_count"],
"completed_count": b["completed_count"],
"avg_elapsed_ms": _avg(b["_elapsed"]),
}
for b in sorted(hour_map.values(), key=lambda x: x["hour"])
]
# ── 明细:排序 + 分页 + 补用户手机号/昵称(批量,防 N+1)──
if sort == "elapsed":
rows.sort(key=lambda r: (r.elapsed_ms is None, -(r.elapsed_ms or 0)))
else: # time:发起时刻倒序
rows.sort(key=lambda r: r.started_at, reverse=True)
page = rows[offset:offset + limit]
uids = {r.user_id for r in page if r.user_id is not None}
user_map: dict[int, tuple[str | None, str | None]] = {}
if uids:
user_map = {
uid: (phone, nickname)
for uid, phone, nickname in db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
).all()
}
items = []
for r in page:
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
items.append(_session_to_row(r, phone, nickname))
return {
"summary": summary,
"daily": daily,
"hourly": hourly,
"total": len(rows),
"items": items,
}
def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
"""某用户全部领券记录(点手机号抽屉用):按发起时刻倒序、不限日期,total=该用户领券总次数。"""
rows = list(db.execute(
select(CouponSession)
.where(CouponSession.user_id == user_id)
.order_by(CouponSession.started_at.desc())
.limit(limit)
).scalars())
total = db.execute(
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
).scalar_one()
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
+166 -1
View File
@@ -7,13 +7,14 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import uuid4
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
from app.integrations import meituan
from app.integrations import jd_union, meituan
from app.repositories import cps_link as cps_link_repo
from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
@@ -24,6 +25,11 @@ from app.models.cps_wx_user import CpsWxUser
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
_SETTLED_STATUS = "6"
_JD_INVALID_CODES = {
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
}
_JD_UNPAID_CODES = {"15"}
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
_BJ_TZ = timezone(timedelta(hours=8))
@@ -47,6 +53,36 @@ def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if not ts:
return None
def _jd_dt_to_utc(value: object) -> datetime | None:
"""京东时间字符串(北京时间) → UTC aware datetime。"""
if value is None:
return None
s = str(value).strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
dt = datetime.strptime(s, fmt)
return dt.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
except ValueError:
continue
return None
def _text(value: object) -> str | None:
if value is None:
return None
s = str(value).strip()
return s or None
def _pick(row: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in row and row[key] is not None:
return row[key]
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
except (ValueError, OSError, TypeError):
@@ -249,6 +285,80 @@ def _map_order_fields(r: dict) -> dict:
}
def _jd_order_key(r: dict[str, Any]) -> str | None:
row_id = _text(_pick(r, "id", "rowId", "orderRowId"))
if row_id:
return f"jd:{row_id}"
order_id = _text(_pick(r, "orderId", "parentOrderId"))
sku_id = _text(_pick(r, "skuId"))
if order_id and sku_id:
return f"jd:{order_id}:{sku_id}"
if order_id:
return f"jd:{order_id}"
return None
def _map_jd_order_fields(r: dict[str, Any]) -> dict:
"""京东 order.row.query 单条订单行 → CpsOrder 字段。"""
sku_name = _text(_pick(r, "skuName", "goodsName", "productName"))
if sku_name and len(sku_name) > 500:
sku_name = sku_name[:500]
valid_code = _text(_pick(r, "validCode", "valid_code"))
actual_fee = _yuan_to_cents(_pick(r, "actualFee", "actual_fee"))
estimate_fee = _yuan_to_cents(_pick(r, "estimateFee", "estimate_fee"))
commission = actual_fee if actual_fee not in (None, 0) else estimate_fee
order_time = _jd_dt_to_utc(_pick(r, "orderTime", "order_time"))
return {
"platform": "jd",
"external_order_id": _text(_pick(r, "orderId", "parentOrderId")),
"external_row_id": _text(_pick(r, "id", "rowId", "orderRowId")),
"sid": _text(_pick(r, "subUnionId", "sub_union_id")),
"act_id": None,
"biz_line": None,
"trade_type": None,
"pay_price_cents": _yuan_to_cents(
_pick(r, "actualCosPrice", "estimateCosPrice", "price")
),
"commission_cents": commission,
"commission_rate": _text(_pick(r, "commissionRate", "commission_rate")),
"refund_price_cents": None,
"refund_profit_cents": None,
"estimated_commission_cents": estimate_fee,
"actual_commission_cents": actual_fee,
"mt_status": None,
"jd_valid_code": valid_code,
"invalid_reason": None if _is_jd_valid_code(valid_code) else f"validCode={valid_code}",
"product_name": sku_name,
"settle_month": _text(_pick(r, "payMonth", "settleMonth", "pay_month")),
"site_id": _text(_pick(r, "siteId", "site_id")),
"position_id": _text(_pick(r, "positionId", "position_id")),
"pid": _text(_pick(r, "pid")),
"sub_union_id": _text(_pick(r, "subUnionId", "sub_union_id")),
"pay_time": order_time,
"mt_update_time": _jd_dt_to_utc(_pick(r, "modifyTime", "updateTime", "modify_time"))
or order_time,
"raw": r,
}
def _is_jd_valid_code(valid_code: str | None) -> bool:
code = str(valid_code).strip() if valid_code is not None else ""
return bool(code and code not in _JD_INVALID_CODES and code not in _JD_UNPAID_CODES)
def is_jd_order_valid(order: CpsOrder) -> bool:
return _is_jd_valid_code(order.jd_valid_code)
def effective_commission_cents(order: CpsOrder) -> int:
if order.platform == "jd":
if order.actual_commission_cents not in (None, 0):
return order.actual_commission_cents or 0
if order.estimated_commission_cents is not None:
return order.estimated_commission_cents or 0
return order.commission_cents or 0
def reconcile_orders(
db: Session, *, start_time: int, end_time: int,
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
@@ -274,6 +384,11 @@ def reconcile_orders(
continue
fetched += 1
fields = _map_order_fields(r)
fields.setdefault("platform", "meituan")
fields.setdefault("external_order_id", order_id)
fields.setdefault("external_row_id", None)
fields.setdefault("estimated_commission_cents", fields.get("commission_cents"))
fields.setdefault("actual_commission_cents", None)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
@@ -291,6 +406,56 @@ def reconcile_orders(
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def reconcile_jd_orders(
db: Session, *, start_time: datetime, end_time: datetime,
query_time_type: int = 3, max_pages: int = 100,
) -> dict:
"""调京东 order.row.query 拉单 → 按订单行 upsert。
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
"""
fetched = inserted = updated = pages = 0
cur = start_time
while cur < end_time:
win_end = min(cur + timedelta(hours=1), end_time)
page = 1
while page <= max_pages:
resp = jd_union.query_order_rows(
start_time=cur,
end_time=win_end,
query_time_type=query_time_type,
page_index=page,
page_size=200,
)
rows = resp.get("rows") or []
has_more = bool(resp.get("has_more"))
if not rows:
break
pages += 1
for r in rows:
order_id = _jd_order_key(r)
if not order_id:
continue
fetched += 1
fields = _map_jd_order_fields(r)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
if existing is None:
db.add(CpsOrder(order_id=order_id, **fields))
inserted += 1
else:
for k, v in fields.items():
setattr(existing, k, v)
updated += 1
if not has_more or len(rows) < 200:
break
page += 1
cur = win_end
db.commit()
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def list_orders(
db: Session, *, sid: str | None = None, mt_status: str | None = None,
limit: int = 20, cursor: int | None = None,
+21 -3
View File
@@ -788,6 +788,7 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
"user": user,
"coin_balance": acc.coin_balance if acc else 0,
"cash_balance_cents": acc.cash_balance_cents if acc else 0,
"invite_cash_balance_cents": acc.invite_cash_balance_cents if acc else 0,
"total_coin_earned": acc.total_coin_earned if acc else 0,
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
"comparison_success": _count(
@@ -805,6 +806,12 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
}
def _as_utc_naive(value: datetime) -> datetime:
"""窗口入参 → UTC naive(= _as_utc 去时区),与库里按 naive UTC 存取的 created_at 同口径比较。
历史遗留:_window_conds 一直引用本函数却未定义(自定义区间会 NameError),此处补上。"""
return _as_utc(value).replace(tzinfo=None)
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
conds = []
@@ -894,6 +901,13 @@ def user_reward_stats(
}
def _cn_wall_to_utc(dt: datetime) -> datetime:
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
def user_coin_records(
db: Session,
user_id: int,
@@ -914,6 +928,9 @@ def user_coin_records(
offset = max(cursor or 0, 0)
fetch = offset + limit + 1
rows: list[dict] = []
# coin_transaction 存北京 wall-clock(其余表存 UTC);签到窗口边界 +8h 对齐北京,过滤/计数才不偏移 8 小时
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
for rec in db.execute(
select(AdRewardRecord)
@@ -957,7 +974,7 @@ def user_coin_records(
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
@@ -965,7 +982,8 @@ def user_coin_records(
rows.append({
"source": "signin",
"source_label": "签到",
"created_at": rec.created_at,
# 北京 wall-clock → UTC,与广告记录统一(前端 apiTime 会 +8 回北京展示,不然签到会多 8 小时)
"created_at": _cn_wall_to_utc(rec.created_at),
"ecpm": None,
"coin": rec.amount,
})
@@ -991,7 +1009,7 @@ def user_coin_records(
+ _count(
CoinTransaction, CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
)
)
return rows[offset:offset + limit], (offset + limit if has_more else None), total
+94 -15
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.cps_order import CpsOrder
@@ -35,6 +36,13 @@ REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
)
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
MEITUAN_CPS_SETTLED_STATUS = "6"
COMPARE_START_EVENT = "real_compare_start"
COUPON_START_EVENT = "real_coupon_start"
JD_CPS_INVALID_CODES = {
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
}
JD_CPS_UNPAID_CODES = {"15"}
def _beijing_today_start_utc() -> datetime:
@@ -45,14 +53,31 @@ def _beijing_today_start_utc() -> datetime:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
"""
today_bj = datetime.now(_BEIJING).date()
today_start = _beijing_today_start_utc()
return int(
db.execute(select(func.count(User.id)).where(User.last_login_at >= today_start)).scalar_one()
tomorrow_start = today_start + timedelta(days=1)
login_user_ids = _id_set(
db,
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), today_start, tomorrow_start
)
coupon_claim_user_ids = _id_set(
db,
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == today_bj,
CouponPromptEngagement.engage_type == "claim_started",
),
)
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
def _default_period_end() -> date:
@@ -91,6 +116,24 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
return [date_from + timedelta(days=i) for i in range(days + 1)]
def _id_set(db: Session, stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
def _event_user_ids(
db: Session, event_names: tuple[str, ...], start_utc: datetime, end_utc: datetime
) -> set[int]:
return _id_set(
db,
select(AnalyticsEvent.user_id).where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(event_names),
AnalyticsEvent.created_at >= start_utc,
AnalyticsEvent.created_at < end_utc,
),
)
def _commission_rate_percent(raw: str | None) -> Decimal | None:
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%""""
if raw is None:
@@ -107,6 +150,11 @@ def _commission_rate_percent(raw: str | None) -> Decimal | None:
return val / Decimal("100")
def _jd_valid_order(order: CpsOrder) -> bool:
code = str(order.jd_valid_code).strip() if order.jd_valid_code is not None else ""
return bool(code and code not in JD_CPS_INVALID_CODES and code not in JD_CPS_UNPAID_CODES)
def dashboard_overview(
db: Session, *, date_from: date | None = None, date_to: date | None = None
) -> dict:
@@ -216,17 +264,22 @@ def dashboard_overview(
login_user_ids = _user_id_set(
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
)
compare_user_ids = _user_id_set(
select(ComparisonRecord.user_id).where(*period_comparison_conds)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), start_utc, end_utc
)
coupon_user_ids = _user_id_set(
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), start_utc, end_utc
)
coupon_claim_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date >= period_from,
CouponPromptEngagement.engage_date <= period_to,
CouponPromptEngagement.engage_type == "claim_started",
)
)
period_active_user_ids = login_user_ids | compare_user_ids | coupon_user_ids
period_active_user_ids = (
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
)
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
period_retention_rate = (
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
@@ -248,10 +301,13 @@ def dashboard_overview(
User.last_login_at < day_end_utc,
)
)
daily_compare_user_ids = _user_id_set(
select(ComparisonRecord.user_id).where(*daily_comparison_conds)
daily_compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
)
daily_coupon_user_ids = _user_id_set(
daily_coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
)
daily_coupon_claim_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == cur_date,
CouponPromptEngagement.engage_type == "claim_started",
@@ -261,7 +317,10 @@ def dashboard_overview(
{
"date": cur_date,
"active_users": len(
daily_login_user_ids | daily_compare_user_ids | daily_coupon_user_ids
daily_login_user_ids
| daily_compare_start_user_ids
| daily_coupon_event_user_ids
| daily_coupon_claim_user_ids
),
"new_users": _count(
User,
@@ -317,7 +376,7 @@ def dashboard_overview(
*period_coin_conds,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
)
period_meituan_orders = list(
period_cps_orders = list(
db.execute(
select(CpsOrder).where(
CpsOrder.pay_time >= start_utc,
@@ -325,9 +384,17 @@ def dashboard_overview(
)
).scalars()
)
period_meituan_orders = [
o for o in period_cps_orders if (o.platform or "meituan") == "meituan"
]
period_meituan_valid_orders = [
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
]
period_jd_orders = [o for o in period_cps_orders if o.platform == "jd"]
period_jd_valid_orders = [o for o in period_jd_orders if _jd_valid_order(o)]
period_jd_invalid_orders = [
o for o in period_jd_orders if o.jd_valid_code and not _jd_valid_order(o)
]
period_meituan_hit_count = 0
period_meituan_miss_count = 0
period_meituan_unknown_rate_count = 0
@@ -412,8 +479,8 @@ def dashboard_overview(
"retained_new_users": len(period_retained_new_user_ids),
"retention_rate": period_retention_rate,
"retention_note": (
"近似口径:登录(last_login_at)+已上报比价记录+领券claim_started;"
"尚不包含未完成上报的比价开始事件"
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
"开始领券(real_coupon_start/claim_started),按用户去重"
),
},
"comparison": {
@@ -450,7 +517,7 @@ def dashboard_overview(
},
"cps": {
"available": True,
"note": "美团 CPS 读 cps_order 对账订单;淘宝/京东佣金暂空",
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
"meituan_order_count": len(period_meituan_valid_orders),
"meituan_commission_cents": sum(
o.commission_cents or 0 for o in period_meituan_valid_orders
@@ -459,5 +526,17 @@ def dashboard_overview(
"meituan_miss_count": period_meituan_miss_count,
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
"meituan_hit_rate": period_meituan_hit_rate,
"jd_order_count": len(period_jd_valid_orders),
# 数据大盘京东 CPS 只看实际佣金,不再用预估佣金兜底。
"jd_commission_cents": sum(
o.actual_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_actual_commission_cents": sum(
o.actual_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_estimated_commission_cents": sum(
o.estimated_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_invalid_count": len(period_jd_invalid_orders),
},
}
+106
View File
@@ -0,0 +1,106 @@
"""admin「领券数据」看板:发起/完成数 + 领券耗时(均值 + P5/P50/P95/P99)+ 按天趋势 + 逐条明细。
任意已登录 admin 可看(只读)。聚合逻辑在 app/admin/repositories/coupon_data.py。
数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报)。
"""
from __future__ import annotations
from datetime import date as _date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import coupon_data
from app.admin.schemas.coupon_data import (
CouponDataDaily,
CouponDataHourly,
CouponDataOut,
CouponDataRow,
CouponDataSummary,
CouponUserRecordsOut,
)
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/coupon-data",
tags=["admin-coupon-data"],
dependencies=[Depends(get_current_admin)],
)
# 区间最大跨度(天);超出拒绝,避免一次拉过多天拖垮接口(对齐广告收益报表)。
_MAX_RANGE_DAYS = 92
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
if value is None:
return default
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
@router.get(
"",
response_model=CouponDataOut,
summary="领券数据看板(发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细)",
)
def get_coupon_data(
db: AdminDb,
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过条数)=(页码-1)×每页条数")] = 0,
sort: Annotated[
str, Query(description="排序:time=发起时间倒序(默认) / elapsed=耗时倒序")
] = "time",
) -> CouponDataOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
d_to = _parse_day(date_to, field="date_to", default=d_from)
if d_to < d_from:
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS}")
# 报表默认只看 prod(对齐广告报表防串台口径);app_env=all 时不过滤、看全部环境。
env = None if app_env == "all" else app_env
result = coupon_data.coupon_data_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user=user, app_env=env, granularity=granularity,
limit=limit, offset=offset, sort=sort,
)
return CouponDataOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
summary=CouponDataSummary(**result["summary"]),
daily=[CouponDataDaily(**d) for d in result["daily"]],
hourly=[CouponDataHourly(**h) for h in result["hourly"]],
total=result["total"],
items=[CouponDataRow(**r) for r in result["items"]],
)
@router.get(
"/user-records",
response_model=CouponUserRecordsOut,
summary="某用户全部领券记录(点手机号抽屉:领券次数 + 记录列表)",
)
def get_user_coupon_records(
db: AdminDb,
user_id: Annotated[int, Query(description="用户 id")],
limit: Annotated[int, Query(ge=1, le=500, description="最多返回条数")] = 100,
sort_by: Annotated[str, Query(description="兼容 UserRecordsDrawer 参数;固定按发起时间倒序")] = "created_at",
sort_order: Annotated[str, Query(description="兼容参数,忽略")] = "desc",
) -> CouponUserRecordsOut:
result = coupon_data.coupon_user_records(db, user_id=user_id, limit=limit)
return CouponUserRecordsOut(
items=[CouponDataRow(**r) for r in result["items"]],
total=result["total"],
)
+48 -16
View File
@@ -1,7 +1,7 @@
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
淘宝暂未接 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
"""
from __future__ import annotations
@@ -34,6 +34,7 @@ from app.admin.schemas.cps import (
from app.core import media
from app.core.config import settings
from app.integrations import meituan
from app.integrations.jd_union import JdUnionError
from app.integrations.meituan import MeituanCpsError
from app.models.admin import AdminUser
from app.models.cps_activity import CpsActivity
@@ -373,7 +374,22 @@ def _reconcile_range_to_ts(
return int(start_dt.timestamp()), int(end_dt.timestamp())
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
def _reconcile_range_to_bj_dt(
date_from: _date | None, date_to: _date | None, days: int
) -> tuple[datetime, datetime]:
start_ts, end_ts = _reconcile_range_to_ts(date_from, date_to, days)
return (
datetime.fromtimestamp(start_ts, tz=_BEIJING),
datetime.fromtimestamp(end_ts, tz=_BEIJING),
)
def _merge_reconcile_result(total: dict, current: dict) -> None:
for key in ("fetched", "inserted", "updated", "pages"):
total[key] = int(total.get(key, 0)) + int(current.get(key, 0))
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取联盟订单对账")
def reconcile_orders(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
@@ -382,26 +398,42 @@ def reconcile_orders(
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
sid: Annotated[str | None, Query(max_length=64)] = None,
query_time_type: Annotated[int, Query(ge=1, le=2)] = 1,
query_time_type: Annotated[int, Query(ge=1, le=3)] = 1,
platform: Annotated[str, Query(pattern="^(all|meituan|jd)$")] = "all",
) -> CpsReconcileResult:
start_ts, end_ts = _reconcile_range_to_ts(
_parse_day(date_from, field="date_from"),
_parse_day(date_to, field="date_to"),
days,
)
parsed_from = _parse_day(date_from, field="date_from")
parsed_to = _parse_day(date_to, field="date_to")
result = {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0}
try:
result = cps_repo.reconcile_orders(
db,
start_time=start_ts,
end_time=end_ts,
query_time_type=query_time_type,
sid=sid,
)
if platform in {"all", "meituan"}:
start_ts, end_ts = _reconcile_range_to_ts(parsed_from, parsed_to, days)
mt_result = cps_repo.reconcile_orders(
db,
start_time=start_ts,
end_time=end_ts,
query_time_type=query_time_type if query_time_type in (1, 2) else 2,
sid=sid,
)
_merge_reconcile_result(result, mt_result)
if platform in {"all", "jd"}:
if sid:
raise HTTPException(status_code=422, detail="京东订单刷新不支持 sid 筛选")
start_dt, end_dt = _reconcile_range_to_bj_dt(parsed_from, parsed_to, days)
jd_result = cps_repo.reconcile_jd_orders(
db,
start_time=start_dt,
end_time=end_dt,
query_time_type=query_time_type,
)
_merge_reconcile_result(result, jd_result)
except MeituanCpsError as e:
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
except JdUnionError as e:
raise HTTPException(status_code=502, detail=f"京东拉单失败: {e}") from e
write_audit(
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
detail={
"platform": platform,
"date_from": date_from,
"date_to": date_to,
"days": days,
+19 -11
View File
@@ -210,22 +210,28 @@ def grant_user_cash(
db: AdminDb,
) -> OkResponse:
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
account=coin_cash(金币兑现金)/ invite_cash(邀请奖励金):两本账物理隔离、各调各的。
主要用于让无现金用户直接测试提现。"""
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
# 按目标账户选「余额字段 + 变动入口」(grant_invite_cash 与 grant_cash 同构)
is_invite = body.account == "invite_cash"
balance_attr = "invite_cash_balance_cents" if is_invite else "cash_balance_cents"
grant_fn = wallet_repo.grant_invite_cash if is_invite else wallet_repo.grant_cash
acct_label = "邀请奖励金" if is_invite else "现金"
before: int | None = None
# set=设为目标值:读当前余额算差值,仍复用 grant 写一笔流水(沿用原子/审计/扣负保护)
if body.mode == "set":
if body.amount_cents < 0:
raise HTTPException(status_code=400, detail="目标现金值不能为负")
raise HTTPException(status_code=400, detail=f"目标{acct_label}值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
before = wallet_repo.get_or_create_account(
db, user_id, commit=False, lock=True
).cash_balance_cents
acc_locked = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
before = getattr(acc_locked, balance_attr)
delta = body.amount_cents - before
if delta == 0:
raise HTTPException(
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
status_code=400, detail=f"当前{acct_label}已为 {body.amount_cents} 分,无需调整"
)
else:
if body.amount_cents == 0:
@@ -234,18 +240,20 @@ def grant_user_cash(
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.cash_balance_cents + delta < 0:
if getattr(acc_now, balance_attr) + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
status_code=400,
detail=f"扣减后{acct_label}为负(当前余额 {getattr(acc_now, balance_attr)} 分)",
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_cash(
# grant 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = grant_fn(
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
detail = {
"account": body.account,
"amount_cents": delta,
"balance_after_cents": acc.cash_balance_cents,
"balance_after_cents": getattr(acc, balance_attr),
"reason": body.reason,
}
if body.mode == "set":
+14
View File
@@ -94,6 +94,11 @@ class AdRevenueRow(BaseModel):
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
row_revenue_yuan: float | None = Field(
None,
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
"其它行为空(前端回退取 revenue_yuan)。不进合计/趋势,避免与展示侧重复计",
)
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
# ── 发奖侧 ──
@@ -106,6 +111,15 @@ class AdRevenueRow(BaseModel):
None,
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
)
sub_rewards: list[AdRevenueRecord] = Field(
default_factory=list,
description="一次比价/领券聚合行的组内逐条发奖明细(同一整场 ad_session_id 的多条广告);"
"点行展开渲染多行。激励视频/纯展示行为空(单条看 reward_detail)",
)
sub_count: int = Field(
1,
description="本行聚合的发奖条数:一次比价/领券=该次广告条数(≥1);激励视频/纯展示=1",
)
class AdRevenueReportOut(BaseModel):
+83
View File
@@ -0,0 +1,83 @@
"""admin「领券数据」看板 schemas:汇总卡 + 按天/小时趋势 + 逐条领券明细。
数据源 coupon_session(一次领券一行)。耗时单位 ms(前端按需折秒);均值/分位只统计 completed。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class CouponDataSummary(BaseModel):
"""汇总卡:发起/完成数 + 耗时均值与分位(P5/P50/P95/P99,基于 completed 的 elapsed_ms)。"""
started_count: int = Field(..., description="发起数(区间内所有领券 session)")
completed_count: int = Field(..., description="完成数(status=completed)")
avg_elapsed_ms: int | None = Field(None, description="平均耗时(ms,仅 completed;无数据为空)")
p5_ms: int | None = Field(None, description="耗时 5 分位(ms)")
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
class CouponDataDaily(BaseModel):
"""按天趋势(全量,不受分页影响):柱=发起/完成数,线=平均耗时。"""
date: str = Field(..., description="北京时间 YYYY-MM-DD")
started_count: int
completed_count: int
avg_elapsed_ms: int | None = Field(None, description="当天平均耗时(ms,仅 completed)")
class CouponDataHourly(BaseModel):
"""按北京小时(023)趋势(单日 granularity=hour 时非空)。"""
hour: int = Field(..., description="北京时间小时 023")
started_count: int
completed_count: int
avg_elapsed_ms: int | None = None
class CouponDataRow(BaseModel):
"""一条领券明细(一次领券任务)。"""
id: int = Field(..., description="coupon_session 主键(抽屉 rowKey 用)")
trace_id: str
user_id: int | None = None
user_phone: str | None = Field(None, description="手机号(admin 展示;匿名领券/查不到为空)")
user_nickname: str | None = Field(None, description="昵称")
status: str = Field(..., description="started / completed / failed / abandoned")
platforms: list[str] | None = Field(None, description="发起勾选平台")
origin_package: str | None = Field(None, description="发起来源 App 包名;null=App 内(傻瓜比价首页)发起")
elapsed_ms: int | None = Field(None, description="全程耗时(ms)")
platform_elapsed: dict[str, int] | None = Field(
None, description="各平台耗时 {meituan-waimai/taobao-shanguang/jd-waimai: ms}"
)
device_model: str | None = None
rom: str | None = None
app_env: str | None = None
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
class CouponDataOut(BaseModel):
"""领券数据看板响应:汇总卡 + 趋势 + 明细分页。"""
date_from: str
date_to: str
summary: CouponDataSummary
daily: list[CouponDataDaily] = Field(default_factory=list, description="按天趋势(全量)")
hourly: list[CouponDataHourly] = Field(
default_factory=list, description="按小时趋势(单日 hour 粒度时非空)"
)
total: int = Field(..., description="明细总条数(全量,不受分页)")
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
class CouponUserRecordsOut(BaseModel):
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
items: list[CouponDataRow]
total: int
+9 -2
View File
@@ -1,7 +1,7 @@
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接 + 订单 API 对账)。
对账类字段对淘宝为 None → 前端显示 "-"(暂未对账)。
"""
from __future__ import annotations
@@ -116,15 +116,22 @@ class CpsOrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
platform: str = "meituan"
order_id: str
external_order_id: str | None = None
external_row_id: str | None = None
sid: str | None = None
act_id: str | None = None
pay_price_cents: int | None = None
commission_cents: int | None = None
estimated_commission_cents: int | None = None
actual_commission_cents: int | None = None
commission_rate: str | None = None
mt_status: str | None = None
jd_valid_code: str | None = None
invalid_reason: str | None = None
product_name: str | None = None
settle_month: str | None = None
pay_time: datetime | None = None
+5
View File
@@ -103,6 +103,11 @@ class DashboardCps(BaseModel):
meituan_miss_count: int = 0
meituan_unknown_rate_count: int = 0
meituan_hit_rate: float | None = None
jd_order_count: int = 0
jd_commission_cents: int = 0
jd_actual_commission_cents: int = 0
jd_estimated_commission_cents: int = 0
jd_invalid_count: int = 0
class DashboardOverview(BaseModel):
+7
View File
@@ -17,6 +17,7 @@ class AdminUserListItem(BaseModel):
status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None
wechat_nickname: str | None = None
created_at: datetime
last_login_at: datetime
@@ -29,6 +30,7 @@ class AdminUserOverview(BaseModel):
user: AdminUserListItem
coin_balance: int
cash_balance_cents: int
invite_cash_balance_cents: int # 邀请奖励金余额(与 cash_balance_cents 物理隔离)
total_coin_earned: int
comparison_total: int
comparison_success: int
@@ -88,6 +90,11 @@ class GrantCoinsRequest(BaseModel):
class GrantCashRequest(BaseModel):
# 目标账户:金币兑换的现金(cash_balance_cents)与邀请奖励金(invite_cash_balance_cents)物理隔离,
# 各调各的、不可串。默认 coin_cash 兼容旧调用。
account: Literal["coin_cash", "invite_cash"] = Field(
"coin_cash", description="目标账户:coin_cash=金币兑现金账户 / invite_cash=邀请奖励金账户"
)
mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
)
+29
View File
@@ -29,6 +29,7 @@ from app.schemas.coupon_state import (
CouponPromptDismissIn,
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponSessionIn,
CouponStatsOut,
)
@@ -194,6 +195,34 @@ async def coupon_step(
return resp_json
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
try:
coupon_repo.upsert_coupon_session(
db,
trace_id=payload.trace_id,
device_id=payload.device_id,
status=payload.status,
started_at_ms=payload.started_at_ms,
user_id=payload.user_id,
platforms=payload.platforms,
origin_package=payload.origin_package,
device_model=payload.device_model,
rom=payload.rom,
app_env=payload.app_env,
elapsed_ms=payload.elapsed_ms,
platform_elapsed=payload.platform_elapsed,
claimed_count=payload.claimed_count,
trace_url=payload.trace_url,
)
except Exception as e: # noqa: BLE001
logger.warning("coupon session write failed: %s", e)
return {"ok": True}
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
+3
View File
@@ -202,6 +202,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
order = crud_wallet.create_withdraw(
db, user.id, req.amount_cents, source=req.source,
user_name=req.user_name, out_bill_no=req.out_bill_no,
# 0.01 元调试提现:放行低于最低额的小额。双闸——客户端仅 debug 包在「0.01 元提现」开关开时
# 连同 skip_review 一起下发;服务端仅非 prod 才认。生产恒 False,最低额校验照常。
allow_sub_min=(req.skip_review and not settings.is_prod),
)
except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException(
+15
View File
@@ -113,6 +113,21 @@ class Settings(BaseSettings):
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
# ===== 京东联盟 CPS =====
# app_key/app_secret 来自京东联盟应用;site_id 是推广管理里的 APP/网站 ID;
# auth_key 是工具商授权 key,自有应用查询可留空。
JD_UNION_APP_KEY: str = ""
JD_UNION_APP_SECRET: str = ""
JD_UNION_SITE_ID: str = ""
JD_UNION_AUTH_KEY: str = ""
JD_UNION_GATEWAY: str = "https://api.jd.com/routerjson"
JD_UNION_TIMEOUT_SEC: int = 15
@property
def jd_union_configured(self) -> bool:
"""京东联盟订单查询凭证齐全。"""
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
# ===== 微信服务号(网页授权) =====
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
+163
View File
@@ -0,0 +1,163 @@
"""京东联盟 OpenAPI 客户端。
当前只接数据大盘需要的订单明细接口:
`jd.union.open.order.row.query`。京东要求订单查询时间窗最长 1 小时,
调用方负责切窗分页。
"""
from __future__ import annotations
import hashlib
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
_BEIJING = timezone(timedelta(hours=8))
class JdUnionError(RuntimeError):
"""京东联盟 API 调用失败。"""
def _parse_json_maybe(value: Any) -> Any:
if not isinstance(value, str):
return value
text = value.strip()
if not text:
return value
try:
return json.loads(text)
except json.JSONDecodeError:
return value
def _sign(params: dict[str, Any], secret: str) -> str:
pieces = [secret]
for key in sorted(k for k in params if k != "sign"):
value = params[key]
if value is None:
continue
pieces.append(f"{key}{value}")
pieces.append(secret)
raw = "".join(pieces)
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def _unwrap_response(data: dict[str, Any]) -> dict[str, Any]:
if "error_response" in data:
err = data["error_response"] or {}
msg = err.get("zh_desc") or err.get("en_desc") or err.get("msg") or err
raise JdUnionError(f"京东 API 错误: {msg}")
body: Any = data
for key, value in data.items():
if key.endswith("_responce") or key.endswith("_response"):
body = value
break
body = _parse_json_maybe(body)
if not isinstance(body, dict):
raise JdUnionError("京东 API 返回格式异常")
result = body.get("queryResult", body.get("result", body))
result = _parse_json_maybe(result)
if not isinstance(result, dict):
raise JdUnionError("京东 API 业务结果格式异常")
code = str(result.get("code", result.get("resultCode", "200")))
if code not in {"0", "200"}:
msg = result.get("message") or result.get("msg") or result.get("resultMsg") or result
raise JdUnionError(f"京东 API 业务错误: {msg}")
return result
def call(method: str, payload: dict[str, Any], *, version: str = "1.0") -> dict[str, Any]:
if not settings.jd_union_configured:
raise JdUnionError("京东联盟凭证未配置")
biz_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
params: dict[str, Any] = {
"method": method,
"app_key": settings.JD_UNION_APP_KEY,
"timestamp": datetime.now(_BEIJING).strftime("%Y-%m-%d %H:%M:%S"),
"format": "json",
"v": version,
"sign_method": "md5",
"360buy_param_json": biz_json,
}
params["sign"] = _sign(params, settings.JD_UNION_APP_SECRET)
try:
with httpx.Client(timeout=settings.JD_UNION_TIMEOUT_SEC, trust_env=False) as client:
resp = client.post(settings.JD_UNION_GATEWAY, data=params)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
raise JdUnionError(f"京东 API 网络错误: {e}") from e
except json.JSONDecodeError as e:
raise JdUnionError("京东 API 返回非 JSON") from e
return _unwrap_response(data)
def _extract_rows(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
payload = _parse_json_maybe(result.get("data", result.get("result", result)))
has_more = bool(result.get("hasMore") or result.get("has_more"))
if isinstance(payload, dict):
for key in ("orderRowResp", "orderRows", "orderList", "orders", "list", "rows"):
rows = _parse_json_maybe(payload.get(key))
if isinstance(rows, list):
return [r for r in rows if isinstance(r, dict)], bool(
payload.get("hasMore") or payload.get("has_more") or has_more
)
return [], bool(payload.get("hasMore") or payload.get("has_more") or has_more)
if isinstance(payload, list):
return [r for r in payload if isinstance(r, dict)], has_more
return [], has_more
def query_order_rows(
*,
start_time: datetime,
end_time: datetime,
query_time_type: int = 3,
page_index: int = 1,
page_size: int = 200,
) -> dict[str, Any]:
"""查询京东 CPS 订单行。
query_time_type: 1 下单时间, 2 完成时间, 3 更新时间。
start_time/end_time 用北京时间展示给京东;调用方需保证窗口不超过 1 小时。
"""
start_bj = start_time.astimezone(_BEIJING)
end_bj = end_time.astimezone(_BEIJING)
if end_bj <= start_bj:
return {"rows": [], "has_more": False}
if end_bj - start_bj > timedelta(hours=1):
raise JdUnionError("京东订单查询单次时间窗不能超过 1 小时")
order_req: dict[str, Any] = {
"pageIndex": page_index,
"pageSize": min(max(page_size, 1), 200),
"type": query_time_type,
"startTime": start_bj.strftime("%Y-%m-%d %H:%M:%S"),
"endTime": end_bj.strftime("%Y-%m-%d %H:%M:%S"),
}
if settings.JD_UNION_AUTH_KEY:
order_req["key"] = settings.JD_UNION_AUTH_KEY
result = call("jd.union.open.order.row.query", {"orderReq": order_req})
rows, has_more = _extract_rows(result)
logger.info(
"jd.union.open.order.row.query fetched rows=%s page=%s has_more=%s",
len(rows),
page_index,
has_more,
)
return {"rows": rows, "has_more": has_more, "raw": result}
+54 -10
View File
@@ -2,18 +2,23 @@
通过 `uvicorn app.main:app --reload` 启动。
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.ad import router as ad_router
from app.api.v1.analytics import router as analytics_router
from app.api.v1.auth import router as auth_router
@@ -21,12 +26,8 @@ from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.device import router as device_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.device import router as device_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
@@ -40,14 +41,14 @@ from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.logging import setup_logging
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
@@ -137,8 +138,51 @@ app.include_router(cps_redirect_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
_media_root.mkdir(parents=True, exist_ok=True)
# 官网下载的 APK 直链(落地页 dl.html「官网下载」按钮指向 /media/shaguabijia.apk)。
# 必须在 StaticFiles 挂载【之前】注册,否则被静态挂载吃掉。
# StaticFiles 给 .apk 的 Content-Type 不对、且无 attachment 头 → 部分国产浏览器不触发下载、转甩应用市场;
# 这里显式回 application/vnd.android.package-archive + Content-Disposition:attachment 强制浏览器下载。
# 文件由 scripts/publish_apk.sh 编 release 包后放到 data/media/shaguabijia.apk(*.apk 不入 git,需部署时放)。
_APK_PATH = _media_root / "shaguabijia.apk"
@app.get(f"{settings.MEDIA_URL_PREFIX}/shaguabijia.apk", tags=["meta"], include_in_schema=False)
def download_apk() -> FileResponse:
if not _APK_PATH.is_file():
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="安装包未就绪")
return FileResponse(
_APK_PATH,
media_type="application/vnd.android.package-archive",
filename="shaguabijia.apk",
headers={"Content-Disposition": 'attachment; filename="shaguabijia.apk"'},
)
app.mount(
settings.MEDIA_URL_PREFIX,
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
View File
@@ -19,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
+76
View File
@@ -181,3 +181,79 @@ class CouponPromptEngagement(Base):
f"<CouponPromptEngagement device={self.device_id} "
f"date={self.engage_date} type={self.engage_type}>"
)
class CouponSession(Base):
"""一次领券任务的全程流水(admin「领券数据」看板数据源,2026-06-30)。
与 coupon_claim_record(按券一天一条去重)、coupon_daily_completion(按设备一天一条)都不同:
本表**一次领券一条**(trace_id 唯一),记从发起(started)到收尾(completed/failed/abandoned)的
全程耗时 + 各平台耗时 + 机型/ROM。客户端 POST /api/v1/coupon/session 两段上报:发起建行、
收尾按 trace_id 更新同一行。发起即落库 → admin 可算「发起数」与中途流失(started 无终态=未完成)。
口径:elapsed_ms 由客户端全程计时(点发起→收尾)、权威;started_at/finished_at 为时刻留痕。
started_date = started_at 的 Asia/Shanghai 自然日,供 admin 按天聚合 / 日期筛选(索引)。
"""
__tablename__ = "coupon_session"
__table_args__ = (
# 一次领券一行:trace_id 幂等 upsert(发起建、收尾更新同一行)。
UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
# admin 主聚合/筛选:按上海自然日 + 环境(报表默认只看 prod,避免测试数据串台)。
Index("ix_coupon_session_date_env", "started_date", "app_env"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键。
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空。
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# started / completed / failed / abandoned。started 无终态 = 中途流失(未完成)。
status: Mapped[str] = mapped_column(String(16), nullable=False)
# prod / dev(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod(对齐广告报表防串台口径)。
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
# 发起勾选平台 ["meituan-waimai", ...](空=全领)。
platforms: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。
# admin「发起平台」列据此区分(空→傻瓜比价,包名→对应平台)。
origin_package: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 机型(Build.MANUFACTURER + MODEL)与 ROM(OemDetector,如 "ColorOS 14")。明细「机型/ROM」列 + 维度筛选。
device_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
rom: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 发起时刻(客户端墙钟):明细「时间」列、趋势 X 轴。
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# 发起的 Asia/Shanghai 自然日:按天聚合 / 日期范围筛选(索引)。
started_date: Mapped[date] = mapped_column(Date, nullable=False)
# 收尾时刻(服务端 now);未收尾(流失)为空。
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 全程耗时(ms,客户端点发起→收尾):平均 / 分位都基于它(只统计 completed)。
elapsed_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 各平台领券耗时 {"meituan-waimai":3200,...}(ms)。明细美团/淘宝/京东耗时列。
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# 领到总张数(收尾帧带)。
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_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"<CouponSession trace={self.trace_id} status={self.status} "
f"elapsed_ms={self.elapsed_ms}>"
)
+22 -4
View File
@@ -1,11 +1,13 @@
"""CPS 对账订单(cps_order)。
美团联盟 query_order 按时间窗拉回、按 sid 归群的订单明细。字段对齐 query_order
从联盟 API 按时间窗拉回、按平台落库的 CPS 订单明细。字段最初对齐美团 query_order,
后续兼容京东订单报表:
实测返回:
- payPrice / profit 是「元」字符串 → 入库统一转「分」(与全站口径一致)
- payTime / updateTime 是秒级时间戳 → 入库转 tz-aware datetime
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。京东订单用
`jd:<row_id>` 前缀避免与美团订单号碰撞。
"""
from __future__ import annotations
@@ -22,8 +24,13 @@ class CpsOrder(Base):
__tablename__ = "cps_order"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 美团订单号(加密串),全局唯一,upsert 幂等键
# 平台:meituan / jd。历史数据迁移默认 meituan
platform: Mapped[str] = mapped_column(String(20), default="meituan", index=True, nullable=False)
# 平台订单号/行号包装后的全局唯一键,upsert 幂等。
order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# 平台原始订单号/行号。京东一笔订单多 SKU 时可按行号区分。
external_order_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
external_row_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
@@ -35,11 +42,21 @@ class CpsOrder(Base):
commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1%
refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 通用佣金拆分。美团只有预估 profit;京东有预估/实际佣金。
estimated_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
actual_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 美团订单状态: 2付款 3完成 4取消 5风控 6结算
mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True)
# 京东订单有效码(validCode),用于判断是否有效/已完成。
jd_valid_code: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
product_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
settle_month: Mapped[str | None] = mapped_column(String(16), nullable=True)
site_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
position_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
pid: Mapped[str | None] = mapped_column(String(128), nullable=True)
sub_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
pay_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
@@ -59,5 +76,6 @@ class CpsOrder(Base):
def __repr__(self) -> str: # pragma: no cover
return (
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
f"platform={self.platform!r} sid={self.sid!r} "
f"status={self.mt_status or self.jd_valid_code} profit_cents={self.commission_cents}>"
)
+90 -1
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
import logging
from datetime import date, datetime
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import delete, func, select
@@ -17,6 +17,7 @@ from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
logger = logging.getLogger("shagua.coupon_state")
@@ -232,3 +233,91 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
)
).scalar_one()
return int(total or 0)
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
def upsert_coupon_session(
db: Session,
*,
trace_id: str,
device_id: str,
status: str,
started_at_ms: int,
user_id: int | None = None,
platforms: list[str] | None = None,
origin_package: str | None = None,
device_model: str | None = None,
rom: str | None = None,
app_env: str | None = None,
elapsed_ms: int | None = None,
platform_elapsed: dict | None = None,
claimed_count: int | None = None,
trace_url: str | None = None,
) -> None:
"""一条领券流水按 trace_id 幂等 upsert(发起 started 建行、收尾终态更新同一行)。
- 乱序/重复兜底:终态(completed/failed/abandoned)先到也建行;started 重复到不覆盖已有终态
(状态只前进,不降级)。
- started_at 由客户端墙钟毫秒转;started_date 取其 Asia/Shanghai 自然日(admin 按天聚合/筛选)。
- 终态帧补 finished_at=服务端 now;各字段非空才写(避免 started 帧的 None 抹掉收尾值,反之亦然)。
并发 IntegrityError 回滚忽略(本就幂等)。
"""
started_at = datetime.fromtimestamp(started_at_ms / 1000, tz=timezone.utc)
started_date = started_at.astimezone(_CN_TZ).date()
is_terminal = status in ("completed", "failed", "abandoned")
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
if row is None:
db.add(CouponSession(
trace_id=trace_id,
device_id=device_id,
user_id=user_id,
status=status,
app_env=app_env,
platforms=platforms,
origin_package=origin_package,
device_model=device_model,
rom=rom,
started_at=started_at,
started_date=started_date,
finished_at=datetime.now(timezone.utc) if is_terminal else None,
elapsed_ms=elapsed_ms,
platform_elapsed=platform_elapsed,
claimed_count=claimed_count,
trace_url=trace_url,
))
else:
# 状态只前进:started 帧重复到(如 START_STICKY 重启)不把已有终态降级回 started。
if not (status == "started" and row.status in ("completed", "failed", "abandoned")):
row.status = status
if is_terminal:
row.finished_at = datetime.now(timezone.utc)
if user_id is not None:
row.user_id = user_id
if platforms is not None:
row.platforms = platforms
if origin_package is not None:
row.origin_package = origin_package
if device_model is not None:
row.device_model = device_model
if rom is not None:
row.rom = rom
if app_env is not None:
row.app_env = app_env
if elapsed_ms is not None:
row.elapsed_ms = elapsed_ms
if platform_elapsed is not None:
row.platform_elapsed = platform_elapsed
if claimed_count is not None:
row.claimed_count = claimed_count
if trace_url is not None:
row.trace_url = trace_url
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
+9 -1
View File
@@ -216,13 +216,21 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
"已邀请好友数"口径 = 完成一次比价(已触发邀请奖励金)的被邀请人数,即 compare_reward_granted=True。
不再数"仅绑定未比价"的关系——否则会出现"已邀请 1、可提现余额 0"(好友下载登录但没比价),
与产品口径"已邀请好友数 × 2元 = 累计提现 + 可提现余额"对不上。过滤后该恒等式天然成立
(每个计入的好友都恰好发过 1 笔 2 元,钱要么在余额要么已提现)。
金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats /
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。
"""
count = db.execute(
select(func.count())
.select_from(InviteRelation)
.where(InviteRelation.inviter_user_id == inviter_id)
.where(
InviteRelation.inviter_user_id == inviter_id,
InviteRelation.compare_reward_granted.is_(True),
)
).scalar_one()
coins = db.execute(
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
+7 -1
View File
@@ -610,6 +610,7 @@ def create_withdraw(
source: str = "coin_cash",
user_name: str | None = None,
out_bill_no: str | None = None,
allow_sub_min: bool = False,
) -> WithdrawOrder:
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
@@ -619,8 +620,13 @@ def create_withdraw(
重复发起多笔提现(审核拒绝再退回)。
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
allow_sub_min:放行低于"提现最低额"的小额(用于 0.01 元调试提现)。仅由 endpoint 在
`skip_review and not is_prod`(debug 包 + 非生产双闸)时置 True;仍受 schema gt=0 与 max 上限约束。
生产恒为 False → 最低额校验照常,绝不可能提 0.01。
"""
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
min_c = 0 if allow_sub_min else rewards.get_withdraw_min_cents(db)
if amount_cents < min_c or amount_cents > rewards.get_withdraw_max_cents(db):
raise InvalidWithdrawAmountError
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
+25
View File
@@ -49,3 +49,28 @@ class CouponStatsOut(BaseModel):
"""
coupon_count: int
class CouponSessionIn(BaseModel):
"""客户端领券流水上报体(admin「领券数据」看板数据源,POST /api/v1/coupon/session)。
一次领券两段上报, trace_id upsert coupon_session:
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)
- 收尾(completed/failed/abandoned): elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count
不鉴权(同领券循环 MVP, device_id/trace_id),user_id 登录态带上做留痕(可空)
"""
trace_id: str
device_id: str
status: str # started / completed / failed / abandoned
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
user_id: int | None = None
platforms: list[str] | None = None
origin_package: str | None = None
device_model: str | None = None
rom: str | None = None
app_env: str | None = None
elapsed_ms: int | None = None
platform_elapsed: dict[str, int] | None = None
claimed_count: int | None = None
trace_url: str | None = None
Binary file not shown.

After

Width:  |  Height:  |  Size: 847 KiB

+13 -4
View File
@@ -198,7 +198,7 @@
选择「<span class="guide-highlight">在浏览器打开</span>
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
</h2>
<div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div>
<!-- <div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div> -->
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
@@ -263,7 +263,7 @@
var wxGuide = document.getElementById("wxGuide");
function showWxGuide() { wxGuide.classList.add("show"); }
function hideWxGuide() { wxGuide.classList.remove("show"); }
document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide);
// document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide); // 「我知道了 ✕」已注释隐藏
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
function showToast(text) {
@@ -273,15 +273,24 @@
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
}
// ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
// ===== 应用商店下载:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
function handleDownload() {
if (isWeChat) { showWxGuide(); return; }
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
openStore();
}
// ===== 官网下载:微信/iOS 同上引导;安卓直接跳 APK 直链 → 弹系统下载弹窗 =====
// APK_URL 跟随页面 host:本地走 LAN、生产走 app-api.shaguabijia.com(见邀请功能文档约定)。
var APK_URL = location.origin + "/media/shaguabijia.apk";
function handleWebsiteDownload() {
if (isWeChat) { showWxGuide(); return; }
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
copyInviteCode(); // 同样先写邀请码进剪贴板(供 App 首启归因)
window.location.href = APK_URL;
}
document.getElementById("dlbtn").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleWebsiteDownload);
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+67
View File
@@ -0,0 +1,67 @@
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
> 对象:维护「每天拉穿山甲后台收益入库」这套定时任务的同事。
> 🔒 服务器登录信息见**私密交接清单**,不入库。
## 它是什么
admin「广告收益报表」里的「穿山甲后台收益(T+1)」读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查穿山甲**。穿山甲只通过 GroMore 数据 API 给数、且 **T+1**(次日约 10:00 出昨天的数),所以每天得拉一次入库,报表才会往前走。
- 每天 10:30 跑一轮 `scripts/sync_pangle_revenue.py`,默认 `--days 3` 回补近 3 天。
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(预估)+ `api_revenue`(结算口径)。
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
## 文件
| 项 | 路径 |
|---|---|
| 脚本入口 | `scripts/sync_pangle_revenue.py` |
| 拉取 / 签名 | `app/integrations/pangle_report.py`(签名=参数字典序拼接+secure_key 后 MD5) |
| 入库表 | `ad_pangle_daily_revenue`(读写在 `app/repositories/ad_pangle_revenue.py`) |
| 凭证 | `.env``PANGLE_REPORT_USER_ID` / `PANGLE_REPORT_ROLE_ID` / `PANGLE_REPORT_SECURITY_KEY` |
| 应用映射 | `.env``PANGLE_REPORT_SITE_ID_PROD`(5830519)/ `PANGLE_REPORT_SITE_ID_TEST`(5832303) |
| systemd 单元 | `deploy/pangle-revenue.{service,timer}` |
## 上线前置(只做一次)
1. **填凭证**:后台「接入中心 → GroMore-API」领 user_id / role_id / Security Key(`secure_key`,**≠ 发奖 m-key**),填进线上 `.env``PANGLE_REPORT_*`。三项填齐脚本才工作,缺任一 → no-op 退出。
2. **子账号要授权**:线上若用子账号(role_id ≠ user_id),需主账号给它授「查看全部数据」,否则接口报 **118**(无权限)。本人自查自己(user_id=role_id)无此问题。
3. 凭证泄露:后台「重置 key 值」即可,改完同步线上 `.env` 重跑。
## 部署(Linux 服务器,需 root)
```bash
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(应是次日 10:30)
```
## 怎么看健康 / 手动跑一次
```bash
sudo systemctl start pangle-revenue.service # 立即手动跑一轮(不等 10:30)
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 预估收益合计
```
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;预估收益合计 ¥19.42`
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
## 本机 Windows 开发(无 systemd)
直接手动跑:
```
.venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天
.venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
.venv\Scripts\python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天
.venv\Scripts\python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 区间回补
```
## 脚本参数
- 无参:拉**昨天**(北京时间)。timer 用的是 `--days 3`
- `--days N`:从昨天起往前回补 N 天(含昨天)。
- `--date YYYY-MM-DD`:指定单天。
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
## 注意事项
- **触发时间**:`OnCalendar=*-*-* 10:30:00`。穿山甲 ~10:00 出数,故别早于 10:00 跑(会拉到空/不全)。
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
- **`api_revenue` 很稀疏**:测试应用 ADN 没配 Reporting → 全 0,仅 prod 个别位有;`revenue`(预估)才是稳的主力。
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
+38
View File
@@ -0,0 +1,38 @@
# 每天拉穿山甲 GroMore T+1 天级收益入库 —— 单轮跑,由 pangle-revenue.timer 每天 10:30 触发。
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的「穿山甲后台收益(T+1)」区块。
#
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
# .venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
#
# 部署(服务器):
# sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
# # 手动跑一次验证: sudo systemctl start pangle-revenue.service && journalctl -u pangle-revenue -n 30
#
# 前置:.env 配好 PANGLE_REPORT_USER_ID / PANGLE_REPORT_ROLE_ID / PANGLE_REPORT_SECURITY_KEY
# (后台「接入中心 → GroMore-API」领;子账号需主账号授「查看全部数据」否则接口 118)。
# 未配齐这三项 → 脚本自动 no-op 退出,免动 timer。
[Unit]
Description=Sync Pangle GroMore daily revenue (T+1, one-shot, driven by timer)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
# 默认拉昨天;--days 3 回补近 3 天(幂等 upsert,应对偶发漏跑 + 穿山甲对历史数据订正,重跑无害)。
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.sync_pangle_revenue --days 3
SyslogIdentifier=pangle-revenue
# 仅几个 HTTP 请求 + 小批量入库,通常数秒;给 10min 硬超时防穿山甲接口卡死。
TimeoutStartSec=600
# 与主服务 shaguabijia-app-server.service 同款加固。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
+14
View File
@@ -0,0 +1,14 @@
# 每天 10:30 触发一次穿山甲 GroMore T+1 收益拉取入库(Linux 服务器用)。
# 见 pangle-revenue.service 顶部注释的部署步骤。
[Unit]
Description=Run Pangle GroMore daily revenue sync at 10:30
[Timer]
# 穿山甲 T+1、次日约 10:00 出数;10:30 触发留 30min 余量。要错开整点扎堆可微调到 10:35。
OnCalendar=*-*-* 10:30:00
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
+154
View File
@@ -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 + 资源指纹 |
+223
View File
@@ -0,0 +1,223 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../../shared/fonts.css">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'PuHuiTi',-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;background:#F5F5F5;color:#1A1A1A;padding:4px 14px 40px;-webkit-text-size-adjust:100%}
.ag-title{font-size:19px;font-weight:700;line-height:1.4;color:#1A1A1A;text-align:center;margin:10px 0 6px}
.ag-meta{font-size:12px;font-weight:400;line-height:1.7;color:#999;text-align:center;margin-bottom:14px}
.ag-card{background:#fff;border-radius:14px;box-shadow:0 1px 2px rgba(0,0,0,.03);padding:18px 16px 20px}
.ag-lead{font-size:13px;font-weight:400;line-height:1.85;color:#666;margin-bottom:10px}
.ag-h{font-size:16px;font-weight:600;line-height:1.5;color:#1A1A1A;margin:22px 0 8px}
.ag-h3{font-size:14px;font-weight:600;line-height:1.5;color:#1A1A1A;margin:14px 0 6px}
.ag-p{font-size:13px;font-weight:400;line-height:1.85;color:#666;margin-bottom:8px}
.ag-toc{list-style:none;padding-left:0}
strong{font-weight:600;color:#1A1A1A}
ul,ol{margin:4px 0 8px;padding-left:20px}
li{font-size:13px;font-weight:400;line-height:1.8;color:#666;margin-bottom:6px}
.ag-hr{border:none;border-top:0.5px solid #E5E5E5;margin:16px 0}
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px;line-height:1.55}
th,td{border:0.5px solid #E0E0E0;padding:5px 6px;text-align:left;vertical-align:top;color:#666;word-break:break-word}
th{background:#F7F7F7;color:#1A1A1A;font-weight:600}
a{color:#1565C0;text-decoration:none;word-break:break-all}
.ag-note{font-size:12px;color:#999;line-height:1.7;margin-top:8px}
.ag-foot{text-align:center;font-size:12px;font-weight:400;color:#CCC;line-height:1.7;margin:24px 0 8px}
</style>
</head>
<body>
<div class="ag-meta">生效日期:【2026 年 06 月 25 日】</div>
<div class="ag-card">
<div class="ag-lead">万德一博(北京)科技有限公司(以下简称“我们”)深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守适用法律和我们对您的承诺,遵循正当、合法、必要、诚信、最小必要的原则处理您的个人信息。</div>
<div class="ag-p">请您在使用“傻瓜比价”App(以下简称“本 App”或“本产品”)前,仔细阅读并充分理解本《傻瓜比价隐私政策》(以下简称“本政策”,<strong>我们已将重点内容以加粗形式提示,请您特别关注</strong>),并在确认充分理解并同意后再开始使用。如您对本政策有任何疑问,可通过本政策第八条“如何联系我们”提供的方式与我们联系。</div>
<div class="ag-p"><strong>【特别提示】</strong>本 App 是一款“比价工具”。我们本身不销售任何商品、不提供外卖/团购等交易服务,也不是您与电商/外卖/团购平台之间交易的相对方。您看到的商品、价格、优惠均来自淘宝、京东、拼多多、抖音、美团等第三方平台,最终下单、支付、履约均在相应第三方平台内完成,受该第三方平台自己的协议与隐私政策约束。</div>
<div class="ag-p">本政策将帮助您了解以下内容:</div>
<ul class="ag-toc">
<li>一、我们如何收集和使用您的个人信息</li>
<li>二、我们如何使用 Cookie 和同类技术</li>
<li>三、我们如何共享、转让、公开披露您的个人信息</li>
<li>四、我们如何保存和保护您的个人信息</li>
<li>五、您如何管理您的个人信息</li>
<li>六、未成年人的个人信息保护</li>
<li>七、通知和修订</li>
<li>八、如何联系我们</li>
<li>九、附录(第三方 SDK 清单、敏感个人信息清单)</li>
</ul>
<div class="ag-hr"></div>
<div class="ag-h">一、我们如何收集和使用您的个人信息</div>
<div class="ag-p">本 App 的基本功能为“跨平台商品比价”。您同意本政策表示您已了解本 App 提供的基本功能,以及基本功能运行所必需的个人信息,并给予相应的收集使用授权。但这并不代表您同意我们收集、处理非必要个人信息;扩展功能所需的个人信息、以及涉及敏感个人信息、第三方共享、调用系统权限等情形,我们将在您实际使用具体功能时单独征求您的同意。<strong>如您拒绝开启扩展功能或提供非必要个人信息,不会影响您使用基本功能。</strong></div>
<div class="ag-p">我们会遵循正当、合法、必要的原则,出于以下目的收集和使用您的个人信息:</div>
<div class="ag-h3">(一)帮助您注册和登录账号</div>
<div class="ag-p">当您注册、登录本 App 时,您需要向我们提供手机号码,我们通过发送短信验证码验证您的身份。您的手机号码用于创建账号、登录、账号安全保护以及向您发送服务通知。</div>
<div class="ag-p">为方便您快捷登录,您可以选择“一键登录”功能,我们会使用基础电信运营商提供的快捷登录能力,经您授权后获取您当前设备的本机手机号为您完成注册/登录。如您不希望使用,可通过短信验证码方式登录。</div>
<div class="ag-p">在您完成首次比价体验前,<strong>您无需登录即可使用核心比价功能</strong>(详见下文第(三)条);只有在您需要领取金币奖励、提现、查看比价记录等需要账号的功能时,才需要登录。</div>
<div class="ag-h3">(二)权限与设备信息的获取</div>
<div class="ag-p">为实现比价功能、保障账号与运行安全,我们会在您授权后获取以下信息。<strong>所有系统权限我们均不会默认开启,会在您使用到对应功能时向您申请,您可随时在系统设置中关闭:</strong></div>
<ol>
<li><strong>悬浮窗权限(在其他应用上层显示):</strong>用于在您浏览购物平台时显示“比价”悬浮按钮、比价进度与比价结果。这是本产品“无需打开 App 即可比价”体验的必要权限。</li>
<li><strong>无障碍服务权限(Accessibility Service):</strong>用于实现核心比价功能,详见第(三)条专门说明。</li>
<li><strong>设备与网络信息:</strong>为保障账号安全、识别风险、适配界面并排查故障,我们会收集您的设备型号、操作系统及版本、应用版本、屏幕分辨率、语言设置、设备标识符(如 OAID、Android ID,安卓系统不再提供 IMEI 时不强制收集)、网络状态、IP 地址、电信运营商等软硬件特征信息。</li>
<li><strong>自启动、忽略电池优化等系统设置:</strong>为保证比价与悬浮窗服务在后台稳定运行、避免被系统中断,我们会引导您开启应用自启动、将本 App 加入电池优化白名单(忽略电池优化)。这些设置仅用于维持服务可用性,不收集您的个人信息。您可随时在系统设置中关闭,关闭后可能影响比价服务的稳定性。</li>
</ol>
<div class="ag-h3">(三)核心比价功能所需的系统能力</div>
<div class="ag-p">为实现“在购物页面一键比价”以及“省钱记录”,本 App 需要您开启系统的无障碍服务与悬浮窗权限。开启后,<strong>仅在您使用比价相关功能时</strong>,本 App 会:①当您点击“比价”时,识别您当前查看的商品信息(如名称、价格、规格),并代您在其他购物平台搜索同款、读取价格,生成比价结果;②当您通过比价结果跳转并在第三方平台完成购买后,识别下单/支付成功结果,用于为您生成省钱记录。我们就此说明:</div>
<ul>
<li>商品识别尽量在您的设备本地完成,仅将商品文字关键信息(如名称/关键词、品牌、规格、价格)上传至服务器用于同款匹配,<strong>不会上传商品页面截图或任何屏幕图像</strong></li>
<li>下单记录:当您完成购买后,本 App 会识别下单/支付成功页面的结果信息(如下单平台、商品、金额、下单时间),用于在您的“比价记录/省钱统计”中展示。<strong>我们仅记录上述用于省钱统计所必需的信息,不会记录您的收货地址、收件人、银行卡号、支付密码、短信验证码或聊天等内容</strong></li>
<li>本 App 不会代您下单或支付,也不会进行与上述比价、省钱记录无关的操作;</li>
<li>比价结果仅供参考,各平台价格与优惠实时变动,最终价格、优惠及交易以第三方平台实际展示和结算为准;</li>
<li>您可随时在系统设置中关闭相关权限,关闭后将无法使用自动比价与省钱记录功能,但不影响 App 内其他功能。</li>
</ul>
<div class="ag-h3">(四)为您提供比价记录与“省钱统计”</div>
<div class="ag-p">为向您展示历史比价记录与累计为您节省的金额,我们会记录:</div>
<ul>
<li>比价记录:您在本 App 内发起的比价(如比价时间、目标商品、各平台价格对比结果);</li>
<li>下单记录:通过无障碍服务识别您在第三方平台的下单/支付成功结果(下单平台、商品、金额、下单时间),用于统计为您节省的金额并在记录页展示。<strong>我们仅记录用于上述统计所必需的信息,不记录您的收货地址、收件人、银行卡号、支付密码等信息。</strong>识别方式详见第(三)项关于无障碍服务的说明。</li>
</ul>
<div class="ag-p">此外,返佣/返现结算:我们通过所对接的 CPS 联盟接口(京东联盟、淘宝闪购联盟、美团联盟等)获取相关订单的结算状态,用于发放返现/奖励。</div>
<div class="ag-h3">(五)金币、奖励与提现功能(含敏感个人信息)</div>
<div class="ag-p">本 App 提供金币体系:您可通过签到、记录比价战绩、观看激励视频、邀请好友等任务获取金币,金币可按 App 内公示规则提现(具体兑换比例、提现门槛以 App 内最新公示为准)。为此我们会处理:</div>
<ul>
<li>任务与金币记录:您的签到、任务完成、金币收支、邀请关系等记录;</li>
<li>提现相关信息:当您申请提现时,我们通过微信支付向您的微信账户发放提现金额。<strong>实名核验与收款均由微信支付侧完成,我们不收集、不存储您的身份证件号码、银行卡号等信息</strong>;我们仅处理完成微信提现所必需的信息(如您的微信授权标识/OpenID、提现金额与提现记录)。如您不使用提现功能,则无需提供上述信息。</li>
</ul>
<div class="ag-h3">(六)为您展示内容与改进服务</div>
<div class="ag-p">为向您展示首页低价商品、“低价盲盒”、福利任务等内容,并改进我们的产品与服务,我们会收集您的浏览、点击、搜索、比价、收藏等行为信息进行统计分析。<strong>我们不基于上述信息对您进行个性化推荐。</strong></div>
<div class="ag-h3">(七)广告功能</div>
<div class="ag-p">在比价等待期的悬浮窗中、以及福利 Tab“看视频赚金币”任务中,我们会向您展示广告(含激励视频广告)。为实现广告展示与反作弊,接入的广告 SDK 可能收集设备信息等(详见第九条 SDK 清单)。</div>
<ul>
<li><strong>首次比价不展示任何广告;广告从第二次比价开始展示。</strong></li>
<li>您可通过【我的 → 设置 → 关于傻瓜比价 → 个性化广告】管理个性化广告;关闭后您看到的广告相关性会降低,但广告不会消失。</li>
</ul>
<div class="ag-h3">(八)跳转第三方平台与私域社群</div>
<ul>
<li>跳转购买:您点击比价结果卡片或低价商品时,我们会通过 CPS 联盟推广链接将您跳转至对应第三方平台的商品页面,您在该平台内完成下单与支付。<strong>我们不会获取您在第三方平台输入的账户密码、支付密码。</strong></li>
<li>私域社群:如您主动选择加入我们的福利社群,可能跳转至企业微信等第三方工具,相关信息处理适用该第三方的规则。</li>
</ul>
<div class="ag-h3">(九)为您提供客服与安全保障</div>
<ul>
<li>客服:当您联系客服时,我们会记录沟通记录、并核验您的账号信息,以处理您的问题。</li>
<li>安全保障:为保障账号、交易与系统运行安全,预防欺诈、薅羊毛、刷量等行为,我们会收集设备信息、日志信息、IP 地址、应用运行与崩溃信息等用于风险识别与防控。</li>
</ul>
<div class="ag-h3">(十)征得授权同意的例外</div>
<div class="ag-p">根据法律法规,在下列情形下我们处理您的个人信息无需征得您的授权同意:与履行法定义务相关、与国家安全/公共安全/公共卫生/重大公共利益直接相关、为订立或履行您作为一方当事人的合同所必需、在合理范围内处理您自行公开或已合法公开的信息、应急情况下为保护自然人生命健康和财产安全所必需,以及法律法规规定的其他情形。</div>
<div class="ag-hr"></div>
<div class="ag-h">二、我们如何使用 Cookie 和同类技术</div>
<div class="ag-p">为确保本产品正常运转、为您提供更便捷的体验,我们会在您的设备上存储名为 Cookie、设备标识或同类技术的小型数据文件,用于:记住您的身份与偏好设置、保障登录与数据安全、分析产品使用情况以优化体验。您可在系统/浏览器中清除或管理上述数据,但清除后可能影响您使用部分功能。</div>
<div class="ag-hr"></div>
<div class="ag-h">三、我们如何共享、转让、公开披露您的个人信息</div>
<div class="ag-h3">(一)共享</div>
<div class="ag-p">我们不会与任何公司、组织和个人共享您的个人信息,但以下情形除外:</div>
<ol>
<li>事先取得您的明确同意或授权;涉及敏感个人信息或向第三方提供个人信息时,我们将取得您的单独同意;</li>
<li>根据法律法规、行政或司法机关的强制性要求;</li>
<li>在符合法律法规的前提下,为实现本政策所述目的,与下列合作伙伴在必要范围内共享:
<ul>
<li>第三方 SDK 服务商(如广告、统计、推送、一键登录、崩溃监控等,详见第九条清单);</li>
<li>CPS/CPA 联盟平台:为完成返佣结算、订单追踪、拉新结算,我们会与所对接的电商/外卖联盟在必要范围内交互订单/设备标识等信息;</li>
<li>云服务、短信、实名核验、收款/支付通道服务商:为实现注册登录、提现到账、实名核验等。</li>
</ul>
</li>
</ol>
<div class="ag-p">对受托处理个人信息的合作方,我们会与其签订严格的数据保护协议,要求其按照本政策及法律法规处理您的个人信息。</div>
<div class="ag-h3">(二)转让</div>
<div class="ag-p">我们不会向任何公司、组织和个人转让您的个人信息,但以下情形除外:取得您的明确同意;在涉及合并、分立、收购、资产转让或类似交易时,我们会要求新的持有方继续受本政策约束,否则将要求其重新征得您的授权同意。</div>
<div class="ag-h3">(三)公开披露</div>
<div class="ag-p">我们仅在取得您单独同意、或基于法律法规、强制性行政/司法要求的情形下公开披露您的个人信息,并采取符合业界标准的安全防护措施。</div>
<div class="ag-hr"></div>
<div class="ag-h">四、我们如何保存和保护您的个人信息</div>
<div class="ag-h3">(一)保存地点</div>
<div class="ag-p">您的个人信息存储于中华人民共和国境内(我们使用阿里云作为云服务商,数据存储于其境内数据中心)。<strong>我们不会向境外传输您的个人信息。</strong></div>
<div class="ag-h3">(二)保存期限</div>
<div class="ag-p">我们仅在实现本政策所述目的所必需的最短期限内保存您的个人信息,并结合法律的强制留存要求确定保存期限(例如《电子商务法》要求商品和服务信息、交易信息自交易完成之日起保存不少于三年)。超出保存期限后,我们将对您的个人信息进行删除或匿名化处理。</div>
<div class="ag-h3">(三)保护措施</div>
<div class="ag-p">我们采用符合业界标准的安全防护措施保护您的个人信息,包括传输与存储加密(如 HTTPS)、访问权限控制、数据脱敏、内部安全管理制度与员工保密义务等,防止信息遭到未经授权的访问、使用、修改或泄露、损毁、丢失。</div>
<div class="ag-h3">(四)安全事件处置</div>
<div class="ag-p">若不幸发生个人信息安全事件,我们将按法律法规要求及时向您告知:事件基本情况与可能影响、我们已采取或将采取的处置措施、您可自主防范和降低风险的建议、对您的补救措施等,并以推送、短信、邮件、公告等方式告知您,同时按规定向监管部门上报。</div>
<div class="ag-h3">(五)产品或服务停止运营时的处置</div>
<div class="ag-p">如本 App 停止运营,我们将:①以推送、公告等显著方式通知您;②停止继续收集您的个人信息;③对我们持有的您的个人信息进行删除或匿名化处理,法律法规另有强制留存要求的除外。</div>
<div class="ag-hr"></div>
<div class="ag-h">五、您如何管理您的个人信息</div>
<div class="ag-p">您对您的个人信息享有以下权利,可通过【我的 → 设置 → 隐私管理】或本政策第八条的联系方式行使:</div>
<ol>
<li>访问、复制、更正、补充您的个人信息;</li>
<li>删除您的个人信息(在我们违法处理、未经同意处理、或您注销账号等情形下,您可请求删除);</li>
<li>改变或撤回授权同意:您可关闭相应系统权限或在设置中撤回授权。请您理解,撤回同意不影响撤回前基于您同意已进行的处理;撤回后我们将无法继续提供对应功能;</li>
<li>注销账号:您可通过【我的 → 设置 → 账户与安全 → 注销账号】注销。注销后我们将停止为您提供产品/服务,并依法删除或匿名化您的个人信息;</li>
<li>获取个人信息副本;</li>
<li>响应您的请求:我们将在收到并核验您身份后的 15 天内予以答复。对合理请求原则上不收取费用;对重复、超出合理限度的请求,我们可能酌情收取成本费用或予以拒绝。</li>
</ol>
<div class="ag-p">如您对我们的答复不满意,特别是认为我们的处理行为损害了您的合法权益,您可向网信、电信、公安、市场监管等监管部门投诉、举报。</div>
<div class="ag-hr"></div>
<div class="ag-h">六、未成年人的个人信息保护</div>
<div class="ag-p">本 App 主要面向成年人。若您是 18 周岁以下的未成年人,请在监护人陪同下阅读本政策,并在监护人同意后再使用本产品及提供个人信息。对于不满 14 周岁儿童的个人信息,我们将依据《儿童个人信息网络保护规定》等要求,在取得其监护人单独同意后方可处理,并采取严格保护措施。如您是未成年人的监护人,对未成年人个人信息有任何疑问,请通过第八条方式联系我们。</div>
<div class="ag-hr"></div>
<div class="ag-h">七、通知和修订</div>
<div class="ag-p">为提供更好的服务,本政策可能适时更新。<strong>未经您明确同意,我们不会削减您依据本政策应享有的权利。</strong>政策更新后,我们会通过 App 内公告、弹窗、推送或其他适当方式提示您。对于重大变更(如处理目的、处理信息类型、共享对象、您行使权利的方式发生重大变化,或所有权结构发生重大变化等),我们会提供更显著的通知并在生效前再次征得您的同意(如适用)。</div>
<div class="ag-hr"></div>
<div class="ag-h">八、如何联系我们</div>
<div class="ag-p">如您对本政策、您的个人信息处理、或需要投诉、举报、咨询,请通过以下方式与我们联系:</div>
<ul>
<li>公司全称:万德一博(北京)科技有限公司</li>
<li>注册地址:北京市丰台区丰管路甲1号北楼三层302-765室</li>
<li>客服 / 个人信息保护事务邮箱:support@wonderable.ai</li>
</ul>
<div class="ag-p">一般情况下,我们将在 15 天内回复您的请求。</div>
<div class="ag-hr"></div>
<div class="ag-h">九、附录</div>
<div class="ag-h3">附录一:第三方 SDK 与合作方清单</div>
<div class="ag-p">本清单是应用商店审核与监管检查的重点项。穿山甲为聚合广告平台(GroMore),其下游可能调用快手、腾讯优量汇等第三方广告网络,这些下游网络也会收集设备标识等信息,已在表中一并披露。</div>
<table>
<thead>
<tr><th>SDK / 合作方名称</th><th>提供方公司全称</th><th>使用目的</th><th>收集的个人信息</th><th>隐私政策链接</th></tr>
</thead>
<tbody>
<tr><td>穿山甲(GroMore 聚合广告平台,含激励视频广告)</td><td>北京巨量引擎网络技术有限公司</td><td>展示激励视频广告、广告归因与反作弊</td><td>设备标识符(OAID 等)、设备信息、网络信息、广告交互数据</td><td><a href="https://www.csjplatform.com/privacy">https://www.csjplatform.com/privacy</a></td></tr>
<tr><td>└ 经穿山甲聚合调用的下游广告网络(快手广告)</td><td>北京快手科技有限公司</td><td>通过聚合平台填充与展示广告</td><td>设备标识符、设备信息、网络信息</td><td><a href="https://www.kuaishou.com/about/policy">https://www.kuaishou.com/about/policy</a></td></tr>
<tr><td>└ 经穿山甲聚合调用的下游广告网络(腾讯优量汇)</td><td>深圳市腾讯计算机系统有限公司</td><td>通过聚合平台填充与展示广告</td><td>设备标识符、设备信息、网络信息</td><td><a href="https://e.qq.com/optout.html">https://e.qq.com/optout.html</a></td></tr>
<tr><td>极光(JIGUANG,一键登录 / 号码认证 / 短信验证)</td><td>深圳市和讯华谷信息技术有限公司</td><td>一键登录取号、短信验证码下发与号码认证</td><td>本机号码(运营商掩码取号)、设备信息、网络信息</td><td><a href="https://www.jiguang.cn/license/privacy">https://www.jiguang.cn/license/privacy</a></td></tr>
<tr><td>美团联盟(API,无 SDK</td><td>北京三快科技有限公司</td><td>外卖/团购比价的返佣订单追踪与结算</td><td>订单信息、推广标识</td><td>以美团联盟官方规则为准</td></tr>
<tr><td>京东联盟(API/链接,无 SDK</td><td>北京京东叁佰陆拾度电子商务有限公司及其关联公司</td><td>电商比价的返佣订单追踪与结算</td><td>订单信息、推广标识</td><td>以京东联盟官方规则为准</td></tr>
<tr><td>淘宝闪购联盟(API/链接,无 SDK)</td><td>阿里巴巴(中国)软件有限公司 / 阿里妈妈</td><td>电商/闪购比价的返佣订单追踪与结算</td><td>订单信息、推广标识</td><td>以对应联盟官方规则为准</td></tr>
<tr><td>阿里云</td><td>阿里云计算有限公司</td><td>云服务器、数据存储等基础设施(受托处理)</td><td>受托存储上述各项个人信息</td><td><a href="https://www.aliyun.com/">https://www.aliyun.com/</a></td></tr>
<tr><td>微信支付(提现)</td><td>财付通支付科技有限公司</td><td>向用户发放微信提现、实名核验与收款(由微信侧完成)</td><td>微信授权标识/OpenID、提现金额与记录</td><td>以微信支付官方规则为准</td></tr>
</tbody>
</table>
<div class="ag-note">说明:本 App 未接入独立的统计分析、消息推送 SDK,也不会将屏幕图像上传至云端 AI 大模型。</div>
<div class="ag-h3">附录二:本政策涉及的敏感个人信息清单</div>
<div class="ag-p">为便于您识别,下列为本 App 在特定功能下处理的、属于敏感或受单独同意约束的信息:</div>
<ul>
<li>下单记录:通过无障碍服务识别您的下单/支付成功结果(下单平台、商品、金额、下单时间)用于省钱统计,详见正文第一条第(三)项;</li>
<li>提现:实名核验与银行卡/身份证件号码由微信支付侧收集与处理,本 App 不收集、不存储。</li>
<li>本 App 不处理人脸、指纹等生物识别信息,不收集身份证件号码、银行卡号,不收集行踪轨迹,不读取通讯录、短信、相册(除您主动上传的反馈截图外)。</li>
</ul>
</div>
<div class="ag-foot">傻瓜比价 · 隐私政策<br>万德一博(北京)科技有限公司</div>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../../shared/fonts.css">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'PuHuiTi',-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;background:#F5F5F5;color:#1A1A1A;padding:4px 14px 40px;-webkit-text-size-adjust:100%}
.ag-title{font-size:19px;font-weight:700;line-height:1.4;color:#1A1A1A;text-align:center;margin:10px 0 6px}
.ag-meta{font-size:12px;font-weight:400;line-height:1.7;color:#999;text-align:center;margin-bottom:14px}
.ag-card{background:#fff;border-radius:14px;box-shadow:0 1px 2px rgba(0,0,0,.03);padding:18px 16px 20px}
.ag-lead{font-size:13px;font-weight:400;line-height:1.85;color:#666;margin-bottom:10px}
.ag-h{font-size:16px;font-weight:600;line-height:1.5;color:#1A1A1A;margin:22px 0 8px}
.ag-p{font-size:13px;font-weight:400;line-height:1.85;color:#666;margin-bottom:8px}
strong{font-weight:600;color:#1A1A1A}
ol{margin:4px 0 8px;padding-left:20px}
li{font-size:13px;font-weight:400;line-height:1.8;color:#666;margin-bottom:6px}
.ag-hr{border:none;border-top:0.5px solid #E5E5E5;margin:16px 0}
a{color:#1565C0;text-decoration:none;word-break:break-all}
.ag-foot{text-align:center;font-size:12px;font-weight:400;color:#CCC;line-height:1.7;margin:24px 0 8px}
</style>
</head>
<body>
<div class="ag-meta">生效日期:【2026 年 06 月 25 日】</div>
<div class="ag-card">
<div class="ag-lead">欢迎您使用“傻瓜比价”App!本《傻瓜比价用户服务协议》(以下简称“本协议”)是您与万德一博(北京)科技有限公司(注册地址:北京市丰台区丰管路甲1号北楼三层302-765室,以下简称“我们”或“傻瓜比价”)之间就您注册、使用“傻瓜比价”App(以下简称“本 App”或“本服务”)所订立的协议。</div>
<div class="ag-p"><strong>请您务必审慎阅读、充分理解本协议各条款,特别是以加粗形式提示您的免除或限制我们责任、需您自行承担风险或责任的条款。</strong>如您未满 18 周岁,请在监护人陪同下阅读并在其同意后使用本服务。</div>
<div class="ag-p"><strong>当您勾选同意、或以注册、登录、使用本服务等任何方式表示接受本协议时,即视为您已阅读并同意本协议的全部内容,自愿接受其约束。</strong>如您不同意本协议任何内容,请勿注册或使用本服务。</div>
<div class="ag-p">本协议内容同时包括《傻瓜比价隐私政策》及我们在 App 内公示的与本服务相关的各项规则(如金币规则、活动规则等),上述内容为本协议不可分割的组成部分,与本协议正文具有同等效力。</div>
<div class="ag-hr"></div>
<div class="ag-h">一、服务说明(请重点阅读本 App 的性质)</div>
<ol>
<li><strong>本 App 是一款“比价工具”/信息服务,不是商品或服务的销售方、交易方。</strong>本 App 帮助您在淘宝、京东、拼多多、抖音、美团等第三方平台浏览商品时,自动跨平台搜索同款商品并展示价格对比结果。</li>
<li><strong>我们不销售任何商品、不提供外卖/团购/物流等交易及履约服务。</strong>您看到的商品、价格、优惠、店铺均来自第三方平台;您的下单、支付、发货、售后、退换货等全部在相应第三方平台内完成,并受该第三方平台自身的用户协议、规则与隐私政策约束。因商品质量、发货、售后、虚假宣传等产生的争议,应由您与该第三方平台或商家解决,我们不承担销售者/服务提供者责任,但会在合理范围内为您提供必要协助。</li>
<li><strong>比价结果仅供参考。</strong>各平台价格、库存、优惠券、满减、返现等实时变动,且受地域、账号、活动、时间等多种因素影响。我们力求比价结果准确及时,但<strong>不对比价结果(包括但不限于“全网最低价”的表述、同款匹配的准确性、价格的实时性)作任何明示或默示的保证;最终价格、优惠及交易以第三方平台实际展示和结算为准。</strong>请您在下单前自行核对。</li>
<li><strong>本服务的核心功能依赖系统无障碍服务与悬浮窗权限。</strong>您在使用前需要在系统设置中授予相应权限。关于该等权限读取与处理信息的范围、本地处理原则及您的关闭方式,详见《傻瓜比价隐私政策》第一条第(三)项。</li>
</ol>
<div class="ag-h">二、比价功能的开启与使用</div>
<ol>
<li>本服务的比价功能需要您开启系统的无障碍服务与悬浮窗权限。开启后,仅在您使用比价相关功能时,本 App 会识别您当前查看的商品并代您在其他购物平台搜索同款、比对价格,并在您完成购买后识别下单结果用于为您生成省钱记录;不会代您下单、支付或修改账户信息,也不会进行与比价、省钱记录无关的操作。关于权限读取与处理信息的范围、本地处理原则及关闭方式,详见《傻瓜比价隐私政策》第一条第(三)项。</li>
<li><strong>您理解并同意,是否使用比价功能由您自主选择。</strong>您可随时在系统设置中关闭相关权限,关闭后比价功能将不可用,但不影响您使用 App 内其他功能。</li>
</ol>
<div class="ag-h">三、账号的注册与使用</div>
<ol>
<li><strong>【注册资料】</strong>您应以诚实信用原则提供真实、准确、合法的注册信息(如手机号码),并在信息变动时及时更新。因您提供的信息不真实、不准确或未及时更新导致的后果,由您自行承担。</li>
<li><strong>【实名与提现】</strong>因法律法规、反洗钱及收款渠道要求,您在使用提现等功能时可能需要完成实名核验并提供收款账号信息,详见《傻瓜比价隐私政策》。未完成必要核验的,您可能无法使用相应功能。</li>
<li><strong>【账号安全】</strong>您应妥善保管账号及密码,对账号项下的全部行为负责。如发现账号被盗用或存在安全问题,应立即通知我们。因您自身保管不善导致的账号丢失、泄露及由此产生的损失,我们不承担责任(我们存在过错的除外)。</li>
<li><strong>【账号管理】</strong>原则上一名用户对应一个账号。如您存在不当注册或不当使用多个账号、利用账号从事违规行为的情形,我们有权对相关账号采取限制、冻结、合并、注销等措施。</li>
</ol>
<div class="ag-h">四、金币、奖励与提现规则</div>
<ol>
<li><strong>【金币性质】</strong>金币是本 App 内用于激励用户的虚拟权益,不是法定货币、不可在用户间转让、不可用于本 App 约定用途之外的交易。金币的获取、消耗、兑换、提现规则以 App 内公示的最新规则为准。</li>
<li><strong>【提现】</strong>金币可按 App 内公示的兑换比例与提现门槛提现(具体数值以 App 内最新公示页面为准)。当前提现通过微信支付发放,实名核验与收款由微信支付侧完成。</li>
<li><strong>【反作弊】(请重点阅读)您不得通过作弊、外挂、机器批量操作、虚假注册、刷量、薅羊毛、利用系统漏洞等任何不正当方式获取金币或奖励。一经发现,我们有权扣除相应金币/奖励、驳回提现、冻结或注销账号,并保留追究法律责任的权利。</strong>金币规则及反作弊规则如需调整,我们将提前以合理方式公示;如您对反作弊判定有异议,可通过第五条约定的申诉渠道提出申诉。</li>
<li><strong>【活动规则】</strong>各项福利任务、裂变、邀请等活动的具体规则以活动页面公示为准;如活动规则与本协议不一致,就该活动事项以活动规则为准。</li>
</ol>
<div class="ag-h">五、用户行为规范</div>
<div class="ag-p">您承诺在使用本服务过程中遵守法律法规,不从事下列行为,否则我们有权视情形采取警告、限制功能、冻结或注销账号等措施,由此产生的责任由您承担:</div>
<ol>
<li>发布、传播违反宪法确定的基本原则,危害国家安全、社会稳定、违背公序良俗,或法律法规禁止的其他内容;</li>
<li>侵犯他人知识产权、隐私权、名誉权等合法权益;</li>
<li>实施网络攻击、传播病毒/木马,破解、干扰、破坏本服务或其相关系统;</li>
<li>未经我们书面许可,自行或协助第三方对本服务进行非法抓取、反向工程、反编译,或使用外挂、插件、机器人等干扰本服务正常运行;</li>
<li>利用本服务从事诈骗、洗钱、刷单炒信、薅羊毛等违法或不正当行为;</li>
<li>其他违反法律法规或损害我们、其他用户、第三方合法权益的行为。</li>
</ol>
<div class="ag-p"><strong>【处罚与申诉】</strong>我们对账号采取限制、冻结、注销等处罚措施时,将根据违规行为的性质与严重程度合理处理,并以适当方式告知您处罚的事由。如您认为处罚有误,可通过 support@wonderable.ai 或 App 内客服渠道提出申诉,我们将在收到申诉后 15 个工作日内核实并答复。我们不会在不提供任何申诉渠道的情况下对账号作出无理由的永久封禁。</div>
<div class="ag-h">六、知识产权</div>
<ol>
<li>除另有声明外,本服务所包含的内容(包括但不限于软件、技术、程序、界面、文字、图片、图标、音视频、版面设计、商标、标识等)的知识产权归我们或相关权利人所有,受法律保护。</li>
<li>未经我们事先书面许可,您不得以任何形式使用、复制、修改、发布、出售、出租、反向工程、反编译上述内容,或制作衍生作品。</li>
<li>您在本服务中上传/发布的内容(如“反馈更低价”提交的链接、截图、文字等),应为您原创或已获合法授权,不侵犯他人合法权益;您授权我们在为提供和改进本服务所必需的范围内对该等内容进行使用。</li>
<li><strong>【侵权投诉处理】</strong>如您认为本服务中的内容侵犯了您的知识产权或其他合法权益,可通过 support@wonderable.ai 向我们发起投诉并提供权属证明及相关材料。我们核实后将依法采取删除、屏蔽等必要措施;被投诉方可提交不侵权声明进行申诉。</li>
</ol>
<div class="ag-h">七、第三方平台与第三方服务</div>
<ol>
<li>本服务包含跳转至第三方平台(电商/外卖/团购平台、CPS 联盟链接、私域社群、广告等)的功能。该等第三方平台与服务由第三方独立提供并独立承担责任,受其自身协议与规则约束。我们对第三方平台的内容、商品、服务、价格及其行为不作保证,亦不承担责任。</li>
<li>您通过本服务跳转第三方平台进行的交易,是您与该第三方平台/商家之间的交易,相关权利义务由您与该第三方解决。</li>
</ol>
<div class="ag-h">八、广告</div>
<ol>
<li>您理解并同意,为维持本服务的免费运营,我们或我们授权的第三方可能在本服务中(如比价等待期悬浮窗、福利任务)向您展示广告(含激励视频广告)。</li>
<li><strong>您应对广告内容自行审慎判断,除法律法规明确规定外,您应对依广告信息进行的交易自行负责。</strong>您可按《傻瓜比价隐私政策》的说明管理个性化广告。</li>
</ol>
<div class="ag-h">九、免责声明与责任限制(请重点阅读)</div>
<ol>
<li><strong>【服务“现状”提供】</strong>在法律允许的范围内,本服务按“现状”和“可得”基础提供。我们不保证本服务不会中断,也不保证其及时性、安全性、准确性、无错误。</li>
<li><strong>【比价准确性免责】</strong>如前所述,我们不对比价结果的准确性、完整性、实时性及“最低价”表述作保证,您应在第三方平台下单前自行核对价格与优惠。</li>
<li><strong>【第三方原因免责】</strong>对因第三方平台规则变化、接口变动、风控封禁、第三方 SDK/服务故障等非我们直接原因导致的服务异常或损失,在法律允许范围内我们不承担责任。</li>
<li><strong>【第三方平台规则风险】</strong>您理解并同意,是否使用本服务由您自主选择。各第三方平台对其平台的访问与使用有其自身规则,若您与第三方平台之间因平台规则产生账号限制等后果,在法律允许范围内由您与该第三方平台依其规则处理,我们不对该等第三方平台的处理结果承担责任;我们会在合理范围内持续优化服务以降低此类情况发生。</li>
<li><strong>【不可抗力】</strong>因不可抗力(如自然灾害、网络故障、电力中断、政府行为、法律法规变动等)导致服务中断或无法提供的,在法律允许范围内我们免责,但会尽力减少对您的影响。</li>
<li><strong>【责任限额】</strong>在法律允许的最大范围内,对于因使用或无法使用本服务造成的间接、偶然、惩罚性损失(如利润损失、数据损失),我们不承担责任;我们对您承担的累计赔偿责任总额,以法律强制规定为限【如需设置上限,由法务确定具体表述】。本条不排除或限制依法不可排除或限制的责任(如因我们故意或重大过失、或侵害您人身权益依法应承担的责任)。</li>
</ol>
<div class="ag-h">十、协议的变更、中止与终止</div>
<ol>
<li><strong>【协议变更】</strong>我们可根据法律法规变化或运营需要修订本协议,并通过 App 内公告、弹窗、推送等合理方式提前通知。变更内容将在公示的生效日期起施行(通常不短于公示后 7-8 个自然日)。如您不同意变更内容,应停止使用相关服务;变更生效后您继续使用的,视为同意变更后的协议。</li>
<li><strong>【服务中止/终止】</strong>在您违反本协议、法律法规要求、或我们基于合理商业安排经合理通知终止服务等情形下,我们有权中止或终止向您提供部分或全部服务。</li>
<li><strong>【账号注销】</strong>您可按 App 内流程注销账号;注销后我们将停止为您提供服务,并依法处理您的个人信息。</li>
<li>协议终止不影响在此之前已产生的权利义务,以及知识产权、责任限制等依其性质应继续有效的条款。</li>
</ol>
<div class="ag-h">十一、通知与送达</div>
<div class="ag-p">我们可通过以下任一方式向您送达通知:App 内公告/弹窗、站内信、推送、向您注册的手机号发送短信、向您提供的邮箱发送邮件等。上述通知于发送成功或刊登完成时视为送达。请您保证联系方式真实有效并及时更新,因联系方式不准确或未及时更新导致通知无法送达的后果由您承担。</div>
<div class="ag-h">十二、法律适用与争议解决</div>
<ol>
<li>本协议的订立、生效、履行、解释及争议解决,适用中华人民共和国法律(不含冲突法及港澳台地区法律)。</li>
<li>因本协议或本服务产生的争议,您与我们应友好协商解决;协商不成的,任何一方均可向本协议签订地有管辖权的人民法院提起诉讼。本协议签订地为北京市丰台区。(注:约定公司所在地法院管辖属合理约定;不得约定与双方无实际联系的偏远地区法院,否则该管辖约定可能被认定无效。)</li>
</ol>
<div class="ag-h">十三、其他</div>
<ol>
<li>本协议各条款标题仅为方便阅读,不影响条款含义的解释。</li>
<li>本协议任一条款被认定为无效或不可执行的,不影响其余条款的效力。</li>
<li>如您对本协议有任何问题或建议,可通过《傻瓜比价隐私政策》“如何联系我们”中的方式与我们联系。</li>
</ol>
</div>
<div class="ag-foot">傻瓜比价 · 用户服务协议<br>万德一博(北京)科技有限公司</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+298
View File
@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../shared/fonts.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>收益明细 · 傻瓜比价</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
/* WebView 是 MATCH_PARENTheight:100% 链即可拿到真实视口高;页面本身不滚,内部列表面板才滚。 */
html, body { height: 100%; overflow: hidden; }
body { font-family: 'PuHuiTi', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; background: #FFFFFF; color: #1A1A1A; }
/* 【去框】原型 .device 375x812 预览框 → 全屏自适应容器(无 min-height:100vh,避免顶栏下移) */
.device { position: relative; width: 100%; height: 100%; background: #FFFFFF; overflow: hidden; }
.screen { position: absolute; inset: 0; display: flex; flex-direction: column; }
/* header —— 与设置/记录等已改造页统一:48px 高、返回居左、标题居中 */
.hdr { height: 48px; flex-shrink: 0; display: flex; align-items: center; padding: 0 8px; position: relative; background: #FFFFFF; }
.hdr-back { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; background: none; border: none; cursor: pointer; padding: 0; }
.hdr-back:active { opacity: .5; }
.hdr-back svg { width: 24px; height: 24px; display: block; }
.hdr-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: #1A1A1A; }
/* 数字用 DIN(对齐原生货币表现) */
.num { font-family: "DIN Alternate", "Helvetica Neue", sans-serif; font-variant-numeric: tabular-nums; }
/* 兑换比例说明条 */
.rate { flex-shrink: 0; padding: 0 16px 8px; font-size: 12px; line-height: 18px; font-weight: 400; color: #666666; }
/* 收益汇总卡 */
.sum-card { flex-shrink: 0; margin: 0 16px 16px; padding: 16px; border-radius: 18px; background: linear-gradient(180deg, #FFE86A 0%, #FFD600 100%); box-shadow: 0 6px 18px rgba(255,179,0,.22); }
.sum-cols { display: flex; gap: 22px; }
.sum-col { flex: 1; min-width: 0; cursor: pointer; }
.sum-label { font-size: 15px; font-weight: 600; color: #1A1A1A; line-height: 20px; }
.sum-valrow { display: flex; align-items: center; gap: 8px; min-height: 31px; margin-top: 8px; }
.sum-val { font-size: 28px; font-weight: 700; color: #FF5A1F; line-height: 30px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chip { flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; height: 26px; padding: 0 10px; border-radius: 999px; background: #FFFFFF; border: none; box-shadow: 0 2px 6px rgba(139,94,0,.13); font-size: 12px; font-weight: 700; color: #1A1A1A; white-space: nowrap; cursor: pointer; }
.chip:active { transform: translateY(1px) scale(.98); }
.chip svg, .chip img { width: 16px; height: 16px; display: block; border-radius: 50%; }
.sum-note { font-size: 11px; font-weight: 400; color: rgba(26,26,26,.58); line-height: 15.4px; margin-top: 8px; }
/* Tab 切换 */
.tabs { position: relative; flex-shrink: 0; display: flex; padding: 0 16px; border-bottom: 2px solid #E5E5E5; }
.tab { flex: 1; text-align: center; padding: 16px 0 12px; font-size: 14px; font-weight: 400; color: #999999; background: none; border: none; cursor: pointer; }
.tab.active { color: #000000; }
.indicator { position: absolute; bottom: -2px; left: 0; width: 44px; height: 3px; border-radius: 999px; background: #1A1A1A; transition: left .24s ease; }
/* 列表:两面板可左右切 */
.list-shell { flex: 1; min-height: 0; overflow: hidden; position: relative; }
.track { display: flex; height: 100%; width: 200%; transition: transform .24s ease; }
.panel { width: 50%; height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch; padding-bottom: 16px; }
.panel::-webkit-scrollbar { display: none; }
.row { min-height: 72px; display: flex; align-items: center; padding: 0 16px; border-bottom: .5px solid #F5F5F5; }
.row-main { flex: 1; min-width: 0; }
.row-title { font-size: 14px; font-weight: 400; color: #1A1A1A; line-height: 21px; word-break: break-word; }
.row-date { font-size: 12px; font-weight: 400; color: #B3B3B3; line-height: 16px; margin-top: 4px; }
.row-amt { margin-left: 8px; font-size: 15px; font-weight: 400; line-height: 22px; white-space: nowrap; }
.row-amt.pos { color: #FF5722; }
.row-amt.neg { color: #1A1A1A; }
.empty { text-align: center; font-size: 13px; font-weight: 400; color: #B3B3B3; line-height: 20px; padding: 40px 0 4px; }
.load-more { width: 100%; border: 0; background: transparent; font-size: 13px; font-weight: 400; color: #B3B3B3; line-height: 20px; padding: 16px 0; cursor: pointer; }
.load-more:active { color: #999999; }
</style>
</head>
<body>
<div class="device">
<div class="screen">
<div class="hdr">
<button class="hdr-back" onclick="onBack()" aria-label="返回">
<svg viewBox="0 0 24 24" fill="none"><path d="M15 6L9 12L15 18" stroke="#1A1A1A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="hdr-title">收益明细</div>
</div>
<div class="rate" id="rate">兑换比例:10000 金币=1元;金币到账可能会有延迟</div>
<div class="sum-card">
<div class="sum-cols">
<div class="sum-col" onclick="switchTab(0)">
<div class="sum-label">我的金币</div>
<div class="sum-valrow"><span class="sum-val num" id="coinBal">0</span></div>
</div>
<div class="sum-col" onclick="switchTab(1)">
<div class="sum-label">我的现金</div>
<div class="sum-valrow">
<span class="sum-val num" id="cashBal">0.00</span>
<button class="chip" onclick="event.stopPropagation(); goWithdraw()" aria-label="去提现">
<img src="assets/wechat-pay-check-icon.png" alt="">去提现
</button>
</div>
</div>
</div>
<div class="sum-note">0点自动兑现金(可能存在延迟)</div>
</div>
<div class="tabs">
<button class="tab active" id="tab0" onclick="switchTab(0)">金币记录</button>
<button class="tab" id="tab1" onclick="switchTab(1)">现金记录</button>
<div class="indicator" id="indicator"></div>
</div>
<div class="list-shell" id="listShell">
<div class="track" id="track">
<div class="panel" id="panelCoin"></div>
<div class="panel" id="panelCash"></div>
</div>
</div>
</div>
</div>
<script src="../shared/bridge.js"></script>
<script src="../shared/api.js"></script>
<script>
(function () {
'use strict';
var curTab = (new URLSearchParams(location.search).get('tab') === 'cash') ? 1 : 0;
var coinCursor = null, cashCursor = null; // 下一页游标(末条 id);null=首屏未拉或已到底
var coinLoaded = false, cashLoaded = false; // 该 tab 是否已拉过首屏(用于区分"加载中" vs "空")
var coinPerYuan = 10000;
// ---------- 工具 ----------
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function thousands(n) { return Number(n || 0).toLocaleString('zh-CN'); }
function yuan(cents) {
return (Number(cents || 0) / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function dateOf(iso) { return String(iso || '').slice(0, 10); }
// 标题映射(对齐原生 CoinHistoryViewModel.coinTitle / cashTitle)
function coinTitle(bt, remark) {
if (bt === 'exchange_out') return '金币兑换现金';
if (remark && remark.trim()) return remark;
if (bt === 'signin') return '每日签到';
if (bt === 'signin_boost') return '签到膨胀奖励';
if (bt === 'task_enable_notification') return '打开消息提醒奖励';
if (bt && bt.indexOf('task_') === 0) return '任务奖励';
if (bt === 'reward_video' || bt === 'ad_reward') return '看视频奖励金币';
if (bt === 'feed_ad_reward') return '信息流广告奖励';
if (bt === 'invite') return '邀请好友';
if (bt === 'compare_milestone') return '比价奖励';
return '奖励';
}
function cashTitle(bt) {
if (bt === 'exchange_in') return '金币兑换现金';
if (bt === 'withdraw') return '提现';
if (bt === 'withdraw_refund') return '提现退款';
return '其他';
}
function signedCoin(a) { return (a >= 0 ? '+ ' : '- ') + thousands(Math.abs(a)) + ' 金币'; }
function signedCash(c) { return (c >= 0 ? '+ ' : '- ') + yuan(Math.abs(c)) + ' 元'; }
function rowHtml(title, date, amountStr) {
var neg = amountStr.replace(/\s/g, '').charAt(0) === '-';
return '<div class="row"><div class="row-main"><div class="row-title">' + esc(title) +
'</div><div class="row-date num">' + esc(date) + '</div></div>' +
'<div class="row-amt num ' + (neg ? 'neg' : 'pos') + '">' + esc(amountStr) + '</div></div>';
}
// ---------- 渲染 ----------
function renderPanel(tab) {
var panel = document.getElementById(tab === 0 ? 'panelCoin' : 'panelCash');
var rows = tab === 0 ? coinRows : cashRows;
var loaded = tab === 0 ? coinLoaded : cashLoaded;
var cursor = tab === 0 ? coinCursor : cashCursor;
if (!loaded) { panel.innerHTML = ''; return; } // 首屏还没回来:留白(不闪"暂无")
if (!rows.length) {
panel.innerHTML = '<div class="empty">' + (tab === 0 ? '暂无金币记录' : '暂无现金记录') + '</div>';
return;
}
var html = rows.join('');
if (cursor != null) {
html += '<button class="load-more" onclick="window.__loadMore(' + tab + ')">查看更多历史明细</button>';
}
panel.innerHTML = html;
}
var coinRows = [], cashRows = [];
function applyCoinPage(page, append) {
var mapped = (page.items || []).map(function (it) {
return rowHtml(coinTitle(it.biz_type, it.remark), dateOf(it.created_at), signedCoin(it.amount));
});
coinRows = append ? coinRows.concat(mapped) : mapped;
coinCursor = page.next_cursor != null ? page.next_cursor : null;
coinLoaded = true;
renderPanel(0);
}
function applyCashPage(page, append) {
var mapped = (page.items || []).map(function (it) {
return rowHtml(cashTitle(it.biz_type), dateOf(it.created_at), signedCash(it.amount_cents));
});
cashRows = append ? cashRows.concat(mapped) : mapped;
cashCursor = page.next_cursor != null ? page.next_cursor : null;
cashLoaded = true;
renderPanel(1);
}
// ---------- 数据 ----------
function loadBalance() {
return SGApi.get('/wallet/account').then(function (a) {
if (!a) return;
document.getElementById('coinBal').textContent = String(a.coin_balance || 0);
document.getElementById('cashBal').textContent = yuan(a.cash_balance_cents);
}).catch(function () {});
}
function loadRate() {
return SGApi.get('/wallet/exchange-info').then(function (info) {
if (info && info.coin_per_yuan) {
coinPerYuan = info.coin_per_yuan;
document.getElementById('rate').textContent =
'兑换比例:' + coinPerYuan + ' 金币=1元;金币到账可能会有延迟';
}
}).catch(function () {});
}
function loadCoin() {
return SGApi.get('/wallet/coin-transactions?limit=20').then(function (p) { applyCoinPage(p || { items: [] }, false); })
.catch(function () { coinLoaded = true; renderPanel(0); });
}
function loadCash() {
return SGApi.get('/wallet/cash-transactions?limit=20').then(function (p) { applyCashPage(p || { items: [] }, false); })
.catch(function () { cashLoaded = true; renderPanel(1); });
}
window.__loadMore = function (tab) {
if (tab === 0 && coinCursor != null) {
SGApi.get('/wallet/coin-transactions?limit=20&cursor=' + coinCursor).then(function (p) { applyCoinPage(p || { items: [] }, true); }).catch(function () {});
} else if (tab === 1 && cashCursor != null) {
SGApi.get('/wallet/cash-transactions?limit=20&cursor=' + cashCursor).then(function (p) { applyCashPage(p || { items: [] }, true); }).catch(function () {});
}
};
// ---------- Tab / 指示器 / 滑动 ----------
function positionIndicator() {
var tabs = document.querySelector('.tabs');
var ind = document.getElementById('indicator');
if (!tabs || !ind) return;
var side = 16, w = tabs.clientWidth, tabW = (w - side * 2) / 2;
ind.style.left = (side + tabW * curTab + (tabW - 44) / 2) + 'px';
}
window.switchTab = function (idx) {
curTab = idx;
document.getElementById('track').style.transform = 'translateX(' + (idx === 1 ? '-50%' : '0') + ')';
document.getElementById('tab0').classList.toggle('active', idx === 0);
document.getElementById('tab1').classList.toggle('active', idx === 1);
positionIndicator();
};
// 横滑切 tab:仅在明显水平手势时触发,纵向留给列表滚动
(function bindSwipe() {
var shell = document.getElementById('listShell'), x0 = 0, y0 = 0, tracking = false;
shell.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { tracking = false; return; }
x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; tracking = true;
}, { passive: true });
shell.addEventListener('touchend', function (e) {
if (!tracking) return; tracking = false;
var t = e.changedTouches[0], dx = t.clientX - x0, dy = t.clientY - y0;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
if (dx < 0 && curTab === 0) switchTab(1);
else if (dx > 0 && curTab === 1) switchTab(0);
}
}, { passive: true });
})();
window.onBack = function () {
if (window.SGBridge && SGBridge.closePage) { SGBridge.closePage(); }
else if (history.length > 1) { history.back(); }
};
window.goWithdraw = function () {
if (window.SGBridge && SGBridge.navigate) { SGBridge.navigate('withdrawal'); }
};
// 初始 tab(不触发动画,直接就位)
document.getElementById('track').style.transform = 'translateX(' + (curTab === 1 ? '-50%' : '0') + ')';
document.getElementById('tab0').classList.toggle('active', curTab === 0);
document.getElementById('tab1').classList.toggle('active', curTab === 1);
window.addEventListener('resize', positionIndicator);
requestAnimationFrame(positionIndicator);
// 首拉 + 可见时轻量刷余额(后台审核发币等外部变更停在本页也能刷出)
loadBalance(); loadRate(); loadCoin(); loadCash();
if (window.SGBridge && SGBridge.on) {
SGBridge.on('onResume', loadBalance);
SGBridge.on('onAuthChange', function () { loadBalance(); loadRate(); coinLoaded = cashLoaded = false; coinCursor = cashCursor = null; loadCoin(); loadCash(); });
}
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+335
View File
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../shared/fonts.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>意见反馈 · 傻瓜比价</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; overflow: hidden; }
/* 【去框】原型 body 黑底 + flex 居中(浏览器预览用)。WebView 里直接铺页面色、不居中。 */
body {
font-family: 'PuHuiTi', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
background: #F5F5F5;
color: #1A1A1A;
}
/* 【去框】原型 .device 是 375x812 预览框(圆角+阴影+固定宽高)→ 全屏自适应容器。 */
.device { position: relative; width: 100%; height: 100%; background: #F5F5F5; overflow: hidden; }
/* 意见反馈屏:灰底白卡(原型 #feedback background #F5F5F5 */
#feedback { position: absolute; inset: 0; display: flex; flex-direction: column; background: #F5F5F5; }
/* header —— 返回 | 标题 | 反馈历史(原型 .header space-between;去框后顶部内距由 40 改 10,状态栏交给原生) */
.header { display: flex; align-items: center; justify-content: space-between; height: 48px; padding: 0 16px; background: #F5F5F5; flex-shrink: 0; position: relative; }
.btn-back { background: none; border: none; cursor: pointer; padding: 0; display: flex; align-items: center; }
.btn-back svg { width: 24px; height: 24px; display: block; }
.btn-back:active { opacity: .5; }
.header-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; line-height: 1.4; color: #1A1A1A; }
.fb-history-entry { background: none; border: none; cursor: pointer; font-family: inherit; font-size: 14px; font-weight: 400; color: #666666; padding: 6px 0; white-space: nowrap; transition: opacity .15s; }
.fb-history-entry:active { opacity: .6; }
/* body —— 滚动区,确定按钮 margin-top:auto 贴底 */
.fb-body { flex: 1; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 8px 16px 0; display: flex; flex-direction: column; gap: 16px; }
.fb-body::-webkit-scrollbar { display: none; }
.fb-card { background: #FFFFFF; border-radius: 14px; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
/* 顶部引导文案 + 奖励 chip */
.fb-intro { margin: -4px 2px 0; }
.fb-intro-text { font-size: 13px; font-weight: 400; line-height: 1.6; color: #666666; margin: 0; }
.fb-intro-reward { display: inline-flex; align-items: center; gap: 5px; margin-top: 10px; padding: 5px 10px; background: #FFF8E1; border-radius: 8px; font-size: 12px; font-weight: 600; color: #7A4F00; }
.fb-intro-reward .coin { width: 14px; height: 14px; display: block; flex-shrink: 0; }
.fb-section-label { font-size: 14px; font-weight: 400; line-height: 1.5; color: #1A1A1A; margin: 4px 0 8px; }
.fb-section-label .req { color: #E02E24; margin-left: 2px; }
.fb-section-label .count { color: #999999; font-weight: 400; font-size: 13px; margin-left: 4px; }
.fb-textarea { width: 100%; min-height: 140px; background: #FFFFFF; border: none; border-radius: 14px; padding: 14px 16px; font-size: 14px; font-weight: 400; color: #1A1A1A; line-height: 1.5; resize: none; outline: none; font-family: inherit; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
.fb-textarea::placeholder { color: #CCCCCC; font-weight: 400; }
.fb-upload-row { display: flex; flex-wrap: wrap; gap: 10px; }
.fb-upload-tile { width: 72px; height: 72px; background: #FFFFFF; border-radius: 14px; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
.fb-thumb { position: relative; width: 72px; height: 72px; border-radius: 14px; overflow: hidden; flex-shrink: 0; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
.fb-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fb-thumb-del { position: absolute; top: 4px; right: 4px; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,.55); color: #FFFFFF; border: none; font-size: 13px; line-height: 1; padding: 0; cursor: pointer; display: flex; align-items: center; justify-content: center; }
/* 加群二维码卡 */
.fb-qr-card { display: flex; gap: 14px; align-items: center; padding: 14px; margin-top: 8px; }
.fb-qr { width: 88px; height: 88px; background: #FFFFFF; border-radius: 8px; flex-shrink: 0; display: block; border: 1px solid #F0F0F0; overflow: hidden; }
.fb-qr svg, .fb-qr img { width: 100%; height: 100%; display: block; }
.fb-qr img { object-fit: cover; }
.fb-qr-text { font-size: 13px; font-weight: 400; line-height: 1.6; color: #1A1A1A; }
.fb-qr-text .quote { font-weight: 600; }
/* 确定按钮(DESIGN cta-primary17/700 + radius 24 + 黄渐变 + 高光 + 暖阴影) */
.fb-submit { width: 100%; border: none; padding: 14px 28px; border-radius: 24px; font-size: 17px; font-weight: 700; line-height: 1; font-family: inherit; background: linear-gradient(180deg, #FFE066 0%, #FFC400 100%); color: #1A1A1A; box-shadow: inset 0 1px 0 rgba(255,255,255,.7), 0 2px 6px rgba(255,179,0,.35); cursor: not-allowed; opacity: .45; transition: opacity .15s; }
.fb-submit.active { opacity: 1; cursor: pointer; }
.fb-submit:disabled { cursor: not-allowed; }
.fb-submit-bar { flex-shrink: 0; padding: 8px 16px 16px; background: #F5F5F5; }
/* 居中 toast */
.toast { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,.82); color: #fff; font-size: 14px; padding: 10px 18px; border-radius: 10px; z-index: 9999; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: 70%; text-align: center; line-height: 1.5; }
.toast.show { opacity: 1; }
</style>
</head>
<body>
<div class="device" id="app">
<div class="screen active" id="feedback">
<div class="header">
<button class="btn-back" onclick="goBack()" aria-label="返回">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M15 6L9 12L15 18" stroke="#1A1A1A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="header-title">意见反馈</span>
<button class="fb-history-entry" onclick="goHistory()" aria-label="反馈历史">反馈历史</button>
</div>
<div class="fb-body">
<div class="fb-intro">
<p class="fb-intro-text">您的建议和问题我们都会认真查看,评价越真实详细,金币奖励越高,感谢您帮我们做得更好~</p>
<div class="fb-intro-reward">
<img class="coin" src="assets/welfare/index/checkin-coin-single.png" alt="" aria-hidden="true">
反馈采纳后,即得金币奖励
</div>
</div>
<div>
<div class="fb-section-label">问题和意见<span class="req">*</span></div>
<textarea class="fb-textarea" id="fbContent" inputmode="text" maxlength="200" placeholder="说说您的建议或问题,以便我们提供更好的服务(15个字以上)"></textarea>
</div>
<div>
<div class="fb-section-label">上传截图<span class="count" id="fbUpCount">(0/6)</span></div>
<div class="fb-upload-row" id="fbUpRow">
<div class="fb-upload-tile" id="fbAddTile" role="button" aria-label="上传截图" onclick="document.getElementById('fbFileInput').click()">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none"><path d="M12 5V19M5 12H19" stroke="#CCCCCC" stroke-width="2" stroke-linecap="round"/></svg>
</div>
</div>
<input type="file" id="fbFileInput" accept="image/*" multiple style="display: none;">
</div>
<div class="fb-card fb-qr-card" id="fbQrCard" style="display: none;">
<div class="fb-qr" id="fbQrSlot">
<svg viewBox="0 0 88 88" xmlns="http://www.w3.org/2000/svg" aria-label="二维码">
<rect width="88" height="88" fill="#FFFFFF"/>
<g fill="#1A1A1A">
<rect x="6" y="6" width="20" height="20"/><rect x="9" y="9" width="14" height="14" fill="#FFFFFF"/><rect x="12" y="12" width="8" height="8"/>
<rect x="62" y="6" width="20" height="20"/><rect x="65" y="9" width="14" height="14" fill="#FFFFFF"/><rect x="68" y="12" width="8" height="8"/>
<rect x="6" y="62" width="20" height="20"/><rect x="9" y="65" width="14" height="14" fill="#FFFFFF"/><rect x="12" y="68" width="8" height="8"/>
<rect x="30" y="6" width="3" height="3"/><rect x="36" y="6" width="3" height="3"/><rect x="42" y="6" width="3" height="3"/><rect x="51" y="6" width="3" height="3"/><rect x="57" y="6" width="3" height="3"/>
<rect x="30" y="12" width="3" height="3"/><rect x="39" y="12" width="3" height="3"/><rect x="48" y="12" width="3" height="3"/><rect x="54" y="12" width="3" height="3"/>
<rect x="33" y="18" width="3" height="3"/><rect x="42" y="18" width="3" height="3"/><rect x="51" y="18" width="3" height="3"/><rect x="57" y="18" width="3" height="3"/>
<rect x="30" y="24" width="3" height="3"/><rect x="36" y="24" width="3" height="3"/><rect x="45" y="24" width="3" height="3"/><rect x="54" y="24" width="3" height="3"/>
<rect x="6" y="30" width="3" height="3"/><rect x="15" y="30" width="3" height="3"/><rect x="24" y="30" width="3" height="3"/><rect x="33" y="30" width="3" height="3"/><rect x="42" y="30" width="3" height="3"/><rect x="48" y="30" width="3" height="3"/><rect x="60" y="30" width="3" height="3"/><rect x="69" y="30" width="3" height="3"/><rect x="78" y="30" width="3" height="3"/>
<rect x="9" y="36" width="3" height="3"/><rect x="18" y="36" width="3" height="3"/><rect x="27" y="36" width="3" height="3"/><rect x="39" y="36" width="3" height="3"/><rect x="51" y="36" width="3" height="3"/><rect x="63" y="36" width="3" height="3"/><rect x="72" y="36" width="3" height="3"/>
<rect x="6" y="42" width="3" height="3"/><rect x="12" y="42" width="3" height="3"/><rect x="21" y="42" width="3" height="3"/><rect x="30" y="42" width="3" height="3"/><rect x="36" y="42" width="3" height="3"/><rect x="45" y="42" width="3" height="3"/><rect x="54" y="42" width="3" height="3"/><rect x="66" y="42" width="3" height="3"/><rect x="75" y="42" width="3" height="3"/>
<rect x="15" y="48" width="3" height="3"/><rect x="24" y="48" width="3" height="3"/><rect x="33" y="48" width="3" height="3"/><rect x="42" y="48" width="3" height="3"/><rect x="51" y="48" width="3" height="3"/><rect x="60" y="48" width="3" height="3"/><rect x="69" y="48" width="3" height="3"/><rect x="78" y="48" width="3" height="3"/>
<rect x="9" y="54" width="3" height="3"/><rect x="18" y="54" width="3" height="3"/><rect x="30" y="54" width="3" height="3"/><rect x="39" y="54" width="3" height="3"/><rect x="48" y="54" width="3" height="3"/><rect x="57" y="54" width="3" height="3"/><rect x="66" y="54" width="3" height="3"/><rect x="75" y="54" width="3" height="3"/>
<rect x="33" y="60" width="3" height="3"/><rect x="42" y="60" width="3" height="3"/><rect x="51" y="60" width="3" height="3"/><rect x="60" y="60" width="3" height="3"/><rect x="72" y="60" width="3" height="3"/>
<rect x="30" y="66" width="3" height="3"/><rect x="39" y="66" width="3" height="3"/><rect x="48" y="66" width="3" height="3"/><rect x="57" y="66" width="3" height="3"/><rect x="69" y="66" width="3" height="3"/><rect x="78" y="66" width="3" height="3"/>
<rect x="33" y="72" width="3" height="3"/><rect x="45" y="72" width="3" height="3"/><rect x="54" y="72" width="3" height="3"/><rect x="63" y="72" width="3" height="3"/><rect x="75" y="72" width="3" height="3"/>
<rect x="36" y="78" width="3" height="3"/><rect x="42" y="78" width="3" height="3"/><rect x="48" y="78" width="3" height="3"/><rect x="57" y="78" width="3" height="3"/><rect x="66" y="78" width="3" height="3"/><rect x="72" y="78" width="3" height="3"/>
</g>
</svg>
</div>
<div class="fb-qr-text">
<div id="fbQrTitle">长按图片保存二维码</div>
<div>直通<span class="quote" id="fbQrGroup">「傻瓜比价官方群」</span></div>
<div id="fbQrSub">一起唠嗑共创、解锁新玩法</div>
</div>
</div>
</div>
<div class="fb-submit-bar">
<button class="fb-submit" id="fbSubmit" disabled>确定</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="../shared/bridge.js"></script>
<script src="../shared/api.js"></script>
<script>
(function () {
var content = document.getElementById('fbContent');
var submit = document.getElementById('fbSubmit');
var fileInput = document.getElementById('fbFileInput');
var row = document.getElementById('fbUpRow');
var addTile = document.getElementById('fbAddTile');
var countEl = document.getElementById('fbUpCount');
var toastEl = document.getElementById('toast');
if (!content || !submit) return;
var MAX_IMG = 6;
var files = []; // 已选 File 对象(提交时压缩后 multipart 上传)
var urls = []; // 与 files 对应的 objectURL(缩略图预览,删除时回收)
var submitting = false;
var toastTimer = null;
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 1600);
}
// 确定按钮:正文非空即可点(文案虽提示「15个字以上」但不强制下限,对齐原型 sync)
function sync() {
var ok = content.value.trim().length >= 1 && !submitting;
submit.classList.toggle('active', ok);
submit.disabled = !ok;
}
content.addEventListener('input', sync);
// 渲染已选截图缩略图(末尾保留「+」继续上传 tile)
function renderShots() {
row.querySelectorAll('.fb-thumb').forEach(function (t) { t.remove(); });
files.forEach(function (f, i) {
var thumb = document.createElement('div');
thumb.className = 'fb-thumb';
var img = document.createElement('img');
img.src = urls[i]; img.alt = '截图' + (i + 1);
var del = document.createElement('button');
del.className = 'fb-thumb-del'; del.type = 'button';
del.setAttribute('aria-label', '删除'); del.textContent = '×';
del.onclick = function () {
try { URL.revokeObjectURL(urls[i]); } catch (e) {}
files.splice(i, 1); urls.splice(i, 1); renderShots();
};
thumb.appendChild(img); thumb.appendChild(del);
row.insertBefore(thumb, addTile);
});
if (countEl) countEl.textContent = '(' + files.length + '/' + MAX_IMG + ')';
addTile.style.display = files.length >= MAX_IMG ? 'none' : 'flex';
}
// 点「+」→ 系统相册多选 → 回填缩略图,最多 6 张
if (fileInput) {
fileInput.addEventListener('change', function () {
Array.prototype.slice.call(fileInput.files || []).forEach(function (f) {
if (files.length >= MAX_IMG) return;
if (!/^image\//.test(f.type)) return;
files.push(f);
urls.push(URL.createObjectURL(f));
});
renderShots();
fileInput.value = ''; // 允许重复选择同一张
});
}
// 提交前逐张压缩(长边 ≤1600,jpeg .85);解码失败兜底原 file
function compressImage(file) {
return new Promise(function (resolve) {
try {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
try {
var max = 1600, w = img.width, h = img.height;
if (w > max || h > max) { var s = Math.min(max / w, max / h); w = Math.round(w * s); h = Math.round(h * s); }
var canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
URL.revokeObjectURL(url);
canvas.toBlob(function (blob) { resolve(blob || file); }, 'image/jpeg', 0.85);
} catch (e) { URL.revokeObjectURL(url); resolve(file); }
};
img.onerror = function () { URL.revokeObjectURL(url); resolve(file); };
img.src = url;
} catch (e) { resolve(file); }
});
}
// 加群二维码卡:接 /feedback/config(运营可配开关+图+三行文案)
function renderQr(cfg) {
var card = document.getElementById('fbQrCard');
// 明确关闭才隐藏;未拉到(null)按默认渲染,避免空白闪烁(对齐原生 QrCard)
if (cfg && cfg.enabled === false) { card.style.display = 'none'; return; }
card.style.display = 'flex';
var title = (cfg && cfg.title) || '长按图片保存二维码';
var group = (cfg && cfg.group_name) || '傻瓜比价官方群';
var sub = (cfg && cfg.subtitle) || '一起唠嗑共创、解锁新玩法';
document.getElementById('fbQrTitle').textContent = title;
document.getElementById('fbQrGroup').textContent = '「' + group + '」';
document.getElementById('fbQrSub').textContent = sub;
var imgUrl = cfg && cfg.image_url;
if (imgUrl) {
var slot = document.getElementById('fbQrSlot');
if (slot) {
var el = document.createElement('div');
el.className = 'fb-qr'; el.id = 'fbQrSlot';
var im = document.createElement('img');
im.src = imgUrl; im.alt = '官方群二维码';
el.appendChild(im);
slot.parentNode.replaceChild(el, slot);
}
}
}
renderQr(null); // 先按默认渲染
if (window.SGApi) { SGApi.get('/feedback/config').then(renderQr).catch(function () {}); }
// 提交反馈:真接 POST /api/v1/feedbackmultipartcontent + 逐张 images
function submitFeedback() {
if (submit.disabled || submitting) return;
var text = content.value.trim();
if (!text) { showToast('请填写问题和意见'); return; }
var hasBridge = window.SGBridge && SGBridge.hasNative && typeof SGBridge.getToken === 'function';
if (!hasBridge) { // 浏览器预览(无原生桥):不真发
console.log('[stub] submitFeedback(浏览器预览,无 Bridge,不真发)', { content: text, images: files.length });
showToast('感谢您的反馈');
return;
}
submitting = true; sync();
var oldText = submit.textContent; submit.textContent = '提交中…';
(async function () {
try {
var token = SGBridge.getToken();
var blobs = [];
for (var i = 0; i < files.length; i++) { blobs.push(await compressImage(files[i])); }
var fd = new FormData();
fd.append('content', text);
blobs.forEach(function (b, i) { fd.append('images', b, 'fb_' + (i + 1) + '.jpg'); });
// 只放 Authorization,不手动设 Content-Type(让浏览器自动带 multipart boundary
var res = await fetch('/api/v1/feedback', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: fd });
if (res.ok) {
// 成功:页内居中 toast 显示后再 closePage 回我的(native 未实现 SGBridge.toast,故不走原生 toast
showToast('感谢您的反馈');
setTimeout(function () { if (window.SGBridge && SGBridge.closePage) SGBridge.closePage(); }, 1200);
return; // 保持按钮禁用,等页面关闭,避免重复提交
} else if (res.status === 401) {
if (SGBridge.requestLogin) SGBridge.requestLogin();
showToast('请先登录');
} else {
var detail = '';
try { var j = await res.json(); detail = (j && j.detail) ? j.detail : ''; } catch (e) {}
showToast(detail || ('提交失败(' + res.status + '),请稍后再试'));
}
} catch (e) {
showToast('网络异常,提交失败');
}
// 失败兜底:恢复按钮,允许重试(成功分支已 return,不会走到这)
submitting = false; submit.textContent = oldText; sync();
})();
}
submit.addEventListener('click', submitFeedback);
sync();
// 顶栏返回 / 反馈历史入口
window.goBack = function () {
if (window.SGBridge && SGBridge.closePage) { SGBridge.closePage(); }
else if (history.length > 1) { history.back(); }
};
window.goHistory = function () {
if (window.SGBridge && SGBridge.navigate) { SGBridge.navigate('feedbackHistory'); }
else { console.log('[nav] feedbackHistory'); }
};
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+266
View File
@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../shared/fonts.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>反馈历史 · 傻瓜比价</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
:root {
--color-primary: #FFD600;
--color-on-primary: #1A1A1A;
--color-surface-page: #F5F5F5;
--color-surface-card: #FFFFFF;
--color-surface-muted: #FAFAFA;
--color-ink: #1A1A1A;
--color-ink-muted: #666666;
--color-ink-subtle: #999999;
--color-ink-disabled: #CCCCCC;
--color-divider: #F0F0F0;
--color-divider-strong: #E5E5E5;
--color-state-success-dark: #2E7D32;
--color-state-success-bg: #E8F5E9;
--color-state-warning-bg: #FFF3E0;
--color-state-warning-ink: #B57400;
--color-state-danger: #E53935;
--color-state-danger-bg: #FFEBEE;
--color-coin-deep: #5A3A00;
--radius: 14px;
--radius-sm: 10px;
--radius-pill: 999px;
--shadow-card: 0 1px 2px rgba(0,0,0,.03);
--font-num: 'DIN Alternate', 'DIN Pro', 'SF Pro Display', system-ui;
}
html, body { height: 100%; overflow: hidden; }
/* 【去框】原型 body 黑底 + flex 居中(浏览器预览用)→ 铺页面色、不居中。 */
body {
font-family: 'PuHuiTi', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
background: var(--color-surface-page);
color: var(--color-ink);
}
/* 【去框】原型 .device 375x812 预览框 → 全屏自适应容器。 */
.device { position: relative; width: 100%; height: 100%; background: var(--color-surface-page); overflow: hidden; }
.screen { position: absolute; inset: 0; display: flex; flex-direction: column; overflow: hidden; }
/* 隐藏原型假状态栏(顶部状态栏交给原生) */
.statusbar { display: none; }
.topnav { height: 48px; display: flex; align-items: center; padding: 0 12px; background: var(--color-surface-page); flex-shrink: 0; position: relative; }
.topnav-back { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; background: none; border: none; padding: 0; }
.topnav-back:active { opacity: .5; }
.topnav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: var(--color-ink); }
.scroll { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 8px 14px calc(24px + env(safe-area-inset-bottom, 0px)); }
.scroll::-webkit-scrollbar { display: none; }
/* === 顶部汇总:反馈采纳累计奖励 === */
.fb-summary { display: flex; align-items: center; justify-content: space-between; background: linear-gradient(135deg, #FFF3CC 0%, #FFE9A8 100%); border-radius: var(--radius); padding: 14px 16px; margin: 4px 0 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,.6), 0 2px 8px rgba(255,179,0,.18); }
.fb-summary-label { font-size: 12px; font-weight: 400; color: var(--color-coin-deep); opacity: .8; }
.fb-summary-num { font-family: var(--font-num); font-size: 28px; font-weight: 700; color: var(--color-coin-deep); line-height: 1.2; margin-top: 2px; }
.fb-summary-num .unit { font-family: 'PuHuiTi', -apple-system, 'PingFang SC', sans-serif; font-size: 13px; font-weight: 600; margin-left: 4px; }
.fb-summary-sub { font-size: 11px; font-weight: 400; color: var(--color-coin-deep); opacity: .7; margin-top: 4px; }
.fb-summary-coin { width: 52px; height: 52px; flex-shrink: 0; }
.filter-row { display: flex; gap: 8px; padding: 0 0 12px; overflow-x: auto; }
.filter-row::-webkit-scrollbar { display: none; }
.filter-chip { flex-shrink: 0; padding: 6px 14px; border-radius: var(--radius-pill); font-size: 13px; font-weight: 400; color: var(--color-ink-muted); background: var(--color-surface-card); border: .5px solid var(--color-divider-strong); cursor: pointer; }
.filter-chip.active { background: var(--color-ink); color: #fff; font-weight: 600; border-color: var(--color-ink); }
.filter-chip .count { margin-left: 4px; opacity: .7; font-weight: 400; }
.rec-card { background: var(--color-surface-card); border-radius: var(--radius); padding: 14px; margin-bottom: 12px; box-shadow: var(--shadow-card); overflow: hidden; }
.rec-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.fb-headtext { flex: 1; min-width: 0; }
.fb-type-row { display: flex; align-items: center; gap: 8px; }
.fb-date { font-size: 12px; font-weight: 400; color: var(--color-ink-subtle); font-family: var(--font-num); }
.fb-content { font-size: 14px; font-weight: 400; color: var(--color-ink); line-height: 1.6; margin-top: 8px; word-break: break-word; }
/* 截图缩略 */
.fb-shots { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.fb-shot { width: 48px; height: 48px; border-radius: 8px; background: var(--color-divider); border: .5px solid var(--color-divider-strong); overflow: hidden; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.fb-shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.badge { flex-shrink: 0; display: inline-flex; align-items: center; gap: 3px; padding: 3px 8px; border-radius: var(--radius-pill); font-size: 11px; font-weight: 700; line-height: 1.2; white-space: nowrap; }
.badge svg { width: 12px; height: 12px; }
.badge-pending { background: var(--color-state-warning-bg); color: var(--color-state-warning-ink); }
.badge-success { background: var(--color-state-success-bg); color: var(--color-state-success-dark); }
.badge-danger { background: var(--color-state-danger-bg); color: var(--color-state-danger); }
/* 已采纳:金币奖励条 */
.rec-reward { margin-top: 12px; background: linear-gradient(90deg, #FFF8E1 0%, #FFFDE7 100%); border-radius: var(--radius-sm); padding: 8px 12px; display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--color-coin-deep); font-weight: 400; }
.rec-reward img { width: 16px; height: 16px; margin-right: 2px; }
.rec-reward-num { font-family: var(--font-num); font-size: 15px; font-weight: 700; }
/* 审核中:进度提示 */
.fb-pending-note { margin-top: 12px; background: var(--color-surface-muted); border-radius: var(--radius-sm); padding: 8px 12px; font-size: 12px; color: var(--color-ink-subtle); line-height: 1.5; }
/* 未采纳:原因 */
.rec-reject { margin-top: 12px; background: var(--color-state-danger-bg); border-radius: var(--radius-sm); padding: 10px 12px; font-size: 13px; color: var(--color-state-danger); line-height: 1.5; }
.rec-reject b { font-weight: 600; }
.list-end { text-align: center; padding: 24px 0 8px; font-size: 12px; color: var(--color-ink-disabled); font-weight: 400; }
.list-empty { text-align: center; padding: 56px 0; font-size: 13px; color: var(--color-ink-subtle); }
/* === 空态:无任何反馈记录 === */
.empty-wrap { flex: 1; display: none; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 0 40px 80px; }
.empty-icon { width: 96px; height: 96px; border-radius: 50%; background: var(--color-divider); display: flex; align-items: center; justify-content: center; margin-bottom: 20px; }
.empty-icon svg { width: 44px; height: 44px; }
.empty-title { font-size: 17px; font-weight: 600; color: var(--color-ink); margin-bottom: 8px; }
.empty-sub { font-size: 13px; font-weight: 400; color: var(--color-ink-subtle); line-height: 1.6; margin-bottom: 28px; }
.empty-cta { border: none; cursor: pointer; font-family: inherit; font-size: 15px; font-weight: 700; color: var(--color-on-primary); padding: 0 32px; height: 44px; border-radius: 24px; background: linear-gradient(180deg, #FFE066 0%, #FFD600 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,.7), 0 6px 18px rgba(255,179,0,.35); transition: transform .12s ease, opacity .15s ease; }
.empty-cta:active { transform: translateY(1px); opacity: .9; }
</style>
</head>
<body>
<div class="device" id="app">
<div class="screen active" id="feedbackHistory">
<div class="topnav">
<button class="topnav-back" onclick="goBack()" aria-label="返回">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<div class="topnav-title">反馈历史</div>
</div>
<div class="scroll" id="fbhScroll">
<div class="fb-summary" id="fbhSummary" style="display: none;">
<div>
<div class="fb-summary-label">反馈采纳累计奖励</div>
<div class="fb-summary-num"><span id="fbhCoinSum">0</span><span class="unit">金币</span></div>
<div class="fb-summary-sub">已采纳 <span id="fbhAdoptCount">0</span> 条 · 反馈越真实详细,奖励越高</div>
</div>
<img class="fb-summary-coin" src="assets/welfare/index/checkin-coin-single.png" alt="">
</div>
<div class="filter-row">
<div class="filter-chip active" data-st="all">全部<span class="count" id="fbhCntAll">0</span></div>
<div class="filter-chip" data-st="pending">审核中<span class="count" id="fbhCntPending">0</span></div>
<div class="filter-chip" data-st="adopted">已采纳<span class="count" id="fbhCntAdopted">0</span></div>
<div class="filter-chip" data-st="rejected">未采纳<span class="count" id="fbhCntRejected">0</span></div>
</div>
<div id="fbhList"></div>
<div class="list-end" id="fbhListEnd" style="display: none;"></div>
</div>
<div class="empty-wrap" id="fbhEmpty">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#B3B3B3" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/><path d="M8.5 11h.01M12 11h.01M15.5 11h.01"/></svg>
</div>
<div class="empty-title">还没有反馈记录</div>
<div class="empty-sub">你的每条建议我们都会认真查看<br>被采纳还能领金币奖励</div>
<button class="empty-cta" onclick="goBack()">去反馈</button>
</div>
</div>
</div>
<script src="../shared/bridge.js"></script>
<script src="../shared/api.js"></script>
<script>
(function () {
var scroll = document.getElementById('fbhScroll');
var listEl = document.getElementById('fbhList');
var emptyEl = document.getElementById('fbhEmpty');
var summaryEl = document.getElementById('fbhSummary');
var listEnd = document.getElementById('fbhListEnd');
var RECORDS = [], COUNTS = { all: 0, pending: 0, adopted: 0, rejected: 0 };
var filter = 'all';
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
// created_at 取前 10 位(YYYY-MM-DD),避免时区换算偏移
function fmtDate(iso) { var s = String(iso || ''); return s.length >= 10 ? s.slice(0, 10) : s; }
var COIN = 'assets/welfare/index/checkin-coin-single.png';
var BADGE = {
pending: { cls: 'badge-pending', text: '审核中', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>' },
adopted: { cls: 'badge-success', text: '已采纳', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7"/></svg>' },
rejected: { cls: 'badge-danger', text: '未采纳', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></svg>' }
};
function cardHtml(rec) {
var st = BADGE[rec.status] || BADGE.pending;
var shots = '';
if (rec.images && rec.images.length) {
shots = '<div class="fb-shots">' + rec.images.map(function (u) { return '<div class="fb-shot"><img src="' + esc(u) + '" alt=""></div>'; }).join('') + '</div>';
}
var extra = '';
if (rec.status === 'adopted') {
extra = '<div class="rec-reward"><img src="' + COIN + '" alt=""><span>+</span><span class="rec-reward-num">' + (rec.reward_coins || 0) + '</span><span>金币已到账</span></div>';
} else if (rec.status === 'rejected') {
extra = '<div class="rec-reject"><b>未采纳原因:</b>' + esc(rec.reject_reason || '经评估,本次反馈暂未采纳。') + '</div>';
} else {
extra = '<div class="fb-pending-note">我们正在认真查看,预计 5 个工作日内完成审核,采纳后金币自动到账。</div>';
}
return '<div class="rec-card"><div class="rec-head"><div class="fb-headtext"><div class="fb-type-row"><span class="fb-date">' + esc(fmtDate(rec.created_at)) + '</span></div><div class="fb-content">' + esc(rec.content) + '</div></div><span class="badge ' + st.cls + '">' + st.icon + st.text + '</span></div>' + shots + extra + '</div>';
}
function currentList() { return filter === 'all' ? RECORDS : RECORDS.filter(function (r) { return r.status === filter; }); }
// 汇总卡:当前筛选视图里有「已采纳」≥1 才显示(对齐原型 refreshSummary
function refreshSummary(view) {
var adopted = view.filter(function (r) { return r.status === 'adopted'; });
if (!adopted.length) { summaryEl.style.display = 'none'; return; }
summaryEl.style.display = 'flex';
var sum = adopted.reduce(function (s, r) { return s + (r.reward_coins || 0); }, 0);
document.getElementById('fbhCoinSum').textContent = sum.toLocaleString('en-US');
document.getElementById('fbhAdoptCount').textContent = adopted.length;
}
// chip 计数:用后端全量 counts(固定,不随筛选变)
function renderCounts() {
document.getElementById('fbhCntAll').textContent = COUNTS.all || 0;
document.getElementById('fbhCntPending').textContent = COUNTS.pending || 0;
document.getElementById('fbhCntAdopted').textContent = COUNTS.adopted || 0;
document.getElementById('fbhCntRejected').textContent = COUNTS.rejected || 0;
}
function render() {
if (!RECORDS.length) { scroll.style.display = 'none'; emptyEl.style.display = 'flex'; return; }
emptyEl.style.display = 'none'; scroll.style.display = '';
var view = currentList();
if (!view.length) {
listEl.innerHTML = '<div class="list-empty">暂无该状态的反馈</div>';
listEnd.style.display = 'none';
} else {
listEl.innerHTML = view.map(cardHtml).join('');
listEnd.style.display = '';
listEnd.textContent = '— 已显示全部 ' + view.length + ' 条 —';
}
refreshSummary(view);
}
Array.prototype.slice.call(document.querySelectorAll('.filter-chip')).forEach(function (chip) {
chip.addEventListener('click', function () {
document.querySelectorAll('.filter-chip').forEach(function (c) { c.classList.remove('active'); });
chip.classList.add('active');
filter = chip.getAttribute('data-st') || 'all';
render();
});
});
async function load() {
var data = null;
try { if (window.SGApi) data = await SGApi.get('/feedback/records'); } catch (e) {}
if (data) { RECORDS = data.records || []; COUNTS = data.counts || COUNTS; }
else { RECORDS = []; }
renderCounts();
render();
}
load();
// 返回 / 「去反馈」:closePage 回意见反馈页(对齐原生 onGoFeedback = popBackStack
window.goBack = function () {
if (window.SGBridge && SGBridge.closePage) { SGBridge.closePage(); }
else if (history.length > 1) { history.back(); }
};
// 回前台刷新
if (window.SGBridge && SGBridge.on) { SGBridge.on('onResume', load); }
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

+43
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Some files were not shown because too many files have changed in this diff Show More