wangyunpeng hace 1 semana
padre
commit
7366ce5711

+ 193 - 0
core/src/main/java/com/tzld/rta/client/RtaManageClient.java

@@ -0,0 +1,193 @@
+package com.tzld.rta.client;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.tzld.rta.model.dto.RtaManageDTO;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 腾讯 RTA 自助管理 API 客户端
+ * <p>
+ * API 域名: https://api.rta.qq.com/
+ * 鉴权方式: Authorization = md5(RtaId + Token + Time)
+ * <p>
+ * HTTP Header 必传:
+ *   RtaId         腾讯分配给对接团队的 ID
+ *   Authorization md5(RtaId + Token + Time) 32位小写
+ *   Time          请求发生时间戳(秒),10位
+ *   Content-Type  application/json
+ */
+@Slf4j
+@Component
+public class RtaManageClient {
+
+    private static final String BASE_URL = "https://api.rta.qq.com";
+    private static final String PATH_RTA_INFO    = "/api/v1/RtaInfo";
+    private static final String PATH_TARGET_GET  = "/api/v1/target/get";
+    private static final String PATH_TARGET_SET  = "/api/v1/target/set";
+    private static final String PATH_TARGET_DEL  = "/api/v1/target/delete";
+    private static final String PATH_BIND        = "/api/v1/target/bind";
+    private static final String PATH_BIND_DELETE = "/api/v1/target/bind/delete";
+    private static final String PATH_BIND_STATUS = "/api/v1/target/bind/status";
+
+    private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
+
+    /** 腾讯分配的 RtaId */
+    @Value("${rta.manage.rta-id:}")
+    private String rtaId;
+
+    /** 腾讯分配的 Token(RTA前台设置) */
+    @Value("${rta.manage.token:}")
+    private String token;
+
+    /** 请求超时(ms) */
+    @Value("${rta.manage.timeout-ms:5000}")
+    private int timeoutMs;
+
+    private OkHttpClient httpClient;
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @PostConstruct
+    public void init() {
+        httpClient = new OkHttpClient.Builder()
+                .connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+                .readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+                .writeTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+                .build();
+    }
+
+    // ===================== 2.2.2 基本信息查询 =====================
+
+    /**
+     * 查询 RTA 基本信息(账号、bidUrl、缓存时间、规则等)
+     */
+    public RtaManageDTO.ApiResponse<RtaManageDTO.RtaInfo> getRtaInfo() throws Exception {
+        Request request = buildGetRequest(PATH_RTA_INFO);
+        return execute(request, new TypeReference<RtaManageDTO.ApiResponse<RtaManageDTO.RtaInfo>>() {});
+    }
+
+    // ===================== 2.2.3 策略 IO 接口 =====================
+
+    /**
+     * 查询策略列表
+     */
+    public RtaManageDTO.ApiResponse<?> getTargets(RtaManageDTO.TargetGetRequest req) throws Exception {
+        // 文档显示 GET 接口通过 body 传参,用 POST 方式传递参数
+        String body = objectMapper.writeValueAsString(req);
+        Request request = buildPostRequest(PATH_TARGET_GET, body);
+        return execute(request, new TypeReference<RtaManageDTO.ApiResponse<?>>() {});
+    }
+
+    /**
+     * 新增策略
+     */
+    public RtaManageDTO.ApiResponse<?> addTarget(RtaManageDTO.TargetSetRequest req) throws Exception {
+        Request request = buildPostRequest(PATH_TARGET_SET, objectMapper.writeValueAsString(req));
+        return execute(request, new TypeReference<RtaManageDTO.ApiResponse<?>>() {});
+    }
+
+    /**
+     * 删除策略
+     */
+    public RtaManageDTO.ApiResponse<?> deleteTarget(RtaManageDTO.TargetDeleteRequest req) throws Exception {
+        Request request = buildPostRequest(PATH_TARGET_DEL, objectMapper.writeValueAsString(req));
+        return execute(request, new TypeReference<RtaManageDTO.ApiResponse<?>>() {});
+    }
+
+    // ===================== 2.2.4 策略绑定接口 =====================
+
+    /**
+     * 绑定策略与广告主账号/广告
+     */
+    public RtaManageDTO.ApiResponse<?> bindTarget(RtaManageDTO.TargetBindRequest req) throws Exception {
+        Request request = buildPostRequest(PATH_BIND, objectMapper.writeValueAsString(req));
+        return execute(request, new TypeReference<RtaManageDTO.ApiResponse<?>>() {});
+    }
+
+    /**
+     * 解绑策略
+     */
+    public RtaManageDTO.ApiResponse<?> unbindTarget(RtaManageDTO.TargetBindDeleteRequest req) throws Exception {
+        Request request = buildPostRequest(PATH_BIND_DELETE, objectMapper.writeValueAsString(req));
+        return execute(request, new TypeReference<RtaManageDTO.ApiResponse<?>>() {});
+    }
+
+    /**
+     * 查询绑定状态
+     */
+    public RtaManageDTO.ApiResponse<?> getBindStatus(RtaManageDTO.TargetBindStatusRequest req) throws Exception {
+        Request request = buildPostRequest(PATH_BIND_STATUS, objectMapper.writeValueAsString(req));
+        return execute(request, new TypeReference<RtaManageDTO.ApiResponse<?>>() {});
+    }
+
+    // ===================== 内部工具方法 =====================
+
+    private Request buildGetRequest(String path) {
+        String time = String.valueOf(System.currentTimeMillis() / 1000);
+        return new Request.Builder()
+                .url(BASE_URL + path)
+                .get()
+                .addHeader("RtaId", rtaId)
+                .addHeader("Authorization", buildAuthorization(time))
+                .addHeader("Time", time)
+                .addHeader("Content-Type", "application/json")
+                .build();
+    }
+
+    private Request buildPostRequest(String path, String jsonBody) {
+        String time = String.valueOf(System.currentTimeMillis() / 1000);
+        RequestBody body = RequestBody.create(JSON, jsonBody);
+        return new Request.Builder()
+                .url(BASE_URL + path)
+                .post(body)
+                .addHeader("RtaId", rtaId)
+                .addHeader("Authorization", buildAuthorization(time))
+                .addHeader("Time", time)
+                .build();
+    }
+
+    /**
+     * Authorization = md5(RtaId + Token + Time)
+     * 文档示例: md5(10000|11502dfcd79dbbd08a0203dbde9a9cc5|1497196800)
+     * 注意:文档中字符串直接拼接,无分隔符
+     */
+    private String buildAuthorization(String time) {
+        String raw = rtaId + token + time;
+        return md5(raw);
+    }
+
+    private String md5(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder(32);
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b & 0xff));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            throw new RuntimeException("MD5 failed", e);
+        }
+    }
+
+    private <T> T execute(Request request, TypeReference<T> typeRef) throws Exception {
+        try (Response response = httpClient.newCall(request).execute()) {
+            if (!response.isSuccessful()) {
+                log.error("[RtaManage] HTTP error, status={}, url={}", response.code(), request.url());
+                throw new IOException("RTA Manage API HTTP error: " + response.code());
+            }
+            String respBody = response.body() != null ? response.body().string() : "";
+            log.debug("[RtaManage] {} -> {}", request.url(), respBody);
+            return objectMapper.readValue(respBody, typeRef);
+        }
+    }
+}

+ 660 - 0
core/src/main/java/com/tzld/rta/codec/RtaProtobufCodec.java

