1、文档规范化

2、增加cid核心功能entity
This commit is contained in:
2026-08-25 11:54:29 +08:00
parent 25e87240f9
commit b3c0855236
9 changed files with 651 additions and 65 deletions
+83 -65
View File
@@ -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.
+99
View File
@@ -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 调度参数、数据库连接等)。
+68
View File
@@ -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",
}
+51
View File
@@ -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",
}
+39
View File
@@ -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",
}
+155
View File
@@ -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 队列 + 失败退避重试,用于广告系统优化投放)
### 表设计(PGPhase 1
**click_log**(高频写入,按 day 分区):
```sql
CREATE TABLE click_log (
id BIGINT PRIMARY KEY,
click_id VARCHAR(32) NOT NULL, -- 对外 CIDbase62 短码)
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**:引入 MQNATS 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_eventClickHouse/MQ 消息契约)放 `model/event/`,不属于 entity