From b3c08552362b45722806637e283aa0fa9b85d2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Tue, 25 Aug 2026 11:54:29 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E6=96=87=E6=A1=A3=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=202=E3=80=81=E5=A2=9E=E5=8A=A0cid=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=8A=9F=E8=83=BDentity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 148 +++++++++-------- README.md | 99 +++++++++++ model/entity/attribution/click_log.go | 68 ++++++++ model/entity/attribution/conversion_order.go | 72 ++++++++ model/entity/attribution/report_task.go | 51 ++++++ model/entity/platform/ad_platform_account.go | 45 +++++ .../platform/commerce_platform_config.go | 39 +++++ model/entity/platform/item_mapping.go | 39 +++++ 技术设计.md | 155 ++++++++++++++++++ 9 files changed, 651 insertions(+), 65 deletions(-) create mode 100644 README.md create mode 100644 model/entity/attribution/click_log.go create mode 100644 model/entity/attribution/conversion_order.go create mode 100644 model/entity/attribution/report_task.go create mode 100644 model/entity/platform/ad_platform_account.go create mode 100644 model/entity/platform/commerce_platform_config.go create mode 100644 model/entity/platform/item_mapping.go create mode 100644 技术设计.md diff --git a/CLAUDE.md b/CLAUDE.md index 122bec6..0901075 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..951420c --- /dev/null +++ b/README.md @@ -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 调度参数、数据库连接等)。 diff --git a/model/entity/attribution/click_log.go b/model/entity/attribution/click_log.go new file mode 100644 index 0000000..efdc414 --- /dev/null +++ b/model/entity/attribution/click_log.go @@ -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", +} diff --git a/model/entity/attribution/conversion_order.go b/model/entity/attribution/conversion_order.go new file mode 100644 index 0000000..203f937 --- /dev/null +++ b/model/entity/attribution/conversion_order.go @@ -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", +} diff --git a/model/entity/attribution/report_task.go b/model/entity/attribution/report_task.go new file mode 100644 index 0000000..065d0cf --- /dev/null +++ b/model/entity/attribution/report_task.go @@ -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", +} diff --git a/model/entity/platform/ad_platform_account.go b/model/entity/platform/ad_platform_account.go new file mode 100644 index 0000000..8847328 --- /dev/null +++ b/model/entity/platform/ad_platform_account.go @@ -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", +} diff --git a/model/entity/platform/commerce_platform_config.go b/model/entity/platform/commerce_platform_config.go new file mode 100644 index 0000000..9779fbc --- /dev/null +++ b/model/entity/platform/commerce_platform_config.go @@ -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", +} diff --git a/model/entity/platform/item_mapping.go b/model/entity/platform/item_mapping.go new file mode 100644 index 0000000..cb8cf89 --- /dev/null +++ b/model/entity/platform/item_mapping.go @@ -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", +} diff --git a/技术设计.md b/技术设计.md new file mode 100644 index 0000000..90844b3 --- /dev/null +++ b/技术设计.md @@ -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