25+广告渠道如何统一管理?——从屎山代码到优雅架构的演进之路

这是广告归因系列的第4篇。前面我们讲了点击追踪、激活归因,现在来解决一个更底层的问题:当你的游戏接入了二十多个广告渠道,代码要怎么写才不会变成一坨屎山?


一、多渠道管理的挑战

假设你的游戏要上线了,运营说要接这些渠道:

每家都有自己的:

1.1 最Naive的写法

最naive的写法是什么?if-else大法:

// 三个月后会让新同事辞职的代码
public function reportConversion($channel, $event) {
    if ($channel === 'bytedance') {
        $params = [
            'event_type' => 'activate',
            'clickid' => $event['click_id'],
            'conv_time' => $event['timestamp'] * 1000, // 转毫秒
        ];
        $sign = md5($this->bytedanceSecret . http_build_query($params));
        $params['signature'] = $sign;
        $response = $this->httpPost('https://api.oceanengine.com/...', $params);
        
    } elseif ($channel === 'tencent') {
        $params = [
            'click_id' => $event['click_id'],
            'action_type' => 'ACTIVATE_APP',
            'action_time' => $event['timestamp'],
        ];
        $sign = hash_hmac('sha256', json_encode($params), $this->tencentSecret);
        $headers = ['Authorization' => 'Bearer ' . $this->tencentToken];
        $response = $this->httpPost('https://api.e.qq.com/...', $params, $headers);
        
    } elseif ($channel === 'kuaishou') {
        // 快手的逻辑...又是另一套
    } elseif ($channel === 'facebook') {
        // Facebook的逻辑...完全不同的API风格
    }
    // ... 20多个分支,每个分支100-200行
}

三个月后,新同事打开这个文件,看到2000行的if-else,直接辞职。

1.2 问题会持续恶化

这还不是最糟的。随着业务发展,你会遇到:

某天,巨量引擎宣布API v2废弃,必须升级到v3。
你打开代码,发现v2的调用散落在7个地方...
改完一处,漏了另一处,上线后渠道报警。
公司上线了第二款游戏,也要接广告渠道。
你复制了第一款的代码,改了改配置。
现在两套代码各自演进,bug修两遍,功能加两遍。
新同事问:"我要接入小红书,怎么搞?"
你说:"先看bytedance的分支,参考着写,注意别影响其他渠道..."
新人一脸懵逼,写出来的代码bug一堆。

1.3 问题的本质

透过现象看本质,这些问题的根源是:

问题 本质
扩展困难 每加一个渠道,就要改核心代码(违反开闭原则)
耦合严重 业务逻辑和渠道细节混在一起(高耦合低内聚)
测试噩梦 改一处可能影响其他渠道(缺乏隔离)
维护成本高 渠道API升级,要在代码里到处找(职责分散)
复用困难 代码和具体游戏绑定(缺乏抽象层)

我们需要一个架构,让加渠道像插U盘一样简单——即插即用,热插拔,不影响系统稳定性。


二、架构设计:统一抽象层

2.1 什么是抽象层?

抽象层的本质是:定义一套统一的语言,让上游不用关心下游的差异。

用现实世界的例子:

USB的出现,定义了一个统一的抽象层:

在我们的场景里,抽象层要回答这些问题:

问题 答案
渠道有哪些类型? 信息流、搜索、社交、视频、应用商店……
每个渠道需要什么配置? 账号ID、密钥、回调地址、特殊参数……
渠道能做什么操作? 生成追踪链接、上报转化、拉取报表、接收回调……
数据用什么格式交互? 统一的Click、Conversion、Report模型

2.2 核心抽象接口设计

我们需要定义一组接口,所有渠道都要实现。这是整个架构的基石:

<?php
/**
 * 广告渠道统一接口
 * 所有渠道适配器必须实现此接口
 */
interface AdChannelInterface {
    
    /**
     * 获取渠道唯一标识
     * @return string 如 'bytedance', 'tencent', 'kuaishou'
     */
    public function getChannelId(): string;
    
    /**
     * 获取渠道显示名称
     * @return string 如 '巨量引擎', '腾讯广告', '快手广告'
     */
    public function getChannelName(): string;
    
    /**
     * 获取渠道类型
     * @return ChannelType enum: INFORMATION_FLOW, SEARCH, SOCIAL, VIDEO, APP_STORE
     */
    public function getChannelType(): ChannelType;
    
    /**
     * 生成追踪链接
     * @param TrackingUrlParams $params 追踪参数
     * @return string 完整的追踪链接
     */
    public function generateTrackingUrl(TrackingUrlParams $params): string;
    
    /**
     * 解析回调数据
     * @param array $rawData 渠道原始回调数据
     * @return StandardClickEvent 标准化的点击事件
     */
    public function parseCallback(array $rawData): StandardClickEvent;
    
    /**
     * 验证回调签名
     * @param array $rawData 渠道原始回调数据
     * @return bool 签名是否有效
     */
    public function verifyCallback(array $rawData): bool;
    
    /**
     * 上报转化事件
     * @param StandardConversionEvent $event 标准转化事件
     * @return ReportResult 上报结果
     */
    public function reportConversion(StandardConversionEvent $event): ReportResult;
    
    /**
     * 批量上报转化事件
     * @param array $events 标准转化事件数组
     * @return BatchReportResult 批量上报结果
     */
    public function reportConversionBatch(array $events): BatchReportResult;
    
    /**
     * 拉取投放数据报表
     * @param ReportQuery $query 查询条件
     * @return ReportData 报表数据
     */
    public function fetchReport(ReportQuery $query): ReportData;
    
    /**
     * 获取渠道配置Schema
     * 用于生成配置表单、验证配置
     * @return ConfigSchema
     */
    public function getConfigSchema(): ConfigSchema;
    
    /**
     * 健康检查
     * 检查渠道连接是否正常
     * @return HealthStatus
     */
    public function healthCheck(): HealthStatus;
}

这套接口就是契约(Contract)。不管你是什么渠道,想接入我们的系统,就得遵守这个契约。

2.3 抽象层的价值

有了抽象层后,一切都不一样了:

// 业务层代码,完全不需要知道具体是哪个渠道
class ConversionService {
    public function reportUserActivation(string $channelId, User $user): void {
        $event = $this->buildActivationEvent($user);
        $channel = $this->channelFactory->create($channelId);
        $result = $channel->reportConversion($event);
        
        if (!$result->isSuccess()) {
            $this->logger->warning("转化上报失败", [
                'channel' => $channelId,
                'error' => $result->getErrorMessage()
            ]);
        }
    }
}

三、渠道适配器模式详解

3.1 为什么用适配器模式?

适配器模式的核心思想:把不兼容的接口转换成兼容的接口。

每个广告渠道都有自己的API设计,差异巨大:

维度 巨量引擎 腾讯广告 Facebook
API风格 REST REST GraphQL
认证方式 AK/SK签名 OAuth2 + HMAC OAuth2
点击ID字段 clickid request_id fbclid
时间格式 毫秒时间戳 秒时间戳 Unix时间戳
金额单位 美分
回传方式 同步HTTP 同步HTTP 异步Webhook

适配器的职责就是:屏蔽这些差异,对外提供统一接口。

用图来表示:

