1、文档规范化
2、增加cid核心功能entity
This commit is contained in:
@@ -1,90 +1,108 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
## 目录结构与职责(硬性约束)
|
||||
|
||||
## Build & Run
|
||||
> **`biz/` 是泛化占位名,不是固定目录命名**。表格中 `biz/` 代表「业务模块目录」,各项目必须按自身业务命名替换(本项目即 `biz/`),禁止新项目照抄 `biz/`;`data/`、`workspace/` 亦为本项目目录名,各项目按自身命名。
|
||||
|
||||
```bash
|
||||
# Build (Docker)
|
||||
docker build -t cid .
|
||||
| 目录 | 职责 | 强约束 |
|
||||
|---|---|---|
|
||||
| common/ | 通用层:HTTP 服务与鉴权中间件、文件解析(parser + pdf/docx/html/text)、中文分词、向量 JSON、DAO 基类、查询缓存、协程池封装 | 不得依赖业务模块包;新增跨模块通用能力放这里 |
|
||||
| biz/consts/ | 常量集中地:表名(table_name.go)、状态(status.go)、内容类型、默认参数与各协程池默认大小(consts.go) | 业务常量一律在此集中,禁止散落 magic number;新增池默认大小在此定义 |
|
||||
| biz/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型:跨表聚合与服务层组装值,可被 dto/entity 引用) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口;domain 收纳不属于 dto 也不属于 entity 的类型(见下) |
|
||||
| biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 |
|
||||
| biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 |
|
||||
| biz/controller/ | 接口层:接收参数、调用 service、组装返回值 | 见「分层职责规范」;禁止调用 dao |
|
||||
| 运行时数据目录 | SQLite 库、上传/解析文件(本项目 `data/` `workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 |
|
||||
|
||||
# Run locally (requires Go 1.26)
|
||||
go mod download && go mod tidy
|
||||
go run main.go
|
||||
## 分层职责规范(硬性要求)
|
||||
|
||||
# Build binary
|
||||
go build -ldflags="-s -w" -o main ./main.go
|
||||
严格分层 `controller → service → dao`,禁止跨层调用(controller 禁止直接调 dao)。
|
||||
|
||||
# Test
|
||||
go test ./...
|
||||
```
|
||||
| 层 | 目录 | 职责 | 禁止 |
|
||||
|---|---|---|---|
|
||||
| controller | biz/controller | 接收 dto 请求参数(依赖 DTO `v` tag 自动校验)调用 service,原样返回 service 结果(返回类型与 service 一致,即 dto);**传参方式:整个 `*dto.XxxReq` 直接传给 service,禁止从 dto 拆出多个属性逐个传参** | 直接调用 dao;任何组装/映射/字段搬运;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 |
|
||||
| service | biz/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao;只允许返回 dto 类型(返回与 controller 输出一致的 `*dto.XxxRes`),派生值(如 scene_name/node_count)在 service 用 dto 组装 | 直接写 HTTP 响应(例外见下);返回裸 gdb.Record 或任何非 dto 类型 |
|
||||
| dao | biz/dao | 构建 SQL 并执行;行→结构体转换在 dao 内部用 GoFrame 自带方法(`Record.Struct` / `Result.Structs`,按 `orm` tag)完成,对外只允许返回 entity(或单值如 int/map) | 业务逻辑;返回裸 gdb.Record——**裸 gdb.Record 不允许作为任何分层方法的返回值**(含 service 内事务读),转换只发生在 dao 内部,不外泄 |
|
||||
|
||||
## GoFrame CLI (optional)
|
||||
**例外**:SSE 流式响应、HTML/文件导出等"直接写响应体"的场景由 controller 完成——这是"值返回"的流式形式,事件序列化、心跳属 HTTP 协议职责,保留在 controller。
|
||||
|
||||
```bash
|
||||
# Install gf CLI
|
||||
go install github.com/gogf/gf/v2/cmd/gf/v2@latest
|
||||
**分层锚定原则(controller 反向锚定,防跑偏)**:controller 只透传 ⇒ 接口返回类型以 dto 为准 ⇒ service 返回类型被 dto 锁死 ⇒ dao 输出被 entity 锁死。任何一层若出现"为下一层做数据搬运"(controller 映射 service 结果、service 逐键取裸 Record 字段),即违反本原则,应向上收敛:转换在 dao 内部(Record→entity)、组装在 service(entity→dto)、透传在 controller。
|
||||
|
||||
# Generate DAO/Model from DB
|
||||
gf gen dao -c config.yml
|
||||
```
|
||||
**教训(此前偏离原因,开发时引以为戒)**:
|
||||
1. 自定义中转层不得替代 dto:domain 只收"不进 HTTP 出入参"的纯领域值(如结算输入 SettleState/结算结果 FinalSettle);凡出现在接口出入参中的类型一律用 dto,由 service 直接产出——否则 controller 被迫承担映射,违反"controller 只透传"
|
||||
2. dao 必须完成行→结构体转换:裸 gdb.Record 的 string 键取值是魔法值,拼错列名编译期不报错,且会把键取值扩散到 service/controller;GoFrame 自带 `Record.Struct`/`Result.Structs`(按 `orm` tag)即转换手段
|
||||
3. 新分层先对齐框架惯例:GoFrame 原生分层即 dao 转 entity / service 返回 dto / controller 薄透传,自定义设计前先核对框架默认范式
|
||||
|
||||
## Project Overview
|
||||
## 分层文件对齐与代码模式(硬性要求)
|
||||
|
||||
CID is a **content moderation service** that submits advertising images/videos to Netease Yidun (易盾) for automated content review. It provides REST APIs, a scheduled batch-checking scheduler, and a frontend management UI.
|
||||
- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(核验方式:每层目录文件数 = 分层表数,分层表数 = 总表数 − 豁免表数);虚拟表(向量 vec0 / FTS5)与**流水/记录类表(如 point_log)豁免分层对齐**:不建任何独立分层文件(含 entity/dao),建表由主表 dao 统一管理(同虚拟表模式),由使用方 service 事务内直写,禁止为只写不读的审计表造分层门面;无任何读写引用的死表连表带分层整套删除,启动时 DROP 库内残留表与代码保持一致
|
||||
- **非表文件一律不进业务分层目录**:路由注册与中间件装配、表初始化列表(建表 + 死表 DROP)直接写在 `main.go`;鉴权等跨模块通用能力放 `common/`;跨表业务流程归入所属表文件(如闯关 Choose 属 level 表)——分层目录出现非表文件即违反对齐,禁止以非表名开独立分层文件
|
||||
- **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件
|
||||
- entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射
|
||||
- dto:请求/响应结构,`g.Meta` 内嵌定义路由;只描述 HTTP 出入参,不承载领域逻辑
|
||||
- **domain(目录 `biz/model/domain/`,package domain)**:仅收纳**不进 HTTP 出入参**的纯领域值(如结算输入 SettleState/结算结果 FinalSettle,service 内部流转 + 单测使用);判断标准:类型是否出现在接口出入参中——出现即用 dto。entity 对表、dto 对 HTTP、domain 对纯领域;service 返回 dto(允许 import dto),dao 返回 entity(禁止外泄 gdb.Record)
|
||||
- dao:单例 `var Xxx = &xxxDao{}`,`init()` 内 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk)
|
||||
- controller:路由**反射注册**(`common.BindController`),path/method 唯一来源为 dto 的 `g.Meta`(携带 path/method/summary);新增接口只需写 dto + controller 方法,禁止在 main.go 手动逐条注册;跨组方法以 `H5` 前缀命名(h5 组只注册 H5 开头方法,admin 组跳过)
|
||||
- **接口只允许 GET / POST**:写操作传 JSON body(或 multipart),读操作走 query params;无 PUT/DELETE
|
||||
- dao 查询缓存:查询用 `gdb.CacheOption`(TTL 来自配置),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值"
|
||||
|
||||
### Core Flow
|
||||
## 错误处理规范(硬性要求)
|
||||
|
||||
1. **Material sources**: `tencent_image` / `tencent_video` tables in the `dataengine` PostgreSQL database (external system)
|
||||
2. **Detection**: Submit media to Yidun API for content moderation (async callback mode or sync polling mode)
|
||||
3. **Result handling**: Yidun pushes results to callback endpoints, or the service polls Yidun for results
|
||||
4. **Logging**: Results recorded in `material_verify_log` table (cid DB) with status backfilled to source tables
|
||||
- 所有可能失败的调用必须显式处理返回的 error:向上返回(保留上下文用 `gerror.Wrap`/`Newf`)或记录日志,禁止 `_, _ =` 静默丢弃——吞错会掩盖故障根因,修复问题必须先定位错误路径,不得以忽略 error 换取编译通过
|
||||
- defer 关闭等无法向上返回的资源清理错误,用 `defer func() { _ = x.Close() }()` 显式声明忽略意图,禁止裸 `defer x.Close()` 隐式吞错
|
||||
|
||||
### Architecture
|
||||
## 并发规范
|
||||
|
||||
```
|
||||
controller/ HTTP handlers (GoFrame controller + ghttp.Request handlers)
|
||||
├── yidun/ Content detection API (text/image/video submit, callback receive, result polling)
|
||||
├── dataengine/ Material verification UI API (list, stats, manual verify, batch verify, export, callback)
|
||||
service/ Business logic layer
|
||||
├── yidun/ Yidun SDK wrappers (text/image/video detection, callback processing)
|
||||
├── dataengine/ Material verification pipeline, content check scheduler, callback handling
|
||||
dao/ Data access layer (GoFrame ORM + custom gfdb wrapper)
|
||||
├── dataengine/ DAOs for tencent_image, tencent_video, material_verify_log, etc.
|
||||
model/ Entity/DTO definitions
|
||||
├── entity/dataengine/ DB entity structs with field constants
|
||||
├── dto/yidun/ Request/response DTOs
|
||||
consts/ Constants
|
||||
├── dataengine/ Table names, check statuses, suggestion values
|
||||
├── public/ Shared constants (currently empty)
|
||||
resource/frontend/ Static frontend HTML (material-verify.html)
|
||||
sql/ DDL and migration scripts
|
||||
```
|
||||
- **可并行的场景**:纯 IO 任务——读查询、LLM/Embedding 调用、文件读取。SQLite 写一律回主 goroutine 串行(无 WAL 时并发写会 `database is locked`,锁定风险归零,并发只赢在 IO 等待上)
|
||||
- **新增并行点的固定三处**:common 加池封装(grpool)→ `biz/consts` 加默认大小 → `config.yml` 加 `key: 并发度`(缺失或非法时回退默认值)
|
||||
- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池
|
||||
- **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine
|
||||
- **共享状态安全(内存)**:池内任务并发执行,共享实例(service 单例、model 句柄等)只允许**只读**访问;可变字段必须在**提交池之前**由主 goroutine 一次性预置,任务内禁止写共享字段——Go map 并发写直接 `fatal error: concurrent map writes`,无锁、无降级、不可恢复,只能崩溃重启。需要可变共享状态时按优先级:① 无共享(任务内新建、buffered channel 传递结果)② 锁(`sync.Mutex`/`RWMutex`,锁内只做内存操作,LLM/DB 等 IO 放锁外)③ `sync/atomic`(仅限 int 类标量计数/标志,如 `atomic.AddInt64`,并发计数禁用普通 `++`;复合结构不要用 atomic,指针 CAS 属例外)
|
||||
- **锁的使用**:互斥场景唯一入口是 `common.WithLock[T any](ctx, key, expire, retries, retryInterval, fn func() (T, error)) (T, error)`——泛型回调,业务返回值经 T 原样透出给下游;内部按 config.yml 自动选择锁实现(配置了 `redis` 节点 → redis 锁,跨实例互斥,SET NX EX + token 对比删除防误删他人锁;未配置 → gcache 内存锁,单实例互斥),禁止直接用 gcache/gredis 自己实现加锁。拿不到锁(被占用,`ErrLockHeld`)最多重试 `retries` 次、每次间隔 `retryInterval`(`retries=0` 立即失败;ctx 取消/超时同样终止等待);中间件故障不重试直接返回。锁自动释放:无论 fn 成功、失败还是 panic,defer 释放。`expire` 必须 > 0(进程崩溃兜底不死锁),fn 耗时必须在 expire 前完成,fn 内禁止长耗时 IO(LLM/DB 调用);锁粒度按业务唯一键尽量小
|
||||
- **裸 `go` 允许的例外**:`go func(){ wg.Wait(); close(ch) }()` 收尾惯用法、SSE 心跳、流式管道(Stream 读写)等长生命周期/非工作负载协程
|
||||
|
||||
### Two Detection Modes
|
||||
## 文档职责(三文档体系)
|
||||
|
||||
Configured via `yidun.callback_mode` in config.yml:
|
||||
- **Callback mode** (true): Yidun pushes results to `/yidun/callback/receiveImage` / `/yidun/callback/receiveVideo` after async detection completes. Requires a public-facing callback URL configured in `yidun.image.callback_url` / `yidun.video.callback_url`.
|
||||
- **Polling mode** (false): After submitting content, the service immediately calls Yidun's sync image API (for images) or queries results via callback API (for video). Manual polling endpoints are also available.
|
||||
| 文档 | 职责 | 何时补充/更新 |
|
||||
|---|---|---|
|
||||
| CLAUDE.md(本文件) | 公司通用开发规范:分层职责、代码模式、并发/事务/缓存约束、流程 | 规范变化时 |
|
||||
| README.md | 项目功能介绍:架构、数据流、表清单、功能模块、API 清单、使用说明 | 功能增减时 |
|
||||
| 技术设计.md | 实现细节与技术决策:DDL、检索参数、风险与备选方案 | 关键技术决策/参数变化时 |
|
||||
|
||||
### Scheduled Content Check
|
||||
## 开发流程(文档驱动,硬性要求)
|
||||
|
||||
The `content_check` section in config.yml controls a background scheduler (`tencent_content_check_service.go`) that periodically fetches pending media from source tables and submits them for verification. Configurable via `batch_size`, `interval_seconds`, `image_enabled`, `video_enabled`, `scheduler_enabled`.
|
||||
永远以文档驱动开发:用户提出开发需求 → 先给出实现方案(技术选型、影响面、改动清单) → **用户确认后先补充文档再动手写代码**。补充哪个文档取决于内容性质:规范 → 本文件,功能 → README,实现细节/技术决策 → 技术设计.md。禁止未经确认直接开发,禁止先写代码后补文档。
|
||||
|
||||
## Key Design Decisions
|
||||
## 数据访问规范(硬性要求)
|
||||
|
||||
- **Snowflake IDs**: DAO `Create()` methods generate IDs client-side via Snowflake because GoFrame v2.10's pgsql driver doesn't support `RETURNING` / `LastInsertId`.
|
||||
- **Dual database**: The `cid` DB holds verification logs; the `dataengine` DB holds source material tables. The `db.go` helper (`Model(tableName)`) selects the `dataEngine` DB group.
|
||||
- **Mixed ORM**: Some DAOs use `g.DB("dataEngine").Model()` (standard GoFrame), others use `gfdb.DB(ctx, "cid").Model(ctx, ...)` (custom common library wrapper that passes context to DB operations). Both access the same underlying gdb engine.
|
||||
- **Singleton pattern**: All controllers, services, and DAOs are package-level global singletons (e.g., `var MaterialVerify = new(MaterialVerifyService)`).
|
||||
- **Context user**: Each controller method injects a hardcoded `beans.User` into context (`ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1})`). No real auth.
|
||||
- **Verify status values**: `PENDING` → `SUBMITTING` → `VERIFIED` / `REJECTED` / `REVIEW`. Source tables use a different set: `PENDING` / `SUBMITTING` / `SUCCESS` / `FAILED` / `COMPLETED`.
|
||||
- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。**事务必须在 service 层**(`g.DB().Transaction` 包裹与事务内写方法如 `XxxInTx`,service 持有 tx 句柄编排多表),dao 层只做单表无状态 CRUD,不持事务;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback
|
||||
- **SQL 单表约束**:每个 SQL 只允许访问一张表,禁止 JOIN 与跨表子查询(`IN (SELECT ...)` / `EXISTS`);跨表数据一律拆为多条单表 SQL + 应用层内存组装——先取外键 id 列表,再对目标表 `IN` 查询;`IN` 参数须按 ≤100 分批(SQLite 变量数上限 999)
|
||||
- **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组
|
||||
- **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存
|
||||
- **批处理 SQL**:批量写入用 `InsertAll` 类方法,批量删除用 `IN` 子句,禁止循环单条 INSERT/DELETE
|
||||
- **配置即使用**:config.yml 中出现 redis / mq 等中间件配置时,代码必须实际接入使用,禁止"配置了但代码不用"或"代码写死但配置缺失"
|
||||
- **消息/回调幂等**:接入 MQ / Webhook 时,消费与回调处理必须幂等——MQ 至少一次语义、webhook 失败重试都可能重复投递同一事件,禁止依赖"只投一次"假设。幂等手段:以业务唯一键(如 `任务ID + 事件类型`)先查重或建唯一约束再落库,重复事件直接忽略;重试与补偿逻辑同样要防重复执行
|
||||
|
||||
## API Routes
|
||||
## 运维部署规范(硬性要求)
|
||||
|
||||
Routes are registered in `main.go` via `http.RouteRegister()` (from the common library). Key endpoint groups:
|
||||
- **部署形态**:Docker Compose 单机部署,前后端一体单端口;运行时数据目录挂载持久化,容器重建不丢数据。部署文件在项目根目录(`Dockerfile` / `docker-compose.yml`),启动与使用见 README
|
||||
- **数据即文件**:文件型存储,备份 = 打包「数据库目录(小而关键)+ 上传文件目录(大而可重建)」两个目录;迁移 = 拷贝到新机器即可
|
||||
- **运行时数据与代码分离**:数据目录不提交 git;**删除即丢数据,改动前先确认**
|
||||
- **配置即文件**:项目配置文件为唯一配置入口(监听端口、并发度等),环境变量覆盖无效
|
||||
|
||||
## 金额单位规范(硬性要求)
|
||||
|
||||
- **金额一律以「分」为单位存储与传输**:所有金额字段(零售价、进价、单价、小计、总金额)在数据库与接口中使用整数分(entity/dto 字段类型 `int64`),禁止浮点元
|
||||
- **展示转换在前端**:前端展示 ÷100 转元保留两位小数,提交 ×100 转分;禁止后端做元↔分转换,后端只处理整数分
|
||||
- **金额计算**:`数量(浮点克) × 单价(分)` 后必须 `common.RoundInt` 四舍五入到整数分,禁止裸浮点累加
|
||||
- **量纲区分**:数量/库存/用量为浮点克(REAL),金额为整数分;混算时必须显式转换
|
||||
- 存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行
|
||||
|
||||
## 约定
|
||||
|
||||
- controller 方法签名固定为 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,实例注册模式 `var Xxx = &xxx{}`
|
||||
- **参数校验优先用 GoFrame DTO 校验**:请求结构体用 `v` tag(required / regex / in 等)声明,框架自动校验并返回错误,controller 不手写校验;仅 DTO 表达不了的业务规则(跨字段依赖、查库校验如重名、取值范围依赖配置)放 service。JSON 格式解析可留在 controller 或下沉 service,但须保持与调用点一致
|
||||
- 响应组装(实体 → DTO 字段映射)在 controller 进行
|
||||
- service 方法签名 ctx 开头,错误统一用 `gerror`
|
||||
- 编译验证:`go build ./...`
|
||||
|
||||
- **Yidun detection** (`controller/yidun`): POST `/yidun/detectText`, `/yidun/detectImage`, `/yidun/detectVideo` — submit content for detection
|
||||
- **Yidun callback** (`controller/yidun`): POST `/yidun/callback/receiveImage`, `/yidun/callback/receiveVideo` — receive Yidun push results; POST `/yidun/callback/poll`, `/yidun/callback/pollImage`, `/yidun/callback/pollVideo` — manual polling
|
||||
- **Material verify** (`controller/dataengine`): POST `/dataengine/listImage`, `/dataengine/listVideo`, `/dataengine/statsImage`, `/dataengine/statsVideo`, `/dataengine/manualVerifyImage`, `/dataengine/batchVerifyImage`, `/dataengine/exportRejected`, `/dataengine/imageCallback`, etc.
|
||||
- **Content check** (`controller/yidun`): POST `/contentCheck/start`, `/contentCheck/stop`, `/contentCheck/status`, `/contentCheck/manualSubmitImageByID`, etc.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# CID
|
||||
|
||||
CID 服务,承载两块业务:
|
||||
|
||||
1. **内容审核(现有)**:将广告素材(图片/视频)提交易盾进行内容审核,提供 REST API、定时送检调度器和前端管理页
|
||||
2. **CID 归因平台(规划中)**:连接上游广告平台(抖音/小红书/快手等)与下游电商平台(京东/淘宝等),记录广告点击、归因转化、回传转化给广告平台
|
||||
|
||||
## 目录结构与业务模块
|
||||
|
||||
分层 + 业务模块结构:顶层按层分目录,每层下按业务域分子模块,各层模块目录严格镜像。
|
||||
|
||||
```
|
||||
controller/ HTTP 接口层,按模块分子目录
|
||||
service/ 业务逻辑层,按模块分子目录
|
||||
dao/ 数据访问层,按模块分子目录(各模块 db.go 提供 Model helper 选择数据源库)
|
||||
model/
|
||||
├── entity/ 数据库表实体(每表一个文件:Entity + Col + Cols 三段式)
|
||||
├── dto/ 请求/响应结构
|
||||
└── event/ 事件体(MQ/ClickHouse 消息契约,规划中)
|
||||
consts/ 常量(表名、状态),按模块分子目录
|
||||
resource/frontend/ 静态前端页面
|
||||
sql/ DDL 与迁移脚本
|
||||
```
|
||||
|
||||
| 模块 | 业务域 | 主要表 |
|
||||
|---|---|---|
|
||||
| check/ | 内容审核(现有):易盾检测提交/回调/轮询、素材校验、送检调度 | tencent_image、tencent_video、material_verify_log、tencent_content_check_log、tencent_account_relation |
|
||||
| attribution/ | 归因域(规划):点击记录、CID 生成、订单归因与转化回传(点击→转化→回传为同一链路) | click_log、conversion_order、report_task |
|
||||
| platform/ | 平台配置域(规划):上游广告平台/下游电商平台对接配置与商品映射 | ad_platform_account、commerce_platform_config、item_mapping |
|
||||
|
||||
## 数据流
|
||||
|
||||
**内容审核链路**:
|
||||
|
||||
```
|
||||
素材表(tencent_image/tencent_video, dataengine库)
|
||||
→ 定时送检调度器 / 手动送检
|
||||
→ 易盾检测(回调模式或轮询模式)
|
||||
→ 结果写入 material_verify_log(cid库) + 回填素材表审核状态
|
||||
```
|
||||
|
||||
**CID 归因链路(规划)**:
|
||||
|
||||
```
|
||||
用户点击广告 → 广告平台跳转 CID(携带广告参数)
|
||||
→ 生成 click_id、记录点击(click_log)→ 302 跳转电商平台(URL 带 click_id)
|
||||
→ 用户下单支付 → 电商平台回调 CID(订单 + click_id)
|
||||
→ 归因去重(conversion_order,幂等)→ 回传转化给广告平台(report_task 队列 + 失败重试)
|
||||
```
|
||||
|
||||
## 表清单
|
||||
|
||||
### check 模块(现有)
|
||||
|
||||
| 表 | 库 | 说明 |
|
||||
|---|---|---|
|
||||
| tencent_image | dataengine | 图片素材(外部系统共享) |
|
||||
| tencent_video | dataengine | 视频素材(外部系统共享) |
|
||||
| material_verify_log | cid | 素材校验日志(易盾结果落库) |
|
||||
| tencent_content_check_log | cid | 送检日志 |
|
||||
| tencent_account_relation | cid | 腾讯广告账户关系 |
|
||||
|
||||
### attribution / platform 模块(规划,DDL 见技术设计.md)
|
||||
|
||||
| 表 | 库 | 说明 |
|
||||
|---|---|---|
|
||||
| click_log | cid | 点击日志(不可变事件),按 day 分区 |
|
||||
| conversion_order | cid | 转化订单(归因与回传状态) |
|
||||
| report_task | cid | 转化回传任务队列(失败退避重试) |
|
||||
| ad_platform_account | cid | 上游广告平台对接配置 |
|
||||
| commerce_platform_config | cid | 下游电商平台对接配置 |
|
||||
| item_mapping | cid | 广告商品 ↔ 电商商品映射 |
|
||||
|
||||
## API 清单
|
||||
|
||||
路由由 common 库 `http.RouteRegister` 反射注册:路径按控制器结构体名 + 方法名自动派生(如 `YidunController` 的 `DetectText` → `/yidun/controller/detect-text`)。main.go 注册的控制器:
|
||||
|
||||
| 控制器 | 职责 |
|
||||
|---|---|
|
||||
| check.YidunController | 文本/图片/视频检测提交、结果查询 |
|
||||
| check.YidunCallback | 易盾回调接收、结果轮询 |
|
||||
| check.ContentCheck | 送检调度器启停、状态查询、手动送检 |
|
||||
| check.MaterialVerify | 素材校验列表/统计/人工审核/导出 |
|
||||
|
||||
## 构建与运行
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
docker build -t cid .
|
||||
|
||||
# 本地运行(需 Go 1.26)
|
||||
go mod download && go mod tidy
|
||||
go run main.go
|
||||
|
||||
# 编译
|
||||
go build -ldflags="-s -w" -o main ./main.go
|
||||
```
|
||||
|
||||
配置入口为 `config.yml`(易盾凭据、callback_mode、content_check 调度参数、数据库连接等)。
|
||||
@@ -0,0 +1,68 @@
|
||||
package attribution
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// ClickLog 点击日志实体(CID核心表,按 day 分区)
|
||||
type ClickLog struct {
|
||||
beans.SQLBaseDO `orm:",inherit"`
|
||||
// 业务字段
|
||||
ClickID string `orm:"click_id" json:"clickId" description:"对外CID"`
|
||||
RequestID string `orm:"request_id" json:"requestId" description:"广告平台请求号(去重键)"`
|
||||
AdPlatform string `orm:"ad_platform" json:"adPlatform" description:"广告平台 douyin/xiaohongshu/kuaishou"`
|
||||
AccountID string `orm:"account_id" json:"accountId" description:"广告账户ID"`
|
||||
CampaignID string `orm:"campaign_id" json:"campaignId" description:"广告计划ID"`
|
||||
AdGroupID string `orm:"ad_group_id" json:"adGroupId" description:"广告组ID"`
|
||||
AdID string `orm:"ad_id" json:"adId" description:"广告ID"`
|
||||
CreativeID string `orm:"creative_id" json:"creativeId" description:"创意ID"`
|
||||
DeviceID string `orm:"device_id" json:"deviceId" description:"设备ID oaid/imei/idfa"`
|
||||
IP string `orm:"ip" json:"ip" description:"点击IP"`
|
||||
UA string `orm:"ua" json:"ua" description:"用户代理"`
|
||||
LandingURL string `orm:"landing_url" json:"landingUrl" description:"原始落地页URL"`
|
||||
RedirectURL string `orm:"redirect_url" json:"redirectUrl" description:"跳转下游URL(带click_id)"`
|
||||
Extra string `orm:"extra" json:"extra" description:"平台透传参数(JSON)"`
|
||||
Day *gtime.Time `orm:"day" json:"day" description:"分区日期"`
|
||||
}
|
||||
|
||||
// ClickLogCol 点击日志表字段定义
|
||||
type ClickLogCol struct {
|
||||
beans.SQLBaseCol
|
||||
ClickID string
|
||||
RequestID string
|
||||
AdPlatform string
|
||||
AccountID string
|
||||
CampaignID string
|
||||
AdGroupID string
|
||||
AdID string
|
||||
CreativeID string
|
||||
DeviceID string
|
||||
IP string
|
||||
UA string
|
||||
LandingURL string
|
||||
RedirectURL string
|
||||
Extra string
|
||||
Day string
|
||||
}
|
||||
|
||||
// ClickLogCols 点击日志表字段常量
|
||||
var ClickLogCols = ClickLogCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ClickID: "click_id",
|
||||
RequestID: "request_id",
|
||||
AdPlatform: "ad_platform",
|
||||
AccountID: "account_id",
|
||||
CampaignID: "campaign_id",
|
||||
AdGroupID: "ad_group_id",
|
||||
AdID: "ad_id",
|
||||
CreativeID: "creative_id",
|
||||
DeviceID: "device_id",
|
||||
IP: "ip",
|
||||
UA: "ua",
|
||||
LandingURL: "landing_url",
|
||||
RedirectURL: "redirect_url",
|
||||
Extra: "extra",
|
||||
Day: "day",
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package attribution
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// ConversionOrder 转化订单实体(电商平台回传的订单/转化)
|
||||
type ConversionOrder struct {
|
||||
beans.SQLBaseDO `orm:",inherit"`
|
||||
// 业务字段
|
||||
ConversionID string `orm:"conversion_id" json:"conversionId" description:"转化事件ID(幂等键)"`
|
||||
OrderID string `orm:"order_id" json:"orderId" description:"电商平台订单号"`
|
||||
CommercePlatform string `orm:"commerce_platform" json:"commercePlatform" description:"电商平台 jd/taobao"`
|
||||
ClickID string `orm:"click_id" json:"clickId" description:"关联点击CID"`
|
||||
DeviceID string `orm:"device_id" json:"deviceId" description:"设备ID(无click_id时兜底归因)"`
|
||||
ItemID string `orm:"item_id" json:"itemId" description:"商品ID"`
|
||||
ItemName string `orm:"item_name" json:"itemName" description:"商品名称"`
|
||||
SkuID string `orm:"sku_id" json:"skuId" description:"SKU ID"`
|
||||
ConversionType string `orm:"conversion_type" json:"conversionType" description:"转化类型 order/pay/refund/cart"`
|
||||
OrderAmount int64 `orm:"order_amount" json:"orderAmount" description:"订单金额(分)"`
|
||||
PayAmount int64 `orm:"pay_amount" json:"payAmount" description:"实付金额(分)"`
|
||||
Quantity int `orm:"quantity" json:"quantity" description:"商品数量"`
|
||||
OrderStatus string `orm:"order_status" json:"orderStatus" description:"订单状态 paid/refunded/canceled"`
|
||||
AttributedBy string `orm:"attributed_by" json:"attributedBy" description:"归因方式 click_id/device/manual"`
|
||||
ReportStatus int `orm:"report_status" json:"reportStatus" description:"回传状态 0待回传 1已回传 2失败"`
|
||||
ReportCount int `orm:"report_count" json:"reportCount" description:"回传次数"`
|
||||
FirstClickTime int64 `orm:"first_click_time" json:"firstClickTime" description:"首次点击时间戳"`
|
||||
}
|
||||
|
||||
// ConversionOrderCol 转化订单表字段定义
|
||||
type ConversionOrderCol struct {
|
||||
beans.SQLBaseCol
|
||||
ConversionID string
|
||||
OrderID string
|
||||
CommercePlatform string
|
||||
ClickID string
|
||||
DeviceID string
|
||||
ItemID string
|
||||
ItemName string
|
||||
SkuID string
|
||||
ConversionType string
|
||||
OrderAmount string
|
||||
PayAmount string
|
||||
Quantity string
|
||||
OrderStatus string
|
||||
AttributedBy string
|
||||
ReportStatus string
|
||||
ReportCount string
|
||||
FirstClickTime string
|
||||
}
|
||||
|
||||
// ConversionOrderCols 转化订单表字段常量
|
||||
var ConversionOrderCols = ConversionOrderCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ConversionID: "conversion_id",
|
||||
OrderID: "order_id",
|
||||
CommercePlatform: "commerce_platform",
|
||||
ClickID: "click_id",
|
||||
DeviceID: "device_id",
|
||||
ItemID: "item_id",
|
||||
ItemName: "item_name",
|
||||
SkuID: "sku_id",
|
||||
ConversionType: "conversion_type",
|
||||
OrderAmount: "order_amount",
|
||||
PayAmount: "pay_amount",
|
||||
Quantity: "quantity",
|
||||
OrderStatus: "order_status",
|
||||
AttributedBy: "attributed_by",
|
||||
ReportStatus: "report_status",
|
||||
ReportCount: "report_count",
|
||||
FirstClickTime: "first_click_time",
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package attribution
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// ReportTask 转化回传任务实体(回传广告平台的任务队列,失败退避重试)
|
||||
type ReportTask struct {
|
||||
beans.SQLBaseDO `orm:",inherit"`
|
||||
// 业务字段
|
||||
ConversionID string `orm:"conversion_id" json:"conversionId" description:"关联转化事件ID"`
|
||||
ClickID string `orm:"click_id" json:"clickId" description:"点击CID"`
|
||||
AdPlatform string `orm:"ad_platform" json:"adPlatform" description:"回传目标广告平台"`
|
||||
TaskType string `orm:"task_type" json:"taskType" description:"回传类型 conversion/refund"`
|
||||
Status int `orm:"status" json:"status" description:"状态 0待回传 1回传中 2成功 3失败"`
|
||||
RetryCount int `orm:"retry_count" json:"retryCount" description:"重试次数"`
|
||||
NextRetryAt int64 `orm:"next_retry_at" json:"nextRetryAt" description:"下次重试时间戳"`
|
||||
RequestBody string `orm:"request_body" json:"requestBody" description:"回传请求体(JSON)"`
|
||||
ResponseBody string `orm:"response_body" json:"responseBody" description:"回传响应体(JSON)"`
|
||||
TraceID string `orm:"trace_id" json:"traceId" description:"链路追踪ID"`
|
||||
}
|
||||
|
||||
// ReportTaskCol 回传任务表字段定义
|
||||
type ReportTaskCol struct {
|
||||
beans.SQLBaseCol
|
||||
ConversionID string
|
||||
ClickID string
|
||||
AdPlatform string
|
||||
TaskType string
|
||||
Status string
|
||||
RetryCount string
|
||||
NextRetryAt string
|
||||
RequestBody string
|
||||
ResponseBody string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// ReportTaskCols 回传任务表字段常量
|
||||
var ReportTaskCols = ReportTaskCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
ConversionID: "conversion_id",
|
||||
ClickID: "click_id",
|
||||
AdPlatform: "ad_platform",
|
||||
TaskType: "task_type",
|
||||
Status: "status",
|
||||
RetryCount: "retry_count",
|
||||
NextRetryAt: "next_retry_at",
|
||||
RequestBody: "request_body",
|
||||
ResponseBody: "response_body",
|
||||
TraceID: "trace_id",
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// AdPlatformAccount 广告平台账户配置实体(上游广告平台对接凭据)
|
||||
type AdPlatformAccount struct {
|
||||
beans.SQLBaseDO `orm:",inherit"`
|
||||
// 业务字段
|
||||
Platform string `orm:"platform" json:"platform" description:"广告平台 douyin/xiaohongshu/kuaishou"`
|
||||
AccountID string `orm:"account_id" json:"accountId" description:"平台侧账户ID"`
|
||||
AppID string `orm:"app_id" json:"appId" description:"应用ID"`
|
||||
AppSecret string `orm:"app_secret" json:"appSecret" description:"应用密钥"`
|
||||
AccessToken string `orm:"access_token" json:"accessToken" description:"访问令牌"`
|
||||
TokenExpireAt int64 `orm:"token_expire_at" json:"tokenExpireAt" description:"令牌过期时间戳"`
|
||||
Config string `orm:"config" json:"config" description:"平台特定配置(JSON)"`
|
||||
Enabled bool `orm:"enabled" json:"enabled" description:"是否启用"`
|
||||
}
|
||||
|
||||
// AdPlatformAccountCol 广告平台账户表字段定义
|
||||
type AdPlatformAccountCol struct {
|
||||
beans.SQLBaseCol
|
||||
Platform string
|
||||
AccountID string
|
||||
AppID string
|
||||
AppSecret string
|
||||
AccessToken string
|
||||
TokenExpireAt string
|
||||
Config string
|
||||
Enabled string
|
||||
}
|
||||
|
||||
// AdPlatformAccountCols 广告平台账户表字段常量
|
||||
var AdPlatformAccountCols = AdPlatformAccountCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
Platform: "platform",
|
||||
AccountID: "account_id",
|
||||
AppID: "app_id",
|
||||
AppSecret: "app_secret",
|
||||
AccessToken: "access_token",
|
||||
TokenExpireAt: "token_expire_at",
|
||||
Config: "config",
|
||||
Enabled: "enabled",
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// CommercePlatformConfig 电商平台配置实体(下游商品平台对接配置)
|
||||
type CommercePlatformConfig struct {
|
||||
beans.SQLBaseDO `orm:",inherit"`
|
||||
// 业务字段
|
||||
Platform string `orm:"platform" json:"platform" description:"电商平台 jd/taobao"`
|
||||
AppKey string `orm:"app_key" json:"appKey" description:"应用Key"`
|
||||
AppSecret string `orm:"app_secret" json:"appSecret" description:"应用密钥"`
|
||||
CallbackURL string `orm:"callback_url" json:"callbackUrl" description:"订单回调接收URL"`
|
||||
Config string `orm:"config" json:"config" description:"平台特定配置(JSON)"`
|
||||
Enabled bool `orm:"enabled" json:"enabled" description:"是否启用"`
|
||||
}
|
||||
|
||||
// CommercePlatformConfigCol 电商平台配置表字段定义
|
||||
type CommercePlatformConfigCol struct {
|
||||
beans.SQLBaseCol
|
||||
Platform string
|
||||
AppKey string
|
||||
AppSecret string
|
||||
CallbackURL string
|
||||
Config string
|
||||
Enabled string
|
||||
}
|
||||
|
||||
// CommercePlatformConfigCols 电商平台配置表字段常量
|
||||
var CommercePlatformConfigCols = CommercePlatformConfigCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
Platform: "platform",
|
||||
AppKey: "app_key",
|
||||
AppSecret: "app_secret",
|
||||
CallbackURL: "callback_url",
|
||||
Config: "config",
|
||||
Enabled: "enabled",
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// ItemMapping 广告商品与电商商品映射实体
|
||||
type ItemMapping struct {
|
||||
beans.SQLBaseDO `orm:",inherit"`
|
||||
// 业务字段
|
||||
AdPlatform string `orm:"ad_platform" json:"adPlatform" description:"广告平台"`
|
||||
AdItemID string `orm:"ad_item_id" json:"adItemId" description:"广告平台侧商品ID"`
|
||||
CommercePlatform string `orm:"commerce_platform" json:"commercePlatform" description:"电商平台"`
|
||||
CommerceItemID string `orm:"commerce_item_id" json:"commerceItemId" description:"电商平台侧商品ID"`
|
||||
SkuID string `orm:"sku_id" json:"skuId" description:"SKU ID"`
|
||||
Config string `orm:"config" json:"config" description:"映射扩展配置(JSON)"`
|
||||
}
|
||||
|
||||
// ItemMappingCol 商品映射表字段定义
|
||||
type ItemMappingCol struct {
|
||||
beans.SQLBaseCol
|
||||
AdPlatform string
|
||||
AdItemID string
|
||||
CommercePlatform string
|
||||
CommerceItemID string
|
||||
SkuID string
|
||||
Config string
|
||||
}
|
||||
|
||||
// ItemMappingCols 商品映射表字段常量
|
||||
var ItemMappingCols = ItemMappingCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
AdPlatform: "ad_platform",
|
||||
AdItemID: "ad_item_id",
|
||||
CommercePlatform: "commerce_platform",
|
||||
CommerceItemID: "commerce_item_id",
|
||||
SkuID: "sku_id",
|
||||
Config: "config",
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
# 技术设计
|
||||
|
||||
## CID 归因平台(规划中)
|
||||
|
||||
### 核心链路
|
||||
|
||||
1. 用户点击广告 → 广告平台(抖音/小红书/快手等)跳转到 CID 落地页(携带广告参数)
|
||||
2. CID 生成唯一 click_id,记录点击,302 跳转电商平台(URL 带 click_id)
|
||||
3. 用户下单支付 → 电商平台(京东/淘宝等)回调 CID(订单号 + click_id)
|
||||
4. CID 归因去重(精确匹配 click_id,兜底 device_id + item + 归因窗口)
|
||||
5. 回传转化给广告平台(report_task 队列 + 失败退避重试,用于广告系统优化投放)
|
||||
|
||||
### 表设计(PG,Phase 1)
|
||||
|
||||
**click_log**(高频写入,按 day 分区):
|
||||
|
||||
```sql
|
||||
CREATE TABLE click_log (
|
||||
id BIGINT PRIMARY KEY,
|
||||
click_id VARCHAR(32) NOT NULL, -- 对外 CID(base62 短码)
|
||||
request_id VARCHAR(64), -- 广告平台请求号(去重键)
|
||||
ad_platform VARCHAR(20) NOT NULL, -- douyin/xiaohongshu/kuaishou
|
||||
account_id VARCHAR(64),
|
||||
campaign_id VARCHAR(64),
|
||||
ad_group_id VARCHAR(64),
|
||||
ad_id VARCHAR(64),
|
||||
creative_id VARCHAR(64),
|
||||
device_id VARCHAR(64), -- oaid/imei/idfa
|
||||
ip VARCHAR(64),
|
||||
ua TEXT,
|
||||
landing_url TEXT, -- 原始落地页
|
||||
redirect_url TEXT, -- 跳转下游 URL(带 click_id)
|
||||
extra JSONB, -- 平台透传参数(新平台零 DDL)
|
||||
day DATE NOT NULL, -- 分区键
|
||||
create_time TIMESTAMP NOT NULL DEFAULT now(),
|
||||
UNIQUE (click_id),
|
||||
UNIQUE (ad_platform, request_id) -- 重复回调去重硬保障
|
||||
);
|
||||
CREATE INDEX idx_click_platform_day ON click_log (ad_platform, day, ad_id);
|
||||
CREATE INDEX idx_click_device ON click_log (device_id, day);
|
||||
```
|
||||
|
||||
**conversion_order**(幂等约束为硬要求——转化回传涉及资金归因):
|
||||
|
||||
```sql
|
||||
CREATE TABLE conversion_order (
|
||||
id BIGINT PRIMARY KEY,
|
||||
conversion_id VARCHAR(64) NOT NULL, -- 转化事件 ID(幂等键,防重复消费)
|
||||
order_id VARCHAR(64) NOT NULL, -- 电商订单号
|
||||
commerce_platform VARCHAR(20) NOT NULL, -- jd/taobao
|
||||
click_id VARCHAR(32),
|
||||
device_id VARCHAR(64), -- 兜底归因
|
||||
item_id VARCHAR(64),
|
||||
item_name VARCHAR(255),
|
||||
sku_id VARCHAR(64),
|
||||
conversion_type VARCHAR(20), -- order/pay/refund/cart
|
||||
order_amount BIGINT, -- 金额一律以分存储
|
||||
pay_amount BIGINT,
|
||||
quantity INT,
|
||||
order_status VARCHAR(20), -- paid/refunded/canceled
|
||||
attributed_by VARCHAR(16), -- click_id/device/manual
|
||||
report_status SMALLINT DEFAULT 0, -- 0 待回传 1 已回传 2 失败
|
||||
report_count INT DEFAULT 0,
|
||||
first_click_time BIGINT,
|
||||
create_time TIMESTAMP NOT NULL DEFAULT now(),
|
||||
update_time TIMESTAMP,
|
||||
UNIQUE (commerce_platform, order_id),
|
||||
UNIQUE (conversion_id)
|
||||
);
|
||||
```
|
||||
|
||||
**report_task**(回传任务队列,调度器消费,失败退避重试):
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_task (
|
||||
id BIGINT PRIMARY KEY,
|
||||
conversion_id VARCHAR(64) NOT NULL,
|
||||
click_id VARCHAR(32),
|
||||
ad_platform VARCHAR(20) NOT NULL,
|
||||
task_type VARCHAR(20), -- conversion/refund
|
||||
status SMALLINT DEFAULT 0, -- 0 待回传 1 回传中 2 成功 3 失败
|
||||
retry_count INT DEFAULT 0,
|
||||
next_retry_at BIGINT, -- 失败退避重试时间
|
||||
request_body TEXT,
|
||||
response_body TEXT,
|
||||
trace_id VARCHAR(64),
|
||||
create_time TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_report_status ON report_task (status, next_retry_at);
|
||||
```
|
||||
|
||||
**配置表**(低频,JSONB 兜底平台特有配置,新平台只加一行配置 + 一个适配器):
|
||||
|
||||
- `ad_platform_account`:上游广告平台对接配置(platform、account_id、app_id、app_secret、access_token、token_expire_at、config JSONB、enabled)
|
||||
- `commerce_platform_config`:下游电商平台配置(platform、app_key、app_secret、callback_url、config JSONB、enabled)
|
||||
- `item_mapping`:广告商品 ↔ 电商商品映射(ad_platform、ad_item_id、commerce_platform、commerce_item_id、sku_id、config JSONB)
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
| 决策 | 方案 | 理由 |
|
||||
|---|---|---|
|
||||
| 平台扩展 | 平台字段枚举 + `extra/config JSONB` + 适配器 | 平台不确定,新增平台零 DDL、不改主流程 |
|
||||
| 点击去重 | 接入层 Redis 布隆过滤器(热路径拦截) + 存储层 `UNIQUE(ad_platform, request_id)` 兜底 | Redis 挡 99% 重复;唯一约束是易失层之外的硬保障 |
|
||||
| 点击事件不可变 | click_log 不含转化状态字段,转化状态由 conversion_order.click_id 反查 | 高频写入表不做 UPDATE,避免行锁竞争与表膨胀 |
|
||||
| 转化幂等 | `UNIQUE(commerce_platform, order_id)` + `UNIQUE(conversion_id)` | 同单只记一次;重复事件直接忽略(MQ 至少一次语义) |
|
||||
| 回传可靠性 | report_task 状态机 + 定时重试(退避) | 回传失败必须重试,重试靠持久化状态而非内存 |
|
||||
| 兜底归因 | device_id + item_id + 归因窗口(默认 7 天) | 用户清 cookie 丢 click_id 时仍可归因 |
|
||||
| click_id 编码 | base62:平台前缀 + 分片位 + 时间 + 随机 + 校验位 | 短、唯一、不可预测;预留分片/路由能力 |
|
||||
| 金额 | 一律整数分(int64),外部平台以元回传时在接入边界转分 | 禁止浮点元(公司规范) |
|
||||
|
||||
### 事件驱动演进(分阶段,不一步到位)
|
||||
|
||||
当前为单 PG 架构,事件模型先定好,按量级演进:
|
||||
|
||||
- **Phase 1(当前)**:PG 分区表 + JSONB 扩展字段,归因走 SQL;表结构与 Phase 3 事件模型兼容
|
||||
- **Phase 2**:引入 MQ(NATS JetStream,已确定),点击/转化异步写事件流,PG 只做状态层;转化事件走低延迟通道(涉及资金回传,不进粗粒度批量管道),点击事件可批量消费
|
||||
- **Phase 3**:引入 ClickHouse 承接事件与分析(点击日百万级后归因/报表查询拖垮 PG 时再做),迁移事件表,归因/报表查询走列存
|
||||
- **Phase 4**:流式归因,Phase 1 的 SQL 归因退役
|
||||
|
||||
**MQ 选型(已确定)**:NATS JetStream,一条服务两条流,按事件类型配置不同:
|
||||
|
||||
| 流 | 保留策略 | 消费方式 |
|
||||
|---|---|---|
|
||||
| 点击流 | 按量/时长裁剪 | Pull consumer `Fetch` 批量消费,攒批落库 |
|
||||
| 转化流 | 长保留不裁剪 | 实时低延迟直通,幂等落 PG + 生成回传任务 |
|
||||
|
||||
弃选 RabbitMQ(队列语义,ack 即删,无重放)与 Kafka(运维重,量级远未到需要分区);Redis 定位为去重/计数(布隆过滤器、SETNX),不做事件管道——内存层对资金路径(转化)不可靠。
|
||||
|
||||
**幂等分层**(贯穿所有阶段):
|
||||
|
||||
```
|
||||
接入层 Redis(省):布隆过滤器挡重复,热路径
|
||||
存储层表约束(对):UNIQUE 兜底,低频,决定正确性
|
||||
回传层状态机(对):report_task 持久化状态 + 重试
|
||||
```
|
||||
|
||||
Redis 幂等是易失的(内存淘汰/重启丢 key),只能减少压力不能替代持久化层约束;MQ 消费侧 at-least-once,重复是常态,表约束是最后防线。
|
||||
|
||||
### entity 代码模式
|
||||
|
||||
每表一个文件,三段式(见 `model/entity/attribution/click_log.go` 等):
|
||||
|
||||
```go
|
||||
type ClickLog struct {
|
||||
beans.SQLBaseDO `orm:",inherit"` // 公共字段:id/tenant_id/creator/created_at/updater/updated_at/deleted_at
|
||||
ClickID string `orm:"click_id" json:"clickId" description:"对外CID"`
|
||||
// ...
|
||||
}
|
||||
type ClickLogCol struct{ beans.SQLBaseCol; /* ... */ }
|
||||
var ClickLogCols = ClickLogCol{SQLBaseCol: beans.DefSQLBaseCol, /* ... */ }
|
||||
```
|
||||
|
||||
- 业务常量放 `consts/<模块>/`(表名 table.go、状态 status.go),不内嵌 entity 文件
|
||||
- entity 保持纯表映射,联表/展示字段(`orm:"-"`)放 dto 响应结构
|
||||
- 事件体(click_event/conversion_event,ClickHouse/MQ 消息契约)放 `model/event/`,不属于 entity
|
||||
Reference in New Issue
Block a user