Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52c530f0a1 | |||
| b5962464e8 | |||
| e2a485dfe6 | |||
| 6c0fc303e1 | |||
| 266b32dee4 | |||
| a604f4d614 | |||
| 5881dd4f05 | |||
| 74da3525a9 | |||
| 2de1f537c0 | |||
| 267d65a473 | |||
| ab46dec102 | |||
| bf4b08d567 | |||
| fc21ab95cc | |||
| ab12b2eab1 |
+12
-76
@@ -27,55 +27,7 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
|
||||
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
|
||||
JG_REQUEST_TIMEOUT_SEC=15
|
||||
|
||||
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)=====
|
||||
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
|
||||
# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。
|
||||
ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia
|
||||
PUSH_REQUEST_TIMEOUT_SEC=15
|
||||
PUSH_TIME_TO_LIVE_SEC=86400
|
||||
|
||||
HONOR_PUSH_APP_ID=
|
||||
HONOR_PUSH_CLIENT_ID=
|
||||
HONOR_PUSH_CLIENT_SECRET=
|
||||
HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token
|
||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage
|
||||
|
||||
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret
|
||||
HUAWEI_PUSH_APP_ID=
|
||||
HUAWEI_PUSH_APP_SECRET=
|
||||
HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token
|
||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send
|
||||
|
||||
VIVO_PUSH_APP_ID=
|
||||
VIVO_PUSH_APP_KEY=
|
||||
VIVO_PUSH_APP_SECRET=
|
||||
VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth
|
||||
VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send
|
||||
# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。
|
||||
VIVO_PUSH_MODE=1
|
||||
VIVO_PUSH_NOTIFY_TYPE=4
|
||||
VIVO_PUSH_CATEGORY=DEVICE_REMINDER
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET=
|
||||
XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid
|
||||
XIAOMI_PUSH_CHANNEL_ID=
|
||||
XIAOMI_PUSH_TEMPLATE_ID=
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE=
|
||||
XIAOMI_PUSH_TEMPLATE_DESCRIPTION=
|
||||
# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"}
|
||||
XIAOMI_PUSH_TEMPLATE_PARAM_JSON=
|
||||
|
||||
OPPO_PUSH_APP_KEY=
|
||||
OPPO_PUSH_MASTER_SECRET=
|
||||
OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth
|
||||
OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast
|
||||
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」;
|
||||
# notify_level 0=不传走默认,内容营销类仅支持 1/2)
|
||||
OPPO_PUSH_CHANNEL_ID=
|
||||
OPPO_PUSH_CATEGORY=
|
||||
OPPO_PUSH_NOTIFY_LEVEL=0
|
||||
|
||||
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
|
||||
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||
HEARTBEAT_MONITOR_ENABLED=true
|
||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||
@@ -113,13 +65,6 @@ JD_UNION_APP_SECRET=
|
||||
JD_UNION_SITE_ID=
|
||||
JD_UNION_AUTH_KEY=
|
||||
|
||||
# 美团 + 京东订单每天北京时间 05:00 自动对账;按更新时间回拉近 3 天,重叠防漏单并刷新状态。
|
||||
# 手动对账按钮不受该开关影响。通常保持开启;临时停自动任务时设为 false。
|
||||
CPS_AUTO_RECONCILE_ENABLED=true
|
||||
CPS_AUTO_RECONCILE_RUN_HOUR=5
|
||||
CPS_AUTO_RECONCILE_LOOKBACK_DAYS=3
|
||||
CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC=60
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||
@@ -139,11 +84,6 @@ PRICEBOT_COMPARE_TIMEOUT_SEC=60
|
||||
# 必须与 pricebot 侧的 INTERNAL_API_SECRET **同值**;留空 = 内部写端点关闭(返 503)。
|
||||
# 启用前两边都填同一高熵串:python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
INTERNAL_API_SECRET=
|
||||
# 新版比价 harvest 完成后即时回填;以下 worker 再补偿短暂故障期间漏掉的记录。
|
||||
LLM_COST_BACKFILL_ENABLED=true
|
||||
LLM_COST_BACKFILL_INTERVAL_SEC=300
|
||||
LLM_COST_BACKFILL_BATCH_SIZE=100
|
||||
LLM_COST_BACKFILL_LOOKBACK_DAYS=30
|
||||
|
||||
# ===== CORS =====
|
||||
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
|
||||
@@ -198,18 +138,14 @@ PANGLE_REPORT_SECURITY_KEY=
|
||||
PANGLE_REPORT_SITE_ID_PROD=5830519
|
||||
PANGLE_REPORT_SITE_ID_TEST=5832303
|
||||
|
||||
# ===== 可观测(OpenObserve 接口指标)=====
|
||||
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
|
||||
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
|
||||
# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。
|
||||
OBSERVE_ENABLED=false
|
||||
OBSERVE_ENDPOINT=http://localhost:5080
|
||||
OBSERVE_ORG=default
|
||||
OBSERVE_STREAM=app_requests
|
||||
OBSERVE_USER=admin@shaguabijia.local
|
||||
OBSERVE_PASSWORD=Complexpass#123
|
||||
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
|
||||
OBSERVE_FLUSH_INTERVAL_SEC=5
|
||||
OBSERVE_BATCH_MAX=200
|
||||
OBSERVE_QUEUE_MAX=10000
|
||||
OBSERVE_TIMEOUT_SEC=5
|
||||
# ===== 客户端运行日志上报(POST /api/v1/applog/batch)=====
|
||||
# 客户端批量上报的 App 运行日志逐条落到独立滚动文件 logs/app-client.log,供阿里云 Logtail
|
||||
# 采进【独立 SLS logstore】(与服务日志 app-server.log 分开;滚动机制相同,trace_id 可跨层检索)。
|
||||
# 全部有默认值,不填即用默认(定义见 app/core/client_log.py 与 app/api/v1/applog.py)。
|
||||
# CLIENT_LOG_FILE=logs/app-client.log # 落盘路径
|
||||
# CLIENT_LOG_MAX_BYTES=20971520 # 单文件 20MB 滚动
|
||||
# CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲(给 Logtail 断线留余量)
|
||||
# CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段
|
||||
# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启)
|
||||
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调)
|
||||
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断
|
||||
|
||||
+13
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -77,6 +77,18 @@ def get_current_user_optional(
|
||||
return user
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""客户端真实 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP。
|
||||
|
||||
路由层取 IP 的规范实现(analytics / applog 等共用);core 层(ratelimit)因不能
|
||||
反向依赖 app.api,自留一份私有副本。
|
||||
"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
+2
-10
@@ -10,7 +10,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.api.deps import DbSession, get_client_ip
|
||||
from app.repositories import analytics as analytics_repo
|
||||
from app.repositories import analytics_selfstat as selfstat_repo
|
||||
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
|
||||
@@ -20,19 +20,11 @@ router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
||||
logger = logging.getLogger("shagua.analytics")
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
|
||||
def ingest_events(
|
||||
batch: AnalyticsBatchIn, request: Request, db: DbSession
|
||||
) -> AnalyticsIngestOut:
|
||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
||||
n = analytics_repo.record_batch(db, batch, client_ip=get_client_ip(request))
|
||||
return AnalyticsIngestOut(received=n)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""客户端运行日志批量上报接口。
|
||||
|
||||
POST /api/v1/applog/batch — 批量接收客户端运行日志,逐条写专用滚动文件 logs/app-client.log
|
||||
(供 Logtail 采进独立 SLS logstore)。鉴权同 analytics(不强制登录,user_id 可选在 body)。
|
||||
fire-and-forget:写失败也不 500(避免客户端重试风暴);超批 422、超体积 413、msg 超限截断。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.api.deps import get_client_ip
|
||||
from app.core.client_log import write_records
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.schemas.applog import AppLogBatchIn, AppLogIngestOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/applog", tags=["applog"])
|
||||
|
||||
|
||||
def _enforce_body_limit(request: Request) -> None:
|
||||
"""依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。"""
|
||||
max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024 * 2)))
|
||||
cl = request.headers.get("content-length")
|
||||
if cl is not None and cl.isdigit() and int(cl) > max_bytes:
|
||||
raise HTTPException(status_code=413, detail="日志批量过大")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch",
|
||||
response_model=AppLogIngestOut,
|
||||
summary="批量上报客户端运行日志",
|
||||
dependencies=[
|
||||
Depends(rate_limit(120, 60, "applog-batch")),
|
||||
Depends(_enforce_body_limit),
|
||||
],
|
||||
)
|
||||
def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut:
|
||||
received, dropped = write_records(
|
||||
batch.logs,
|
||||
meta={
|
||||
"device_id": batch.device_id,
|
||||
"user_id": batch.user_id,
|
||||
"app_ver": batch.app_ver,
|
||||
"platform": batch.platform,
|
||||
"sent_at": batch.sent_at,
|
||||
},
|
||||
client_ip=get_client_ip(request),
|
||||
)
|
||||
return AppLogIngestOut(received=received, dropped=dropped)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。
|
||||
|
||||
- 独占 logger "shagua.client_log" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。
|
||||
- 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再
|
||||
json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身
|
||||
已是 JSON,不能再过 JsonFormatter 二次编码。
|
||||
- 滚动 20MB×10(env 可调),与服务日志同机制。
|
||||
⚠️ 依赖 --workers 1:RotatingFileHandler 多进程并发 doRollover 会损坏/丢日志;扩 worker
|
||||
前换 QueueHandler→单写入者 / 外部 logrotate(copytruncate) / 写 stdout 交 journald。
|
||||
|
||||
服务端补的字段(time/source/service/client_ip/device_id/...)是「事实」,与客户端自述分开。
|
||||
`time` 用服务端接收时间作 SLS 主时间(客户端时钟不可信),client_ts 另存为可查字段。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
# 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸
|
||||
_TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg")
|
||||
|
||||
# trace_id/tag/level 是 SLS 索引字段(spec §8):给长度上限,防客户端塞超大值撑爆索引/抬升成本。
|
||||
# (msg 另有字节截断;data 内的值不限,留待后续「服务端脱敏」knob。)
|
||||
_MAX_LEVEL_LEN = 16
|
||||
_MAX_TRACE_ID_LEN = 256
|
||||
_MAX_TAG_LEN = 128
|
||||
|
||||
_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _max_msg_bytes() -> int:
|
||||
# 每次调用现读 env(不设模块级常量):便于运行期调整 / 测试 monkeypatch,开销可忽略。
|
||||
return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192"))
|
||||
|
||||
|
||||
def _build_logger() -> logging.Logger:
|
||||
lg = logging.getLogger("shagua.client_log") # 与仓库 shagua.* 业务 logger 命名一致
|
||||
lg.setLevel(logging.INFO)
|
||||
lg.propagate = False # 不冒泡到 root → 不写进 app-server.log
|
||||
log_file = os.getenv("CLIENT_LOG_FILE") or str(
|
||||
Path(os.getenv("LOG_DIR", "logs")) / "app-client.log"
|
||||
)
|
||||
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=int(os.getenv("CLIENT_LOG_MAX_BYTES", str(20 * 1024 * 1024))),
|
||||
backupCount=int(os.getenv("CLIENT_LOG_BACKUP_COUNT", "10")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(logging.Formatter("%(message)s")) # 行已是 JSON,不再包装
|
||||
lg.handlers = [handler]
|
||||
return lg
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
global _logger
|
||||
if _logger is None:
|
||||
_logger = _build_logger()
|
||||
return _logger
|
||||
|
||||
|
||||
def reset_client_logger() -> None:
|
||||
"""测试用:关闭并丢弃当前 logger,使下次 get_logger 按当时 env 重建(切临时文件)。"""
|
||||
global _logger
|
||||
if _logger is not None:
|
||||
for h in list(_logger.handlers):
|
||||
h.close()
|
||||
_logger.handlers = []
|
||||
_logger = None
|
||||
|
||||
|
||||
def _truncate_msg(msg: str) -> tuple[str, bool]:
|
||||
raw = msg.encode("utf-8")
|
||||
limit = _max_msg_bytes()
|
||||
if len(raw) <= limit:
|
||||
return msg, False
|
||||
# 按字节截断后解码,忽略截断处半个多字节字符
|
||||
return raw[:limit].decode("utf-8", "ignore") + "…[truncated]", True
|
||||
|
||||
|
||||
def _build_line(
|
||||
record: dict, *, meta: dict, client_ip: str, service: str, now_iso: str
|
||||
) -> str:
|
||||
out: dict = {
|
||||
"time": now_iso,
|
||||
"source": "client",
|
||||
"service": service,
|
||||
"client_ip": client_ip,
|
||||
}
|
||||
# 批级公共字段(非空才带)
|
||||
for k in ("device_id", "user_id", "app_ver", "platform", "sent_at"):
|
||||
v = meta.get(k)
|
||||
if v is not None:
|
||||
out[k] = v
|
||||
# 白名单键提顶层(索引字段做长度上限 + 统一转 str,保证 SLS 里类型/大小可控)
|
||||
if record.get("level") is not None:
|
||||
out["level"] = str(record["level"])[:_MAX_LEVEL_LEN].upper()
|
||||
if record.get("trace_id"):
|
||||
out["trace_id"] = str(record["trace_id"])[:_MAX_TRACE_ID_LEN]
|
||||
if record.get("tag"):
|
||||
out["tag"] = str(record["tag"])[:_MAX_TAG_LEN]
|
||||
if record.get("client_ts") is not None:
|
||||
out["client_ts"] = record["client_ts"]
|
||||
if record.get("msg") is not None:
|
||||
msg, truncated = _truncate_msg(str(record["msg"]))
|
||||
out["msg"] = msg
|
||||
if truncated:
|
||||
out["msg_truncated"] = True
|
||||
# 其余键并入 data:先收白名单外的散键(兜底),再让客户端显式的 data 覆盖同名散键
|
||||
# —— 显式 data 为准,不静默丢客户端明确给的值(保真)。
|
||||
data: dict = {}
|
||||
for k, v in record.items():
|
||||
if k in _TOP_LEVEL_KEYS or k == "data":
|
||||
continue
|
||||
data[k] = v
|
||||
client_data = record.get("data")
|
||||
if isinstance(client_data, dict):
|
||||
data.update(client_data)
|
||||
if data:
|
||||
out["data"] = data
|
||||
return json.dumps(out, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def write_records(
|
||||
records: list[dict], *, meta: dict, client_ip: str
|
||||
) -> tuple[int, int]:
|
||||
"""把一批客户端日志逐条写入专用文件。返回 (received, dropped)。
|
||||
|
||||
尽力而为(fire-and-forget):logger 初始化或单条写入失败只跳过并计 dropped,
|
||||
不抛给上层——端点因此永不因写日志而 500。
|
||||
"""
|
||||
try:
|
||||
lg = get_logger()
|
||||
except Exception: # noqa: BLE001 — 初始化失败也不能让端点 500
|
||||
logging.getLogger("shagua.applog").exception("client log writer init failed")
|
||||
return 0, len(records)
|
||||
service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client")
|
||||
# 一批共用同一「服务端接收时间」:降开销,且语义上是服务端「收到」而非逐条「处理」时间。
|
||||
now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
||||
received = dropped = 0
|
||||
for rec in records:
|
||||
try:
|
||||
line = _build_line(
|
||||
rec, meta=meta, client_ip=client_ip, service=service, now_iso=now_iso
|
||||
)
|
||||
lg.info(line)
|
||||
received += 1
|
||||
except Exception: # noqa: BLE001 — 坏条跳过,不影响其余
|
||||
logging.getLogger("shagua.applog").exception(
|
||||
"client log record dropped client_ip=%s", client_ip
|
||||
)
|
||||
dropped += 1
|
||||
return received, dropped
|
||||
@@ -21,6 +21,7 @@ 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.applog import router as applog_router
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.compare import router as compare_router
|
||||
from app.api.v1.compare_milestone import router as compare_milestone_router
|
||||
@@ -153,6 +154,7 @@ app.include_router(auth_router)
|
||||
app.include_router(user_router)
|
||||
app.include_router(feedback_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(applog_router)
|
||||
app.include_router(invite_router)
|
||||
app.include_router(coupon_router)
|
||||
app.include_router(device_router)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""客户端运行日志批量上报 schema。
|
||||
|
||||
批级公共字段(device_id/user_id/app_ver/platform/sent_at)发一次;logs 为原始 dict 列表,
|
||||
**不强类型**——尽力而为的日志链路,单条内容异常不该让整批 422。每条的「白名单键 + data
|
||||
兜底」拆分在写入层 [app.core.client_log] 做(见 spec §4/§5)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 单批条数上限。注意:这是**导入期**常量(Pydantic Field(max_length=) 在类定义时求值),
|
||||
# 改它需重启进程;要运行期可调的上限用 APPLOG_MAX_BODY_BYTES(端点里 call-time 读)。
|
||||
_MAX_BATCH = int(os.getenv("APPLOG_MAX_BATCH", "500"))
|
||||
|
||||
|
||||
class AppLogBatchIn(BaseModel):
|
||||
device_id: str = Field(max_length=64)
|
||||
user_id: int | None = None
|
||||
app_ver: str | None = Field(default=None, max_length=32)
|
||||
platform: str | None = Field(default=None, max_length=16)
|
||||
sent_at: int | None = None
|
||||
logs: list[dict[str, Any]] = Field(min_length=1, max_length=_MAX_BATCH)
|
||||
|
||||
|
||||
class AppLogIngestOut(BaseModel):
|
||||
ok: bool = True
|
||||
received: int
|
||||
dropped: int = 0
|
||||
+4
-23
@@ -1,13 +1,9 @@
|
||||
# 傻瓜比价 App 后端 — API 接口文档(索引)
|
||||
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
<<<<<<< HEAD
|
||||
> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点)
|
||||
=======
|
||||
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03)
|
||||
>>>>>>> origin/main
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -82,6 +78,7 @@
|
||||
| **签到**(前缀 `/api/v1/signin`) |||
|
||||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) |
|
||||
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) |
|
||||
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) |
|
||||
| **任务**(前缀 `/api/v1/tasks`) |||
|
||||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) |
|
||||
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) |
|
||||
@@ -92,7 +89,6 @@
|
||||
| **看广告发奖**(前缀 `/api/v1/ad`) |||
|
||||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) |
|
||||
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) |
|
||||
| 33a | `GET /api/v1/ad/reward-result/{ad_session_id}` | Bearer | [详情](./ad/ad-reward-result.md)(本次实发金币 + 本轮膨胀累计 `round_coin`,弹窗数字用它) |
|
||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) |
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) |
|
||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) |
|
||||
@@ -107,33 +103,19 @@
|
||||
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
|
||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||
<<<<<<< HEAD
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) |
|
||||
| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) |||
|
||||
| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) |
|
||||
| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) |
|
||||
| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) |
|
||||
| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase) |||
|
||||
| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) |
|
||||
| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) |
|
||||
| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) |
|
||||
=======
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) |
|
||||
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
|
||||
| **埋点 & 订单上报 / 客户端日志**(前缀分散;全部 Bearer 除 analytics/events、applog/batch 不强制登录) |||
|
||||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
|
||||
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
|
||||
>>>>>>> origin/main
|
||||
| E3 | `POST /api/v1/applog/batch` | 无 | [详情](./other/applog-batch.md)(批量上报客户端运行日志,逐条落独立文件供 Logtail 采进 SLS,不强制登录,每批≤500条) |
|
||||
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
|
||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
|
||||
| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) |
|
||||
| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) |
|
||||
| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) |
|
||||
| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) |
|
||||
| **微信支付回调**(前缀 `/api/v1/wxpay`) |||
|
||||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
|
||||
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
||||
@@ -172,7 +154,6 @@
|
||||
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
|
||||
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
|
||||
| A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
|
||||
| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) |
|
||||
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
|
||||
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
||||
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# POST /api/v1/applog/batch — 批量上报客户端运行日志
|
||||
|
||||
> 所属:客户端日志组(前缀 `/api/v1/applog`) | 鉴权:无(不强制登录,`user_id` 可选带上) | [← 返回 API 索引](../README.md)
|
||||
|
||||
批量接收客户端 App 运行日志(自动化步骤 / 网络 / 崩溃 / 调试等),**逐条**封装成单行 JSON 写入独立滚动文件 `logs/app-client.log`(与服务日志 `app-server.log` 分开),由阿里云 Logtail 采进**独立 SLS logstore**,**不落库**。服务端补 `client_ip`(X-Forwarded-For)与 `time`(接收时间,SLS 主时间)。`trace_id` 提到输出行顶层,便于在 SLS 里跨「客户端 / 服务端」两个 logstore 按 trace 拼出端到端链路。**fire-and-forget**:写失败也返回 2xx,不 500。
|
||||
|
||||
## 入参
|
||||
|
||||
批级公共字段发一次;`logs` 里每条只带日志本身。**每条只有 `client_ts/level/trace_id/tag/msg` 会提到输出顶层,其余自定义字段一律并入输出的 `data`**(防 SLS 索引列爆炸)。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅(≤64) | 设备 ID(限流 / 分组键) |
|
||||
| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) |
|
||||
| `app_ver` | string \| null | ❌(≤32) | App 版本 |
|
||||
| `platform` | string \| null | ❌(≤16) | 平台(`android` / `ios` / `harmony`) |
|
||||
| `sent_at` | int \| null | ❌ | 批次发送时间(epoch ms) |
|
||||
| `logs` | list[object] | ✅(1-500 条) | 日志数组(每条为对象,内部字段**不强类型**) |
|
||||
| `logs[].client_ts` | int | ❌ | 端侧日志时间(epoch ms) |
|
||||
| `logs[].level` | string | ❌ | 级别(服务端归一化大写,截断 ≤16) |
|
||||
| `logs[].trace_id` | string \| null | ❌ | 关联服务端比价链路的 trace(有服务端交互的日志带上,截断 ≤256) |
|
||||
| `logs[].tag` | string \| null | ❌ | 模块 / 分类(截断 ≤128) |
|
||||
| `logs[].msg` | string | ❌ | 消息主体(超 8KB **字节**截断,加 `msg_truncated` 标记) |
|
||||
| `logs[].*` | any | ❌ | 其余任意自定义字段 → 一律并入输出的 `data`(客户端自带的 `data` 对象会被合并进来) |
|
||||
|
||||
Mock 入参:
|
||||
```json
|
||||
{
|
||||
"device_id": "android_abc123def456",
|
||||
"user_id": 42,
|
||||
"app_ver": "0.1.5",
|
||||
"platform": "android",
|
||||
"sent_at": 1719993700000,
|
||||
"logs": [
|
||||
{
|
||||
"client_ts": 1719993600000,
|
||||
"level": "info",
|
||||
"tag": "automation",
|
||||
"msg": "compare flow start",
|
||||
"trace_id": "t_ab12cd34"
|
||||
},
|
||||
{
|
||||
"client_ts": 1719993615000,
|
||||
"level": "error",
|
||||
"tag": "network",
|
||||
"msg": "timeout calling /price/step",
|
||||
"trace_id": "t_ab12cd34",
|
||||
"http_status": 504,
|
||||
"retry": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
> 上例第二条的 `http_status` / `retry` 不在白名单 → 会被并入落盘行的 `data`:`{"http_status":504,"retry":2}`。
|
||||
|
||||
## 出参
|
||||
|
||||
响应 `200`:`AppLogIngestOut`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ok` | bool | 固定 `true` |
|
||||
| `received` | int | 成功写入文件的条数 |
|
||||
| `dropped` | int | 跳过的条数(服务端处理 / 序列化失败;正常为 `0`) |
|
||||
|
||||
Mock 出参:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"received": 2,
|
||||
"dropped": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
- `413` 请求体超过上限(默认 2MB;服务端查 `Content-Length`,在 body 校验前拦截。缺该头时由 nginx `client_max_body_size` 兜底)
|
||||
- `422` `logs` 为空或超过 500 条 / `device_id` 缺失 / 字段类型不符
|
||||
- `429` 触发限流(同 IP 每分钟 > 120 次)
|
||||
|
||||
## 说明
|
||||
- **落盘 → SLS**:逐条写独立文件 `logs/app-client.log`(单行 JSON,`propagate=False` 不污染 `app-server.log`),由 Logtail JSON 模式采进**独立 logstore**;不进数据库。落盘行除白名单字段外,服务端另补 `time`(接收时间)、`source="client"`、`service`、`client_ip` 及批级 `device_id/user_id/app_ver/platform/sent_at`。
|
||||
- **`trace_id` 跨层检索**:字段名与服务端日志一致。客户端应给**有服务端交互**的日志带上当初 API 返回的 `trace_id`(如 `/api/v1/price/step` 等透传族返回的 trace),即可在 SLS 里 `trace_id: "xxx"` 一查拼出「客户端视角 + 服务端比价链路」;纯客户端日志不带即可。
|
||||
- **白名单 + `data` 兜底**:只有 `client_ts/level/trace_id/tag/msg` 上顶层,其余键并入 `data`,把 SLS 索引列钉死在固定集合,防客户端任意 key 撑爆索引。索引字段另有长度上限(`level`≤16 / `trace_id`≤256 / `tag`≤128 / `msg`≤8KB)。
|
||||
- **fire-and-forget**:写文件失败也返回 2xx(避免客户端重试风暴);网络重试可能在 SLS 造成重复条目,可接受。
|
||||
- **量级建议**:客户端做等级过滤 / 采样,攒到一定量再批量上报;单批 ≤500 条、body ≤2MB。
|
||||
- `client_ts` 是端侧时间(客户端时钟不可信),`time` 由服务端补(可靠时间轴)。
|
||||
- `user_id` 不靠 JWT:未登录态也要采日志;带上便于按用户排查。
|
||||
@@ -0,0 +1,561 @@
|
||||
# 客户端运行日志批量上报 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 新增 `POST /api/v1/applog/batch`,把客户端批量上报的运行日志逐条写进独立滚动文件 `logs/app-client.log`,供 Aliyun Logtail 采进独立 SLS logstore。
|
||||
|
||||
**Architecture:** 复用两个现成范式——analytics 的「批量入口 + 服务端补 IP」+ `app-server.log` 的「单行 JSON + RotatingFileHandler + Logtail」。核心逻辑在写入层 `app/core/client_log.py`:专用 `client` logger(`propagate=False`,不污染服务日志),每条按「白名单键提顶层 + 其余并入 `data`」封装成一行 JSON,`trace_id` 提到顶层以便跨层检索。端点瘦、fire-and-forget(写失败不 500)。
|
||||
|
||||
**Tech Stack:** FastAPI、Pydantic v2、Python `logging.handlers.RotatingFileHandler`、pytest + `TestClient`。
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md](../specs/2026-07-19-client-applog-ingest-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 职责 | 动作 |
|
||||
|---|---|---|
|
||||
| `app/core/client_log.py` | 专用 logger/handler、滚动配置、白名单+`data` 兜底与信封拼装、`write_records()` | Create |
|
||||
| `app/schemas/applog.py` | `AppLogBatchIn`(`logs: list[dict]`)/ `AppLogIngestOut` | Create |
|
||||
| `app/api/v1/applog.py` | 瘦路由:body 上限依赖 → 调 writer → 返回计数 | Create |
|
||||
| `app/main.py` | 注册 `applog_router` | Modify |
|
||||
| `tests/test_applog.py` | writer 单测 + 端点集成测试 | Create |
|
||||
|
||||
前置约束(写进代码注释):`RotatingFileHandler` 多进程并发 `doRollover()` 会损坏/丢日志;本方案依赖生产 `--workers 1`(与限流器/SMS 码/SQLite 同一既有假设)。扩 worker 前必须换 `QueueHandler`→单写入者 / 外部 logrotate / 写 stdout 交 journald。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 专用落盘 writer `app/core/client_log.py`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/core/client_log.py`
|
||||
- Test: `tests/test_applog.py`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(writer 层)**
|
||||
|
||||
创建 `tests/test_applog.py`:
|
||||
|
||||
```python
|
||||
"""客户端运行日志上报:writer 单测 + 端点集成测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import client_log
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_log_file(tmp_path, monkeypatch):
|
||||
"""把客户端日志切到临时文件,并重置 writer 单例使其按当时 env 重建。"""
|
||||
p = tmp_path / "app-client.log"
|
||||
monkeypatch.setenv("CLIENT_LOG_FILE", str(p))
|
||||
client_log.reset_client_logger()
|
||||
yield p
|
||||
client_log.reset_client_logger()
|
||||
|
||||
|
||||
def _read_lines(p: Path) -> list[dict]:
|
||||
text = p.read_text(encoding="utf-8").strip()
|
||||
return [json.loads(ln) for ln in text.splitlines() if ln]
|
||||
|
||||
|
||||
# ---------------- writer 层 ----------------
|
||||
|
||||
def _meta(**kw) -> dict:
|
||||
base = {"device_id": "d-1", "user_id": None, "app_ver": None,
|
||||
"platform": None, "sent_at": None}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_writer_writes_one_line_per_record(client_log_file):
|
||||
recs = [
|
||||
{"client_ts": 1737000000000, "level": "info", "msg": "hello"},
|
||||
{"client_ts": 1737000000001, "level": "error", "msg": "boom", "tag": "net"},
|
||||
]
|
||||
received, dropped = client_log.write_records(
|
||||
recs, meta=_meta(user_id=42, app_ver="1.2.3", platform="android"),
|
||||
client_ip="1.2.3.4",
|
||||
)
|
||||
assert (received, dropped) == (2, 0)
|
||||
lines = _read_lines(client_log_file)
|
||||
assert len(lines) == 2
|
||||
assert lines[0]["source"] == "client"
|
||||
assert lines[0]["service"] == "app-client"
|
||||
assert lines[0]["device_id"] == "d-1"
|
||||
assert lines[0]["user_id"] == 42
|
||||
assert lines[0]["app_ver"] == "1.2.3"
|
||||
assert lines[0]["client_ip"] == "1.2.3.4"
|
||||
assert lines[0]["level"] == "INFO" # 归一化大写
|
||||
assert lines[0]["msg"] == "hello"
|
||||
assert lines[0]["client_ts"] == 1737000000000
|
||||
assert lines[1]["tag"] == "net"
|
||||
|
||||
|
||||
def test_writer_hoists_trace_id_to_top_level(client_log_file):
|
||||
client_log.write_records(
|
||||
[{"client_ts": 1, "level": "info", "msg": "x", "trace_id": "abc123"}],
|
||||
meta=_meta(), client_ip="",
|
||||
)
|
||||
assert _read_lines(client_log_file)[0]["trace_id"] == "abc123"
|
||||
|
||||
|
||||
def test_writer_sweeps_unknown_keys_into_data(client_log_file):
|
||||
client_log.write_records(
|
||||
[{"client_ts": 1, "level": "info", "msg": "x",
|
||||
"foo": 123, "data": {"bar": "baz"}}],
|
||||
meta=_meta(), client_ip="",
|
||||
)
|
||||
line = _read_lines(client_log_file)[0]
|
||||
assert "foo" not in line # 白名单外不进顶层
|
||||
assert line["data"]["foo"] == 123 # 兜底进 data
|
||||
assert line["data"]["bar"] == "baz" # 客户端自带 data 合并进来
|
||||
|
||||
|
||||
def test_writer_truncates_oversize_msg(client_log_file, monkeypatch):
|
||||
monkeypatch.setenv("APPLOG_MAX_MSG_BYTES", "10")
|
||||
received, dropped = client_log.write_records(
|
||||
[{"client_ts": 1, "level": "info", "msg": "x" * 100}],
|
||||
meta=_meta(), client_ip="",
|
||||
)
|
||||
assert (received, dropped) == (1, 0) # 截断而非丢弃
|
||||
line = _read_lines(client_log_file)[0]
|
||||
assert line["msg_truncated"] is True
|
||||
assert line["msg"].endswith("…[truncated]")
|
||||
|
||||
|
||||
def test_writer_logger_does_not_propagate(client_log_file):
|
||||
lg = client_log.get_logger()
|
||||
assert lg.name == "client"
|
||||
assert lg.propagate is False # 不冒泡到 root → 不写 app-server.log
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_applog.py -q`
|
||||
Expected: FAIL —— `AttributeError: module 'app.core.client_log' has no attribute 'reset_client_logger'`(模块尚不存在)。
|
||||
|
||||
- [ ] **Step 3: 实现 `app/core/client_log.py`**
|
||||
|
||||
```python
|
||||
"""客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。
|
||||
|
||||
- 独占 logger "client" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。
|
||||
- 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再
|
||||
json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身
|
||||
已是 JSON,不能再过 JsonFormatter 二次编码。
|
||||
- 滚动 20MB×10(env 可调),与服务日志同机制。
|
||||
⚠️ 依赖 --workers 1:RotatingFileHandler 多进程并发 doRollover 会损坏/丢日志;扩 worker
|
||||
前换 QueueHandler→单写入者 / 外部 logrotate(copytruncate) / 写 stdout 交 journald。
|
||||
|
||||
服务端补的字段(time/source/service/client_ip/device_id/...)是「事实」,与客户端自述分开。
|
||||
`time` 用服务端接收时间作 SLS 主时间(客户端时钟不可信),client_ts 另存为可查字段。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
# 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸
|
||||
_TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg")
|
||||
|
||||
_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _max_msg_bytes() -> int:
|
||||
return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192"))
|
||||
|
||||
|
||||
def _build_logger() -> logging.Logger:
|
||||
lg = logging.getLogger("client")
|
||||
lg.setLevel(logging.INFO)
|
||||
lg.propagate = False # 不冒泡到 root → 不写进 app-server.log
|
||||
log_file = os.getenv("CLIENT_LOG_FILE") or str(
|
||||
Path(os.getenv("LOG_DIR", "logs")) / "app-client.log"
|
||||
)
|
||||
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=int(os.getenv("CLIENT_LOG_MAX_BYTES", str(20 * 1024 * 1024))),
|
||||
backupCount=int(os.getenv("CLIENT_LOG_BACKUP_COUNT", "10")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(logging.Formatter("%(message)s")) # 行已是 JSON,不再包装
|
||||
lg.handlers = [handler]
|
||||
return lg
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
global _logger
|
||||
if _logger is None:
|
||||
_logger = _build_logger()
|
||||
return _logger
|
||||
|
||||
|
||||
def reset_client_logger() -> None:
|
||||
"""测试用:关闭并丢弃当前 logger,使下次 get_logger 按当时 env 重建(切临时文件)。"""
|
||||
global _logger
|
||||
if _logger is not None:
|
||||
for h in list(_logger.handlers):
|
||||
h.close()
|
||||
_logger.handlers = []
|
||||
_logger = None
|
||||
|
||||
|
||||
def _truncate_msg(msg: str) -> tuple[str, bool]:
|
||||
raw = msg.encode("utf-8")
|
||||
limit = _max_msg_bytes()
|
||||
if len(raw) <= limit:
|
||||
return msg, False
|
||||
# 按字节截断后解码,忽略截断处半个多字节字符
|
||||
return raw[:limit].decode("utf-8", "ignore") + "…[truncated]", True
|
||||
|
||||
|
||||
def _build_line(
|
||||
record: dict, *, meta: dict, client_ip: str, service: str, now_iso: str
|
||||
) -> str:
|
||||
out: dict = {
|
||||
"time": now_iso,
|
||||
"source": "client",
|
||||
"service": service,
|
||||
"client_ip": client_ip,
|
||||
}
|
||||
# 批级公共字段(非空才带)
|
||||
for k in ("device_id", "user_id", "app_ver", "platform", "sent_at"):
|
||||
v = meta.get(k)
|
||||
if v is not None:
|
||||
out[k] = v
|
||||
# 白名单键提顶层
|
||||
if record.get("level") is not None:
|
||||
out["level"] = str(record["level"]).upper()
|
||||
if record.get("trace_id"):
|
||||
out["trace_id"] = record["trace_id"]
|
||||
if record.get("tag"):
|
||||
out["tag"] = record["tag"]
|
||||
if record.get("client_ts") is not None:
|
||||
out["client_ts"] = record["client_ts"]
|
||||
if record.get("msg") is not None:
|
||||
msg, truncated = _truncate_msg(str(record["msg"]))
|
||||
out["msg"] = msg
|
||||
if truncated:
|
||||
out["msg_truncated"] = True
|
||||
# 其余键(含客户端自带 data)并入 data
|
||||
data: dict = {}
|
||||
client_data = record.get("data")
|
||||
if isinstance(client_data, dict):
|
||||
data.update(client_data)
|
||||
for k, v in record.items():
|
||||
if k in _TOP_LEVEL_KEYS or k == "data":
|
||||
continue
|
||||
data[k] = v
|
||||
if data:
|
||||
out["data"] = data
|
||||
return json.dumps(out, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def write_records(
|
||||
records: list[dict], *, meta: dict, client_ip: str
|
||||
) -> tuple[int, int]:
|
||||
"""把一批客户端日志逐条写入专用文件。返回 (received, dropped)。
|
||||
|
||||
尽力而为(fire-and-forget):logger 初始化或单条写入失败只跳过并计 dropped,
|
||||
不抛给上层——端点因此永不因写日志而 500。
|
||||
"""
|
||||
try:
|
||||
lg = get_logger()
|
||||
except Exception: # noqa: BLE001 — 初始化失败也不能让端点 500
|
||||
logging.getLogger("shagua.applog").exception("client log writer init failed")
|
||||
return 0, len(records)
|
||||
service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client")
|
||||
now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
||||
received = dropped = 0
|
||||
for rec in records:
|
||||
try:
|
||||
line = _build_line(
|
||||
rec, meta=meta, client_ip=client_ip, service=service, now_iso=now_iso
|
||||
)
|
||||
lg.info(line)
|
||||
received += 1
|
||||
except Exception: # noqa: BLE001 — 坏条跳过,不影响其余
|
||||
dropped += 1
|
||||
return received, dropped
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_applog.py -q`
|
||||
Expected: PASS(5 个 writer 测试全绿)。
|
||||
|
||||
- [ ] **Step 5: ruff**
|
||||
|
||||
Run: `ruff check app/core/client_log.py tests/test_applog.py`
|
||||
Expected: 无错误(如有 import 排序等自动可修问题:`ruff check --fix`)。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add app/core/client_log.py tests/test_applog.py
|
||||
git commit -m "feat(applog): 客户端日志专用落盘 writer(白名单+data 兜底, propagate=False)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 端点 + schema + 注册
|
||||
|
||||
**Files:**
|
||||
- Create: `app/schemas/applog.py`
|
||||
- Create: `app/api/v1/applog.py`
|
||||
- Modify: `app/main.py`(import + `include_router`)
|
||||
- Test: `tests/test_applog.py`(追加端点用例)
|
||||
|
||||
- [ ] **Step 1: 追加失败测试(端点层)**
|
||||
|
||||
在 `tests/test_applog.py` 末尾追加:
|
||||
|
||||
```python
|
||||
# ---------------- 端点层 ----------------
|
||||
|
||||
def _post(client, body):
|
||||
return client.post("/api/v1/applog/batch", json=body)
|
||||
|
||||
|
||||
def test_endpoint_happy_path(client, client_log_file):
|
||||
body = {
|
||||
"device_id": "d-1", "user_id": 42, "app_ver": "1.2.3", "platform": "android",
|
||||
"logs": [
|
||||
{"client_ts": 1, "level": "info", "msg": "a", "trace_id": "t1"},
|
||||
{"client_ts": 2, "level": "warn", "msg": "b"},
|
||||
],
|
||||
}
|
||||
resp = _post(client, body)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "received": 2, "dropped": 0}
|
||||
lines = _read_lines(client_log_file)
|
||||
assert len(lines) == 2
|
||||
assert lines[0]["trace_id"] == "t1" # 端到端:trace_id 落到文件顶层
|
||||
assert lines[0]["client_ip"] # 服务端补了 IP
|
||||
|
||||
|
||||
def test_endpoint_rejects_batch_over_max(client, client_log_file):
|
||||
body = {"device_id": "d-1",
|
||||
"logs": [{"client_ts": i, "level": "info", "msg": str(i)} for i in range(501)]}
|
||||
resp = _post(client, body)
|
||||
assert resp.status_code == 422 # Pydantic max_length=500
|
||||
|
||||
|
||||
def test_endpoint_rejects_body_over_limit(client, client_log_file, monkeypatch):
|
||||
monkeypatch.setenv("APPLOG_MAX_BODY_BYTES", "50")
|
||||
body = {"device_id": "d-1", "logs": [{"client_ts": 1, "level": "info", "msg": "x" * 500}]}
|
||||
resp = _post(client, body)
|
||||
assert resp.status_code == 413 # 依赖查 Content-Length,body 校验前拦截
|
||||
|
||||
|
||||
def test_endpoint_returns_200_on_write_failure(client, client_log_file, monkeypatch):
|
||||
class _BoomLogger:
|
||||
name = "client"
|
||||
propagate = False
|
||||
handlers: list = []
|
||||
|
||||
def info(self, *a, **k):
|
||||
raise RuntimeError("disk full")
|
||||
|
||||
monkeypatch.setattr(client_log, "get_logger", lambda: _BoomLogger())
|
||||
body = {"device_id": "d-1", "logs": [
|
||||
{"client_ts": 1, "level": "info", "msg": "a"},
|
||||
{"client_ts": 2, "level": "info", "msg": "b"}]}
|
||||
resp = _post(client, body)
|
||||
assert resp.status_code == 200 # fire-and-forget:写失败不 500
|
||||
assert resp.json() == {"ok": True, "received": 0, "dropped": 2}
|
||||
|
||||
|
||||
def test_endpoint_missing_device_id_is_422(client, client_log_file):
|
||||
resp = _post(client, {"logs": [{"client_ts": 1, "level": "info", "msg": "a"}]})
|
||||
assert resp.status_code == 422 # device_id 必填
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_applog.py -q`
|
||||
Expected: FAIL —— 端点未注册,`POST /api/v1/applog/batch` 返回 404(happy-path 断言 200 失败)。
|
||||
|
||||
- [ ] **Step 3: 实现 schema `app/schemas/applog.py`**
|
||||
|
||||
```python
|
||||
"""客户端运行日志批量上报 schema。
|
||||
|
||||
批级公共字段(device_id/user_id/app_ver/platform/sent_at)发一次;logs 为原始 dict 列表,
|
||||
**不强类型**——尽力而为的日志链路,单条内容异常不该让整批 422。每条的「白名单键 + data
|
||||
兜底」拆分在写入层 [app.core.client_log] 做(见 spec §4/§5)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
_MAX_BATCH = int(os.getenv("APPLOG_MAX_BATCH", "500"))
|
||||
|
||||
|
||||
class AppLogBatchIn(BaseModel):
|
||||
device_id: str = Field(max_length=64)
|
||||
user_id: int | None = None
|
||||
app_ver: str | None = Field(default=None, max_length=32)
|
||||
platform: str | None = Field(default=None, max_length=16)
|
||||
sent_at: int | None = None
|
||||
logs: list[dict[str, Any]] = Field(min_length=1, max_length=_MAX_BATCH)
|
||||
|
||||
|
||||
class AppLogIngestOut(BaseModel):
|
||||
ok: bool = True
|
||||
received: int
|
||||
dropped: int = 0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现端点 `app/api/v1/applog.py`**
|
||||
|
||||
```python
|
||||
"""客户端运行日志批量上报接口。
|
||||
|
||||
POST /api/v1/applog/batch — 批量接收客户端运行日志,逐条写专用滚动文件 logs/app-client.log
|
||||
(供 Logtail 采进独立 SLS logstore)。鉴权同 analytics(不强制登录,user_id 可选在 body)。
|
||||
fire-and-forget:写失败也不 500(避免客户端重试风暴);超批 422、超体积 413、msg 超限截断。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.core.client_log import write_records
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.schemas.applog import AppLogBatchIn, AppLogIngestOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/applog", tags=["applog"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP(同 analytics)。"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
def _enforce_body_limit(request: Request) -> None:
|
||||
"""依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。"""
|
||||
max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024 * 2)))
|
||||
cl = request.headers.get("content-length")
|
||||
if cl is not None and cl.isdigit() and int(cl) > max_bytes:
|
||||
raise HTTPException(status_code=413, detail="日志批量过大")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch",
|
||||
response_model=AppLogIngestOut,
|
||||
summary="批量上报客户端运行日志",
|
||||
dependencies=[
|
||||
Depends(rate_limit(120, 60, "applog-batch")),
|
||||
Depends(_enforce_body_limit),
|
||||
],
|
||||
)
|
||||
def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut:
|
||||
received, dropped = write_records(
|
||||
batch.logs,
|
||||
meta={
|
||||
"device_id": batch.device_id,
|
||||
"user_id": batch.user_id,
|
||||
"app_ver": batch.app_ver,
|
||||
"platform": batch.platform,
|
||||
"sent_at": batch.sent_at,
|
||||
},
|
||||
client_ip=_client_ip(request),
|
||||
)
|
||||
return AppLogIngestOut(received=received, dropped=dropped)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 注册路由 `app/main.py`**
|
||||
|
||||
在 import 区(analytics_router 之后,约 [app/main.py:23](../../../app/main.py#L23))加:
|
||||
|
||||
```python
|
||||
from app.api.v1.applog import router as applog_router
|
||||
```
|
||||
|
||||
在 `include_router` 区(`app.include_router(analytics_router)` 之后,约 [app/main.py:125](../../../app/main.py#L125))加:
|
||||
|
||||
```python
|
||||
app.include_router(applog_router)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_applog.py -q`
|
||||
Expected: PASS(writer 5 + 端点 5,共 10 个)。
|
||||
|
||||
- [ ] **Step 7: 全量测试 + ruff**
|
||||
|
||||
Run: `pytest -q && ruff check app/api/v1/applog.py app/schemas/applog.py app/main.py`
|
||||
Expected: 全绿、无 lint 错误。
|
||||
|
||||
- [ ] **Step 8: 提交**
|
||||
|
||||
```bash
|
||||
git add app/api/v1/applog.py app/schemas/applog.py app/main.py tests/test_applog.py
|
||||
git commit -m "feat(applog): POST /api/v1/applog/batch 批量上报端点(限流+体积上限+fire-and-forget)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `.env.example` 文档化新 env(可选但推荐)
|
||||
|
||||
**Files:**
|
||||
- Modify: `.env.example`
|
||||
|
||||
- [ ] **Step 1: 追加 env 说明**
|
||||
|
||||
在 `.env.example` 末尾(或日志相关区块)追加,让运维知道可调项:
|
||||
|
||||
```bash
|
||||
# 客户端运行日志上报(POST /api/v1/applog/batch)→ 落 logs/app-client.log 供 Logtail 采集
|
||||
# 独立于服务日志 app-server.log;滚动机制同服务日志。默认值见 app/core/client_log.py。
|
||||
# CLIENT_LOG_FILE=logs/app-client.log
|
||||
# CLIENT_LOG_MAX_BYTES=20971520 # 单文件 20MB 滚动
|
||||
# CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲
|
||||
# CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段
|
||||
# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422)
|
||||
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413)
|
||||
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超限截断
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add .env.example
|
||||
git commit -m "docs(applog): .env.example 补充客户端日志上报可调 env"
|
||||
```
|
||||
|
||||
> 若仓库无 `.env.example`(以 `git ls-files .env.example` 确认),跳过本任务。
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] `pytest -q` 全绿(含新增 `tests/test_applog.py` 10 用例)。
|
||||
- [ ] `ruff check .` 无错误。
|
||||
- [ ] `POST /api/v1/applog/batch` 手动冒烟:发一批含 `trace_id` 的日志,确认 `logs/app-client.log` 出现对应单行 JSON、`trace_id` 在顶层、未知键落在 `data`,且 `logs/app-server.log` **未**被写入客户端记录。
|
||||
- [ ] Spec 的运维项(独立 logstore、Logtail JSON 模式采 `app-client.log`、给 `trace_id` 建索引、nginx `client_max_body_size` 对齐)已同步给运维(不在本仓代码内,见 spec §8)。
|
||||
|
||||
## Self-Review 结论(作者已核对)
|
||||
|
||||
- **Spec 覆盖**:§4 端点/限额 → Task 2;§5 白名单+data 兜底 → Task 1 `_build_line`;§6 落盘/滚动/单 worker 注释 → Task 1;§7 trace_id 顶层 → Task 1 + 端点测试;§9 fire-and-forget → Task 1 `write_records` + 端点测试;§13 env → Task 1/2 读取 + Task 3 文档化。§8 为纯运维配置,列入 Definition of Done。
|
||||
- **无占位符**:所有步骤含完整代码/命令/预期。
|
||||
- **类型/命名一致**:`write_records(records, *, meta, client_ip) -> (received, dropped)`、`get_logger()`、`reset_client_logger()`、`_TOP_LEVEL_KEYS`、`AppLogBatchIn/AppLogIngestOut` 在 Task 1/2 间一致引用。
|
||||
@@ -0,0 +1,200 @@
|
||||
# 客户端运行日志批量上报 → 落专用文件 → SLS 采集 设计
|
||||
|
||||
- 日期:2026-07-19
|
||||
- 状态:设计已评审,待写实现计划
|
||||
- 相关代码:[app/core/logging.py](../../../app/core/logging.py)(服务端日志落盘范式)、[app/api/v1/analytics.py](../../../app/api/v1/analytics.py)(批量上报入口范式)、[deploy/shaguabijia-app-server.service](../../../deploy/shaguabijia-app-server.service)(`--workers 1`)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网络 / 崩溃 / 调试等),批量以 JSON 数组发给后端。后端把这些日志**逐条**写进一个**专用滚动日志文件**,文件滚动规则与服务日志一致;再由阿里云 SLS 的 Logtail 采集该文件、进独立 logstore。
|
||||
|
||||
目标:
|
||||
|
||||
1. 新增 `POST /api/v1/applog/batch` 接收批量客户端日志。
|
||||
2. 逐条落进 `logs/app-client.log`(**独立于** `app-server.log`),单行 JSON、大小滚动、供 Logtail JSON 模式零正则采集。
|
||||
3. 客户端日志进**独立 logstore**,但 **可按 `trace_id` 检索**,以便和服务端比价链路跨层对齐。
|
||||
4. 入口安全:鉴权同 analytics(不强制 Bearer),加体积/条数上限、限流,防撑盘与日志注入。
|
||||
|
||||
本方案是两个**已验证范式的组合**:analytics 的「批量入口 + 服务端补 IP/接收时间」+ `app-server.log` 的「单行 JSON + RotatingFileHandler + Logtail」。不是新架构。
|
||||
|
||||
## 2. 非目标(本期不做,YAGNI)
|
||||
|
||||
- **异步队列 / 后台 writer**:`<10 万条/天` ≈ 均值 1.2 条/秒,10× 突发 ~12/秒,请求路径同步写文件足矣。
|
||||
- **去重**:网络重试会在 SLS 产生重复条目。本期**接受重复**并在文档写明;后续如需,加客户端 `batch_id` 短窗口去重。
|
||||
- **服务端脱敏**:本期靠**客户端侧**控制等级/采样与不打 PII;服务端只做体积截断。脱敏留作后续 knob。
|
||||
- **多 worker 支持**:见 §5 的单 worker 约束。
|
||||
- **入库 / admin 查询界面**:日志的归宿是 SLS,不落 DB。
|
||||
|
||||
## 3. 方案总览与备选取舍
|
||||
|
||||
**采用 A**:客户端 → 后端接口 → 专用滚动文件 → Logtail → SLS。
|
||||
|
||||
| 方案 | 说明 | 为何不选 |
|
||||
|---|---|---|
|
||||
| **A(选定)** | 后端中转落文件,Logtail 采集 | — 代码最少、完全复用现成文件→Logtail 管线;AK/SK 不进 APK;Logtail 天然提供落盘缓冲+断点续传 |
|
||||
| B | 客户端直连 SLS(Producer SDK / Web-Tracking) | 要么把凭证埋进 APK,要么额外跑 STS 换 token 服务;服务端难做鉴权/富化/脱敏 |
|
||||
| C | 后端调 SLS PutLogs API(不落文件) | 请求路径硬依赖 SLS 可用性,需自建缓冲/背压/重试——等于重造 Logtail |
|
||||
|
||||
## 4. 端点契约
|
||||
|
||||
`POST /api/v1/applog/batch`
|
||||
|
||||
- 新路由 `app/api/v1/applog.py`,在 [app/main.py](../../../app/main.py) `import ... as applog_router` 并 `app.include_router(applog_router)`(紧挨 analytics)。
|
||||
- 鉴权同 analytics:**不强制 Bearer**,`user_id` 可选放 body;服务端补 `client_ip`(复用 analytics 里的 `_client_ip` 取 `X-Forwarded-For` 首段逻辑)与接收时间。
|
||||
|
||||
### 请求体 `AppLogBatchIn`(批级公共字段发一次,省带宽)
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束/说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | str | 是 | `max_length=64`;限流/分组键 |
|
||||
| `user_id` | int? | 否 | 可选,未登录态也采集 |
|
||||
| `app_ver` | str? | 否 | `max_length=32` |
|
||||
| `platform` | str? | 否 | `max_length=16`(android/ios/harmony) |
|
||||
| `sent_at` | int? | 否 | 本批上报时刻 epoch ms |
|
||||
| `logs` | list[dict] | 是 | `min_length=1, max_length=500`;**每条为对象**,逐条按 §5 契约处理 |
|
||||
|
||||
`logs` 用 `list[dict[str, Any]]` 而非强类型列表:这是**尽力而为**的日志链路,单条内容异常不应让整批 422 失败。超过 500 条由 Pydantic `max_length` 触发 422(客户端应更小批)。
|
||||
|
||||
### 响应 `AppLogIngestOut`
|
||||
|
||||
```jsonc
|
||||
{ "ok": true, "received": 128, "dropped": 2 }
|
||||
```
|
||||
- `received`:成功写入文件的条数。
|
||||
- `dropped`:服务端处理/序列化失败被跳过的条数(正常为 0,属异常兜底计数)。**oversize 的 `msg` 是截断而非丢弃**,不计入 dropped。
|
||||
|
||||
### 限额(防撑盘 / 注入 / DoS)
|
||||
|
||||
| 限额 | 默认 | 超限行为 | env |
|
||||
|---|---|---|---|
|
||||
| 单批条数 | 500 | 422(Pydantic) | `APPLOG_MAX_BATCH` |
|
||||
| body 字节 | 2 MB | 413(依赖查 `Content-Length`,在 body 校验前拦截;缺该头由 nginx `client_max_body_size` 兜底) | `APPLOG_MAX_BODY_BYTES` |
|
||||
| 单条 `msg` 字节 | 8192 | 截断 + 标记,不丢 | `APPLOG_MAX_MSG_BYTES` |
|
||||
|
||||
同时**对齐 nginx `client_max_body_size`**(见 [deploy/nginx](../../../deploy/nginx/))避免反代先于应用截断。限流复用 `app/core/ratelimit.py`(项目现有 IP 固定窗口);具体挂法参照现有已限流写端点,测试环境 `RATE_LIMIT_ENABLED=false` 关闭。
|
||||
|
||||
## 5. 每条记录契约 + 白名单键 + `data` 兜底
|
||||
|
||||
**客户端每条日志的识别键(仅这些提到输出行顶层):**
|
||||
|
||||
| 键 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `client_ts` | int | 端事件时间 epoch ms(与 analytics 命名一致) |
|
||||
| `level` | str | 服务端归一化为大写;不做硬枚举拒绝(fire-and-forget) |
|
||||
| `trace_id` | str? | **§6 的核心**:有服务端交互的日志带上当初 API 返回的 trace |
|
||||
| `tag` | str? | 模块/分类,便于 SLS 过滤 |
|
||||
| `msg` | str | 消息主体,超 `APPLOG_MAX_MSG_BYTES` 截断并加标记 |
|
||||
|
||||
**其余任意自定义字段 → 服务端一律收进单个 `data` 对象。**
|
||||
|
||||
**为什么这么设计(防 SLS 索引列爆炸):** Logtail JSON 模式下每个**顶层 key** 都会成为 logstore 一个可索引字段/列。若放任客户端往顶层写任意 key(甚至把动态 id 拼进 key),logstore 会长出成千上万个不同顶层列 → 索引成本膨胀、可能撞字段数上限、schema 混乱到无法建稳定 dashboard/告警,一个客户端 bug 就能把 logstore 搞脏。
|
||||
|
||||
**「兜底」= 服务端强制、不信任客户端。** `logs` 以 `list[dict]` 原样收下(不强类型,单条异常不该让整批 422),在**写入层 `client_log.py`** 逐条拆分:白名单键提顶层,**其余键(含客户端自带的 `data`)统一并进 `data`**(而非透传到顶层,也非静默丢弃——日志要保真)。于是**输出行顶层列恒定**,无论客户端怎么发。
|
||||
|
||||
这与现有 `JsonFormatter` 平铺 `phase/step/command/cost_ms` 同思路,区别:那些 extra 是**服务端可信有限**的键;客户端不可信无界,故只给一个 `data` 沙盒。
|
||||
|
||||
## 6. 落盘 writer
|
||||
|
||||
新模块 `app/core/client_log.py`:
|
||||
|
||||
- 惰性单例 `logging.getLogger("client")`,**`propagate=False`**(否则冒泡进 root 被 `app-server.log` 二次写入并污染)。
|
||||
- 独占一个 `RotatingFileHandler`,formatter 为 `%(message)s`——**不复用 root 的 `JsonFormatter`**(那会把已是 JSON 的行二次编码成字符串)。writer 自己拼信封 dict 后 `json.dumps(..., ensure_ascii=False, default=str)` 得到**一行**,`logger.info(line)` 写出。
|
||||
- 复用 `logging` 模块的 handler 锁保证多线程(uvicorn threadpool)并发写安全。
|
||||
- 幂等 setup(仿 `setup_logging` 的 `_CONFIGURED` 守卫)。
|
||||
|
||||
**滚动规则(与服务日志同机制 `RotatingFileHandler`,尺寸给客户端量级):**
|
||||
|
||||
| 参数 | 默认 | env |
|
||||
|---|---|---|
|
||||
| 文件路径 | `logs/app-client.log` | `CLIENT_LOG_FILE` / `LOG_DIR` |
|
||||
| `maxBytes` | 20 MB | `CLIENT_LOG_MAX_BYTES` |
|
||||
| `backupCount` | 10 | `CLIENT_LOG_BACKUP_COUNT` |
|
||||
| `service` 字段 | `app-client` | `CLIENT_LOG_SERVICE_NAME` |
|
||||
|
||||
≈ 200 MB / ~2 天缓冲,给 Logtail 断线留余量(按 <10 万条/天、条均值估算)。**滚动文件不 gzip**——Logtail 读不了压缩包会丢数据。
|
||||
|
||||
> ⚠️ **单 worker 约束**:`RotatingFileHandler` 多进程并发 `doRollover()` 会损坏/丢日志。当前生产 `--workers 1`(与限流器/SMS 码/SQLite 写锁同一既有假设,见 [deploy/shaguabijia-app-server.service](../../../deploy/shaguabijia-app-server.service))故安全。**代码注释显式标注**:扩 worker 前必须换 `QueueHandler`→单写入者 或外部 logrotate(copytruncate)或写 stdout 交 journald 采集。
|
||||
|
||||
**每条输出行(写入 `app-client.log`):**
|
||||
|
||||
```jsonc
|
||||
{ "time": "2026-07-19T12:00:00.123", // 服务端接收时间 = SLS 主时间(客户端时钟不可信)
|
||||
"source": "client", "service": "app-client",
|
||||
"level": "ERROR", "trace_id": "abc123", "tag": "automation", "msg": "...",
|
||||
"device_id": "d-xxx", "user_id": 123, "app_ver": "1.2.3", "platform": "android",
|
||||
"client_ip": "1.2.3.4", "client_ts": 1737000000123, "sent_at": 1737000005000,
|
||||
"data": { /* 客户端其余任意字段 */ } }
|
||||
```
|
||||
- `time` 用服务端接收时间作 SLS 主时间;`client_ts` 保留为可查字段(时钟漂移不影响检索基准)。
|
||||
- 值为空的可选字段省略,保持行精简(仿服务端 formatter 省略空 `trace_id`)。
|
||||
|
||||
## 7. trace_id 检索(满足目标 #3)
|
||||
|
||||
关键:**per-record 把 `trace_id` 提到输出行顶层,字段名与服务端日志完全一致(`trace_id`)**。来源是客户端在**它发起过服务端调用的那些日志**里带上当初 API 返回的 trace(如 `/api/v1/compare/*` 由 [pricebot_router](../../../app/core/pricebot_router.py) 透传的 trace)。没有服务端交互的纯客户端日志不带 `trace_id`,正常。
|
||||
|
||||
于是 SLS 里对客户端 logstore 与服务端 logstore 各查 `trace_id: "xxx"`,即可拼出「客户端自动化视角 + 服务端比价链路」的端到端故事。
|
||||
|
||||
> **需客户端配合**:给有服务端 trace 的日志记录打上 `trace_id`(Android 侧改动,见 §10)。
|
||||
|
||||
## 8. Logtail / SLS 侧配置(运维,非本仓代码)
|
||||
|
||||
- 新建**独立 logstore**(独立保留期,客户端日志建议**比服务端短**以控成本)。
|
||||
- 新 Logtail 配置采集 `logs/app-client.log`,**JSON 模式**(每行一条 JSON,零正则,与 `app-server.log` 同套路)。
|
||||
- **把 `trace_id` 配成索引字段**(否则 #3 查不了);`level` / `tag` / `device_id` 亦建议建索引。
|
||||
- 时间字段用输出行 `time`(服务端接收时间)。
|
||||
|
||||
## 9. 失败语义 / 安全
|
||||
|
||||
- **fire-and-forget**:写文件失败 → 服务端记一笔(`shagua.applog` logger)+ 仍返回 2xx,**绝不 500**(对比 selfstat 的 503 是 DB 关键链路,日志不是;500 会招致客户端重试风暴+重复上报)。
|
||||
- **日志行注入防护**:一律 `json.dumps` 重新序列化,内嵌 `\n` 被转义,客户端伪造不出假日志行;绝不把客户端原始字符串直接写文件。
|
||||
- **撑盘/DoS**:§4 的条数/体积/msg 上限 + IP 限流;`device_id` 便于后续拉黑滥用设备。
|
||||
|
||||
## 10. 客户端契约(Android 侧需配合,属另一仓)
|
||||
|
||||
1. 每条日志结构:顶层放 `client_ts / level / trace_id? / tag? / msg`,其余自定义字段放 `data`(否则会被服务端兜底挪进 `data`)。
|
||||
2. 有服务端交互的日志带上对应 `trace_id`。
|
||||
3. 等级/采样与 PII 控制在客户端侧做(省流量、免服务端脱敏)。
|
||||
4. 单批 ≤500 条、body ≤2MB;失败可重试(服务端接受重复)。
|
||||
|
||||
## 11. 代码落点
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `app/api/v1/applog.py` | 瘦路由:body 上限依赖 → 解析 `AppLogBatchIn` → 调 writer → 返回 `received/dropped`;`_client_ip` 复用 analytics 逻辑、IP 限流 |
|
||||
| `app/schemas/applog.py` | `AppLogBatchIn`(`logs: list[dict]` 不强类型)/ `AppLogIngestOut` |
|
||||
| `app/core/client_log.py` | 专用 logger/handler、滚动配置、白名单+`data` 兜底与信封拼装 `write_records(...)` |
|
||||
| `app/main.py` | 注册 `applog_router` |
|
||||
|
||||
## 12. 测试计划(`tests/test_applog.py`)
|
||||
|
||||
用临时目录做 `CLIENT_LOG_FILE`(测试前置 env + 重置 writer 单例)。
|
||||
|
||||
1. **落盘逐行**:POST N 条 → 200,`received=N`,文件恰 N 行、每行合法 JSON、关键字段齐。
|
||||
2. **trace_id 顶层**:带 `trace_id` 的记录 → 输出行顶层出现 `trace_id`。
|
||||
3. **未知键兜底**:记录带 `foo` → 输出行顶层无 `foo`,`data.foo` 存在。
|
||||
4. **msg 截断**:`msg` > 8KB → 截断+标记,仍 `received`(不进 dropped)。
|
||||
5. **超批拒绝**:>500 条 → 422。
|
||||
6. **超体积拒绝**:`Content-Length` > 2MB → 413。
|
||||
7. **写失败不 500**:monkeypatch writer 抛错 → 仍 2xx。
|
||||
8. **不污染服务日志**:`client` logger `propagate=False`,写客户端日志不落 `app-server.log`。
|
||||
|
||||
## 13. env 变量汇总
|
||||
|
||||
| env | 默认 | 用途 |
|
||||
|---|---|---|
|
||||
| `CLIENT_LOG_FILE` | `logs/app-client.log` | 客户端日志文件路径 |
|
||||
| `CLIENT_LOG_MAX_BYTES` | `20971520`(20MB) | 单文件滚动阈值 |
|
||||
| `CLIENT_LOG_BACKUP_COUNT` | `10` | 保留滚动文件数 |
|
||||
| `CLIENT_LOG_SERVICE_NAME` | `app-client` | 输出行 `service` 字段 |
|
||||
| `APPLOG_MAX_BATCH` | `500` | 单批最大条数 |
|
||||
| `APPLOG_MAX_BODY_BYTES` | `2097152`(2MB) | 请求体上限 |
|
||||
| `APPLOG_MAX_MSG_BYTES` | `8192` | 单条 msg 截断阈值 |
|
||||
|
||||
滚动/文件类 env 在 `client_log.py` 用 `os.getenv` 读取(与 [logging.py](../../../app/core/logging.py) 风格一致);请求限额类同样以 `os.getenv` 兜默认。
|
||||
|
||||
## 14. 已知取舍 / 未来工作
|
||||
|
||||
- **重复**:本期接受 SLS 重复条目;需要时加 `batch_id` 去重。
|
||||
- **多 worker**:见 §6 约束;扩容前迁移写入模型。
|
||||
- **服务端脱敏**:留作后续 knob。
|
||||
- **更多设备维度**(os/model/rom):需要时加到批级字段或让客户端放 `data`;可经 `device_id` 与 analytics/device 表关联。
|
||||
@@ -0,0 +1,182 @@
|
||||
"""客户端运行日志上报:writer 单测 + 端点集成测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import client_log
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_log_file(tmp_path, monkeypatch):
|
||||
"""把客户端日志切到临时文件,并重置 writer 单例使其按当时 env 重建。"""
|
||||
p = tmp_path / "app-client.log"
|
||||
monkeypatch.setenv("CLIENT_LOG_FILE", str(p))
|
||||
client_log.reset_client_logger()
|
||||
yield p
|
||||
client_log.reset_client_logger()
|
||||
|
||||
|
||||
def _read_lines(p: Path) -> list[dict]:
|
||||
text = p.read_text(encoding="utf-8").strip()
|
||||
return [json.loads(ln) for ln in text.splitlines() if ln]
|
||||
|
||||
|
||||
# ---------------- writer 层 ----------------
|
||||
|
||||
def _meta(**kw) -> dict:
|
||||
base = {"device_id": "d-1", "user_id": None, "app_ver": None,
|
||||
"platform": None, "sent_at": None}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_writer_writes_one_line_per_record(client_log_file):
|
||||
recs = [
|
||||
{"client_ts": 1737000000000, "level": "info", "msg": "hello"},
|
||||
{"client_ts": 1737000000001, "level": "error", "msg": "boom", "tag": "net"},
|
||||
]
|
||||
received, dropped = client_log.write_records(
|
||||
recs, meta=_meta(user_id=42, app_ver="1.2.3", platform="android"),
|
||||
client_ip="1.2.3.4",
|
||||
)
|
||||
assert (received, dropped) == (2, 0)
|
||||
lines = _read_lines(client_log_file)
|
||||
assert len(lines) == 2
|
||||
assert lines[0]["source"] == "client"
|
||||
assert lines[0]["service"] == "app-client"
|
||||
assert lines[0]["device_id"] == "d-1"
|
||||
assert lines[0]["user_id"] == 42
|
||||
assert lines[0]["app_ver"] == "1.2.3"
|
||||
assert lines[0]["client_ip"] == "1.2.3.4"
|
||||
assert lines[0]["level"] == "INFO" # 归一化大写
|
||||
assert lines[0]["msg"] == "hello"
|
||||
assert lines[0]["client_ts"] == 1737000000000
|
||||
assert lines[1]["tag"] == "net"
|
||||
|
||||
|
||||
def test_writer_hoists_trace_id_to_top_level(client_log_file):
|
||||
client_log.write_records(
|
||||
[{"client_ts": 1, "level": "info", "msg": "x", "trace_id": "abc123"}],
|
||||
meta=_meta(), client_ip="",
|
||||
)
|
||||
assert _read_lines(client_log_file)[0]["trace_id"] == "abc123"
|
||||
|
||||
|
||||
def test_writer_sweeps_unknown_keys_into_data(client_log_file):
|
||||
client_log.write_records(
|
||||
[{"client_ts": 1, "level": "info", "msg": "x",
|
||||
"foo": 123, "data": {"bar": "baz"}}],
|
||||
meta=_meta(), client_ip="",
|
||||
)
|
||||
line = _read_lines(client_log_file)[0]
|
||||
assert "foo" not in line # 白名单外不进顶层
|
||||
assert line["data"]["foo"] == 123 # 兜底进 data
|
||||
assert line["data"]["bar"] == "baz" # 客户端自带 data 合并进来
|
||||
|
||||
|
||||
def test_writer_truncates_oversize_msg(client_log_file, monkeypatch):
|
||||
monkeypatch.setenv("APPLOG_MAX_MSG_BYTES", "10")
|
||||
received, dropped = client_log.write_records(
|
||||
[{"client_ts": 1, "level": "info", "msg": "x" * 100}],
|
||||
meta=_meta(), client_ip="",
|
||||
)
|
||||
assert (received, dropped) == (1, 0) # 截断而非丢弃
|
||||
line = _read_lines(client_log_file)[0]
|
||||
assert line["msg_truncated"] is True
|
||||
assert line["msg"].endswith("…[truncated]")
|
||||
body = line["msg"].removesuffix("…[truncated]")
|
||||
assert len(body.encode("utf-8")) <= 10 # 截断体不超过字节上限
|
||||
|
||||
|
||||
def test_writer_caps_indexed_field_lengths(client_log_file):
|
||||
client_log.write_records(
|
||||
[{"client_ts": 1, "level": "info" * 20, "msg": "x",
|
||||
"trace_id": "t" * 1000, "tag": "g" * 1000}],
|
||||
meta=_meta(), client_ip="",
|
||||
)
|
||||
line = _read_lines(client_log_file)[0]
|
||||
assert len(line["level"]) <= 16 # 索引字段(spec §8)做长度上限
|
||||
assert len(line["trace_id"]) <= 256
|
||||
assert len(line["tag"]) <= 128
|
||||
|
||||
|
||||
def test_writer_logger_does_not_propagate(client_log_file):
|
||||
lg = client_log.get_logger()
|
||||
assert lg.name == "shagua.client_log"
|
||||
assert lg.propagate is False # 不冒泡到 root → 不写 app-server.log
|
||||
|
||||
|
||||
def test_writer_drops_bad_record_without_raising(client_log_file, monkeypatch):
|
||||
def _boom(*a, **kw):
|
||||
raise RuntimeError("boom")
|
||||
monkeypatch.setattr(client_log, "_build_line", _boom)
|
||||
received, dropped = client_log.write_records(
|
||||
[{"client_ts": 1, "level": "info", "msg": "x"}],
|
||||
meta=_meta(), client_ip="",
|
||||
)
|
||||
assert (received, dropped) == (0, 1) # 坏条计 dropped,且不抛给上层
|
||||
|
||||
|
||||
# ---------------- 端点层 ----------------
|
||||
|
||||
def _post(client, body):
|
||||
return client.post("/api/v1/applog/batch", json=body)
|
||||
|
||||
|
||||
def test_endpoint_happy_path(client, client_log_file):
|
||||
body = {
|
||||
"device_id": "d-1", "user_id": 42, "app_ver": "1.2.3", "platform": "android",
|
||||
"logs": [
|
||||
{"client_ts": 1, "level": "info", "msg": "a", "trace_id": "t1"},
|
||||
{"client_ts": 2, "level": "warn", "msg": "b"},
|
||||
],
|
||||
}
|
||||
resp = _post(client, body)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "received": 2, "dropped": 0}
|
||||
lines = _read_lines(client_log_file)
|
||||
assert len(lines) == 2
|
||||
assert lines[0]["trace_id"] == "t1" # 端到端:trace_id 落到文件顶层
|
||||
assert lines[0]["client_ip"] # 服务端补了 IP
|
||||
|
||||
|
||||
def test_endpoint_rejects_batch_over_max(client, client_log_file):
|
||||
body = {"device_id": "d-1",
|
||||
"logs": [{"client_ts": i, "level": "info", "msg": str(i)} for i in range(501)]}
|
||||
resp = _post(client, body)
|
||||
assert resp.status_code == 422 # Pydantic max_length=500
|
||||
|
||||
|
||||
def test_endpoint_rejects_body_over_limit(client, client_log_file, monkeypatch):
|
||||
# 只测有 Content-Length 的常规情形(TestClient/requests 恒发该头)。缺该头(chunked)
|
||||
# 时依赖 nginx client_max_body_size 兜底,不在应用层测。
|
||||
monkeypatch.setenv("APPLOG_MAX_BODY_BYTES", "50")
|
||||
body = {"device_id": "d-1", "logs": [{"client_ts": 1, "level": "info", "msg": "x" * 500}]}
|
||||
resp = _post(client, body)
|
||||
assert resp.status_code == 413 # 依赖查 Content-Length,body 校验前拦截
|
||||
|
||||
|
||||
def test_endpoint_returns_200_on_write_failure(client, client_log_file, monkeypatch):
|
||||
class _BoomLogger:
|
||||
name = "shagua.client_log"
|
||||
propagate = False
|
||||
handlers: list = []
|
||||
|
||||
def info(self, *a, **k):
|
||||
raise RuntimeError("disk full")
|
||||
|
||||
monkeypatch.setattr(client_log, "get_logger", lambda: _BoomLogger())
|
||||
body = {"device_id": "d-1", "logs": [
|
||||
{"client_ts": 1, "level": "info", "msg": "a"},
|
||||
{"client_ts": 2, "level": "info", "msg": "b"}]}
|
||||
resp = _post(client, body)
|
||||
assert resp.status_code == 200 # fire-and-forget:写失败不 500
|
||||
assert resp.json() == {"ok": True, "received": 0, "dropped": 2}
|
||||
|
||||
|
||||
def test_endpoint_missing_device_id_is_422(client, client_log_file):
|
||||
resp = _post(client, {"logs": [{"client_ts": 1, "level": "info", "msg": "a"}]})
|
||||
assert resp.status_code == 422 # device_id 必填
|
||||
Reference in New Issue
Block a user