┌─────────────────────────────────────────────────────────┐
│                     业务层(调用方)                      │
│                                                         │
│   $channel->reportConversion($event);                   │
│   // 不需要知道是哪个渠道,接口都一样                      │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│              统一抽象层(AdChannelInterface)             │
└─────────────────────────────────────────────────────────┘
                           │
        ┌──────────────────┼──────────────────┐
        ▼                  ▼                  ▼
┌───────────────┐  ┌───────────────┐  ┌───────────────┐
│ ByteDance     │  │ Tencent       │  │ Facebook      │
│ Adapter       │  │ Adapter       │  │ Adapter       │
│               │  │               │  │               │
│ - 签名算法A   │  │ - 签名算法B   │  │ - OAuth流程   │
│ - 字段映射A   │  │ - 字段映射B   │  │ - 字段映射C   │
│ - 错误码转换A │  │ - 错误码转换B │  │ - 错误码转换C │
└───────────────┘  └───────────────┘  └───────────────┘
        │                  │                  │
        ▼                  ▼                  ▼
   巨量引擎API         腾讯广告API        Facebook API

3.2 适配器的完整实现

让我们看一个完整的适配器实现示例:

<?php
/**
 * 巨量引擎(抖音/今日头条)适配器
 */
class ByteDanceAdapter implements AdChannelInterface {
    
    private array $config;
    private HttpClientInterface $httpClient;
    private LoggerInterface $logger;
    
    // API端点
    private const API_BASE = 'https://api.oceanengine.com/open_api/v3.0';
    private const ENDPOINTS = [
        'conversion' => '/event/convert/',
        'report' => '/report/ad/get/',
    ];
    
    public function __construct(
        array $config,
        HttpClientInterface $httpClient,
        LoggerInterface $logger
    ) {
        $this->validateConfig($config);
        $this->config = $config;
        $this->httpClient = $httpClient;
        $this->logger = $logger;
    }
    
    // ========== 实现接口方法 ==========
    
    public function getChannelId(): string {
        return 'bytedance';
    }
    
    public function getChannelName(): string {
        return '巨量引擎';
    }
    
    public function getChannelType(): ChannelType {
        return ChannelType::INFORMATION_FLOW;
    }
    
    public function generateTrackingUrl(TrackingUrlParams $params): string {
        // 巨量引擎的追踪链接格式
        $baseUrl = $this->config['callback_url'] ?? 'https://ad.example.com/callback';
        
        $queryParams = [
            'channel' => $this->getChannelId(),
            '__aid__' => $params->getCampaignId(),      // 计划ID
            '__cid__' => $params->getCreativeId(),      // 创意ID
            '__os__' => $params->getOs(),               // 操作系统
            // 巨量引擎会自动追加 clickid 参数
        ];
        
        return $baseUrl . '?' . http_build_query($queryParams);
    }
    
    public function parseCallback(array $rawData): StandardClickEvent {
        // 解析巨量引擎的回调数据
        $this->logger->debug('解析巨量引擎回调', ['data' => $rawData]);
        
        return (new StandardClickEvent())
            ->setClickId($rawData['clickid'] ?? '')
            ->setChannelId($this->getChannelId())
            ->setTimestamp((int)($rawData['ts'] ?? 0) * 1000) // 秒转毫秒
            ->setCampaignId($rawData['campaign_id'] ?? '')
            ->setAdgroupId($rawData['adgroup_id'] ?? '')
            ->setCreativeId($rawData['creative_id'] ?? '')
            ->setExtra([
                'ip' => $rawData['ip'] ?? '',
                'ua' => $rawData['ua'] ?? '',
                'os' => $rawData['os'] ?? '',
            ]);
    }
    
    public function verifyCallback(array $rawData): bool {
        // 巨量引擎的签名验证
        if (!isset($rawData['signature'])) {
            return false;
        }
        
        $expectedSignature = $rawData['signature'];
        unset($rawData['signature']);
        
        // 按巨量引擎的签名规则计算
        ksort($rawData);
        $signString = $this->config['secret_key'] . http_build_query($rawData);
        $actualSignature = md5($signString);
        
        return hash_equals($expectedSignature, $actualSignature);
    }
    
    public function reportConversion(StandardConversionEvent $event): ReportResult {
        // 转换为巨量引擎要求的格式
        $params = $this->transformConversionEvent($event);
        
        // 添加认证信息
        $params['advertiser_id'] = $this->config['advertiser_id'];
        $params['timestamp'] = time();
        
        // 生成签名
        $params['signature'] = $this->generateSignature($params);
        
        try {
            $response = $this->callApi(self::ENDPOINTS['conversion'], $params);
            
            return ReportResult::success(
                $response['request_id'] ?? '',
                $response
            );
        } catch (ChannelException $e) {
            $this->logger->error('巨量引擎转化上报失败', [
                'event' => $event->toArray(),
                'error' => $e->getMessage(),
            ]);
            
            return ReportResult::failure(
                $e->getCode(),
                $e->getMessage()
            );
        }
    }
    
    public function fetchReport(ReportQuery $query): ReportData {
        $params = [
            'advertiser_id' => $this->config['advertiser_id'],
            'start_date' => $query->getStartDate(),
            'end_date' => $query->getEndDate(),
            'fields' => implode(',', $this->getReportFields()),
            'filtering' => json_encode($this->buildFilters($query)),
        ];
        
        $params['signature'] = $this->generateSignature($params);
        
        $response = $this->callApi(self::ENDPOINTS['report'], $params);
        
        return $this->transformReportData($response['data'] ?? []);
    }
    
    public function getConfigSchema(): ConfigSchema {
        return (new ConfigSchema())
            ->addField('advertiser_id', '广告主ID', 'string', true)
            ->addField('access_token', '访问令牌', 'string', true, true) // secret
            ->addField('secret_key', '签名密钥', 'string', true, true)
            ->addField('callback_url', '回调地址', 'string', false)
            ->addField('rate_limit', 'API限流(次/秒)', 'int', false, false, 100);
    }
    
    public function healthCheck(): HealthStatus {
        try {
            // 尝试获取一个简单的接口,验证连接
            $response = $this->httpClient->get(
                self::API_BASE . '/user/info/',
                ['headers' => $this->getAuthHeaders()]
            );
            
            return HealthStatus::healthy('连接正常');
        } catch (\Exception $e) {
            return HealthStatus::unhealthy($e->getMessage());
        }
    }
    
    // ========== 私有方法 ==========
    
    /**
     * 转换标准事件为巨量引擎格式
     */
    private function transformConversionEvent(StandardConversionEvent $event): array {
        // 事件类型映射
        $eventTypeMap = [
            'activate' => 'activate',
            'register' => 'active_register',
            'purchase' => 'active_pay',
            'retention' => 'active_retention',
        ];
        
        return [
            'event_type' => $eventTypeMap[$event->getConversionType()] ?? 'custom',
            'clickid' => $event->getClickId(),
            'conv_time' => $event->getTimestamp(), // 毫秒
            'value' => $event->getValue(), // 分为单位
            'os' => $event->getOs(),
        ];
    }
    
    /**
     * 生成API签名
     */
    private function generateSignature(array $params): string {
        ksort($params);
        $signString = $this->config['secret_key'];
        foreach ($params as $key => $value) {
            $signString .= $key . $value;
        }
        return md5($signString);
    }
    
