本地生活 CPS 聚合插件(UTS):一套代码,全端返利应用开箱即用

70 阅读8分钟

uni-app x · UTS · 插件市场 · CPS 聚合

做返利/导购类应用的同学应该都有同感:想覆盖美团外卖、饿了么、到店团购、酒店、电影、打车这些本地生活场景,就得逐个去对接各家 CPS 平台。每家平台的接口地址不一样、鉴权方式不一样、字段命名不一样、返回结构更是五花八门。等项目跑起来,光维护这些 SDK 就够喝一壶的。

于是我封装了一个 uni-app x 的 UTS 插件 ls-cps:一套统一的 API,背后用适配器模式隔离各 CPS 平台的差异,目前内置 聚推客 平台适配,跑通了活动列表、转链、订单全链路,并且已经用 HBuilderX 的 Android 编译 + 真实接口联调验证通过。今天把这篇文章写下来,既是分享,也是记录过程中踩的几个坑。

为什么用 UTS 插件

  • 一套代码,全端编译:UTS 是 uni-app x 的强类型语言,可同时编译到 Android / iOS / 小程序(微信、支付宝等)/ H5,不需要为每个端各写一套。
  • 类型安全:相比 JS 的 Any 满天飞,UTS 在编译期就能抓出字段拼写错误,重构成本低。
  • 插件市场分发:打成 uni_modules 插件后,用户复制目录即可使用,接入成本极低。

插件能力一览

方法说明
LocalLife.init(config)初始化并选择 CPS 平台
LocalLife.getHotList(categoryId, page, pageSize)获取推广活动列表
LocalLife.getCategoryTree()获取类目树
LocalLife.createLink(productId, linkType)生成推广链接(H5 / 小程序码等)
LocalLife.getOrderList(status, page, pageSize)查询订单与佣金
LocalLife.search(keyword, categoryId, page, pageSize)商品搜索
LocalLife.getBalance()账户余额

说明:search / getBalance 是通用接口预留,部分 CPS 平台(如聚推客的联盟活动模式)不提供商品搜索与余额查询,此时会抛出明确的业务提示,业务层可据此优雅降级。

支持的 uni-app 版本与前端平台兼容性

uni-app 版本要求

  • 插件基于 uni-app x(UTS)开发。uni-app x 项目中使用 UTS 调用 UTS 插件自 HBuilderX 3.9 起支持,正式环境建议使用 HBuilderX 4.0 及以上版本(推荐最新稳定版,当前已在 HBuilderX 5.24 上编译验证通过)。
  • 请注意:uni-app x 与经典 uni-app(vue)项目引擎不同。本插件为 UTS 插件,主要面向 uni-app x 项目开发与验证;经典 uni-app(vue2/vue3)项目理论上可通过 UTS 插件机制引用(HBuilderX 3.6.8+),但本插件未在经典 uni-app 项目做完整验证,如需使用请先自测。

前端平台兼容性

前端平台支持情况说明
App-Android✅ 已验证HBuilderX 编译 + 真机联调通过
App-iOS✅ 声明支持仅依赖 uni.request 与标准全局 API,建议真机自测
H5✅ 声明支持注意浏览器跨域(CORS),生产建议服务端中转
微信小程序 mp-weixin✅ 声明支持需配置 request 合法域名,建议 https
支付宝小程序 mp-alipay✅ 声明支持同上
百度小程序 mp-baidu✅ 声明支持同上
抖音小程序 mp-toutiao✅ 声明支持同上

package.json 的 uni_modules.platforms 已声明以上全部平台,插件本身不包含任何平台私有 API,因此具备跨端编译能力;未逐一真机验证的平台,建议按上表做一次冒烟测试。

