码桶
发现社区成员的开源项目
版本历史
8 次提交
1.0.14geekssl 提交于 2026-09-21 08:53
f8d02891.0.14geekssl 提交于 2026-09-21 08:52eb6110f1.0.14geekssl 提交于 2026-09-21 08:52c221b4a1.0.14geekssl 提交于 2026-09-21 08:5057d01b1更新项目文件geekssl 提交于 2026-09-16 17:17b82a3e1更新项目文件geekssl 提交于 2026-09-16 17:173ccfa18更新项目文件geekssl 提交于 2026-09-16 17:15ba23a60创建仓库geekssl 提交于 2026-09-16 17:13a04b89a 提交详情
57d01b1提交编号
57d01b195dfcd6e16b918419a875ef61a6ec27a4提交作者geekssl<codebucket@localhost>
提交时间2026-09-21 08:50:55 +0800
更新说明1.0.14
文件变更 42 个文件
文件新增删除
app/Controllers/AlertController.php+683-683app/Controllers/AuthController.php+303-289app/Controllers/BrandController.php+93-0app/Controllers/CollectController.php+408-353app/Controllers/ExportController.php+390-390app/Controllers/OpenController.php+198-198app/Controllers/ProfileController.php+143-143app/Controllers/SettingController.php+472-319app/Controllers/SiteController.php+455-412app/Controllers/SpiderController.php+339-0app/Controllers/StatsController.php+3500-3074app/Controllers/TokenController.php+53-53app/Controllers/UserController.php+327-327app/Http/Request.php+58-58app/Http/Router.php+83-83app/Support/AlertEngine.php+723-723app/Support/Auth.php+80-80app/Support/Db.php+216-185app/Support/IpLocator.php+653-653app/Support/Mailer.php+200-200app/Support/Notifier.php+237-237app/Support/OpLog.php+172-172app/Support/PAT.php+83-83app/Support/PageInject.php+41-41app/Support/RateLimiter.php+48-48app/Support/Rds.php+80-80app/Support/RedisClient.php+482-482app/Support/RedisGuard.php+271-271app/Support/Referrer.php+129-128app/Support/SearchEngine.php+111-0app/Support/Sessionizer.php+184-184app/Support/Settings.php+354-280app/Support/SiteAccess.php+168-168app/Support/SiteStore.php+45-45app/Support/Spider.php+260-0app/Support/SpiderLog.php+165-0app/Support/UaParser.php+126-126app/Support/Updater.php+902-902app/Support/Util.php+466-466app/Support/Verify.php+448-448app/bootstrap.php+150-150app/version.php+17-17文件差异 逐行查看本次提交的修改
app/Controllers/AlertController.php+683-683
@@ -1,683 +1,683 @@1
-<?php2
-/**3
- * 告警与日报控制器4
- *5
- * 推送渠道:GET/POST /api/alerts/channels,PATCH/DELETE /api/alerts/channels/{id},POST .../{id}/test6
- * 告警规则:GET/POST /api/alerts/rules,PATCH/DELETE /api/alerts/rules/{id}7
- * 告警历史:GET /api/alerts/logs8
- * 日报订阅:GET/POST /api/alerts/reports,PATCH/DELETE /api/alerts/reports/{id}9
- * 概览计数:GET /api/alerts/summary10
- *11
- * 权限:全部为用户私有资源(user_id 归属校验),站点参数仅允许自己名下的站点;12
- * 只读协作成员看不到这些配置(它们属于「站点所有者」的运维设置)。13
- */14
-declare(strict_types=1);15
-16
-namespace Wstat\Controllers;17
-18
-use Wstat\Http\Request;19
-use Wstat\Support\AlertEngine;20
-use Wstat\Support\Auth;21
-use Wstat\Support\Db;22
-use Wstat\Support\Notifier;23
-use Wstat\Support\SiteAccess;24
-use Wstat\Support\Util;25
-26
-class AlertController27
-{28
- private const MAX_CHANNELS = 20;29
- private const MAX_RULES = 50;30
- private const MAX_REPORTS = 20;31
-32
- /* ==================== 推送渠道 ==================== */33
-34
- /** GET /api/alerts/channels */35
- public function channelIndex(Request $req): void36
- {37
- $u = Auth::requireUser($req);38
- $rows = Db::select(39
- 'SELECT id,name,type,config,is_active,last_status,last_sent_at,created_at40
- FROM notify_channels WHERE user_id=? ORDER BY id',41
- [(int) $u['id']]42
- );43
- foreach ($rows as &$r) {44
- $r['id'] = (int) $r['id'];45
- $r['is_active'] = (int) $r['is_active'];46
- $r['last_sent_at'] = (int) $r['last_sent_at'];47
- $r['config'] = self::maskConfig(json_decode((string) $r['config'], true) ?: []);48
- }49
- unset($r);50
- wstat_json(['items' => $rows, 'types' => Notifier::TYPES]);51
- }52
-53
- /** POST /api/alerts/channels */54
- public function channelStore(Request $req): void55
- {56
- $u = Auth::requireUser($req);57
- $uid = (int) $u['id'];58
- if ((int) Db::value('SELECT COUNT(*) FROM notify_channels WHERE user_id=?', [$uid]) >= self::MAX_CHANNELS) {59
- wstat_err('推送渠道数量已达上限(' . self::MAX_CHANNELS . ')', 422);60
- }61
- [$name, $type, $cfg] = $this->channelInput($req);62
- $id = Db::insert('notify_channels', [63
- 'user_id' => $uid,64
- 'name' => $name,65
- 'type' => $type,66
- 'config' => json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),67
- 'is_active' => 1,68
- 'created_at' => time(),69
- 'updated_at' => time(),70
- ]);71
- wstat_json(['id' => $id]);72
- }73
-74
- /** PATCH /api/alerts/channels/{id} */75
- public function channelUpdate(Request $req): void76
- {77
- $ch = $this->ownChannel($req, (int) $req->param('id'));78
- $fields = [];79
- $name = trim((string) $req->input('name', ''));80
- if ($name !== '') {81
- if (mb_strlen($name) > 60) {82
- wstat_err('渠道名称过长', 422);83
- }84
- $fields['name'] = $name;85
- }86
- $isActive = $req->input('is_active', null);87
- if ($isActive !== null) {88
- $fields['is_active'] = (int) (bool) $isActive;89
- }90
- // 传了 config 才更新(避免「只改名称」时把密钥擦掉)91
- if ($req->input('config', null) !== null) {92
- $type = (string) $req->input('type', (string) $ch['type']);93
- if (!in_array($type, Notifier::TYPES, true)) {94
- wstat_err('不支持的渠道类型', 422);95
- }96
- $cfg = $this->cfgArray($req->input('config'));97
- $cfg = self::mergeConfig($type, $cfg, json_decode((string) $ch['config'], true) ?: []);98
- if ($type !== (string) $ch['type']) {99
- $fields['type'] = $type;100
- }101
- $fields['config'] = json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);102
- }103
- if (!$fields) {104
- wstat_err('无有效字段', 422);105
- }106
- $set = [];107
- $vals = [];108
- foreach ($fields as $k => $v) {109
- $set[] = "`$k`=?";110
- $vals[] = $v;111
- }112
- $set[] = 'updated_at=' . time();113
- $vals[] = (int) $ch['id'];114
- Db::execute('UPDATE notify_channels SET ' . implode(',', $set) . ' WHERE id=?', $vals);115
- wstat_json(['ok' => true]);116
- }117
-118
- /** DELETE /api/alerts/channels/{id} */119
- public function channelDelete(Request $req): void120
- {121
- $ch = $this->ownChannel($req, (int) $req->param('id'));122
- Db::execute('DELETE FROM notify_channels WHERE id=?', [(int) $ch['id']]);123
- wstat_json(['ok' => true]);124
- }125
-126
- /** POST /api/alerts/channels/{id}/test 发送测试消息 */127
- public function channelTest(Request $req): void128
- {129
- $ch = $this->ownChannel($req, (int) $req->param('id'));130
- $title = '通道连通性测试';131
- $text = "这是一条 WebStats 测试消息。\n"132
- . '渠道:' . (string) $ch['name'] . '(' . (string) $ch['type'] . ")\n"133
- . '若你能看到本条消息,说明该渠道配置正确。';134
- $res = Notifier::send([$ch], $title, $text);135
- $one = $res['results'][0] ?? ['ok' => false, 'msg' => '未知错误'];136
- if (!$one['ok']) {137
- wstat_json(['ok' => false, 'msg' => $one['msg']], 200, 0, '测试发送失败:' . $one['msg']);138
- }139
- wstat_json(['ok' => true]);140
- }141
-142
- /* ==================== 告警规则 ==================== */143
-144
- /** GET /api/alerts/rules */145
- public function ruleIndex(Request $req): void146
- {147
- $u = Auth::requireUser($req);148
- $rows = Db::select(149
- 'SELECT r.*, s.name AS site_name, s.domain AS site_domain150
- FROM alert_rules r LEFT JOIN sites s ON s.id=r.site_id151
- WHERE r.user_id=? ORDER BY r.id DESC',152
- [(int) $u['id']]153
- );154
- foreach ($rows as &$r) {155
- foreach (['id', 'site_id', 'window_min', 'min_sample', 'cooldown_min', 'is_active', 'last_fired_at'] as $k) {156
- $r[$k] = (int) $r[$k];157
- }158
- $r['threshold'] = (float) $r['threshold'];159
- $r['channel_ids'] = array_values(array_filter(array_map('intval', explode(',', (string) $r['channels']))));160
- // v1.3 新列(老库缺列时补默认值,前端无需判空)161
- $r['recover_notify'] = (int) ($r['recover_notify'] ?? 0);162
- $r['firing'] = (int) ($r['firing'] ?? 0);163
- $r['quiet_start'] = (string) ($r['quiet_start'] ?? '');164
- $r['quiet_end'] = (string) ($r['quiet_end'] ?? '');165
- }166
- unset($r);167
- wstat_json([168
- 'items' => $rows,169
- 'metrics' => AlertEngine::METRICS,170
- 'compares' => AlertEngine::COMPARES,171
- 'baselines' => AlertEngine::BASELINES,172
- 'levels' => AlertEngine::LEVELS,173
- 'recovery_available' => self::ruleExtCols(),174
- ]);175
- }176
-177
- /** POST /api/alerts/rules */178
- public function ruleStore(Request $req): void179
- {180
- $u = Auth::requireUser($req);181
- $uid = (int) $u['id'];182
- if ((int) Db::value('SELECT COUNT(*) FROM alert_rules WHERE user_id=?', [$uid]) >= self::MAX_RULES) {183
- wstat_err('告警规则数量已达上限(' . self::MAX_RULES . ')', 422);184
- }185
- $d = $this->ruleInput($req);186
- $now = time();187
- $id = Db::insert('alert_rules', array_merge($d, [188
- 'user_id' => $uid,189
- 'is_active' => 1,190
- 'last_fired_at' => 0,191
- 'created_at' => $now,192
- 'updated_at' => $now,193
- ]));194
- wstat_json(['id' => $id]);195
- }196
-197
- /** PATCH /api/alerts/rules/{id} */198
- public function ruleUpdate(Request $req): void199
- {200
- $rule = $this->ownRule($req, (int) $req->param('id'));201
- $d = $this->ruleInput($req, $rule);202
- $isActive = $req->input('is_active', null);203
- if ($isActive !== null) {204
- $d['is_active'] = (int) (bool) $isActive;205
- }206
- $set = [];207
- $vals = [];208
- foreach ($d as $k => $v) {209
- $set[] = "`$k`=?";210
- $vals[] = $v;211
- }212
- $set[] = 'updated_at=' . time();213
- $vals[] = (int) $rule['id'];214
- Db::execute('UPDATE alert_rules SET ' . implode(',', $set) . ' WHERE id=?', $vals);215
- wstat_json(['ok' => true]);216
- }217
-218
- /** DELETE /api/alerts/rules/{id} */219
- public function ruleDelete(Request $req): void220
- {221
- $rule = $this->ownRule($req, (int) $req->param('id'));222
- Db::execute('DELETE FROM alert_rules WHERE id=?', [(int) $rule['id']]);223
- wstat_json(['ok' => true]);224
- }225
-226
- /* ==================== 告警历史 ==================== */227
-228
- /** GET /api/alerts/logs?site_id=&level=&page=&size= */229
- public function logs(Request $req): void230
- {231
- $u = Auth::requireUser($req);232
- $page = max(1, (int) $req->input('page', 1));233
- $size = min(100, max(1, (int) $req->input('size', 20)));234
-235
- $where = 'l.user_id=?';236
- $args = [(int) $u['id']];237
- $socialSite = (int) $req->input('site_id', 0);238
- if ($socialSite > 0) {239
- $where .= ' AND l.site_id=?';240
- $args[] = $socialSite;241
- }242
- $level = trim((string) $req->input('level', ''));243
- if (in_array($level, AlertEngine::LEVELS, true)) {244
- $where .= ' AND l.level=?';245
- $args[] = $level;246
- }247
- $total = (int) Db::value("SELECT COUNT(*) FROM alert_logs l WHERE $where", $args);248
- $rows = Db::select(249
- "SELECT l.*, r.name AS rule_name, s.name AS site_name, s.domain AS site_domain250
- FROM alert_logs l251
- LEFT JOIN alert_rules r ON r.id=l.rule_id252
- LEFT JOIN sites s ON s.id=l.site_id253
- WHERE $where ORDER BY l.id DESC LIMIT " . (($page - 1) * $size) . ",$size",254
- $args255
- );256
- foreach ($rows as &$r) {257
- $r['id'] = (int) $r['id'];258
- $r['rule_id'] = (int) $r['rule_id'];259
- $r['site_id'] = (int) $r['site_id'];260
- $r['value'] = (float) $r['value'];261
- $r['baseline'] = (float) $r['baseline'];262
- $r['created_at'] = (int) $r['created_at'];263
- }264
- unset($r);265
- wstat_json(['items' => $rows, 'total' => $total, 'page' => $page, 'size' => $size]);266
- }267
-268
- /* ==================== 日报订阅 ==================== */269
-270
- /** GET /api/alerts/reports */271
- public function reportIndex(Request $req): void272
- {273
- $u = Auth::requireUser($req);274
- $rows = Db::select(275
- 'SELECT r.*, s.name AS site_name, s.domain AS site_domain276
- FROM report_subscriptions r LEFT JOIN sites s ON s.id=r.site_id277
- WHERE r.user_id=? ORDER BY r.id DESC',278
- [(int) $u['id']]279
- );280
- foreach ($rows as &$r) {281
- $r['id'] = (int) $r['id'];282
- $r['site_id'] = (int) $r['site_id'];283
- $r['hour'] = (int) $r['hour'];284
- $r['is_active'] = (int) $r['is_active'];285
- $r['channel_ids'] = array_values(array_filter(array_map('intval', explode(',', (string) $r['channels']))));286
- }287
- unset($r);288
- wstat_json(['items' => $rows]);289
- }290
-291
- /** POST /api/alerts/reports */292
- public function reportStore(Request $req): void293
- {294
- $u = Auth::requireUser($req);295
- $uid = (int) $u['id'];296
- if ((int) Db::value('SELECT COUNT(*) FROM report_subscriptions WHERE user_id=?', [$uid]) >= self::MAX_REPORTS) {297
- wstat_err('订阅数量已达上限(' . self::MAX_REPORTS . ')', 422);298
- }299
- $d = $this->reportInput($req);300
- $now = time();301
- $id = Db::insert('report_subscriptions', array_merge($d, [302
- 'user_id' => $uid,303
- 'is_active' => 1,304
- 'last_sent_day' => '',305
- 'last_status' => '',306
- 'created_at' => $now,307
- 'updated_at' => $now,308
- ]));309
- wstat_json(['id' => $id]);310
- }311
-312
- /** PATCH /api/alerts/reports/{id} */313
- public function reportUpdate(Request $req): void314
- {315
- $sub = $this->ownReport($req, (int) $req->param('id'));316
- $d = $this->reportInput($req, $sub);317
- $isActive = $req->input('is_active', null);318
- if ($isActive !== null) {319
- $d['is_active'] = (int) (bool) $isActive;320
- }321
- $set = [];322
- $vals = [];323
- foreach ($d as $k => $v) {324
- $set[] = "`$k`=?";325
- $vals[] = $v;326
- }327
- $set[] = 'updated_at=' . time();328
- $vals[] = (int) $sub['id'];329
- Db::execute('UPDATE report_subscriptions SET ' . implode(',', $set) . ' WHERE id=?', $vals);330
- wstat_json(['ok' => true]);331
- }332
-333
- /** DELETE /api/alerts/reports/{id} */334
- public function reportDelete(Request $req): void335
- {336
- $sub = $this->ownReport($req, (int) $req->param('id'));337
- Db::execute('DELETE FROM report_subscriptions WHERE id=?', [(int) $sub['id']]);338
- wstat_json(['ok' => true]);339
- }340
-341
- /* ==================== 概览计数 ==================== */342
-343
- /** GET /api/alerts/summary */344
- public function summary(Request $req): void345
- {346
- $u = Auth::requireUser($req);347
- $uid = (int) $u['id'];348
- wstat_json([349
- 'channels' => (int) Db::value('SELECT COUNT(*) FROM notify_channels WHERE user_id=? AND is_active=1', [$uid]),350
- 'rules' => (int) Db::value('SELECT COUNT(*) FROM alert_rules WHERE user_id=? AND is_active=1', [$uid]),351
- 'reports' => (int) Db::value('SELECT COUNT(*) FROM report_subscriptions WHERE user_id=? AND is_active=1', [$uid]),352
- 'logs_24h' => (int) Db::value(353
- 'SELECT COUNT(*) FROM alert_logs WHERE user_id=? AND created_at>=?',354
- [$uid, time() - 86400]355
- ),356
- 'last_log' => Db::first(357
- 'SELECT id,level,message,created_at,status FROM alert_logs WHERE user_id=? ORDER BY id DESC LIMIT 1',358
- [$uid]359
- ),360
- ]);361
- }362
-363
- /* ==================== 内部:校验与取值 ==================== */364
-365
- private function ownChannel(Request $req, int $id): array366
- {367
- $u = Auth::requireUser($req);368
- $ch = Db::first('SELECT * FROM notify_channels WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);369
- if ($ch === null) {370
- wstat_err('推送渠道不存在', 404, 404);371
- }372
- return $ch;373
- }374
-375
- private function ownRule(Request $req, int $id): array376
- {377
- $u = Auth::requireUser($req);378
- $r = Db::first('SELECT * FROM alert_rules WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);379
- if ($r === null) {380
- wstat_err('告警规则不存在', 404, 404);381
- }382
- return $r;383
- }384
-385
- private function ownReport(Request $req, int $id): array386
- {387
- $u = Auth::requireUser($req);388
- $r = Db::first('SELECT * FROM report_subscriptions WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);389
- if ($r === null) {390
- wstat_err('订阅不存在', 404, 404);391
- }392
- return $r;393
- }394
-395
- /** 渠道入参校验(新建) */396
- private function channelInput(Request $req): array397
- {398
- $name = trim((string) $req->input('name', ''));399
- $type = (string) $req->input('type', '');400
- if ($name === '' || mb_strlen($name) > 60) {401
- wstat_err('请填写渠道名称(≤60字)', 422);402
- }403
- if (!in_array($type, Notifier::TYPES, true)) {404
- wstat_err('不支持的渠道类型', 422);405
- }406
- $cfg = $this->requiredConfig($type, $this->cfgArray($req->input('config')));407
- return [$name, $type, $cfg];408
- }409
-410
- private function cfgArray($v): array411
- {412
- if (is_array($v)) {413
- return $v;414
- }415
- if (is_string($v) && trim($v) !== '') {416
- $j = json_decode($v, true);417
- return is_array($j) ? $j : [];418
- }419
- return [];420
- }421
-422
- /** 必填项校验(按渠道类型) */423
- private function requiredConfig(string $type, array $cfg): array424
- {425
- $need = static function (array $c, string $key, string $label): void {426
- if (trim((string) ($c[$key] ?? '')) === '') {427
- wstat_err('请填写' . $label, 422);428
- }429
- };430
- switch ($type) {431
- case 'webhook':432
- case 'feishu':433
- case 'dingtalk':434
- case 'wecom':435
- $need($cfg, 'url', 'Webhook 地址');436
- break;437
- case 'serverchan':438
- $need($cfg, 'key', 'SendKey');439
- break;440
- case 'bark':441
- $need($cfg, 'key', 'Bark Key');442
- break;443
- case 'email':444
- $to = $cfg['to'] ?? '';445
- $list = is_array($to) ? $to : array_filter(array_map('trim', explode(',', (string) $to)));446
- if (!$list) {447
- wstat_err('请填写收件人邮箱', 422);448
- }449
- foreach ($list as $a) {450
- if (!Util::validEmail((string) $a)) {451
- wstat_err('收件人邮箱格式不正确: ' . $a, 422);452
- }453
- }454
- $cfg['to'] = array_values($list);455
- break;456
- }457
- return $cfg;458
- }459
-460
- /**461
- * 合并旧配置:值为掩码「******」或空字符串的密钥字段沿用旧值,462
- * 这样前端回显掩码、用户只改名称时不会把密钥清空。463
- */464
- private static function mergeConfig(string $type, array $incoming, array $existing): array465
- {466
- $secretKeys = ['secret', 'key', 'password', 'token'];467
- foreach ($secretKeys as $k) {468
- $v = (string) ($incoming[$k] ?? '');469
- if ($v === '' || preg_match('/^\*+$/', $v)) {470
- if (array_key_exists($k, $existing)) {471
- $incoming[$k] = $existing[$k];472
- }473
- }474
- }475
- if (($incoming['url'] ?? '') === '' && isset($existing['url'])) {476
- $incoming['url'] = $existing['url'];477
- }478
- if ($type === 'email') {479
- $to = $incoming['to'] ?? [];480
- if (is_string($to)) {481
- $to = array_filter(array_map('trim', explode(',', $to)));482
- }483
- if (!$to && isset($existing['to'])) {484
- $incoming['to'] = $existing['to'];485
- }486
- }487
- return $incoming;488
- }489
-490
- /** 回显时把密钥打码(前端只看到掩码,保存时原样回传即可保留) */491
- private static function maskConfig(array $cfg): array492
- {493
- foreach (['secret', 'key', 'password', 'token'] as $k) {494
- if (isset($cfg[$k]) && (string) $cfg[$k] !== '') {495
- $cfg[$k] = '******';496
- }497
- }498
- return $cfg;499
- }500
-501
- /**502
- * 站点参数校验:0 = 全部站点;否则要求对该站点至少具备 $min 角色。503
- * 规则写入场景默认要求「可编辑」——所有者与协作者可配,只读成员不可。504
- */505
- private function siteArg(Request $req, string $min = SiteAccess::EDITOR): int506
- {507
- $sid = (int) $req->input('site_id', 0);508
- if ($sid <= 0) {509
- return 0;510
- }511
- SiteAccess::require($req, $sid, $min);512
- return $sid;513
- }514
-515
- /** 渠道 id 列表校验(必须属于当前用户) */516
- private function channelSpec(Request $req): string517
- {518
- $u = Auth::requireUser($req);519
- $v = $req->input('channel_ids', $req->input('channels', null));520
- if ($v === null) {521
- return '';522
- }523
- $ids = is_array($v) ? $v : explode(',', (string) $v);524
- $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));525
- if (!$ids) {526
- return '';527
- }528
- $ph = implode(',', array_fill(0, count($ids), '?'));529
- $owned = Db::select(530
- "SELECT id FROM notify_channels WHERE user_id=? AND id IN ($ph)",531
- [(int) $u['id'], ...$ids]532
- );533
- $ok = array_map(static fn ($r) => (int) $r['id'], $owned);534
- return implode(',', $ok);535
- }536
-537
- /** 规则入参($base 非空时表示补丁更新:未提供的字段沿用旧值) */538
- private function ruleInput(Request $req, array $base = []): array539
- {540
- $get = static function (string $k, $def = null) use ($req, $base) {541
- $v = $req->input($k, null);542
- if ($v !== null) {543
- return $v;544
- }545
- return array_key_exists($k, $base) ? $base[$k] : $def;546
- };547
-548
- $name = trim((string) $get('name', ''));549
- if ($name === '' || mb_strlen($name) > 100) {550
- wstat_err('请填写规则名称(≤100字)', 422);551
- }552
- $metric = (string) $get('metric', 'pv');553
- if (!in_array($metric, AlertEngine::METRICS, true)) {554
- wstat_err('不支持的指标', 422);555
- }556
- $compare = (string) $get('compare', 'ratio_down');557
- if (!in_array($compare, AlertEngine::COMPARES, true)) {558
- wstat_err('不支持的比较方式', 422);559
- }560
- $baseline = (string) $get('baseline', 'prev_day');561
- if (!in_array($baseline, AlertEngine::BASELINES, true)) {562
- wstat_err('不支持的基线', 422);563
- }564
- $level = (string) $get('level', 'warn');565
- if (!in_array($level, AlertEngine::LEVELS, true)) {566
- $level = 'warn';567
- }568
- $window = (int) $get('window_min', 60);569
- if ($window < 1 || $window > 1440) {570
- wstat_err('评估窗口需在 1~1440 分钟之间', 422);571
- }572
- $cooldown = (int) $get('cooldown_min', 60);573
- if ($cooldown < 1 || $cooldown > 10080) {574
- wstat_err('冷却时间需在 1~10080 分钟之间', 422);575
- }576
- $minSample = (int) $get('min_sample', 10);577
- if ($minSample < 0 || $minSample > 100000000) {578
- wstat_err('基线样本量不合法', 422);579
- }580
- $threshold = $get('threshold', 0);581
- if (!is_numeric($threshold)) {582
- wstat_err('阈值需为数字', 422);583
- }584
- $threshold = (float) $threshold;585
- if ($compare === 'ratio_up' || $compare === 'ratio_down') {586
- if ($threshold <= 0 || $threshold > 10000) {587
- wstat_err('比例类比较的阈值需大于 0(百分比)', 422);588
- }589
- }590
-591
- $out = [592
- 'site_id' => $this->siteArg($req),593
- 'name' => $name,594
- 'metric' => $metric,595
- 'window_min' => $window,596
- 'compare' => $compare,597
- 'baseline' => $baseline,598
- 'threshold' => round($threshold, 2),599
- 'min_sample' => $minSample,600
- 'level' => $level,601
- 'cooldown_min' => $cooldown,602
- 'channels' => $this->channelSpec($req),603
- ];604
-605
- // v1.3 新列(恢复通知 + 静默时段):老库缺列时自动省略(优雅降级)606
- if (self::ruleExtCols()) {607
- $recover = (int) (bool) $get('recover_notify', array_key_exists('recover_notify', $base) ? $base['recover_notify'] : 0);608
- $qs = trim((string) $get('quiet_start', array_key_exists('quiet_start', $base) ? $base['quiet_start'] : ''));609
- $qe = trim((string) $get('quiet_end', array_key_exists('quiet_end', $base) ? $base['quiet_end'] : ''));610
- $hm = static fn (string $s): bool => preg_match('/^([01]?\d|2[0-3]):[0-5]\d$/', $s) === 1;611
- if ($qs !== '' && !$hm($qs)) {612
- wstat_err('静默开始时间格式应为 HH:MM(如 23:00)', 422);613
- }614
- if ($qe !== '' && !$hm($qe)) {615
- wstat_err('静默结束时间格式应为 HH:MM(如 07:00)', 422);616
- }617
- if (($qs === '') !== ($qe === '')) {618
- wstat_err('静默时段需同时填写开始与结束,或都留空', 422);619
- }620
- $out['recover_notify'] = $recover;621
- $out['quiet_start'] = $qs;622
- $out['quiet_end'] = $qe;623
- }624
- return $out;625
- }626
-627
- /** alert_rules 新列(recover_notify 等)是否存在(进程内缓存,缺列=老库未跑增量脚本) */628
- private static function ruleExtCols(): bool629
- {630
- static $ok = null;631
- if ($ok === null) {632
- try {633
- // tableColumns() 返回键=列名,判列必须 array_key_exists634
- $c = Db::tableColumns('alert_rules');635
- $ok = array_key_exists('recover_notify', $c) && array_key_exists('quiet_start', $c);636
- } catch (\Throwable $e) {637
- $ok = false;638
- }639
- }640
- return $ok;641
- }642
-643
- /** 订阅入参 */644
- private function reportInput(Request $req, array $base = []): array645
- {646
- $get = static function (string $k, $def = null) use ($req, $base) {647
- $v = $req->input($k, null);648
- if ($v !== null) {649
- return $v;650
- }651
- return array_key_exists($k, $base) ? $base[$k] : $def;652
- };653
-654
- $freq = (string) $get('freq', 'daily');655
- if (!in_array($freq, ['daily', 'weekly'], true)) {656
- wstat_err('推送频率只能是 daily 或 weekly', 422);657
- }658
- $hour = (int) $get('hour', 9);659
- if ($hour < 0 || $hour > 23) {660
- wstat_err('推送小时需在 0~23 之间', 422);661
- }662
- $daysRaw = $get('days', '1,2,3,4,5,6,7');663
- $days = is_array($daysRaw) ? $daysRaw : explode(',', (string) $daysRaw);664
- $days = array_values(array_unique(array_filter(array_map('intval', $days), static fn ($d) => $d >= 1 && $d <= 7)));665
- sort($days);666
- if ($freq === 'weekly' && !$days) {667
- wstat_err('周报至少选择一天', 422);668
- }669
- $name = trim((string) $get('name', ''));670
- if (mb_strlen($name) > 100) {671
- wstat_err('订阅名称过长', 422);672
- }673
-674
- return [675
- 'site_id' => $this->siteArg($req),676
- 'name' => $name !== '' ? $name : ($freq === 'weekly' ? '每周数据周报' : '每日数据日报'),677
- 'freq' => $freq,678
- 'days' => $days ? implode(',', $days) : '1,2,3,4,5,6,7',679
- 'hour' => $hour,680
- 'channels' => $this->channelSpec($req),681
- ];682
- }683
-}1
+<?php2
+/**3
+ * 告警与日报控制器4
+ *5
+ * 推送渠道:GET/POST /api/alerts/channels,PATCH/DELETE /api/alerts/channels/{id},POST .../{id}/test6
+ * 告警规则:GET/POST /api/alerts/rules,PATCH/DELETE /api/alerts/rules/{id}7
+ * 告警历史:GET /api/alerts/logs8
+ * 日报订阅:GET/POST /api/alerts/reports,PATCH/DELETE /api/alerts/reports/{id}9
+ * 概览计数:GET /api/alerts/summary10
+ *11
+ * 权限:全部为用户私有资源(user_id 归属校验),站点参数仅允许自己名下的站点;12
+ * 只读协作成员看不到这些配置(它们属于「站点所有者」的运维设置)。13
+ */14
+declare(strict_types=1);15
+16
+namespace Wstat\Controllers;17
+18
+use Wstat\Http\Request;19
+use Wstat\Support\AlertEngine;20
+use Wstat\Support\Auth;21
+use Wstat\Support\Db;22
+use Wstat\Support\Notifier;23
+use Wstat\Support\SiteAccess;24
+use Wstat\Support\Util;25
+26
+class AlertController27
+{28
+ private const MAX_CHANNELS = 20;29
+ private const MAX_RULES = 50;30
+ private const MAX_REPORTS = 20;31
+32
+ /* ==================== 推送渠道 ==================== */33
+34
+ /** GET /api/alerts/channels */35
+ public function channelIndex(Request $req): void36
+ {37
+ $u = Auth::requireUser($req);38
+ $rows = Db::select(39
+ 'SELECT id,name,type,config,is_active,last_status,last_sent_at,created_at40
+ FROM notify_channels WHERE user_id=? ORDER BY id',41
+ [(int) $u['id']]42
+ );43
+ foreach ($rows as &$r) {44
+ $r['id'] = (int) $r['id'];45
+ $r['is_active'] = (int) $r['is_active'];46
+ $r['last_sent_at'] = (int) $r['last_sent_at'];47
+ $r['config'] = self::maskConfig(json_decode((string) $r['config'], true) ?: []);48
+ }49
+ unset($r);50
+ wstat_json(['items' => $rows, 'types' => Notifier::TYPES]);51
+ }52
+53
+ /** POST /api/alerts/channels */54
+ public function channelStore(Request $req): void55
+ {56
+ $u = Auth::requireUser($req);57
+ $uid = (int) $u['id'];58
+ if ((int) Db::value('SELECT COUNT(*) FROM notify_channels WHERE user_id=?', [$uid]) >= self::MAX_CHANNELS) {59
+ wstat_err('推送渠道数量已达上限(' . self::MAX_CHANNELS . ')', 422);60
+ }61
+ [$name, $type, $cfg] = $this->channelInput($req);62
+ $id = Db::insert('notify_channels', [63
+ 'user_id' => $uid,64
+ 'name' => $name,65
+ 'type' => $type,66
+ 'config' => json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),67
+ 'is_active' => 1,68
+ 'created_at' => time(),69
+ 'updated_at' => time(),70
+ ]);71
+ wstat_json(['id' => $id]);72
+ }73
+74
+ /** PATCH /api/alerts/channels/{id} */75
+ public function channelUpdate(Request $req): void76
+ {77
+ $ch = $this->ownChannel($req, (int) $req->param('id'));78
+ $fields = [];79
+ $name = trim((string) $req->input('name', ''));80
+ if ($name !== '') {81
+ if (mb_strlen($name) > 60) {82
+ wstat_err('渠道名称过长', 422);83
+ }84
+ $fields['name'] = $name;85
+ }86
+ $isActive = $req->input('is_active', null);87
+ if ($isActive !== null) {88
+ $fields['is_active'] = (int) (bool) $isActive;89
+ }90
+ // 传了 config 才更新(避免「只改名称」时把密钥擦掉)91
+ if ($req->input('config', null) !== null) {92
+ $type = (string) $req->input('type', (string) $ch['type']);93
+ if (!in_array($type, Notifier::TYPES, true)) {94
+ wstat_err('不支持的渠道类型', 422);95
+ }96
+ $cfg = $this->cfgArray($req->input('config'));97
+ $cfg = self::mergeConfig($type, $cfg, json_decode((string) $ch['config'], true) ?: []);98
+ if ($type !== (string) $ch['type']) {99
+ $fields['type'] = $type;100
+ }101
+ $fields['config'] = json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);102
+ }103
+ if (!$fields) {104
+ wstat_err('无有效字段', 422);105
+ }106
+ $set = [];107
+ $vals = [];108
+ foreach ($fields as $k => $v) {109
+ $set[] = "`$k`=?";110
+ $vals[] = $v;111
+ }112
+ $set[] = 'updated_at=' . time();113
+ $vals[] = (int) $ch['id'];114
+ Db::execute('UPDATE notify_channels SET ' . implode(',', $set) . ' WHERE id=?', $vals);115
+ wstat_json(['ok' => true]);116
+ }117
+118
+ /** DELETE /api/alerts/channels/{id} */119
+ public function channelDelete(Request $req): void120
+ {121
+ $ch = $this->ownChannel($req, (int) $req->param('id'));122
+ Db::execute('DELETE FROM notify_channels WHERE id=?', [(int) $ch['id']]);123
+ wstat_json(['ok' => true]);124
+ }125
+126
+ /** POST /api/alerts/channels/{id}/test 发送测试消息 */127
+ public function channelTest(Request $req): void128
+ {129
+ $ch = $this->ownChannel($req, (int) $req->param('id'));130
+ $title = '通道连通性测试';131
+ $text = "这是一条 WebStats 测试消息。\n"132
+ . '渠道:' . (string) $ch['name'] . '(' . (string) $ch['type'] . ")\n"133
+ . '若你能看到本条消息,说明该渠道配置正确。';134
+ $res = Notifier::send([$ch], $title, $text);135
+ $one = $res['results'][0] ?? ['ok' => false, 'msg' => '未知错误'];136
+ if (!$one['ok']) {137
+ wstat_json(['ok' => false, 'msg' => $one['msg']], 200, 0, '测试发送失败:' . $one['msg']);138
+ }139
+ wstat_json(['ok' => true]);140
+ }141
+142
+ /* ==================== 告警规则 ==================== */143
+144
+ /** GET /api/alerts/rules */145
+ public function ruleIndex(Request $req): void146
+ {147
+ $u = Auth::requireUser($req);148
+ $rows = Db::select(149
+ 'SELECT r.*, s.name AS site_name, s.domain AS site_domain150
+ FROM alert_rules r LEFT JOIN sites s ON s.id=r.site_id151
+ WHERE r.user_id=? ORDER BY r.id DESC',152
+ [(int) $u['id']]153
+ );154
+ foreach ($rows as &$r) {155
+ foreach (['id', 'site_id', 'window_min', 'min_sample', 'cooldown_min', 'is_active', 'last_fired_at'] as $k) {156
+ $r[$k] = (int) $r[$k];157
+ }158
+ $r['threshold'] = (float) $r['threshold'];159
+ $r['channel_ids'] = array_values(array_filter(array_map('intval', explode(',', (string) $r['channels']))));160
+ // v1.3 新列(老库缺列时补默认值,前端无需判空)161
+ $r['recover_notify'] = (int) ($r['recover_notify'] ?? 0);162
+ $r['firing'] = (int) ($r['firing'] ?? 0);163
+ $r['quiet_start'] = (string) ($r['quiet_start'] ?? '');164
+ $r['quiet_end'] = (string) ($r['quiet_end'] ?? '');165
+ }166
+ unset($r);167
+ wstat_json([168
+ 'items' => $rows,169
+ 'metrics' => AlertEngine::METRICS,170
+ 'compares' => AlertEngine::COMPARES,171
+ 'baselines' => AlertEngine::BASELINES,172
+ 'levels' => AlertEngine::LEVELS,173
+ 'recovery_available' => self::ruleExtCols(),174
+ ]);175
+ }176
+177
+ /** POST /api/alerts/rules */178
+ public function ruleStore(Request $req): void179
+ {180
+ $u = Auth::requireUser($req);181
+ $uid = (int) $u['id'];182
+ if ((int) Db::value('SELECT COUNT(*) FROM alert_rules WHERE user_id=?', [$uid]) >= self::MAX_RULES) {183
+ wstat_err('告警规则数量已达上限(' . self::MAX_RULES . ')', 422);184
+ }185
+ $d = $this->ruleInput($req);186
+ $now = time();187
+ $id = Db::insert('alert_rules', array_merge($d, [188
+ 'user_id' => $uid,189
+ 'is_active' => 1,190
+ 'last_fired_at' => 0,191
+ 'created_at' => $now,192
+ 'updated_at' => $now,193
+ ]));194
+ wstat_json(['id' => $id]);195
+ }196
+197
+ /** PATCH /api/alerts/rules/{id} */198
+ public function ruleUpdate(Request $req): void199
+ {200
+ $rule = $this->ownRule($req, (int) $req->param('id'));201
+ $d = $this->ruleInput($req, $rule);202
+ $isActive = $req->input('is_active', null);203
+ if ($isActive !== null) {204
+ $d['is_active'] = (int) (bool) $isActive;205
+ }206
+ $set = [];207
+ $vals = [];208
+ foreach ($d as $k => $v) {209
+ $set[] = "`$k`=?";210
+ $vals[] = $v;211
+ }212
+ $set[] = 'updated_at=' . time();213
+ $vals[] = (int) $rule['id'];214
+ Db::execute('UPDATE alert_rules SET ' . implode(',', $set) . ' WHERE id=?', $vals);215
+ wstat_json(['ok' => true]);216
+ }217
+218
+ /** DELETE /api/alerts/rules/{id} */219
+ public function ruleDelete(Request $req): void220
+ {221
+ $rule = $this->ownRule($req, (int) $req->param('id'));222
+ Db::execute('DELETE FROM alert_rules WHERE id=?', [(int) $rule['id']]);223
+ wstat_json(['ok' => true]);224
+ }225
+226
+ /* ==================== 告警历史 ==================== */227
+228
+ /** GET /api/alerts/logs?site_id=&level=&page=&size= */229
+ public function logs(Request $req): void230
+ {231
+ $u = Auth::requireUser($req);232
+ $page = max(1, (int) $req->input('page', 1));233
+ $size = min(100, max(1, (int) $req->input('size', 20)));234
+235
+ $where = 'l.user_id=?';236
+ $args = [(int) $u['id']];237
+ $socialSite = (int) $req->input('site_id', 0);238
+ if ($socialSite > 0) {239
+ $where .= ' AND l.site_id=?';240
+ $args[] = $socialSite;241
+ }242
+ $level = trim((string) $req->input('level', ''));243
+ if (in_array($level, AlertEngine::LEVELS, true)) {244
+ $where .= ' AND l.level=?';245
+ $args[] = $level;246
+ }247
+ $total = (int) Db::value("SELECT COUNT(*) FROM alert_logs l WHERE $where", $args);248
+ $rows = Db::select(249
+ "SELECT l.*, r.name AS rule_name, s.name AS site_name, s.domain AS site_domain250
+ FROM alert_logs l251
+ LEFT JOIN alert_rules r ON r.id=l.rule_id252
+ LEFT JOIN sites s ON s.id=l.site_id253
+ WHERE $where ORDER BY l.id DESC LIMIT " . (($page - 1) * $size) . ",$size",254
+ $args255
+ );256
+ foreach ($rows as &$r) {257
+ $r['id'] = (int) $r['id'];258
+ $r['rule_id'] = (int) $r['rule_id'];259
+ $r['site_id'] = (int) $r['site_id'];260
+ $r['value'] = (float) $r['value'];261
+ $r['baseline'] = (float) $r['baseline'];262
+ $r['created_at'] = (int) $r['created_at'];263
+ }264
+ unset($r);265
+ wstat_json(['items' => $rows, 'total' => $total, 'page' => $page, 'size' => $size]);266
+ }267
+268
+ /* ==================== 日报订阅 ==================== */269
+270
+ /** GET /api/alerts/reports */271
+ public function reportIndex(Request $req): void272
+ {273
+ $u = Auth::requireUser($req);274
+ $rows = Db::select(275
+ 'SELECT r.*, s.name AS site_name, s.domain AS site_domain276
+ FROM report_subscriptions r LEFT JOIN sites s ON s.id=r.site_id277
+ WHERE r.user_id=? ORDER BY r.id DESC',278
+ [(int) $u['id']]279
+ );280
+ foreach ($rows as &$r) {281
+ $r['id'] = (int) $r['id'];282
+ $r['site_id'] = (int) $r['site_id'];283
+ $r['hour'] = (int) $r['hour'];284
+ $r['is_active'] = (int) $r['is_active'];285
+ $r['channel_ids'] = array_values(array_filter(array_map('intval', explode(',', (string) $r['channels']))));286
+ }287
+ unset($r);288
+ wstat_json(['items' => $rows]);289
+ }290
+291
+ /** POST /api/alerts/reports */292
+ public function reportStore(Request $req): void293
+ {294
+ $u = Auth::requireUser($req);295
+ $uid = (int) $u['id'];296
+ if ((int) Db::value('SELECT COUNT(*) FROM report_subscriptions WHERE user_id=?', [$uid]) >= self::MAX_REPORTS) {297
+ wstat_err('订阅数量已达上限(' . self::MAX_REPORTS . ')', 422);298
+ }299
+ $d = $this->reportInput($req);300
+ $now = time();301
+ $id = Db::insert('report_subscriptions', array_merge($d, [302
+ 'user_id' => $uid,303
+ 'is_active' => 1,304
+ 'last_sent_day' => '',305
+ 'last_status' => '',306
+ 'created_at' => $now,307
+ 'updated_at' => $now,308
+ ]));309
+ wstat_json(['id' => $id]);310
+ }311
+312
+ /** PATCH /api/alerts/reports/{id} */313
+ public function reportUpdate(Request $req): void314
+ {315
+ $sub = $this->ownReport($req, (int) $req->param('id'));316
+ $d = $this->reportInput($req, $sub);317
+ $isActive = $req->input('is_active', null);318
+ if ($isActive !== null) {319
+ $d['is_active'] = (int) (bool) $isActive;320
+ }321
+ $set = [];322
+ $vals = [];323
+ foreach ($d as $k => $v) {324
+ $set[] = "`$k`=?";325
+ $vals[] = $v;326
+ }327
+ $set[] = 'updated_at=' . time();328
+ $vals[] = (int) $sub['id'];329
+ Db::execute('UPDATE report_subscriptions SET ' . implode(',', $set) . ' WHERE id=?', $vals);330
+ wstat_json(['ok' => true]);331
+ }332
+333
+ /** DELETE /api/alerts/reports/{id} */334
+ public function reportDelete(Request $req): void335
+ {336
+ $sub = $this->ownReport($req, (int) $req->param('id'));337
+ Db::execute('DELETE FROM report_subscriptions WHERE id=?', [(int) $sub['id']]);338
+ wstat_json(['ok' => true]);339
+ }340
+341
+ /* ==================== 概览计数 ==================== */342
+343
+ /** GET /api/alerts/summary */344
+ public function summary(Request $req): void345
+ {346
+ $u = Auth::requireUser($req);347
+ $uid = (int) $u['id'];348
+ wstat_json([349
+ 'channels' => (int) Db::value('SELECT COUNT(*) FROM notify_channels WHERE user_id=? AND is_active=1', [$uid]),350
+ 'rules' => (int) Db::value('SELECT COUNT(*) FROM alert_rules WHERE user_id=? AND is_active=1', [$uid]),351
+ 'reports' => (int) Db::value('SELECT COUNT(*) FROM report_subscriptions WHERE user_id=? AND is_active=1', [$uid]),352
+ 'logs_24h' => (int) Db::value(353
+ 'SELECT COUNT(*) FROM alert_logs WHERE user_id=? AND created_at>=?',354
+ [$uid, time() - 86400]355
+ ),356
+ 'last_log' => Db::first(357
+ 'SELECT id,level,message,created_at,status FROM alert_logs WHERE user_id=? ORDER BY id DESC LIMIT 1',358
+ [$uid]359
+ ),360
+ ]);361
+ }362
+363
+ /* ==================== 内部:校验与取值 ==================== */364
+365
+ private function ownChannel(Request $req, int $id): array366
+ {367
+ $u = Auth::requireUser($req);368
+ $ch = Db::first('SELECT * FROM notify_channels WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);369
+ if ($ch === null) {370
+ wstat_err('推送渠道不存在', 404, 404);371
+ }372
+ return $ch;373
+ }374
+375
+ private function ownRule(Request $req, int $id): array376
+ {377
+ $u = Auth::requireUser($req);378
+ $r = Db::first('SELECT * FROM alert_rules WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);379
+ if ($r === null) {380
+ wstat_err('告警规则不存在', 404, 404);381
+ }382
+ return $r;383
+ }384
+385
+ private function ownReport(Request $req, int $id): array386
+ {387
+ $u = Auth::requireUser($req);388
+ $r = Db::first('SELECT * FROM report_subscriptions WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);389
+ if ($r === null) {390
+ wstat_err('订阅不存在', 404, 404);391
+ }392
+ return $r;393
+ }394
+395
+ /** 渠道入参校验(新建) */396
+ private function channelInput(Request $req): array397
+ {398
+ $name = trim((string) $req->input('name', ''));399
+ $type = (string) $req->input('type', '');400
+ if ($name === '' || mb_strlen($name) > 60) {401
+ wstat_err('请填写渠道名称(≤60字)', 422);402
+ }403
+ if (!in_array($type, Notifier::TYPES, true)) {404
+ wstat_err('不支持的渠道类型', 422);405
+ }406
+ $cfg = $this->requiredConfig($type, $this->cfgArray($req->input('config')));407
+ return [$name, $type, $cfg];408
+ }409
+410
+ private function cfgArray($v): array411
+ {412
+ if (is_array($v)) {413
+ return $v;414
+ }415
+ if (is_string($v) && trim($v) !== '') {416
+ $j = json_decode($v, true);417
+ return is_array($j) ? $j : [];418
+ }419
+ return [];420
+ }421
+422
+ /** 必填项校验(按渠道类型) */423
+ private function requiredConfig(string $type, array $cfg): array424
+ {425
+ $need = static function (array $c, string $key, string $label): void {426
+ if (trim((string) ($c[$key] ?? '')) === '') {427
+ wstat_err('请填写' . $label, 422);428
+ }429
+ };430
+ switch ($type) {431
+ case 'webhook':432
+ case 'feishu':433
+ case 'dingtalk':434
+ case 'wecom':435
+ $need($cfg, 'url', 'Webhook 地址');436
+ break;437
+ case 'serverchan':438
+ $need($cfg, 'key', 'SendKey');439
+ break;440
+ case 'bark':441
+ $need($cfg, 'key', 'Bark Key');442
+ break;443
+ case 'email':444
+ $to = $cfg['to'] ?? '';445
+ $list = is_array($to) ? $to : array_filter(array_map('trim', explode(',', (string) $to)));446
+ if (!$list) {447
+ wstat_err('请填写收件人邮箱', 422);448
+ }449
+ foreach ($list as $a) {450
+ if (!Util::validEmail((string) $a)) {451
+ wstat_err('收件人邮箱格式不正确: ' . $a, 422);452
+ }453
+ }454
+ $cfg['to'] = array_values($list);455
+ break;456
+ }457
+ return $cfg;458
+ }459
+460
+ /**461
+ * 合并旧配置:值为掩码「******」或空字符串的密钥字段沿用旧值,462
+ * 这样前端回显掩码、用户只改名称时不会把密钥清空。463
+ */464
+ private static function mergeConfig(string $type, array $incoming, array $existing): array465
+ {466
+ $secretKeys = ['secret', 'key', 'password', 'token'];467
+ foreach ($secretKeys as $k) {468
+ $v = (string) ($incoming[$k] ?? '');469
+ if ($v === '' || preg_match('/^\*+$/', $v)) {470
+ if (array_key_exists($k, $existing)) {471
+ $incoming[$k] = $existing[$k];472
+ }473
+ }474
+ }475
+ if (($incoming['url'] ?? '') === '' && isset($existing['url'])) {476
+ $incoming['url'] = $existing['url'];477
+ }478
+ if ($type === 'email') {479
+ $to = $incoming['to'] ?? [];480
+ if (is_string($to)) {481
+ $to = array_filter(array_map('trim', explode(',', $to)));482
+ }483
+ if (!$to && isset($existing['to'])) {484
+ $incoming['to'] = $existing['to'];485
+ }486
+ }487
+ return $incoming;488
+ }489
+490
+ /** 回显时把密钥打码(前端只看到掩码,保存时原样回传即可保留) */491
+ private static function maskConfig(array $cfg): array492
+ {493
+ foreach (['secret', 'key', 'password', 'token'] as $k) {494
+ if (isset($cfg[$k]) && (string) $cfg[$k] !== '') {495
+ $cfg[$k] = '******';496
+ }497
+ }498
+ return $cfg;499
+ }500
+501
+ /**502
+ * 站点参数校验:0 = 全部站点;否则要求对该站点至少具备 $min 角色。503
+ * 规则写入场景默认要求「可编辑」——所有者与协作者可配,只读成员不可。504
+ */505
+ private function siteArg(Request $req, string $min = SiteAccess::EDITOR): int506
+ {507
+ $sid = (int) $req->input('site_id', 0);508
+ if ($sid <= 0) {509
+ return 0;510
+ }511
+ SiteAccess::require($req, $sid, $min);512
+ return $sid;513
+ }514
+515
+ /** 渠道 id 列表校验(必须属于当前用户) */516
+ private function channelSpec(Request $req): string517
+ {518
+ $u = Auth::requireUser($req);519
+ $v = $req->input('channel_ids', $req->input('channels', null));520
+ if ($v === null) {521
+ return '';522
+ }523
+ $ids = is_array($v) ? $v : explode(',', (string) $v);524
+ $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));525
+ if (!$ids) {526
+ return '';527
+ }528
+ $ph = implode(',', array_fill(0, count($ids), '?'));529
+ $owned = Db::select(530
+ "SELECT id FROM notify_channels WHERE user_id=? AND id IN ($ph)",531
+ [(int) $u['id'], ...$ids]532
+ );533
+ $ok = array_map(static fn ($r) => (int) $r['id'], $owned);534
+ return implode(',', $ok);535
+ }536
+537
+ /** 规则入参($base 非空时表示补丁更新:未提供的字段沿用旧值) */538
+ private function ruleInput(Request $req, array $base = []): array539
+ {540
+ $get = static function (string $k, $def = null) use ($req, $base) {541
+ $v = $req->input($k, null);542
+ if ($v !== null) {543
+ return $v;544
+ }545
+ return array_key_exists($k, $base) ? $base[$k] : $def;546
+ };547
+548
+ $name = trim((string) $get('name', ''));549
+ if ($name === '' || mb_strlen($name) > 100) {550
+ wstat_err('请填写规则名称(≤100字)', 422);551
+ }552
+ $metric = (string) $get('metric', 'pv');553
+ if (!in_array($metric, AlertEngine::METRICS, true)) {554
+ wstat_err('不支持的指标', 422);555
+ }556
+ $compare = (string) $get('compare', 'ratio_down');557
+ if (!in_array($compare, AlertEngine::COMPARES, true)) {558
+ wstat_err('不支持的比较方式', 422);559
+ }560
+ $baseline = (string) $get('baseline', 'prev_day');561
+ if (!in_array($baseline, AlertEngine::BASELINES, true)) {562
+ wstat_err('不支持的基线', 422);563
+ }564
+ $level = (string) $get('level', 'warn');565
+ if (!in_array($level, AlertEngine::LEVELS, true)) {566
+ $level = 'warn';567
+ }568
+ $window = (int) $get('window_min', 60);569
+ if ($window < 1 || $window > 1440) {570
+ wstat_err('评估窗口需在 1~1440 分钟之间', 422);571
+ }572
+ $cooldown = (int) $get('cooldown_min', 60);573
+ if ($cooldown < 1 || $cooldown > 10080) {574
+ wstat_err('冷却时间需在 1~10080 分钟之间', 422);575
+ }576
+ $minSample = (int) $get('min_sample', 10);577
+ if ($minSample < 0 || $minSample > 100000000) {578
+ wstat_err('基线样本量不合法', 422);579
+ }580
+ $threshold = $get('threshold', 0);581
+ if (!is_numeric($threshold)) {582
+ wstat_err('阈值需为数字', 422);583
+ }584
+ $threshold = (float) $threshold;585
+ if ($compare === 'ratio_up' || $compare === 'ratio_down') {586
+ if ($threshold <= 0 || $threshold > 10000) {587
+ wstat_err('比例类比较的阈值需大于 0(百分比)', 422);588
+ }589
+ }590
+591
+ $out = [592
+ 'site_id' => $this->siteArg($req),593
+ 'name' => $name,594
+ 'metric' => $metric,595
+ 'window_min' => $window,596
+ 'compare' => $compare,597
+ 'baseline' => $baseline,598
+ 'threshold' => round($threshold, 2),599
+ 'min_sample' => $minSample,600
+ 'level' => $level,601
+ 'cooldown_min' => $cooldown,602
+ 'channels' => $this->channelSpec($req),603
+ ];604
+605
+ // v1.3 新列(恢复通知 + 静默时段):老库缺列时自动省略(优雅降级)606
+ if (self::ruleExtCols()) {607
+ $recover = (int) (bool) $get('recover_notify', array_key_exists('recover_notify', $base) ? $base['recover_notify'] : 0);608
+ $qs = trim((string) $get('quiet_start', array_key_exists('quiet_start', $base) ? $base['quiet_start'] : ''));609
+ $qe = trim((string) $get('quiet_end', array_key_exists('quiet_end', $base) ? $base['quiet_end'] : ''));610
+ $hm = static fn (string $s): bool => preg_match('/^([01]?\d|2[0-3]):[0-5]\d$/', $s) === 1;611
+ if ($qs !== '' && !$hm($qs)) {612
+ wstat_err('静默开始时间格式应为 HH:MM(如 23:00)', 422);613
+ }614
+ if ($qe !== '' && !$hm($qe)) {615
+ wstat_err('静默结束时间格式应为 HH:MM(如 07:00)', 422);616
+ }617
+ if (($qs === '') !== ($qe === '')) {618
+ wstat_err('静默时段需同时填写开始与结束,或都留空', 422);619
+ }620
+ $out['recover_notify'] = $recover;621
+ $out['quiet_start'] = $qs;622
+ $out['quiet_end'] = $qe;623
+ }624
+ return $out;625
+ }626
+627
+ /** alert_rules 新列(recover_notify 等)是否存在(进程内缓存,缺列=老库未跑增量脚本) */628
+ private static function ruleExtCols(): bool629
+ {630
+ static $ok = null;631
+ if ($ok === null) {632
+ try {633
+ // tableColumns() 返回键=列名,判列必须 array_key_exists634
+ $c = Db::tableColumns('alert_rules');635
+ $ok = array_key_exists('recover_notify', $c) && array_key_exists('quiet_start', $c);636
+ } catch (\Throwable $e) {637
+ $ok = false;638
+ }639
+ }640
+ return $ok;641
+ }642
+643
+ /** 订阅入参 */644
+ private function reportInput(Request $req, array $base = []): array645
+ {646
+ $get = static function (string $k, $def = null) use ($req, $base) {647
+ $v = $req->input($k, null);648
+ if ($v !== null) {649
+ return $v;650
+ }651
+ return array_key_exists($k, $base) ? $base[$k] : $def;652
+ };653
+654
+ $freq = (string) $get('freq', 'daily');655
+ if (!in_array($freq, ['daily', 'weekly'], true)) {656
+ wstat_err('推送频率只能是 daily 或 weekly', 422);657
+ }658
+ $hour = (int) $get('hour', 9);659
+ if ($hour < 0 || $hour > 23) {660
+ wstat_err('推送小时需在 0~23 之间', 422);661
+ }662
+ $daysRaw = $get('days', '1,2,3,4,5,6,7');663
+ $days = is_array($daysRaw) ? $daysRaw : explode(',', (string) $daysRaw);664
+ $days = array_values(array_unique(array_filter(array_map('intval', $days), static fn ($d) => $d >= 1 && $d <= 7)));665
+ sort($days);666
+ if ($freq === 'weekly' && !$days) {667
+ wstat_err('周报至少选择一天', 422);668
+ }669
+ $name = trim((string) $get('name', ''));670
+ if (mb_strlen($name) > 100) {671
+ wstat_err('订阅名称过长', 422);672
+ }673
+674
+ return [675
+ 'site_id' => $this->siteArg($req),676
+ 'name' => $name !== '' ? $name : ($freq === 'weekly' ? '每周数据周报' : '每日数据日报'),677
+ 'freq' => $freq,678
+ 'days' => $days ? implode(',', $days) : '1,2,3,4,5,6,7',679
+ 'hour' => $hour,680
+ 'channels' => $this->channelSpec($req),681
+ ];682
+ }683
+}app/Controllers/AuthController.php+303-289
@@ -1,289 +1,303 @@1
-<?php2
-/**3
- * 认证控制器:注册 / 登录 / 当前用户 / 退出4
- */5
-declare(strict_types=1);6
-7
-namespace Wstat\Controllers;8
-9
-use Wstat\Http\Request;10
-use Wstat\Support\Auth;11
-use Wstat\Support\Db;12
-use Wstat\Support\OpLog;13
-use Wstat\Support\Rds;14
-use Wstat\Support\Settings;15
-use Wstat\Support\Util;16
-use Wstat\Support\Verify;17
-18
-class AuthController19
-{20
- /** POST /api/auth/register */21
- public function register(Request $req): void22
- {23
- // 管理员可全局关闭注册(系统设置 → 功能开关)24
- if (!Settings::int('registration_enabled')) {25
- wstat_err('注册已关闭,请联系管理员', 403, 403);26
- }27
- $email = trim((string) $req->input('email', ''));28
- $pass = (string) $req->input('password', '');29
- $nickname = trim((string) $req->input('nickname', ''));30
- $emailCode = trim((string) $req->input('email_code', ''));31
- $lang = $req->input('lang', 'zh-CN');32
- if (!in_array($lang, ['zh-CN', 'en-US'], true)) {33
- $lang = 'zh-CN';34
- }35
- $min = (int) wstat_config('security.pwd_min');36
-37
- if (!Util::validEmail($email)) {38
- wstat_err('邮箱格式不正确', 422);39
- }40
- if (strlen($pass) < $min || strlen($pass) > 72) {41
- wstat_err('密码长度需 ' . $min . '-72 位', 422);42
- }43
- if (mb_strlen($nickname) > 30) {44
- wstat_err('昵称过长', 422);45
- }46
- if (Db::value('SELECT id FROM users WHERE email=? LIMIT 1', [$email]) !== null) {47
- wstat_err('该邮箱已注册', 409);48
- }49
- // 邮箱验证码放最后校验(消费型):前面任一步失败都不烧码50
- if (Settings::int('email_verify_enabled') && !Verify::emailCheck($email, $emailCode)) {51
- wstat_err('邮箱验证码错误或已失效,请重新获取', 422, 4223);52
- }53
-54
- $now = time();55
- // 首个注册账号默认为系统管理员:与安装向导(Installer::createAdmin)和56
- // sql/upgrade-2026-09-12-settings.sql 的「MIN(id) 即管理员」语义保持一致。57
- // 老库若无 is_admin 列,filterColumns 会丢弃该键,Settings::isAdmin 自动回退 MIN(id)。58
- $isFirstUser = (int) Db::value('SELECT COUNT(*) FROM users') === 0;59
- $row = [60
- 'email' => $email,61
- 'password' => password_hash($pass, PASSWORD_DEFAULT),62
- 'nickname' => $nickname !== '' ? $nickname : explode('@', $email)[0],63
- 'lang' => $lang,64
- 'status' => 1,65
- 'created_at' => $now,66
- 'updated_at' => $now,67
- ];68
- if ($isFirstUser) {69
- $row['is_admin'] = 1;70
- }71
- $id = Db::insert('users', Db::filterColumns('users', $row));72
- wstat_json([73
- 'token' => Auth::issue((int) $id),74
- 'user' => self::shape((int) $id),75
- ]);76
- }77
-78
- /** POST /api/auth/login */79
- public function login(Request $req): void80
- {81
- $email = trim((string) $req->input('email', ''));82
- $pass = (string) $req->input('password', '');83
- $ip = Util::clientIp();84
-85
- // 简易限流(Redis 不可用时放行,不阻断登录)86
- $allowed = Rds::safe(function ($r) use ($ip) {87
- $k = 'login:' . $ip;88
- $n = $r->incr($k);89
- if ($n === 1) {90
- $r->expire($k, 600);91
- }92
- return $n <= (int) wstat_config('security.login_rate');93
- }, true);94
- if (!$allowed) {95
- wstat_err('尝试过于频繁,请10分钟后再试', 429);96
- }97
-98
- $u = Db::first('SELECT * FROM users WHERE email=? LIMIT 1', [$email]);99
- if ($u === null || !password_verify($pass, (string) $u['password'])) {100
- wstat_err('邮箱或密码错误', 401, 401);101
- }102
- if ((int) $u['status'] !== 1) {103
- wstat_err('账号已被禁用', 403, 403);104
- }105
- OpLog::log((int) $u['id'], 'login');106
- wstat_json(['token' => Auth::issue((int) $u['id']), 'user' => self::shape((int) $u['id'])]);107
- }108
-109
- /** GET /api/auth/public(无需登录:给登录/注册页用的公共开关) */110
- public function publicSettings(Request $req): void111
- {112
- wstat_json([113
- 'registration_enabled' => Settings::int('registration_enabled') === 1,114
- // 注册是否需要邮箱验证码(开启时注册页会显示取码流程)115
- 'email_verify_enabled' => Settings::int('email_verify_enabled') === 1,116
- // 服务器是否具备生成图形验证码的能力(缺 GD 时前端给出明确提示而非静默卡死)117
- 'captcha_ready' => Verify::available(),118
- // 运行期版本号(页脚「Powered by Wstats · vX.Y.Z」里的版本片段)。119
- // 来源 = app/version.php(经 config.php 读出),**不是** installed.php 的安装快照:120
- // 后者不会随手工升级刷新,曾导致页脚永远显示安装那一版、更新检查永远提示有新版本。121
- // 这里不给它兜底成 '1.0.0' —— 未知就如实返回(最坏是 0.0.0),122
- // 假版本号比空值更坏:它会让页脚与更新检查同时说谎。123
- // 品牌前缀/名称/官网链接由前端写死(frontend/src/components/PoweredBy.jsx),服务端不下发。124
- 'version' => (string) wstat_config('version'),125
- ]);126
- }127
-128
- /**129
- * GET /api/auth/captcha —— 图形验证码(取邮箱验证码的前置人机校验)。130
- * 返回 {id, image(dataURL), ttl};图与答案都在服务端,id 仅是取答案的凭证。131
- */132
- public function captcha(Request $req): void133
- {134
- if (!Verify::available()) {135
- wstat_err('服务器未启用 GD 扩展,无法生成图形验证码', 500, 500);136
- }137
- if (!Verify::captchaAllow(Util::clientIp())) {138
- wstat_err('请求过于频繁,请稍后再试', 429, 429);139
- }140
- wstat_json(Verify::captchaIssue());141
- }142
-143
- /**144
- * POST /api/auth/email-code —— 发送注册邮箱验证码。145
- * body: {email, captcha_id, captcha, lang?};先过图形验证码(一次性),再按配额发信。146
- */147
- public function emailCode(Request $req): void148
- {149
- $this->sendCode($req, 'register');150
- }151
-152
- /**153
- * POST /api/auth/reset-code —— 发送「重置密码」邮箱验证码。154
- * 与注册取码的差异:155
- * - 不看 registration_enabled(注册可关闭,但已有账号必须能自助找回密码);156
- * - 邮箱必须已注册且账号未被禁用;157
- * - 邮箱验证码是重置的唯一凭证,不受 email_verify_enabled 开关影响,恒定启用。158
- * body: {email, captcha_id, captcha, lang?}159
- */160
- public function resetCode(Request $req): void161
- {162
- $this->sendCode($req, 'reset');163
- }164
-165
- /**166
- * POST /api/auth/reset-password —— 凭邮箱验证码重置密码。167
- * body: {email, code, password}168
- * 成功后吊销该用户全部登录令牌(其它设备上的旧会话立即失效)。169
- */170
- public function resetPassword(Request $req): void171
- {172
- $email = trim((string) $req->input('email', ''));173
- $code = trim((string) $req->input('code', ''));174
- $pass = (string) $req->input('password', '');175
- $min = (int) wstat_config('security.pwd_min');176
-177
- if (!Util::validEmail($email)) {178
- wstat_err('邮箱格式不正确', 422);179
- }180
- if (strlen($pass) < $min || strlen($pass) > 72) {181
- wstat_err('密码长度需 ' . $min . '-72 位', 422);182
- }183
- $u = Db::first('SELECT id,status FROM users WHERE email=? LIMIT 1', [$email]);184
- if ($u === null) {185
- wstat_err('该邮箱未注册', 404, 404);186
- }187
- if ((int) $u['status'] !== 1) {188
- wstat_err('账号已被禁用,请联系管理员', 403, 403);189
- }190
- if ($code === '') {191
- wstat_err('请输入邮箱验证码', 422, 4223);192
- }193
- // 验证码放最后校验(消费型):前面任一步失败都不烧码;用途固定为 reset194
- if (!Verify::emailCheck($email, $code, 'reset')) {195
- wstat_err('邮箱验证码错误或已失效,请重新获取', 422, 4223);196
- }197
-198
- $now = time();199
- Db::execute(200
- 'UPDATE users SET password=?, updated_at=? WHERE id=?',201
- [password_hash($pass, PASSWORD_DEFAULT), $now, (int) $u['id']]202
- );203
- // 改密即失效全部旧会话,避免「已重置密码但他人仍保持登录」204
- $revoked = Auth::revokeAll((int) $u['id']);205
- OpLog::log((int) $u['id'], 'password_reset', '通过邮箱验证码重置密码');206
- wstat_json(['ok' => true, 'revoked' => $revoked]);207
- }208
-209
- /**210
- * 取码共用实现($purpose: register|reset)。211
- * 两类验证码的存储键互不通用(见 Support\Verify),冷却与小时配额共用,212
- * 防止「换个用途」绕开限流。213
- */214
- private function sendCode(Request $req, string $purpose): void215
- {216
- $reset = $purpose === 'reset';217
- if (!$reset && !Settings::int('registration_enabled')) {218
- wstat_err('注册已关闭,请联系管理员', 403, 403);219
- }220
- $email = trim((string) $req->input('email', ''));221
- $captchaId = trim((string) $req->input('captcha_id', ''));222
- $captcha = trim((string) $req->input('captcha', ''));223
- $lang = $req->input('lang', 'zh-CN');224
- if (!in_array($lang, ['zh-CN', 'en-US'], true)) {225
- $lang = 'zh-CN';226
- }227
- if (!Util::validEmail($email)) {228
- wstat_err('邮箱格式不正确', 422);229
- }230
- // 图形验证码:服务器具备 GD 能力时强制(缺 GD 时此处退化为「仅邮件配额限流」,231
- // 前端会同时给出提示,避免整个找回流程不可用)232
- if (Verify::available()) {233
- if ($captchaId === '' || $captcha === '') {234
- wstat_err('请输入图片验证码', 422, 4221);235
- }236
- if (!Verify::captchaCheck($captchaId, $captcha)) {237
- wstat_err('图片验证码错误或已失效', 422, 4222);238
- }239
- }240
- $exists = Db::first('SELECT id,status FROM users WHERE email=? LIMIT 1', [$email]);241
- if ($reset) {242
- if ($exists === null) {243
- wstat_err('该邮箱未注册', 404, 404);244
- }245
- if ((int) $exists['status'] !== 1) {246
- wstat_err('账号已被禁用,请联系管理员', 403, 403);247
- }248
- } elseif ($exists !== null) {249
- // 与注册接口同样口径:已注册的邮箱不再发码(避免被用来探测/骚扰)250
- wstat_err('该邮箱已注册', 409, 409);251
- }252
- $r = Verify::emailSend($email, $lang, $purpose);253
- if (!$r['ok']) {254
- wstat_err($r['msg'], (int) $r['http'], (int) $r['http']);255
- }256
- wstat_json(['sent' => true, 'ttl' => (int) $r['ttl'], 'resend' => (int) $r['resend']]);257
- }258
-259
- /** GET /api/auth/me */260
- public function me(Request $req): void261
- {262
- $u = Auth::requireUser($req);263
- wstat_json(self::shape((int) $u['id']));264
- }265
-266
- /** POST /api/auth/logout */267
- public function logout(Request $req): void268
- {269
- $token = $req->bearer();270
- if (strlen($token) === 64) {271
- $u = Auth::user($req); // 吊销前取一次用户,用于留痕272
- Auth::revoke($token);273
- if ($u !== null) {274
- OpLog::log((int) $u['id'], 'logout');275
- }276
- }277
- wstat_json(null);278
- }279
-280
- private static function shape(int $userId): array281
- {282
- $u = Db::first('SELECT id,email,nickname,lang,created_at FROM users WHERE id=?', [$userId]);283
- if ($u === null) {284
- return [];285
- }286
- $u['is_admin'] = Settings::isAdmin($userId);287
- return $u;288
- }289
-}1
+<?php2
+/**3
+ * 认证控制器:注册 / 登录 / 当前用户 / 退出4
+ */5
+declare(strict_types=1);6
+7
+namespace Wstat\Controllers;8
+9
+use Wstat\Http\Request;10
+use Wstat\Support\Auth;11
+use Wstat\Support\Db;12
+use Wstat\Support\OpLog;13
+use Wstat\Support\Rds;14
+use Wstat\Support\Settings;15
+use Wstat\Support\Util;16
+use Wstat\Support\Verify;17
+18
+class AuthController19
+{20
+ /** POST /api/auth/register */21
+ public function register(Request $req): void22
+ {23
+ // 管理员可全局关闭注册(系统设置 → 功能开关)24
+ if (!Settings::int('registration_enabled')) {25
+ wstat_err('注册已关闭,请联系管理员', 403, 403);26
+ }27
+ $email = trim((string) $req->input('email', ''));28
+ $pass = (string) $req->input('password', '');29
+ $nickname = trim((string) $req->input('nickname', ''));30
+ $emailCode = trim((string) $req->input('email_code', ''));31
+ $lang = $req->input('lang', 'zh-CN');32
+ if (!in_array($lang, ['zh-CN', 'en-US'], true)) {33
+ $lang = 'zh-CN';34
+ }35
+ $min = (int) wstat_config('security.pwd_min');36
+37
+ if (!Util::validEmail($email)) {38
+ wstat_err('邮箱格式不正确', 422);39
+ }40
+ if (strlen($pass) < $min || strlen($pass) > 72) {41
+ wstat_err('密码长度需 ' . $min . '-72 位', 422);42
+ }43
+ if (mb_strlen($nickname) > 30) {44
+ wstat_err('昵称过长', 422);45
+ }46
+ if (Db::value('SELECT id FROM users WHERE email=? LIMIT 1', [$email]) !== null) {47
+ wstat_err('该邮箱已注册', 409);48
+ }49
+ // 邮箱验证码放最后校验(消费型):前面任一步失败都不烧码50
+ if (Settings::int('email_verify_enabled') && !Verify::emailCheck($email, $emailCode)) {51
+ wstat_err('邮箱验证码错误或已失效,请重新获取', 422, 4223);52
+ }53
+54
+ $now = time();55
+ // 首个注册账号默认为系统管理员:与安装向导(Installer::createAdmin)和56
+ // sql/upgrade-2026-09-12-settings.sql 的「MIN(id) 即管理员」语义保持一致。57
+ // 老库若无 is_admin 列,filterColumns 会丢弃该键,Settings::isAdmin 自动回退 MIN(id)。58
+ $isFirstUser = (int) Db::value('SELECT COUNT(*) FROM users') === 0;59
+ $row = [60
+ 'email' => $email,61
+ 'password' => password_hash($pass, PASSWORD_DEFAULT),62
+ 'nickname' => $nickname !== '' ? $nickname : explode('@', $email)[0],63
+ 'lang' => $lang,64
+ 'status' => 1,65
+ 'created_at' => $now,66
+ 'updated_at' => $now,67
+ ];68
+ if ($isFirstUser) {69
+ $row['is_admin'] = 1;70
+ }71
+ $id = Db::insert('users', Db::filterColumns('users', $row));72
+ wstat_json([73
+ 'token' => Auth::issue((int) $id),74
+ 'user' => self::shape((int) $id),75
+ ]);76
+ }77
+78
+ /** POST /api/auth/login */79
+ public function login(Request $req): void80
+ {81
+ $email = trim((string) $req->input('email', ''));82
+ $pass = (string) $req->input('password', '');83
+ $ip = Util::clientIp();84
+85
+ // 简易限流(Redis 不可用时放行,不阻断登录)86
+ $allowed = Rds::safe(function ($r) use ($ip) {87
+ $k = 'login:' . $ip;88
+ $n = $r->incr($k);89
+ if ($n === 1) {90
+ $r->expire($k, 600);91
+ }92
+ return $n <= (int) wstat_config('security.login_rate');93
+ }, true);94
+ if (!$allowed) {95
+ wstat_err('尝试过于频繁,请10分钟后再试', 429);96
+ }97
+98
+ $u = Db::first('SELECT * FROM users WHERE email=? LIMIT 1', [$email]);99
+ if ($u === null || !password_verify($pass, (string) $u['password'])) {100
+ wstat_err('邮箱或密码错误', 401, 401);101
+ }102
+ if ((int) $u['status'] !== 1) {103
+ wstat_err('账号已被禁用', 403, 403);104
+ }105
+ OpLog::log((int) $u['id'], 'login');106
+ wstat_json(['token' => Auth::issue((int) $u['id']), 'user' => self::shape((int) $u['id'])]);107
+ }108
+109
+ /** GET /api/auth/public(无需登录:给登录/注册页用的公共开关) */110
+ public function publicSettings(Request $req): void111
+ {112
+ wstat_json([113
+ 'registration_enabled' => Settings::int('registration_enabled') === 1,114
+ // 注册是否需要邮箱验证码(开启时注册页会显示取码流程)115
+ 'email_verify_enabled' => Settings::int('email_verify_enabled') === 1,116
+ // 服务器是否具备生成图形验证码的能力(缺 GD 时前端给出明确提示而非静默卡死)117
+ 'captcha_ready' => Verify::available(),118
+ // 运行期版本号(页脚「Powered by Wstats · vX.Y.Z」里的版本片段)。119
+ // 来源 = app/version.php(经 config.php 读出),**不是** installed.php 的安装快照:120
+ // 后者不会随手工升级刷新,曾导致页脚永远显示安装那一版、更新检查永远提示有新版本。121
+ // 这里不给它兜底成 '1.0.0' —— 未知就如实返回(最坏是 0.0.0),122
+ // 假版本号比空值更坏:它会让页脚与更新检查同时说谎。123
+ // 品牌前缀/名称/官网链接由前端写死(frontend/src/components/PoweredBy.jsx),服务端不下发。124
+ 'version' => (string) wstat_config('version'),125
+ // 界面外观(公开不敏感:品牌图与菜单开关本就体现在公开渲染的页面上)。126
+ // logo/icon 存的是 data/brand/ 下的文件名(空=未设置,前端用内置默认),127
+ // 文件内容经 /brand/logo /brand/icon 公开下发 —— 文件名本身即可用作缓存版本号。128
+ // hidden_menus:被管理员关闭的菜单(登录后侧栏隐藏 + 直连 404,见 Layout 守卫)。129
+ // name:系统名称(空=前端用内置文案);用于侧栏 / 登录注册页 / 浏览器标签页标题。130
+ // keywords/description:管理员设置的 SEO 文案,前端注入 <meta>;空=不输出。131
+ 'ui' => [132
+ 'logo' => Settings::get('brand_logo'),133
+ 'icon' => Settings::get('brand_icon'),134
+ 'name' => Settings::get('brand_name'),135
+ 'keywords' => Settings::get('meta_keywords'),136
+ 'description' => Settings::get('meta_description'),137
+ 'hidden_menus' => Settings::hiddenMenus(),138
+ ],139
+ ]);140
+ }141
+142
+ /**143
+ * GET /api/auth/captcha —— 图形验证码(取邮箱验证码的前置人机校验)。144
+ * 返回 {id, image(dataURL), ttl};图与答案都在服务端,id 仅是取答案的凭证。145
+ */146
+ public function captcha(Request $req): void147
+ {148
+ if (!Verify::available()) {149
+ wstat_err('服务器未启用 GD 扩展,无法生成图形验证码', 500, 500);150
+ }151
+ if (!Verify::captchaAllow(Util::clientIp())) {152
+ wstat_err('请求过于频繁,请稍后再试', 429, 429);153
+ }154
+ wstat_json(Verify::captchaIssue());155
+ }156
+157
+ /**158
+ * POST /api/auth/email-code —— 发送注册邮箱验证码。159
+ * body: {email, captcha_id, captcha, lang?};先过图形验证码(一次性),再按配额发信。160
+ */161
+ public function emailCode(Request $req): void162
+ {163
+ $this->sendCode($req, 'register');164
+ }165
+166
+ /**167
+ * POST /api/auth/reset-code —— 发送「重置密码」邮箱验证码。168
+ * 与注册取码的差异:169
+ * - 不看 registration_enabled(注册可关闭,但已有账号必须能自助找回密码);170
+ * - 邮箱必须已注册且账号未被禁用;171
+ * - 邮箱验证码是重置的唯一凭证,不受 email_verify_enabled 开关影响,恒定启用。172
+ * body: {email, captcha_id, captcha, lang?}173
+ */174
+ public function resetCode(Request $req): void175
+ {176
+ $this->sendCode($req, 'reset');177
+ }178
+179
+ /**180
+ * POST /api/auth/reset-password —— 凭邮箱验证码重置密码。181
+ * body: {email, code, password}182
+ * 成功后吊销该用户全部登录令牌(其它设备上的旧会话立即失效)。183
+ */184
+ public function resetPassword(Request $req): void185
+ {186
+ $email = trim((string) $req->input('email', ''));187
+ $code = trim((string) $req->input('code', ''));188
+ $pass = (string) $req->input('password', '');189
+ $min = (int) wstat_config('security.pwd_min');190
+191
+ if (!Util::validEmail($email)) {192
+ wstat_err('邮箱格式不正确', 422);193
+ }194
+ if (strlen($pass) < $min || strlen($pass) > 72) {195
+ wstat_err('密码长度需 ' . $min . '-72 位', 422);196
+ }197
+ $u = Db::first('SELECT id,status FROM users WHERE email=? LIMIT 1', [$email]);198
+ if ($u === null) {199
+ wstat_err('该邮箱未注册', 404, 404);200
+ }201
+ if ((int) $u['status'] !== 1) {202
+ wstat_err('账号已被禁用,请联系管理员', 403, 403);203
+ }204
+ if ($code === '') {205
+ wstat_err('请输入邮箱验证码', 422, 4223);206
+ }207
+ // 验证码放最后校验(消费型):前面任一步失败都不烧码;用途固定为 reset208
+ if (!Verify::emailCheck($email, $code, 'reset')) {209
+ wstat_err('邮箱验证码错误或已失效,请重新获取', 422, 4223);210
+ }211
+212
+ $now = time();213
+ Db::execute(214
+ 'UPDATE users SET password=?, updated_at=? WHERE id=?',215
+ [password_hash($pass, PASSWORD_DEFAULT), $now, (int) $u['id']]216
+ );217
+ // 改密即失效全部旧会话,避免「已重置密码但他人仍保持登录」218
+ $revoked = Auth::revokeAll((int) $u['id']);219
+ OpLog::log((int) $u['id'], 'password_reset', '通过邮箱验证码重置密码');220
+ wstat_json(['ok' => true, 'revoked' => $revoked]);221
+ }222
+223
+ /**224
+ * 取码共用实现($purpose: register|reset)。225
+ * 两类验证码的存储键互不通用(见 Support\Verify),冷却与小时配额共用,226
+ * 防止「换个用途」绕开限流。227
+ */228
+ private function sendCode(Request $req, string $purpose): void229
+ {230
+ $reset = $purpose === 'reset';231
+ if (!$reset && !Settings::int('registration_enabled')) {232
+ wstat_err('注册已关闭,请联系管理员', 403, 403);233
+ }234
+ $email = trim((string) $req->input('email', ''));235
+ $captchaId = trim((string) $req->input('captcha_id', ''));236
+ $captcha = trim((string) $req->input('captcha', ''));237
+ $lang = $req->input('lang', 'zh-CN');238
+ if (!in_array($lang, ['zh-CN', 'en-US'], true)) {239
+ $lang = 'zh-CN';240
+ }241
+ if (!Util::validEmail($email)) {242
+ wstat_err('邮箱格式不正确', 422);243
+ }244
+ // 图形验证码:服务器具备 GD 能力时强制(缺 GD 时此处退化为「仅邮件配额限流」,245
+ // 前端会同时给出提示,避免整个找回流程不可用)246
+ if (Verify::available()) {247
+ if ($captchaId === '' || $captcha === '') {248
+ wstat_err('请输入图片验证码', 422, 4221);249
+ }250
+ if (!Verify::captchaCheck($captchaId, $captcha)) {251
+ wstat_err('图片验证码错误或已失效', 422, 4222);252
+ }253
+ }254
+ $exists = Db::first('SELECT id,status FROM users WHERE email=? LIMIT 1', [$email]);255
+ if ($reset) {256
+ if ($exists === null) {257
+ wstat_err('该邮箱未注册', 404, 404);258
+ }259
+ if ((int) $exists['status'] !== 1) {260
+ wstat_err('账号已被禁用,请联系管理员', 403, 403);261
+ }262
+ } elseif ($exists !== null) {263
+ // 与注册接口同样口径:已注册的邮箱不再发码(避免被用来探测/骚扰)264
+ wstat_err('该邮箱已注册', 409, 409);265
+ }266
+ $r = Verify::emailSend($email, $lang, $purpose);267
+ if (!$r['ok']) {268
+ wstat_err($r['msg'], (int) $r['http'], (int) $r['http']);269
+ }270
+ wstat_json(['sent' => true, 'ttl' => (int) $r['ttl'], 'resend' => (int) $r['resend']]);271
+ }272
+273
+ /** GET /api/auth/me */274
+ public function me(Request $req): void275
+ {276
+ $u = Auth::requireUser($req);277
+ wstat_json(self::shape((int) $u['id']));278
+ }279
+280
+ /** POST /api/auth/logout */281
+ public function logout(Request $req): void282
+ {283
+ $token = $req->bearer();284
+ if (strlen($token) === 64) {285
+ $u = Auth::user($req); // 吊销前取一次用户,用于留痕286
+ Auth::revoke($token);287
+ if ($u !== null) {288
+ OpLog::log((int) $u['id'], 'logout');289
+ }290
+ }291
+ wstat_json(null);292
+ }293
+294
+ private static function shape(int $userId): array295
+ {296
+ $u = Db::first('SELECT id,email,nickname,lang,created_at FROM users WHERE id=?', [$userId]);297
+ if ($u === null) {298
+ return [];299
+ }300
+ $u['is_admin'] = Settings::isAdmin($userId);301
+ return $u;302
+ }303
+}app/Controllers/CollectController.php+408-353
@@ -1,353 +1,408 @@1
-<?php2
-/**3
- * 采集控制器(数据入口 /collect.php)4
- *5
- * 职责:6
- * 1. 校验站点 (site_key) 与基础字段7
- * 2. Redis 实时计数(今日 PV/UV/IP、分钟 PV、在线访客)—— 毫秒级热路径8
- * 3. 原始事件压入队列,由 worker 批量落库(避免高并发直写 MySQL)9
- * 4. Redis 不可用时降级为直写(单页会话近似),保证不丢点10
- *11
- * 响应保持最小体积,采集端仅关心 HTTP 200。12
- */13
-declare(strict_types=1);14
-15
-namespace Wstat\Controllers;16
-17
-use Wstat\Http\Request;18
-use Wstat\Support\Db;19
-use Wstat\Support\IpLocator;20
-use Wstat\Support\RateLimiter;21
-use Wstat\Support\Rds;22
-use Wstat\Support\RedisGuard;23
-use Wstat\Support\Referrer;24
-use Wstat\Support\Sessionizer;25
-use Wstat\Support\Settings;26
-use Wstat\Support\SiteStore;27
-use Wstat\Support\UaParser;28
-use Wstat\Support\Util;29
-30
-class CollectController31
-{32
- private const TYPES = ['pageview', 'event', 'perf', 'click', 'scroll', 'hb', 'outlink', 'download', 'search'];33
- private const MAX_QUEUE = 200000; // 队列积压保护34
- private const RATE_PER_MIN = 300; // 同站点同 IP 每分钟上报上限(正常页面远低于此值)35
- private const MAX_PAYLOAD = 8192; // 事件附加数据 JSON 上限(字节)36
-37
- /** 入口 */38
- public function handle(Request $req): void39
- {40
- // ---- 全局采集开关(系统设置 → 功能开关)----41
- if (!Settings::int('collect_enabled')) {42
- wstat_err('collect disabled', 403);43
- }44
-45
- // ---- 站点校验 ----46
- $siteKey = trim((string) $req->input('ak', ''));47
- $site = SiteStore::byKey($siteKey);48
- if ($site === null) {49
- wstat_err('bad site', 404);50
- }51
- if ((int) $site['status'] !== 1) {52
- wstat_err('site disabled', 403);53
- }54
- // 仅已验证站点才允许采集上报(SDK 生效前提)。本地联调可设 WSTAT_ALLOW_UNVERIFIED=1 放开。55
- $allowUnverified = (bool) (getenv('WSTAT_ALLOW_UNVERIFIED') ?: '0');56
- if (!$allowUnverified && (int) $site['verified_at'] === 0) {57
- wstat_err('site unverified', 403);58
- }59
-60
- $sid = (int) $site['id'];61
- $type = (string) $req->input('t', 'pageview');62
- if (!in_array($type, self::TYPES, true)) {63
- $type = 'pageview';64
- }65
-66
- // ---- 访客 / 会话 / 上下文 ----67
- $uid = self::id($req->input('uid', ''), 40);68
- $sidStr = self::id($req->input('sid', ''), 40);69
- $endUser = self::endUser($req->input('ui', ''));70
- $url = self::safeUrl((string) $req->input('url', ''), 600);71
- $title = self::cut((string) $req->input('title', ''), 255);72
- $ref = self::safeUrl((string) $req->input('ref', ''), 600);73
- $screen = self::cut((string) $req->input('scr', ''), 24);74
- $lang = self::cut((string) $req->input('lang', ''), 16);75
- $tzMin = (int) $req->input('tz', 0);76
- if ($tzMin < -720 || $tzMin > 840) {77
- $tzMin = 0;78
- }79
- $ip = Util::clientIp();80
- $now = time();81
- $uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');82
- $uaRow = UaParser::parse($uaRaw);83
-84
- // ---- 爬虫过滤(系统设置可关):命中爬虫/HTTP 客户端 UA 静默丢弃。85
- // 返回 200 而非 403 —— 大多数爬虫会对 4xx 重试,静默丢弃可避免刷日志与放大流量。86
- if (Settings::int('bot_filter_enabled') && UaParser::isBot($uaRaw)) {87
- $this->tiny();88
- return;89
- }90
-91
- // ---- 频率限制:同站点同 IP 固定窗口计数(防恶意刷量/重放)。92
- // 同样静默丢弃(200):返回 429 只会触发对方重试,放大攻击流量。93
- // Redis 不可用时 fail-open 放行(限流不得成为可用性故障点)。94
- // WSTAT_RATE_LIMIT 环境变量可覆盖限额(自测用;0=不限)。95
- $limit = filter_var(getenv('WSTAT_RATE_LIMIT') ?: self::RATE_PER_MIN, FILTER_VALIDATE_INT);96
- if ($limit !== false && !RateLimiter::allow('collect:' . $sid . ':' . ($ip !== '' ? $ip : 'unknown'), $limit)) {97
- $this->tiny();98
- return;99
- }100
-101
- // UTM 五参数 + ClickID(SDK 已做首触留存,服务端再次判定)102
- $utm = [103
- 'source' => self::cut((string) $req->input('utm_source', ''), 128),104
- 'medium' => self::cut((string) $req->input('utm_medium', ''), 128),105
- 'campaign' => self::cut((string) $req->input('utm_campaign', ''), 128),106
- 'content' => self::cut((string) $req->input('utm_content', ''), 128),107
- 'term' => self::cut((string) $req->input('utm_term', ''), 128),108
- ];109
- $clickId = self::cut((string) $req->input('click_id', ''), 128);110
- $clickSource = self::cut((string) $req->input('click_name', ''), 64);111
- $cls = Referrer::classify($ref, (string) $site['domain'], $utm, $clickId);112
-113
- // ---- 事件明细(落库形态,worker 消费) ----114
- $extra = $req->input('d');115
- $payload = null;116
- if (is_array($extra) && count($extra) > 0) {117
- $payload = json_encode($extra, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);118
- // 超限/编码失败一律置空:拒绝超大附加数据占库119
- $payload = is_string($payload) && strlen($payload) <= self::MAX_PAYLOAD ? $payload : null;120
- }121
- $evt = [122
- 'day' => Util::localDay($now, $tzMin !== 0 ? $tzMin : (int) round(Util::tzOffsetSec((string) $site['timezone']) / 60)),123
- 'site_id' => $sid,124
- 'session_id' => $sidStr,125
- 'visitor_id' => $uid,126
- 'end_user' => $endUser,127
- 'type' => $type,128
- 'ts' => $now,129
- 'url' => $url,130
- 'title' => $title,131
- 'ref' => $ref,132
- 'browser' => $uaRow['browser'],133
- 'os' => $uaRow['os'],134
- 'device' => $uaRow['device'],135
- 'screen' => $screen,136
- 'lang' => $lang,137
- 'ip' => $ip,138
- 'country' => '',139
- 'province' => '',140
- 'city' => '',141
- 'source' => $cls['type'],142
- 'medium' => $cls['medium'],143
- 'utm_source' => $utm['source'],144
- 'utm_medium' => $utm['medium'],145
- 'campaign' => $utm['campaign'],146
- 'content' => $utm['content'],147
- 'term' => $utm['term'],148
- 'click_id' => $clickId,149
- 'click_source' => $clickSource,150
- 'ref_host' => (string) ($cls['host'] ?? ''),151
- 'payload' => $payload,152
- ];153
-154
- // 心跳事件只刷新在线状态,不落库不入队155
- if ($type === 'hb') {156
- $this->touchOnline($sid, $sidStr);157
- $this->tiny();158
- return;159
- }160
-161
- // ---- IP 归属解析(离线 xdb 本地检索,微秒级;失败/未识别保持空串) ----162
- if (($evt['country'] ?? '') === '' && $ip !== '') {163
- $geo = IpLocator::resolve($ip);164
- $evt['country'] = (string) ($geo['country'] ?? '');165
- $evt['province'] = (string) ($geo['province'] ?? '');166
- $evt['city'] = (string) ($geo['city'] ?? '');167
- }168
-169
- $redis = Rds::get();170
- if ($redis !== null) {171
- // ---- 热路径:实时计数 + 队列 ----172
- // 任何 Redis 异常(含键类型污染 WRONGTYPE、超时、断连)都不得让采集返回 500:173
- // 捕获后置空 $redis,走下方降级直写,保证不丢点。174
- try {175
- $this->realtime($redis, $sid, $sidStr, $tzMin, $type, $now);176
- // hb 已提前返回;白名单内的其余类型(pageview/event/perf/click/scroll/outlink/download/search)一律入队177
- $queueLen = (int) $redis->llen('queue');178
- if ($queueLen < self::MAX_QUEUE) {179
- $redis->lpush('queue', json_encode($evt, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));180
- }181
- } catch (\Throwable $e) {182
- $redis = null;183
- // WRONGTYPE 单独标记:便于与网络抖动区分(后者可自愈,前者必须人工处理)184
- $tag = RedisGuard::isWrongType($e) ? 'WRONGTYPE(键类型被污染)' : 'redis error';185
- error_log(sprintf(186
- '[wstat] collect 热路径 %s,已降级直写 MySQL;键类型可用 php %s 审计:%s',187
- $tag,188
- wstat_rel('scripts/doctor.php'),189
- $e->getMessage()190
- ));191
- }192
- }193
- if ($redis === null) {194
- // ---- 降级:直写(事件表;自动剔除未迁移列容错) ----195
- Db::insertBatch('events', [Db::filterColumns('events', $evt)]);196
- if ($type === 'pageview') {197
- $this->degradedSession($evt);198
- }199
- }200
- $this->tiny();201
- }202
-203
- /* ================= 内部 ================= */204
-205
- /**206
- * 采集热路径的实时计数(写入 Redis)。207
- *208
- * 注意:自 2026-09-15 起这里**不再写 `uv:` / `ip:` 两个 HyperLogLog 键**。209
- * 原因:HLL 是估算结构,在极小基数下会多估,直接导致「概览 IP 数 4 / IP 地域页 3」这类210
- * 口径不一致;现在 UV 与独立 IP 一律由 events 明细精确去重211
- * (见 StatsController::exactUniq / todayExact),今日实时数字同样取自 events。212
- * 保留 `today:pv` / `rt:min:` / 在线 ZSet 等精确实时计数不变。213
- */214
- private function realtime($r, int $sid, string $sidStr, int $tzMin, string $type, int $now): void215
- {216
- $ymd = gmdate('Ymd', $now + $tzMin * 60);217
- if ($type === 'pageview') {218
- $r->hincrby("today:$sid", "$ymd:pv", 1);219
- $r->hincrby("rt:min:$sid", gmdate('YmdHi', $now + $tzMin * 60), 1);220
- Sessionizer::markLive($r, $sid, $sidStr, $now); // 进入“进行中会话”,实时可见221
- }222
- $this->touchOnline($sid, $sidStr, $r);223
- }224
-225
- private function touchOnline(int $sid, string $sidStr, $r = null): void226
- {227
- if ($sidStr === '') {228
- return;229
- }230
- $now = time();231
- $fn = function ($r) use ($sid, $sidStr, $now) {232
- $r->zadd("on:$sid", (float) $now, $sidStr);233
- };234
- if ($r !== null) {235
- $fn($r);236
- } else {237
- Rds::safe($fn);238
- }239
- }240
-241
- /**242
- * 降级/无 Redis 模式:会话累计行。243
- * 同 session 多次 pageview 自动累加 pageviews、刷新离开时间并近似结算时长;244
- * 写失败仅记 error_log,绝不阻断采集(events 已先行落库)。245
- */246
- private function degradedSession(array $evt): void247
- {248
- try {249
- $now = $evt['ts'];250
- // 老库可能还没有 end_user 列(batch3 未执行):有则一并写入,无则省略251
- static $hasEndUser = null;252
- if ($hasEndUser === null) {253
- $hasEndUser = array_key_exists('end_user', Db::tableColumns('sessions'));254
- }255
- // 占位符严格按列顺序:site_id,session_id,visitor_id[,end_user],start_ts,end_ts,256
- // entry_url,exit_url,source,medium,campaign,content,term,click_id,257
- // browser,os,device,screen,lang,ip,country,province,city,created_at;258
- // pageviews=1 / duration=0 / bounce=1 / is_new=0 为字面常量259
- $sql = $hasEndUser260
- ? 'INSERT INTO sessions261
- (site_id,session_id,visitor_id,end_user,start_ts,end_ts,pageviews,duration,bounce,is_new,262
- entry_url,exit_url,source,medium,campaign,content,term,click_id,263
- browser,os,device,screen,lang,ip,country,province,city,created_at)264
- VALUES (?,?,?,?,?,?,?,1,0,1,0,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)265
- ON DUPLICATE KEY UPDATE266
- pageviews=pageviews+1,267
- end_ts=VALUES(end_ts),268
- duration=VALUES(end_ts)-start_ts,269
- bounce=0,270
- exit_url=VALUES(exit_url)'271
- : 'INSERT INTO sessions272
- (site_id,session_id,visitor_id,start_ts,end_ts,pageviews,duration,bounce,is_new,273
- entry_url,exit_url,source,medium,campaign,content,term,click_id,274
- browser,os,device,screen,lang,ip,country,province,city,created_at)275
- VALUES (?,?,?,?,?,1,0,1,0,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)276
- ON DUPLICATE KEY UPDATE277
- pageviews=pageviews+1,278
- end_ts=VALUES(end_ts),279
- duration=VALUES(end_ts)-start_ts,280
- bounce=0,281
- exit_url=VALUES(exit_url)';282
- $args = $hasEndUser283
- ? [$evt['site_id'], $evt['session_id'], $evt['visitor_id'], $evt['end_user'], $now, $now,284
- $evt['url'], $evt['url'],285
- $evt['source'], $evt['medium'], $evt['campaign'], $evt['content'], $evt['term'], $evt['click_id'],286
- $evt['browser'], $evt['os'], $evt['device'], $evt['screen'], $evt['lang'],287
- $evt['ip'], $evt['country'], $evt['province'], $evt['city'], $now]288
- : [$evt['site_id'], $evt['session_id'], $evt['visitor_id'], $now, $now,289
- $evt['url'], $evt['url'],290
- $evt['source'], $evt['medium'], $evt['campaign'], $evt['content'], $evt['term'], $evt['click_id'],291
- $evt['browser'], $evt['os'], $evt['device'], $evt['screen'], $evt['lang'],292
- $evt['ip'], $evt['country'], $evt['province'], $evt['city'], $now];293
- // 自检:占位符数量必须与绑定参数一致(防再次错位)294
- if (substr_count($sql, '?') !== count($args)) {295
- error_log('[wstat] degradedSession 占位符/参数数量不一致,跳过会话直写');296
- return;297
- }298
- Db::execute($sql, $args);299
- } catch (\Throwable $e) {300
- error_log('[wstat] degradedSession failed: ' . $e->getMessage());301
- }302
- }303
-304
- /** 极简成功体 */305
- private function tiny(): void306
- {307
- wstat_json(['ok' => 1], 200);308
- }309
-310
- private static function id($v, int $maxLen): string311
- {312
- $v = (string) $v;313
- if (!preg_match('/^[A-Za-z0-9_\-]{8,' . $maxLen . '}$/', $v)) {314
- return '';315
- }316
- return $v;317
- }318
-319
- private static function cut(string $s, int $len): string320
- {321
- $s = trim($s);322
- if (mb_strlen($s) > $len) {323
- $s = mb_substr($s, 0, $len);324
- }325
- return $s;326
- }327
-328
- /**329
- * 页面 URL / Referer 白名单:仅接受 http(s) 绝对地址或站内相对路径。330
- * 拒绝 javascript: / data: / file: 等协议 —— 这类值一旦入库,331
- * 会在管理端各报表的「链接直达」跳转处成为存储型 XSS 载体。332
- */333
- private static function safeUrl(string $s, int $len): string334
- {335
- $s = self::cut($s, $len);336
- if ($s === '') {337
- return '';338
- }339
- // 去掉控制字符与不可见空白(防御性:URL 正常不含这些)340
- $s = preg_replace('/[\x00-\x1F\x7F]+/', '', $s) ?? '';341
- return preg_match('#^(https?://|/)#i', $s) === 1 ? $s : '';342
- }343
-344
- /** 终端用户标识(SDK identify 上报):字母数字与 _. - @ + :,1-128 位;不合法返回空 */345
- private static function endUser($v): string346
- {347
- $v = trim((string) $v);348
- if ($v === '') {349
- return '';350
- }351
- return preg_match('/^[A-Za-z0-9_.\-@+:]{1,128}$/', $v) === 1 ? $v : '';352
- }353
-}1
+<?php2
+/**3
+ * 采集控制器(数据入口 /collect.php)4
+ *5
+ * 职责:6
+ * 1. 校验站点 (site_key) 与基础字段7
+ * 2. Redis 实时计数(今日 PV/UV/IP、分钟 PV、在线访客)—— 毫秒级热路径8
+ * 3. 原始事件压入队列,由 worker 批量落库(避免高并发直写 MySQL)9
+ * 4. Redis 不可用时降级为直写(单页会话近似),保证不丢点10
+ *11
+ * 响应保持最小体积,采集端仅关心 HTTP 200。12
+ */13
+declare(strict_types=1);14
+15
+namespace Wstat\Controllers;16
+17
+use Wstat\Http\Request;18
+use Wstat\Support\Db;19
+use Wstat\Support\IpLocator;20
+use Wstat\Support\RateLimiter;21
+use Wstat\Support\Rds;22
+use Wstat\Support\RedisGuard;23
+use Wstat\Support\Referrer;24
+use Wstat\Support\SearchEngine;25
+use Wstat\Support\Sessionizer;26
+use Wstat\Support\Settings;27
+use Wstat\Support\SiteStore;28
+use Wstat\Support\Spider;29
+use Wstat\Support\SpiderLog;30
+use Wstat\Support\UaParser;31
+use Wstat\Support\Util;32
+33
+class CollectController34
+{35
+ private const TYPES = ['pageview', 'event', 'perf', 'click', 'scroll', 'hb', 'outlink', 'download', 'search'];36
+ private const MAX_QUEUE = 200000; // 队列积压保护37
+ private const RATE_PER_MIN = 300; // 同站点同 IP 每分钟上报上限(正常页面远低于此值)38
+ private const SPIDER_RATE_PER_MIN = 300; // 同站点同 IP 每分钟**蜘蛛记账**上限(独立桶,见 handle())39
+ private const MAX_PAYLOAD = 8192; // 事件附加数据 JSON 上限(字节)40
+41
+ /** 入口 */42
+ public function handle(Request $req): void43
+ {44
+ // ---- 全局采集开关(系统设置 → 功能开关)----45
+ if (!Settings::int('collect_enabled')) {46
+ wstat_err('collect disabled', 403);47
+ }48
+49
+ // ---- 站点校验 ----50
+ $siteKey = trim((string) $req->input('ak', ''));51
+ $site = SiteStore::byKey($siteKey);52
+ if ($site === null) {53
+ wstat_err('bad site', 404);54
+ }55
+ if ((int) $site['status'] !== 1) {56
+ wstat_err('site disabled', 403);57
+ }58
+ // 仅已验证站点才允许采集上报(SDK 生效前提)。本地联调可设 WSTAT_ALLOW_UNVERIFIED=1 放开。59
+ $allowUnverified = (bool) (getenv('WSTAT_ALLOW_UNVERIFIED') ?: '0');60
+ if (!$allowUnverified && (int) $site['verified_at'] === 0) {61
+ wstat_err('site unverified', 403);62
+ }63
+64
+ $sid = (int) $site['id'];65
+ $type = (string) $req->input('t', 'pageview');66
+ if (!in_array($type, self::TYPES, true)) {67
+ $type = 'pageview';68
+ }69
+70
+ // ---- 访客 / 会话 / 上下文 ----71
+ $uid = self::id($req->input('uid', ''), 40);72
+ $sidStr = self::id($req->input('sid', ''), 40);73
+ $endUser = self::endUser($req->input('ui', ''));74
+ $url = self::safeUrl((string) $req->input('url', ''), 600);75
+ $title = self::cut((string) $req->input('title', ''), 255);76
+ $ref = self::safeUrl((string) $req->input('ref', ''), 600);77
+78
+ // ---- 301/302 来源保真:跳板跳转时以 ?wstat_ref=<真源> 携带。79
+ // SDK v1.5+ 已优先上报并从 url 剔除该参数;旧版 SDK 仍会把它留在 url 里 ——80
+ // 这里兜底提取,并从存库 url 中剔除以免污染页面明细。81
+ // wstat_ref 是跳板显式声明的完整真源,信息量 ≥ 被浏览器按 Referrer-Policy82
+ // 裁剪的 referrer(跨域往往只剩 origin),与 UTM 同属「客户端声明、只影响归因」83
+ // 的信任级别 —— 存在即采信。84
+ $uParts = parse_url($url);85
+ if (is_array($uParts) && !empty($uParts['query']) && strpos($uParts['query'], 'wstat_ref=') !== false) {86
+ $wref = '';87
+ parse_str($uParts['query'], $uq);88
+ if (isset($uq['wstat_ref']) && is_string($uq['wstat_ref'])) {89
+ $wref = self::safeUrl($uq['wstat_ref'], 600);90
+ }91
+ $qs = trim((string) preg_replace('/(^|&)wstat_ref=[^&]*/', '', (string) $uParts['query']), '&');92
+ $url = (string) ($uParts['path'] ?? '') . ($qs !== '' ? '?' . $qs : '');93
+ if ($wref !== '') {94
+ $ref = $wref;95
+ }96
+ }97
+ $screen = self::cut((string) $req->input('scr', ''), 24);98
+ $lang = self::cut((string) $req->input('lang', ''), 16);99
+ $tzMin = (int) $req->input('tz', 0);100
+ if ($tzMin < -720 || $tzMin > 840) {101
+ $tzMin = 0;102
+ }103
+ $ip = Util::clientIp();104
+ $now = time();105
+ $uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');106
+ $uaRow = UaParser::parse($uaRaw);107
+108
+ // ---- 蜘蛛统计(与下面的过滤**互相独立**):识别为爬虫就先记一笔蜘蛛账。109
+ // 覆盖面说明:服务端接入(/spider.php)负责「不执行 JS 的爬虫」(绝大多数),110
+ // 这里负责「爬虫恰好也执行了 JS」的兜底(Googlebot / 移动版 Baiduspider 常见),111
+ // 站长零接入也能看到一部分数据。爬虫数据只落 spider_hits,**不参与任何访客指标**,112
+ // 所以「记账」与「过滤开关」是两件事、互不影响。113
+ // 写库前过一道按 (站点,IP) 的限流桶:爬虫从少量 IP 高频抓取,避免把写库打成主要开销。114
+ $spiderInfo = Spider::detect($uaRaw);115
+ if ($spiderInfo['bot']) {116
+ $sLimit = filter_var(getenv('WSTAT_RATE_LIMIT') ?: self::SPIDER_RATE_PER_MIN, FILTER_VALIDATE_INT);117
+ if ($sLimit === false118
+ || RateLimiter::allow('spider:' . $sid . ':' . ($ip !== '' ? $ip : 'unknown'), $sLimit)) {119
+ SpiderLog::hit(120
+ $sid,121
+ Util::localDay($now, $tzMin !== 0 ? $tzMin : (int) round(Util::tzOffsetSec((string) $site['timezone']) / 60)),122
+ $spiderInfo['name'],123
+ SpiderLog::cleanUrl($url),124
+ 0,125
+ 0,126
+ $now127
+ );128
+ }129
+ }130
+131
+ // ---- 爬虫过滤(系统设置可关):命中爬虫/HTTP 客户端 UA 静默丢弃。132
+ // 返回 200 而非 403 —— 大多数爬虫会对 4xx 重试,静默丢弃可避免刷日志与放大流量。133
+ if (Settings::int('bot_filter_enabled') && $spiderInfo['bot']) {134
+ $this->tiny();135
+ return;136
+ }137
+138
+ // ---- 频率限制:同站点同 IP 固定窗口计数(防恶意刷量/重放)。139
+ // 同样静默丢弃(200):返回 429 只会触发对方重试,放大攻击流量。140
+ // Redis 不可用时 fail-open 放行(限流不得成为可用性故障点)。141
+ // WSTAT_RATE_LIMIT 环境变量可覆盖限额(自测用;0=不限)。142
+ $limit = filter_var(getenv('WSTAT_RATE_LIMIT') ?: self::RATE_PER_MIN, FILTER_VALIDATE_INT);143
+ if ($limit !== false && !RateLimiter::allow('collect:' . $sid . ':' . ($ip !== '' ? $ip : 'unknown'), $limit)) {144
+ $this->tiny();145
+ return;146
+ }147
+148
+ // UTM 五参数 + ClickID(SDK 已做首触留存,服务端再次判定)149
+ $utm = [150
+ 'source' => self::cut((string) $req->input('utm_source', ''), 128),151
+ 'medium' => self::cut((string) $req->input('utm_medium', ''), 128),152
+ 'campaign' => self::cut((string) $req->input('utm_campaign', ''), 128),153
+ 'content' => self::cut((string) $req->input('utm_content', ''), 128),154
+ 'term' => self::cut((string) $req->input('utm_term', ''), 128),155
+ ];156
+ $clickId = self::cut((string) $req->input('click_id', ''), 128);157
+ $clickSource = self::cut((string) $req->input('click_name', ''), 64);158
+ $cls = Referrer::classify($ref, (string) $site['domain'], $utm, $clickId);159
+160
+ // ---- 事件明细(落库形态,worker 消费) ----161
+ $extra = $req->input('d');162
+ $payload = null;163
+ if (is_array($extra) && count($extra) > 0) {164
+ $payload = json_encode($extra, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);165
+ // 超限/编码失败一律置空:拒绝超大附加数据占库166
+ $payload = is_string($payload) && strlen($payload) <= self::MAX_PAYLOAD ? $payload : null;167
+ }168
+ $evt = [169
+ 'day' => Util::localDay($now, $tzMin !== 0 ? $tzMin : (int) round(Util::tzOffsetSec((string) $site['timezone']) / 60)),170
+ 'site_id' => $sid,171
+ 'session_id' => $sidStr,172
+ 'visitor_id' => $uid,173
+ 'end_user' => $endUser,174
+ 'type' => $type,175
+ 'ts' => $now,176
+ 'url' => $url,177
+ 'title' => $title,178
+ 'ref' => $ref,179
+ 'browser' => $uaRow['browser'],180
+ 'os' => $uaRow['os'],181
+ 'device' => $uaRow['device'],182
+ 'screen' => $screen,183
+ 'lang' => $lang,184
+ 'ip' => $ip,185
+ 'country' => '',186
+ 'province' => '',187
+ 'city' => '',188
+ 'source' => $cls['type'],189
+ 'medium' => $cls['medium'],190
+ 'utm_source' => $utm['source'],191
+ 'utm_medium' => $utm['medium'],192
+ 'campaign' => $utm['campaign'],193
+ 'content' => $utm['content'],194
+ 'term' => $utm['term'],195
+ 'click_id' => $clickId,196
+ 'click_source' => $clickSource,197
+ 'ref_host' => (string) ($cls['host'] ?? ''),198
+ // 搜索引擎来路关键词:仅 search 来源从 ref 提取(拿不到=空,报表归「未提供」)199
+ 'kw' => $cls['type'] === 'search' ? SearchEngine::kw($ref) : '',200
+ 'payload' => $payload,201
+ ];202
+203
+ // 心跳事件只刷新在线状态,不落库不入队204
+ if ($type === 'hb') {205
+ $this->touchOnline($sid, $sidStr);206
+ $this->tiny();207
+ return;208
+ }209
+210
+ // ---- IP 归属解析(离线 xdb 本地检索,微秒级;失败/未识别保持空串) ----211
+ if (($evt['country'] ?? '') === '' && $ip !== '') {212
+ $geo = IpLocator::resolve($ip);213
+ $evt['country'] = (string) ($geo['country'] ?? '');214
+ $evt['province'] = (string) ($geo['province'] ?? '');215
+ $evt['city'] = (string) ($geo['city'] ?? '');216
+ }217
+218
+ $redis = Rds::get();219
+ if ($redis !== null) {220
+ // ---- 热路径:实时计数 + 队列 ----221
+ // 任何 Redis 异常(含键类型污染 WRONGTYPE、超时、断连)都不得让采集返回 500:222
+ // 捕获后置空 $redis,走下方降级直写,保证不丢点。223
+ try {224
+ $this->realtime($redis, $sid, $sidStr, $tzMin, $type, $now);225
+ // hb 已提前返回;白名单内的其余类型(pageview/event/perf/click/scroll/outlink/download/search)一律入队226
+ $queueLen = (int) $redis->llen('queue');227
+ if ($queueLen < self::MAX_QUEUE) {228
+ $redis->lpush('queue', json_encode($evt, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));229
+ }230
+ } catch (\Throwable $e) {231
+ $redis = null;232
+ // WRONGTYPE 单独标记:便于与网络抖动区分(后者可自愈,前者必须人工处理)233
+ $tag = RedisGuard::isWrongType($e) ? 'WRONGTYPE(键类型被污染)' : 'redis error';234
+ error_log(sprintf(235
+ '[wstat] collect 热路径 %s,已降级直写 MySQL;键类型可用 php %s 审计:%s',236
+ $tag,237
+ wstat_rel('scripts/doctor.php'),238
+ $e->getMessage()239
+ ));240
+ }241
+ }242
+ if ($redis === null) {243
+ // ---- 降级:直写(事件表;自动剔除未迁移列容错) ----244
+ Db::insertBatch('events', [Db::filterColumns('events', $evt)]);245
+ if ($type === 'pageview') {246
+ $this->degradedSession($evt);247
+ }248
+ }249
+ $this->tiny();250
+ }251
+252
+ /* ================= 内部 ================= */253
+254
+ /**255
+ * 采集热路径的实时计数(写入 Redis)。256
+ *257
+ * 注意:自 2026-09-15 起这里**不再写 `uv:` / `ip:` 两个 HyperLogLog 键**。258
+ * 原因:HLL 是估算结构,在极小基数下会多估,直接导致「概览 IP 数 4 / IP 地域页 3」这类259
+ * 口径不一致;现在 UV 与独立 IP 一律由 events 明细精确去重260
+ * (见 StatsController::exactUniq / todayExact),今日实时数字同样取自 events。261
+ * 保留 `today:pv` / `rt:min:` / 在线 ZSet 等精确实时计数不变。262
+ */263
+ private function realtime($r, int $sid, string $sidStr, int $tzMin, string $type, int $now): void264
+ {265
+ $ymd = gmdate('Ymd', $now + $tzMin * 60);266
+ if ($type === 'pageview') {267
+ $r->hincrby("today:$sid", "$ymd:pv", 1);268
+ $r->hincrby("rt:min:$sid", gmdate('YmdHi', $now + $tzMin * 60), 1);269
+ Sessionizer::markLive($r, $sid, $sidStr, $now); // 进入“进行中会话”,实时可见270
+ }271
+ $this->touchOnline($sid, $sidStr, $r);272
+ }273
+274
+ private function touchOnline(int $sid, string $sidStr, $r = null): void275
+ {276
+ if ($sidStr === '') {277
+ return;278
+ }279
+ $now = time();280
+ $fn = function ($r) use ($sid, $sidStr, $now) {281
+ $r->zadd("on:$sid", (float) $now, $sidStr);282
+ };283
+ if ($r !== null) {284
+ $fn($r);285
+ } else {286
+ Rds::safe($fn);287
+ }288
+ }289
+290
+ /**291
+ * 降级/无 Redis 模式:会话累计行。292
+ * 同 session 多次 pageview 自动累加 pageviews、刷新离开时间并近似结算时长;293
+ * 写失败仅记 error_log,绝不阻断采集(events 已先行落库)。294
+ */295
+ private function degradedSession(array $evt): void296
+ {297
+ try {298
+ $now = $evt['ts'];299
+ // 老库可能还没有 end_user 列(batch3 未执行):有则一并写入,无则省略300
+ static $hasEndUser = null;301
+ if ($hasEndUser === null) {302
+ $hasEndUser = array_key_exists('end_user', Db::tableColumns('sessions'));303
+ }304
+ // 列与值**全部占位符绑定**(不写字面量常量):一旦列数/顺序与参数不一致,305
+ // 下方自检立刻拦下。历史事故:字面量 1,0,1,0 夹在占位符序列中间时多写了一个306
+ // `?`,整段值右移一位 —— pageviews 绑定到 URL 字符串(→0)、entry_url 绑到字面量307
+ // 0(→入口页显示 “0”)、duration/bounce/is_new 全错,且占位符数与参数数「同时308
+ // 多一个」使旧自检形同虚设。309
+ // 顺序:site_id,session_id,visitor_id[,end_user],start_ts,end_ts,310
+ // pageviews,duration,bounce,is_new,entry_url,exit_url,311
+ // source,medium,campaign,content,term,click_id,312
+ // browser,os,device,screen,lang,ip,country,province,city,created_at313
+ $dup = ' ON DUPLICATE KEY UPDATE314
+ pageviews=pageviews+1,315
+ end_ts=VALUES(end_ts),316
+ duration=VALUES(end_ts)-start_ts,317
+ bounce=0,318
+ exit_url=VALUES(exit_url)';319
+ if ($hasEndUser) {320
+ $sql = 'INSERT INTO sessions321
+ (site_id,session_id,visitor_id,end_user,start_ts,end_ts,pageviews,duration,bounce,is_new,322
+ entry_url,exit_url,source,medium,campaign,content,term,click_id,323
+ browser,os,device,screen,lang,ip,country,province,city,created_at)324
+ VALUES (' . implode(',', array_fill(0, 28, '?')) . ')' . $dup;325
+ $args = [326
+ $evt['site_id'], $evt['session_id'], $evt['visitor_id'], $evt['end_user'],327
+ $now, $now, 1, 0, 1, 0,328
+ $evt['url'], $evt['url'],329
+ $evt['source'], $evt['medium'], $evt['campaign'], $evt['content'], $evt['term'], $evt['click_id'],330
+ $evt['browser'], $evt['os'], $evt['device'], $evt['screen'], $evt['lang'],331
+ $evt['ip'], $evt['country'], $evt['province'], $evt['city'], $now,332
+ ];333
+ } else {334
+ $sql = 'INSERT INTO sessions335
+ (site_id,session_id,visitor_id,start_ts,end_ts,pageviews,duration,bounce,is_new,336
+ entry_url,exit_url,source,medium,campaign,content,term,click_id,337
+ browser,os,device,screen,lang,ip,country,province,city,created_at)338
+ VALUES (' . implode(',', array_fill(0, 27, '?')) . ')' . $dup;339
+ $args = [340
+ $evt['site_id'], $evt['session_id'], $evt['visitor_id'],341
+ $now, $now, 1, 0, 1, 0,342
+ $evt['url'], $evt['url'],343
+ $evt['source'], $evt['medium'], $evt['campaign'], $evt['content'], $evt['term'], $evt['click_id'],344
+ $evt['browser'], $evt['os'], $evt['device'], $evt['screen'], $evt['lang'],345
+ $evt['ip'], $evt['country'], $evt['province'], $evt['city'], $now,346
+ ];347
+ }348
+ // 自检:占位符数量必须与绑定参数一致(防再次错位)349
+ if (substr_count($sql, '?') !== count($args)) {350
+ error_log('[wstat] degradedSession 占位符/参数数量不一致,跳过会话直写');351
+ return;352
+ }353
+ Db::execute($sql, $args);354
+ } catch (\Throwable $e) {355
+ error_log('[wstat] degradedSession failed: ' . $e->getMessage());356
+ }357
+ }358
+359
+ /** 极简成功体 */360
+ private function tiny(): void361
+ {362
+ wstat_json(['ok' => 1], 200);363
+ }364
+365
+ private static function id($v, int $maxLen): string366
+ {367
+ $v = (string) $v;368
+ if (!preg_match('/^[A-Za-z0-9_\-]{8,' . $maxLen . '}$/', $v)) {369
+ return '';370
+ }371
+ return $v;372
+ }373
+374
+ private static function cut(string $s, int $len): string375
+ {376
+ $s = trim($s);377
+ if (mb_strlen($s) > $len) {378
+ $s = mb_substr($s, 0, $len);379
+ }380
+ return $s;381
+ }382
+383
+ /**384
+ * 页面 URL / Referer 白名单:仅接受 http(s) 绝对地址或站内相对路径。385
+ * 拒绝 javascript: / data: / file: 等协议 —— 这类值一旦入库,386
+ * 会在管理端各报表的「链接直达」跳转处成为存储型 XSS 载体。387
+ */388
+ private static function safeUrl(string $s, int $len): string389
+ {390
+ $s = self::cut($s, $len);391
+ if ($s === '') {392
+ return '';393
+ }394
+ // 去掉控制字符与不可见空白(防御性:URL 正常不含这些)395
+ $s = preg_replace('/[\x00-\x1F\x7F]+/', '', $s) ?? '';396
+ return preg_match('#^(https?://|/)#i', $s) === 1 ? $s : '';397
+ }398
+399
+ /** 终端用户标识(SDK identify 上报):字母数字与 _. - @ + :,1-128 位;不合法返回空 */400
+ private static function endUser($v): string401
+ {402
+ $v = trim((string) $v);403
+ if ($v === '') {404
+ return '';405
+ }406
+ return preg_match('/^[A-Za-z0-9_.\-@+:]{1,128}$/', $v) === 1 ? $v : '';407
+ }408
+}app/Controllers/ExportController.php+390-390
@@ -1,390 +1,390 @@1
-<?php2
-/**3
- * 数据导出控制器(CSV)4
- *5
- * 统一入口:GET /api/export/{kind}?site_id=&start=&end=[&筛选参数]6
- * kind = daily 按日 PV / UV / 独立IP / 访问次数 / 跳出率 / 平均访问时长7
- * | channels 来源渠道构成8
- * | referrers 外链来源(referrer 主机名)9
- * | pages 页面路径(按完整 URL 聚合)10
- * | entries 入口页11
- * | exits 退出页12
- * | sessions 会话明细(支持 q/browser/os/device/source 筛选,最多 5万行)13
- * | geo 国家 / 省份 / 城市14
- * | devices / browsers / oses / langs 环境维度15
- *16
- * 设计要点:17
- * 1) 一律直接聚合 events / sessions(不依赖 site_daily 预聚合),保证导出数字与18
- * 「会话/明细」口径一致,不会因 rollup 未跑而缺行;19
- * 2) 输出带 UTF-8 BOM,Excel 双击不乱码;文件名同时给 ASCII 与 filename*(中文名);20
- * 3) 只读权限即可导出(复用 SiteAccess,viewer 也能拿到自己可见站点的数据);21
- * 4) 每类导出都设行数上限,避免超大站点一次性拉爆内存。22
- */23
-declare(strict_types=1);24
-25
-namespace Wstat\Controllers;26
-27
-use Wstat\Http\Request;28
-use Wstat\Support\Db;29
-use Wstat\Support\SiteAccess;30
-use Wstat\Support\Util;31
-32
-class ExportController33
-{34
- /** 单次导出行数上限 */35
- private const MAX_ROWS = 50000;36
-37
- /** 日期区间上限(与 StatsController 保持一致) */38
- private const MAX_RANGE_DAYS = 366;39
-40
- /** 分组维度的行数上限(榜单类) */41
- private const MAX_GROUP = 5000;42
-43
- /**44
- * GET /api/export/{kind}45
- * 路由统一指向本方法,kind 由路由参数给出。46
- */47
- public function handle(Request $req): void48
- {49
- $kind = (string) $req->param('kind', 'daily');50
- $site = SiteAccess::site($req, (int) $req->input('site_id', 0));51
- [$start, $end] = $this->range($req);52
- [$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);53
- $sid = (int) $site['id'];54
- $tz = (string) $site['timezone'];55
-56
- $stamp = $start === $end ? $start : ($start . '_' . $end);57
- $base = preg_replace('/[^A-Za-z0-9_\-]/', '', (string) $site['domain']);58
-59
- switch ($kind) {60
- case 'daily':61
- [$header, $rows] = $this->daily($sid, $start, $end, $tz);62
- $this->csv("wstat-daily-$base-$stamp.csv", "流量趋势(PV-UV)_$stamp.csv", $header, $rows);63
- break;64
- case 'channels':65
- [$header, $rows] = $this->groupByEvents('source', "AND source<>''", $sid, $start, $end, ['来源渠道', 'PV', '占比(%)']);66
- $this->csv("wstat-channels-$base-$stamp.csv", "来源渠道_$stamp.csv", $header, $rows);67
- break;68
- case 'referrers':69
- [$header, $rows] = $this->groupByEvents('ref_host', "AND ref_host<>''", $sid, $start, $end, ['外链来源', 'PV', '占比(%)']);70
- $this->csv("wstat-referrers-$base-$stamp.csv", "外链来源_$stamp.csv", $header, $rows);71
- break;72
- case 'pages':73
- [$header, $rows] = $this->groupByEvents('url', '', $sid, $start, $end, ['页面URL', 'PV', '占比(%)']);74
- $this->csv("wstat-pages-$base-$stamp.csv", "页面分析_$stamp.csv", $header, $rows);75
- break;76
- case 'entries':77
- [$header, $rows] = $this->groupBySessions('entry_url', $sid, $startTs, $endTs, ['入口页', 'PV', '占比(%)']);78
- $this->csv("wstat-entries-$base-$stamp.csv", "入口页_$stamp.csv", $header, $rows);79
- break;80
- case 'exits':81
- [$header, $rows] = $this->groupBySessions('exit_url', $sid, $startTs, $endTs, ['退出页', 'PV', '占比(%)']);82
- $this->csv("wstat-exits-$base-$stamp.csv", "退出页_$stamp.csv", $header, $rows);83
- break;84
- case 'sessions':85
- [$header, $rows] = $this->sessions($req, $sid, $startTs, $endTs);86
- $this->csv("wstat-sessions-$base-$stamp.csv", "会话明细_$stamp.csv", $header, $rows);87
- break;88
- case 'geo':89
- [$header, $rows] = $this->geo($sid, $start, $end);90
- $this->csv("wstat-geo-$base-$stamp.csv", "地域分布_$stamp.csv", $header, $rows);91
- break;92
- case 'devices':93
- [$header, $rows] = $this->groupByEvents('device', "AND device<>''", $sid, $start, $end, ['设备', 'PV', '占比(%)']);94
- $this->csv("wstat-devices-$base-$stamp.csv", "设备分布_$stamp.csv", $header, $rows);95
- break;96
- case 'browsers':97
- [$header, $rows] = $this->groupByEvents('browser', "AND browser<>''", $sid, $start, $end, ['浏览器', 'PV', '占比(%)']);98
- $this->csv("wstat-browsers-$base-$stamp.csv", "浏览器分布_$stamp.csv", $header, $rows);99
- break;100
- case 'oses':101
- [$header, $rows] = $this->groupByEvents('os', "AND os<>''", $sid, $start, $end, ['操作系统', 'PV', '占比(%)']);102
- $this->csv("wstat-oses-$base-$stamp.csv", "操作系统分布_$stamp.csv", $header, $rows);103
- break;104
- case 'langs':105
- [$header, $rows] = $this->groupByEvents('lang', "AND lang<>''", $sid, $start, $end, ['语言', 'PV', '占比(%)']);106
- $this->csv("wstat-langs-$base-$stamp.csv", "语言分布_$stamp.csv", $header, $rows);107
- break;108
- default:109
- wstat_err('不支持的导出类型: ' . $kind, 422);110
- }111
- }112
-113
- /* ==================== 各导出数据集 ==================== */114
-115
- /** 按日趋势(零填充完整日期轴) */116
- private function daily(int $sid, string $start, string $end, string $tz): array117
- {118
- [$startTs, $endTs] = Util::dateRangeToTs($start, $end, $tz);119
- $offset = Util::tzOffsetSec($tz);120
-121
- $ev = [];122
- foreach (123
- Db::select(124
- "SELECT `day`,125
- SUM(type='pageview') pv,126
- COUNT(DISTINCT CASE WHEN type='pageview' THEN visitor_id END) uv,127
- COUNT(DISTINCT CASE WHEN type='pageview' THEN ip END) ipc128
- FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",129
- [$sid, $start, $end]130
- ) as $r131
- ) {132
- $ev[(string) $r['day']] = [133
- 'pv' => (int) $r['pv'], 'uv' => (int) $r['uv'], 'ipc' => (int) $r['ipc'],134
- ];135
- }136
-137
- $ss = [];138
- foreach (139
- Db::select(140
- "SELECT DATE(FROM_UNIXTIME(start_ts+?)) d,141
- COUNT(*) c, COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du142
- FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",143
- [$offset, $sid, $startTs, $endTs]144
- ) as $r145
- ) {146
- $ss[(string) $r['d']] = ['visits' => (int) $r['c'], 'bounce' => (int) $r['b'], 'dur' => (int) $r['du']];147
- }148
-149
- $header = ['日期', '浏览量(PV)', '访客数(UV)', '独立IP', '访问次数', '跳出率(%)', '平均访问时长(秒)'];150
- $rows = [];151
- // 完整日期轴(用 UTC 日推进,避免 DST 误算;本站时区已折算到 day 字段)152
- $cur = strtotime($start . ' 00:00:00 UTC');153
- $last = strtotime($end . ' 00:00:00 UTC');154
- for (; $cur <= $last; $cur += 86400) {155
- $d = gmdate('Y-m-d', $cur);156
- $e = $ev[$d] ?? ['pv' => 0, 'uv' => 0, 'ipc' => 0];157
- $s = $ss[$d] ?? ['visits' => 0, 'bounce' => 0, 'dur' => 0];158
- $visits = (int) $s['visits'];159
- $rows[] = [160
- $d,161
- (int) $e['pv'],162
- (int) $e['uv'],163
- (int) $e['ipc'],164
- $visits,165
- $visits > 0 ? round((int) $s['bounce'] / $visits * 100, 2) : 0,166
- $visits > 0 ? round((int) $s['dur'] / $visits, 1) : 0,167
- ];168
- }169
- return [$header, $rows];170
- }171
-172
- /** events 维度聚合($col 为内部白名单,无注入面) */173
- private function groupByEvents(string $col, string $cond, int $sid, string $start, string $end, array $header): array174
- {175
- $total = (int) Db::value(176
- "SELECT COUNT(*) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",177
- [$sid, $start, $end]178
- );179
- $rows = [];180
- foreach (181
- Db::select(182
- "SELECT `$col` AS name, COUNT(*) AS pv183
- FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $cond184
- GROUP BY `$col` ORDER BY pv DESC LIMIT " . self::MAX_GROUP,185
- [$sid, $start, $end]186
- ) as $r187
- ) {188
- $name = (string) $r['name'];189
- $pv = (int) $r['pv'];190
- $rows[] = [$name === '' ? '(空)' : $name, $pv, $total > 0 ? round($pv / $total * 100, 2) : 0];191
- }192
- return [$header, $rows];193
- }194
-195
- /** sessions 维度聚合(入口/退出页) */196
- private function groupBySessions(string $col, int $sid, int $startTs, int $endTs, array $header): array197
- {198
- $rows = Db::select(199
- "SELECT `$col` AS name, COALESCE(SUM(pageviews),0) AS pv200
- FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? AND `$col`<>''201
- GROUP BY `$col` ORDER BY pv DESC LIMIT " . self::MAX_GROUP,202
- [$sid, $startTs, $endTs]203
- );204
- $total = 0;205
- foreach ($rows as $r) {206
- $total += (int) $r['pv'];207
- }208
- $out = [];209
- foreach ($rows as $r) {210
- $pv = (int) $r['pv'];211
- $out[] = [(string) $r['name'], $pv, $total > 0 ? round($pv / $total * 100, 2) : 0];212
- }213
- return [$header, $out];214
- }215
-216
- /** 会话明细(筛选逻辑与 /api/stats/sessions 保持一致) */217
- private function sessions(Request $req, int $sid, int $startTs, int $endTs): array218
- {219
- $where = 'site_id=? AND start_ts>=? AND start_ts<?';220
- $args = [$sid, $startTs, $endTs];221
-222
- $q = trim((string) $req->input('q', ''));223
- if ($q !== '') {224
- $like = '%' . $this->likeEscape($q) . '%';225
- $ipLike = isset(Db::tableColumns('sessions')['ip']) ? " OR ip LIKE ? ESCAPE '\\\\'" : '';226
- $where .= " AND (visitor_id LIKE ? ESCAPE '\\\\' OR entry_url LIKE ? ESCAPE '\\\\'$ipLike)";227
- $args[] = $like;228
- $args[] = $like;229
- if ($ipLike !== '') {230
- $args[] = $like;231
- }232
- }233
- foreach (['browser', 'os', 'device', 'source'] as $col) {234
- $val = trim((string) $req->input($col, ''));235
- if ($val !== '') {236
- $where .= " AND $col LIKE ? ESCAPE '\\\\'";237
- $args[] = '%' . $this->likeEscape($val) . '%';238
- }239
- }240
-241
- $ipCol = isset(Db::tableColumns('sessions')['ip']) ? ',ip' : '';242
- $rows = Db::select(243
- "SELECT session_id,visitor_id,start_ts,end_ts,pageviews,duration,bounce,is_new,244
- entry_url,exit_url,source,medium,campaign,click_id,245
- browser,os,device,screen,lang,country,province,city$ipCol246
- FROM sessions WHERE $where ORDER BY start_ts DESC LIMIT " . self::MAX_ROWS,247
- $args248
- );249
-250
- $header = [251
- '会话ID', '访客ID', '开始时间', '结束时间', '浏览页数', '时长(秒)', '是否跳出', '是否新访客',252
- '入口页', '退出页', '来源', '媒介', '广告系列', 'ClickID',253
- '浏览器', '操作系统', '设备', '屏幕', '语言', '国家', '省份', '城市', 'IP',254
- ];255
- $out = [];256
- foreach ($rows as $r) {257
- $out[] = [258
- (string) $r['session_id'],259
- (string) $r['visitor_id'],260
- date('Y-m-d H:i:s', (int) $r['start_ts']),261
- date('Y-m-d H:i:s', (int) $r['end_ts']),262
- (int) $r['pageviews'],263
- (int) $r['duration'],264
- (int) $r['bounce'] ? '是' : '否',265
- (int) $r['is_new'] ? '是' : '否',266
- (string) $r['entry_url'],267
- (string) $r['exit_url'],268
- (string) $r['source'],269
- (string) $r['medium'],270
- (string) $r['campaign'],271
- (string) $r['click_id'],272
- (string) $r['browser'],273
- (string) $r['os'],274
- (string) $r['device'],275
- (string) $r['screen'],276
- (string) $r['lang'],277
- (string) $r['country'],278
- (string) $r['province'],279
- (string) $r['city'],280
- (string) ($r['ip'] ?? ''),281
- ];282
- }283
- return [$header, $out];284
- }285
-286
- /** 地域分布(国家 / 省份 / 城市三层,去重 PV) */287
- private function geo(int $sid, string $start, string $end): array288
- {289
- $header = ['国家/地区', '省份', '城市', 'PV', '占比(%)'];290
- $total = (int) Db::value(291
- "SELECT COUNT(*) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",292
- [$sid, $start, $end]293
- );294
- $rows = [];295
- foreach (296
- Db::select(297
- "SELECT country,province,city,COUNT(*) pv298
- FROM events299
- WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?300
- AND (country<>'' OR province<>'' OR city<>'')301
- GROUP BY country,province,city ORDER BY pv DESC LIMIT " . self::MAX_GROUP,302
- [$sid, $start, $end]303
- ) as $r304
- ) {305
- $pv = (int) $r['pv'];306
- $rows[] = [307
- (string) $r['country'], (string) $r['province'], (string) $r['city'],308
- $pv, $total > 0 ? round($pv / $total * 100, 2) : 0,309
- ];310
- }311
- return [$header, $rows];312
- }313
-314
- /* ==================== 输出与工具 ==================== */315
-316
- /**317
- * 输出 CSV 并结束请求。318
- * $asciiName 给老浏览器(Content-Disposition filename),$utf8Name 给现代浏览器(filename*)。319
- */320
- private function csv(string $asciiName, string $utf8Name, array $header, array $rows): void321
- {322
- // 丢弃此前可能的杂散输出,避免污染 CSV 正文。323
- // 只清空、不销毁最外层缓冲:销毁所有层级会连带破坏调用方(如 CLI 自检324
- // 脚本用 ob_start() 回收响应体)的缓冲,使其拿到空响应。325
- while (ob_get_level() > 1) {326
- ob_end_clean();327
- }328
- if (ob_get_level() > 0) {329
- ob_clean();330
- }331
- header('Content-Type: text/csv; charset=utf-8');332
- header(333
- 'Content-Disposition: attachment; filename="' . $asciiName . '"; '334
- . "filename*=UTF-8''" . rawurlencode($utf8Name)335
- );336
- header('Cache-Control: no-store');337
- header('X-Export-Rows: ' . count($rows));338
-339
- $out = fopen('php://output', 'wb');340
- if ($out === false) {341
- exit(0);342
- }343
- fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM:Excel 识别编码344
- fputcsv($out, $header);345
- foreach ($rows as $r) {346
- fputcsv($out, $r);347
- }348
- fclose($out);349
- exit(0);350
- }351
-352
- /** 校验/默认日期区间(与 StatsController::range 同语义) */353
- private function range(Request $req): array354
- {355
- $today = date('Y-m-d');356
- $start = $this->ymd((string) $req->input('start', date('Y-m-d', strtotime('-29 days'))));357
- $end = $this->ymd((string) $req->input('end', $today));358
- if ($start === '' || $end === '') {359
- wstat_err('日期格式应为 YYYY-MM-DD', 422);360
- }361
- if ($start > $end) {362
- [$start, $end] = [$end, $start];363
- }364
- if (strtotime($end) - strtotime($start) > (self::MAX_RANGE_DAYS - 1) * 86400) {365
- wstat_err('导出区间最长 366 天', 422);366
- }367
- return [$start, $end];368
- }369
-370
- private function ymd(string $v): string371
- {372
- $v = trim($v);373
- if ($v === '') {374
- return '';375
- }376
- if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {377
- return $v;378
- }379
- if (preg_match('/^(\d{4}-\d{2}-\d{2})[T ]/', $v)) {380
- return (string) substr($v, 0, 10);381
- }382
- $ts = strtotime($v);383
- return $ts === false ? '' : date('Y-m-d', $ts);384
- }385
-386
- private function likeEscape(string $s): string387
- {388
- return addcslashes($s, '%_\\');389
- }390
-}1
+<?php2
+/**3
+ * 数据导出控制器(CSV)4
+ *5
+ * 统一入口:GET /api/export/{kind}?site_id=&start=&end=[&筛选参数]6
+ * kind = daily 按日 PV / UV / 独立IP / 访问次数 / 跳出率 / 平均访问时长7
+ * | channels 来源渠道构成8
+ * | referrers 外链来源(referrer 主机名)9
+ * | pages 页面路径(按完整 URL 聚合)10
+ * | entries 入口页11
+ * | exits 退出页12
+ * | sessions 会话明细(支持 q/browser/os/device/source 筛选,最多 5万行)13
+ * | geo 国家 / 省份 / 城市14
+ * | devices / browsers / oses / langs 环境维度15
+ *16
+ * 设计要点:17
+ * 1) 一律直接聚合 events / sessions(不依赖 site_daily 预聚合),保证导出数字与18
+ * 「会话/明细」口径一致,不会因 rollup 未跑而缺行;19
+ * 2) 输出带 UTF-8 BOM,Excel 双击不乱码;文件名同时给 ASCII 与 filename*(中文名);20
+ * 3) 只读权限即可导出(复用 SiteAccess,viewer 也能拿到自己可见站点的数据);21
+ * 4) 每类导出都设行数上限,避免超大站点一次性拉爆内存。22
+ */23
+declare(strict_types=1);24
+25
+namespace Wstat\Controllers;26
+27
+use Wstat\Http\Request;28
+use Wstat\Support\Db;29
+use Wstat\Support\SiteAccess;30
+use Wstat\Support\Util;31
+32
+class ExportController33
+{34
+ /** 单次导出行数上限 */35
+ private const MAX_ROWS = 50000;36
+37
+ /** 日期区间上限(与 StatsController 保持一致) */38
+ private const MAX_RANGE_DAYS = 366;39
+40
+ /** 分组维度的行数上限(榜单类) */41
+ private const MAX_GROUP = 5000;42
+43
+ /**44
+ * GET /api/export/{kind}45
+ * 路由统一指向本方法,kind 由路由参数给出。46
+ */47
+ public function handle(Request $req): void48
+ {49
+ $kind = (string) $req->param('kind', 'daily');50
+ $site = SiteAccess::site($req, (int) $req->input('site_id', 0));51
+ [$start, $end] = $this->range($req);52
+ [$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);53
+ $sid = (int) $site['id'];54
+ $tz = (string) $site['timezone'];55
+56
+ $stamp = $start === $end ? $start : ($start . '_' . $end);57
+ $base = preg_replace('/[^A-Za-z0-9_\-]/', '', (string) $site['domain']);58
+59
+ switch ($kind) {60
+ case 'daily':61
+ [$header, $rows] = $this->daily($sid, $start, $end, $tz);62
+ $this->csv("wstat-daily-$base-$stamp.csv", "流量趋势(PV-UV)_$stamp.csv", $header, $rows);63
+ break;64
+ case 'channels':65
+ [$header, $rows] = $this->groupByEvents('source', "AND source<>''", $sid, $start, $end, ['来源渠道', 'PV', '占比(%)']);66
+ $this->csv("wstat-channels-$base-$stamp.csv", "来源渠道_$stamp.csv", $header, $rows);67
+ break;68
+ case 'referrers':69
+ [$header, $rows] = $this->groupByEvents('ref_host', "AND ref_host<>''", $sid, $start, $end, ['外链来源', 'PV', '占比(%)']);70
+ $this->csv("wstat-referrers-$base-$stamp.csv", "外链来源_$stamp.csv", $header, $rows);71
+ break;72
+ case 'pages':73
+ [$header, $rows] = $this->groupByEvents('url', '', $sid, $start, $end, ['页面URL', 'PV', '占比(%)']);74
+ $this->csv("wstat-pages-$base-$stamp.csv", "页面分析_$stamp.csv", $header, $rows);75
+ break;76
+ case 'entries':77
+ [$header, $rows] = $this->groupBySessions('entry_url', $sid, $startTs, $endTs, ['入口页', 'PV', '占比(%)']);78
+ $this->csv("wstat-entries-$base-$stamp.csv", "入口页_$stamp.csv", $header, $rows);79
+ break;80
+ case 'exits':81
+ [$header, $rows] = $this->groupBySessions('exit_url', $sid, $startTs, $endTs, ['退出页', 'PV', '占比(%)']);82
+ $this->csv("wstat-exits-$base-$stamp.csv", "退出页_$stamp.csv", $header, $rows);83
+ break;84
+ case 'sessions':85
+ [$header, $rows] = $this->sessions($req, $sid, $startTs, $endTs);86
+ $this->csv("wstat-sessions-$base-$stamp.csv", "会话明细_$stamp.csv", $header, $rows);87
+ break;88
+ case 'geo':89
+ [$header, $rows] = $this->geo($sid, $start, $end);90
+ $this->csv("wstat-geo-$base-$stamp.csv", "地域分布_$stamp.csv", $header, $rows);91
+ break;92
+ case 'devices':93
+ [$header, $rows] = $this->groupByEvents('device', "AND device<>''", $sid, $start, $end, ['设备', 'PV', '占比(%)']);94
+ $this->csv("wstat-devices-$base-$stamp.csv", "设备分布_$stamp.csv", $header, $rows);95
+ break;96
+ case 'browsers':97
+ [$header, $rows] = $this->groupByEvents('browser', "AND browser<>''", $sid, $start, $end, ['浏览器', 'PV', '占比(%)']);98
+ $this->csv("wstat-browsers-$base-$stamp.csv", "浏览器分布_$stamp.csv", $header, $rows);99
+ break;100
+ case 'oses':101
+ [$header, $rows] = $this->groupByEvents('os', "AND os<>''", $sid, $start, $end, ['操作系统', 'PV', '占比(%)']);102
+ $this->csv("wstat-oses-$base-$stamp.csv", "操作系统分布_$stamp.csv", $header, $rows);103
+ break;104
+ case 'langs':105
+ [$header, $rows] = $this->groupByEvents('lang', "AND lang<>''", $sid, $start, $end, ['语言', 'PV', '占比(%)']);106
+ $this->csv("wstat-langs-$base-$stamp.csv", "语言分布_$stamp.csv", $header, $rows);107
+ break;108
+ default:109
+ wstat_err('不支持的导出类型: ' . $kind, 422);110
+ }111
+ }112
+113
+ /* ==================== 各导出数据集 ==================== */114
+115
+ /** 按日趋势(零填充完整日期轴) */116
+ private function daily(int $sid, string $start, string $end, string $tz): array117
+ {118
+ [$startTs, $endTs] = Util::dateRangeToTs($start, $end, $tz);119
+ $offset = Util::tzOffsetSec($tz);120
+121
+ $ev = [];122
+ foreach (123
+ Db::select(124
+ "SELECT `day`,125
+ SUM(type='pageview') pv,126
+ COUNT(DISTINCT CASE WHEN type='pageview' THEN visitor_id END) uv,127
+ COUNT(DISTINCT CASE WHEN type='pageview' THEN ip END) ipc128
+ FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",129
+ [$sid, $start, $end]130
+ ) as $r131
+ ) {132
+ $ev[(string) $r['day']] = [133
+ 'pv' => (int) $r['pv'], 'uv' => (int) $r['uv'], 'ipc' => (int) $r['ipc'],134
+ ];135
+ }136
+137
+ $ss = [];138
+ foreach (139
+ Db::select(140
+ "SELECT DATE(FROM_UNIXTIME(start_ts+?)) d,141
+ COUNT(*) c, COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du142
+ FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",143
+ [$offset, $sid, $startTs, $endTs]144
+ ) as $r145
+ ) {146
+ $ss[(string) $r['d']] = ['visits' => (int) $r['c'], 'bounce' => (int) $r['b'], 'dur' => (int) $r['du']];147
+ }148
+149
+ $header = ['日期', '浏览量(PV)', '访客数(UV)', '独立IP', '访问次数', '跳出率(%)', '平均访问时长(秒)'];150
+ $rows = [];151
+ // 完整日期轴(用 UTC 日推进,避免 DST 误算;本站时区已折算到 day 字段)152
+ $cur = strtotime($start . ' 00:00:00 UTC');153
+ $last = strtotime($end . ' 00:00:00 UTC');154
+ for (; $cur <= $last; $cur += 86400) {155
+ $d = gmdate('Y-m-d', $cur);156
+ $e = $ev[$d] ?? ['pv' => 0, 'uv' => 0, 'ipc' => 0];157
+ $s = $ss[$d] ?? ['visits' => 0, 'bounce' => 0, 'dur' => 0];158
+ $visits = (int) $s['visits'];159
+ $rows[] = [160
+ $d,161
+ (int) $e['pv'],162
+ (int) $e['uv'],163
+ (int) $e['ipc'],164
+ $visits,165
+ $visits > 0 ? round((int) $s['bounce'] / $visits * 100, 2) : 0,166
+ $visits > 0 ? round((int) $s['dur'] / $visits, 1) : 0,167
+ ];168
+ }169
+ return [$header, $rows];170
+ }171
+172
+ /** events 维度聚合($col 为内部白名单,无注入面) */173
+ private function groupByEvents(string $col, string $cond, int $sid, string $start, string $end, array $header): array174
+ {175
+ $total = (int) Db::value(176
+ "SELECT COUNT(*) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",177
+ [$sid, $start, $end]178
+ );179
+ $rows = [];180
+ foreach (181
+ Db::select(182
+ "SELECT `$col` AS name, COUNT(*) AS pv183
+ FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $cond184
+ GROUP BY `$col` ORDER BY pv DESC LIMIT " . self::MAX_GROUP,185
+ [$sid, $start, $end]186
+ ) as $r187
+ ) {188
+ $name = (string) $r['name'];189
+ $pv = (int) $r['pv'];190
+ $rows[] = [$name === '' ? '(空)' : $name, $pv, $total > 0 ? round($pv / $total * 100, 2) : 0];191
+ }192
+ return [$header, $rows];193
+ }194
+195
+ /** sessions 维度聚合(入口/退出页) */196
+ private function groupBySessions(string $col, int $sid, int $startTs, int $endTs, array $header): array197
+ {198
+ $rows = Db::select(199
+ "SELECT `$col` AS name, COALESCE(SUM(pageviews),0) AS pv200
+ FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? AND `$col`<>''201
+ GROUP BY `$col` ORDER BY pv DESC LIMIT " . self::MAX_GROUP,202
+ [$sid, $startTs, $endTs]203
+ );204
+ $total = 0;205
+ foreach ($rows as $r) {206
+ $total += (int) $r['pv'];207
+ }208
+ $out = [];209
+ foreach ($rows as $r) {210
+ $pv = (int) $r['pv'];211
+ $out[] = [(string) $r['name'], $pv, $total > 0 ? round($pv / $total * 100, 2) : 0];212
+ }213
+ return [$header, $out];214
+ }215
+216
+ /** 会话明细(筛选逻辑与 /api/stats/sessions 保持一致) */217
+ private function sessions(Request $req, int $sid, int $startTs, int $endTs): array218
+ {219
+ $where = 'site_id=? AND start_ts>=? AND start_ts<?';220
+ $args = [$sid, $startTs, $endTs];221
+222
+ $q = trim((string) $req->input('q', ''));223
+ if ($q !== '') {224
+ $like = '%' . $this->likeEscape($q) . '%';225
+ $ipLike = isset(Db::tableColumns('sessions')['ip']) ? " OR ip LIKE ? ESCAPE '\\\\'" : '';226
+ $where .= " AND (visitor_id LIKE ? ESCAPE '\\\\' OR entry_url LIKE ? ESCAPE '\\\\'$ipLike)";227
+ $args[] = $like;228
+ $args[] = $like;229
+ if ($ipLike !== '') {230
+ $args[] = $like;231
+ }232
+ }233
+ foreach (['browser', 'os', 'device', 'source'] as $col) {234
+ $val = trim((string) $req->input($col, ''));235
+ if ($val !== '') {236
+ $where .= " AND $col LIKE ? ESCAPE '\\\\'";237
+ $args[] = '%' . $this->likeEscape($val) . '%';238
+ }239
+ }240
+241
+ $ipCol = isset(Db::tableColumns('sessions')['ip']) ? ',ip' : '';242
+ $rows = Db::select(243
+ "SELECT session_id,visitor_id,start_ts,end_ts,pageviews,duration,bounce,is_new,244
+ entry_url,exit_url,source,medium,campaign,click_id,245
+ browser,os,device,screen,lang,country,province,city$ipCol246
+ FROM sessions WHERE $where ORDER BY start_ts DESC LIMIT " . self::MAX_ROWS,247
+ $args248
+ );249
+250
+ $header = [251
+ '会话ID', '访客ID', '开始时间', '结束时间', '浏览页数', '时长(秒)', '是否跳出', '是否新访客',252
+ '入口页', '退出页', '来源', '媒介', '广告系列', 'ClickID',253
+ '浏览器', '操作系统', '设备', '屏幕', '语言', '国家', '省份', '城市', 'IP',254
+ ];255
+ $out = [];256
+ foreach ($rows as $r) {257
+ $out[] = [258
+ (string) $r['session_id'],259
+ (string) $r['visitor_id'],260
+ date('Y-m-d H:i:s', (int) $r['start_ts']),261
+ date('Y-m-d H:i:s', (int) $r['end_ts']),262
+ (int) $r['pageviews'],263
+ (int) $r['duration'],264
+ (int) $r['bounce'] ? '是' : '否',265
+ (int) $r['is_new'] ? '是' : '否',266
+ (string) $r['entry_url'],267
+ (string) $r['exit_url'],268
+ (string) $r['source'],269
+ (string) $r['medium'],270
+ (string) $r['campaign'],271
+ (string) $r['click_id'],272
+ (string) $r['browser'],273
+ (string) $r['os'],274
+ (string) $r['device'],275
+ (string) $r['screen'],276
+ (string) $r['lang'],277
+ (string) $r['country'],278
+ (string) $r['province'],279
+ (string) $r['city'],280
+ (string) ($r['ip'] ?? ''),281
+ ];282
+ }283
+ return [$header, $out];284
+ }285
+286
+ /** 地域分布(国家 / 省份 / 城市三层,去重 PV) */287
+ private function geo(int $sid, string $start, string $end): array288
+ {289
+ $header = ['国家/地区', '省份', '城市', 'PV', '占比(%)'];290
+ $total = (int) Db::value(291
+ "SELECT COUNT(*) FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",292
+ [$sid, $start, $end]293
+ );294
+ $rows = [];295
+ foreach (296
+ Db::select(297
+ "SELECT country,province,city,COUNT(*) pv298
+ FROM events299
+ WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?300
+ AND (country<>'' OR province<>'' OR city<>'')301
+ GROUP BY country,province,city ORDER BY pv DESC LIMIT " . self::MAX_GROUP,302
+ [$sid, $start, $end]303
+ ) as $r304
+ ) {305
+ $pv = (int) $r['pv'];306
+ $rows[] = [307
+ (string) $r['country'], (string) $r['province'], (string) $r['city'],308
+ $pv, $total > 0 ? round($pv / $total * 100, 2) : 0,309
+ ];310
+ }311
+ return [$header, $rows];312
+ }313
+314
+ /* ==================== 输出与工具 ==================== */315
+316
+ /**317
+ * 输出 CSV 并结束请求。318
+ * $asciiName 给老浏览器(Content-Disposition filename),$utf8Name 给现代浏览器(filename*)。319
+ */320
+ private function csv(string $asciiName, string $utf8Name, array $header, array $rows): void321
+ {322
+ // 丢弃此前可能的杂散输出,避免污染 CSV 正文。323
+ // 只清空、不销毁最外层缓冲:销毁所有层级会连带破坏调用方(如 CLI 自检324
+ // 脚本用 ob_start() 回收响应体)的缓冲,使其拿到空响应。325
+ while (ob_get_level() > 1) {326
+ ob_end_clean();327
+ }328
+ if (ob_get_level() > 0) {329
+ ob_clean();330
+ }331
+ header('Content-Type: text/csv; charset=utf-8');332
+ header(333
+ 'Content-Disposition: attachment; filename="' . $asciiName . '"; '334
+ . "filename*=UTF-8''" . rawurlencode($utf8Name)335
+ );336
+ header('Cache-Control: no-store');337
+ header('X-Export-Rows: ' . count($rows));338
+339
+ $out = fopen('php://output', 'wb');340
+ if ($out === false) {341
+ exit(0);342
+ }343
+ fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM:Excel 识别编码344
+ fputcsv($out, $header);345
+ foreach ($rows as $r) {346
+ fputcsv($out, $r);347
+ }348
+ fclose($out);349
+ exit(0);350
+ }351
+352
+ /** 校验/默认日期区间(与 StatsController::range 同语义) */353
+ private function range(Request $req): array354
+ {355
+ $today = date('Y-m-d');356
+ $start = $this->ymd((string) $req->input('start', date('Y-m-d', strtotime('-29 days'))));357
+ $end = $this->ymd((string) $req->input('end', $today));358
+ if ($start === '' || $end === '') {359
+ wstat_err('日期格式应为 YYYY-MM-DD', 422);360
+ }361
+ if ($start > $end) {362
+ [$start, $end] = [$end, $start];363
+ }364
+ if (strtotime($end) - strtotime($start) > (self::MAX_RANGE_DAYS - 1) * 86400) {365
+ wstat_err('导出区间最长 366 天', 422);366
+ }367
+ return [$start, $end];368
+ }369
+370
+ private function ymd(string $v): string371
+ {372
+ $v = trim($v);373
+ if ($v === '') {374
+ return '';375
+ }376
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {377
+ return $v;378
+ }379
+ if (preg_match('/^(\d{4}-\d{2}-\d{2})[T ]/', $v)) {380
+ return (string) substr($v, 0, 10);381
+ }382
+ $ts = strtotime($v);383
+ return $ts === false ? '' : date('Y-m-d', $ts);384
+ }385
+386
+ private function likeEscape(string $s): string387
+ {388
+ return addcslashes($s, '%_\\');389
+ }390
+}app/Controllers/OpenController.php+198-198
@@ -1,198 +1,198 @@1
-<?php2
-/**3
- * 开放 API(/open/v1/*):个人访问令牌(PAT)鉴权,只读统计白名单。4
- *5
- * 鉴权:Authorization: Bearer <64位token>(api_tokens 表,见 Support\PAT)。6
- * 授权:令牌属主对该站点具备任意角色(owner/editor/viewer 均可只读)。7
- * 限额:暂不启用硬性限流;last_used_at 节流刷新用于审计。8
- */9
-declare(strict_types=1);10
-11
-namespace Wstat\Controllers;12
-13
-use Wstat\Http\Request;14
-use Wstat\Support\Db;15
-use Wstat\Support\PAT;16
-use Wstat\Support\Rds;17
-use Wstat\Support\SiteAccess;18
-use Wstat\Support\Util;19
-20
-class OpenController21
-{22
- /** 与 StatsController::URL_PATH_EXPR 同款路径表达式(跨控制器私有,故此处独立维护) */23
- private const URL_PATH_EXPR = "IF(LOCATE('//', url) > 0, IF(LOCATE('/', url, LOCATE('//', url) + 2) > 0, SUBSTRING(url, LOCATE('/', url, LOCATE('//', url) + 2)), '/'), url)";24
-25
- /**26
- * GET /open/v1/overview?site_id=&start=&end=27
- * 概览:totals(pv/uv/ip/visits/bounce_rate/avg_duration/new_users)+ daily 逐日序列。28
- * 口径与看板概览一致:events 按 day(站点时区本地日)、sessions 按站点本地日聚合。29
- */30
- public function overview(Request $req): void31
- {32
- [$site, $start, $end] = $this->site($req);33
- [$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);34
- $sid = (int) $site['id'];35
- $offset = Util::tzOffsetSec((string) $site['timezone']);36
-37
- $daily = [];38
- foreach (Util::dailySeries($startTs, $endTs, (string) $site['timezone'], fn () => null) as $d => $_) {39
- $daily[(string) $d] = ['day' => (string) $d, 'pv' => 0, 'uv' => 0, 'ip' => 0,40
- 'visits' => 0, 'bounce_rate' => 0.0, 'avg_duration' => 0.0, 'new_users' => 0];41
- }42
-43
- foreach (44
- Db::select(45
- "SELECT `day`,46
- SUM(type='pageview') pv,47
- COUNT(DISTINCT CASE WHEN type='pageview' THEN visitor_id END) uv,48
- COUNT(DISTINCT CASE WHEN type='pageview' AND ip<>'' THEN ip END) ipc49
- FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",50
- [$sid, $start, $end]51
- ) as $r52
- ) {53
- $d = (string) $r['day'];54
- if (isset($daily[$d])) {55
- $daily[$d]['pv'] = (int) $r['pv'];56
- $daily[$d]['uv'] = (int) $r['uv'];57
- $daily[$d]['ip'] = (int) $r['ipc'];58
- }59
- }60
- foreach (61
- Db::select(62
- "SELECT DATE(FROM_UNIXTIME(start_ts+?)) d, COUNT(*) c,63
- COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du, COALESCE(SUM(is_new),0) nw64
- FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",65
- [$offset, $sid, $startTs, $endTs]66
- ) as $r67
- ) {68
- $d = (string) $r['d'];69
- if (isset($daily[$d])) {70
- $c = (int) $r['c'];71
- $daily[$d]['visits'] = $c;72
- $daily[$d]['bounce_rate'] = $c > 0 ? round((int) $r['b'] / $c * 100, 1) : 0.0;73
- $daily[$d]['avg_duration'] = $c > 0 ? round((int) $r['du'] / $c) : 0.0;74
- $daily[$d]['new_users'] = (int) $r['nw'];75
- }76
- }77
-78
- $rows = array_values($daily);79
- $t = ['pv' => 0, 'uv' => 0, 'ip' => 0, 'visits' => 0, 'bounce' => 0, 'duration' => 0, 'new_users' => 0];80
- foreach ($rows as $r) {81
- $t['pv'] += $r['pv'];82
- $t['visits'] += $r['visits'];83
- $t['bounce'] += round($r['bounce_rate'] * $r['visits'] / 100);84
- $t['duration'] += round($r['avg_duration'] * $r['visits']);85
- $t['new_users'] += $r['new_users'];86
- }87
- // 区间 UV / 独立 IP:跨日必须去重(逐日相加会把同一访客 / IP 在多天里重复计数)。88
- // 与看板概览(StatsController::exactUniq)同一条 SQL,保证开放接口与面板数字一致。89
- $uniq = Db::first(90
- "SELECT COUNT(DISTINCT visitor_id) uv, COUNT(DISTINCT CASE WHEN ip<>'' THEN ip END) ip91
- FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",92
- [$sid, $start, $end]93
- ) ?: [];94
- $t['uv'] = (int) ($uniq['uv'] ?? 0);95
- $t['ip'] = (int) ($uniq['ip'] ?? 0);96
- $totals = [97
- 'pv' => $t['pv'], 'uv' => $t['uv'], 'ip' => $t['ip'],98
- 'visits' => $t['visits'],99
- 'bounce_rate' => $t['visits'] > 0 ? round($t['bounce'] / $t['visits'] * 100, 1) : 0.0,100
- 'avg_duration' => $t['visits'] > 0 ? round($t['duration'] / $t['visits']) : 0.0,101
- 'new_users' => $t['new_users'],102
- ];103
-104
- wstat_json([105
- 'site' => ['id' => $sid, 'name' => $site['name'], 'domain' => $site['domain']],106
- 'range' => ['start' => $start, 'end' => $end],107
- 'totals' => $totals,108
- 'daily' => $rows,109
- ]);110
- }111
-112
- /**113
- * GET /open/v1/pages?site_id=&start=&end=&limit=50114
- * 热门页面 TOP N(按 PV 降序,附 UV)。115
- */116
- public function pages(Request $req): void117
- {118
- [$site, $start, $end] = $this->site($req);119
- $limit = min(200, max(1, (int) $req->input('limit', 50)));120
- $items = [];121
- foreach (122
- Db::select(123
- 'SELECT ' . self::URL_PATH_EXPR . ' AS path,124
- SUM(type=\'pageview\') pv,125
- COUNT(DISTINCT CASE WHEN type=\'pageview\' THEN visitor_id END) uv126
- FROM events127
- WHERE site_id=? AND `day` BETWEEN ? AND ? AND type=\'pageview\'128
- GROUP BY path ORDER BY pv DESC LIMIT ' . $limit,129
- [(int) $site['id'], $start, $end]130
- ) as $r131
- ) {132
- $items[] = ['path' => (string) $r['path'], 'pv' => (int) $r['pv'], 'uv' => (int) $r['uv']];133
- }134
- wstat_json([135
- 'site' => ['id' => (int) $site['id'], 'name' => $site['name'], 'domain' => $site['domain']],136
- 'range' => ['start' => $start, 'end' => $end],137
- 'items' => $items,138
- ]);139
- }140
-141
- /**142
- * GET /open/v1/online?site_id=143
- * 当前在线访客数(5 分钟窗口,Redis zset)。144
- */145
- public function online(Request $req): void146
- {147
- [$site] = $this->site($req);148
- $sid = (int) $site['id'];149
- $now = time();150
- if (!\Wstat\Support\Rds::enabled()) {151
- // 无 Redis 模式:sessions 近 5 分钟活跃152
- $count = (int) Db::value(153
- 'SELECT COUNT(*) FROM sessions WHERE site_id=? AND end_ts>=? AND start_ts<=?',154
- [$sid, $now - 300, $now]155
- );156
- } else {157
- $count = (int) Rds::safe(function ($r) use ($sid, $now) {158
- return $r->zcount("on:$sid", (string) ($now - 300), (string) ($now + 1));159
- });160
- }161
- wstat_json([162
- 'site' => ['id' => $sid, 'name' => $site['name'], 'domain' => $site['domain']],163
- 'online' => $count,164
- 'time' => $now,165
- ]);166
- }167
-168
- /** PAT 鉴权 + 站点访问校验,返回 [站点行, start, end] */169
- private function site(Request $req): array170
- {171
- $u = PAT::user($req);172
- if ($u === null) {173
- http_response_code(401);174
- header('Content-Type: application/json; charset=utf-8');175
- echo json_encode(['error' => 'invalid_token', 'message' => '无效或过期的访问令牌'], JSON_UNESCAPED_UNICODE);176
- exit(0);177
- }178
- $sid = (int) $req->input('site_id', 0);179
- $site = Db::first(180
- 'SELECT id,name,domain,site_key,timezone FROM sites WHERE id=? AND status=1 LIMIT 1',181
- [$sid]182
- );183
- if ($site === null || SiteAccess::role((int) $u['id'], $sid) === null) {184
- wstat_err('站点不存在', 404, 404);185
- }186
- // 日期区间(站点时区本地日,默认近 30 天)187
- $offset = Util::tzOffsetSec((string) $site['timezone']);188
- $start = (string) $req->input('start', gmdate('Y-m-d', time() + $offset - 29 * 86400));189
- $end = (string) $req->input('end', gmdate('Y-m-d', time() + $offset));190
- if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {191
- wstat_err('日期格式应为 YYYY-MM-DD', 422);192
- }193
- if ($start > $end) {194
- [$start, $end] = [$end, $start];195
- }196
- return [$site, $start, $end];197
- }198
-}1
+<?php2
+/**3
+ * 开放 API(/open/v1/*):个人访问令牌(PAT)鉴权,只读统计白名单。4
+ *5
+ * 鉴权:Authorization: Bearer <64位token>(api_tokens 表,见 Support\PAT)。6
+ * 授权:令牌属主对该站点具备任意角色(owner/editor/viewer 均可只读)。7
+ * 限额:暂不启用硬性限流;last_used_at 节流刷新用于审计。8
+ */9
+declare(strict_types=1);10
+11
+namespace Wstat\Controllers;12
+13
+use Wstat\Http\Request;14
+use Wstat\Support\Db;15
+use Wstat\Support\PAT;16
+use Wstat\Support\Rds;17
+use Wstat\Support\SiteAccess;18
+use Wstat\Support\Util;19
+20
+class OpenController21
+{22
+ /** 与 StatsController::URL_PATH_EXPR 同款路径表达式(跨控制器私有,故此处独立维护) */23
+ private const URL_PATH_EXPR = "IF(LOCATE('//', url) > 0, IF(LOCATE('/', url, LOCATE('//', url) + 2) > 0, SUBSTRING(url, LOCATE('/', url, LOCATE('//', url) + 2)), '/'), url)";24
+25
+ /**26
+ * GET /open/v1/overview?site_id=&start=&end=27
+ * 概览:totals(pv/uv/ip/visits/bounce_rate/avg_duration/new_users)+ daily 逐日序列。28
+ * 口径与看板概览一致:events 按 day(站点时区本地日)、sessions 按站点本地日聚合。29
+ */30
+ public function overview(Request $req): void31
+ {32
+ [$site, $start, $end] = $this->site($req);33
+ [$startTs, $endTs] = Util::dateRangeToTs($start, $end, (string) $site['timezone']);34
+ $sid = (int) $site['id'];35
+ $offset = Util::tzOffsetSec((string) $site['timezone']);36
+37
+ $daily = [];38
+ foreach (Util::dailySeries($startTs, $endTs, (string) $site['timezone'], fn () => null) as $d => $_) {39
+ $daily[(string) $d] = ['day' => (string) $d, 'pv' => 0, 'uv' => 0, 'ip' => 0,40
+ 'visits' => 0, 'bounce_rate' => 0.0, 'avg_duration' => 0.0, 'new_users' => 0];41
+ }42
+43
+ foreach (44
+ Db::select(45
+ "SELECT `day`,46
+ SUM(type='pageview') pv,47
+ COUNT(DISTINCT CASE WHEN type='pageview' THEN visitor_id END) uv,48
+ COUNT(DISTINCT CASE WHEN type='pageview' AND ip<>'' THEN ip END) ipc49
+ FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",50
+ [$sid, $start, $end]51
+ ) as $r52
+ ) {53
+ $d = (string) $r['day'];54
+ if (isset($daily[$d])) {55
+ $daily[$d]['pv'] = (int) $r['pv'];56
+ $daily[$d]['uv'] = (int) $r['uv'];57
+ $daily[$d]['ip'] = (int) $r['ipc'];58
+ }59
+ }60
+ foreach (61
+ Db::select(62
+ "SELECT DATE(FROM_UNIXTIME(start_ts+?)) d, COUNT(*) c,63
+ COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du, COALESCE(SUM(is_new),0) nw64
+ FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",65
+ [$offset, $sid, $startTs, $endTs]66
+ ) as $r67
+ ) {68
+ $d = (string) $r['d'];69
+ if (isset($daily[$d])) {70
+ $c = (int) $r['c'];71
+ $daily[$d]['visits'] = $c;72
+ $daily[$d]['bounce_rate'] = $c > 0 ? round((int) $r['b'] / $c * 100, 1) : 0.0;73
+ $daily[$d]['avg_duration'] = $c > 0 ? round((int) $r['du'] / $c) : 0.0;74
+ $daily[$d]['new_users'] = (int) $r['nw'];75
+ }76
+ }77
+78
+ $rows = array_values($daily);79
+ $t = ['pv' => 0, 'uv' => 0, 'ip' => 0, 'visits' => 0, 'bounce' => 0, 'duration' => 0, 'new_users' => 0];80
+ foreach ($rows as $r) {81
+ $t['pv'] += $r['pv'];82
+ $t['visits'] += $r['visits'];83
+ $t['bounce'] += round($r['bounce_rate'] * $r['visits'] / 100);84
+ $t['duration'] += round($r['avg_duration'] * $r['visits']);85
+ $t['new_users'] += $r['new_users'];86
+ }87
+ // 区间 UV / 独立 IP:跨日必须去重(逐日相加会把同一访客 / IP 在多天里重复计数)。88
+ // 与看板概览(StatsController::exactUniq)同一条 SQL,保证开放接口与面板数字一致。89
+ $uniq = Db::first(90
+ "SELECT COUNT(DISTINCT visitor_id) uv, COUNT(DISTINCT CASE WHEN ip<>'' THEN ip END) ip91
+ FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ?",92
+ [$sid, $start, $end]93
+ ) ?: [];94
+ $t['uv'] = (int) ($uniq['uv'] ?? 0);95
+ $t['ip'] = (int) ($uniq['ip'] ?? 0);96
+ $totals = [97
+ 'pv' => $t['pv'], 'uv' => $t['uv'], 'ip' => $t['ip'],98
+ 'visits' => $t['visits'],99
+ 'bounce_rate' => $t['visits'] > 0 ? round($t['bounce'] / $t['visits'] * 100, 1) : 0.0,100
+ 'avg_duration' => $t['visits'] > 0 ? round($t['duration'] / $t['visits']) : 0.0,101
+ 'new_users' => $t['new_users'],102
+ ];103
+104
+ wstat_json([105
+ 'site' => ['id' => $sid, 'name' => $site['name'], 'domain' => $site['domain']],106
+ 'range' => ['start' => $start, 'end' => $end],107
+ 'totals' => $totals,108
+ 'daily' => $rows,109
+ ]);110
+ }111
+112
+ /**113
+ * GET /open/v1/pages?site_id=&start=&end=&limit=50114
+ * 热门页面 TOP N(按 PV 降序,附 UV)。115
+ */116
+ public function pages(Request $req): void117
+ {118
+ [$site, $start, $end] = $this->site($req);119
+ $limit = min(200, max(1, (int) $req->input('limit', 50)));120
+ $items = [];121
+ foreach (122
+ Db::select(123
+ 'SELECT ' . self::URL_PATH_EXPR . ' AS path,124
+ SUM(type=\'pageview\') pv,125
+ COUNT(DISTINCT CASE WHEN type=\'pageview\' THEN visitor_id END) uv126
+ FROM events127
+ WHERE site_id=? AND `day` BETWEEN ? AND ? AND type=\'pageview\'128
+ GROUP BY path ORDER BY pv DESC LIMIT ' . $limit,129
+ [(int) $site['id'], $start, $end]130
+ ) as $r131
+ ) {132
+ $items[] = ['path' => (string) $r['path'], 'pv' => (int) $r['pv'], 'uv' => (int) $r['uv']];133
+ }134
+ wstat_json([135
+ 'site' => ['id' => (int) $site['id'], 'name' => $site['name'], 'domain' => $site['domain']],136
+ 'range' => ['start' => $start, 'end' => $end],137
+ 'items' => $items,138
+ ]);139
+ }140
+141
+ /**142
+ * GET /open/v1/online?site_id=143
+ * 当前在线访客数(5 分钟窗口,Redis zset)。144
+ */145
+ public function online(Request $req): void146
+ {147
+ [$site] = $this->site($req);148
+ $sid = (int) $site['id'];149
+ $now = time();150
+ if (!\Wstat\Support\Rds::enabled()) {151
+ // 无 Redis 模式:sessions 近 5 分钟活跃152
+ $count = (int) Db::value(153
+ 'SELECT COUNT(*) FROM sessions WHERE site_id=? AND end_ts>=? AND start_ts<=?',154
+ [$sid, $now - 300, $now]155
+ );156
+ } else {157
+ $count = (int) Rds::safe(function ($r) use ($sid, $now) {158
+ return $r->zcount("on:$sid", (string) ($now - 300), (string) ($now + 1));159
+ });160
+ }161
+ wstat_json([162
+ 'site' => ['id' => $sid, 'name' => $site['name'], 'domain' => $site['domain']],163
+ 'online' => $count,164
+ 'time' => $now,165
+ ]);166
+ }167
+168
+ /** PAT 鉴权 + 站点访问校验,返回 [站点行, start, end] */169
+ private function site(Request $req): array170
+ {171
+ $u = PAT::user($req);172
+ if ($u === null) {173
+ http_response_code(401);174
+ header('Content-Type: application/json; charset=utf-8');175
+ echo json_encode(['error' => 'invalid_token', 'message' => '无效或过期的访问令牌'], JSON_UNESCAPED_UNICODE);176
+ exit(0);177
+ }178
+ $sid = (int) $req->input('site_id', 0);179
+ $site = Db::first(180
+ 'SELECT id,name,domain,site_key,timezone FROM sites WHERE id=? AND status=1 LIMIT 1',181
+ [$sid]182
+ );183
+ if ($site === null || SiteAccess::role((int) $u['id'], $sid) === null) {184
+ wstat_err('站点不存在', 404, 404);185
+ }186
+ // 日期区间(站点时区本地日,默认近 30 天)187
+ $offset = Util::tzOffsetSec((string) $site['timezone']);188
+ $start = (string) $req->input('start', gmdate('Y-m-d', time() + $offset - 29 * 86400));189
+ $end = (string) $req->input('end', gmdate('Y-m-d', time() + $offset));190
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {191
+ wstat_err('日期格式应为 YYYY-MM-DD', 422);192
+ }193
+ if ($start > $end) {194
+ [$start, $end] = [$end, $start];195
+ }196
+ return [$site, $start, $end];197
+ }198
+}app/Controllers/ProfileController.php+143-143
@@ -1,143 +1,143 @@1
-<?php2
-/**3
- * 个人中心:个人资料查看/修改、修改密码、我的操作日志4
- *5
- * 设计要点:6
- * - 邮箱**只读**:它是登录凭证且唯一,变更涉及邮箱验证与通知,暂不开放(界面明确标注);7
- * - 修改密码成功后吊销全部旧会话并下发新 token(与找回密码一致的安全语义),8
- * 当前设备的会话换新令牌继续有效,其它设备立即失效;9
- * - 所有变更动作写入 user_op_logs(表缺失时降级,见 Support\OpLog)。10
- */11
-declare(strict_types=1);12
-13
-namespace Wstat\Controllers;14
-15
-use Wstat\Http\Request;16
-use Wstat\Support\Auth;17
-use Wstat\Support\Db;18
-use Wstat\Support\OpLog;19
-use Wstat\Support\Settings;20
-21
-class ProfileController22
-{23
- /** GET /api/profile —— 当前用户资料 */24
- public function show(Request $req): void25
- {26
- $u = Auth::requireUser($req);27
- wstat_json(self::data((int) $u['id']));28
- }29
-30
- /** PATCH /api/profile —— 修改昵称 / 界面语言 */31
- public function update(Request $req): void32
- {33
- $u = Auth::requireUser($req);34
- $id = (int) $u['id'];35
-36
- $upd = [];37
- $changed = [];38
- if ($req->input('nickname') !== null) {39
- $nick = trim((string) $req->input('nickname'));40
- if (mb_strlen($nick) > 30) {41
- wstat_err('昵称过长(最多 30 字)', 422);42
- }43
- $upd['nickname'] = $nick;44
- $changed[] = '昵称';45
- }46
- if ($req->input('lang') !== null) {47
- $lang = (string) $req->input('lang');48
- if (!in_array($lang, ['zh-CN', 'en-US'], true)) {49
- wstat_err('界面语言仅支持 zh-CN / en-US', 422);50
- }51
- $upd['lang'] = $lang;52
- $changed[] = '界面语言';53
- }54
- if ($upd === []) {55
- wstat_err('没有需要修改的字段', 422);56
- }57
- $upd['updated_at'] = time();58
-59
- $sets = implode(',', array_map(static fn (string $k): string => '`' . $k . '`=?', array_keys($upd)));60
- Db::execute(61
- 'UPDATE users SET ' . $sets . ' WHERE id=?',62
- array_merge(array_values($upd), [$id])63
- );64
- OpLog::log($id, 'profile_update', '修改了 ' . implode('、', $changed));65
- wstat_json(self::data($id));66
- }67
-68
- /**69
- * POST /api/profile/password —— 修改密码。70
- * body: {old_password, new_password}71
- * 成功返回新 token(当前会话无缝续期);其它设备全部吊销。72
- */73
- public function password(Request $req): void74
- {75
- $u = Auth::requireUser($req);76
- $id = (int) $u['id'];77
- $old = (string) $req->input('old_password', '');78
- $new = (string) $req->input('new_password', '');79
- $min = (int) wstat_config('security.pwd_min');80
-81
- $row = Db::first('SELECT password FROM users WHERE id=? LIMIT 1', [$id]);82
- if ($row === null) {83
- wstat_err('账号不存在', 404, 404);84
- }85
- if (!password_verify($old, (string) $row['password'])) {86
- wstat_err('当前密码不正确', 422, 4224);87
- }88
- if (strlen($new) < $min || strlen($new) > 72) {89
- wstat_err('新密码长度需 ' . $min . '-72 位', 422);90
- }91
- if ($new === $old) {92
- wstat_err('新密码不能与当前密码相同', 422);93
- }94
-95
- Db::execute(96
- 'UPDATE users SET password=?, updated_at=? WHERE id=?',97
- [password_hash($new, PASSWORD_DEFAULT), time(), $id]98
- );99
- $revoked = Auth::revokeAll($id);100
- $token = Auth::issue($id);101
- OpLog::log($id, 'password_change', '修改密码(吊销旧会话 ' . $revoked . ' 个,当前设备已换新令牌)');102
- wstat_json(['ok' => true, 'token' => $token, 'revoked' => $revoked]);103
- }104
-105
- /** GET /api/profile/logs?page=&page_size= —— 我的操作日志 */106
- public function logs(Request $req): void107
- {108
- $u = Auth::requireUser($req);109
- $page = max(1, (int) $req->input('page', 1));110
- $size = (int) $req->input('page_size', 20);111
- wstat_json(OpLog::page((int) $u['id'], $page, $size));112
- }113
-114
- /** 资料数组(不含 password);附带名下/被授权站点计数 */115
- private static function data(int $userId): array116
- {117
- $row = Db::first(118
- 'SELECT id,email,nickname,lang,status,created_at,updated_at FROM users WHERE id=? LIMIT 1',119
- [$userId]120
- );121
- if ($row === null) {122
- wstat_err('账号不存在', 404, 404);123
- }124
- $row['is_admin'] = Settings::isAdmin($userId);125
- $row['sites_owned'] = (int) Db::value('SELECT COUNT(*) FROM sites WHERE user_id=?', [$userId]);126
- $row['sites_shared'] = 0;127
- try {128
- if ((int) Db::value(129
- 'SELECT COUNT(*) FROM information_schema.TABLES130
- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?',131
- ['site_members']132
- ) > 0) {133
- $row['sites_shared'] = (int) Db::value(134
- 'SELECT COUNT(*) FROM site_members WHERE user_id=?',135
- [$userId]136
- );137
- }138
- } catch (\Throwable $e) {139
- $row['sites_shared'] = 0;140
- }141
- return $row;142
- }143
-}1
+<?php2
+/**3
+ * 个人中心:个人资料查看/修改、修改密码、我的操作日志4
+ *5
+ * 设计要点:6
+ * - 邮箱**只读**:它是登录凭证且唯一,变更涉及邮箱验证与通知,暂不开放(界面明确标注);7
+ * - 修改密码成功后吊销全部旧会话并下发新 token(与找回密码一致的安全语义),8
+ * 当前设备的会话换新令牌继续有效,其它设备立即失效;9
+ * - 所有变更动作写入 user_op_logs(表缺失时降级,见 Support\OpLog)。10
+ */11
+declare(strict_types=1);12
+13
+namespace Wstat\Controllers;14
+15
+use Wstat\Http\Request;16
+use Wstat\Support\Auth;17
+use Wstat\Support\Db;18
+use Wstat\Support\OpLog;19
+use Wstat\Support\Settings;20
+21
+class ProfileController22
+{23
+ /** GET /api/profile —— 当前用户资料 */24
+ public function show(Request $req): void25
+ {26
+ $u = Auth::requireUser($req);27
+ wstat_json(self::data((int) $u['id']));28
+ }29
+30
+ /** PATCH /api/profile —— 修改昵称 / 界面语言 */31
+ public function update(Request $req): void32
+ {33
+ $u = Auth::requireUser($req);34
+ $id = (int) $u['id'];35
+36
+ $upd = [];37
+ $changed = [];38
+ if ($req->input('nickname') !== null) {39
+ $nick = trim((string) $req->input('nickname'));40
+ if (mb_strlen($nick) > 30) {41
+ wstat_err('昵称过长(最多 30 字)', 422);42
+ }43
+ $upd['nickname'] = $nick;44
+ $changed[] = '昵称';45
+ }46
+ if ($req->input('lang') !== null) {47
+ $lang = (string) $req->input('lang');48
+ if (!in_array($lang, ['zh-CN', 'en-US'], true)) {49
+ wstat_err('界面语言仅支持 zh-CN / en-US', 422);50
+ }51
+ $upd['lang'] = $lang;52
+ $changed[] = '界面语言';53
+ }54
+ if ($upd === []) {55
+ wstat_err('没有需要修改的字段', 422);56
+ }57
+ $upd['updated_at'] = time();58
+59
+ $sets = implode(',', array_map(static fn (string $k): string => '`' . $k . '`=?', array_keys($upd)));60
+ Db::execute(61
+ 'UPDATE users SET ' . $sets . ' WHERE id=?',62
+ array_merge(array_values($upd), [$id])63
+ );64
+ OpLog::log($id, 'profile_update', '修改了 ' . implode('、', $changed));65
+ wstat_json(self::data($id));66
+ }67
+68
+ /**69
+ * POST /api/profile/password —— 修改密码。70
+ * body: {old_password, new_password}71
+ * 成功返回新 token(当前会话无缝续期);其它设备全部吊销。72
+ */73
+ public function password(Request $req): void74
+ {75
+ $u = Auth::requireUser($req);76
+ $id = (int) $u['id'];77
+ $old = (string) $req->input('old_password', '');78
+ $new = (string) $req->input('new_password', '');79
+ $min = (int) wstat_config('security.pwd_min');80
+81
+ $row = Db::first('SELECT password FROM users WHERE id=? LIMIT 1', [$id]);82
+ if ($row === null) {83
+ wstat_err('账号不存在', 404, 404);84
+ }85
+ if (!password_verify($old, (string) $row['password'])) {86
+ wstat_err('当前密码不正确', 422, 4224);87
+ }88
+ if (strlen($new) < $min || strlen($new) > 72) {89
+ wstat_err('新密码长度需 ' . $min . '-72 位', 422);90
+ }91
+ if ($new === $old) {92
+ wstat_err('新密码不能与当前密码相同', 422);93
+ }94
+95
+ Db::execute(96
+ 'UPDATE users SET password=?, updated_at=? WHERE id=?',97
+ [password_hash($new, PASSWORD_DEFAULT), time(), $id]98
+ );99
+ $revoked = Auth::revokeAll($id);100
+ $token = Auth::issue($id);101
+ OpLog::log($id, 'password_change', '修改密码(吊销旧会话 ' . $revoked . ' 个,当前设备已换新令牌)');102
+ wstat_json(['ok' => true, 'token' => $token, 'revoked' => $revoked]);103
+ }104
+105
+ /** GET /api/profile/logs?page=&page_size= —— 我的操作日志 */106
+ public function logs(Request $req): void107
+ {108
+ $u = Auth::requireUser($req);109
+ $page = max(1, (int) $req->input('page', 1));110
+ $size = (int) $req->input('page_size', 20);111
+ wstat_json(OpLog::page((int) $u['id'], $page, $size));112
+ }113
+114
+ /** 资料数组(不含 password);附带名下/被授权站点计数 */115
+ private static function data(int $userId): array116
+ {117
+ $row = Db::first(118
+ 'SELECT id,email,nickname,lang,status,created_at,updated_at FROM users WHERE id=? LIMIT 1',119
+ [$userId]120
+ );121
+ if ($row === null) {122
+ wstat_err('账号不存在', 404, 404);123
+ }124
+ $row['is_admin'] = Settings::isAdmin($userId);125
+ $row['sites_owned'] = (int) Db::value('SELECT COUNT(*) FROM sites WHERE user_id=?', [$userId]);126
+ $row['sites_shared'] = 0;127
+ try {128
+ if ((int) Db::value(129
+ 'SELECT COUNT(*) FROM information_schema.TABLES130
+ WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?',131
+ ['site_members']132
+ ) > 0) {133
+ $row['sites_shared'] = (int) Db::value(134
+ 'SELECT COUNT(*) FROM site_members WHERE user_id=?',135
+ [$userId]136
+ );137
+ }138
+ } catch (\Throwable $e) {139
+ $row['sites_shared'] = 0;140
+ }141
+ return $row;142
+ }143
+}app/Controllers/SettingController.php+472-319
@@ -1,319 +1,472 @@1
-<?php2
-/**3
- * 系统设置控制器(仅系统管理员)。4
- *5
- * GET /api/settings 读取全部设置(smtp_password 掩码回显)6
- * PATCH /api/settings 保存设置(白名单键;密码留空/掩码则沿用旧值)7
- * GET /api/sdk-code 站点接入代码模板(任意登录用户;供 Sites 页渲染接入代码)8
- * POST /api/settings/email-test 发送测试邮件(body: {to})9
- * GET /api/settings/version 当前版本 + 远端在线更新检查10
- * POST /api/settings/update 一键升级到最新版本(下载地址由服务端决定,不接受客户端入参)11
- * GET /api/settings/ip-check 真实 IP 采集自检(列出各候选来源的实际取值,供管理员手动指定来源)12
- */13
-declare(strict_types=1);14
-15
-namespace Wstat\Controllers;16
-17
-use Wstat\Http\Request;18
-use Wstat\Support\Auth;19
-use Wstat\Support\IpLocator;20
-use Wstat\Support\Mailer;21
-use Wstat\Support\Settings;22
-use Wstat\Support\Updater;23
-use Wstat\Support\Util;24
-use Wstat\Support\Verify;25
-26
-class SettingController27
-{28
- /** GET /api/settings */29
- public function show(Request $req): void30
- {31
- Settings::requireAdmin($req);32
- $all = Settings::all();33
- if ((string) $all['smtp_password'] !== '') {34
- $all['smtp_password'] = '******';35
- }36
- if ((string) $all['screen_password'] !== '') {37
- $all['screen_password'] = '******';38
- }39
- // 附加运行时状态(非持久化设置):注册邮箱验证码依赖图形验证码 → 依赖 GD 扩展40
- $all['captcha_ready'] = Verify::available();41
- // 内置接入代码模板(前端「恢复默认」「预览」用;自定义内容存在 sdk_snippet)42
- $all['sdk_snippet_default'] = Settings::SDK_SNIPPET_DEFAULT;43
- $all['sdk_snippet_custom'] = Settings::sdkSnippetIsCustom();44
- wstat_json($all);45
- }46
-47
- /**48
- * GET /api/sdk-code —— 「站点接入代码」模板。49
- *50
- * 刻意**不是** admin 接口:Sites 页的「查看接入代码」是所有站点成员(owner/editor/viewer)51
- * 都要用的功能,而模板内容本身就是给访客网站粘贴的代码,不构成敏感信息。52
- * 返回模板原文(含 {site_key} / {host} 占位符),由前端替换为具体站点信息。53
- */54
- public function sdkCode(Request $req): void55
- {56
- Auth::requireUser($req);57
- wstat_json([58
- 'template' => Settings::sdkSnippetTemplate(),59
- 'custom' => Settings::sdkSnippetIsCustom(),60
- 'default' => Settings::SDK_SNIPPET_DEFAULT,61
- ]);62
- }63
-64
- /**65
- * GET /api/settings/ip-check —— 「真实 IP 采集」自检。66
- *67
- * **取值来源完全由管理员手动指定**(见 `Util::IP_SOURCES`),本接口不做任何自动判定,68
- * 只把事实摆出来:每个候选来源在**本次请求**里会取到什么值、当前生效的是哪一个、69
- * 以及当前配置会出问题的两处(套了 CDN 却选 REMOTE_ADDR / 选了可被客户端伪造的头)。70
- *71
- * 返回的是本次请求的真实环境,所以「点一下检测」就等于用管理员自己的浏览器做一次采样 ——72
- * 选一个能显示自己真实地址的来源即可,不需要理解任何代理链原理。73
- */74
- public function ipCheck(Request $req): void75
- {76
- Settings::requireAdmin($req);77
- $t = Util::ipTrace();78
- $ip = (string) $t['ip'];79
-80
- $geo = $ip !== '' ? IpLocator::resolve($ip) : ['country' => '', 'province' => '', 'city' => ''];81
- $geoOk = ($geo['country'] ?? '') !== '' || ($geo['province'] ?? '') !== '' || ($geo['city'] ?? '') !== '';82
- $isPublic = $ip !== '' && Util::isPublicIp($ip);83
- $isIpv6 = $ip !== '' && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;84
-85
- $advice = [];86
- if ($t['fallback']) {87
- $advice[] = '所选的 ' . $t['header'] . ' 在本次请求里没有合法 IP,已回落 REMOTE_ADDR。'88
- . '若访客 IP 显示为 CDN / 反代节点地址,请改选其它来源。';89
- }90
- if ($t['source_key'] === 'remote_addr' && $t['remote_cdn'] !== '') {91
- $advice[] = 'REMOTE_ADDR(' . $t['remote'] . ')属于 ' . $t['remote_cdn'] . ' 回源网段 → '92
- . '当前记录的是 CDN 节点地址,不是访客地址。请改选 X-Forwarded-For;'93
- . '若该头也被 CDN 覆盖为节点地址,请选「自定义头」并填 CF-Connecting-IP。';94
- } elseif ($t['source_key'] === 'remote_addr' && $t['remote'] !== '') {95
- $advice[] = 'REMOTE_ADDR 即 TCP 对端地址,客户端无法伪造 —— 这是最安全的取值方式,'96
- . '适用于「源站直接对外、前面没有 CDN / 反向代理」的部署。';97
- }98
- if ($t['spoofable']) {99
- $advice[] = '注意:' . $t['header'] . ' 由客户端可自行设置,只有在 CDN / 反向代理确实覆盖写入它时才可信;'100
- . '源站若能被绕过 CDN 直接访问,任何人都能伪造该头来污染统计。';101
- }102
- if ($t['source_key'] === 'custom' && $t['header'] === '') {103
- $advice[] = '已选择「自定义头」但头名称为空 → 等同于取值失败回落 REMOTE_ADDR,请填写头名称。';104
- }105
- if ($ip === '') {106
- $advice[] = '本次请求没有可用的 IP(CLI 或异常网关),属于非 HTTP 上下文,可忽略。';107
- } elseif (!$isPublic) {108
- $advice[] = '判定出的地址是内网 / 保留地址:多为「管理员在本机访问面板」,或反向代理未透传真实 IP。';109
- }110
- if ($ip !== '' && $isPublic && !$geoOk) {111
- // 按地址族分别给原因:v4 / v6 是两个库、两条独立的排查路径112
- $advice[] = $isIpv6113
- ? '该访客是 IPv6,但 IPv6 离线库没解析出地域:请确认 data/ip2region_v6.xdb 存在'114
- . '(可跑 php scripts/fetch-geo.php 补齐;v6 库缺失只影响 IPv6 访客,不影响 IPv4)。'115
- : '未能解析出地域:请确认 data/ip2region.xdb 存在,且 collect.geo_driver 不是 none。';116
- }117
-118
- wstat_json([119
- 'ip' => $ip,120
- 'source' => $t['source'],121
- 'source_key' => $t['source_key'],122
- 'header' => $t['header'],123
- 'header_raw' => $t['raw'],124
- 'remote' => $t['remote'],125
- 'remote_cdn' => $t['remote_cdn'],126
- 'remote_private' => $t['remote_private'],127
- 'fallback' => $t['fallback'],128
- 'spoofable' => $t['spoofable'],129
- 'note' => $t['note'],130
- 'candidates' => $t['candidates'],131
- 'geo' => $geo,132
- 'geo_ok' => $geoOk,133
- 'is_public' => $isPublic,134
- 'is_ipv6' => $isIpv6,135
- 'advice' => $advice,136
- 'warn' => $t['warn'],137
- ]);138
- }139
-140
- /** PATCH /api/settings */141
- public function update(Request $req): void142
- {143
- Settings::requireAdmin($req);144
-145
- $kv = [];146
- // 开关:仅接受 0/1147
- foreach (['registration_enabled', 'collect_enabled', 'alert_enabled', 'bot_filter_enabled', 'email_verify_enabled'] as $k) {148
- if ($req->input($k) !== null) {149
- $kv[$k] = (int) (bool) $req->input($k);150
- }151
- }152
- // SMTP 文本项153
- foreach (['smtp_host', 'smtp_username', 'smtp_from_name'] as $k) {154
- if ($req->input($k) !== null) {155
- $kv[$k] = trim((string) $req->input($k));156
- if (mb_strlen($kv[$k]) > 190) {157
- wstat_err($k . ' 过长', 422);158
- }159
- }160
- }161
- // 端口162
- if ($req->input('smtp_port') !== null) {163
- $port = (int) $req->input('smtp_port');164
- if ($port < 1 || $port > 65535) {165
- wstat_err('SMTP 端口需在 1-65535', 422);166
- }167
- $kv['smtp_port'] = $port;168
- }169
- // 加密方式170
- if ($req->input('smtp_secure') !== null) {171
- $sec = strtolower(trim((string) $req->input('smtp_secure')));172
- if (!in_array($sec, ['ssl', 'tls', 'none'], true)) {173
- wstat_err('加密方式仅支持 ssl / tls / none', 422);174
- }175
- $kv['smtp_secure'] = $sec;176
- }177
- // 发件人地址178
- if ($req->input('smtp_from_email') !== null) {179
- $e = trim((string) $req->input('smtp_from_email'));180
- if ($e !== '' && !Util::validEmail($e)) {181
- wstat_err('发件人邮箱格式不正确', 422);182
- }183
- $kv['smtp_from_email'] = $e;184
- }185
- // 密码:留空或全掩码则沿用旧值186
- if ($req->input('smtp_password') !== null) {187
- $pwd = (string) $req->input('smtp_password');188
- if (trim($pwd) !== '' && preg_match('/^\*+$/', trim($pwd)) !== 1) {189
- $kv['smtp_password'] = trim($pwd);190
- }191
- }192
- // 大屏访问密码:全掩码沿用旧值;其余(含空串)原样保存 —— 空串=关闭密码保护193
- if ($req->input('screen_password') !== null) {194
- $sp = trim((string) $req->input('screen_password'));195
- if (preg_match('/^\*+$/', $sp) !== 1) {196
- if (mb_strlen($sp) > 64) {197
- wstat_err('大屏访问密码过长(≤64 字符)', 422);198
- }199
- $kv['screen_password'] = $sp;200
- }201
- }202
- // 站点接入代码模板:多行代码;空串=用内置默认(占位符 {site_key} / {host})203
- if ($req->input('sdk_snippet') !== null) {204
- $snip = trim((string) $req->input('sdk_snippet'));205
- if (mb_strlen($snip) > Settings::SDK_SNIPPET_MAX) {206
- wstat_err('统计代码过长(≤' . Settings::SDK_SNIPPET_MAX . ' 字符)', 422);207
- }208
- $kv['sdk_snippet'] = $snip;209
- }210
- // 外部代码(第三方统计 / 在线客服,注入本站页面 </head> 前):空串=不注入211
- if ($req->input('inject_code') !== null) {212
- $ic = trim((string) $req->input('inject_code'));213
- if (mb_strlen($ic) > Settings::INJECT_CODE_MAX) {214
- wstat_err('外部代码过长(≤' . Settings::INJECT_CODE_MAX . ' 字符)', 422);215
- }216
- $kv['inject_code'] = $ic;217
- }218
-219
- // 访客 IP 取值来源:**手动指定**,程序不做任何自动判断(校验枚举 + 自定义头名)220
- if ($req->input('ip_source') !== null) {221
- $src = strtolower(trim((string) $req->input('ip_source')));222
- if (!in_array($src, Util::IP_SOURCES, true)) {223
- wstat_err('IP 取值来源仅支持 ' . implode(' / ', Util::IP_SOURCES), 422);224
- }225
- if ($src === 'custom' && Util::normalizeHeaderName((string) $req->input('ip_source_header')) === '') {226
- wstat_err('选择「自定义头」时必须填写合法的请求头名称(仅字母、数字、连字符)', 422);227
- }228
- $kv['ip_source'] = $src;229
- }230
- if ($req->input('ip_source_header') !== null) {231
- $hdr = trim((string) $req->input('ip_source_header'));232
- if ($hdr !== '' && Util::normalizeHeaderName($hdr) === '') {233
- wstat_err('请求头名称不合法(仅允许字母、数字、连字符,长度 ≤64)', 422);234
- }235
- $kv['ip_source_header'] = $hdr;236
- }237
-238
- if (!$kv) {239
- wstat_err('没有需要保存的设置', 422);240
- }241
- $n = Settings::set($kv);242
- wstat_json(['saved' => $n]);243
- }244
-245
- /**246
- * GET /api/settings/version —— 当前版本 + 远端更新检查。247
- * 远端地址取 config.php 的 update_check_url(默认 https://wstats-update.kfw.cc/update.php,248
- * 环境变量 WSTAT_UPDATE_URL 可覆盖);拉取失败不影响本接口返回当前版本。249
- */250
- public function version(Request $req): void251
- {252
- Settings::requireAdmin($req);253
- $r = Updater::check();254
- wstat_json([255
- 'version' => $r['version'],256
- 'latest' => $r['latest'],257
- 'has_update' => $r['has_update'],258
- 'msg' => $r['reason'],259
- 'reason' => $r['reason'],260
- 'can_auto' => $r['can_auto'],261
- 'requires_full' => $r['requires_full'],262
- 'layout' => $r['layout'],263
- 'check_url' => $r['check_url'], // 本机实际使用的更新服务地址(排错用)264
- 'changelog' => $r['changelog'],265
- 'notes' => $r['notes'],266
- 'released_at' => $r['released_at'],267
- 'update_url' => $r['update_url'],268
- 'update_size' => $r['update_size'],269
- 'full_url' => $r['full_url'],270
- 'full_size' => $r['full_size'],271
- 'php_ok' => $r['php_ok'],272
- 'update_enabled' => $r['update_enabled'],273
- 'backups' => Updater::backups(),274
- ]);275
- }276
-277
- /**278
- * POST /api/settings/update —— 一键升级到最新版本。279
- *280
- * 安全:**不接受客户端传入的下载地址**,一律由服务端重新执行 check() 取回官方地址,281
- * 否则面板就成了「可写任意文件的下载器」。升级前的备份、校验、回滚见 Support\Updater。282
- */283
- public function applyUpdate(Request $req): void284
- {285
- Settings::requireAdmin($req);286
- $chk = Updater::check();287
- if (!$chk['ok']) {288
- wstat_err($chk['reason'] !== '' ? $chk['reason'] : '无法连接更新服务', 502);289
- }290
- if (!$chk['has_update']) {291
- wstat_err('已是最新版本(v' . $chk['version'] . ')', 422);292
- }293
- if (!$chk['can_auto']) {294
- wstat_err($chk['reason'], 422);295
- }296
- try {297
- $r = Updater::apply($chk['update_url'], $chk['update_sha256']);298
- } catch (\Throwable $e) {299
- wstat_err('升级失败:' . $e->getMessage(), 500);300
- }301
- wstat_json($r);302
- }303
-304
- /** POST /api/settings/email-test */305
- public function emailTest(Request $req): void306
- {307
- Settings::requireAdmin($req);308
- $to = trim((string) $req->input('to', ''));309
- if (!Util::validEmail($to)) {310
- wstat_err('收件邮箱格式不正确', 422);311
- }312
- $host = trim(Settings::get('smtp_host'));313
- $via = $host !== '' ? ('SMTP ' . $host) : 'PHP mail()';314
- [$ok, $msg] = Mailer::send([$to], 'WebStats 测试邮件', '这是一封来自 WebStats 的测试邮件。'315
- . "\n" . '发送方式: ' . $via316
- . "\n" . '时间: ' . date('Y-m-d H:i:s') . "\n");317
- wstat_json(['ok' => $ok, 'via' => $via, 'msg' => $msg]);318
- }319
-}1
+<?php2
+/**3
+ * 系统设置控制器(仅系统管理员)。4
+ *5
+ * GET /api/settings 读取全部设置(smtp_password 掩码回显)6
+ * PATCH /api/settings 保存设置(白名单键;密码留空/掩码则沿用旧值)7
+ * GET /api/sdk-code 站点接入代码模板(任意登录用户;供 Sites 页渲染接入代码)8
+ * POST /api/settings/email-test 发送测试邮件(body: {to})9
+ * GET /api/settings/version 当前版本 + 远端在线更新检查10
+ * POST /api/settings/update 一键升级到最新版本(下载地址由服务端决定,不接受客户端入参)11
+ * GET /api/settings/ip-check 真实 IP 采集自检(列出各候选来源的实际取值,供管理员手动指定来源)12
+ * POST /api/settings/brand 上传 / 复位品牌图(multipart:logo / icon 文件;或 reset=logo|icon)13
+ */14
+declare(strict_types=1);15
+16
+namespace Wstat\Controllers;17
+18
+use Wstat\Http\Request;19
+use Wstat\Support\Auth;20
+use Wstat\Support\IpLocator;21
+use Wstat\Support\Mailer;22
+use Wstat\Support\Settings;23
+use Wstat\Support\Updater;24
+use Wstat\Support\Util;25
+use Wstat\Support\Verify;26
+27
+class SettingController28
+{29
+ /** GET /api/settings */30
+ public function show(Request $req): void31
+ {32
+ Settings::requireAdmin($req);33
+ $all = Settings::all();34
+ if ((string) $all['smtp_password'] !== '') {35
+ $all['smtp_password'] = '******';36
+ }37
+ if ((string) $all['screen_password'] !== '') {38
+ $all['screen_password'] = '******';39
+ }40
+ // 附加运行时状态(非持久化设置):注册邮箱验证码依赖图形验证码 → 依赖 GD 扩展41
+ $all['captcha_ready'] = Verify::available();42
+ // 内置接入代码模板(前端「恢复默认」「预览」用;自定义内容存在 sdk_snippet)43
+ $all['sdk_snippet_default'] = Settings::SDK_SNIPPET_DEFAULT;44
+ $all['sdk_snippet_custom'] = Settings::sdkSnippetIsCustom();45
+ wstat_json($all);46
+ }47
+48
+ /**49
+ * GET /api/sdk-code —— 「站点接入代码」模板。50
+ *51
+ * 刻意**不是** admin 接口:Sites 页的「查看接入代码」是所有站点成员(owner/editor/viewer)52
+ * 都要用的功能,而模板内容本身就是给访客网站粘贴的代码,不构成敏感信息。53
+ * 返回模板原文(含 {site_key} / {host} 占位符),由前端替换为具体站点信息。54
+ */55
+ public function sdkCode(Request $req): void56
+ {57
+ Auth::requireUser($req);58
+ wstat_json([59
+ 'template' => Settings::sdkSnippetTemplate(),60
+ 'custom' => Settings::sdkSnippetIsCustom(),61
+ 'default' => Settings::SDK_SNIPPET_DEFAULT,62
+ ]);63
+ }64
+65
+ /**66
+ * GET /api/settings/ip-check —— 「真实 IP 采集」自检。67
+ *68
+ * **取值来源完全由管理员手动指定**(见 `Util::IP_SOURCES`),本接口不做任何自动判定,69
+ * 只把事实摆出来:每个候选来源在**本次请求**里会取到什么值、当前生效的是哪一个、70
+ * 以及当前配置会出问题的两处(套了 CDN 却选 REMOTE_ADDR / 选了可被客户端伪造的头)。71
+ *72
+ * 返回的是本次请求的真实环境,所以「点一下检测」就等于用管理员自己的浏览器做一次采样 ——73
+ * 选一个能显示自己真实地址的来源即可,不需要理解任何代理链原理。74
+ */75
+ public function ipCheck(Request $req): void76
+ {77
+ Settings::requireAdmin($req);78
+ $t = Util::ipTrace();79
+ $ip = (string) $t['ip'];80
+81
+ $geo = $ip !== '' ? IpLocator::resolve($ip) : ['country' => '', 'province' => '', 'city' => ''];82
+ $geoOk = ($geo['country'] ?? '') !== '' || ($geo['province'] ?? '') !== '' || ($geo['city'] ?? '') !== '';83
+ $isPublic = $ip !== '' && Util::isPublicIp($ip);84
+ $isIpv6 = $ip !== '' && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;85
+86
+ $advice = [];87
+ if ($t['fallback']) {88
+ $advice[] = '所选的 ' . $t['header'] . ' 在本次请求里没有合法 IP,已回落 REMOTE_ADDR。'89
+ . '若访客 IP 显示为 CDN / 反代节点地址,请改选其它来源。';90
+ }91
+ if ($t['source_key'] === 'remote_addr' && $t['remote_cdn'] !== '') {92
+ $advice[] = 'REMOTE_ADDR(' . $t['remote'] . ')属于 ' . $t['remote_cdn'] . ' 回源网段 → '93
+ . '当前记录的是 CDN 节点地址,不是访客地址。请改选 X-Forwarded-For;'94
+ . '若该头也被 CDN 覆盖为节点地址,请选「自定义头」并填 CF-Connecting-IP。';95
+ } elseif ($t['source_key'] === 'remote_addr' && $t['remote'] !== '') {96
+ $advice[] = 'REMOTE_ADDR 即 TCP 对端地址,客户端无法伪造 —— 这是最安全的取值方式,'97
+ . '适用于「源站直接对外、前面没有 CDN / 反向代理」的部署。';98
+ }99
+ if ($t['spoofable']) {100
+ $advice[] = '注意:' . $t['header'] . ' 由客户端可自行设置,只有在 CDN / 反向代理确实覆盖写入它时才可信;'101
+ . '源站若能被绕过 CDN 直接访问,任何人都能伪造该头来污染统计。';102
+ }103
+ if ($t['source_key'] === 'custom' && $t['header'] === '') {104
+ $advice[] = '已选择「自定义头」但头名称为空 → 等同于取值失败回落 REMOTE_ADDR,请填写头名称。';105
+ }106
+ if ($ip === '') {107
+ $advice[] = '本次请求没有可用的 IP(CLI 或异常网关),属于非 HTTP 上下文,可忽略。';108
+ } elseif (!$isPublic) {109
+ $advice[] = '判定出的地址是内网 / 保留地址:多为「管理员在本机访问面板」,或反向代理未透传真实 IP。';110
+ }111
+ if ($ip !== '' && $isPublic && !$geoOk) {112
+ // 按地址族分别给原因:v4 / v6 是两个库、两条独立的排查路径113
+ $advice[] = $isIpv6114
+ ? '该访客是 IPv6,但 IPv6 离线库没解析出地域:请确认 data/ip2region_v6.xdb 存在'115
+ . '(可跑 php scripts/fetch-geo.php 补齐;v6 库缺失只影响 IPv6 访客,不影响 IPv4)。'116
+ : '未能解析出地域:请确认 data/ip2region.xdb 存在,且 collect.geo_driver 不是 none。';117
+ }118
+119
+ wstat_json([120
+ 'ip' => $ip,121
+ 'source' => $t['source'],122
+ 'source_key' => $t['source_key'],123
+ 'header' => $t['header'],124
+ 'header_raw' => $t['raw'],125
+ 'remote' => $t['remote'],126
+ 'remote_cdn' => $t['remote_cdn'],127
+ 'remote_private' => $t['remote_private'],128
+ 'fallback' => $t['fallback'],129
+ 'spoofable' => $t['spoofable'],130
+ 'note' => $t['note'],131
+ 'candidates' => $t['candidates'],132
+ 'geo' => $geo,133
+ 'geo_ok' => $geoOk,134
+ 'is_public' => $isPublic,135
+ 'is_ipv6' => $isIpv6,136
+ 'advice' => $advice,137
+ 'warn' => $t['warn'],138
+ ]);139
+ }140
+141
+ /** PATCH /api/settings */142
+ public function update(Request $req): void143
+ {144
+ Settings::requireAdmin($req);145
+146
+ $kv = [];147
+ // 开关:仅接受 0/1148
+ foreach (['registration_enabled', 'collect_enabled', 'alert_enabled', 'bot_filter_enabled', 'email_verify_enabled', 'spider_enabled'] as $k) {149
+ if ($req->input($k) !== null) {150
+ $kv[$k] = (int) (bool) $req->input($k);151
+ }152
+ }153
+ // 事件明细保留天数:空串 = 跟随 config.php 的 collect.event_retention;154
+ // 非空必须是 1-3650 的整数(cron clean 直接拿去算分区删除边界)。155
+ // 注意:本键此前只在白名单里、没有写入分支 —— 前端发了、服务端静默丢掉,156
+ // 表现为「改完点保存提示成功、刷新又变回去」。新增/删除键时务必同步这里。157
+ if ($req->input('retention_days') !== null) {158
+ $rd = trim((string) $req->input('retention_days'));159
+ if ($rd !== '') {160
+ if (!preg_match('/^\d+$/', $rd) || (int) $rd < 1 || (int) $rd > 3650) {161
+ wstat_err('事件明细保留天数需为 1-3650 的整数(留空表示跟随配置文件)', 422);162
+ }163
+ $kv['retention_days'] = (string) (int) $rd;164
+ } else {165
+ $kv['retention_days'] = '';166
+ }167
+ }168
+ // SMTP 文本项169
+ foreach (['smtp_host', 'smtp_username', 'smtp_from_name'] as $k) {170
+ if ($req->input($k) !== null) {171
+ $kv[$k] = trim((string) $req->input($k));172
+ if (mb_strlen($kv[$k]) > 190) {173
+ wstat_err($k . ' 过长', 422);174
+ }175
+ }176
+ }177
+ // 端口178
+ if ($req->input('smtp_port') !== null) {179
+ $port = (int) $req->input('smtp_port');180
+ if ($port < 1 || $port > 65535) {181
+ wstat_err('SMTP 端口需在 1-65535', 422);182
+ }183
+ $kv['smtp_port'] = $port;184
+ }185
+ // 加密方式186
+ if ($req->input('smtp_secure') !== null) {187
+ $sec = strtolower(trim((string) $req->input('smtp_secure')));188
+ if (!in_array($sec, ['ssl', 'tls', 'none'], true)) {189
+ wstat_err('加密方式仅支持 ssl / tls / none', 422);190
+ }191
+ $kv['smtp_secure'] = $sec;192
+ }193
+ // 发件人地址194
+ if ($req->input('smtp_from_email') !== null) {195
+ $e = trim((string) $req->input('smtp_from_email'));196
+ if ($e !== '' && !Util::validEmail($e)) {197
+ wstat_err('发件人邮箱格式不正确', 422);198
+ }199
+ $kv['smtp_from_email'] = $e;200
+ }201
+ // 密码:留空或全掩码则沿用旧值202
+ if ($req->input('smtp_password') !== null) {203
+ $pwd = (string) $req->input('smtp_password');204
+ if (trim($pwd) !== '' && preg_match('/^\*+$/', trim($pwd)) !== 1) {205
+ $kv['smtp_password'] = trim($pwd);206
+ }207
+ }208
+ // 大屏访问密码:全掩码沿用旧值;其余(含空串)原样保存 —— 空串=关闭密码保护209
+ if ($req->input('screen_password') !== null) {210
+ $sp = trim((string) $req->input('screen_password'));211
+ if (preg_match('/^\*+$/', $sp) !== 1) {212
+ if (mb_strlen($sp) > 64) {213
+ wstat_err('大屏访问密码过长(≤64 字符)', 422);214
+ }215
+ $kv['screen_password'] = $sp;216
+ }217
+ }218
+ // 站点接入代码模板:多行代码;空串=用内置默认(占位符 {site_key} / {host})219
+ if ($req->input('sdk_snippet') !== null) {220
+ $snip = trim((string) $req->input('sdk_snippet'));221
+ if (mb_strlen($snip) > Settings::SDK_SNIPPET_MAX) {222
+ wstat_err('统计代码过长(≤' . Settings::SDK_SNIPPET_MAX . ' 字符)', 422);223
+ }224
+ $kv['sdk_snippet'] = $snip;225
+ }226
+ // 外部代码(第三方统计 / 在线客服,注入本站页面 </head> 前):空串=不注入227
+ if ($req->input('inject_code') !== null) {228
+ $ic = trim((string) $req->input('inject_code'));229
+ if (mb_strlen($ic) > Settings::INJECT_CODE_MAX) {230
+ wstat_err('外部代码过长(≤' . Settings::INJECT_CODE_MAX . ' 字符)', 422);231
+ }232
+ $kv['inject_code'] = $ic;233
+ }234
+235
+ // 访客 IP 取值来源:**手动指定**,程序不做任何自动判断(校验枚举 + 自定义头名)236
+ if ($req->input('ip_source') !== null) {237
+ $src = strtolower(trim((string) $req->input('ip_source')));238
+ if (!in_array($src, Util::IP_SOURCES, true)) {239
+ wstat_err('IP 取值来源仅支持 ' . implode(' / ', Util::IP_SOURCES), 422);240
+ }241
+ if ($src === 'custom' && Util::normalizeHeaderName((string) $req->input('ip_source_header')) === '') {242
+ wstat_err('选择「自定义头」时必须填写合法的请求头名称(仅字母、数字、连字符)', 422);243
+ }244
+ $kv['ip_source'] = $src;245
+ }246
+ if ($req->input('ip_source_header') !== null) {247
+ $hdr = trim((string) $req->input('ip_source_header'));248
+ if ($hdr !== '' && Util::normalizeHeaderName($hdr) === '') {249
+ wstat_err('请求头名称不合法(仅允许字母、数字、连字符,长度 ≤64)', 422);250
+ }251
+ $kv['ip_source_header'] = $hdr;252
+ }253
+254
+ // 被关闭的菜单:逗号分隔的路由 key(如 /spiders,/heatmap);空串=全部显示。255
+ // 每个 token 必须形如 /xxx(小写字母数字连字符),非法 token 直接 422 整批不落库。256
+ // 注意:brand_logo / brand_icon 刻意**没有** PATCH 分支 —— 品牌图只能走257
+ // /api/settings/brand(上传时同步落文件),不能凭空写一个不存在的文件名。258
+ if ($req->input('hidden_menus') !== null) {259
+ $raw = trim((string) $req->input('hidden_menus'));260
+ if ($raw !== '') {261
+ foreach (explode(',', $raw) as $k) {262
+ if (preg_match('#^/[a-z0-9-]+$#', trim($k)) !== 1) {263
+ wstat_err('菜单 key 格式不正确:' . trim($k), 422);264
+ }265
+ }266
+ $kv['hidden_menus'] = implode(',', array_values(array_filter(array_map('trim', explode(',', $raw)))));267
+ } else {268
+ $kv['hidden_menus'] = '';269
+ }270
+ }271
+272
+ // 系统名称 / meta 关键词 / meta 简介:纯文本,trim 后限长(超长 422 整批不落库)。273
+ // 空串=恢复默认(前端回落内置文案 / 不输出 meta)。随 /api/auth/public 的 ui 段公开下发。274
+ $textLimits = [275
+ 'brand_name' => Settings::BRAND_NAME_MAX,276
+ 'meta_keywords' => Settings::META_KEYWORDS_MAX,277
+ 'meta_description' => Settings::META_DESCRIPTION_MAX,278
+ ];279
+ foreach ($textLimits as $k => $max) {280
+ if ($req->input($k) !== null) {281
+ $v = trim((string) $req->input($k));282
+ if (mb_strlen($v) > $max) {283
+ wstat_err('内容超过长度上限(' . $max . ' 字符):' . $k, 422);284
+ }285
+ $kv[$k] = $v;286
+ }287
+ }288
+289
+ if (!$kv) {290
+ wstat_err('没有需要保存的设置', 422);291
+ }292
+ $n = Settings::set($kv);293
+ wstat_json(['saved' => $n]);294
+ }295
+296
+ /**297
+ * GET /api/settings/version —— 当前版本 + 远端更新检查。298
+ * 远端地址取 config.php 的 update_check_url(默认 https://wstats-update.kfw.cc/update.php,299
+ * 环境变量 WSTAT_UPDATE_URL 可覆盖);拉取失败不影响本接口返回当前版本。300
+ */301
+ public function version(Request $req): void302
+ {303
+ Settings::requireAdmin($req);304
+ $r = Updater::check();305
+ wstat_json([306
+ 'version' => $r['version'],307
+ 'latest' => $r['latest'],308
+ 'has_update' => $r['has_update'],309
+ 'msg' => $r['reason'],310
+ 'reason' => $r['reason'],311
+ 'can_auto' => $r['can_auto'],312
+ 'requires_full' => $r['requires_full'],313
+ 'layout' => $r['layout'],314
+ 'check_url' => $r['check_url'], // 本机实际使用的更新服务地址(排错用)315
+ 'changelog' => $r['changelog'],316
+ 'notes' => $r['notes'],317
+ 'released_at' => $r['released_at'],318
+ 'update_url' => $r['update_url'],319
+ 'update_size' => $r['update_size'],320
+ 'full_url' => $r['full_url'],321
+ 'full_size' => $r['full_size'],322
+ 'php_ok' => $r['php_ok'],323
+ 'update_enabled' => $r['update_enabled'],324
+ 'backups' => Updater::backups(),325
+ ]);326
+ }327
+328
+ /**329
+ * POST /api/settings/update —— 一键升级到最新版本。330
+ *331
+ * 安全:**不接受客户端传入的下载地址**,一律由服务端重新执行 check() 取回官方地址,332
+ * 否则面板就成了「可写任意文件的下载器」。升级前的备份、校验、回滚见 Support\Updater。333
+ */334
+ public function applyUpdate(Request $req): void335
+ {336
+ Settings::requireAdmin($req);337
+ $chk = Updater::check();338
+ if (!$chk['ok']) {339
+ wstat_err($chk['reason'] !== '' ? $chk['reason'] : '无法连接更新服务', 502);340
+ }341
+ if (!$chk['has_update']) {342
+ wstat_err('已是最新版本(v' . $chk['version'] . ')', 422);343
+ }344
+ if (!$chk['can_auto']) {345
+ wstat_err($chk['reason'], 422);346
+ }347
+ try {348
+ $r = Updater::apply($chk['update_url'], $chk['update_sha256']);349
+ } catch (\Throwable $e) {350
+ wstat_err('升级失败:' . $e->getMessage(), 500);351
+ }352
+ wstat_json($r);353
+ }354
+355
+ /**356
+ * POST /api/settings/brand —— 上传 / 复位品牌图(Logo 与 Favicon)。357
+ *358
+ * multipart 字段二选一(也可同请求都带):359
+ * logo 面板 Logo(≤1MB,png/jpg/jpeg/svg/ico/webp)360
+ * icon 浏览器标签页图标(≤256KB,同格式)361
+ * 或表单字段 reset=logo|icon(可逗号分隔多个)表示恢复内置默认。362
+ *363
+ * 存储:data/brand/<kind>.<ext>(同 kind 换扩展名时删除旧文件);364
+ * 设置里只记文件名 —— 文件名本身即缓存版本号(变化 → 前端 URL 变化 → 浏览器缓存失效)。365
+ * 校验扩展名 + finfo MIME 双重确认,避免「把 PHP/HTML 改名上传」被当作图片下发。366
+ */367
+ public function brand(Request $req): void368
+ {369
+ Settings::requireAdmin($req);370
+ $dir = Settings::brandDir();371
+ $done = [];372
+373
+ // ---- 复位:删除文件 + 清空设置 ----374
+ $reset = trim((string) ($req->input('reset') ?? ''));375
+ if ($reset !== '') {376
+ foreach (explode(',', $reset) as $kindRaw) {377
+ $kind = trim(strtolower($kindRaw));378
+ if (!in_array($kind, ['logo', 'icon'], true)) {379
+ continue;380
+ }381
+ $old = basename(trim(Settings::get('brand_' . $kind)));382
+ if ($old !== '' && is_file($dir . '/' . $old)) {383
+ @unlink($dir . '/' . $old);384
+ }385
+ Settings::set(['brand_' . $kind => '']);386
+ $done[$kind] = '';387
+ }388
+ }389
+390
+ // ---- 上传 ----391
+ foreach (['logo' => Settings::BRAND_LOGO_MAX, 'icon' => Settings::BRAND_ICON_MAX] as $kind => $maxBytes) {392
+ $f = $_FILES[$kind] ?? null;393
+ if (!is_array($f) || ($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {394
+ continue;395
+ }396
+ if (($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {397
+ wstat_err('上传失败(错误码 ' . (string) ($f['error'] ?? -1) . '),请重试', 422);398
+ }399
+ $size = (int) ($f['size'] ?? 0);400
+ if ($size <= 0 || $size > $maxBytes) {401
+ wstat_err($kind === 'logo'402
+ ? 'Logo 需为 ≤1MB 的图片文件'403
+ : 'Icon 需为 ≤256KB 的图片文件', 422);404
+ }405
+ $ext = strtolower(pathinfo((string) ($f['name'] ?? ''), PATHINFO_EXTENSION));406
+ if (!in_array($ext, Settings::BRAND_EXTS, true)) {407
+ wstat_err('仅支持 ' . implode(' / ', Settings::BRAND_EXTS) . ' 格式', 422);408
+ }409
+ // MIME 实测:扩展名可以骗,真实字节骗不了410
+ $mime = '';411
+ if (function_exists('finfo_open')) {412
+ $fi = finfo_open(FILEINFO_MIME_TYPE);413
+ if ($fi) {414
+ $mime = (string) finfo_file($fi, (string) $f['tmp_name']);415
+ finfo_close($fi);416
+ }417
+ }418
+ $allow = [419
+ 'png' => ['image/png'], 'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'],420
+ 'svg' => ['image/svg+xml', 'text/plain', 'text/xml', 'application/xml'],421
+ 'ico' => ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png'],422
+ 'webp' => ['image/webp'],423
+ ];424
+ if ($mime !== '' && !in_array($mime, $allow[$ext] ?? [], true)) {425
+ wstat_err('文件内容与扩展名不符(检测到 ' . $mime . ')', 422);426
+ }427
+ // 同 kind 换扩展名时清掉旧文件(设置里只记一个文件名)428
+ $old = basename(trim(Settings::get('brand_' . $kind)));429
+ $name = $kind . '.' . $ext;430
+ // move_uploaded_file 校验 is_uploaded_file,CLI 自测夹具(伪造 $_FILES)下必然失败,431
+ // 因此补 copy 兜底 —— 正常 Web 路径仍走 move_uploaded_file,语义不变。432
+ $moved = @move_uploaded_file((string) $f['tmp_name'], $dir . '/' . $name);433
+ if (!$moved) {434
+ $moved = @copy((string) $f['tmp_name'], $dir . '/' . $name);435
+ }436
+ if (!$moved) {437
+ wstat_err('保存文件失败(检查 data/brand/ 目录写权限)', 500);438
+ }439
+ @chmod($dir . '/' . $name, 0644);440
+ if ($old !== '' && $old !== $name && is_file($dir . '/' . $old)) {441
+ @unlink($dir . '/' . $old);442
+ }443
+ Settings::set(['brand_' . $kind => $name]);444
+ $done[$kind] = $name;445
+ }446
+447
+ if (!$done) {448
+ wstat_err('没有可处理的品牌图(multipart 字段 logo / icon,或 reset=logo|icon)', 422);449
+ }450
+ wstat_json([451
+ 'saved' => array_keys($done),452
+ 'logo' => Settings::get('brand_logo'),453
+ 'icon' => Settings::get('brand_icon'),454
+ ]);455
+ }456
+457
+ /** POST /api/settings/email-test */458
+ public function emailTest(Request $req): void459
+ {460
+ Settings::requireAdmin($req);461
+ $to = trim((string) $req->input('to', ''));462
+ if (!Util::validEmail($to)) {463
+ wstat_err('收件邮箱格式不正确', 422);464
+ }465
+ $host = trim(Settings::get('smtp_host'));466
+ $via = $host !== '' ? ('SMTP ' . $host) : 'PHP mail()';467
+ [$ok, $msg] = Mailer::send([$to], 'WebStats 测试邮件', '这是一封来自 WebStats 的测试邮件。'468
+ . "\n" . '发送方式: ' . $via469
+ . "\n" . '时间: ' . date('Y-m-d H:i:s') . "\n");470
+ wstat_json(['ok' => $ok, 'via' => $via, 'msg' => $msg]);471
+ }472
+}app/Controllers/SiteController.php+455-412
@@ -1,412 +1,455 @@1
-<?php2
-/**3
- * 站点控制器:多站点 CRUD + 文件验证4
- */5
-declare(strict_types=1);6
-7
-namespace Wstat\Controllers;8
-9
-use Wstat\Http\Request;10
-use Wstat\Support\Auth;11
-use Wstat\Support\Db;12
-use Wstat\Support\SiteAccess;13
-use Wstat\Support\SiteStore;14
-use Wstat\Support\Util;15
-16
-class SiteController17
-{18
- /** GET /api/sites 当前用户可见站点(自有 + 被授权协作),带 role 字段 */19
- public function index(Request $req): void20
- {本次改动内容较多,仅显示前 5000 行差异。