Appearance
问卷怎么按地区控制样本量?IP 归属地自动配额抽样(PHP 实现)
做市场调研的人都懂这种痛:甲方丢过来一句「华东收 800、华南收 500、其他地区 300」,问卷星上手动筛吧,得等回收结束再按 IP 地址一个个归类,无效样本剔完发现广东早超了、青海还差 200,只能补发一轮定向渠道。一来一回,项目周期拖一周。
更麻烦的是现在问卷大多靠微信群、朋友圈转发扩散,样本天然扎堆在发起人所在的城市。配额控制不做在回收环节,后面做数据分析时地域结构就是歪的。这篇文章讲怎么在问卷系统提交入口加一道「地区配额闸门」:答卷人 IP 归属地落在哪个省,就扣哪个省的配额,扣完直接礼貌谢绝,样本结构当场可控。
一、实时配额 vs 回收后筛选
回收后筛选的问题是「晚了」:发出去的链接收不回来,超配地区白花钱,欠配地区还得二次触达。实时配额在答卷提交那一刻就做判断:
- 取答卷人真实 IP(注意 CDN 场景);
- 查 IP 归属地,拿到省份;
- Redis 里对省份计数 +1,超过配额上限就返回「本地区样本已收满」;
- 配额内正常入库,顺带把省份写进答卷记录,后续分析直接用。
这套逻辑放在 PHP 问卷系统里很自然——问卷系统大多是 PHP 写的(问卷星自建版、开源 LimeSurvey 生态都是),提交入口加两个函数就够。
二、PHP 实现
php
<?php
// 问卷提交入口 diqu_quota_check.php,在 saveAnswer() 之前调用
$quota = [ // 各省目标配额(份)
'北京' => 300, '上海' => 300, '广东' => 500,
'浙江' => 200, '江苏' => 200, '其他' => 300,
];
function getUserIp(): string
{
// 经过了自家 CDN/反代才可信;裸奔环境直接用 REMOTE_ADDR
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$first = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
return trim($first);
}
return $_SERVER['REMOTE_ADDR'] ?? '';
}
function queryGeo(string $ip): ?array
{
// 文件缓存:同一 IP 一天内只查一次,省接口额度
$cacheDir = __DIR__ . '/geo_cache';
if (!is_dir($cacheDir)) { mkdir($cacheDir, 0755, true); }
$cacheFile = $cacheDir . '/' . md5($ip) . '.json';
if (is_file($cacheFile) && time() - filemtime($cacheFile) < 86400) {
return json_decode(file_get_contents($cacheFile), true);
}
$ctx = stream_context_create(['http' => ['timeout' => 3]]);
$raw = @file_get_contents("https://ip9.com.cn/get?ip={$ip}", false, $ctx);
if ($raw === false) { return null; }
$resp = json_decode($raw, true);
if (!isset($resp['ret']) || $resp['ret'] !== 200) { return null; }
file_put_contents($cacheFile, json_encode($resp['data'], JSON_UNESCAPED_UNICODE));
return $resp['data']; // 含 prov/city/isp/lat/lng 等字段
}
function quotaCheck(array $quota, Redis $redis, ?array $geo): array
{
$prov = $geo['prov'] ?? '其他';
if (!isset($quota[$prov])) { $prov = '其他'; }
// hIncrBy 原子自增,并发答卷不会重复放行
$used = (int) $redis->hIncrBy('survey_quota_20260927', $prov, 1);
$left = $quota[$prov] - $used;
if ($left < 0) {
return ['ok' => false, 'msg' => "{$prov}地区样本已收满,感谢参与"];
}
return ['ok' => true, 'prov' => $prov, 'left' => $left];
}
// ---- 提交流程 ----
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->select(0);
$ip = getUserIp();
$geo = queryGeo($ip);
if ($geo === null) {
// 降级策略:归属地接口挂了不能让问卷整体挂掉,放行但打标记事后补算
saveAnswer($_POST, ['quota_checked' => false]);
echo json_encode(['ok' => true, 'msg' => '提交成功']);
exit;
}
$result = quotaCheck($quota, $redis, $geo);
if (!$result['ok']) {
echo json_encode(['ok' => false, 'msg' => $result['msg']]);
exit;
}
saveAnswer($_POST, [
'quota_checked' => true,
'prov' => $result['prov'],
'left' => $result['left'],
]);
echo json_encode(['ok' => true, 'msg' => '提交成功', 'left' => $result['left']]);两个细节值得交代。getUserIp 里取 X-Forwarded-For 第一个值,是因为自己的 CDN 会把真实 IP 追加在最前;但如果把后端直接暴露在公网,这个头就能伪造——生产环境务必只信网关层改写过的头。quotaCheck 用 Redis hIncrBy 原子自增,两个答卷人同时提交同一个省的最后一份名额时,只有一个人能拿到,不会超发。
三、落地注意事项
- 配额单位要想清楚:按「提交份数」还是按「有效份数」。强烈建议配额闸门只挡「明显刷量」和「超收」,正常答卷先收着并标记
quota_checked,洗数据阶段再按prov字段精确卡有效样本——否则接口精度波动或一个人答两份会把达标答卷无辜挡掉。 - IP 归属地是城市级,不是精确位置:省一级用它没问题,别拿去判定答卷人具体城区;
area(区县)字段是 VIP 版能力,真有区县配额需求再看官网 https://www.ip9.com.cn 说明。 - 免费额度要算账:
https://ip9.com.cn/get?ip=xxx免费版 60 次/分钟/IP。日均几百份答卷的调研项目,服务端单 IP 调用完全够;配合上面的文件缓存,同一 IP 重复答卷只查一次。量再大(比如渠道投放日增几万份)就得加 Redis 缓存或转 VIP。 - 别在提示语里泄露配额逻辑:满额提示统一写成「本地区样本已收满」,别告诉答卷人具体省份和剩余数量,防止被渠道方定点刷配额外样本。
- 合规:IP 属于个人信息,答卷收集页的隐私条款里写明用途是「地域配额与样本质量控制」,保留期限随问卷项目结束一并清理。
总结
问卷配额这件事,早做五分钟,晚省两星期。在提交入口加一道「IP 归属地 → 省份计数 → 满额谢绝」的闸门,样本省域结构从源头就受控,后面数据分析不用再为地域偏差写一堆后处理脚本。PHP 侧实现就三个函数:取真 IP、查归属地(带文件缓存)、Redis 原子计数,半小时能接完。别忘两条兜底:接口异常降级放行 + 答卷落库时把省份写进记录,这样哪怕配额闸门临时失灵,清洗阶段也还能兜回来。