ocpcx

package module
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 18 Imported by: 0

README

ocpcx

Go

广告媒体 OCPC 转化回传协议实现。

本包只回答一个问题:怎么把一次转化发给某个媒体账户。转化数据从哪来、失败了怎么办、 什么时候发、凭据从哪读,全部由调用方决定。

两类回传

两条链路的归因方式与数据模型完全不同,是两套接口,不要混用:

接口 归因 媒体
网页推广 Reporter 点击 ID(bd_vid / qhclickid) baidu(网页)、qihoo360(搜索)
应用推广 AppReporter 设备 + 回调串(oaid / callback) baidu_apphuawei

百度那条同时服务搜索与信息流两种流量(官方归类为线索 API),360 那条是搜索推广产品线; 两家的共性是按点击 ID 归因,故合用同一套 Reporter

⚠️ 两个百度不是同一个接口:网页推广走 ocpcapi/api/uploadConvertData, 应用推广走 ocpcapi/cb/actionCb,媒体标识分别是 baidubaidu_app

安装

go get github.com/gtkit/ocpcx

特性

  • 已支持 4 家媒体:网页推广 2 家 + 应用推广 2 家(见上表)
  • 两种用法:直接构造(NewBaidu / NewHuawei / …)与配置驱动(New + ConfigNewApp + AppConfig),共用同一份协议实现
  • 配置可内联:Config 带 yaml/json tag,yaml:",inline" 进调用方自己的配置结构; 新增媒体时调用方的装配代码与配置结构体都不用改
  • 启动期校验:Config.Validate() 查凭据完整性与落地页格式,把配置错误挡在服务对外之前
  • 错误可分类:媒体明确拒绝返回 *APIError(重试无意义),网络/超时返回其他错误(重试有意义)
  • 连接池可共享:WithHTTPClient 注入,多个 Reporter 复用同一个 transport
  • 无内置日志:错误全部回传,由调用方带业务上下文记录
  • 无内置重试:只有调用方知道自己的幂等性与退避节奏
  • 凭据不外泄:app_secret / token 不出现在任何返回的错误信息里
  • 并发安全:Reporter 构造后只读

依赖:gtkit/httpc(>= v1.5.0,需要 RawBody)、gtkit/jsongtkit/stringx(字符串拼接)。