@@ -0,0 +1,660 @@
+package com.tzld.rta.codec;
+
+import com.google.protobuf.CodedInputStream;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.WireFormat;
+import com.tzld.rta.model.DynamicContentInfoModel;
+import com.tzld.rta.model.DynamicProductInfoModel;
+import com.tzld.rta.model.RtaRequestModel;
+import com.tzld.rta.model.RtaResponseModel;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+
+/**
+ * 腾讯RTA Protobuf 手工编解码器
+ * <p>
+ * 按照 rta.proto 的 field number 手动读写,避免依赖 protoc 生成代码
+ * 协议参考: https://wiki.algo.com.cn/docs/rta/dev/proto
+ * <p>
+ * 完整覆盖 proto 所有字段:
+ * - RtaRequest: id/is_ping/is_test/device/request_info/exps/ad_infos/unified_request_id/fl_models/crt_template_id/rta_trace_id_list/ad_request_time
+ * - RtaResponse: request_id/code/processing_time_ms/response_cache_time/dsp_tag/dynamic_product_infos/out_target_id/target_infos/aid_whitelist/ad_results/fl_model_res_infos/dsp_tag_str/sdpa_dynamic_product_infos
+ */
+@Slf4j
+public class RtaProtobufCodec {
+
+    // ====================== 解码 RtaRequest ======================
+
+    /**
+     * 解析 RtaRequest protobuf 二进制数据
+     *
+     * @param bytes 请求字节
+     * @return 解析后的模型
+     */
+    public static RtaRequestModel decodeRequest(byte[] bytes) throws IOException {
+        RtaRequestModel req = new RtaRequestModel();
+        CodedInputStream input = CodedInputStream.newInstance(bytes);
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            int fieldNumber = tag >>> 3;
+            switch (fieldNumber) {
+                case 0:
+                    done = true;
+                    break;
+                case 1: // id (string)
+                    req.setId(input.readString());
+                    break;
+                case 2: // is_ping (bool)
+                    req.setPing(input.readBool());
+                    break;
+                case 3: // is_test (bool)
+                    req.setTest(input.readBool());
+                    break;
+                case 5: // device (message)
+                    int deviceLength = input.readRawVarint32();
+                    int deviceLimit = input.pushLimit(deviceLength);
+                    req.setDevice(decodeDevice(input));
+                    input.popLimit(deviceLimit);
+                    break;
+                case 6: // siteset_id (uint64, 已禁用)
+                    req.setSitesetId(input.readUInt64());
+                    break;
+                case 8: // request_info (message)
+                    int reqInfoLen = input.readRawVarint32();
+                    int reqInfoLimit = input.pushLimit(reqInfoLen);
+                    req.setRequestInfo(decodeRequestInfo(input));
+                    input.popLimit(reqInfoLimit);
+                    break;
+                case 9: // exps (repeated message)
+                    int expLen = input.readRawVarint32();
+                    int expLimit = input.pushLimit(expLen);
+                    RtaRequestModel.ExperimentModel exp = decodeExperiment(input);
+                    input.popLimit(expLimit);
+                    if (req.getExps() == null) req.setExps(new ArrayList<>());
+                    req.getExps().add(exp);
+                    break;
+                case 11: // ad_infos (repeated message, 二次请求)
+                    int adInfoLen = input.readRawVarint32();
+                    int adInfoLimit = input.pushLimit(adInfoLen);
+                    RtaRequestModel.AdInfoModel adInfo = decodeAdInfo(input);
+                    input.popLimit(adInfoLimit);
+                    if (req.getAdInfos() == null) req.setAdInfos(new ArrayList<>());
+                    req.getAdInfos().add(adInfo);
+                    break;
+                case 12: // unified_request_id (string)
+                    req.setUnifiedRequestId(input.readString());
+                    break;
+                case 14: // fl_models (repeated message)
+                    int flLen = input.readRawVarint32();
+                    int flLimit = input.pushLimit(flLen);
+                    RtaRequestModel.FLModelModel flModel = decodeFLModel(input);
+                    input.popLimit(flLimit);
+                    if (req.getFlModels() == null) req.setFlModels(new ArrayList<>());
+                    req.getFlModels().add(flModel);
+                    break;
+                case 15: // crt_template_id (repeated uint32, packed)
+                    decodePacked(input, tag, (val) -> {
+                        if (req.getCrtTemplateIds() == null) req.setCrtTemplateIds(new ArrayList<>());
+                        req.getCrtTemplateIds().add((int) val);
+                    });
+                    break;
+                case 18: // rta_trace_id_list (repeated string)
+                    if (req.getRtaTraceIdList() == null) req.setRtaTraceIdList(new ArrayList<>());
+                    req.getRtaTraceIdList().add(input.readString());
+                    break;
+                case 19: // ad_request_time (uint64)
+                    req.setAdRequestTime(input.readUInt64());
+                    break;
+                default:
+                    if (!input.skipField(tag)) {
+                        done = true;
+                    }
+                    break;
+            }
+        }
+        return req;
+    }
+
+    private static RtaRequestModel.RequestInfoModel decodeRequestInfo(CodedInputStream input) throws IOException {
+        RtaRequestModel.RequestInfoModel info = new RtaRequestModel.RequestInfoModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            switch (tag >>> 3) {
+                case 0: done = true; break;
+                case 1: info.setRequestType(input.readEnum()); break;
+                default: if (!input.skipField(tag)) { done = true; } break;
+            }
+        }
+        return info;
+    }
+
+    private static RtaRequestModel.ExperimentModel decodeExperiment(CodedInputStream input) throws IOException {
+        RtaRequestModel.ExperimentModel exp = new RtaRequestModel.ExperimentModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            switch (tag >>> 3) {
+                case 0: done = true; break;
+                case 1: exp.setExpId((int) input.readUInt32()); break;
+                default: if (!input.skipField(tag)) { done = true; } break;
+            }
+        }
+        return exp;
+    }
+
+    private static RtaRequestModel.AdInfoModel decodeAdInfo(CodedInputStream input) throws IOException {
+        RtaRequestModel.AdInfoModel adInfo = new RtaRequestModel.AdInfoModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            switch (tag >>> 3) {
+                case 0: done = true; break;
+                case 1: adInfo.setAdgroupId(input.readUInt64()); break;
+                case 2: // creative_infos (repeated message)
+                    int crtLen = input.readRawVarint32();
+                    int crtLimit = input.pushLimit(crtLen);
+                    RtaRequestModel.CreativeInfoModel crtInfo = decodeCreativeInfo(input);
+                    input.popLimit(crtLimit);
+                    if (adInfo.getCreativeInfos() == null) adInfo.setCreativeInfos(new ArrayList<>());
+                    adInfo.getCreativeInfos().add(crtInfo);
+                    break;
+                case 3: // product_infos (repeated message)
+                    int prodLen = input.readRawVarint32();
+                    int prodLimit = input.pushLimit(prodLen);
+                    RtaRequestModel.ProductInfoModel prodInfo = decodeProductInfo(input);
+                    input.popLimit(prodLimit);
+                    if (adInfo.getProductInfos() == null) adInfo.setProductInfos(new ArrayList<>());
+                    adInfo.getProductInfos().add(prodInfo);
+                    break;
+                case 4: adInfo.setAdvertiserId(input.readUInt32()); break;
+                case 5: adInfo.setOutTargetId(input.readString()); break;
+                default: if (!input.skipField(tag)) { done = true; } break;
+            }
+        }
+        return adInfo;
+    }
+
+    private static RtaRequestModel.CreativeInfoModel decodeCreativeInfo(CodedInputStream input) throws IOException {
+        RtaRequestModel.CreativeInfoModel info = new RtaRequestModel.CreativeInfoModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            switch (tag >>> 3) {
+                case 0: done = true; break;
+                case 1: info.setCreativeId(input.readUInt64()); break;
+                case 2: info.setCrtTemplateId((int) input.readUInt32()); break;
+                default: if (!input.skipField(tag)) { done = true; } break;
+            }
+        }
+        return info;
+    }
+
+    private static RtaRequestModel.ProductInfoModel decodeProductInfo(CodedInputStream input) throws IOException {
+        RtaRequestModel.ProductInfoModel info = new RtaRequestModel.ProductInfoModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            switch (tag >>> 3) {
+                case 0: done = true; break;
+                case 1: info.setProductLib(input.readUInt64()); break;
+                case 2: info.setOutProductId(input.readString()); break;
+                default: if (!input.skipField(tag)) { done = true; } break;
+            }
+        }
+        return info;
+    }
+
+    private static RtaRequestModel.FLModelModel decodeFLModel(CodedInputStream input) throws IOException {
+        RtaRequestModel.FLModelModel model = new RtaRequestModel.FLModelModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            switch (tag >>> 3) {
+                case 0: done = true; break;
+                case 1: model.setModelId(input.readUInt64()); break;
+                case 2: model.setModelName(input.readString()); break;
+                case 3: model.setModelVersion(input.readString()); break;
+                case 4: model.setModelType(input.readEnum()); break;
+                case 5: // embedding (repeated float, packed)
+                    decodePacked(input, tag, (val) -> {
+                        // packed float 需特殊处理,此处作为原始bits读取
+                        if (model.getEmbedding() == null) model.setEmbedding(new ArrayList<>());
+                        model.getEmbedding().add(Float.intBitsToFloat((int) val));
+                    });
+                    break;
+                default: if (!input.skipField(tag)) { done = true; } break;
+            }
+        }
+        return model;
+    }
+
+    /**
+     * 解析 Device 子消息
+     */
+    private static RtaRequestModel.DeviceModel decodeDevice(CodedInputStream input) throws IOException {
+        RtaRequestModel.DeviceModel device = new RtaRequestModel.DeviceModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            int fieldNumber = tag >>> 3;
+            switch (fieldNumber) {
+                case 0:
+                    done = true;
+                    break;
+                case 1: // os (enum)
+                    device.setOs(input.readEnum());
+                    break;
+                case 2: // idfa_md5sum (string)
+                    device.setIdfaMd5sum(input.readString());
+                    break;
+                case 3: // imei_md5sum (string)
+                    device.setImeiMd5sum(input.readString());
+                    break;
+                case 4: // android_id_md5sum (string)
+                    device.setAndroidIdMd5sum(input.readString());
+                    break;
+                case 5: // mac_md5sum (string)
+                    device.setMacMd5sum(input.readString());
+                    break;
+                case 6: // oaid_md5sum (string)
+                    device.setOaidMd5sum(input.readString());
+                    break;
+                case 7: // ip (string)
+                    device.setIp(input.readString());
+                    break;
+                case 9: // doubtful_ids_list (repeated enum)
+                    if (device.getDoubtfulIdsList() == null) device.setDoubtfulIdsList(new ArrayList<>());
+                    device.getDoubtfulIdsList().add(input.readEnum());
+                    break;
+                case 10: // cached_deviceid_type (enum)
+                    device.setCachedDeviceidType(input.readEnum());
+                    break;
+                case 13: // qaid_infos (repeated message)
+                    int qaidLen = input.readRawVarint32();
+                    int qaidLimit = input.pushLimit(qaidLen);
+                    RtaRequestModel.QaidInfoModel qaid = decodeQaidInfo(input);
+                    input.popLimit(qaidLimit);
+                    if (device.getQaidInfos() == null) device.setQaidInfos(new ArrayList<>());
+                    device.getQaidInfos().add(qaid);
+                    break;
+                case 14: // wx_openid (repeated message)
+                    int wxLen = input.readRawVarint32();
+                    int wxLimit = input.pushLimit(wxLen);
+                    RtaRequestModel.WxOpenidModel wx = decodeWxOpenid(input);
+                    input.popLimit(wxLimit);
+                    if (device.getWxOpenids() == null) device.setWxOpenids(new ArrayList<>());
+                    device.getWxOpenids().add(wx);
+                    break;
+                default:
+                    if (!input.skipField(tag)) {
+                        done = true;
+                    }
+                    break;
+            }
+        }
+        return device;
+    }
+
+    private static RtaRequestModel.QaidInfoModel decodeQaidInfo(CodedInputStream input) throws IOException {
+        RtaRequestModel.QaidInfoModel qaid = new RtaRequestModel.QaidInfoModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            switch (tag >>> 3) {
+                case 0: done = true; break;
+                case 1: qaid.setVersion((int) input.readUInt32()); break;
+                case 2: qaid.setQaid(input.readString()); break;
+                case 3: qaid.setOriginVersion(input.readString()); break;
+                case 4: qaid.setQaidMd5sum(input.readString()); break;
+                default: if (!input.skipField(tag)) { done = true; } break;
+            }
+        }
+        return qaid;
+    }
+
+    private static RtaRequestModel.WxOpenidModel decodeWxOpenid(CodedInputStream input) throws IOException {
+        RtaRequestModel.WxOpenidModel wx = new RtaRequestModel.WxOpenidModel();
+        boolean done = false;
+        while (!done) {
+            int tag = input.readTag();
+            switch (tag >>> 3) {
+                case 0: done = true; break;
+                case 1: wx.setAppid(input.readString()); break;
+                case 2: wx.setOpenid(input.readString()); break;
+                default: if (!input.skipField(tag)) { done = true; } break;
+            }
+        }
+        return wx;
+    }
+
+    // ====================== 编码 RtaResponse ======================
+
+    /**
+     * 将 RtaResponseModel 序列化为 protobuf 二进制
+     *
+     * @param response 响应模型
+     * @return protobuf 字节
+     */
+    public static byte[] encodeResponse(RtaResponseModel response) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(256);
+        CodedOutputStream output = CodedOutputStream.newInstance(baos);
+
+        // field 1: request_id (string)
+        if (response.getRequestId() != null && !response.getRequestId().isEmpty()) {
+            output.writeString(1, response.getRequestId());
+        }
+
+        // field 2: code (uint32)
+        output.writeUInt32(2, response.getCode());
+
+        // field 4: processing_time_ms (int32)
+        if (response.getProcessingTimeMs() > 0) {
+            output.writeInt32(4, response.getProcessingTimeMs());
+        }
+
+        // field 7: response_cache_time (uint32)
+        if (response.getResponseCacheTime() > 0) {
+            output.writeUInt32(7, response.getResponseCacheTime());
+        }
+
+        // field 8: dsp_tag (uint64)
+        if (response.getDspTag() > 0) {
+            output.writeUInt64(8, response.getDspTag());
+        }
+
+        // field 9: dynamic_product_infos (repeated message, mDPA 全局级)
+        if (response.getDynamicProductInfos() != null) {
+            for (DynamicProductInfoModel dpi : response.getDynamicProductInfos()) {
+                writeMessage(output, 9, encodeDynamicProductInfo(dpi));
+            }
+        }
+
+        // field 10: out_target_id (repeated string)
+        if (response.getOutTargetIds() != null) {
+            for (String targetId : response.getOutTargetIds()) {
+                output.writeString(10, targetId);
+            }
+        }
+
+        // field 12: target_infos (repeated message)
+        if (response.getTargetInfos() != null) {
+            for (RtaResponseModel.TargetInfoModel ti : response.getTargetInfos()) {
+                writeMessage(output, 12, encodeTargetInfo(ti));
+            }
+        }
+
+        // field 13: aid_whitelist (repeated uint64, packed)
+        if (response.getAidWhitelist() != null && !response.getAidWhitelist().isEmpty()) {
+            ByteArrayOutputStream packedBaos = new ByteArrayOutputStream();
+            CodedOutputStream packedOut = CodedOutputStream.newInstance(packedBaos);
+            for (Long aid : response.getAidWhitelist()) {
+                packedOut.writeUInt64NoTag(aid);
+            }
+            packedOut.flush();
+            byte[] packedBytes = packedBaos.toByteArray();
+            output.writeTag(13, WireFormat.WIRETYPE_LENGTH_DELIMITED);
+            output.writeRawVarint32(packedBytes.length);
+            output.writeRawBytes(packedBytes);
+        }
+
+        // field 14: ad_results (repeated message, 二次请求改价)
+        if (response.getAdResults() != null) {
+            for (RtaResponseModel.AdResultModel ar : response.getAdResults()) {
+                writeMessage(output, 14, encodeAdResult(ar));
+            }
+        }
+
+        // field 16: fl_model_res_infos (repeated message)
+        if (response.getFlModelResInfos() != null) {
+            for (RtaResponseModel.FLModelResInfoModel fl : response.getFlModelResInfos()) {
+                writeMessage(output, 16, encodeFLModelResInfo(fl));
+            }
+        }
+
+        // field 17: dsp_tag_str (string)
+        if (response.getDspTagStr() != null && !response.getDspTagStr().isEmpty()) {
+            output.writeString(17, response.getDspTagStr());
+        }
+
+        // field 18: sdpa_dynamic_product_infos (repeated message)
+        if (response.getSdpaDynamicProductInfos() != null) {
+            for (DynamicProductInfoModel sdpa : response.getSdpaDynamicProductInfos()) {
+                writeMessage(output, 18, encodeDynamicProductInfo(sdpa));
+            }
+        }
+
+        output.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeTargetInfo(RtaResponseModel.TargetInfoModel ti) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(128);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+
+        // field 1: out_target_id (string)
+        if (ti.getOutTargetId() != null && !ti.getOutTargetId().isEmpty()) {
+            out.writeString(1, ti.getOutTargetId());
+        }
+        // field 3: cpc_price (uint32)
+        if (ti.getCpcPrice() > 0) {
+            out.writeUInt32(3, ti.getCpcPrice());
+        }
+        // field 4: dynamic_product_infos (repeated message, mDPA 策略级)
+        if (ti.getDynamicProductInfos() != null) {
+            for (DynamicProductInfoModel dpi : ti.getDynamicProductInfos()) {
+                writeMessage(out, 4, encodeDynamicProductInfo(dpi));
+            }
+        }
+        // field 5: cpa_price (uint32)
+        if (ti.getCpaPrice() > 0) {
+            out.writeUInt32(5, ti.getCpaPrice());
+        }
+        // field 6: user_weight_factor (float)
+        if (ti.getUserWeightFactor() != 0f) {
+            out.writeFloat(6, ti.getUserWeightFactor());
+        }
+        // field 7: cpc_factor (float)
+        if (ti.getCpcFactor() != 0f) {
+            out.writeFloat(7, ti.getCpcFactor());
+        }
+        // field 8: aid (uint64)
+        if (ti.getAid() > 0) {
+            out.writeUInt64(8, ti.getAid());
+        }
+        // field 9: dsp_tag (uint64)
+        if (ti.getDspTag() > 0) {
+            out.writeUInt64(9, ti.getDspTag());
+        }
+        // field 10: dsp_tag_str (string)
+        if (ti.getDspTagStr() != null && !ti.getDspTagStr().isEmpty()) {
+            out.writeString(10, ti.getDspTagStr());
+        }
+        // field 11: pcvr (float)
+        if (ti.getPcvr() != 0f) {
+            out.writeFloat(11, ti.getPcvr());
+        }
+        // field 12: advertiser_id (uint64)
+        if (ti.getAdvertiserId() > 0) {
+            out.writeUInt64(12, ti.getAdvertiserId());
+        }
+        // field 14: sdpa_product_id (string)
+        if (ti.getSdpaProductId() != null && !ti.getSdpaProductId().isEmpty()) {
+            out.writeString(14, ti.getSdpaProductId());
+        }
+        // field 15: sdpa_product_lib (uint64)
+        if (ti.getSdpaProductLib() > 0) {
+            out.writeUInt64(15, ti.getSdpaProductLib());
+        }
+        // field 17: dynamic_content_infos (repeated message, DCA)
+        if (ti.getDynamicContentInfos() != null) {
+            for (DynamicContentInfoModel dci : ti.getDynamicContentInfos()) {
+                writeMessage(out, 17, encodeDynamicContentInfo(dci));
+            }
+        }
+        // field 18: sp_action (message)
+        if (ti.getSpAction() != null) {
+            writeMessage(out, 18, encodeSpAssist(ti.getSpAction()));
+        }
+
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeDynamicProductInfo(DynamicProductInfoModel dpi) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(64);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        // field 1: product_lib (uint64)
+        if (dpi.getProductLib() > 0) {
+            out.writeUInt64(1, dpi.getProductLib());
+        }
+        // field 2: products (repeated message)
+        if (dpi.getProducts() != null) {
+            for (DynamicProductInfoModel.Product p : dpi.getProducts()) {
+                writeMessage(out, 2, encodeProduct(p));
+            }
+        }
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeProduct(DynamicProductInfoModel.Product p) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(32);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        if (p.getId() > 0) out.writeUInt64(1, p.getId());
+        if (p.getPriority() > 0) out.writeUInt32(2, p.getPriority());
+        if (p.getTimestamp() > 0) out.writeUInt64(3, p.getTimestamp());
+        if (p.getSdpaProductId() != null && !p.getSdpaProductId().isEmpty()) out.writeString(4, p.getSdpaProductId());
+        if (p.getIdStr() != null && !p.getIdStr().isEmpty()) out.writeString(5, p.getIdStr());
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeDynamicContentInfo(DynamicContentInfoModel dci) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(32);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        if (dci.getTags() != null) {
+            for (DynamicContentInfoModel.Tag tag : dci.getTags()) {
+                writeMessage(out, 1, encodeTag(tag));
+            }
+        }
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeTag(DynamicContentInfoModel.Tag tag) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(16);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        if (tag.getName() != null && !tag.getName().isEmpty()) out.writeString(1, tag.getName());
+        if (tag.getValue() != null && !tag.getValue().isEmpty()) out.writeString(2, tag.getValue());
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeSpAssist(RtaResponseModel.SpAssistModel sp) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(8);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        if (sp.getType() != 0) out.writeEnum(1, sp.getType());
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeAdResult(RtaResponseModel.AdResultModel ar) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(64);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        // field 1: adgroup_id (uint64)
+        if (ar.getAdgroupId() > 0) out.writeUInt64(1, ar.getAdgroupId());
+        // field 2: creative_results (repeated message)
+        if (ar.getCreativeResults() != null) {
+            for (RtaResponseModel.CreativeResultModel cr : ar.getCreativeResults()) {
+                writeMessage(out, 2, encodeCreativeResult(cr));
+            }
+        }
+        // field 3: cpc_price (uint32, 广告级)
+        if (ar.getCpcPrice() > 0) out.writeUInt32(3, ar.getCpcPrice());
+        // field 4: ignore (bool)
+        if (ar.isIgnore()) out.writeBool(4, true);
+        // field 5: product_results (repeated message)
+        if (ar.getProductResults() != null) {
+            for (RtaResponseModel.ProductResultModel pr : ar.getProductResults()) {
+                writeMessage(out, 5, encodeProductResult(pr));
+            }
+        }
+        // field 6: cpa_price (uint32, 广告级)
+        if (ar.getCpaPrice() > 0) out.writeUInt32(6, ar.getCpaPrice());
+        // field 12: raise_factor_to_tencent (float)
+        if (ar.getRaiseFactorToTencent() > 1.0f) out.writeFloat(12, ar.getRaiseFactorToTencent());
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeCreativeResult(RtaResponseModel.CreativeResultModel cr) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(32);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        if (cr.getCreativeId() > 0) out.writeUInt64(1, cr.getCreativeId());
+        if (cr.getCpcPrice() > 0) out.writeUInt32(2, cr.getCpcPrice());
+        if (cr.getOutRaiseFactor() != 0f) out.writeFloat(3, cr.getOutRaiseFactor());
+        if (cr.getCpaPrice() > 0) out.writeUInt32(4, cr.getCpaPrice());
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeProductResult(RtaResponseModel.ProductResultModel pr) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(32);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        if (pr.getProductLib() > 0) out.writeUInt64(1, pr.getProductLib());
+        if (pr.getOutProductId() != null && !pr.getOutProductId().isEmpty()) out.writeString(2, pr.getOutProductId());
+        if (pr.getCpcPrice() > 0) out.writeUInt32(3, pr.getCpcPrice());
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    private static byte[] encodeFLModelResInfo(RtaResponseModel.FLModelResInfoModel fl) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(16);
+        CodedOutputStream out = CodedOutputStream.newInstance(baos);
+        if (fl.getModelId() > 0) out.writeUInt64(1, fl.getModelId());
+        if (fl.getStatus() != 0) out.writeEnum(2, fl.getStatus());
+        out.flush();
+        return baos.toByteArray();
+    }
+
+    // ====================== 工具方法 ======================
+
+    /**
+     * 写入嵌套 message 字段
+     */
+    private static void writeMessage(CodedOutputStream output, int fieldNumber, byte[] bytes) throws IOException {
+        output.writeTag(fieldNumber, WireFormat.WIRETYPE_LENGTH_DELIMITED);
+        output.writeRawVarint32(bytes.length);
+        output.writeRawBytes(bytes);
+    }
+
+    /**
+     * 处理 packed repeated 字段(int/uint32/enum/float 等)
+     * 同时兼容 packed 和 non-packed 两种编码方式
+     */
+    private static void decodePacked(CodedInputStream input, int tag, LongConsumer consumer) throws IOException {
+        int wireType = tag & 0x7;
+        if (wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED) {
+            // packed 编码
+            int length = input.readRawVarint32();
+            int limit = input.pushLimit(length);
+            while (input.getBytesUntilLimit() > 0) {
+                consumer.accept(input.readRawVarint32());
+            }
+            input.popLimit(limit);
+        } else {
+            // non-packed 编码
+            consumer.accept(input.readRawVarint32());
+        }
+    }
+
+    @FunctionalInterface
+    private interface LongConsumer {
+        void accept(long value) throws IOException;
+    }
+}

+ 61 - 0
core/src/main/java/com/tzld/rta/config/RtaConfig.java

@@ -0,0 +1,61 @@
+package com.tzld.rta.config;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * RTA 配置类
+ * <p>
+ * 1. 本地 Caffeine 缓存:缓存设备命中结果,减少 Redis 压力
+ *    - 缓存时间: 写入后 expireAfterWrite,默认 60s
+ *    - 最大容量: 默认 50W 条
+ * <p>
+ * 2. 配置说明(Apollo 动态配置):
+ *    rta.enabled=true/false          - 总开关
+ *    rta.local.cache.enabled=true    - 本地缓存开关
+ *    rta.local.cache.ttl.hit=60      - 缓存TTL(秒)
+ *    rta.local.cache.max.size=500000 - 本地缓存最大容量
+ */
+@Slf4j
+@Configuration
+public class RtaConfig {
+
+    /**
+     * 本地缓存 TTL(秒)
+     * 命中和未命中的设备 ID 均使用此 TTL
+     */
+    @Value("${rta.local.cache.ttl.hit:60}")
+    private int localCacheTtlHit;
+
+    /**
+     * 本地缓存最大容量
+     * 100W 设备 × 约 200B per entry ≈ 200MB
+     */
+    @Value("${rta.local.cache.max.size:500000}")
+    private long localCacheMaxSize;
+
+    /**
+     * RTA 设备人群命中结果本地缓存
+     * key: deviceId (md5)
+     * value: List<String> 命中的 outTargetIds(null 表示未命中)
+     * <p>
+     * 使用 Caffeine 的 expireAfterWrite 策略,保证时效性
+     */
+    @Bean("rtaLocalCache")
+    public Cache<String, List<String>> rtaLocalCache() {
+        return Caffeine.newBuilder()
+                .maximumSize(localCacheMaxSize)
+                // 写入后固定过期(命中和未命中用同一 TTL,简化逻辑)
+                .expireAfterWrite(localCacheTtlHit, TimeUnit.SECONDS)
+                // 记录缓存统计(便于监控)
+                .recordStats()
+                .build();
+    }
+}

