SEO关键词按天扣费系统源码:幂等、账本与对账设计 1. 先看清“按天扣费”到底在扣什么上周一位做关键词监测服务的朋友把他们的后台截图发我看说最近客户投诉有点多问我能不能帮着看看账。我扫了两眼就发现一个问题一个词在客户后台已经被标成“暂停监测”了可扣费流水还在一天天长已经连扣六天。他的原话是“按天扣费不就是个定时任务吗跑一遍就完事了”。这句话我听过太多次了每次听到都觉得后背发凉——最简单的东西恰恰最容易在边界上翻车。这篇东西我想聊的是 seo 扣费系统源码 里最容易被写崩、也最容易被客户抓住不放的那一块关键词按天扣费。它表面上是“每天算一次钱”实际上牵涉排名采集的稳定性、计费粒度的定义、时间边界的划分、并发幂等、账本审计、退款补扣这一整条链路。如果你正在做一套关键词排名监测计费系统或者你手上有一份别人给的源码准备二次改造再或者你是服务商老板被客户质疑过账单这篇应该对你有用。我会按“模型选型—架构拆分—采集可靠性—扣费引擎实现—定价规则—对账风控”这个顺序往下讲中间穿插我自己踩过的坑和实测过的写法。1.1 三种主流计费模型以及它们各自的“坑点”很多所谓“按天扣费源码”其实是把三种完全不同的东西混着叫先把这件事讲清楚后面所有实现才好谈。第一种按词按天固定单价。逻辑是关键词数量 × 计费天数 × 单价。比如客户买了 100 个词单价 0.50 元/天一个月就是 100 × 30 × 0.5 1500 元。这套模型实现最简单一张关键词表加一张日流水表就能跑。但它有个致命问题——客户会直接问“这个词掉到第 80 名了你还按原价扣凭什么”一旦排名掉下去还照扣投诉率必然上升退费率也会跟着涨。第二种按排名位置阶梯计费。排名在前 3 一个价4 到 10 一个价11 到 20 一个价50 名以外不计费或者只计基础服务费。客户接受度非常高因为账算得明白没效果就不花钱。但实现复杂度直接翻倍——你需要判定“今天的排名算第几档”而排名本身每天在动一天在 9 名、第二天到 11 名档位就跳了。如果不做平滑处理账单会一天高一天低客户看着就烦。第三种基础服务费加效果奖励费。词条本身收一个很低的固定监测费进入前 20 再加一笔。这种模型在长期服务里最常见因为它把“抓取和计算”这笔成本先覆盖掉了效果部分单独算。源码上一般是两张计费规则表叠加订单上挂多条规则。模型实现复杂度客户接受度账单波动常见故障点按词按天固定单价低中极小停用未停扣、到期未停扣排名阶梯计费高高大档位判定、采集失败误判基础费加效果费中高中规则叠加重复计算按采集成功次数计费中中中重试导致的重复计数说句实话我在实际项目里最推荐的组合是“第三种模型 阶梯档位做平滑”因为它同时解决了两个问题一是有保底的监测成本二是排名掉了客户能看到扣费明显下降心理落差小。纯固定单价那套除非你是打包年费否则别用。1.2 为什么“天”这个粒度最容易出事小时、天、月三种粒度天是最别扭的。按小时算扣费次数多但单次金额小客户不太较真按月算一次性结算清楚。按天算正好卡在中间——金额不大不小客户天天看而且**“今天”这个概念本身在系统里就不唯一**。举一个我真实遇到过的场景系统跑批时间设在凌晨 2 点服务器时区是 UTC但客户和运营都在东八区。结果客户 3 号下午下单开通的词系统在 4 号凌晨 2 点UTC跑批时把它算进了 3 号的账单客户后台看到的是“4 号开通的词 3 号就扣了钱”。这种投诉你拿什么解释代码没错时区没错但人就是觉得你多扣了一天。还有一类更隐蔽的跑批快照的时间点不稳定。今天凌晨 1 点跑明天因为任务排队到早上 7 点才跑采集到的排名数据其实是两个不同的时间截面。对客户来说凌晨和早上的排名可能差好几位档位一跳账单就变了。他自己昨天截图是第 8 名你今天扣的是 12 名的钱他马上就来问。所以我在设计时会把这几件事定死写进配置里而不是写死在代码里业务日定义统一用哪个时区、每天几点作为“业务日切分点”全系统只认这一个配置。快照时间戳每条排名快照必须带采集时间计费时按“业务日内的最后一次有效快照”取而不是按“跑批时刻的最新数据”取。补跑窗口允许补跑最近 N 天我一般设 3 到 7 天超过窗口的日期一律不允许重新计费防止账目倒腾。注意这三条看着像运维细节实际上全是账务问题。凡是能改变“某个业务日余额数字”的操作都必须受控都要留痕。2. 账单要经得起追问架构上先把账本和余额分开我见过不少“源码”是这么写的用户表里一个balance字段扣费时直接update user set balance balance - x。跑得快的时候没问题一旦客户来一句“我这个月到底扣在哪了”你就只能翻日志日志还不一定有。这套写法在单人小工具里能活做成对外服务必然崩。2.1 余额是结果流水才是事实核心原则一句话余额是流水推算出来的结果而不是唯一真相。我在实现时一定是两张表——账户余额表负责“当前可用额度”这种高频读账户流水表负责每一笔加款、扣费、退款、调整带唯一业务键可追溯、可回滚、可对账。好处有三个都是实战里被逼出来的可审计。客户对账单有疑问时你打开流水就能看到某天某词扣了多少、当时排名第几、用的哪条定价规则。没有任何猜测空间。可修复。发现某天调度挂了漏扣不用手工去改余额而是补一笔“补扣”流水余额跟着重算或增量调整。所有动作都有记录财务对得上。可防重复。流水表上给业务唯一键加唯一索引同一个词同一天同一策略版本数据库层面直接拒绝第二次写入。这比在代码里判断“是不是已经扣过”可靠一万倍。2.2 四层职责要切干净一套能长期跑的计费系统我一般切成四层每层只干一件事配置层关键词、单价规则、计费策略版本、客户服务期。变化频率低但每次变化都要记录生效时间不能覆盖式修改。采集层按关键词拉排名落快照处理失败重试。它只负责“客观记录看到什么”不做任何计费判断。计费层读快照、读规则、算金额、写流水、扣余额。幂等、事务、锁都集中在这一层。结算层日结、月结、报表、对账、告警。只读不写出问题不影响主链路。把采集和计费混在一起的写法我见过太多采集脚本里直接减余额。一旦采集接口超时重试三次余额就减三次。这种 bug 在生产上极其难查因为日志里看着每次都“成功”了。2.3 表结构的关键字段下面是我常用的一套精简结构可以直接参考字段按需增删-- 排名快照 create table rank_snapshot ( id bigint primary key auto_increment, keyword_id bigint not null, biz_date date not null, -- 业务日按统一时区计算 rank_pos int null, -- null 表示未采集成功0 表示无排名 collect_status tinyint not null, -- 1成功 2失败 3无结果 collected_at datetime not null, evidence_hash varchar(64), -- 页面摘要哈希用于举证 unique key uk_kw_date (keyword_id, biz_date, collected_at) ); -- 账户流水 create table account_ledger ( id bigint primary key auto_increment, account_id bigint not null, biz_type tinyint not null, -- 1充值 2扣费 3退款 4补扣 5调整 amount decimal(12,4) not null, -- 正负号表示方向 balance_after decimal(12,4) not null, biz_key varchar(128) not null, -- 幂等键 ref_id bigint, -- 关联扣费明细 id created_at datetime not null, unique key uk_biz_key (biz_key) ); -- 扣费明细 create table charge_detail ( id bigint primary key auto_increment, account_id bigint not null, keyword_id bigint not null, biz_date date not null, rank_pos int, tier_code varchar(16), -- 命中的档位 unit_price decimal(10,4), amount decimal(10,4), policy_version int not null, status tinyint not null, -- 1待扣 2成功 3失败 4已冲正 unique key uk_kw_date_ver (keyword_id, biz_date, policy_version) );policy_version这个字段千万别省。定价规则一改历史账单必须还能按老规则复算否则客户拿三个月前的单子来问你没法解释。3. 采集不稳扣费就不可信排名数据的可靠性设计这可能是整套系统里最容易被低估的部分。很多源码把排名采集当成“随便抓一下”抓到就存抓不到就跳过。问题是——抓不到的时候扣不扣钱这一个问题能引出十几种边界。3.1 必须区分“采集失败”和“确实没排名”这是我最想强调的一点。两者在数据库里如果都存成rank_pos null计费逻辑就没法正确判断。采集失败网络超时、接口限流、页面结构变更、解析异常。这时候你不知道排名是多少属于“信息缺失”。确实没排名页面正常返回翻到第 10 页也没有这个词。这是“客观事实”排名就是很差或者没有。处理策略我一般这么定情形快照记录当日计费说明采集成功且有排名status1, rank实际值按档位正常计标准路径采集成功但无排名status3, rank0按最低档或不计费规则可配采集失败可重试status2, ranknull挂起重试成功再补最多重试 3 次重试全失败status2, ranknull当日不计费标记待人工宁可少收不可错收目标页结构异常status2全量暂停计费并告警这是系统性故障我坚持“采集失败不扣费”这条。理由很直接客户买的是“排名监测”你没监测到凭什么收钱一次两次不扣成本你自己扛如果大面积失败还在扣那是事故不是生意。3.2 排名波动不是“真波动”做过一段时间监测就知道同一个词同一天不同时间查结果可能差好几位。地域、设备、是否登录、结果页个性化都会影响。如果系统一天只取一个点那这个点恰好落在波谷客户就觉得自己被多扣了。我的处理办法有三条实测下来投诉率明显下降第一固定采集窗口。每个词每天在同一个时间段采集比如上午 9 点到 11 点之间并且记录精确时间。这样一段时间内数据口径一致客户自己截图对比也不会差太远。第二档位平滑。不按“当天排名”直接定档而是看“连续 N 天的中位数”或者“近 3 天里有 2 天在该档位”才生效。代价是响应慢一点但账单稳定客户不会天天看到数字跳来跳去。第三快照留证。每条快照存一个页面内容哈希必要时能拿出当时的页面结构做佐证。真遇到较真的客户你甩一张带时间戳的记录比解释十句都管用。3.3 采集频率和成本的账要算清按天扣费的场景里很多词其实不需要一天采多次。假设 5000 个词每个词每天采 1 次单次成本按 0.02 元算一天就是 100 元一个月 3000 元。如果每个词采 3 次做平滑成本直接翻三倍。而你的计费单价可能只有 0.5 元/天5000 个词一个月收入 75000 元采集成本 9000 元占比 12%。这个比例还算健康但如果单价压到 0.2 元采集成本就吃掉 30%再做重试和三次采集基本不赚钱。所以我在配置里会让“采集频率”成为可调参数按词的重要程度分档核心词一天 2 到 3 次长尾词一天 1 次甚至可以两天 1 次计费仍按天但档位用近两次结果。这个取舍必须显式做不能一刀切。4. 扣费引擎怎么写才不出重复账到核心了。这一层的目标只有一个同一个词、同一个业务日、同一套规则无论跑多少遍只能扣一次钱。4.1 幂等键是地基我的做法是把幂等键设计成可读字符串比如charge:{account_id}:{keyword_id}:{biz_date}:{policy_version}。这个键写进流水表的唯一索引。任何重复执行插入直接失败捕获异常后当作“已处理”跳过。这种数据库层的兜底比任何代码判断都可靠。为什么要带policy_version因为定价规则调整后理论上需要按新规则重新算历史账比如客户投诉说旧规则算错了。带上版本号新旧两笔可以共存通过状态字段标记哪笔有效不会互相覆盖。4.2 余额扣减用条件更新不要先查后改经典错误写法是先select balance判断够不够再update balance balance - x。并发下两个线程都读到 100都判断够都减 60余额变成 -20。正确写法是让它变成一条原子语句update account_balance set balance balance - #{amount}, updated_at now() where account_id #{accountId} and balance #{amount};受影响行数为 0说明余额不足或者账户不存在这时候走欠费处理分支而不是硬扣成负数。这套写法我在高并发场景下压过很稳。4.3 事务边界先写明细还是先扣钱我推荐的顺序是写扣费明细status待扣→ 扣余额 → 写流水 → 更新明细为成功。整段放在一个事务里任何一步失败全回滚。余额扣减用了条件更新天然具备幂等性的一部分再叠加流水唯一键双保险。但这里有个现实问题一个大客户有 20000 个词一天要写 20000 条明细、20000 条流水一个大事务锁太久容易把数据库拖死。所以我会按账户分批每批 500 到 1000 条一个事务单个账户内的批之间串行。这样既控制了锁范围又保证了同一账户不会并发乱序。4.4 一段可以直接参考的实现下面这段是扣费引擎的核心逻辑骨架Python 写的思路通用换成别的语言照样成立def charge_one(conn, account_id, keyword_id, biz_date, rank_pos, policy): biz_key fcharge:{account_id}:{keyword_id}:{biz_date}:{policy.version} tier policy.match_tier(rank_pos) # 命中档位可能返回 None if tier is None or tier.unit_price 0: return skip # 不计费档位直接跳过 amount round(tier.unit_price * 1, 4) # 按天天数固定为 1 with conn.transaction(): # 1. 先占位唯一键冲突说明已处理 detail_id conn.insert_ignore( charge_detail, dict(account_idaccount_id, keyword_idkeyword_id, biz_datebiz_date, rank_posrank_pos, tier_codetier.code, unit_pricetier.unit_price, amountamount, policy_versionpolicy.version, status1), unique_key(keyword_id, biz_date, policy_version)) if detail_id is None: return duplicated # 2. 条件扣减余额 affected conn.execute( update account_balance set balance balance - %s where account_id %s and balance %s, (amount, account_id, amount)) if affected 0: conn.update(charge_detail, {id: detail_id, status: 3}) # 余额不足 return insufficient # 3. 写流水 conn.insert(account_ledger, dict( account_idaccount_id, biz_type2, amount-amount, balance_afterconn.query_one(select balance from account_balance where account_id%s, (account_id,))[balance], biz_keybiz_key, ref_iddetail_id)) conn.update(charge_detail, {id: detail_id, status: 2}) return ok注意几个细节insert_ignore依赖唯一键不是先查后插余额不足时明细状态置为失败而不是删除保留痕迹流水里的balance_after是扣完之后的快照方便对账时逐笔核对。5. 定价规则怎么配才不容易被质疑5.1 阶梯定价的示例算法假设一个词档位配置如下档位排名区间日单价元说明S1 - 32.00头部位置A4 - 101.20首页可见B11 - 200.60第二页附近C21 - 500.20长尾覆盖D51 名以外或无排名0.00不计费采集失败-0.00当日挂起一个客户有 3 个词某天排名分别是 2、7、35当天扣费 2.00 1.20 0.20 3.40 元。一个月按 30 天算如果排名不变就是 102 元。客户自己拿表格一算就对得上这比任何解释都有效。这里有个容易忽略的点档位边界是闭区间还是开区间必须写清楚。第 10 名算 A 还是 B我见过两个开发各写各的一个用 10一个用 10同一个词一天被算了两次不同价格。规则表里我建议直接存min_rank和max_rank查询时用between一句话说清。5.2 停扣规则必须做成可配置开关回到开头那个朋友的问题词停了还在扣。根因是停扣判断散落在各个地方甚至有遗漏。我的做法是把“是否应计费”抽成一个独立判断函数输入是关键词状态、服务期、账户状态、当日快照状态输出是“计费/跳过/挂起”三态所有分支集中在这一处好审好测。判断顺序我一般这么排关键词是否处于启用状态停用直接跳过。当前业务日是否在服务期内到期次日起不再计费。账户是否处于冻结或欠费超限状态是则挂起不扣。当日快照是否有效失败则挂起成功才继续。命中档位单价是否为 0是则跳过。顺序很重要。如果先判断档位再判断服务期一个已过期的词照样会被算出金额只是后面被拦掉日志里全是干扰信息。5.3 补扣、冲正、退款这三件事补扣调度挂了导致某天漏扣事后补。我的限制是只允许补最近 7 天且必须走独立入口生成biz_type4的流水备注原因和操作人。超过 7 天不补宁可认亏也不要在客户账单上突然冒出一笔陈年旧账。冲正算错了要退。不要直接改原流水金额而是新增一笔反向流水原流水标记status4。账本只增不改这是审计的基本要求。退款客户终止服务剩余未消耗天数按原单价退。这里要注意“已扣但服务未完成”的部分怎么算——我一般按业务日粒度退当天已扣的不退次日起算。规则写进合同系统里只做执行不做判断。6. 对账与告警让客户自己看明白比解释一百句有用6.1 日报要能下钻到证据我给客户看的日报结构是这样的一行一个关键词列出业务日、采集时间、排名、命中档位、单价、金额最后合计。每一行点开能看到当天快照的采集记录和页面摘要哈希。客户质疑某个词你直接把那一行展开时间、位置、依据全在沟通成本几乎为零。这套报表的成本其实很低就是把charge_detail和rank_snapshot关联查询出来。但很多系统不做因为开发觉得“后台能看到余额就够了”。等客户真较真的时候你就知道这张表值多少钱。6.2 三类必须有的告警采集成功率跌破阈值比如某小时内成功率低于 80%立刻告警。这通常意味着目标页改版或者被限流继续跑下去就是大面积“采集失败不扣费”收入直接受影响。单账户日扣费异常和该账户近 7 天日均值比偏离超过 50% 就告警。可能是定价规则配错也可能是某个词的档位跳变导致。全站日扣费为零或骤降这是最危险的信号通常说明调度没跑或者事务全失败。我见过一次是数据库连接池被打满任务全部超时当天一分钱没扣第二天才发现。6.3 一个容易被忽略的对账口径余额表、流水表、明细表三者的金额必须能对上期初余额 充值 - 扣费 - 退款 ± 调整 期末余额。我建议每天跑一次这个校验不平就告警。听着像财务的事实际上这是发现代码 bug 最快的手段——任何一次重复扣费或者漏写流水这个等式立刻就不成立了。7. 常见问题与排查速查下面这些是我在实际运维中反复遇到的按现象归类遇到问题可以直接照着查现象可能原因排查路径处理方式词已停用仍在扣费停扣判断遗漏分支查该词近期 charge_detail 的 status 与关键词状态变更时间冲正错误扣费补上判断逻辑同一天被扣两次幂等键缺失或未加唯一索引查 ledger 表 biz_key 是否重复加唯一索引冲正重复部分账单金额比预期高档位边界写错开闭区间对比规则表 min/max 与代码判断统一改为 between 查询某天全员零扣费调度未执行或事务全失败查任务执行日志与数据库连接数补跑并限制补跑窗口排名与客户截图不符采集时间点不一致对比快照 collected_at固定采集窗口并明示时间余额被扣成负数先查后改的并发问题查是否有条件更新改原子更新负数冲正到期后仍在计费服务期判断用了错误时区核对业务日切分配置统一时区冲正多扣部分采集大面积失败目标页结构变更或限流看成功率曲线与错误码分布暂停计费并告警修复后补跑再说两个不是表格能装下的经验。第一个不要在客户投诉的当下改数据。我见过运营为了安抚客户直接手工把余额加回去流水也不记。结果是月底对账永远不平查了三天才发现是手工改的。正确做法是走冲正流程哪怕慢一点账必须是干净的。第二个定价规则变更一定要带生效时间。直接覆盖原规则表历史账单就再也算不出来了。我的做法是规则表带effective_from和effective_to新规则插入新行旧行只改结束时间。这样任何一天的账都能按当天的规则复算。8. 上线前的自检清单这套东西我在不同项目里上线过几次每次都会过一遍下面这张清单能省掉大量事后救火业务日时区和切分点写进配置且全系统只有一处读取。charge_detail和account_ledger都有业务唯一键和唯一索引。余额扣减全部是条件更新没有任何“先查后改”。停扣判断收敛到一个函数有单元测试覆盖全部分支。采集失败与无排名在库里有明确区分计费逻辑分别处理。补跑窗口有硬限制超窗口操作需要显式开关。余额、流水、明细三表每日对账任务已配置不平即告警。日报可下钻到快照证据客户能自助查询。灰度策略先小比例账户开启自动扣费观察一到两周账单和投诉率再全量。灰度这一步特别值得说。我一般会挑 5% 的账户先跑同时保留人工复核。观察期内重点看三个指标扣费成功率和重复率、客户主动查询账单的次数、投诉数量。如果重复率不是零先别扩量一定是幂等哪里漏了。最后分享一点我自己的体会。这套系统写了这么多年我越来越觉得它的核心难点不在代码而在“定义”。什么叫一天、什么算采集成功、排名掉了该不该少收、漏扣了要不要补——这些问题的答案不在任何一份源码里而在你和客户之间的约定里。代码只是把这个约定翻译成可执行的规则。所以我现在接手任何一套计费系统第一件事不是看代码写得好不好而是拿一张纸把“什么情况下扣钱、扣多少、什么时候不扣”一条条写下来写不满一页纸的代码再漂亮也迟早出事。