    /**
     * 调用API
     */
    private function callApi(string $endpoint, array $params): array {
        $url = self::API_BASE . $endpoint;
        
        $response = $this->httpClient->post($url, [
            'headers' => $this->getAuthHeaders(),
            'form_params' => $params,
            'timeout' => 10,
        ]);
        
        $data = json_decode($response->getBody(), true);
        
        if ($data['code'] !== 0) {
            throw new ChannelException(
                $data['message'] ?? 'Unknown error',
                $data['code']
            );
        }
        
        return $data;
    }
    
    /**
     * 获取认证Headers
     */
    private function getAuthHeaders(): array {
        return [
            'Access-Token' => $this->config['access_token'],
            'Content-Type' => 'application/json',
        ];
    }
    
    /**
     * 验证配置
     */
    private function validateConfig(array $config): void {
        $schema = $this->getConfigSchema();
        $errors = $schema->validate($config);
        
        if (!empty($errors)) {
            throw new ConfigValidationException(
                '配置验证失败: ' . implode('; ', $errors)
            );
        }
    }
}

3.3 工厂模式管理适配器创建

用工厂来管理适配器的创建和生命周期:

<?php
/**
 * 渠道适配器工厂
 */
class ChannelFactory {
    
    /** @var array<string, class-string<AdChannelInterface>> */
    private static array $registry = [];
    
    /** @var array<string, AdChannelInterface> */
    private array $instances = [];
    
    private ContainerInterface $container;
    
    public function __construct(ContainerInterface $container) {
        $this->container = $container;
    }
    
    /**
     * 注册渠道适配器
     */
    public static function register(string $channelId, string $adapterClass): void {
        if (!in_array(AdChannelInterface::class, class_implements($adapterClass))) {
            throw new \InvalidArgumentException(
                "{$adapterClass} 必须实现 AdChannelInterface"
            );
        }
        self::$registry[$channelId] = $adapterClass;
    }
    
    /**
     * 批量注册
     */
    public static function registerMany(array $adapters): void {
        foreach ($adapters as $channelId => $adapterClass) {
            self::register($channelId, $adapterClass);
        }
    }
    
    /**
     * 创建或获取适配器实例
     */
    public function create(string $channelId, ?array $config = null): AdChannelInterface {
        // 使用缓存的实例(如果配置相同)
        $cacheKey = $this->getCacheKey($channelId, $config);
        if (isset($this->instances[$cacheKey])) {
            return $this->instances[$cacheKey];
        }
        
        // 检查渠道是否已注册
        if (!isset(self::$registry[$channelId])) {
            throw new ChannelNotFoundException(
                "渠道 '{$channelId}' 未注册。可用渠道: " . 
                implode(', ', array_keys(self::$registry))
            );
        }
        
        // 获取配置
        if ($config === null) {
            $config = $this->loadConfig($channelId);
        }
        
        // 创建实例
        $adapterClass = self::$registry[$channelId];
        $instance = $this->container->make($adapterClass, ['config' => $config]);
        
        // 缓存
        $this->instances[$cacheKey] = $instance;
        
        return $instance;
    }
    
    /**
     * 获取所有已注册的渠道ID
     */
    public static function getRegisteredChannels(): array {
        return array_keys(self::$registry);
    }
    
    /**
     * 检查渠道是否已注册
     */
    public static function hasChannel(string $channelId): bool {
        return isset(self::$registry[$channelId]);
    }
    
    private function getCacheKey(string $channelId, ?array $config): string {
        return $channelId . ':' . md5(json_encode($config ?? []));
    }
    
    private function loadConfig(string $channelId): array {
        // 从配置中心加载
        return $this->container->get(ConfigManager::class)
            ->getChannelConfig($channelId);
    }
}
// 在应用启动时注册所有渠道
ChannelFactory::registerMany([
    'bytedance' => ByteDanceAdapter::class,
    'tencent' => TencentAdapter::class,
    'kuaishou' => KuaishouAdapter::class,
    'facebook' => FacebookAdapter::class,
    'google' => GoogleAdsAdapter::class,
    // ... 更多渠道
]);

// 在业务代码中使用
$factory = new ChannelFactory($container);

// 创建适配器(配置自动加载)
$channel = $factory->create('bytedance');
$channel->reportConversion($event);

// 或使用自定义配置
$channel = $factory->create('bytedance', [
    'advertiser_id' => 'xxx',
    'access_token' => 'xxx',
    'secret_key' => 'xxx',
]);

四、数据标准化:打通渠道的"巴别塔"

4.1 为什么叫"巴别塔"?

《圣经》里有个故事:人类想建一座通天的塔,上帝看他们太团结,就变乱了他们的语言,让他们无法沟通,塔就建不下去了。

多渠道管理面临同样的问题:每个渠道都在说自己的"语言"(数据格式),如果不统一,系统就乱套了。

4.2 渠道数据差异一览

不同渠道的数据格式差异巨大:

维度 巨量引擎 腾讯广告 快手 Facebook Google Ads
点击ID字段 clickid request_id track_id fbclid gclid
时间格式 毫秒时间戳 秒时间戳 ISO8601 Unix时间戳 ISO8601
金额单位 美分 微单位
激活事件名 activate ACTIVATE_APP app_install APP_INSTALL first_open
回调参数位置 Query Body Query Header Query

如果不标准化,上层业务代码会被这些细节淹没:

// 不好的做法:到处处理格式差异
if ($channel === 'bytedance') {
    $timestamp = $data['ts'] * 1000;
} elseif ($channel === 'tencent') {
    $timestamp = $data['time'];
} elseif ($channel === 'facebook') {
    $timestamp = strtotime($data['created_time']);
}
// ... 业务逻辑被格式处理淹没

4.3 标准数据模型设计

定义一套内部标准模型,让所有数据都转换成统一格式:

<?php
/**
 * 标准点击事件
 */
class StandardClickEvent {
    private string $clickId;          // 统一的点击ID
    private string $channelId;        // 渠道标识
    private int $timestamp;           // 毫秒时间戳(统一)
    private string $campaignId;       // 计划ID
    private string $campaignName;     // 计划名称
    private string $adgroupId;        // 组ID
    private string $adgroupName;      // 组名称
    private string $creativeId;       // 创意ID
    private string $creativeName;     // 创意名称
    private ?string $keyword;         // 搜索关键词(搜索广告)
    private ?string $ip;              // 用户IP
    private ?string $ua;              // User-Agent
    private ?string $os;              // 操作系统
    private ?string $deviceId;        // 设备ID
    private array $extra;             // 扩展字段
    
    // ... getters/setters
}

/**
 * 标准转化事件
 */
class StandardConversionEvent {
    private string $clickId;          // 关联的点击ID
    private string $channelId;        // 渠道标识
    private string $conversionType;   // 转化类型
    private int $timestamp;           // 毫秒时间戳
    private int $value;               // 转化价值(分)
    private string $currency;         // 币种(CNY/USD)
    private ?string $orderId;         // 订单ID
    private ?string $userId;          // 用户ID
    private array $extra;             // 扩展字段
    
    // 预定义的转化类型
    const TYPE_ACTIVATE = 'activate';       // 激活
    const TYPE_REGISTER = 'register';       // 注册
    const TYPE_LOGIN = 'login';             // 登录
    const TYPE_PURCHASE = 'purchase';       // 付费
    const TYPE_ADD_TO_CART = 'add_to_cart'; // 加购
    const TYPE_VIEW_CONTENT = 'view_content'; // 浏览
    const TYPE_RETENTION_D1 = 'retention_d1'; // 次留
    const TYPE_RETENTION_D7 = 'retention_d7'; // 7留
    
