码桶

发现社区成员的开源项目

geekssl / 公开

版本历史

8 次提交

提交详情

57d01b1
提交编号57d01b195dfcd6e16b918419a875ef61a6ec27a4
提交作者geekssl<codebucket@localhost>
提交时间2026-09-21 08:50:55 +0800
更新说明1.0.14

文件变更 42 个文件

文件新增删除
app/Controllers/AlertController.php+683-683
app/Controllers/AuthController.php+303-289
app/Controllers/BrandController.php+93-0
app/Controllers/CollectController.php+408-353
app/Controllers/ExportController.php+390-390
app/Controllers/OpenController.php+198-198
app/Controllers/ProfileController.php+143-143
app/Controllers/SettingController.php+472-319
app/Controllers/SiteController.php+455-412
app/Controllers/SpiderController.php+339-0
app/Controllers/StatsController.php+3500-3074
app/Controllers/TokenController.php+53-53
app/Controllers/UserController.php+327-327
app/Http/Request.php+58-58
app/Http/Router.php+83-83
app/Support/AlertEngine.php+723-723
app/Support/Auth.php+80-80
app/Support/Db.php+216-185
app/Support/IpLocator.php+653-653
app/Support/Mailer.php+200-200
app/Support/Notifier.php+237-237
app/Support/OpLog.php+172-172
app/Support/PAT.php+83-83
app/Support/PageInject.php+41-41
app/Support/RateLimiter.php+48-48
app/Support/Rds.php+80-80
app/Support/RedisClient.php+482-482
app/Support/RedisGuard.php+271-271
app/Support/Referrer.php+129-128
app/Support/SearchEngine.php+111-0
app/Support/Sessionizer.php+184-184
app/Support/Settings.php+354-280
app/Support/SiteAccess.php+168-168
app/Support/SiteStore.php+45-45
app/Support/Spider.php+260-0
app/Support/SpiderLog.php+165-0
app/Support/UaParser.php+126-126
app/Support/Updater.php+902-902
app/Support/Util.php+466-466
app/Support/Verify.php+448-448
app/bootstrap.php+150-150
app/version.php+17-17

文件差异 逐行查看本次提交的修改