兼容性注意事项

  • 插件只依赖 uni.request 与标准全局对象(Date / Map / Promise / encodeURIComponent),这些能力在所有目标端均可用,不需要额外安装依赖。
  • 网络域名:聚推客官方接口为 http://api.jutuike.com(明文 http),需要注意:
    • App-Android:Android 7.0(API 24)起系统默认只信任系统证书、限制明文 http(Network Security Configuration),若真机联调请求失败,请确认明文流量配置;App-Android 最低支持 Android 5.0(API 21)。
    • 小程序端:必须在对应平台后台把接口域名加入 request 合法域名白名单,且微信/支付宝普遍要求 https,建议接入 https 中转服务。
    • H5 端:存在浏览器跨域(CORS)问题,生产环境建议走服务端中转。
  • 想省心的话,最稳妥的用法是把请求都转发到自己的服务端(顺便解决密钥不落地的问题),插件里把 baseUrl 指向你的中转网关即可。

快速上手

把 uni_modules/ls-cps 目录复制进你的 uni-app x 项目,然后:

import { LocalLife } from '@/uni_modules/ls-cps'

// 1. 初始化(以聚推客为例)
LocalLife.init({
  platform: 'jutuihe',
  apiKey: '你的聚推客apikey',   // 单密钥鉴权
  sid: '你的跟单标识',          // 生成推广链接时必传的自定义跟单参数
  baseUrl: ''                  // 留空使用插件内置地址
})

// 2. 获取类目(外卖 / 团购 / 打车 / 酒店 / 电影 ...)
const cats = await LocalLife.getCategoryTree()
console.log('分类数:', cats.data.length)

// 3. 拉取某分类下的推广活动
const hot = await LocalLife.getHotList('美团', 1, 20)
const act = hot.data.list[0]
console.log('活动:', act.id, act.title)

// 4. 生成推广链接(返回 H5 短链 / 长链 / 小程序路径 / 小程序码)
const link = await LocalLife.createLink(act.id, 'h5')
console.log('H5 短链:', link.data.url)
console.log('小程序:', link.data.extra.get('we_app_id'))

// 5. 查询订单与佣金(插件自动按平台规则补全时间窗口)
const orders = await LocalLife.getOrderList('', 1, 20)
console.log('订单数:', orders.data.total)

所有方法统一返回 Promise<CpsResult<T>>,业务层只需要认一套返回协议:

interface CpsResult<T> {
  code: number          // 0 表示成功
  msg: string           // 提示信息
  data: T | null        // 业务数据
}

统一数据模型

不管底层是哪家平台,上层拿到的都是统一模型,列表页、详情页、订单页的代码可以完全复用:

  • CpsProduct:id / title / price / commission / image / platform / extra
  • CpsLink:url / shortUrl / posterImage / expireTime / extra
  • CpsOrder:orderId / productTitle / status / statusText / commission / extra
  • CpsPageResult<T>:list / page / pageSize / total / hasMore

各平台特有字段统一放进 extra: Map<string, any>,既不丢失信息,也不污染通用模型。

架构设计:适配器模式隔离平台差异

┌────────────┐   ┌──────────────────────────────┐
│  LocalLife │   │  ProviderFactory             │
│  (门面)    │──▶│  register('jutuihe', ...)     │
└────────────┘   └──────────────┬───────────────┘
                                ▼
                   ┌───────────────────────────┐
                   │  ICpsProvider (接口)      │
                   │  search / getHotList /    │
                   │  createLink / getOrder... │
                   └────────────┬──────────────┘
                    ┌───────────┴───────────┐
                    ▼                       ▼
         ┌─────────────────┐     ┌─────────────────┐
         │ JutuiheProvider │     │  XxxProvider    │
         │  (聚推客)        │     │  (其他平台,预留)  │
         └─────────────────┘     └─────────────────┘
  • index.uts:对外门面 LocalLife,只暴露统一 API。
  • providers/ICpsProvider.uts:适配器接口 + ProviderConfig,定义统一契约。
  • providers/jutuihe/:聚推客具体实现。
    • JutuiheProvider.uts:协议层,负责参数组装、请求、字段映射。
    • jutuihe.config.uts:纯配置——接口地址、请求方式、成功码、分页字段、字段映射全部收敛在这里。平台接口变了,改配置即可,不动业务代码。
  • common/:http 封装、JSON 工具、签名器(md5)等基础设施。

想接入新平台?三步搞定:

  1. 新建 providers/<platform>/ 目录,实现 ICpsProvider 全部方法;
  2. 把该平台的接口地址 / 字段映射写进自己的 config;
  3. 在 LocalLife.init() 里 ProviderFactory.register('平台名', () => new XxxProvider()),然后 platform 传对应名字即可切换。

业务层代码一行不用改。

实战踩坑记录

联调真实接口时踩了几个很有意思的坑,都很有代表性,记录一下:

坑 1:CPS 平台的"单密钥"鉴权,没有签名

市面上很多电商 CPS 是 appKey + appSecret 双密钥 + MD5 签名。聚推客这类本地生活联盟却只有一把 apikey,不需要签名。如果硬套"双密钥 + 签名"模型,永远鉴权不过。

所以 ProviderConfig 设计成双模式兼容:保留 appKey/appSecret/pid(双密钥平台用),新增可选的 apiKey/sid/pubId(单密钥平台用)。聚推客实现里直接取 apiKey 作为 apikey 参数,跳过签名逻辑。

坑 2:uni-app x 的 uni.request,GET 请求别用 Map 当 data

App 端第一次跑,getHotList 直接返回 平台返回错误: 0 非法访问。但同样的 apikey 我用 curl 直连 http://api.jutuike.com/union/act_list?apikey=... 却正常返回数据。

原因:uni-app x 中 uni.request 对 GET 请求并不会把 Map<string,string> 类型的 data 正确拼进 URL query,服务端收不到 apikey 就判定"非法访问"。

修复方案:手动把参数 encodeURIComponent 后拼进 URL,再发起请求:

let url = base + apiPath
let first = true
for (const k of params.keys()) {
  url += (first ? '?' : '&')
  first = false
  url += k + '=' + encodeURIComponent(params.get(k)!)
}

坑 3:订单接口的 code:0 不一定是错误

聚推客订单接口,没有订单数据时返回的是 {"code":0, "msg":"暂无相应的订单数据。", "data":[]}——这在语义上其实是一次"成功但空结果"。

如果沿用统一的 code === successCode 判断,就会把"没订单"误判成平台报错。

解决:为订单解析增加 allowEmpty 模式——只要返回了 data(哪怕是空数组),就视为正常结果,不再抛异常:

private extractData(raw: any, allowEmpty = false): any {
  const result = this.toResult(raw)
  if (!this.isSuccess(result.code)) {
    if (!(allowEmpty && result.data != null)) {
      throw new Error('平台返回错误: ' + result.code + ' ' + result.msg)
    }
  }
  return result.data
}

坑 4:文档示例的字段位置,和真实返回不一致

聚推客转链接口文档示例里 miniCode 在顶层,但饿了么活动的真实返回里,miniCode 藏在 we_app_info 里面。文档给的"标准答案"和线上真实数据有出入。

处理方式:解析时做双层兼容——顶层取不到就从 we_app_info 里取,取不到才给空串:

const topMini = JsonUtil.getStr(raw, 'miniCode')
const innerMini = JsonUtil.getStr(weApp, 'miniCode')
const miniCode = topMini.length > 0 ? topMini : innerMini

教训:对接 CPS 平台,别全信文档,一定要用真实接口联调一遍。

结语

这个插件目前作为第一个平台的样板实现,走通了"统一门面 → 适配器 → 配置化 → 真实接口联调"的完整链路,并且已经通过 HBuilderX 的 Android 编译验证。后续扩展其他 CPS 平台(高佣联盟、好省、美团联盟等),只需要在适配器层补一个 Provider 就行,业务层完全无感。

如果你也在做本地生活 / 返利类应用,或者对 uni-app x 的 UTS 插件封装感兴趣,欢迎一起交流。