    // ... getters/setters
}

/**
 * 标准报表数据
 */
class StandardReportRow {
    private string $date;             // 日期 YYYY-MM-DD
    private string $channelId;        // 渠道ID
    private string $campaignId;       // 计划ID
    private string $campaignName;     // 计划名称
    private int $impression;          // 展示数
    private int $click;               // 点击数
    private int $cost;                // 消耗(分)
    private int $conversion;          // 转化数
    private int $conversionValue;     // 转化价值(分)
    private float $ctr;               // 点击率
    private float $cvr;               // 转化率
    private float $cpm;               // 千次展示成本
    private float $cpc;               // 点击成本
    private float $cpa;               // 转化成本
    private float $roi;               // 投资回报率
    
    // ... getters/setters
}

4.4 数据转换器实现

每个适配器负责把自己的数据格式转换成标准格式:

<?php
/**
 * 数据转换器接口
 */
interface DataTransformerInterface {
    /**
     * 渠道格式 → 标准格式
     */
    public function toStandardClick(array $channelData): StandardClickEvent;
    public function toStandardConversion(array $channelData): StandardConversionEvent;
    
    /**
     * 标准格式 → 渠道格式
     */
    public function fromStandardConversion(StandardConversionEvent $event): array;
}

/**
 * 巨量引擎数据转换器
 */
class ByteDanceTransformer implements DataTransformerInterface {
    
    // 事件类型映射表
    private const EVENT_TYPE_MAP = [
        // 标准 → 渠道
        'activate' => 'activate',
        'register' => 'active_register',
        'purchase' => 'active_pay',
        'retention_d1' => 'active_retention',
        // 渠道 → 标准
        'activate' => 'activate',
        'active_register' => 'register',
        'active_pay' => 'purchase',
    ];
    
    public function toStandardClick(array $data): StandardClickEvent {
        return (new StandardClickEvent())
            ->setClickId($data['clickid'] ?? '')
            ->setChannelId('bytedance')
            ->setTimestamp((int)($data['ts'] ?? 0) * 1000) // 秒→毫秒
            ->setCampaignId($data['campaign_id'] ?? '')
            ->setCampaignName($data['campaign_name'] ?? '')
            ->setAdgroupId($data['adgroup_id'] ?? '')
            ->setCreativeId($data['creative_id'] ?? '')
            ->setIp($data['ip'] ?? null)
            ->setUa($data['ua'] ?? null)
            ->setOs($data['os'] ?? null)
            ->setExtra([
                'click_time' => $data['click_time'] ?? null,
                'conversion_intent' => $data['conversion_intent'] ?? null,
            ]);
    }
    
    public function toStandardConversion(array $data): StandardConversionEvent {
        $eventType = $data['event_type'] ?? '';
        
        return (new StandardConversionEvent())
            ->setClickId($data['clickid'] ?? '')
            ->setChannelId('bytedance')
            ->setConversionType(self::EVENT_TYPE_MAP[$eventType] ?? $eventType)
            ->setTimestamp((int)($data['conv_time'] ?? 0))
            ->setValue((int)($data['value'] ?? 0)) // 已经是分
            ->setCurrency('CNY')
            ->setExtra([
                'request_id' => $data['request_id'] ?? null,
            ]);
    }
    
    public function fromStandardConversion(StandardConversionEvent $event): array {
        return [
            'event_type' => self::EVENT_TYPE_MAP[$event->getConversionType()] 
                ?? $event->getConversionType(),
            'clickid' => $event->getClickId(),
            'conv_time' => $event->getTimestamp(),
            'value' => $event->getValue(),
            'os' => $event->getOs(),
        ];
    }
}

4.5 字段映射配置化

对于简单的字段映射,可以用配置代替代码:

<?php
/**
 * 字段映射配置
 */
class FieldMappingConfig {
    
    // 点击事件字段映射
    public const CLICK_FIELD_MAPPINGS = [
        'bytedance' => [
            'clickId' => 'clickid',
            'timestamp' => ['field' => 'ts', 'transform' => 'secondsToMillis'],
            'campaignId' => 'campaign_id',
            'adgroupId' => 'adgroup_id',
            'creativeId' => 'creative_id',
        ],
        'tencent' => [
            'clickId' => 'request_id',
            'timestamp' => 'click_time',
            'campaignId' => 'campaign_id',
            'adgroupId' => 'adgroup_id',
            'creativeId' => 'creative_id',
        ],
        'facebook' => [
            'clickId' => 'fbclid',
            'timestamp' => ['field' => 'click_time', 'transform' => 'isoToMillis'],
            'campaignId' => 'campaign_id',
        ],
    ];
    
    // 转化事件类型映射
    public const CONVERSION_TYPE_MAPPINGS = [
        'bytedance' => [
            'activate' => 'activate',
            'register' => 'active_register',
            'purchase' => 'active_pay',
        ],
        'tencent' => [
            'activate' => 'ACTIVATE_APP',
            'register' => 'REGISTER',
            'purchase' => 'PURCHASE',
        ],
        'facebook' => [
            'activate' => 'APP_INSTALL',
            'register' => 'COMPLETE_REGISTRATION',
            'purchase' => 'PURCHASE',
        ],
    ];
}

/**
 * 通用字段映射器
 */
class GenericFieldMapper {
    
    public function mapClickData(string $channelId, array $data): StandardClickEvent {
        $mapping = FieldMappingConfig::CLICK_FIELD_MAPPINGS[$channelId] ?? [];
        $event = new StandardClickEvent();
        $event->setChannelId($channelId);
        
        foreach ($mapping as $standardField => $channelField) {
            if (is_array($channelField)) {
                // 带转换的映射
                $value = $data[$channelField['field']] ?? null;
                $value = $this->transform($value, $channelField['transform']);
            } else {
                // 直接映射
                $value = $data[$channelField] ?? null;
            }
            
            $this->setField($event, $standardField, $value);
        }
        
        return $event;
    }
    
    private function transform($value, string $type) {
        return match($type) {
            'secondsToMillis' => (int)$value * 1000,
            'isoToMillis' => strtotime($value) * 1000,
            'yuanToFen' => (float)$value * 100,
            default => $value,
        };
    }
}

这样,新渠道接入时,很多情况下只需要配置映射关系,不需要写转换代码。


五、渠道接入流程:从0到1的完整指南

5.1 新渠道接入清单

接入一个新渠道,需要完成以下步骤:

┌─────────────────────────────────────────────────────────┐
│                   新渠道接入流程                         │
└─────────────────────────────────────────────────────────┘
                           │
        ┌──────────────────┴──────────────────┐
        ▼                                      ▼
┌───────────────┐                    ┌───────────────┐
│ 1. 需求分析   │                    │ 4. 开发实现   │
│ - 业务场景    │                    │ - 适配器开发  │
│ - API文档研究 │                    │ - 转换器开发  │
│ - 配置项梳理  │                    │ - 单元测试    │
└───────────────┘                    └───────────────┘
        │                                      │
        ▼                                      ▼
┌───────────────┐                    ┌───────────────┐
│ 2. Schema设计 │                    │ 5. 集成测试   │
│ - 配置Schema  │                    │ - 沙箱环境    │
│ - 字段映射    │                    │ - 回调测试    │
│ - 事件映射    │                    │ - 回传测试    │
└───────────────┘                    └───────────────┘
        │                                      │
        ▼                                      ▼