stringx 根包会间接带进 golang.org/x/net/idnagolang.org/x/text/*(它的邮箱/宽度校验用得到)。 若下游在意依赖体积,把 import 换成子包 github.com/gtkit/stringx/textx 即可—— BuilderJoin 本体就在那里,零 x/ 依赖。

快速上手

r, err := ocpcx.NewBaidu(ocpcx.BaiduConfig{
    Token:          token,
    LandingURL:     "https://example.com/",       // 不得自带 query
    ConversionType: ocpcx.BaiduConvFormSubmit,    // 百度 newType,见下表
})
if err != nil {
    return err
}

// clickID 是落地页回传的 bd_vid
if err := r.Report(ctx, clickID); err != nil {
    return err
}

360:

r, err := ocpcx.NewQihoo360(ocpcx.Qihoo360Config{
    AppKey:       key,
    AppSecret:    secret,
    DataIndustry: ocpcx.Qihoo360IndustryPCSearch, // 可省,缺省即 PC 搜索推广
    Event:        ocpcx.Qihoo360EventOrder,       // 可省,缺省即订单
})
// clickID 是 qhclickid
err = r.Report(ctx, clickID)

DataIndustry 必须与账户实际投放的产品线一致——360 要求不同产品线的参数不能混用, 传错会被当作无效数据丢弃,且丢弃是静默的。本包按点击 ID 组装请求,因此只支持以点击 ID 归因的 Qihoo360IndustryPCSearch(PC 搜索)与 Qihoo360IndustryMobileSearch(移动搜索); 配成移动推广(以 impression_id 归因)或展示广告(还需 jzqs)会在构造期报错。

按单次上报指定转化类型

Event(360)与 ConversionType(百度)在配置里给的是该 Reporter 的缺省转化类型;同一套 凭据下要按单次上报发不同类型时,经 ConversionReporter 逐次覆盖,不必为此多造一个 Reporter:

cr := r.(ocpcx.ConversionReporter)

// 360:这一笔报「支付成功」,配置里的缺省不变
err = cr.ReportConversion(ctx, ocpcx.ClickConversion{
    ClickID: clickID,
    Event:   ocpcx.Qihoo360EventPaySuccess,
})

// 百度:这一笔报「订单提交成功」
err = cr.ReportConversion(ctx, ocpcx.ClickConversion{
    ClickID:        clickID,
    ConversionType: ocpcx.BaiduConvOrderSubmit,
})

优先级是单次上报给的值 > 构造期配置值 > 包内缺省。零值即「未指定」:360 的空字符串、 百度的 0 都回退到配置值,现有调用方不给这两个字段时行为不变。百度的负数在上报前报错 ——构造期对该字段的约束是「必须为正」,per-call 是另一条入口,同样受这条约束。

两处连带效应值得记住:

  • 360 的去重键跟着变。 trans_id 留空时按 (点击 ID, 生效事件) 派生,换事件即换去重键 ——360 视为两笔不同的转化各计一次。这正是同一点击 ID 回传多种转化类型的前提; 要自己控去重键就显式给 TransID,本包原样使用。
  • 百度 46 的必填校验按生效编码判定。 配置为 3、单次上报指定 46 时,ConvertTime 变成必填;反过来配置为 46、单次上报指定 3 时不再必填。
360 的事务号与转化时间

trans_id 是 360 对重复上报去重的键,协议还要求二次回传时与首次保持一致。本包按 (点击 ID, 转化事件) 确定性派生,重传得到同一个值,当前时刻不参与计算。

event_time 是转化实际发生的时间。不给的话本包回退到上报时刻——补数据、重试、批处理 这类延迟上报的场景下,那会把转化归到错误的时间点。

两者都经 ConversionReporter 给,类型断言取入口:

if cr, ok := r.(ocpcx.ConversionReporter); ok {
    err = cr.ReportConversion(ctx, ocpcx.ClickConversion{
        ClickID:     clickID,
        TransID:     orderID,      // 可省,按业务主键去重时给
        ConvertTime: convertedAt,  // 可省,延迟上报时务必给准
    })
} else {
    err = r.Report(ctx, clickID)
}

ConvertTime 的「有没有给」按 Unix 时间戳是否为正判定,不是按 time.Time 零值—— time.Unix(0, 0)(从库里 0 值转出来的时间)不是零值但时间戳是 0,当作有效时间发出去 就是一个 1970 年的转化。

配置驱动

Config 可直接内联进调用方的配置结构,YAML 严格解析(KnownFields(true))下拼错的键 会在启动期报错,不会静默失效:

type Target struct {
    Name         string   `yaml:"name"`
    Channels     []string `yaml:"channels"`
    ocpcx.Config `yaml:",inline"`
}
targets:
  - name: "360-1"
    channels: ["ac_1"]
    media: "qihoo360"
    qihoo360:
      app_key: "..."
      app_secret: "..."
      # data_industry 与 event 可省,缺省是 PC 搜索推广 + 订单
      data_industry: "ocpc_ps_convert"
      event: "ORDER"

  - name: "baidu-1"
    channels: ["af_1"]
    media: "baidu"
    baidu:
      token: "..."
      landing_url: "https://example.com/"
      conversion_type: 3          # 表单提交成功,编码见「百度转化类型编码」
shared := httpc.New(httpc.WithTimeout(10 * time.Second))

for i, t := range targets {
    if err := t.Config.Validate(); err != nil {           // 启动期校验,附加自己的配置路径
        return fmt.Errorf("targets[%d](%s): %w", i, t.Name, err)
    }
    r, err := ocpcx.New(t.Config, ocpcx.WithHTTPClient(shared))
    if err != nil {
        return err
    }
    // ...
}

错误处理

var apiErr *ocpcx.APIError
switch err := r.Report(ctx, clickID); {
case err == nil:
    // 媒体已确认接收

case errors.As(err, &apiErr):
    // 媒体明确拒绝:HTTP 非 200,或 200 但业务字段表示失败。
    // 凭据错、点击 ID 非法、转化类型不对——重试多少次都没用,该告警让人改配置。
    // DetailCode 是媒体在业务码之外给出的更细错误码(百度网页填 9018xxx,其余媒体留空)
    alert(apiErr.Media, apiErr.StatusCode, apiErr.Code, apiErr.DetailCode, apiErr.Message, apiErr.Body)

default:
    // 网络、超时、被取消,或 200 却给不出合法 JSON(多半是网关插了一脚)——重试有意义。
    retryLater()
}

构造期错误用 errors.Is 判:ErrUnknownMedia(媒体标识不认识)、 ErrInvalidConfig(凭据缺失或格式不合法)。

API 概览

符号 说明
Reporter Media() string / Report(ctx, clickID) error
New(cfg Config, opts ...Option) (Reporter, error) 点击 ID 归因:按 cfg.Media 构造,构造前先 Validate
NewQihoo360 / NewBaidu 直接构造点击 ID 归因的媒体
AppReporter Media() string / Report(ctx, ConversionEvent) error
NewApp(cfg AppConfig, opts ...Option) (AppReporter, error) 应用推广:按 cfg.Media 构造
NewHuawei / NewBaiduApp 直接构造应用推广媒体
ConversionEvent / ConversionType 应用推广的转化事件与类型
Config / AppConfig + 各媒体 Config 配置,带 yaml/json tag
Config.Validate() / AppConfig.Validate() 启动期校验,不发起网络调用
Medias() / AppMedias() 已实现的媒体标识,顺序稳定
WithHTTPClient / WithTimeout Options
APIError / ErrUnknownMedia / ErrInvalidConfig / ErrResponseTooLarge / ErrMediaUnavailable 错误
Media* / Conversion* / DefaultTimeout 常量

应用推广用法

r, err := ocpcx.NewHuawei(ocpcx.HuaweiConfig{Secret: secret})
if err != nil {
    return err
}
err = r.Report(ctx, ocpcx.ConversionEvent{
    OAID:        oaid,        // 设备标识(华为可选)
    Callback:    callback,    // 媒体点击时下发的回调串
    Type:        ocpcx.ConversionActivate,
    ConvertTime: convertedAt, // 转化实际发生的时间,不是上报时刻
})

配置驱动同样支持内联:

type Target struct {
    Name            string `yaml:"name"`
    ocpcx.AppConfig `yaml:",inline"`
}
r, err := ocpcx.NewApp(t.AppConfig, ocpcx.WithHTTPClient(shared))
media: "huawei"
huawei:
  secret: "..."
归因维度(百度应用)

百度把 join_type 标为必填字段——它标明这条转化实际靠哪个标识匹配成功。归因由你的 业务侧完成,本包不推断:你按自己的优先级匹配,同时持有多个标识但只有一个命中是常态, 本包若按「哪个字段有值」去猜,报错维度会让百度侧的归因统计失真,而请求照常返回成功。

// 归因服务按优先级匹配,得知本条是 IDFA 命中
err = r.Report(ctx, ocpcx.ConversionEvent{
    Callback:    callback,
    Type:        ocpcx.ConversionActivate,
    ConvertTime: convertedAt,
    JoinType:    ocpcx.JoinIDFA,   // 靠哪个维度归因成功
    JoinValue:   idfa,             // 该维度对应的标识
})
// 发出 &join_type=idfa&idfa=<idfa>

JoinType 留空时按 oaid 维度、取 OAID 字段的值发出,既有代码无需改动。

常量 join_type 说明
JoinOAID oaid 通过 oaid 或 oaid_md5 归因成功
JoinIMEI imei 通过 imei_md5 归因成功
JoinAndroidID android_id 通过 android_id 或 android_id_md5 归因成功
JoinIDFA idfa 通过 idfa 归因成功(iOS)
JoinCAID caid 通过 caid 归因成功(iOS)
JoinMAC mac 通过 mac 或 mac_md5 归因成功
JoinIP ip 通过 ip 和系统信息(ua、os_version、model)归因成功
JoinBDVID bd_vid 通过注入 APK V2 签名块的广告 ID 归因成功,需先申请开通
JoinPAID paid 官方标注灰度
JoinBTUT bt_ut 官方标注灰度

常量只供起名,不构成校验——百度仍在扩充维度,表外取值原样发出,媒体不认识时以 error_code=105 拒绝,可由 *APIError.Code 直接看出。

官方建议的归因优先级:Android 是 BD_VID > OAID > IMEI > AndroidId > IP+UA, iOS 是 IDFA > CAID > IP+UAbd_vid 来自《Android BD_VID 注入归因增强方案》, 官方称成功注入可保证 100% 准确归因。

百度应用错误码
error_code 含义
0 成功
100 sign 签名错误(签名值为空,或位数不是 32 位)
101 ext_info 数据为空
102 ATYPE 或 AVALUE 值错误
103 akey 值不符合要求
104 searchid 验证失败
105 归因类型参数 join_type 错误
110 ATIME 参数错误(须为 10 位秒级时间戳,且不得是未来时间)

以上都返回 *APIError(重试无意义)。另有 500可重试处理,可用 errors.Is(err, ocpcx.ErrMediaUnavailable) 命中——该值不在官方现行错误码表中, 其定性来自本包的既有实现。

ConvertTimea_time(秒级时间戳)发出。百度对未来时间戳会以 110 拒绝,本包 不在本地拦截——那要拿本机时钟与百度服务器比较,本机略快就会误拒合法上报。

转化类型支持矩阵

媒体不支持某个类型时返回错误,而不是降级成激活上报——把注册静默报成激活, 数据错了从外面完全看不出来。

media activate register retain
huawei activate register retain
baidu_app activate register retain_1day

百度的次留是 retain_1day 而不是 retain:它的 a_type 枚举是 activate / register / orders / user_defined / retain_1day / highvalue_customer, 其中没有 retain

协议要点

网页推广(点击 ID 归因)
360 搜索(qihoo360) 百度网页(baidu)
端点 POST convert.dop.360.cn/uploadWebConvert POST ocpc.baidu.com/ocpcapi/api/uploadConvertData
鉴权 App-Key + App-Sign body 里的 token
签名 md5(app_secret + 请求体字节)
去重 trans_id,按 (点击 ID, 事件) 派生或调用方指定
请求体 data.data_detail 对象 conversionTypes 数组(官方上限 100 条,本包一次一条)
成功判定 HTTP 200 且 error == "Success" HTTP 200 且 header.status == 0
百度应答的两级码

header.status 是粗分类,五个取值:

status 官方说明 本包处理
0 用户回传数据成功 成功
1 用户回传数据部分成功 *APIError
2 用户回传数据全部失败 *APIError
3 API 接口 TOKEN 校验失败 *APIError
4 服务内部错误 ErrMediaUnavailable,可重试

4 单独归为可重试——官方示例代码对该值就是继续重试的,用 errors.Is(err, ocpcx.ErrMediaUnavailable) 命中。其余非 0 值是媒体明确拒绝:官方对失败记录的说法是 「可根据返回的错误提示对发送失败的记录进行修改,修改后可以再发送」,即改参数后重发,原样重试没有意义。

指明是哪个字段出错的是 header.errors[].code,七个官方码都有具名常量:

常量 官方说明
BaiduErrEmptyConversions 9018000 上传的 conversionTypes 数组为空
BaiduErrTooManyConversions 9018001 conversionTypes 数组长度超过 100
BaiduErrInvalidLogidURL 9018002 logidUrl 无效
BaiduErrInvalidIsConvert 9018003 isConvert 无效
BaiduErrInvalidConvType 9018004 newType 或 convertType 无效
BaiduErrInvalidConvertTime 9018005 convertTime 无效或超过有效上报时间
BaiduErrInvalidConvValue 9018006 convertValue 无效

该码同时进 *APIError.DetailCode(结构化)与 *APIError.Message(拼成 partly ok [9018002 logidUrl is not valid @1] 便于日志阅读),按码分支用前者:

var apiErr *ocpcx.APIError
if errors.As(err, &apiErr) {
    switch apiErr.DetailCode {
    case ocpcx.BaiduErrInvalidLogidURL:     // 9018002
        alert("landing_url 配错了")
    case ocpcx.BaiduErrInvalidConvType:     // 9018004
        alert("conversion_type 填错了")
    case ocpcx.BaiduErrInvalidConvertTime:  // 9018005
        drop("转化时间无效,或已超过有效上报时间")
    }
}

常量只供识别,不构成白名单——百度返回表外的码时照样带进 DetailCode。 应答不带 errors 时(如 status=3)该字段为空字符串;360、华为、百度应用的应答只有单级业务码, 该字段恒为空。

logidUrl 由落地页拼 ?bd_vid=<点击ID> 得到,官方限定不超过 1024 字符,超了在发送前就报错。

百度转化类型编码

BaiduConfig.ConversionType(即 newType)填的是编码。填错不会被任何一方报错——百度按收到的 编码归类转化,归错只表现为报表里某个类型的数字不对。取值见 BaiduConv* 常量:

常量 编码 转化类型 常量 编码 转化类型
BaiduConvConsultButton 1 咨询按钮点击 BaiduConvCustom 27 客户自定义
BaiduConvPhoneButton 2 电话按钮点击 BaiduConvAppRetain 28 次日留存
BaiduConvFormSubmit 3 表单提交成功 BaiduConvPhoneConnected 30 电话拨通
BaiduConvAppActivate 4 APP激活 BaiduConvMapButton 31 地图按钮点击
BaiduConvFormButton 5 表单按钮点击 BaiduConvQQButton 32 QQ咨询按钮点击
BaiduConvDownloadButton 6 下载按钮点击 BaiduConvLotteryButton 33 抽奖按钮点击
BaiduConvPurchaseButton 7 购买按钮点击 BaiduConvVoteButton 34 投票按钮点击
BaiduConvSMSButton 8 短信咨询按钮点击 BaiduConvWechatCopyClick 35 微信复制按钮点击
BaiduConvPurchase 10 购买成功 BaiduConvApply 41 申请
BaiduConvOrderSubmit 14 订单提交成功 BaiduConvCredit 42 授信
BaiduConvConsultThree 17 三句话咨询 BaiduConvGoodsOrder 45 商品下单成功
BaiduConvLeaveClue 18 留线索 BaiduConvAddToCart 46 加入购物车
BaiduConvConsultOne 19 一句话咨询 BaiduConvLoginAfterReg 49 注册激活后登录
BaiduConvDeepPageVisit 20 深度页面访问 BaiduConvReservation 50 预约
BaiduConvAppRegister 25 APP注册 BaiduConvIntentCustomer 51 有意向客户
BaiduConvAppPaid 26 APP付费 BaiduConvDeepUsage 52 深度使用
BaiduConvSecondRedirect 61 二次跳转
BaiduConvAppInvoke 71 应用调起

常量不构成校验:ConversionType 仍接受表外的任何正整数。百度在持续增补编码(上表号段 本就有空缺),而某个账户实际能用哪些类型由推广后台「新建转化追踪」时披露——填哪个以后台为准。

⚠️ BaiduConvAddToCart(46,加入购物车)按官方规定必须带转化时间,要用 ConversionReporter 传:

cr := r.(ocpcx.ConversionReporter)
err = cr.ReportConversion(ctx, ocpcx.ClickConversion{
    ClickID:     clickID,
    ConvertTime: convertedAt,   // 46 必填;其他类型给了也会一并发出
})

配了 46 却用 Report(不带时间)会在发送前报错——发出去也只会被百度以 9018005 拒掉。

360 转化类型

Qihoo360Config.Event(即请求体里的 event)填的是转化类型字面量。填错不会被任何一方报错 ——360 对账户没开通的类型静默丢弃,回传照常返回成功,只表现为后台报表里那个类型的数字 一直是 0。取值见 Qihoo360Event* 常量:

常量 event 转化类型 常量 event 转化类型
Qihoo360EventSubmitButton SUBMIT_BUTTON 点击页面表单按钮 Qihoo360EventRegistered REGISTERED 成功产生一次注册
Qihoo360EventAdvisoryButton ADVISORY_BUTTON 点击页面咨询按钮 Qihoo360EventLogin LOGIN 通过账号登录平台
Qihoo360EventCallButton CALL_BUTTON 点击页面电话按钮 Qihoo360EventRealName REAL_NAME 用户实名认证
Qihoo360EventShopButton SHOP_BUTTON 点击页面购买按钮 Qihoo360EventEnterpriseCertification ENTERPRISE_CERTIFICATION 企业提交资质进行平台认证
Qihoo360EventCartButton CART_BUTTON 点击页面加入购物车按钮 Qihoo360EventActivation ACTIVATION 程序激活
Qihoo360EventRegisterButton REGISTER_BUTTON 点击页面注册按钮 Qihoo360EventRetention RETENTION 次日留存
Qihoo360EventScanButton SCAN_BUTTON 点击页面扫码按钮 Qihoo360EventSiteVisitDepth SITE_VISIT_DEPTH 访问深度大于等于 3
Qihoo360EventWechatCopyButton WX_BUTTON_C 微信复制按钮点击 Qihoo360EventVPPV VPPV 深度页面访问
Qihoo360EventSiteDownload SITEDOWNLOAD 点击页面下载按钮 Qihoo360EventBrowseDepth BROWSE_DEPTH 页面浏览到达指定位置
Qihoo360EventSubmit SUBMIT 成功提交表单内容 Qihoo360EventBrowseTime BROWSETIME 浏览时长达到定义值
Qihoo360EventCall CALL 有效电话拨打 Qihoo360EventDetailsPageArrived DETAILS_PAGE_ARRIVED 详情页到达
Qihoo360EventAdvisory ADVISORY 访客发起一次对话 Qihoo360EventMiddlePage MIDDLE_PAGE 扫码进入移动中间页
Qihoo360EventAdvisoryDepth ADVISORY_DEPTH 三句话咨询 Qihoo360EventScanCode SCAN_CODE 用户进行扫码行为
Qihoo360EventEffectiveAdvisory EFFECTIVE_ADVISORY 产生有效的咨询行为 Qihoo360EventAppletStartup APPLET_STARTUP 点广告打开微信小程序
Qihoo360EventLeaveContact LEAVE_CONTACT 留下联系方式 Qihoo360EventRoleCreate ROLE_CREAT 成功创建一个游戏角色
Qihoo360EventAddFansWechat ADD_FANS_WX 微信加粉 Qihoo360EventTryToPlay TRY_TO_PLAY 进行游戏试玩
Qihoo360EventIntentional INTENTIONAL 用户产生消费意向 Qihoo360EventTrial TRIAL 注册或登录后体验推广产品
Qihoo360EventVisitClinic VISIT_CLINIC 客户预约后到医院就诊 Qihoo360EventConcern CONCEM 关注
Qihoo360EventOrder ORDER 成功产生一次订单 Qihoo360EventRelease RELEASE 发布招聘职位等相关信息
Qihoo360EventPlaceOrder PLACE_ORDER 订单提交 Qihoo360EventSubmitResume SUBMIT_RESUME 投递简历
Qihoo360EventOrderValidity ORDER_VALIDITY 确认为有效的订单 Qihoo360EventAppletPay APPLET_PAY 在小程序游戏内充值
Qihoo360EventLowPay LOW_PAY 低价订单付费 Qihoo360EventAppletRoleCreate APPLET_ROLE_CREAT 在小程序游戏内创建角色
Qihoo360EventPay PAY 正式订单付费 Qihoo360EventTrialLessonPerform TRIAL_LESSON_PERFORM 进入试听课课程学习
Qihoo360EventPaySuccess PAY_SUCCESS 支付成功 Qihoo360EventTrialLessonComplete TRIAL_LESSON_COMPLETE 完成当日大部分试听内容
Qihoo360EventAddToCart ADD_TO_CART 将商品添加到购物车 Qihoo360EventCustomize COUSTOMIZE 客户自定义类型
Qihoo360EventCredit CREDIT 用户小额贷款申请通过

常量不构成校验:Event 仍接受表外的任何取值。360 在持续增补枚举,而某个账户实际能用 哪些类型由后台「创建 API 回传转化类型」时披露——填哪个以后台为准。

⚠️ 官方表里有三项的拼写是笔误:COUSTOMIZEROLE_CREATCONCEM。常量照抄媒体侧字面量, 名字用可读英文(Qihoo360EventCustomize / Qihoo360EventRoleCreate / Qihoo360EventConcern) ——凭印象写成 CUSTOMIZE / ROLE_CREATE / CONCERN 的那条路径,用常量就不存在。

表末四项(APPLET_PAYAPPLET_ROLE_CREATTRIAL_LESSON_PERFORMTRIAL_LESSON_COMPLETE) 由展示广告线(ocpc_zs_convert)披露;本包服务的产品线见上文 DataIndustry 一节。

应用推广
华为 百度应用
方法 POST JSON GET query
端点 ppscrowd-drcn.op.cloud.huawei.com/…/actionupload http://ocpc.baidu.com/ocpcapi/cb/actionCb
签名 Authorization: Digest …response="hmac_sha256(secret, 请求体字节)" md5(端点+"?"+query+secret)
需要 OAID 否(给了则发 device_id+id_type)
成功判定 resultCode==0 && resultMessage=="success" error_code==0 && status<=200

华为按 v2.1.6 协议发送:设备标识用 device_id + id_type(10 为 OAID 明文); oaiduser_agent(v2.1.3 删除)、conversion_count(v1.1.4 删除)与 tracking_enabled 都不再发送。ConversionEventCountUserAgent 对应后两个字段,向华为上报时 设置了会报错——静默不发会让调用方一直以为它们在生效。

百度的 error_code=500(服务异常)按可重试处理:返回的不是 *APIError,而是 可用 errors.Is(err, ocpcx.ErrMediaUnavailable) 命中的错误。100(签名错误)、 101(数据错误)仍是 *APIError

几处**不能顺手"优化"**的地方:

  • 百度应用的端点是 http:// 且不能改成 https://:签名串是 md5(端点+"?"+query+secret), 端点原样参与计算,改 scheme 会让签名对不上。这与网页推广的百度(https,签的是 body)是两条链路。
⚠ 百度应用签名:一处待实测核实的差异

百度开发者文档写的签名规则是 「先对回调 URL 做 urldecode,再 md5(解码后的URL去掉&sign= + akey)」, 而本实现(沿用线上跑通的做法)签的是 编码后 的 query 串。

两者只在 ext_info(回调串)含需要百分号编码的字符时才不同——base64 的 + / = 会被 url.Values.Encode() 转成 %2B %2F %3D。若回调串是纯字母数字与短横线, 编码前后完全一致,差异不显现;这多半就是线上能跑通的原因。

  • 踩上时的表现:百度返回 error_code=100(签名错误),可由 *APIError.Code 直接看出, 不会静默丢数据。
  • 接入新账号前:拿一条含 + / = 的真实回调串验一次。确实需要签解码串的话, 改动就在 baiduapp.gosign 那一行。

360 的签名按实际发送出去的 body 字节计算,包内先 json.Marshal 得到字节,签名与请求体 共用同一份——因此与所用 JSON 后端无关。改动这处实现前请先看 TestQihoo360SignMatchesSentBody:它从服务端收到的 body 反算签名,任何「结构体交给客户端 再序列化一次、签名另算一次」的改法都会被它拦下(真出问题时是静默失败:HTTP 200 + error != "Success")。

conversionTypes 对百度传的是对象而非数组:以线上跑通的形态为准。

新增一家媒体

先判断它属于哪一类回传(点击 ID 归因 → 网页侧;设备+回调串归因 → 应用侧),然后:

  1. 注册表(网页侧 search.go,应用侧 app.go):加 Media<X> 常量,对应 Config/AppConfigX *XConfig 字段(结构体新增字段向后兼容),Medias()/AppMedias()Validate()New()/NewApp() 各加一处分支;
  2. 新建 <x>.go:XConfig + validate() + newX() + NewX() + reporter 实现, 请求体自己 json.Marshal,走 postJSON / getJSON 骨架;
  3. 新建/追加 <x>_test.go:参数与签名断言 + 成功/拒绝/HTTP 错误三种应答 + 凭据不泄漏
    • 转化类型支持范围。

调用方无需改动装配代码与配置结构体,只改 YAML。

Documentation

Overview

Package ocpcx 实现广告媒体的 OCPC 转化回传协议。

它只回答一个问题:**怎么把一次转化发给某个媒体账户**。转化数据从哪来、失败了怎么办、 什么时候发、凭据从哪读,全部由调用方决定——本包不含队列、调度、重试与存储概念, 也不产生任何日志(错误经返回值上抛,由调用方带业务上下文记录)。

两类回传

两条链路的归因方式与数据模型完全不同,因此是两套接口,不要混用:

网页推广 [Reporter]     点击 ID 归因(bd_vid / qhclickid)    百度网页、360 搜索
应用推广 [AppReporter]  设备+回调串归因(oaid / callback)   百度应用、华为

百度那条同时服务搜索与信息流两种流量(官方归类为线索 API),360 那条是搜索推广产品线; 两家的共性是**按点击 ID 归因**,故合用同一套 Reporter

尤其注意**两个百度不是同一个接口**:网页推广走 ocpcapi/api/uploadConvertData, 应用推广走 ocpcapi/cb/actionCb,媒体标识分别是 MediaBaiduMediaBaiduApp

快速上手

r, err := ocpcx.NewBaidu(ocpcx.BaiduConfig{
    Token:          token,
    LandingURL:     "https://example.com/",
    ConversionType: ocpcx.BaiduConvFormSubmit, // 转化类型编码,取值见 BaiduConv* 常量
})
if err != nil {
    return err
}
if err := r.Report(ctx, "bd_vid_from_landing_page"); err != nil {
    return err
}

360 的事务号(trans_id)是媒体侧的去重键,本包按 (点击 ID, 转化事件) 确定性派生, 重传得到同一个值。按自己的业务主键(订单号等)去重时,用可选接口 ConversionReporter 显式给出。

配置驱动

需要按配置文件选媒体时用 NewConfig:Config 带 yaml/json tag, 可直接内联进调用方自己的配置结构(`yaml:",inline"`),新增媒体时调用方 的装配代码与配置结构体都不用改。

type Target struct {
    Name         string `yaml:"name"`
    ocpcx.Config `yaml:",inline"`
}

if err := t.Config.Validate(); err != nil {          // 启动期校验凭据
    return fmt.Errorf("targets[%d]: %w", i, err)
}
r, err := ocpcx.New(t.Config, ocpcx.WithHTTPClient(shared))

错误处理

媒体明确拒绝(HTTP 非 200,或 HTTP 200 但应答表示业务失败)返回 *APIError, 重试无意义,应告警并检查凭据/参数;网络与超时类故障返回其他错误,重试才有意义。 媒体自报服务异常且协议标注可重试时(如百度应用的 error_code=500)返回 ErrMediaUnavailable,它同样属于可重试的一类。

var apiErr *ocpcx.APIError
switch {
case errors.As(err, &apiErr):
    alert("媒体拒绝", apiErr.Media, apiErr.Code, apiErr.Message)
case err != nil:
    retryLater()
}

应用推广

AppReporter 收的是 ConversionEvent(设备标识 + 回调串 + 转化类型 + 时间), 用法与点击归因侧对称:

r, err := ocpcx.NewHuawei(ocpcx.HuaweiConfig{Secret: secret})
err = r.Report(ctx, ocpcx.ConversionEvent{
    OAID:        oaid,
    Callback:    callback,
    Type:        ocpcx.ConversionActivate,
    ConvertTime: convertedAt,
})

媒体不支持某个转化类型时返回错误而**不是**降级成激活上报,支持范围见 AppReporter 的文档。

并发

Reporter 与 AppReporter 构造后只读,可并发使用;凭据在构造期已拷成实例自有字段, 实例之间零共享。多个实例应经 WithHTTPClient 共享同一个客户端,以复用连接池。

构造一次、长期持有;不要每次上报都 New——那会让每次调用都新建连接池, 重做 TLS 握手并堆积 TIME_WAIT。

Index

Examples

Constants

View Source
const (
	// MediaHuawei 是华为应用推广转化回传。
	MediaHuawei = "huawei"
	// MediaBaiduApp 是百度**应用**推广转化回传(区别于网页推广的 MediaBaidu)。
	MediaBaiduApp = "baidu_app"
)

应用推广已实现的媒体标识。

与网页推广的 MediaBaidu 刻意区分:百度这两条链路是**不同的接口** (网页走 ocpcapi/api/uploadConvertData 报 bd_vid,应用走 ocpcapi/cb/actionCb 报 oaid), 配置里若都叫 "baidu" 就分不清是哪条,故应用侧叫 MediaBaiduApp

View Source
const (
	// 页面按钮点击类:JS、小程序与线索 API 支持。
	BaiduConvConsultButton   = 1  // 咨询按钮点击
	BaiduConvPhoneButton     = 2  // 电话按钮点击
	BaiduConvFormButton      = 5  // 表单按钮点击
	BaiduConvDownloadButton  = 6  // 下载按钮点击(信息流为「下载预约按钮点击」)
	BaiduConvPurchaseButton  = 7  // 购买按钮点击
	BaiduConvSMSButton       = 8  // 短信咨询按钮点击
	BaiduConvMapButton       = 31 // 地图按钮点击
	BaiduConvQQButton        = 32 // QQ咨询按钮点击
	BaiduConvLotteryButton   = 33 // 抽奖按钮点击
	BaiduConvVoteButton      = 34 // 投票按钮点击
	BaiduConvWechatCopyClick = 35 // 微信复制按钮点击

	// 线索与成交类。
	BaiduConvFormSubmit     = 3  // 表单提交成功
	BaiduConvPurchase       = 10 // 服务购买成功
	BaiduConvOrderSubmit    = 14 // 订单提交成功
	BaiduConvConsultThree   = 17 // 三句话咨询
	BaiduConvLeaveClue      = 18 // 留线索
	BaiduConvConsultOne     = 19 // 一句话咨询(信息流为「有效咨询」)
	BaiduConvDeepPageVisit  = 20 // 深度页面访问
	BaiduConvPhoneConnected = 30 // 电话拨通
	BaiduConvApply          = 41 // 申请
	BaiduConvCredit         = 42 // 授信
	BaiduConvGoodsOrder     = 45 // 商品下单成功(电商购买)
	BaiduConvAddToCart      = 46 // 加入购物车
	BaiduConvLoginAfterReg  = 49 // 注册激活后登录
	BaiduConvReservation    = 50 // 预约
	BaiduConvIntentCustomer = 51 // 有意向客户
	BaiduConvSecondRedirect = 61 // 二次跳转

	// 应用类:应用 API 与应用 SDK 支持。
	BaiduConvAppActivate = 4  // APP激活
	BaiduConvAppRegister = 25 // APP注册
	BaiduConvAppPaid     = 26 // APP付费
	BaiduConvAppRetain   = 28 // 次日留存
	BaiduConvDeepUsage   = 52 // 深度使用
	BaiduConvAppInvoke   = 71 // 应用调起

	// BaiduConvCustom 是客户自定义。
	BaiduConvCustom = 27
)

百度转化类型编码,取值与名称照抄官方「转化类型编码」表,供 BaiduConfig.ConversionType 使用。

常量只是给编码一个名字,**不构成校验**:BaiduConfig.ConversionType 仍接受表外的任何正整数。 百度在持续增补编码(下面的号段本就有空缺),而某个账户实际能用哪些类型由推广后台 「新建转化追踪」时披露——本包看不到那个信息,填哪个请以后台为准。

官方表按对接方式(JS / 小程序 / 线索 API / 应用 API / 应用 SDK)标注了各类型的支持范围, 下面按此分组。本包的 NewBaidu 以 bd_vid 归因,走的是线索 API / JS 那条路径。

View Source
const (
	BaiduErrEmptyConversions   = "9018000" // 上传的 conversionTypes 数组为空
	BaiduErrTooManyConversions = "9018001" // conversionTypes 数组长度超过 100
	BaiduErrInvalidLogidURL    = "9018002" // logidUrl 无效
	BaiduErrInvalidIsConvert   = "9018003" // isConvert 无效
	BaiduErrInvalidConvType    = "9018004" // newType 或 convertType 无效
	BaiduErrInvalidConvertTime = "9018005" // convertTime 无效或超过有效上报时间
	BaiduErrInvalidConvValue   = "9018006" // convertValue 无效
)

百度应答 header.errors[].code 的官方错误码,照「返回 errors 错误码说明」表收全, 取值是官方码的十进制字符串——与 APIError.DetailCode 同类型,可直接比较:

var e *ocpcx.APIError
if errors.As(err, &e) && e.DetailCode == ocpcx.BaiduErrInvalidLogidURL {
    alert("landing_url 配错了")
}

常量只是给编码一个名字,**不构成校验**:百度返回表外的码时照样带进 APIError.DetailCode,不会被丢弃(与 BaiduConv* 的取舍一致)。

View Source
const (
	// Qihoo360IndustryPCSearch 是 PC 搜索推广,[Qihoo360Config.DataIndustry] 的缺省值。
	Qihoo360IndustryPCSearch = "ocpc_ps_convert"
	// Qihoo360IndustryMobileSearch 是移动搜索。
	Qihoo360IndustryMobileSearch = "ocpc_ms_convert"
)

360 的产品线标识(data_industry)。四条线的参数要求互不相通,混用会让数据缺失或错乱, 本包按点击 ID(qhclickid)组装请求,故只支持以点击 ID 归因的两条。

View Source
const (
	// 页面按钮点击类。
	Qihoo360EventSubmitButton     = "SUBMIT_BUTTON"   // 点击页面表单按钮
	Qihoo360EventAdvisoryButton   = "ADVISORY_BUTTON" // 点击页面咨询按钮
	Qihoo360EventCallButton       = "CALL_BUTTON"     // 点击页面电话按钮
	Qihoo360EventShopButton       = "SHOP_BUTTON"     // 点击页面购买按钮
	Qihoo360EventCartButton       = "CART_BUTTON"     // 点击页面加入购物车按钮
	Qihoo360EventRegisterButton   = "REGISTER_BUTTON" // 点击页面注册按钮
	Qihoo360EventScanButton       = "SCAN_BUTTON"     // 点击页面扫码按钮
	Qihoo360EventWechatCopyButton = "WX_BUTTON_C"     // 微信复制按钮点击
	Qihoo360EventSiteDownload     = "SITEDOWNLOAD"    // 点击页面下载按钮

	// 线索与咨询类。
	Qihoo360EventSubmit            = "SUBMIT"             // 成功提交表单内容
	Qihoo360EventCall              = "CALL"               // 有效电话拨打
	Qihoo360EventAdvisory          = "ADVISORY"           // 访客发起一次对话
	Qihoo360EventAdvisoryDepth     = "ADVISORY_DEPTH"     // 一次咨询对话中,用户发出大于等于 3 条内容
	Qihoo360EventEffectiveAdvisory = "EFFECTIVE_ADVISORY" // 产生有效的咨询行为
	Qihoo360EventLeaveContact      = "LEAVE_CONTACT"      // 留下联系方式
	Qihoo360EventAddFansWechat     = "ADD_FANS_WX"        // 微信加粉
	Qihoo360EventIntentional       = "INTENTIONAL"        // 用户产生消费意向
	Qihoo360EventVisitClinic       = "VISIT_CLINIC"       // 客户预约后到医院就诊

	// 订单与付费类。
	Qihoo360EventOrder         = "ORDER"          // 成功产生一次订单;[Qihoo360Config.Event] 的缺省值
	Qihoo360EventPlaceOrder    = "PLACE_ORDER"    // 订单提交
	Qihoo360EventOrderValidity = "ORDER_VALIDITY" // 确认为有效的订单
	Qihoo360EventLowPay        = "LOW_PAY"        // 低价订单付费
	Qihoo360EventPay           = "PAY"            // 正式订单付费
	Qihoo360EventPaySuccess    = "PAY_SUCCESS"    // 支付成功
	Qihoo360EventAddToCart     = "ADD_TO_CART"    // 将商品添加到购物车
	Qihoo360EventCredit        = "CREDIT"         // 用户小额贷款申请通过

	// 账号与身份类。
	Qihoo360EventRegistered              = "REGISTERED"               // 成功产生一次注册
	Qihoo360EventLogin                   = "LOGIN"                    // 通过账号登录平台
	Qihoo360EventRealName                = "REAL_NAME"                // 用户实名认证
	Qihoo360EventEnterpriseCertification = "ENTERPRISE_CERTIFICATION" // 企业提交资质进行平台认证
	Qihoo360EventActivation              = "ACTIVATION"               // 程序激活
	Qihoo360EventRetention               = "RETENTION"                // 次日留存

	// 页面浏览与到达类。
	Qihoo360EventSiteVisitDepth     = "SITE_VISIT_DEPTH"     // 经广告进入落地页,访问深度大于等于 3
	Qihoo360EventVPPV               = "VPPV"                 // 深度页面访问
	Qihoo360EventBrowseDepth        = "BROWSE_DEPTH"         // 页面浏览完成,到达指定位置
	Qihoo360EventBrowseTime         = "BROWSETIME"           // 浏览时长大于等于定义值时计入一次转化
	Qihoo360EventDetailsPageArrived = "DETAILS_PAGE_ARRIVED" // 详情页到达
	Qihoo360EventMiddlePage         = "MIDDLE_PAGE"          // 扫描 PC 页面二维码进入移动中间页

	// 扫码与小程序类。
	Qihoo360EventScanCode      = "SCAN_CODE"      // 用户进行扫码行为
	Qihoo360EventAppletStartup = "APPLET_STARTUP" // 用户点广告打开微信小程序

	// 游戏与内容类。
	Qihoo360EventRoleCreate   = "ROLE_CREAT"    // 成功创建一个游戏角色(媒体侧字面量为 ROLE_CREAT)
	Qihoo360EventTryToPlay    = "TRY_TO_PLAY"   // 进行游戏试玩
	Qihoo360EventTrial        = "TRIAL"         // 用户注册或登录后体验该推广产品
	Qihoo360EventConcern      = "CONCEM"        // 关注(媒体侧字面量为 CONCEM)
	Qihoo360EventRelease      = "RELEASE"       // 发布招聘职位等相关信息
	Qihoo360EventSubmitResume = "SUBMIT_RESUME" // 投递简历

	// 展示广告线(ocpc_zs_convert)披露的四项;本包服务的产品线见 [Qihoo360Config.DataIndustry]。
	// 列出它们是为了这张表与官方表逐项对得上。
	Qihoo360EventAppletPay           = "APPLET_PAY"            // 在小程序游戏内充值
	Qihoo360EventAppletRoleCreate    = "APPLET_ROLE_CREAT"     // 在小程序游戏内创建角色
	Qihoo360EventTrialLessonPerform  = "TRIAL_LESSON_PERFORM"  // 在微信社群中进入试听课课程学习
	Qihoo360EventTrialLessonComplete = "TRIAL_LESSON_COMPLETE" // 完成当日大部分课程试听内容学习

	// Qihoo360EventCustomize 是客户自定义类型(媒体侧字面量为 COUSTOMIZE)。
	Qihoo360EventCustomize = "COUSTOMIZE"
)

360 转化类型,取值与描述照抄官方「转化类型参数值」表,供 Qihoo360Config.Event 使用。

常量只是给字面量一个名字,**不构成校验**:Qihoo360Config.Event 仍接受表外的任何取值。 360 在持续增补枚举,而某个账户实际能用哪些类型由后台「创建 API 回传转化类型」时披露 ——本包看不到那个信息,填哪个请以后台为准。账户没开通的类型发过去会被静默丢弃:回传照常 返回成功,只有后台报表里那个类型的数字一直是 0。

官方表里有三项的拼写是笔误:COUSTOMIZE、ROLE_CREAT、CONCEM。常量照抄媒体侧字面量, 名字用可读英文,下面逐项标注了这层出入。

官方表按产品线(移动推广 / PC 搜索 / 移动搜索 / 展示广告)分列各类型的转化名称。本包按 点击 ID(qhclickid)组装请求,服务的产品线见 Qihoo360Config.DataIndustry;下面按业务 语义分组。

View Source
const (
	// MediaQihoo360 是 360 搜索推广转化回传。
	MediaQihoo360 = "qihoo360"
	// MediaBaidu 是百度**网页**推广转化回传,同时服务搜索与信息流两种流量
	// (区别于应用推广的 [MediaBaiduApp])。
	MediaBaidu = "baidu"
)

已实现的媒体标识。新增媒体时在此加常量,并在 MediasConfigNewConfig.Validate 各加一处;调用方无需改动。

View Source
const DefaultTimeout = 10 * time.Second

DefaultTimeout 是未注入 HTTP 客户端时的缺省单次调用超时。

View Source
const Version = "v1.4.0"

Version 是本包的当前版本号,与 git 附注标签保持一致。

发版走 make release-patch / make release-minor:脚本会原地自增下面这行的版本号、 提交并据此打标签,因此这一行的形状——Version 常量赋值为带 v 前缀的三段版本号—— 不能改动,本注释内也不得出现版本号字面量(脚本取文件里第一个匹配到的版本号)。

Variables

View Source
var (
	// ErrUnknownMedia 表示 Config.Media 不是本包已实现的媒体(含空串)。
	ErrUnknownMedia = errors.New("ocpcx: unknown media")
	// ErrInvalidConfig 表示凭据或投放参数缺失/格式不合法。
	ErrInvalidConfig = errors.New("ocpcx: invalid config")
	// ErrMediaUnavailable 表示媒体自报服务异常且协议标注该情形可重试:
	// 百度网页的 header.status=4(官方示例代码对它继续重试)、
	// 百度应用的 error_code=500(官方标注可重试)。
	//
	// 它刻意**不是** [APIError]:那个类型表示「媒体明确拒绝,重试无意义」。
	// 媒体自己说「我这会儿不行,再试」时按可重试处理,调用方退避后重发即可。
	ErrMediaUnavailable = errors.New("ocpcx: media temporarily unavailable")
)

构造期 sentinel 错误,调用方可用 errors.Is 判断。

View Source
var ErrResponseTooLarge = errors.New("ocpcx: response body too large")

ErrResponseTooLarge 表示媒体响应体超过客户端配置的上限。

与「解码失败」分开报:截断后的 JSON 一样解不出来,但那个错误信息会把人引向 「媒体返回了非法 JSON」,而真实原因是响应大得离谱(多半是被网关塞了错误页)。

Functions

func AppMedias

func AppMedias() []string

AppMedias 返回已实现的应用推广媒体标识,顺序稳定。

func Medias

func Medias() []string

Medias 返回已实现的媒体标识,顺序稳定(便于拼进错误信息与文档)。

Types

type APIError

type APIError struct {
	// Media 是媒体标识,取值见 Media* 常量。
	Media string
	// StatusCode 是 HTTP 状态码。
	StatusCode int
	// Code 是媒体侧的业务码,各家取自应答里的不同字段:
	//
	//	360        error(字符串,如 "InvalidClickId")
	//	百度网页    header.status
	//	百度应用    error_code
	//	华为        resultCode
	//
	// HTTP 层就失败时为空。
	Code string
	// DetailCode 是媒体在 [APIError.Code] 之外给出的**更细粒度**错误码。
	//
	// 百度网页的应答是两级结构:header.status 只分成功/部分成功/全部失败/token 失败/服务异常
	// 五类,指明是哪个字段出错的是 header.errors[].code。粗码不足以定位问题,故单独取出
	// ——否则调用方只能从 [APIError.Message] 的文本里匹配。
	//
	// 官方七个错误码的取值与说明见 [BaiduErrInvalidLogidURL] 所在的常量块。
	//
	//	百度网页    header.errors[0].code(如 "9018002")
	//	360        空(应答只有单级业务码)
	//	百度应用    空(同上)
	//	华为        空(同上)
	//
	// 取值不限于 BaiduErr* 列出的官方码:表外的码原样带出,不会被丢弃。
	DetailCode string
	// Message 是媒体侧给的描述(百度 header.desc);媒体未提供时为空。
	Message string
	// Body 是截断后的原始响应,供排查。
	Body string
}

APIError 表示媒体**明确拒绝**了这次回传:HTTP 状态码非 200,或 HTTP 200 但应答里 的业务字段表示失败。

它与网络/超时类错误的区别对调用方是有意义的:媒体拒绝(凭据错、点击 ID 非法、 转化类型不对)重试多少次都没用,该告警让人改配置;网络故障重试才有价值。 用 errors.As 取出本类型即可区分。

本类型不携带任何凭据。

Example

区分「媒体明确拒绝」与「网络故障」:前者重试无用,后者才该重试。

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/gtkit/ocpcx"
)

func main() {
	r, err := ocpcx.NewBaidu(ocpcx.BaiduConfig{
		Token: "t", LandingURL: "https://example.com/", ConversionType: ocpcx.BaiduConvFormSubmit,
	})
	if err != nil {
		return
	}

	var apiErr *ocpcx.APIError
	switch err := r.Report(context.Background(), "vid-1"); {
	case err == nil:
		fmt.Println("成功")
	case errors.As(err, &apiErr):
		// 凭据错、点击 ID 非法、转化类型不对——重试多少次都没用,该告警。
		fmt.Printf("媒体拒绝: media=%s code=%s desc=%s\n", apiErr.Media, apiErr.Code, apiErr.Message)
	default:
		// 网络、超时、被取消——稍后重试有意义。
		fmt.Println("传输故障,稍后重试")
	}
}

func (*APIError) Error

func (e *APIError) Error() string

Error 实现 error。

可选字段收进切片后一次拼接,而不是逐段 += ——后者每加一段就重新分配一次整串。

type AppConfig

type AppConfig struct {
	// Media 是媒体标识,必填,取值见 [MediaHuawei] / [MediaBaiduApp]。
	Media string `yaml:"media" json:"media"`
	// Huawei 在 Media 为 [MediaHuawei] 时必填。
	Huawei *HuaweiConfig `yaml:"huawei" json:"huawei,omitempty"`
	// BaiduApp 在 Media 为 [MediaBaiduApp] 时必填。
	BaiduApp *BaiduAppConfig `yaml:"baidu_app" json:"baidu_app,omitempty"`
}

AppConfig 是构造 AppReporter 的统一入参:Media 选协议,同名子块提供该媒体的账户凭据。

Config 同样带 yaml/json tag,可 `yaml:",inline"` 内联进调用方自己的配置结构。

func (AppConfig) Validate

func (c AppConfig) Validate() error

Validate 校验媒体标识与对应凭据,不发起任何网络调用。用法与 Config.Validate 一致: 返回的错误只描述「哪里不合法」,调用方应包装上自己的配置路径。

type AppReporter

type AppReporter interface {
	// Media 返回媒体标识,取值见 [MediaHuawei] / [MediaBaiduApp]。
	Media() string
	// Report 上报一条转化事件;返回 nil 即媒体侧确认接收。
	//
	// 媒体明确拒绝时返回 *APIError(重试无意义);网络与超时类故障返回其他错误。
	// 该媒体不支持这个 [ConversionType] 时返回错误而**不是**降级成激活上报
	// ——把注册静默报成激活,数据错了也看不出来。当前两家对三种类型全支持。
	//
	// 本方法不重试:只有调用方知道自己的幂等性与退避节奏。
	Report(ctx context.Context, e ConversionEvent) error
}

AppReporter 是「向某个应用推广媒体账户上报一条转化事件」的能力。 账户凭据在构造期绑定,实现构造后只读,可并发使用。

Example

应用推广:媒体不支持某个转化类型时直接报错,不会降级成激活上报。

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/gtkit/ocpcx"
)

func main() {
	r, err := ocpcx.NewHuawei(ocpcx.HuaweiConfig{Secret: "your-secret"})
	if err != nil {
		return
	}

	err = r.Report(context.Background(), ocpcx.ConversionEvent{
		Callback:    "callback-from-click",
		Type:        ocpcx.ConversionType("paid"), // 不在归一化取值内
		ConvertTime: time.Now(),
	})
	fmt.Println(err)
}
Output:
ocpcx: huawei 不支持转化类型 "paid"

func NewApp

func NewApp(cfg AppConfig, opts ...Option) (AppReporter, error)

NewApp 按 cfg.Media 构造对应媒体的 AppReporter,构造前先执行 AppConfig.Validate

配置驱动的调用方用它;不需要按配置选媒体时直接用 NewHuawei / NewBaiduApp——两条路径共用同一份协议实现。

func NewBaiduApp

func NewBaiduApp(cfg BaiduAppConfig, opts ...Option) (AppReporter, error)

NewBaiduApp 构造百度应用转化回传 AppReporter(区别于网页推广的 NewBaidu)。

func NewHuawei

func NewHuawei(cfg HuaweiConfig, opts ...Option) (AppReporter, error)

NewHuawei 构造华为应用转化回传 AppReporter。凭据不合法返回 ErrInvalidConfig 类错误。

Example

应用推广:直接构造某一家媒体的 AppReporter。

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/gtkit/ocpcx"
)

func main() {
	r, err := ocpcx.NewHuawei(ocpcx.HuaweiConfig{Secret: "your-secret"})
	if err != nil {
		fmt.Println("构造失败:", err)
		return
	}

	err = r.Report(context.Background(), ocpcx.ConversionEvent{
		OAID:        "device-oaid",
		Callback:    "callback-from-click",
		Type:        ocpcx.ConversionActivate,
		ConvertTime: time.Now(),
	})
	if err != nil {
		fmt.Println("回传失败:", err)
		return
	}
	fmt.Println(r.Media(), "回传成功")
}

type BaiduAppConfig

type BaiduAppConfig struct {
	// Secret 参与 sign 计算,必填。不会出现在任何错误信息里。
	Secret string `yaml:"secret" json:"secret"`
}

BaiduAppConfig 是百度应用推广账户凭据。

type BaiduConfig

type BaiduConfig struct {
	// Token 是百度 OCPC 回传 token,必填。不会出现在任何错误信息里。
	Token string `yaml:"token" json:"token"`
	// LandingURL 是落地页地址,必填。必须是 http/https 绝对地址且不带 query
	// ——回传时会在其后拼 "?bd_vid={点击ID}"。
	LandingURL string `yaml:"landing_url" json:"landing_url"`
	// ConversionType 是百度的转化类型 newType,必填且为正。
	//
	// 取值见 BaiduConv* 常量;它们只是给编码起名,不限制取值——表外的正整数照样放行,
	// 具体填哪个以推广后台「新建转化追踪」披露的类型为准。
	ConversionType int `yaml:"conversion_type" json:"conversion_type"`
}

BaiduConfig 是百度账户凭据与投放参数。

type ClickConversion

type ClickConversion struct {
	// ClickID 是媒体下发的点击标识,必填。
	ClickID string
	// TransID 是本次转化的事务号,可选。**仅 360 读取**,百度没有事务号概念,给了会被忽略。
	//
	// 360 用它对重复上报去重:同一事务号重复到达只计一次转化。留空时本包按
	// (ClickID, 转化事件) 确定性派生,重传得到同一个值;按自己的业务主键去重时
	// (订单号等)在这里给出即可,本包原样使用,不做二次派生。
	TransID string
	// ConvertTime 是转化**实际发生**的时间(不是上报时刻),可选。两家都读取:
	//
	//	360    作为 event_time 发出;留空则回退到当前时刻。
	//	百度   转化类型为 [BaiduConvAddToCart](46)时**必填**,未给会在上报前报错;
	//	       其余类型给了就一并发出,留空则不发该字段。
	//
	// 补数据、重试、批处理这类延迟上报的场景务必给准:拿上报时刻当转化时间,
	// 媒体会把这条转化归到错误的时间点上。
	//
	// 补得太晚则整条会被拒:百度的 9018005([BaiduErrInvalidConvertTime])是
	// 「convertTime 无效**或超过有效上报时间**」。官方未公布该时效窗口的长度,
	// 本包不按猜测的窗口在本地拦截——发出去由媒体判定,补数据的时限请按自己与媒体的约定把握。
	ConvertTime time.Time
	// Event 是本次上报的转化类型,可选。**仅 360 读取**,百度请用 [ClickConversion.ConversionType]。
	//
	// 留空表示未指定,沿用构造期的 [Qihoo360Config.Event]。给了就覆盖它——同一套凭据下
	// 按单次上报发不同转化类型,不必为此多造一个 Reporter。取值见 Qihoo360Event* 常量;
	// 本包不设白名单,取值以账户在 360 后台「创建 API 回传转化类型」时披露的为准。
	//
	// 它同时决定 [ClickConversion.TransID] 留空时的派生结果:trans_id 按 (ClickID, 生效事件)
	// 确定性生成,换事件即换去重键,360 视为两笔不同的转化各计一次。这正是同一点击 ID
	// 回传多种转化类型时要的语义;要自己控去重键就显式给 TransID。
	Event string
	// ConversionType 是本次上报的转化类型编码,可选。**仅百度读取**,360 请用 [ClickConversion.Event]。
	//
	// 0 表示未指定,沿用构造期的 [BaiduConfig.ConversionType];正数覆盖它;负数在上报前报错
	// (构造期对该字段的约束是「必须为正」,per-call 是另一条入口,同样受这条约束)。
	// 取值见 BaiduConv* 常量。
	//
	// 生效编码为 [BaiduConvAddToCart](46)时 [ClickConversion.ConvertTime] 必填——判定按
	// **生效后**的编码走,不看构造期配的那个。
	ConversionType int
}

ClickConversion 是一次按点击 ID 归因的转化上报。

type Config

type Config struct {
	// Media 是媒体标识,必填,取值见 Media* 常量。
	Media string `yaml:"media" json:"media"`
	// Qihoo360 在 Media 为 [MediaQihoo360] 时必填。
	Qihoo360 *Qihoo360Config `yaml:"qihoo360" json:"qihoo360,omitempty"`
	// Baidu 在 Media 为 [MediaBaidu] 时必填。
	Baidu *BaiduConfig `yaml:"baidu" json:"baidu,omitempty"`
}

Config 是构造 Reporter 的统一入参:Media 选协议,同名子块提供该媒体的账户凭据。

带 yaml/json tag,可直接内联进调用方自己的配置结构:

type Target struct {
    Name         string `yaml:"name"`
    ocpcx.Config `yaml:",inline"`
}

每个媒体一个具名子块而非 map[string]any:调用方启用 YAML 严格解析 (yaml.Decoder.KnownFields(true))时,拼错的键会在启动期报错而非静默失效。

func (Config) Validate

func (c Config) Validate() error

Validate 校验媒体标识与对应凭据的完整性、格式,不发起任何网络调用。

供调用方在**启动期**调用,把配置错误挡在服务对外提供能力之前。返回的错误只描述 「哪里不合法」,不含调用方的配置路径——调用方应包装上自己的位置信息:

if err := t.Config.Validate(); err != nil {
    return fmt.Errorf("targets[%d](%s): %w", i, t.Name, err)
}

错误可用 errors.Is 匹配 ErrUnknownMediaErrInvalidConfig

Example

未知媒体与凭据缺失都在构造期暴露,可用 errors.Is 判定。

package main

import (
	"errors"
	"fmt"

	"github.com/gtkit/ocpcx"
)

func main() {
	err := ocpcx.Config{Media: "toutiao"}.Validate()
	fmt.Println(errors.Is(err, ocpcx.ErrUnknownMedia))

	err = ocpcx.Config{Media: ocpcx.MediaBaidu}.Validate()
	fmt.Println(errors.Is(err, ocpcx.ErrInvalidConfig))
}
Output:
true
true

type ConversionEvent

type ConversionEvent struct {
	// OAID 是设备标识符(明文)。
	//
	// 华为可选:协议里设备 ID 非必选,广告转化回传按 [ConversionEvent.Callback] 归因;
	// 给了就以 device_id + id_type=10(OAID 明文)发出。
	//
	// 百度应用在 [ConversionEvent.JoinType] 留空时读它(按 oaid 维度归因);
	// JoinType 非空时改读 [ConversionEvent.JoinValue],本字段被忽略。
	OAID string
	// JoinType 是归因维度,标明这条转化实际靠哪个标识匹配成功,取值见 Join* 常量。**仅百度应用读取**。
	//
	// 留空时按 [JoinOAID] 处理并取 [ConversionEvent.OAID] 的值——既有调用方无需改动。
	// 非空时标识值取 [ConversionEvent.JoinValue],未给出会在上报前报错。
	//
	// iOS 设备没有 OAID,归因走 [JoinIDFA] 或 [JoinCAID];开通了 BD_VID 注入的账户用 [JoinBDVID]。
	JoinType JoinType
	// JoinValue 是 [ConversionEvent.JoinType] 所声明维度对应的标识值,JoinType 非空时必填。
	//
	// 发出时的参数名即维度名:JoinType 为 [JoinIDFA] 时发 idfa=<JoinValue>,
	// 为 [JoinBDVID] 时发 bd_vid=<JoinValue>。
	JoinValue string
	// Callback 是媒体在点击时下发的回调串,两家媒体都必填,作为普通参数值原样携带。
	Callback string
	// Type 是转化事件类型,必填。各媒体的支持范围见 [AppReporter.Report] 的说明。
	Type ConversionType
	// ConvertTime 是转化事件**实际发生**的时间(不是上报时刻),必填。
	//
	// 与点击归因侧的 [ClickConversion.ConvertTime] 同名同义。补数据、重试这类延迟上报的场景
	// 务必给准:拿上报时刻当转化时间,媒体会把这条转化归到错误的时间点上。
	//
	//	华为      conversion_time(秒)
	//	百度应用   a_time(秒)
	//
	// 百度对 a_time 的约束见官方错误码 110:须为 10 位秒级时间戳,且不得是未来时间。
	// 本包不在本地拦截未来时间——那要拿本机时钟与媒体服务器比较,本机略快就会误拒合法上报,
	// 由媒体判定。
	ConvertTime time.Time
	// Count 是转化数量。
	//
	// Deprecated: 它对应的 conversion_count 已被华为协议删除(v1.1.4, 2024-02-23),
	// 而华为是唯一用过该字段的媒体。向华为上报时设置为正数会**报错**——静默不发会让
	// 调用方以为它还在生效,而字段失效从外面看不出来。转化数量请按条上报。
	Count int
	// ContentID 是素材 ID,可选(仅华为上报)。
	ContentID string
	// CampaignID 是计划 ID,可选(仅华为上报)。
	CampaignID string
	// UserAgent 是用户设备的 User-Agent。
	//
	// Deprecated: 它对应的 user_agent 已被华为协议删除(v2.1.3, 2025-06-20),
	// 而华为是唯一用过该字段的媒体。向华为上报时设置为非空会**报错**,原因同 [ConversionEvent.Count]。
	UserAgent string
}

ConversionEvent 是一次应用推广转化事件。

与点击 ID 归因只需一个字符串不同,应用推广按「设备 + 媒体下发的回调串」归因, 因此 AppReporter 收的是本结构体而不是一个字符串。

type ConversionReporter

type ConversionReporter interface {
	Reporter
	// ReportConversion 上报一条转化,语义与 [Reporter.Report] 一致,
	// 区别在于可以携带 [ClickConversion] 的可选字段。
	ReportConversion(ctx context.Context, c ClickConversion) error
}

ConversionReporter 是 Reporter 的可选扩展:上报一条可携带额外信息的转化。

Reporter.Report 只收一个点击 ID,够用于最常见的即时上报;需要指定去重键或转化发生时间时 用本接口。360 与百度都实现了它,各取 ClickConversion 里对自己有意义的字段:

360    TransID(去重键)、ConvertTime(event_time)
百度   ConvertTime(convertTime,转化类型 46 时必填)

按配置构造 Reporter 的调用方用类型断言取用:

if cr, ok := r.(ocpcx.ConversionReporter); ok {
    err = cr.ReportConversion(ctx, ocpcx.ClickConversion{ClickID: id, ConvertTime: convertedAt})
} else {
    err = r.Report(ctx, id)
}
Example

携带去重键与转化发生时间:Report 只收点击 ID,这些要经 ConversionReporter 给。

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/gtkit/ocpcx"
)

func main() {
	r, err := ocpcx.NewQihoo360(ocpcx.Qihoo360Config{AppKey: "k", AppSecret: "s"})
	if err != nil {
		return
	}

	convertedAt := time.Now().Add(-2 * time.Hour) // 两小时前发生的转化,现在补报
	if cr, ok := r.(ocpcx.ConversionReporter); ok {
		err = cr.ReportConversion(context.Background(), ocpcx.ClickConversion{
			ClickID: "qhclickid-1",
			// 留空时本包按 (点击 ID, 转化事件) 确定性派生;按订单号去重就在这里给。
			TransID: "order-42",
			// 不给的话 360 会把这条转化记在上报时刻,而不是它真正发生的时刻。
			ConvertTime: convertedAt,
		})
	} else {
		err = r.Report(context.Background(), "qhclickid-1")
	}
	if err != nil {
		fmt.Println("回传失败")
		return
	}
	fmt.Println("回传成功")
}

type ConversionType

type ConversionType string

ConversionType 是归一化的应用转化事件类型。

各媒体的实际取值不同(如百度的次留是 retain_1day),由各自实现翻译; 调用方只面对这三个常量。

const (
	// ConversionActivate 是激活。
	ConversionActivate ConversionType = "activate"
	// ConversionRegister 是注册。
	ConversionRegister ConversionType = "register"
	// ConversionRetain 是次日留存。
	ConversionRetain ConversionType = "retain"
)

type HuaweiConfig

type HuaweiConfig struct {
	// Secret 是 HMAC-SHA256 的密钥,参与 Authorization 头计算,必填。不会出现在任何错误信息里。
	Secret string `yaml:"secret" json:"secret"`
}

HuaweiConfig 是华为账户凭据。

type JoinType added in v1.2.0

type JoinType string

JoinType 是转化数据的归因维度,标识该条转化**实际**靠哪个标识匹配成功 (百度应用的 join_type,官方标注必填)。

归因由调用方完成,本包不推断:调用方按自己的优先级(如 OAID > IMEI > IP+UA)匹配, 同时持有多个标识却只有一个命中是常态。若由本包按「哪个字段有值」去猜, 报错维度会让媒体侧的归因统计失真——而请求照常返回成功,回传侧看不出任何异常。

const (
	JoinIMEI      JoinType = "imei"       // 通过 imei_md5 归因成功
	JoinOAID      JoinType = "oaid"       // 通过 oaid 或 oaid_md5 归因成功
	JoinAndroidID JoinType = "android_id" // 通过 android_id 或 android_id_md5 归因成功
	JoinIP        JoinType = "ip"         // 通过 ip 和系统信息(ua、os_version、model)归因成功
	JoinIDFA      JoinType = "idfa"       // 通过 idfa 归因成功
	JoinCAID      JoinType = "caid"       // 通过 caid 归因成功
	JoinMAC       JoinType = "mac"        // 通过 mac 或 mac_md5 归因成功
	JoinPAID      JoinType = "paid"       // 通过 paid 归因成功(官方标注灰度)
	JoinBTUT      JoinType = "bt_ut"      // 通过 bt+ut 归因成功(官方标注灰度)
	// JoinBDVID 通过注入 APK V2 签名块的广告 ID(bd_vid)归因成功,
	// 见官方《Android BD_VID 注入归因增强方案》。需先申请开通该能力。
	JoinBDVID JoinType = "bd_vid"
)

百度应用的归因维度取值,照官方 callback_url 参数表收录。

常量只是给取值一个名字,**不构成校验**:ConversionEvent.JoinType 接受表外的任何取值, 原样发出(媒体不认识时以 error_code 105 拒绝,可由 APIError.Code 直接看出)。 百度仍在扩充维度(paid、bt_ut 目前标注为灰度),白名单会让本包成为调用方的瓶颈。

type Option

type Option func(*options)

Option 配置 Reporter 的构造。

func WithHTTPClient

func WithHTTPClient(c *httpc.Client) Option

WithHTTPClient 注入调用方自己的 HTTP 客户端。

构造多个 Reporter 时应注入同一个客户端:连接池挂在客户端底层的 transport 上, 各自新建会让 keep-alive 复用失效。注入后 WithTimeout 不再生效——超时由注入的 客户端自己持有;响应体上限同理,由注入的客户端配置决定(httpc 缺省 10 MiB, 本包缺省客户端设的是 64 KiB)。

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout 设置单次媒体调用的超时,缺省 DefaultTimeout。 仅在未使用 WithHTTPClient 时生效。

type Qihoo360Config

type Qihoo360Config struct {
	// AppKey 放进请求头 App-Key,必填。
	AppKey string `yaml:"app_key" json:"app_key"`
	// AppSecret 参与 App-Sign 计算,必填。不会出现在任何错误信息里。
	AppSecret string `yaml:"app_secret" json:"app_secret"`
	// DataIndustry 是产品线标识 data_industry,可选,留空取 [Qihoo360IndustryPCSearch]。
	//
	// 必须与账户实际投放的产品线一致:360 明确要求不同产品线的参数不能混用,
	// 传错会被当作无效数据丢弃,而丢弃是静默的。
	DataIndustry string `yaml:"data_industry" json:"data_industry,omitempty"`
	// Event 是转化类型,可选,留空取 [Qihoo360EventOrder]。取值见 qihoo360_convtype.go 里的
	// Qihoo360Event* 全表,须与账户在 360 后台「创建 API 回传转化类型」时选定的类型一致:
	// 取值随媒体侧持续新增,故本包不设白名单。
	Event string `yaml:"event" json:"event,omitempty"`
}

Qihoo360Config 是 360 账户凭据与投放参数。

type Reporter

type Reporter interface {
	// Media 返回媒体标识,取值见 Media* 常量。供调用方记录日志用。
	Media() string
	// Report 上报一条转化;返回 nil 即媒体侧确认接收。
	//
	// 媒体明确拒绝时返回 *APIError(重试无意义);网络与超时类故障返回其他错误。
	// 本方法不重试:只有调用方知道自己的幂等性与退避节奏。
	Report(ctx context.Context, clickID string) error
}

Reporter 是「向某个媒体账户上报一条转化」的能力。 账户凭据在构造期绑定,实现构造后只读,可并发使用。

func New

func New(cfg Config, opts ...Option) (Reporter, error)

New 按 cfg.Media 构造对应媒体的 Reporter,构造前先执行 Config.Validate

配置驱动的调用方用它;不需要按配置选媒体时直接用 NewQihoo360 / NewBaidu ——两条路径共用同一份协议实现。

Example

配置驱动:按 Config.Media 选媒体,多个 Reporter 共享同一个 HTTP 客户端。

package main

import (
	"fmt"
	"time"

	"github.com/gtkit/httpc"

	"github.com/gtkit/ocpcx"
)

func main() {
	shared := httpc.New(httpc.WithTimeout(10 * time.Second))

	configs := []ocpcx.Config{
		{Media: ocpcx.MediaQihoo360, Qihoo360: &ocpcx.Qihoo360Config{AppKey: "k", AppSecret: "s"}},
		{Media: ocpcx.MediaBaidu, Baidu: &ocpcx.BaiduConfig{
			Token: "t", LandingURL: "https://example.com/", ConversionType: ocpcx.BaiduConvFormSubmit,
		}},
	}

	for i, cfg := range configs {
		// 启动期先校验,把配置错误挡在对外服务之前。
		if err := cfg.Validate(); err != nil {
			fmt.Printf("targets[%d]: %v\n", i, err)
			return
		}
		r, err := ocpcx.New(cfg, ocpcx.WithHTTPClient(shared))
		if err != nil {
			fmt.Printf("targets[%d]: %v\n", i, err)
			return
		}
		fmt.Println(r.Media())
	}
}
Output:
qihoo360
baidu

func NewBaidu

func NewBaidu(cfg BaiduConfig, opts ...Option) (Reporter, error)

NewBaidu 构造百度转化回传 Reporter。凭据/参数不合法返回 ErrInvalidConfig 类错误。

Example

直接构造某一家媒体的 Reporter。

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/gtkit/ocpcx"
)

func main() {
	r, err := ocpcx.NewBaidu(ocpcx.BaiduConfig{
		Token:          "your-token",
		LandingURL:     "https://example.com/",
		ConversionType: ocpcx.BaiduConvFormSubmit,
	}, ocpcx.WithTimeout(5*time.Second))
	if err != nil {
		fmt.Println("构造失败:", err)
		return
	}

	// 点击 ID 来自落地页回传的 bd_vid。
	if err := r.Report(context.Background(), "bd_vid_from_landing_page"); err != nil {
		fmt.Println("回传失败:", err)
		return
	}
	fmt.Println(r.Media(), "回传成功")
}

func NewQihoo360

func NewQihoo360(cfg Qihoo360Config, opts ...Option) (Reporter, error)

NewQihoo360 构造 360 转化回传 Reporter。凭据不合法返回 ErrInvalidConfig 类错误。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL