简介这是一套面向广告投放与推广竞价场景的订单管理系统源码基于开源caozha-admin开发适合中小型广告主、推广团队及需要快速搭建竞价页订单后台的开发者使用。系统覆盖订单管理、订单回收站、产品管理、批量上传与导出订单、订单重复检测、竞价页下单表单调用以及客户下单后邮件或短信提醒管理员等功能并内置灵活的订单查看权限机制便于多人协作时控制数据可见范围。压缩包共约2000个文件整体20.4MB以855个php业务文件为核心搭配112个js、87个html、41个css及layui、ueditor等前端资源另有63个md说明、40个json配置和2个sql脚本结构清晰便于二次开发与部署。目前已有92人学习下载。系统主打易上手、零门槛、拿来即用界面清爽极简读者可直接获得一套可运行的竞价页订单管理后台并参考其权限设计与导出逻辑快速改造为自有业务系统。1. 广告竞价页订单管理系统从流量到成交的那一段黑匣子广告竞价页的订单管理系统说白了就是承接「点击进来之后到付款完成之前」这段链路的记账与状态机。很多人把竞价页当成一个静态落地页投完广告就等转化结果订单一多谁付了、谁没付、哪个渠道的流量在刷单、退款怎么对账全靠人工翻表格。这个系统的价值不在于页面多花哨而在于把「访问—下单—支付—回调—发货—退款」这条链路的每个状态都落库、可查、可重放。它适合两类人一是投手或运营想搞清楚每个广告计划到底带来了多少真实成交二是后端或全栈开发者想用一套能跑起来的最小系统把订单状态和支付回调这件事做扎实。下面我按「先讲清结构再动手搭最后说坑」的顺序把一套可复现的方案拆开。2. 订单状态机与数据表怎么设计才不返工2.1 为什么竞价页订单不能只有一张 orders 表竞价页的订单和普通电商订单最大的区别是它天然带着广告归因信息。用户从哪个计划、哪个创意、哪个关键词进来这些参数在跳转时通过 URL query 带过来如果下单时没落库后面想按渠道算 ROI 就只能靠猜。所以第一件事是把「归因」和「订单」分开存而不是全塞进一张表。常见做法是三张核心表ad_click记录点击归因orders记录订单主体order_events记录状态变更流水。ad_click在用户落地时写入拿到一个click_id下单时把click_id写进订单这样订单和广告计划就绑定了。order_events是后悔药任何状态变更都追加一条不覆盖出问题时能完整回放。-- 点击归因表用户落地即写入 CREATE TABLE ad_click ( click_id CHAR(32) PRIMARY KEY, -- 落地时生成的唯一标识 plan_id VARCHAR(64) NOT NULL, -- 广告计划 ID creative_id VARCHAR(64), -- 创意 ID keyword VARCHAR(128), -- 关键词可为空 ip VARCHAR(45), -- 兼容 IPv6 ua TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_plan (plan_id, created_at) ); -- 订单主表只存当前快照 CREATE TABLE orders ( order_no VARCHAR(32) PRIMARY KEY, -- 业务订单号 click_id CHAR(32), -- 关联归因 amount DECIMAL(10,2) NOT NULL, -- 应付金额 status TINYINT NOT NULL DEFAULT 0,-- 0待支付 1已支付 2已发货 3已完成 4已退款 pay_channel VARCHAR(16), -- 支付渠道标识 paid_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_status (status, created_at), INDEX idx_click (click_id) ); -- 状态流水表只追加不修改 CREATE TABLE order_events ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, from_status TINYINT, to_status TINYINT NOT NULL, remark VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_order (order_no, created_at) );逻辑说明ad_click的click_id是整个归因链的锚点用 32 位随机串而不是自增避免被外部枚举。orders的status用数字枚举配合order_events做审计。参数上amount用DECIMAL而不是FLOAT金额计算不能有浮点误差ip字段留 45 位是为了兼容 IPv6别图省事写 15 位。2.2 状态流转必须用「条件更新」而不是「先查后改」订单状态最怕并发。用户重复点支付、支付回调重试、运营手动改状态三件事撞在一起先查后改必然出现状态覆盖。正确做法是每次更新都带上前置状态条件用影响行数判断是否成功。# 状态流转带前置状态的条件更新 import pymysql def transit_status(conn, order_no, from_status, to_status, remark): with conn.cursor() as cur: # 只有当前状态等于 from_status 才更新避免并发覆盖 sql UPDATE orders SET status %s, updated_at NOW() WHERE order_no %s AND status %s affected cur.execute(sql, (to_status, order_no, from_status)) if affected 0: # 影响行数为 0说明状态已被别人改过直接返回失败 conn.rollback() return False # 追加流水保留完整轨迹 cur.execute( INSERT INTO order_events (order_no, from_status, to_status, remark) VALUES (%s,%s,%s,%s), (order_no, from_status, to_status, remark) ) conn.commit() return True逻辑说明affected 0是这套写法的核心判断它把「状态是否合法」交给数据库的行锁来保证而不是应用层判断。参数上from_status必须由调用方明确传入比如支付回调只允许从 0 转到 1退款只允许从 1 或 2 转到 4。这样即使回调重复到达第二次执行时status已经是 1条件不满足自然幂等。2.3 支付回调的幂等键怎么选支付渠道的回调通常会带一个渠道侧的流水号这个号在同一笔支付里是唯一的拿它做幂等键最稳。不要用订单号做幂等键因为一个订单可能分多次支付补差价场景订单号会冲突。-- 支付流水表渠道流水号唯一天然幂等 CREATE TABLE pay_records ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, channel_trade VARCHAR(64) NOT NULL, -- 渠道侧流水号 amount DECIMAL(10,2) NOT NULL, raw_body TEXT, -- 原始回调报文排查用 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_channel_trade (channel_trade) );逻辑说明uk_channel_trade唯一索引是幂等的最后一道防线。回调处理时先尝试插入pay_records如果触发唯一键冲突说明这笔支付已经处理过直接返回成功给渠道即可不要再动订单状态。raw_body存原始报文线上出问题时这是唯一的黑匣子。3. 用最小可跑代码把下单到回调串起来3.1 落地页到下单click_id 怎么透传用户点广告进来URL 上带着plan_id、creative_id这些参数。落地页第一件事是生成click_id并写库然后把它塞进表单隐藏域或 Cookie下单时一起提交。// 落地页解析 URL 参数并生成 click_id (function () { const params new URLSearchParams(location.search); const planId params.get(plan_id) || unknown; const creativeId params.get(creative_id) || ; const keyword params.get(keyword) || ; // 生成 32 位随机串作为 click_id function genClickId() { const arr new Uint8Array(16); crypto.getRandomValues(arr); return Array.from(arr, b b.toString(16).padStart(2, 0)).join(); } const clickId genClickId(); // 写入 Cookie有效期 30 天覆盖用户犹豫期 document.cookie click_id${clickId}; max-age${30 * 24 * 3600}; path/; // 上报归因失败也不阻塞页面 fetch(/api/click, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ click_id: clickId, plan_id: planId, creative_id: creativeId, keyword }) }).catch(() {}); // 塞进所有下单表单的隐藏域 document.querySelectorAll(input[nameclick_id]).forEach(el el.value clickId); })();逻辑说明crypto.getRandomValues比Math.random更适合生成标识避免碰撞。Cookie 设 30 天是因为竞价页用户从点击到下单常有延迟太短会丢归因。上报失败用catch吞掉归因丢失可以接受但不能因为上报接口挂了导致页面报错。3.2 下单接口金额必须服务端重算前端传来的金额只能当参考服务端必须根据商品 ID 重新查价。这是血泪经验前端改价是最常见的薅羊毛手法。# 下单接口服务端重算金额 from flask import Flask, request, jsonify import pymysql, time app Flask(__name__) # 商品价格表实际项目从数据库或配置中心读 PRICE_MAP {sku_basic: 99.00, sku_pro: 299.00} app.route(/api/order, methods[POST]) def create_order(): data request.get_json() sku data.get(sku) click_id data.get(click_id, ) if sku not in PRICE_MAP: return jsonify({code: 400, msg: invalid sku}), 400 # 金额以服务端为准忽略前端传的 amount amount PRICE_MAP[sku] order_no fO{int(time.time() * 1000)}{int(time.time() * 1000) % 1000:03d} conn pymysql.connect(host127.0.0.1, userapp, password***, databasead_order) try: with conn.cursor() as cur: cur.execute( INSERT INTO orders (order_no, click_id, amount, status) VALUES (%s,%s,%s,0), (order_no, click_id, amount) ) cur.execute( INSERT INTO order_events (order_no, from_status, to_status, remark) VALUES (%s,NULL,0,created), (order_no,) ) conn.commit() finally: conn.close() return jsonify({code: 0, order_no: order_no, amount: str(amount)})逻辑说明PRICE_MAP只是示意真实项目价格来自数据库且要加缓存。order_no用时间戳加随机尾数保证趋势递增又不易被猜。注意amount直接取服务端值前端传什么都不看这是防篡改的关键。3.3 支付回调验签、幂等、状态流转三步走回调接口是整个系统最容易被攻击的地方必须验签。验签通过后再走幂等和状态流转。# 支付回调验签 - 幂等 - 状态流转 import hashlib, hmac CHANNEL_SECRET byour_channel_secret app.route(/api/pay/callback, methods[POST]) def pay_callback(): raw request.get_data() sign request.headers.get(X-Sign, ) # 第一步验签签名不对直接拒绝 expect hmac.new(CHANNEL_SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expect, sign): return jsonify({code: 401, msg: bad sign}), 401 data request.get_json() order_no data[order_no] channel_trade data[channel_trade] amount data[amount] conn pymysql.connect(host127.0.0.1, userapp, password***, databasead_order) try: with conn.cursor() as cur: # 第二步幂等渠道流水号唯一索引兜底 try: cur.execute( INSERT INTO pay_records (order_no, channel_trade, amount, raw_body) VALUES (%s,%s,%s,%s), (order_no, channel_trade, amount, raw.decode(utf-8, ignore)) ) except pymysql.err.IntegrityError: conn.rollback() return jsonify({code: 0, msg: duplicated}) # 第三步状态流转只允许 0 - 1 affected cur.execute( UPDATE orders SET status1, paid_atNOW() WHERE order_no%s AND status0, (order_no,) ) if affected 0: conn.rollback() return jsonify({code: 0, msg: status not match}) cur.execute( INSERT INTO order_events (order_no, from_status, to_status, remark) VALUES (%s,0,1,paid), (order_no,) ) conn.commit() finally: conn.close() return jsonify({code: 0, msg: ok})逻辑说明hmac.compare_digest是防时序攻击的固定写法不要用比签名。幂等靠pay_records的唯一索引捕获IntegrityError后返回成功让渠道停止重试。状态流转的affected 0处理「已支付但流水没写」的极端情况返回成功避免渠道一直重试。4. 避坑与排查竞价页订单系统最容易翻车的五件事4.1 回调重复到达导致重复发货现象用户收到两份货或者发货接口被调了两次。原因支付渠道在没收到成功响应时会重试如果回调处理耗时超过渠道超时时间渠道会再发一次。解决幂等键必须落在数据库唯一索引上不能只靠应用层判断回调处理要快发货这种重操作应该异步化回调里只改状态和写流水。4.2 金额用浮点数导致对账差几分钱现象对账时订单金额和渠道金额差 0.01。原因FLOAT或DOUBLE存金额累加时精度丢失。解决金额字段一律DECIMAL(10,2)应用层用Decimal而不是float渠道回调的金额也要转成Decimal再比较。4.3 click_id 丢失导致归因全断现象订单表里大量click_id为空渠道 ROI 算不出来。原因落地页上报接口失败、Cookie 被浏览器拦截、或者下单表单没带隐藏域。解决上报失败要有本地兜底比如写localStorage下次补报Cookie 之外再用 URL 参数透传一次下单接口对空click_id要打日志告警别静默接受。4.4 状态机被运营手动改乱现象订单状态出现 0 直接跳到 3流水对不上。原因运营后台直接UPDATE orders SET status3绕过了状态机。解决后台改状态也必须走transit_status函数禁止裸 SQL 更新给运营的界面只暴露合法流转按钮不提供自由输入状态值。4.5 回调验签用了错误的编码现象验签一直失败但报文看起来没问题。原因签名是对原始字节做的如果先json.loads再重新序列化字节就变了。解决验签必须用request.get_data()拿原始字节不要用解析后的字典重新拼如果渠道签名规则是对排序后的参数拼接严格按渠道文档来别自己发挥。5. 用对账脚本验证系统是否真的可靠系统跑起来不代表对。我一般会写一个对账脚本每天定时跑把orders、pay_records和渠道账单三方比对差异落表告警。这是验证整套状态机是否自洽的最直接手段。# 对账脚本三方比对差异落表 import pymysql from decimal import Decimal def reconcile(conn, channel_bill): channel_bill: list of dict, 渠道账单含 order_no / channel_trade / amount diff [] with conn.cursor(pymysql.cursors.DictCursor) as cur: for bill in channel_bill: cur.execute( SELECT o.order_no, o.amount, o.status, p.channel_trade FROM orders o LEFT JOIN pay_records p ON o.order_no p.order_no WHERE o.order_no %s, (bill[order_no],) ) row cur.fetchone() if not row: diff.append((missing_order, bill[order_no], 渠道有账单但本地无订单)) continue # 金额比对用 Decimal 避免浮点误差 if Decimal(str(row[amount])) ! Decimal(str(bill[amount])): diff.append((amount_mismatch, bill[order_no], f本地 {row[amount]} 渠道 {bill[amount]})) # 状态比对已支付必须有流水 if row[status] 1 and not row[channel_trade]: diff.append((missing_pay_record, bill[order_no], 已支付但无支付流水)) return diff逻辑说明LEFT JOIN保证本地有订单但渠道没账单的情况也能查出来。金额比对用Decimal包一层避免字符串和浮点混比。差异类型分三类本地缺订单、金额不一致、状态与流水不匹配每类对应不同的排查方向。这个脚本我一般挂在定时任务里差异超过阈值就告警比事后人工翻账强得多。参数上channel_bill的获取方式取决于渠道常见做法是渠道提供对账文件下载接口每天凌晨拉前一天的。对账时间窗口要覆盖渠道的结算延迟一般 T1 跑前天的账单最稳。如果差异率突然升高先看是不是回调接口挂了再看是不是有人绕过了状态机改数据。最后一个习惯任何状态变更相关的代码改动上线前必须跑一遍「重复回调 并发下单 手动改状态」三个用例。这三个场景覆盖了订单系统 90% 的线上事故。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?