┌───────────────┐                    ┌───────────────┐
│ 3. 原型验证   │                    │ 6. 上线部署   │
│ - 测试账号    │                    │ - 配置录入    │
│ - 接口联调    │                    │ - 监控告警    │
│ - 数据验证    │                    │ - 文档归档    │
└───────────────┘                    └───────────────┘

5.2 Step 1: 需求分析与API文档研究

在写代码之前,先搞清楚几件事:

## 巨量引擎接入分析

### 基本信息
- 渠道类型:信息流广告
- 主要市场:国内
- API版本:v3.0

### 认证方式
- AK/SK签名认证
- 需要申请 advertiser_id 和 access_token

### 核心接口
1. 转化上报:POST /event/convert/
2. 报表拉取:POST /report/ad/get/
3. 回调接收:GET /callback

### 限流策略
- 单账号 100 QPS
- 需要实现重试和队列

### 特殊要求
- 时间戳必须是毫秒
- 金额单位是分

5.3 Step 2: Schema设计

根据API文档,设计配置Schema:

// 渠道配置Schema
$schema = (new ConfigSchema())
    // 必填配置
    ->addField('advertiser_id', '广告主ID', 'string', true)
    ->addField('access_token', '访问令牌', 'string', true, true)
    ->addField('secret_key', '签名密钥', 'string', true, true)
    
    // 可选配置
    ->addField('callback_url', '回调地址', 'string', false, false, 
               'https://ad.example.com/callback')
    ->addField('rate_limit', 'API限流(QPS)', 'int', false, false, 100)
    ->addField('timeout', '超时时间(秒)', 'int', false, false, 10)
    ->addField('retry_times', '重试次数', 'int', false, false, 3)
    
    // 高级配置
    ->addField('extra_params', '额外参数', 'json', false, false, '{}')
    ->addField('debug_mode', '调试模式', 'bool', false, false, false);

// 字段映射配置
$fieldMapping = [
    'clickId' => 'clickid',
    'timestamp' => ['field' => 'ts', 'transform' => 'secondsToMillis'],
    'campaignId' => 'campaign_id',
    'adgroupId' => 'adgroup_id',
    'creativeId' => 'creative_id',
];

// 事件类型映射
$eventMapping = [
    'activate' => 'activate',
    'register' => 'active_register',
    'purchase' => 'active_pay',
    'retention_d1' => 'active_retention',
];

5.4 Step 3: 原型验证

在正式开发前,先写一个简单的原型验证核心功能:

<?php
/**
 * 原型验证脚本 - 验证API连通性和数据格式
 */

// 1. 验证认证
function testAuth($config) {
    $response = callApi('/user/info/', [], $config);
    echo "认证结果: " . ($response['code'] === 0 ? "成功" : "失败") . "\n";
}

// 2. 验证转化上报
function testConversionReport($config) {
    $event = [
        'event_type' => 'activate',
        'clickid' => 'TEST_CLICK_ID_' . time(),
        'conv_time' => time() * 1000,
    ];
    $response = callApi('/event/convert/', $event, $config);
    echo "上报结果: " . json_encode($response) . "\n";
}

// 3. 验证回调解析
function testCallbackParse($config) {
    $mockCallback = [
        'clickid' => 'test_click_id',
        'ts' => time(),
        'campaign_id' => '123',
        'signature' => 'xxx',
    ];
    // 验证签名逻辑
    $isValid = verifySignature($mockCallback, $config['secret_key']);
    echo "签名验证: " . ($isValid ? "通过" : "失败") . "\n";
}

// 4. 验证报表拉取
function testReportFetch($config) {
    $params = [
        'start_date' => date('Y-m-d', strtotime('-1 day')),
        'end_date' => date('Y-m-d'),
    ];
    $response = callApi('/report/ad/get/', $params, $config);
    echo "报表数据: " . count($response['data'] ?? []) . " 条记录\n";
}

// 运行验证
$config = require 'config/bytedance_test.php';
testAuth($config);
testConversionReport($config);
testCallbackParse($config);
testReportFetch($config);

5.5 Step 4: 适配器开发

根据原型验证的结果,正式开发适配器:

<?php
/**
 * 巨量引擎适配器 - 生产版本
 */
class ByteDanceAdapter extends AbstractChannelAdapter {
    
    use SignatureTrait;
    use RetryTrait;
    use LogTrait;
    
    public function reportConversion(StandardConversionEvent $event): ReportResult {
        return $this->retry(function() use ($event) {
            $params = $this->buildConversionParams($event);
            $response = $this->callApi('conversion', $params);
            
            return ReportResult::success($response['request_id'], $response);
        }, $this->config['retry_times'] ?? 3);
    }
    
    // ... 其他方法实现
}

5.6 Step 5: 集成测试

在沙箱环境进行完整测试:

<?php
/**
 * 巨量引擎适配器集成测试
 */
class ByteDanceAdapterTest extends ChannelAdapterTestCase {
    
    private ByteDanceAdapter $adapter;
    private array $testConfig;
    
    protected function setUp(): void {
        $this->testConfig = $this->loadTestConfig('bytedance_sandbox');
        $this->adapter = new ByteDanceAdapter(
            $this->testConfig,
            new GuzzleHttpClient(),
            new TestLogger()
        );
    }
    
    public function testGenerateTrackingUrl(): void {
        $params = new TrackingUrlParams(
            campaignId: '123',
            creativeId: '456',
            os: 'android'
        );
        
        $url = $this->adapter->generateTrackingUrl($params);
        
        $this->assertStringContainsString('channel=bytedance', $url);
        $this->assertStringContainsString('__aid__=123', $url);
        $this->assertStringContainsString('__cid__=456', $url);
    }
    
    public function testParseCallback(): void {
        $rawData = [
            'clickid' => 'test_click_123',
            'ts' => 1709078400,
            'campaign_id' => 'campaign_001',
        ];
        
        $event = $this->adapter->parseCallback($rawData);
        
        $this->assertEquals('test_click_123', $event->getClickId());
        $this->assertEquals('bytedance', $event->getChannelId());
        $this->assertEquals(1709078400000, $event->getTimestamp());
    }
    
    public function testReportConversion(): void {
        $event = (new StandardConversionEvent())
            ->setClickId('test_click_123')
            ->setConversionType('activate')
            ->setTimestamp(time() * 1000);
        
        $result = $this->adapter->reportConversion($event);
        
        $this->assertTrue($result->isSuccess());
        $this->assertNotEmpty($result->getRequestId());
    }
    
    public function testVerifyCallback(): void {
        $rawData = [
            'clickid' => 'test_click_123',
            'ts' => 1709078400,
        ];
        
        // 生成有效签名
        $rawData['signature'] = $this->generateTestSignature($rawData);
        
        $isValid = $this->adapter->verifyCallback($rawData);
        
        $this->assertTrue($isValid);
    }
    
    public function testFetchReport(): void {
        $query = new ReportQuery(
            startDate: date('Y-m-d', strtotime('-1 day')),
            endDate: date('Y-m-d')
        );
        
        $report = $this->adapter->fetchReport($query);
        
        $this->assertNotEmpty($report->getRows());
        
        $row = $report->getRows()[0];
        $this->assertNotEmpty($row->getDate());
        $this->assertNotEmpty($row->getCampaignId());
    }
}