app/Controllers/AlertController.php+683-683
@@ -1,683 +1,683 @@
1-<?php
2-/**
3- * 告警与日报控制器
4- *
5- * 推送渠道:GET/POST /api/alerts/channels,PATCH/DELETE /api/alerts/channels/{id},POST .../{id}/test
6- * 告警规则:GET/POST /api/alerts/rules,PATCH/DELETE /api/alerts/rules/{id}
7- * 告警历史:GET /api/alerts/logs
8- * 日报订阅:GET/POST /api/alerts/reports,PATCH/DELETE /api/alerts/reports/{id}
9- * 概览计数:GET /api/alerts/summary
10- *
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 AlertController
27-{
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): void
36- {
37- $u = Auth::requireUser($req);
38- $rows = Db::select(
39- 'SELECT id,name,type,config,is_active,last_status,last_sent_at,created_at
40- 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): void
55- {
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): void
76- {
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): void
120- {
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): void
128- {
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): void
146- {
147- $u = Auth::requireUser($req);
148- $rows = Db::select(
149- 'SELECT r.*, s.name AS site_name, s.domain AS site_domain
150- FROM alert_rules r LEFT JOIN sites s ON s.id=r.site_id
151- 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): void
179- {
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): void
199- {
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): void
220- {
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): void
230- {
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_domain
250- FROM alert_logs l
251- LEFT JOIN alert_rules r ON r.id=l.rule_id
252- LEFT JOIN sites s ON s.id=l.site_id
253- WHERE $where ORDER BY l.id DESC LIMIT " . (($page - 1) * $size) . ",$size",
254- $args
255- );
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): void
272- {
273- $u = Auth::requireUser($req);
274- $rows = Db::select(
275- 'SELECT r.*, s.name AS site_name, s.domain AS site_domain
276- FROM report_subscriptions r LEFT JOIN sites s ON s.id=r.site_id
277- 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): void
293- {
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): void
314- {
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): void
335- {
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): void
345- {
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): array
366- {
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): array
376- {
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): array
386- {
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): array
397- {
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): array
411- {
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): array
424- {
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): array
465- {
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): array
492- {
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): int
506- {
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): string
517- {
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 = []): array
539- {
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(): bool
629- {
630- static $ok = null;
631- if ($ok === null) {
632- try {
633- // tableColumns() 返回键=列名,判列必须 array_key_exists
634- $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 = []): array
645- {
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+<?php
2+/**
3+ * 告警与日报控制器
4+ *
5+ * 推送渠道:GET/POST /api/alerts/channels,PATCH/DELETE /api/alerts/channels/{id},POST .../{id}/test
6+ * 告警规则:GET/POST /api/alerts/rules,PATCH/DELETE /api/alerts/rules/{id}
7+ * 告警历史:GET /api/alerts/logs
8+ * 日报订阅:GET/POST /api/alerts/reports,PATCH/DELETE /api/alerts/reports/{id}
9+ * 概览计数:GET /api/alerts/summary
10+ *
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 AlertController
27+{
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): void
36+ {
37+ $u = Auth::requireUser($req);
38+ $rows = Db::select(
39+ 'SELECT id,name,type,config,is_active,last_status,last_sent_at,created_at
40+ 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): void
55+ {
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): void
76+ {
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): void
120+ {
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): void
128+ {
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): void
146+ {
147+ $u = Auth::requireUser($req);
148+ $rows = Db::select(
149+ 'SELECT r.*, s.name AS site_name, s.domain AS site_domain
150+ FROM alert_rules r LEFT JOIN sites s ON s.id=r.site_id
151+ 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): void
179+ {
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): void
199+ {
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): void
220+ {
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): void
230+ {
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_domain
250+ FROM alert_logs l
251+ LEFT JOIN alert_rules r ON r.id=l.rule_id
252+ LEFT JOIN sites s ON s.id=l.site_id
253+ WHERE $where ORDER BY l.id DESC LIMIT " . (($page - 1) * $size) . ",$size",
254+ $args
255+ );
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): void
272+ {
273+ $u = Auth::requireUser($req);
274+ $rows = Db::select(
275+ 'SELECT r.*, s.name AS site_name, s.domain AS site_domain
276+ FROM report_subscriptions r LEFT JOIN sites s ON s.id=r.site_id
277+ 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): void
293+ {
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): void
314+ {
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): void
335+ {
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): void
345+ {
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): array
366+ {
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): array
376+ {
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): array
386+ {
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): array
397+ {
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): array
411+ {
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): array
424+ {
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): array
465+ {
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): array
492+ {
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): int
506+ {
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): string
517+ {
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 = []): array
539+ {
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(): bool
629+ {
630+ static $ok = null;
631+ if ($ok === null) {
632+ try {
633+ // tableColumns() 返回键=列名,判列必须 array_key_exists
634+ $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 = []): array
645+ {
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-<?php
2-/**
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 AuthController
19-{
20- /** POST /api/auth/register */
21- public function register(Request $req): void
22- {
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): void
80- {
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): void
111- {
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): void
133- {
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): void
148- {
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): void
161- {
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): void
171- {
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- // 验证码放最后校验(消费型):前面任一步失败都不烧码;用途固定为 reset
194- 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): void
215- {
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): void
261- {
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): void
268- {
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): array
281- {
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+<?php
2+/**
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 AuthController
19+{
20+ /** POST /api/auth/register */
21+ public function register(Request $req): void
22+ {
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): void
80+ {
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): void
111+ {
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): void
147+ {
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): void
162+ {
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): void
175+ {
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): void
185+ {
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+ // 验证码放最后校验(消费型):前面任一步失败都不烧码;用途固定为 reset
208+ 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): void
229+ {
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): void
275+ {
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): void
282+ {
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): array
295+ {
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-<?php
2-/**
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 CollectController
31-{
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): void
39- {
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): void
215- {
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): void
226- {
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): void
247- {
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 = $hasEndUser
260- ? 'INSERT INTO sessions
261- (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 UPDATE
266- 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 sessions
272- (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 UPDATE
277- 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 = $hasEndUser
283- ? [$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(): void
306- {
307- wstat_json(['ok' => 1], 200);
308- }
309-
310- private static function id($v, int $maxLen): string
311- {
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): string
320- {
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): string
334- {
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): string
346- {
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+<?php
2+/**
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 CollectController
34+{
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): void
43+ {
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-Policy
82+ // 裁剪的 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 === false
118+ || 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+ $now
127+ );
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): void
264+ {
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): void
275+ {
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): void
296+ {
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_at
313+ $dup = ' ON DUPLICATE KEY UPDATE
314+ 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 sessions
321+ (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 sessions
335+ (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(): void
361+ {
362+ wstat_json(['ok' => 1], 200);
363+ }
364+
365+ private static function id($v, int $maxLen): string
366+ {
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): string
375+ {
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): string
389+ {
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): string
401+ {
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-<?php
2-/**
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 ExportController
33-{
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): void
48- {
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): array
117- {
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) ipc
128- FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",
129- [$sid, $start, $end]
130- ) as $r
131- ) {
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) du
142- FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",
143- [$offset, $sid, $startTs, $endTs]
144- ) as $r
145- ) {
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): array
174- {
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 pv
183- FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $cond
184- GROUP BY `$col` ORDER BY pv DESC LIMIT " . self::MAX_GROUP,
185- [$sid, $start, $end]
186- ) as $r
187- ) {
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): array
197- {
198- $rows = Db::select(
199- "SELECT `$col` AS name, COALESCE(SUM(pageviews),0) AS pv
200- 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): array
218- {
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$ipCol
246- FROM sessions WHERE $where ORDER BY start_ts DESC LIMIT " . self::MAX_ROWS,
247- $args
248- );
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): array
288- {
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(*) pv
298- FROM events
299- 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 $r
304- ) {
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): void
321- {
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): array
354- {
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): string
371- {
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): string
387- {
388- return addcslashes($s, '%_\\');
389- }
390-}
1+<?php
2+/**
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 ExportController
33+{
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): void
48+ {
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): array
117+ {
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) ipc
128+ FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",
129+ [$sid, $start, $end]
130+ ) as $r
131+ ) {
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) du
142+ FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",
143+ [$offset, $sid, $startTs, $endTs]
144+ ) as $r
145+ ) {
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): array
174+ {
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 pv
183+ FROM events WHERE site_id=? AND type='pageview' AND `day` BETWEEN ? AND ? $cond
184+ GROUP BY `$col` ORDER BY pv DESC LIMIT " . self::MAX_GROUP,
185+ [$sid, $start, $end]
186+ ) as $r
187+ ) {
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): array
197+ {
198+ $rows = Db::select(
199+ "SELECT `$col` AS name, COALESCE(SUM(pageviews),0) AS pv
200+ 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): array
218+ {
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$ipCol
246+ FROM sessions WHERE $where ORDER BY start_ts DESC LIMIT " . self::MAX_ROWS,
247+ $args
248+ );
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): array
288+ {
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(*) pv
298+ FROM events
299+ 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 $r
304+ ) {
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): void
321+ {
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): array
354+ {
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): string
371+ {
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): string
387+ {
388+ return addcslashes($s, '%_\\');
389+ }
390+}
app/Controllers/OpenController.php+198-198
@@ -1,198 +1,198 @@
1-<?php
2-/**
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 OpenController
21-{
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): void
31- {
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) ipc
49- FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",
50- [$sid, $start, $end]
51- ) as $r
52- ) {
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) nw
64- FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",
65- [$offset, $sid, $startTs, $endTs]
66- ) as $r
67- ) {
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) ip
91- 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=50
114- * 热门页面 TOP N(按 PV 降序,附 UV)。
115- */
116- public function pages(Request $req): void
117- {
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) uv
126- FROM events
127- 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 $r
131- ) {
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): void
146- {
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): array
170- {
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+<?php
2+/**
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 OpenController
21+{
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): void
31+ {
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) ipc
49+ FROM events WHERE site_id=? AND `day` BETWEEN ? AND ? GROUP BY `day`",
50+ [$sid, $start, $end]
51+ ) as $r
52+ ) {
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) nw
64+ FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<? GROUP BY d",
65+ [$offset, $sid, $startTs, $endTs]
66+ ) as $r
67+ ) {
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) ip
91+ 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=50
114+ * 热门页面 TOP N(按 PV 降序,附 UV)。
115+ */
116+ public function pages(Request $req): void
117+ {
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) uv
126+ FROM events
127+ 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 $r
131+ ) {
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): void
146+ {
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): array
170+ {
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-<?php
2-/**
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 ProfileController
22-{
23- /** GET /api/profile —— 当前用户资料 */
24- public function show(Request $req): void
25- {
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): void
32- {
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): void
74- {
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): void
107- {
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): array
116- {
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.TABLES
130- 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+<?php
2+/**
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 ProfileController
22+{
23+ /** GET /api/profile —— 当前用户资料 */
24+ public function show(Request $req): void
25+ {
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): void
32+ {
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): void
74+ {
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): void
107+ {
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): array
116+ {
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.TABLES
130+ 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-<?php
2-/**
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 SettingController
27-{
28- /** GET /api/settings */
29- public function show(Request $req): void
30- {
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): void
55- {
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): void
75- {
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[] = $isIpv6
113- ? '该访客是 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): void
142- {
143- Settings::requireAdmin($req);
144-
145- $kv = [];
146- // 开关:仅接受 0/1
147- 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): void
251- {
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): void
284- {
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): void
306- {
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" . '发送方式: ' . $via
316- . "\n" . '时间: ' . date('Y-m-d H:i:s') . "\n");
317- wstat_json(['ok' => $ok, 'via' => $via, 'msg' => $msg]);
318- }
319-}
1+<?php
2+/**
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 SettingController
28+{
29+ /** GET /api/settings */
30+ public function show(Request $req): void
31+ {
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): void
56+ {
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): void
76+ {
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[] = $isIpv6
114+ ? '该访客是 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): void
143+ {
144+ Settings::requireAdmin($req);
145+
146+ $kv = [];
147+ // 开关:仅接受 0/1
148+ 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): void
302+ {
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): void
335+ {
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): void
368+ {
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): void
459+ {
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" . '发送方式: ' . $via
469+ . "\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-<?php
2-/**
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 SiteController
17-{
18- /** GET /api/sites 当前用户可见站点(自有 + 被授权协作),带 role 字段 */
19- public function index(Request $req): void
20- {
差异内容过长,后续内容已省略

本次改动内容较多,仅显示前 5000 行差异。