+ 28 - 0
core/src/main/java/com/tzld/rta/model/DynamicContentInfoModel.java

@@ -0,0 +1,28 @@
+package com.tzld.rta.model;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * DCA 动态创意广告的标签信息(对应 proto DynamicContentInfo)
+ * <p>
+ * 用于动态创意广告(DCA)场景,RTA 响应中通过 TargetInfo.dynamic_content_infos 携带。
+ */
+@Data
+public class DynamicContentInfoModel {
+
+    /** 动态创意标签列表 */
+    private List<Tag> tags;
+
+    /**
+     * 动态创意标签(对应 proto DynamicContentInfo.Tag)
+     */
+    @Data
+    public static class Tag {
+        /** 标签名称 */
+        private String name;
+        /** 标签值(预留字段) */
+        private String value;
+    }
+}

+ 38 - 0
core/src/main/java/com/tzld/rta/model/DynamicProductInfoModel.java

@@ -0,0 +1,38 @@
+package com.tzld.rta.model;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * DPA 动态商品广告的商品信息(对应 proto DynamicProductInfo)
+ * <p>
+ * 用于 mDPA(多商品动态广告)和 sDPA(单商品动态广告)场景。
+ * 在 RtaResponse 中通过 dynamic_product_infos / sdpa_dynamic_product_infos 字段携带。
+ */
+@Data
+public class DynamicProductInfoModel {
+
+    /** 商品库ID */
+    private long productLib;
+
+    /** 推荐商品列表 */
+    private List<Product> products;
+
+    /**
+     * 单个商品信息(对应 proto DynamicProductInfo.Product)
+     */
+    @Data
+    public static class Product {
+        /** mDPA 商品ID(数字型,与 idStr 二选一,优先使用本字段) */
+        private long id;
+        /** 商品推荐权重 */
+        private int priority;
+        /** 用户与商品互动时间(毫秒时间戳) */
+        private long timestamp;
+        /** sDPA 商品ID */
+        private String sdpaProductId;
+        /** 字符串型商品ID(与 id 二选一,优先使用 id 字段) */
+        private String idStr;
+    }
+}

+ 168 - 0
core/src/main/java/com/tzld/rta/model/RtaRequestModel.java

@@ -0,0 +1,168 @@
+package com.tzld.rta.model;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 腾讯RTA请求模型(对应 proto RtaRequest)
+ */
+@Data
+public class RtaRequestModel {
+
+    /** 请求ID */
+    private String id;
+
+    /** 网络耗时监测模式 - 若为true则直接空响应即可 */
+    private boolean isPing;
+
+    /** 模拟测试模式 */
+    private boolean isTest;
+
+    /** 设备信息 */
+    private DeviceModel device;
+
+    /** 流量站点集(已禁用字段,仅保留兼容) */
+    private Long sitesetId;
+
+    /** 请求扩展信息 */
+    private RequestInfoModel requestInfo;
+
+    /** 实验分组信息列表(A/B实验) */
+    private List<ExperimentModel> exps;
+
+    /** 二次请求:粗排检索出的广告列表 */
+    private List<AdInfoModel> adInfos;
+
+    /** 广告请求唯一标识 */
+    private String unifiedRequestId;
+
+    /** 联邦学习模型信息列表 */
+    private List<FLModelModel> flModels;
+
+    /** 二次请求:创意形式ID列表 */
+    private List<Integer> crtTemplateIds;
+
+    /** 二次请求:关联的一次请求 trace ID 列表 */
+    private List<String> rtaTraceIdList;
+
+    /** 广告请求时间(二次请求链路统一) */
+    private long adRequestTime;
+
+    // ===================== 内部子模型 =====================
+
+    @Data
+    public static class DeviceModel {
+        /** 操作系统: 0=unknown,1=ios,2=android,3=windows,4=symbian,5=java,6=mac,7=harmony */
+        private int os;
+        /** iOS IDFA MD5 */
+        private String idfaMd5sum;
+        /** Android IMEI MD5 */
+        private String imeiMd5sum;
+        /** Android ID MD5 */
+        private String androidIdMd5sum;
+        /** MAC MD5 */
+        private String macMd5sum;
+        /** OAID MD5 */
+        private String oaidMd5sum;
+        /** 用户IP */
+        private String ip;
+        /** 不可信设备标识列表(风控字段) */
+        private List<Integer> doubtfulIdsList;
+        /** 策略缓存设备类型: 0=idfa_md5,1=imei_md5,2=oaid,3=oaid_md5,4=androidid_md5,5=mac_md5,6=nil,7=qaid */
+        private int cachedDeviceidType;
+        /** CAID信息列表 */
+        private List<QaidInfoModel> qaidInfos;
+        /** 微信openid列表 */
+        private List<WxOpenidModel> wxOpenids;
+    }
+
+    @Data
+    public static class QaidInfoModel {
+        /** CAID版本 */
+        private int version;
+        /** CAID原始值 */
+        private String qaid;
+        /** CAID版本字符串 */
+        private String originVersion;
+        /** CAID MD5 */
+        private String qaidMd5sum;
+    }
+
+    @Data
+    public static class WxOpenidModel {
+        /** 小程序appid */
+        private String appid;
+        /** 对应的openid */
+        private String openid;
+    }
+
+    /**
+     * 请求扩展信息(对应 proto RequestInfo)
+     * requestType: 0=普通请求, 2=二次请求
+     */
+    @Data
+    public static class RequestInfoModel {
+        /** 请求类型:0=普通请求, 2=二次请求(ADLIST_REQUEST) */
+        private int requestType;
+    }
+
+    /**
+     * A/B 实验分组信息(对应 proto Experiment)
+     */
+    @Data
+    public static class ExperimentModel {
+        /** 实验分组ID */
+        private int expId;
+    }
+
+    /**
+     * 二次请求中的广告信息(对应 proto AdInfo)
+     */
+    @Data
+    public static class AdInfoModel {
+        /** 广告组ID */
+        private long adgroupId;
+        /** 创意信息列表 */
+        private List<CreativeInfoModel> creativeInfos;
+        /** 商品信息列表 */
+        private List<ProductInfoModel> productInfos;
+        /** 广告主账号ID */
+        private long advertiserId;
+        /** 外部策略ID */
+        private String outTargetId;
+    }
+
+    @Data
+    public static class CreativeInfoModel {
+        /** 创意ID */
+        private long creativeId;
+        /** 创意形式ID */
+        private int crtTemplateId;
+    }
+
+    @Data
+    public static class ProductInfoModel {
+        /** 商品库ID */
+        private long productLib;
+        /** 外部商品ID */
+        private String outProductId;
+    }
+
+    /**
+     * 联邦学习模型信息(对应 proto FLModel)
+     */
+    @Data
+    public static class FLModelModel {
+        /** 模型ID */
+        private long modelId;
+        /** 模型名称 */
+        private String modelName;
+        /** 模型版本 */
+        private String modelVersion;
+        /** 模型类型:0=DEFAULT */
+        private int modelType;
+        /** 模型embedding向量 */
+        private List<Float> embedding;
+    }
+}

+ 185 - 0
core/src/main/java/com/tzld/rta/model/RtaResponseModel.java

@@ -0,0 +1,185 @@
+package com.tzld.rta.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 腾讯RTA响应模型(对应 proto RtaResponse)
+ * <p>
+ * code 说明(重要):
+ *   0     = 参竞(策略由 outTargetIds 指定,为空则全部账号参竞)
+ *   非0   = 不参竞(一般回复 1)
+ *   按 protobuf 规范,code 字段默认值为 0,即不写 code 等同参竞
+ */
+@Data
+@Builder
+public class RtaResponseModel {
+
+    /** 回复ID,应原样填入 RtaRequest.id */
+    private String requestId;
+
+    /**
+     * 返回状态码:
+     *   0   = 参竞(配合 outTargetIds 指定参竞范围)
+     *   1   = 不参竞(拒绝所有)
+     */
+    private int code;
+
+    /** 处理时间(ms),可选,便于排查 */
+    private int processingTimeMs;
+
+    /**
+     * 动态缓存时间(秒),可选
+     * 腾讯侧会缓存本次回复,下次命中缓存不再请求
+     * 对未命中人群可设置更长时间(如 3600s)以降低 QPS
+     * 注意:需在腾讯侧提前开启缓存功能,且不超过 7 天
+     */
+    private int responseCacheTime;
+
+    /** 自定义归因串联字段,全局级(数字型) */
+    private long dspTag;
+
+    /** 自定义归因串联字段,全局级(字符型) */
+    private String dspTagStr;
+
+    /** mDPA 推荐商品信息(全局级) */
+    private List<DynamicProductInfoModel> dynamicProductInfos;
+
+    /**
+     * 被调方策略ID列表(参竞时返回,code=0 时生效)
+     * 为空时表示全部绑定账号参竞
+     */
+    private List<String> outTargetIds;
+
+    /** 策略详细信息列表(含出价/加权/商品等) */
+    private List<TargetInfoModel> targetInfos;
+
+    /** 广告组ID白名单 */
+    private List<Long> aidWhitelist;
+
+    /** 二次请求:广告改价结果列表 */
+    private List<AdResultModel> adResults;
+
+    /** 联邦学习模型使用情况反馈 */
+    private List<FLModelResInfoModel> flModelResInfos;
+
+    /** sDPA 商品推荐(单商品动态广告) */
+    private List<DynamicProductInfoModel> sdpaDynamicProductInfos;
+
+    // ===================== 内部子模型 =====================
+
+    /**
+     * 策略/商品/广告主/广告扩展信息(对应 proto TargetInfo)
+     */
+    @Data
+    @Builder
+    public static class TargetInfoModel {
+        /** 策略ID */
+        private String outTargetId;
+        /** CPC出价(分) */
+        private int cpcPrice;
+        /** CPA出价(分) */
+        private int cpaPrice;
+        /** 用户加权系数 */
+        private float userWeightFactor;
+        /** CPC系数 */
+        private float cpcFactor;
+        /** 广告组ID */
+        private long aid;
+        /** 自定义归因串联字段,策略级(数字型) */
+        private long dspTag;
+        /** 自定义归因串联字段,策略级(字符型) */
+        private String dspTagStr;
+        /** pCVR回传 */
+        private float pcvr;
+        /** 广告主ID */
+        private long advertiserId;
+        /** sDPA 商品ID */
+        private String sdpaProductId;
+        /** sDPA 商品库ID */
+        private long sdpaProductLib;
+        /** mDPA 推荐商品信息(策略级) */
+        private List<DynamicProductInfoModel> dynamicProductInfos;
+        /** DCA 动态创意信息列表 */
+        private List<DynamicContentInfoModel> dynamicContentInfos;
+        /** SP 辅助决策 */
+        private SpAssistModel spAction;
+    }
+
+    /**
+     * SP 辅助决策(对应 proto SpAssist)
+     * type: 0=禁用, 1=允许替换策略ID和高阶出价, 2=只允许替换高阶出价
+     */
+    @Data
+    @Builder
+    public static class SpAssistModel {
+        /** 辅助决策类型:0=禁用, 1=REPLACE_ALL, 2=REPLACE_TARGETINFO */
+        private int type;
+    }
+
+    /**
+     * 二次请求广告改价结果(对应 proto AdResult)
+     */
+    @Data
+    @Builder
+    public static class AdResultModel {
+        /** 广告组ID */
+        private long adgroupId;
+        /** 广告级CPC改价(分),优先级:创意>广告级 */
+        private int cpcPrice;
+        /** 广告级CPA改价(分),优先级:创意>广告级 */
+        private int cpaPrice;
+        /** 是否跳过二次请求相关改价和校验逻辑,由媒体决定广告出单 */
+        private boolean ignore;
+        /** 创意级改价列表(优先级最高) */
+        private List<CreativeResultModel> creativeResults;
+        /** 商品级改价列表(优先级:商品>创意>广告) */
+        private List<ProductResultModel> productResults;
+        /** 广告主补贴系数(系数值大于1.0时有效) */
+        private float raiseFactorToTencent;
+    }
+
+    /**
+     * 创意级改价(对应 proto AdResult.CreativeResult)
+     */
+    @Data
+    @Builder
+    public static class CreativeResultModel {
+        /** 创意ID */
+        private long creativeId;
+        /** 创意级CPC改价(分) */
+        private int cpcPrice;
+        /** 客户侧扶持系数 */
+        private float outRaiseFactor;
+        /** 创意级CPA改价(分) */
+        private int cpaPrice;
+    }
+
+    /**
+     * 商品级改价(对应 proto AdResult.ProductResult)
+     */
+    @Data
+    @Builder
+    public static class ProductResultModel {
+        /** 商品库ID */
+        private long productLib;
+        /** 外部商品ID */
+        private String outProductId;
+        /** 商品级CPC改价(分),优先级:商品>创意>广告级 */
+        private int cpcPrice;
+    }
+
+    /**
+     * 联邦学习模型使用情况反馈(对应 proto FLModelResInfo)
+     */
+    @Data
+    @Builder
+    public static class FLModelResInfoModel {
+        /** 模型ID */
+        private long modelId;
+        /** 模型使用状态:0=UNKNOWN, 1=SUCCESS, 2=FAILED */
+        private int status;
+    }
+}

+ 209 - 0
core/src/main/java/com/tzld/rta/model/dto/RtaManageDTO.java

@@ -0,0 +1,209 @@
+package com.tzld.rta.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 腾讯RTA 自助管理 API DTO 汇总
+ * <p>
+ * 对应文档 2.2.x 节的所有管理接口请求/响应结构
+ * API 域名: https://api.rta.qq.com/
+ */
+public class RtaManageDTO {
+
+    // ===================== 通用响应包装 =====================
+
+    /**
+     * 所有管理 API 统一响应格式
+     * status: "success" / "error"
+     * code:   0=成功, 1=通用失败, 1601~1608=具体错误码
+     */
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class ApiResponse<T> {
+        private String status;
+        private Integer code;
+        /** 成功时为业务数据,失败时为错误描述字符串 */
+        private T data;
+    }
+
+    // ===================== 2.2.2 基本信息查询 =====================
+
+    /**
+     * GET /api/v1/RtaInfo 响应 data 结构
+     */
+    @Data
+    @NoArgsConstructor
+    public static class RtaInfo {
+        /** 广告主绑定的 RTA ID */
+        private Long rtaId;
+        /** RTA 账号名称 */
+        private String rtaName;
+        /** 公司名称 */
+        private String rtaCompanyName;
+        /** bid url */
+        private String bidUrl;
+        /** 缓存时间(秒) */
+        private Integer cacheTime;
+        /** 是否开启:0=关闭, 1=开启 */
+        private Integer enable;
+        /** 规则配置列表 */
+        private List<RuleConfig> rules;
+        /** 广告主账号列表 */
+        private List<BindInfo> bidInfos;
+
+        @Data
+        @NoArgsConstructor
+        public static class RuleConfig {
+            private Integer ruleType;
+            private Integer ruleValue;
+            private Integer ruleTypeNone;
+            private String ruleDese;
+        }
+
+        @Data
+        @NoArgsConstructor
+        public static class BindInfo {
+            private Long rtaId;
+            private String bindUrl;
+            private Integer qpsLimit;
+            private Integer phone;
+            private String agencySdk;
+            private String sdkZone;
+            private String proxyUrl;
+            private String latitude;
+        }
+    }
+
+    // ===================== 2.2.3 策略 IO 接口 =====================
+
+    /**
+     * GET /api/v1/target/get 请求参数
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class TargetGetRequest {
+        /** 页码(默认1) */
+        private Integer page;
+        /** 单页数量,最多10(默认10) */
+        private Integer size;
+        /** 外部策略 ID 列表(最多10个) */
+        private List<String> outerTargetIds;
+    }
+
+    /**
+     * GET /api/v1/target/get 响应 data 中单条策略信息
+     */
+    @Data
+    @NoArgsConstructor
+    public static class TargetInfo {
+        /** 腾讯侧 RTA 策略 ID */
+        private String outerTargetId;
+        /** 策略名称 */
+        private String targetName;
+    }
+
+    /**
+     * POST /api/v1/target/set 请求
+     * 新增策略,TargetName 支持最多 20 字符(字母/数字/下划线/中划线)
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class TargetSetRequest {
+        /** 外部策略 ID(必填,最多32字符,字母/数字/下划线/中划线) */
+        private String outerTargetId;
+        /** 策略名称(必填,最多20字符,字母/数字/下划线/中划线) */
+        private String targetName;
+    }
+
+    /**
+     * POST /api/v1/target/delete 请求
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class TargetDeleteRequest {
+        /** 外部策略 ID(必填) */
+        private String outerTargetId;
+    }
+
+    // ===================== 2.2.4 策略绑定接口 =====================
+
+    /**
+     * POST /api/v1/target/bind 请求
+     * 将策略 ID 与广告主账号/广告 ID 绑定
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class TargetBindRequest {
+        /** 外部策略 ID(必填) */
+        private String outerTargetId;
+        /** 广告主账号 ID 列表(与 adIds 二选一或同时填) */
+        private List<Long> advertiserIds;
+        /** 广告 ID 列表(与 advertiserIds 二选一或同时填) */
+        private List<Long> adIds;
+    }
+
+    /**
+     * POST /api/v1/target/bind/delete 请求
+     * 解绑策略与广告主账号/广告
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class TargetBindDeleteRequest {
+        private String outerTargetId;
+        private List<Long> advertiserIds;
+        private List<Long> adIds;
+    }
+
+    /**
+     * POST /api/v1/target/bind/status 请求
+     * 查询绑定状态
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class TargetBindStatusRequest {
+        /** 外部策略 ID(必填) */
+        private String outerTargetId;
+        /** 广告主账号 ID 列表 */
+        private List<Long> advertiserIds;
+        /** 广告 ID 列表 */
+        private List<Long> adIds;
+        /** 页码(默认1) */
+        private Integer page;
+        /** 单页数量(默认10,最大10) */
+        private Integer size;
+    }
+
+    /**
+     * 绑定状态单条记录
+     */
+    @Data
+    @NoArgsConstructor
+    public static class BindStatusInfo {
+        /** 外部策略 ID */
+        private String outerTargetId;
+        /** 广告主账号 ID */
+        private Long advertiserId;
+        /** 广告 ID */
+        private Long adId;
+        /** 绑定状态:0=未绑定, 1=已绑定 */
+        private Integer status;
+    }
+}

+ 45 - 0
core/src/main/java/com/tzld/rta/service/RtaManageService.java

@@ -0,0 +1,45 @@
+package com.tzld.rta.service;
+
+import com.tzld.rta.model.dto.RtaManageDTO;
+
+/**
+ * 腾讯 RTA 自助管理 Service
+ * 封装对腾讯 RTA 管理 API 的调用逻辑
+ */
+public interface RtaManageService {
+
+    /**
+     * 2.2.2 查询 RTA 基本信息
+     */
+    RtaManageDTO.ApiResponse<RtaManageDTO.RtaInfo> getRtaInfo();
+
+    /**
+     * 2.2.3 查询策略列表
+     */
+    RtaManageDTO.ApiResponse<?> getTargets(RtaManageDTO.TargetGetRequest request);
+
+    /**
+     * 2.2.3 新增策略
+     */
+    RtaManageDTO.ApiResponse<?> addTarget(RtaManageDTO.TargetSetRequest request);
+
+    /**
+     * 2.2.3 删除策略
+     */
+    RtaManageDTO.ApiResponse<?> deleteTarget(RtaManageDTO.TargetDeleteRequest request);
+
+    /**
+     * 2.2.4 绑定策略到广告主账号/广告
+     */
+    RtaManageDTO.ApiResponse<?> bindTarget(RtaManageDTO.TargetBindRequest request);
+
+    /**
+     * 2.2.4 解绑策略
+     */
+    RtaManageDTO.ApiResponse<?> unbindTarget(RtaManageDTO.TargetBindDeleteRequest request);
+
+    /**
+     * 2.2.4 查询绑定状态
+     */
+    RtaManageDTO.ApiResponse<?> getBindStatus(RtaManageDTO.TargetBindStatusRequest request);
+}

+ 22 - 0
core/src/main/java/com/tzld/rta/service/RtaService.java

@@ -0,0 +1,22 @@
+package com.tzld.rta.service;
+
+import com.tzld.rta.model.RtaRequestModel;
+import com.tzld.rta.model.RtaResponseModel;
+
+/**
+ * 腾讯RTA服务接口
+ * <p>
+ * 腾讯广告系统(调用方)会通过 HTTP/1.1 POST 请求携带 protobuf body 调用此接口。
+ * 被调方(本服务)需要在 60ms 内(含网络传输)返回响应。
+ * QPS 要求: 10万/秒
+ */
+public interface RtaService {
+
+    /**
+     * 处理RTA请求,返回是否参竞及策略信息
+     *
+     * @param request 解析后的RTA请求
+     * @return RTA响应(包含参竞策略)
+     */
+    RtaResponseModel process(RtaRequestModel request);
+}

+ 93 - 0
core/src/main/java/com/tzld/rta/service/impl/RtaManageServiceImpl.java

@@ -0,0 +1,93 @@
+package com.tzld.rta.service.impl;
+
+import com.tzld.rta.client.RtaManageClient;
+import com.tzld.rta.model.dto.RtaManageDTO;
+import com.tzld.rta.service.RtaManageService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 腾讯 RTA 自助管理 Service 实现
+ */
+@Slf4j
+@Service
+public class RtaManageServiceImpl implements RtaManageService {
+
+    @Autowired
+    private RtaManageClient rtaManageClient;
+
+    @Override
+    public RtaManageDTO.ApiResponse<RtaManageDTO.RtaInfo> getRtaInfo() {
+        try {
+            return rtaManageClient.getRtaInfo();
+        } catch (Exception e) {
+            log.error("[RtaManage] getRtaInfo failed", e);
+            return errorResponse(e.getMessage());
+        }
+    }
+
+    @Override
+    public RtaManageDTO.ApiResponse<?> getTargets(RtaManageDTO.TargetGetRequest request) {
+        try {
+            return rtaManageClient.getTargets(request);
+        } catch (Exception e) {
+            log.error("[RtaManage] getTargets failed, req={}", request, e);
+            return errorResponse(e.getMessage());
+        }
+    }
+
+    @Override
+    public RtaManageDTO.ApiResponse<?> addTarget(RtaManageDTO.TargetSetRequest request) {
+        try {
+            return rtaManageClient.addTarget(request);
+        } catch (Exception e) {
+            log.error("[RtaManage] addTarget failed, req={}", request, e);
+            return errorResponse(e.getMessage());
+        }
+    }
+
+    @Override
+    public RtaManageDTO.ApiResponse<?> deleteTarget(RtaManageDTO.TargetDeleteRequest request) {
+        try {
+            return rtaManageClient.deleteTarget(request);
+        } catch (Exception e) {
+            log.error("[RtaManage] deleteTarget failed, req={}", request, e);
+            return errorResponse(e.getMessage());
+        }
+    }
+
+    @Override
+    public RtaManageDTO.ApiResponse<?> bindTarget(RtaManageDTO.TargetBindRequest request) {
+        try {
+            return rtaManageClient.bindTarget(request);
+        } catch (Exception e) {
+            log.error("[RtaManage] bindTarget failed, req={}", request, e);
+            return errorResponse(e.getMessage());
+        }
+    }
+
+    @Override
+    public RtaManageDTO.ApiResponse<?> unbindTarget(RtaManageDTO.TargetBindDeleteRequest request) {
+        try {
+            return rtaManageClient.unbindTarget(request);
+        } catch (Exception e) {
+            log.error("[RtaManage] unbindTarget failed, req={}", request, e);
+            return errorResponse(e.getMessage());
+        }
+    }
+
+    @Override
+    public RtaManageDTO.ApiResponse<?> getBindStatus(RtaManageDTO.TargetBindStatusRequest request) {
+        try {
+            return rtaManageClient.getBindStatus(request);
+        } catch (Exception e) {
+            log.error("[RtaManage] getBindStatus failed, req={}", request, e);
+            return errorResponse(e.getMessage());
+        }
+    }
+
+    private <T> RtaManageDTO.ApiResponse<T> errorResponse(String msg) {
+        return new RtaManageDTO.ApiResponse<>("error", 1, null);
+    }
+}

+ 217 - 0
core/src/main/java/com/tzld/rta/service/impl/RtaServiceImpl.java

@@ -0,0 +1,217 @@
+package com.tzld.rta.service.impl;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.tzld.rta.model.RtaRequestModel;
+import com.tzld.rta.model.RtaResponseModel;
+import com.tzld.rta.service.RtaService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 腾讯RTA服务实现
+ * <p>
+ * 核心逻辑:
+ * 1. 判断设备ID是否命中人群包(通过 Redis Bloom Filter 或 Redis Set)
+ * 2. 返回匹配的策略 outTargetId 及调权参数
+ * <p>
+ * 性能设计:
+ * - 所有查询均走 Redis,单次 get &lt; 1ms
+ * - 无 DB 操作,无阻塞 I/O
+ * - 使用本地 Caffeine 缓存兜底,减少 Redis 压力
+ */
+@Slf4j
+@Service
+public class RtaServiceImpl implements RtaService {
+
+    /**
+     * 不参竞时的 code 值(文档要求:非0即不参竞)
+     */
+    private static final int CODE_NOT_BID = 1;
+
+    /**
+     * 参竞时的 code 值
+     */
+    private static final int CODE_BID = 0;
+
+    /**
+     * Redis key 前缀,存储设备ID对应的 out_target_id 集合
+     * key = rta:device:{deviceId}
+     * value = out_target_id 列表(逗号分隔)
+     */
+    private static final String REDIS_KEY_PREFIX = "rta:device:";
+
+    /**
+     * 未命中人群时的缓存时间(秒)
+     * 建议设大一些(如 3600s),降低腾讯将未命中设备反复请求的平均 QPS
+     */
+    @Value("${rta.cache.time.miss:3600}")
+    private int cacheMissSeconds;
+
+    /**
+     * 命中人群时的缓存时间(秒)
+     */
+    @Value("${rta.cache.time.hit:3600}")
+    private int cacheHitSeconds;
+
+    /**
+     * RTA 是否开启(Apollo 远程配置,支持动态关闭)
+     */
+    @Value("${rta.enabled:true}")
+    private boolean rtaEnabled;
+
+    /**
+     * 默认策略 ID(人群包命中时返回)
+     * 多个以逗号分隔
+     */
+    @Value("${rta.default.out.target.ids:}")
+    private String defaultOutTargetIds;
+
+    @Resource
+    private StringRedisTemplate stringRedisTemplate;
+
+    /**
+     * 本地 Caffeine 缓存(一级缓存),减少 Redis 压力
+     * key=deviceId, value=出策略ID列表(null 表示未命中)
+     */
+    @Resource
+    @Qualifier("rtaLocalCache")
+    private Cache<String, List<String>> localCache;
+
+    /** 本地缓存是否开启 */
+    @Value("${rta.local.cache.enabled:true}")
+    private boolean localCacheEnabled;
+
+    @Override
+    public RtaResponseModel process(RtaRequestModel request) {
+        long startTime = System.currentTimeMillis();
+        // 构建基础响应(默认 code=0)
+        RtaResponseModel.RtaResponseModelBuilder builder = RtaResponseModel.builder();
+        if (request.getId() != null) {
+            builder.requestId(request.getId());
+        }
+        // Ping 模式:直接返回空响应(用于腾讯测量网络延迟)
+        if (request.isPing()) {
+            builder.code(CODE_BID).processingTimeMs((int) (System.currentTimeMillis() - startTime));
+            return builder.build();
+        }
+        try {
+            // 获取设备标识(优先级: oaid > imei > idfa > androidId)
+            String deviceId = extractDeviceId(request.getDevice());
+            if (!StringUtils.hasText(deviceId) || !rtaEnabled) {
+                // 无法识别设备或RTA未开启 -> 不参竞(code=1)
+                builder.code(CODE_NOT_BID).responseCacheTime(cacheMissSeconds);
+                builder.processingTimeMs((int) (System.currentTimeMillis() - startTime));
+                return builder.build();
+            }
+            // 查询人群包(先走本地缓存,再走 Redis)
+            List<String> matchedTargetIds = queryMatchedTargetIdsWithCache(deviceId);
+            if (matchedTargetIds.isEmpty()) {
+                // 未命中人群包 -> 不参竞(code=1,同时告知腾讯缓存更长时间)
+                builder.code(CODE_NOT_BID).responseCacheTime(cacheMissSeconds);
+            } else {
+                // 命中人群包 -> 参竞(code=0),返回策略列表
+                List<RtaResponseModel.TargetInfoModel> targetInfos = buildTargetInfos(matchedTargetIds);
+                builder.code(CODE_BID).outTargetIds(matchedTargetIds).targetInfos(targetInfos).responseCacheTime(cacheHitSeconds);
+            }
+        } catch (Exception e) {
+            log.error("[RTA] process error, requestId={}", request.getId(), e);
+            // 异常时也要保证 HTTP 200,返回 code=0(按文档异常时以参竞处理)
+            builder.code(CODE_BID);
+        }
+        builder.processingTimeMs((int) (System.currentTimeMillis() - startTime));
+        return builder.build();
+    }
+
+    /**
+     * 从设备信息中提取主设备ID
+     * 优先级: oaid_md5 > imei_md5 > idfa_md5 > android_id_md5 > mac_md5
+     *
+     * @param device 设备模型
+     * @return 主设备ID
+     */
+    private String extractDeviceId(RtaRequestModel.DeviceModel device) {
+        if (device == null) return null;
+        // oaid 覆盖面最广,优先使用
+        if (StringUtils.hasText(device.getOaidMd5sum())) return device.getOaidMd5sum();
+        if (StringUtils.hasText(device.getImeiMd5sum())) return device.getImeiMd5sum();
+        if (StringUtils.hasText(device.getIdfaMd5sum())) return device.getIdfaMd5sum();
+        if (StringUtils.hasText(device.getAndroidIdMd5sum())) return device.getAndroidIdMd5sum();
+        if (StringUtils.hasText(device.getMacMd5sum())) return device.getMacMd5sum();
+        return null;
+    }
+
+    /**
+     * 带本地缓存的人群查询(二级缓存)
+     * L1: Caffeine 本地缓存(0.01ms)
+     * L2: Redis 远程查询(0.5-1ms)
+     */
+    private List<String> queryMatchedTargetIdsWithCache(String deviceId) {
+        if (localCacheEnabled) {
+            List<String> cached = localCache.getIfPresent(deviceId);
+            if (cached != null) {
+                return cached;
+            }
+        }
+        List<String> result = queryMatchedTargetIds(deviceId);
+        if (localCacheEnabled) {
+            localCache.put(deviceId, result);
+        }
+        return result;
+    }
+
+    /**
+     * 查询设备是否命中人群包
+     * <p>
+     * 存储结构: Redis Hash
+     *   HSET rta:device:{deviceId} {outTargetId} 1
+     * <p>
+     * 或使用 Redis Set:
+     *   SADD rta:target:{outTargetId} {deviceId1} {deviceId2} ...
+     *
+     * @param deviceId 设备ID (md5)
+     * @return 命中的策略ID列表
+     */
+    private List<String> queryMatchedTargetIds(String deviceId) {
+        try {
+            String redisKey = REDIS_KEY_PREFIX + deviceId;
+            // 使用 Redis String: 值为逗号分隔的 out_target_id
+            String value = stringRedisTemplate.opsForValue().get(redisKey);
+            if (!StringUtils.hasText(value)) {
+                return Collections.emptyList();
+            }
+            String[] ids = value.split(",");
+            List<String> result = new ArrayList<>(ids.length);
+            for (String id : ids) {
+                if (StringUtils.hasText(id)) result.add(id.trim());
+            }
+            return result;
+        } catch (Exception e) {
+            log.warn("[RTA] redis query failed, deviceId={}", deviceId, e);
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * 构建策略信息列表
+     */
+    private List<RtaResponseModel.TargetInfoModel> buildTargetInfos(List<String> targetIds) {
+        List<RtaResponseModel.TargetInfoModel> list = new ArrayList<>(targetIds.size());
+        for (String targetId : targetIds) {
+            list.add(RtaResponseModel.TargetInfoModel.builder()
+                    .outTargetId(targetId)
+                    // 默认加权系数 1.0,可根据业务逻辑从 Redis 或配置读取
+                    .userWeightFactor(1.0f)
+                    .build());
+        }
+        return list;
+    }
+}

+ 13 - 0
pom.xml

@@ -289,6 +289,19 @@
             <version>0.9.9</version>
             <version>0.9.9</version>
         </dependency>
         </dependency>
 
 
+        <!-- Caffeine 本地缓存(RTA人群包命中缓存) -->
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+
+        <!-- OkHttp3 HTTP客户端(RTA管理API调用) -->
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp</artifactId>
+            <version>3.14.9</version>
+        </dependency>
+
 
 
     </dependencies>
     </dependencies>
 
 

+ 113 - 0
server/src/main/java/com/tzld/rta/controller/rta/RtaController.java

@@ -0,0 +1,113 @@
+package com.tzld.rta.controller.rta;
+
+import com.tzld.rta.codec.RtaProtobufCodec;
+import com.tzld.rta.model.RtaRequestModel;
+import com.tzld.rta.model.RtaResponseModel;
+import com.tzld.rta.service.RtaService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+
+/**
+ * 腾讯RTA接入Controller
+ * <p>
+ * 协议要求:
+ * - HTTP/1.1 长连接(Keep-Alive)
+ * - POST 方法,Content-Type: application/x-protobuf
+ * - 响应始终返回 HTTP 200(异常时也需 200)
+ * - 超时时间: 60ms(含网络传输)
+ * - 超时率/错误率需低于 2%
+ * <p>
+ * 接口地址: POST /rta/bid
+ */
+@Slf4j
+@RestController
+@RequestMapping("/rta")
+public class RtaController {
+
+    private static final String CONTENT_TYPE_PROTOBUF = "application/x-protobuf";
+
+    @Autowired
+    private RtaService rtaService;
+
+    /**
+     * RTA 竞价接口
+     * 腾讯广告系统通过此接口实时查询设备是否参竞
+     *
+     * @param httpRequest HTTP请求
+     * @return protobuf 格式响应
+     */
+    @PostMapping(
+            value = "/bid",
+            consumes = {CONTENT_TYPE_PROTOBUF, MediaType.APPLICATION_OCTET_STREAM_VALUE, MediaType.ALL_VALUE},
+            produces = CONTENT_TYPE_PROTOBUF
+    )
+    public ResponseEntity<byte[]> bid(HttpServletRequest httpRequest) {
+        long startTime = System.currentTimeMillis();
+        try {
+            // 1. 读取请求体
+            byte[] requestBytes = readRequestBody(httpRequest);
+            if (requestBytes == null || requestBytes.length == 0) {
+                return buildEmptyResponse();
+            }
+            // 2. 解码 protobuf 请求
+            RtaRequestModel request = RtaProtobufCodec.decodeRequest(requestBytes);
+            // 3. 业务处理
+            RtaResponseModel response = rtaService.process(request);
+            // 4. 编码 protobuf 响应
+            byte[] responseBytes = RtaProtobufCodec.encodeResponse(response);
+            return ResponseEntity.ok()
+                    .header("Content-Type", CONTENT_TYPE_PROTOBUF)
+                    .header("Connection", "keep-alive")
+                    .header("Content-Length", String.valueOf(responseBytes.length))
+                    .body(responseBytes);
+        } catch (Exception e) {
+            log.error("[RTA] bid error, cost={}ms", System.currentTimeMillis() - startTime, e);
+            // 出错时也要返回 200,返回空 protobuf(视为参竞,符合RTA规范)
+            return buildEmptyResponse();
+        }
+    }
+
+    /**
+     * 读取请求体字节
+     */
+    private byte[] readRequestBody(HttpServletRequest request) throws Exception {
+        try (InputStream is = request.getInputStream()) {
+            ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+            byte[] chunk = new byte[4096];
+            int n;
+            while ((n = is.read(chunk)) != -1) {
+                buffer.write(chunk, 0, n);
+            }
+            return buffer.toByteArray();
+        }
+    }
+
+    /**
+     * 构建空的成功响应(空protobuf body = code:0)
+     */
+    private ResponseEntity<byte[]> buildEmptyResponse() {
+        try {
+            byte[] emptyResponse = RtaProtobufCodec.encodeResponse(
+                    RtaResponseModel.builder().code(0).build()
+            );
+            return ResponseEntity.ok()
+                    .header("Content-Type", CONTENT_TYPE_PROTOBUF)
+                    .header("Connection", "keep-alive")
+                    .header("Content-Length", String.valueOf(emptyResponse.length))
+                    .body(emptyResponse);
+        } catch (Exception ex) {
+            // 兜底,返回纯空body
+            return ResponseEntity.ok()
+                    .header("Content-Type", CONTENT_TYPE_PROTOBUF)
+                    .header("Connection", "keep-alive")
+                    .body(new byte[0]);
+        }
+    }
+}

+ 105 - 0
server/src/main/java/com/tzld/rta/controller/rta/RtaManageController.java

@@ -0,0 +1,105 @@
+package com.tzld.rta.controller.rta;
+
+import com.tzld.rta.model.dto.RtaManageDTO;
+import com.tzld.rta.service.RtaManageService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 腾讯 RTA 自助管理接口代理 Controller
+ * <p>
+ * 本 Controller 作为内部管理后台调用腾讯 RTA 管理 API 的代理层,
+ * 对外暴露标准 REST 接口,内部转发至腾讯 https://api.rta.qq.com/
+ * <p>
+ * 接口前缀: /rta/manage/**
+ * <p>
+ * 包含接口:
+ *   GET  /rta/manage/info                - 查询 RTA 基本信息 (2.2.2)
+ *   GET  /rta/manage/targets             - 查询策略列表 (2.2.3)
+ *   POST /rta/manage/targets             - 新增策略 (2.2.3)
+ *   POST /rta/manage/targets/delete      - 删除策略 (2.2.3)
+ *   POST /rta/manage/bind                - 绑定策略 (2.2.4)
+ *   POST /rta/manage/bind/delete         - 解绑策略 (2.2.4)
+ *   POST /rta/manage/bind/status         - 查询绑定状态 (2.2.4)
+ */
+@Slf4j
+@RestController
+@RequestMapping("/rta/manage")
+public class RtaManageController {
+
+    @Autowired
+    private RtaManageService rtaManageService;
+
+    /**
+     * 2.2.2 查询 RTA 基本信息
+     * 包含 bidUrl、CacheTime、Enable、规则配置、绑定广告主账号列表
+     */
+    @GetMapping("/info")
+    public RtaManageDTO.ApiResponse<RtaManageDTO.RtaInfo> getRtaInfo() {
+        return rtaManageService.getRtaInfo();
+    }
+
+    /**
+     * 2.2.3 查询策略列表
+     * 支持分页和按 outerTargetId 过滤,最多返回 10 条/页
+     */
+    @GetMapping("/targets")
+    public RtaManageDTO.ApiResponse<?> getTargets(
+            @RequestParam(required = false, defaultValue = "1") Integer page,
+            @RequestParam(required = false, defaultValue = "10") Integer size,
+            @RequestParam(required = false) List<String> outerTargetIds) {
+        RtaManageDTO.TargetGetRequest req = RtaManageDTO.TargetGetRequest.builder()
+                .page(page)
+                .size(size)
+                .outerTargetIds(outerTargetIds)
+                .build();
+        return rtaManageService.getTargets(req);
+    }
+
+    /**
+     * 2.2.3 新增策略
+     * outerTargetId: 最多32字符,字母/数字/下划线/中划线
+     * targetName:    最多20字符,字母/数字/下划线/中划线
+     */
+    @PostMapping("/targets")
+    public RtaManageDTO.ApiResponse<?> addTarget(@RequestBody RtaManageDTO.TargetSetRequest request) {
+        return rtaManageService.addTarget(request);
+    }
+
+    /**
+     * 2.2.3 删除策略
+     */
+    @PostMapping("/targets/delete")
+    public RtaManageDTO.ApiResponse<?> deleteTarget(@RequestBody RtaManageDTO.TargetDeleteRequest request) {
+        return rtaManageService.deleteTarget(request);
+    }
+
+    /**
+     * 2.2.4 绑定策略与广告主账号/广告
+     * 注意: 策略绑定存在 1~6 小时延迟生效
+     * 建议先完成绑定,等待生效后再投放广告
+     */
+    @PostMapping("/bind")
+    public RtaManageDTO.ApiResponse<?> bindTarget(@RequestBody RtaManageDTO.TargetBindRequest request) {
+        return rtaManageService.bindTarget(request);
+    }
+
+    /**
+     * 2.2.4 解绑策略
+     */
+    @PostMapping("/bind/delete")
+    public RtaManageDTO.ApiResponse<?> unbindTarget(@RequestBody RtaManageDTO.TargetBindDeleteRequest request) {
+        return rtaManageService.unbindTarget(request);
+    }
+
+    /**
+     * 2.2.4 查询绑定状态
+     */
+    @PostMapping("/bind/status")
+    public RtaManageDTO.ApiResponse<?> getBindStatus(@RequestBody RtaManageDTO.TargetBindStatusRequest request) {
+        return rtaManageService.getBindStatus(request);
+    }
+}