5.7 Step 6: 注册与部署

最后,把适配器注册到系统:

// 在应用初始化时注册
ChannelFactory::register('bytedance', ByteDanceAdapter::class);

// 或者通过配置文件注册
// config/channels.php
return [
    'registered' => [
        'bytedance' => [
            'adapter' => ByteDanceAdapter::class,
            'name' => '巨量引擎',
            'type' => 'information_flow',
            'enabled' => true,
        ],
        // ... 其他渠道
    ],
];

六、渠道配置管理系统

6.1 配置的层次结构

一个完善的配置管理系统应该有三层结构:

┌─────────────────────────────────────────────────────────┐
│                    配置层次结构                          │
└─────────────────────────────────────────────────────────┘

Level 1: 系统级配置(全局)
├── channels/
│   ├── registry.yaml          # 渠道注册表
│   └── schemas/
│       ├── bytedance.yaml     # 巨量引擎配置Schema
│       ├── tencent.yaml       # 腾讯广告配置Schema
│       └── ...
│
Level 2: 渠道级配置(平台默认)
├── defaults/
│   ├── bytedance.yaml         # 巨量引擎默认配置
│   └── tencent.yaml           # 腾讯广告默认配置
│
Level 3: 实例级配置(具体应用)
└── instances/
    ├── game_001/              # 游戏A
    │   ├── bytedance.yaml     # 游戏A在巨量引擎的配置
    │   └── tencent.yaml
    └── game_002/              # 游戏B
        ├── bytedance.yaml
        └── kuaishou.yaml

6.2 配置文件示例

channels:
  bytedance:
    name: 巨量引擎
    adapter: ByteDanceAdapter
    type: information_flow
    markets: [china]
    enabled: true
    priority: 100
    
  tencent:
    name: 腾讯广告
    adapter: TencentAdapter
    type: information_flow
    markets: [china]
    enabled: true
    priority: 90
    
  facebook:
    name: Facebook Ads
    adapter: FacebookAdapter
    type: social
    markets: [overseas]
    enabled: true
    priority: 80
channel_id: bytedance
name: 巨量引擎
version: 1.0

config_schema:
  advertiser_id:
    type: string
    required: true
    label: 广告主ID
    description: 在巨量引擎后台申请的广告主ID
    placeholder: "请输入广告主ID"
    
  access_token:
    type: string
    required: true
    secret: true
    label: 访问令牌
    description: API访问令牌,需要保密
    placeholder: "请输入Access Token"
    
  secret_key:
    type: string
    required: true
    secret: true
    label: 签名密钥
    description: 用于签名验证的密钥
    placeholder: "请输入Secret Key"
    
  callback_url:
    type: string
    required: false
    default: "https://ad.example.com/callback"
    label: 回调地址
    description: 点击回调的接收地址
    
  rate_limit:
    type: integer
    required: false
    default: 100
    label: API限流(QPS)
    description: 每秒最大请求数
    
  debug_mode:
    type: boolean
    required: false
    default: false
    label: 调试模式
    description: 开启后会记录详细日志

# 字段映射配置
field_mappings:
  click:
    clickId: clickid
    timestamp:
      field: ts
      transform: secondsToMillis
    campaignId: campaign_id
    adgroupId: adgroup_id
    creativeId: creative_id
    
  conversion:
    activate: activate
    register: active_register
    purchase: active_pay
channel_id: bytedance
instance_id: game_001

config:
  advertiser_id: "1234567890"
  access_token: "${BYTEDANCE_ACCESS_TOKEN}"  # 从环境变量读取
  secret_key: "${BYTEDANCE_SECRET_KEY}"
  callback_url: "https://ad.mygame.com/callback/bytedance"
  rate_limit: 100
  
# 覆盖默认的事件映射
event_mapping:
  activate: activate
  register: active_register
  purchase: active_pay
  retention_d1: active_retention
  
# 实例级别的特殊配置
options:
  enable_batch_report: true
  batch_size: 100
  async_mode: true

6.3 配置管理器实现

<?php
/**
 * 渠道配置管理器
 */
class ChannelConfigManager {
    
    private string $configPath;
    private array $cache = [];
    private int $cacheTtl = 300; // 5分钟缓存
    
    public function __construct(string $configPath) {
        $this->configPath = rtrim($configPath, '/');
    }
    
    /**
     * 获取渠道配置(合并三层配置)
     */
    public function getConfig(string $channelId, ?string $instanceId = null): array {
        $cacheKey = "{$channelId}:{$instanceId}";
        
        if (isset($this->cache[$cacheKey])) {
            return $this->cache[$cacheKey];
        }
        
        // 1. 加载Schema配置
        $schemaConfig = $this->loadSchemaConfig($channelId);
        
        // 2. 加载默认配置
        $defaultConfig = $this->loadDefaultConfig($channelId);
        
        // 3. 加载实例配置
        $instanceConfig = [];
        if ($instanceId) {
            $instanceConfig = $this->loadInstanceConfig($instanceId, $channelId);
        }
        
        // 4. 合并配置(优先级:实例 > 默认 > Schema)
        $config = array_replace_recursive(
            $schemaConfig,
            $defaultConfig,
            $instanceConfig
        );
        
        // 5. 解析环境变量
        $config = $this->resolveEnvVars($config);
        
        // 6. 验证配置
        $this->validateConfig($channelId, $config);
        
        $this->cache[$cacheKey] = $config;
        
        return $config;
    }
    
    /**
     * 获取所有已注册的渠道
     */
    public function getRegisteredChannels(): array {
        $registry = $this->loadYaml("{$this->configPath}/channels/registry.yaml");
        return $registry['channels'] ?? [];
    }
    
    /**
     * 获取渠道Schema
     */
    public function getSchema(string $channelId): array {
        return $this->loadYaml("{$this->configPath}/channels/schemas/{$channelId}.yaml");
    }
    
    /**
     * 更新实例配置
     */
    public function updateInstanceConfig(
        string $instanceId, 
        string $channelId, 
        array $config
    ): void {
        $path = "{$this->configPath}/instances/{$instanceId}/{$channelId}.yaml";
        
        // 备份原配置
        if (file_exists($path)) {
            copy($path, $path . '.bak');
        }
        
        // 写入新配置
        $this->saveYaml($path, $config);
        
        // 清除缓存
        $this->clearCache($channelId, $instanceId);
        
        // 记录变更日志
        $this->logConfigChange($instanceId, $channelId, $config);
    }
    
    /**
     * 验证配置
     */
    private function validateConfig(string $channelId, array $config): void {
        $schema = $this->getSchema($channelId);
        $errors = [];
        
        foreach ($schema['config_schema'] ?? [] as $field => $rules) {
            // 检查必填字段
            if (($rules['required'] ?? false) && empty($config[$field])) {
                $errors[] = "字段 '{$field}' 是必填的";
            }
            
            // 检查类型
            if (isset($config[$field])) {
                $typeError = $this->validateType($config[$field], $rules['type'] ?? 'string');
                if ($typeError) {
                    $errors[] = "字段 '{$field}' 类型错误: {$typeError}";
                }
            }
        }
        
        if (!empty($errors)) {
            throw new ConfigValidationException(
                "配置验证失败: " . implode('; ', $errors)
            );
        }
    }
    
