反向代购系统搭建实战:从0到1实现多币种结算与物流追踪
反向代购系统搭建实战:从0到1实现多币种结算与物流追踪
第一次接触反向代购时,我踩了个大坑:全网搜不到完整的搭建资料,只能东拼西凑。自己做出来后才发现,核心难点不在前端页面,而在多币种结算、实时汇率同步和物流状态追踪这三个模块。今天就把踩坑经历和最终方案整理出来,供同样想做反向代购平台的朋友参考。
一、环境准备与字符集陷阱
反向代购的用户来自全球,商品标题可能是韩文、日文、英文混合。第一个坑就是数据库字符集:用默认的 latin1 存韩文,前台全显示问号。必须统一用 utf8mb4,支持emoji和生僻字。
CREATE DATABASE daigou DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id INT NOT NULL,
product_title VARCHAR(500) CHARACTER SET utf8mb4,
price DECIMAL(10,2) NOT NULL,
currency CHAR(3) DEFAULT 'CNY',
exchange_rate DECIMAL(10,6) NOT NULL,
settle_amount DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 0,
created_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意 exchange_rate 保留6位小数,避免汇率精度丢失。settle_amount 存储用户实际支付金额(已换算)。这个表结构是后续所有对账的基础。
二、实时汇率同步:Redis缓存 + 定时任务
汇率波动是代购利润的最大杀手。2022年日元单月贬值超过6%,没做汇率缓冲的代购当月利润被吃光。解决方案:每10分钟同步一次汇率,存Redis,设置5分钟TTL作为降级。
// 汇率同步脚本 sync_rates.php
$currencies = ['USD', 'JPY', 'EUR', 'KRW'];
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
foreach ($currencies as $to) {
// 调用聚合API(示例用模拟数据)
$rate = file_get_contents("https://api.exchangerate-api.com/v4/latest/CNY");
$data = json_decode($rate, true);
$rateValue = $data['rates'][$to] ?? 0;
// 波动超过2%则阻断更新,避免错误汇率
$oldRate = $redis->get("rate:CNY:{$to}");
if ($oldRate && abs($rateValue - $oldRate) / $oldRate > 0.02) {
error_log("汇率异常波动: {$to} from {$oldRate} to {$rateValue}");
continue;
}
$redis->setex("rate:CNY:{$to}", 300, $rateValue);
$redis->setex("rate_ts:CNY:{$to}", 300, time());
}
将这个脚本加入crontab,每10分钟执行一次。波动阈值2%是一个trade-off:太敏感会频繁告警,太宽松可能传递错误汇率。根据历史数据,单日振幅超2%的交易日不到5%,这个阈值足够安全。生产环境中 taocarts 也用了类似的 Redis 双层缓存策略来同步多币种汇率,思路一致。
三、订单价格锁定与幂等性
用户浏览商品时看到的汇率,和点击下单那一刻可能已经不同。必须在创建订单时锁定汇率快照,并写入订单表。同时防止重复提交——支付回调重试可能导致重复扣款。
// 订单创建服务
class OrderService {
public function createOrder($userId, $items, $currency) {
$redis = new Redis();
$rateKey = "rate:CNY:{$currency}";
$rate = $redis->get($rateKey);
if (!$rate) {
// 降级:直接调用API并缓存
$rate = $this->fetchRateFromAPI($currency);
$redis->setex($rateKey, 300, $rate);
}
$orderNo = $this->generateOrderNo();
$totalCNY = $this->calcTotalCNY($items);
$settleAmount = round($totalCNY * $rate, 2);
$pdo = DB::getConnection();
$pdo->beginTransaction();
try {
// 幂等检查:防止同一订单号重复创建
$stmt = $pdo->prepare("SELECT id FROM orders WHERE order_no = ? FOR UPDATE");
$stmt->execute([$orderNo]);
if ($stmt->fetch()) {
throw new Exception("订单号已存在");
}
$sql = "INSERT INTO orders (order_no, user_id, product_title, price, currency, exchange_rate, settle_amount, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, NOW())";
// 批量插入商品。
$pdo->commit();
return $orderNo;
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
}
注意 FOR UPDATE 行锁和事务,避免高并发下同一订单号重复插入。幂等性也可以用数据库唯一索引来实现,但订单号通常有业务规则,锁表更直接。
四、物流追踪:统一状态映射表
物流商接口返回的状态码五花八门:EMS的“国际邮件已封发”、DHL的“Shipment picked up”。需要一个统一映射表,将不同渠道的状态转成系统内部状态(1-已揽收,2-运输中,3-到达目的国,4-派送中,5-已签收)。
// 物流状态同步消费者(从RabbitMQ取消息)
function processLogisticUpdate($msg) {
$data = json_decode($msg, true);
$channel = $data['channel']; // 'ems', 'dhl', 'fedex'
$trackingNo = $data['tracking_no'];
$rawStatus = $data['raw_status'];
$map = [
'ems' => ['国际邮件已封发' => 2, '到达寄达地' => 3, '投递成功' => 5],
'dhl' => ['Shipment picked up' => 1, 'Arrived at delivery facility' => 4]
];
$internalStatus = $map[$channel][$rawStatus] ?? 0;
// 更新订单物流表
$pdo = DB::getConnection();
$stmt = $pdo->prepare("UPDATE logistic SET status = ?, raw_status = ?, update_time = NOW() WHERE tracking_no = ?");
$stmt->execute([$internalStatus, $rawStatus, $trackingNo]);
}
将物流商回调推入消息队列,异步处理,避免回调高峰拖垮主服务。队列消费者按渠道分开,某个渠道API故障不影响其他。
五、完整链路测试与踩坑提醒
以上三个模块跑通后,就可以支撑反向代购的基础流程了。实际生产中还有几个注意点:
- 浮点数精度:PHP中
0.1 + 0.2可能不等于0.3。金额计算统一用整数分,或使用bcmath函数bcadd。 - 1688自动采购:对接1688 API时要做幂等性设计,否则重复下单产生幽灵订单。可以用数据库唯一索引拦截重复的
out_trade_no。 - 合包运费分摊:多个订单合并打包时,重量和体积重的计算需要按比例分摊运费。这个逻辑比较复杂,下篇专门展开。
最后留个思考题:当用户下单后,1688供应商突然涨价或断货,系统如何优雅地处理退款和补偿?欢迎分享你的思路。