    /**
     * 解析环境变量
     */
    private function resolveEnvVars(array $config): array {
        array_walk_recursive($config, function(&$value) {
            if (is_string($value) && preg_match('/\$\{(\w+)\}/', $value, $matches)) {
                $envValue = getenv($matches[1]);
                if ($envValue !== false) {
                    $value = str_replace($matches[0], $envValue, $value);
                }
            }
        });
        
        return $config;
    }
    
    // ... 其他辅助方法
}

6.4 配置热更新

配置应该支持热更新,不需要重启服务:

<?php
/**
 * 配置热更新监听器
 */
class ConfigHotReloadListener {
    
    private ChannelConfigManager $configManager;
    private array $watchFiles = [];
    private array $fileMtimes = [];
    
    public function __construct(ChannelConfigManager $configManager) {
        $this->configManager = $configManager;
    }
    
    /**
     * 启动监听(通常在后台进程运行)
     */
    public function start(): void {
        $this->scanConfigFiles();
        
        while (true) {
            sleep(1);
            $this->checkChanges();
        }
    }
    
    /**
     * 扫描配置文件
     */
    private function scanConfigFiles(): void {
        $configPath = $this->configManager->getConfigPath();
        
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($configPath)
        );
        
        foreach ($iterator as $file) {
            if ($file->isFile() && in_array($file->getExtension(), ['yaml', 'yml'])) {
                $this->watchFiles[] = $file->getPathname();
                $this->fileMtimes[$file->getPathname()] = $file->getMTime();
            }
        }
    }
    
    /**
     * 检查文件变更
     */
    private function checkChanges(): void {
        foreach ($this->watchFiles as $file) {
            $currentMtime = filemtime($file);
            
            if ($currentMtime > $this->fileMtimes[$file]) {
                $this->onFileChanged($file);
                $this->fileMtimes[$file] = $currentMtime;
            }
        }
    }
    
    /**
     * 文件变更处理
     */
    private function onFileChanged(string $file): void {
        // 清除相关缓存
        $this->configManager->clearCacheForFile($file);
        
        // 发送配置变更事件
        Event::dispatch(new ConfigChangedEvent($file));
        
        Log::info("配置文件已更新,缓存已清除", ['file' => $file]);
    }
}

6.5 配置变更审计

配置变更要有完整的记录:

<?php
/**
 * 配置变更审计日志
 */
class ConfigAuditLog {
    
    private string $logPath;
    
    public function __construct(string $logPath) {
        $this->logPath = $logPath;
    }
    
    /**
     * 记录配置变更
     */
    public function log(string $action, string $instanceId, string $channelId, array $data): void {
        $entry = [
            'timestamp' => date('Y-m-d H:i:s'),
            'action' => $action, // create, update, delete
            'instance_id' => $instanceId,
            'channel_id' => $channelId,
            'user' => $this->getCurrentUser(),
            'ip' => $this->getClientIp(),
            'changes' => $data,
            'diff' => $this->computeDiff($instanceId, $channelId, $data),
        ];
        
        $logFile = $this->logPath . '/config_audit_' . date('Y-m') . '.jsonl';
        file_put_contents($logFile, json_encode($entry) . "\n", FILE_APPEND);
    }
    
    /**
     * 计算配置差异
     */
    private function computeDiff(string $instanceId, string $channelId, array $newConfig): array {
        $oldConfig = $this->getOldConfig($instanceId, $channelId);
        
        $diff = [];
        foreach ($newConfig as $key => $value) {
            $oldValue = $oldConfig[$key] ?? null;
            if ($oldValue !== $value) {
                $diff[$key] = [
                    'old' => $this->maskSecret($key, $oldValue),
                    'new' => $this->maskSecret($key, $value),
                ];
            }
        }
        
        return $diff;
    }
    
    /**
     * 敏感信息脱敏
     */
    private function maskSecret(string $key, $value): string {
        if (in_array($key, ['access_token', 'secret_key', 'password'])) {
            return '******';
        }
        return $value;
    }
}

七、实战案例:完整调用链路

7.1 用户点击广告到激活的完整流程

用户点击广告 → 渠道回调 → 点击记录 → 用户激活 → 转化上报

时间线:
T0: 用户在抖音看到广告,点击
T1: 巨量引擎发起回调到我们的服务器
T2: 服务器记录点击,返回成功
T3: 用户下载并安装App
T4: 用户打开App,触发激活事件
T5: 服务器向巨量引擎上报激活
T6: 巨量引擎确认收到,完成归因

7.2 代码实现:完整调用链

<?php
/**
 * 广告归因服务 - 完整调用链示例
 */
class AttributionService {
    
    private ChannelFactory $channelFactory;
    private ClickRepository $clickRepo;
    private ConversionRepository $conversionRepo;
    private EventDispatcher $eventDispatcher;
    
    /**
     * 处理渠道回调(点击)
     */
    public function handleCallback(string $channelId, array $rawData): CallbackResult {
        // 1. 获取渠道适配器
        $channel = $this->channelFactory->create($channelId);
        
        // 2. 验证签名
        if (!$channel->verifyCallback($rawData)) {
            return CallbackResult::invalid('签名验证失败');
        }
        
        // 3. 解析为标准格式
        $clickEvent = $channel->parseCallback($rawData);
        
        // 4. 检查是否重复
        if ($this->clickRepo->exists($clickEvent->getClickId())) {
            return CallbackResult::duplicate('点击已存在');
        }
        
        // 5. 存储点击记录
        $this->clickRepo->save($clickEvent);
        
        // 6. 发送点击事件(供其他系统消费)
        $this->eventDispatcher->dispatch(new ClickReceivedEvent($clickEvent));
        
        return CallbackResult::success($clickEvent->getClickId());
    }
    
    /**
     * 处理用户激活
     */
    public function handleActivation(string $deviceId, string $ip): ActivationResult {
        // 1. 查找最近的点击记录(匹配规则:IP + 时间窗口)
        $clickEvent = $this->clickRepo->findMatchingClick($deviceId, $ip);
        
        if (!$clickEvent) {
            return ActivationResult::organic('自然流量');
        }
        
        // 2. 创建转化事件
        $conversionEvent = (new StandardConversionEvent())
            ->setClickId($clickEvent->getClickId())
            ->setChannelId($clickEvent->getChannelId())
            ->setConversionType(StandardConversionEvent::TYPE_ACTIVATE)
            ->setTimestamp(time() * 1000)
            ->setExtra(['device_id' => $deviceId]);
        
        // 3. 存储转化记录
        $this->conversionRepo->save($conversionEvent);
        
        // 4. 异步上报到广告渠道
        $this->eventDispatcher->dispatch(new ConversionReadyEvent($conversionEvent));
        
        return ActivationResult::attributed($clickEvent->getChannelId());
    }
    
    /**
     * 上报转化到渠道(异步任务)
     */
    public function reportConversionToChannel(string $conversionId): ReportResult {
        // 1. 获取转化记录
        $conversion = $this->conversionRepo->find($conversionId);
        
        if (!$conversion) {
            throw new ConversionNotFoundException($conversionId);
        }
        
        // 2. 获取渠道适配器
        $channel = $this->channelFactory->create($conversion->getChannelId());
        
        // 3. 上报转化
        $result = $channel->reportConversion($conversion);
        
        // 4. 更新上报状态
        $this->conversionRepo->updateReportStatus(
            $conversionId,
            $result->isSuccess(),
            $result->getRequestId(),
            $result->getErrorMessage()
        );
        
        return $result;
    }
}

7.3 批量上报优化

对于高并发场景,批量上报可以显著提升性能:

<?php
/**
 * 批量上报队列
 */
class BatchReportQueue {
    
    private Redis $redis;
    private int $batchSize = 100;
    private int $flushInterval = 5; // 秒
    
    /**
     * 添加转化到队列
     */
    public function enqueue(StandardConversionEvent $event): void {
        $queueKey = "batch_report:{$event->getChannelId()}";
        $this->redis->rPush($queueKey, serialize($event));
    }
    
    /**
     * 批量处理(定时任务调用)
     */
    public function processBatch(string $channelId): int {
        $queueKey = "batch_report:{$channelId}";
        $events = [];
        
        // 取出一批事件
        for ($i = 0; $i < $this->batchSize; $i++) {
            $data = $this->redis->lPop($queueKey);
            if (!$data) break;
            $events[] = unserialize($data);
        }
        
        if (empty($events)) {
            return 0;
        }
        
        // 获取适配器
        $channel = $this->channelFactory->create($channelId);
        
        // 批量上报
        $result = $channel->reportConversionBatch($events);
        
        // 处理失败的重试
        foreach ($result->getFailures() as $index => $error) {
            $this->handleFailure($events[$index], $error);
        }
        
        return count($events);
    }
}

八、监控与告警

8.1 关键指标监控

<?php
/**
 * 渠道监控指标
 */
class ChannelMetrics {
    
    // 回调相关指标
    const CALLBACK_TOTAL = 'channel_callback_total';
    const CALLBACK_SUCCESS = 'channel_callback_success';
    const CALLBACK_FAILURE = 'channel_callback_failure';
    const CALLBACK_LATENCY = 'channel_callback_latency_ms';
    
    // 上报相关指标
    const REPORT_TOTAL = 'channel_report_total';
    const REPORT_SUCCESS = 'channel_report_success';
    const REPORT_FAILURE = 'channel_report_failure';
    const REPORT_LATENCY = 'channel_report_latency_ms';
    
    // 队列相关指标
    const QUEUE_SIZE = 'channel_queue_size';
    const QUEUE_AGE = 'channel_queue_age_seconds';
    
    /**
     * 记录回调指标
     */
    public static function recordCallback(
        string $channelId, 
        bool $success, 
        int $latencyMs
    ): void {
        Metrics::increment(self::CALLBACK_TOTAL, ['channel' => $channelId]);
        
        if ($success) {
            Metrics::increment(self::CALLBACK_SUCCESS, ['channel' => $channelId]);
        } else {
            Metrics::increment(self::CALLBACK_FAILURE, ['channel' => $channelId]);
        }
        
        Metrics::histogram(self::CALLBACK_LATENCY, $latencyMs, ['channel' => $channelId]);
    }
    
    /**
     * 记录上报指标
     */
    public static function recordReport(
        string $channelId,
        bool $success,
        int $latencyMs
    ): void {
        Metrics::increment(self::REPORT_TOTAL, ['channel' => $channelId]);
        
        if ($success) {
            Metrics::increment(self::REPORT_SUCCESS, ['channel' => $channelId]);
        } else {
            Metrics::increment(self::REPORT_FAILURE, ['channel' => $channelId]);
        }
        
        Metrics::histogram(self::REPORT_LATENCY, $latencyMs, ['channel' => $channelId]);
    }
}

8.2 告警规则

# 告警规则配置
alerts:
  # 回调失败率告警
  - name: callback_failure_rate
    expr: |
      rate(channel_callback_failure[5m]) / 
      rate(channel_callback_total[5m]) > 0.05
    for: 3m
    labels:
      severity: warning
    annotations:
      summary: "渠道 {{ $labels.channel }} 回调失败率过高"
      description: "过去5分钟回调失败率 {{ $value | humanizePercentage }}"
  
  # 上报延迟告警
  - name: report_latency_high
    expr: |
      histogram_quantile(0.95, 
        rate(channel_report_latency_ms_bucket[5m])
      ) > 5000
    for: 5m
    labels:
      severity: warning
    annotations:
      summary: "渠道 {{ $labels.channel }} 上报延迟过高"
      description: "P95延迟 {{ $value }}ms"
  
  # 渠道健康检查失败
  - name: channel_health_check_failed
    expr: channel_health_status == 0
    for: 1m
    labels:
      severity: critical
    annotations:
      summary: "渠道 {{ $labels.channel }} 健康检查失败"
      description: "渠道连接异常,请立即检查"

九、架构演进之路

9.1 三阶段演进

我们这套架构不是一蹴而就的,而是经历了三个阶段:

时间:项目初期,只有3个渠道
代码:一个文件,if-else搞定
优点:快速上线,简单直接
缺点:难以扩展,维护困难
适用:MVP阶段,验证业务
时间:渠道增加到10个
代码:策略模式,每个渠道一个策略类
优点:职责分离,维护改善
缺点:策略创建分散,配置硬编码
适用:成长期,快速迭代
时间:渠道超过20个,需要长期维护
代码:完整的适配器架构
优点:易扩展、可配置、可测试
缺点:初期投入大
适用:成熟期,长期运营

9.2 什么时候重构?

信号 说明 建议
加渠道要改多处代码 违反DRY原则 考虑抽象层
新人接手困难 代码复杂度过高 考虑模块化
改一个渠道影响其他 耦合过紧 考虑隔离
测试覆盖率下降 难以测试 考虑依赖注入
配置散落各处 配置管理混乱 考虑配置中心

9.3 重构的原则

  1. 渐进式重构:不要一次性改完,分步骤进行
  2. 保持兼容:新旧代码可以共存,逐步迁移
  3. 测试先行:重构前先写测试,确保行为不变
  4. 持续验证:每一步都要验证,及时发现问题

十、总结

管理25+广告渠道的核心思路,可以总结为一句话:把变化隔离在边界之内,让核心保持稳定。

10.1 架构要点回顾

层次 解决的问题 核心模式
统一抽象层 屏蔽渠道差异 接口定义(契约)
适配器模式 转换数据格式 适配器+工厂
数据标准化 统一内部数据 标准模型+转换器
配置化管理 代码配置分离 Schema驱动+热更新

10.2 这套架构的价值

指标 改造前 改造后 提升
新增渠道 改核心代码,风险高 写适配器,零风险 ⬆️ 安全性
渠道升级 满世界找代码 只改一个文件 ⬆️ 效率
代码复杂度 2000行if-else 每个适配器100-200行 ⬇️ 复杂度
测试覆盖 很难写单测 每个适配器独立测试 ⬆️ 可测试性
新人上手 看代码想辞职 看接口就会写 ⬆️ 可维护性
配置变更 需要重启服务 热更新,无需重启 ⬆️ 可用性

10.3 架构不是一蹴而就的

最后说一句:好的架构不是一开始就设计出来的,而是在解决实际问题的过程中演进的。

不要为了架构而架构。只有当你真正感受到痛点时,重构才有意义。过早优化是万恶之源。

重要的是:

  1. 每次重构都要解决真实的痛点
  2. 保持代码可以工作(渐进式)
  3. 测试覆盖保证安全性
  4. 文档跟上变化

10.4 还能做什么?

这套架构还有很大的扩展空间:


下篇预告:有了这么多渠道的数据,怎么把它们整合到一起,做全渠道效果分析和智能投放决策?


💬 评论 (0)

0/500
排序: