码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Controllers/AlertController.php
AlertController.php25.7 KB
<?php
/**
 * 告警与日报控制器
 *
 * 推送渠道:GET/POST /api/alerts/channels,PATCH/DELETE /api/alerts/channels/{id},POST .../{id}/test
 * 告警规则:GET/POST /api/alerts/rules,PATCH/DELETE /api/alerts/rules/{id}
 * 告警历史:GET /api/alerts/logs
 * 日报订阅:GET/POST /api/alerts/reports,PATCH/DELETE /api/alerts/reports/{id}
 * 概览计数:GET /api/alerts/summary
 *
 * 权限:全部为用户私有资源(user_id 归属校验),站点参数仅允许自己名下的站点;
 *      只读协作成员看不到这些配置(它们属于「站点所有者」的运维设置)。
 */
declare(strict_types=1);

namespace Wstat\Controllers;

use Wstat\Http\Request;
use Wstat\Support\AlertEngine;
use Wstat\Support\Auth;
use Wstat\Support\Db;
use Wstat\Support\Notifier;
use Wstat\Support\SiteAccess;
use Wstat\Support\Util;

class AlertController
{
    private const MAX_CHANNELS = 20;
    private const MAX_RULES = 50;
    private const MAX_REPORTS = 20;

    /* ==================== 推送渠道 ==================== */

    /** GET /api/alerts/channels */
    public function channelIndex(Request $req): void
    {
        $u = Auth::requireUser($req);
        $rows = Db::select(
            'SELECT id,name,type,config,is_active,last_status,last_sent_at,created_at
             FROM notify_channels WHERE user_id=? ORDER BY id',
            [(int) $u['id']]
        );
        foreach ($rows as &$r) {
            $r['id'] = (int) $r['id'];
            $r['is_active'] = (int) $r['is_active'];
            $r['last_sent_at'] = (int) $r['last_sent_at'];
            $r['config'] = self::maskConfig(json_decode((string) $r['config'], true) ?: []);
        }
        unset($r);
        wstat_json(['items' => $rows, 'types' => Notifier::TYPES]);
    }

    /** POST /api/alerts/channels */
    public function channelStore(Request $req): void
    {
        $u = Auth::requireUser($req);
        $uid = (int) $u['id'];
        if ((int) Db::value('SELECT COUNT(*) FROM notify_channels WHERE user_id=?', [$uid]) >= self::MAX_CHANNELS) {
            wstat_err('推送渠道数量已达上限(' . self::MAX_CHANNELS . ')', 422);
        }
        [$name, $type, $cfg] = $this->channelInput($req);
        $id = Db::insert('notify_channels', [
            'user_id' => $uid,
            'name' => $name,
            'type' => $type,
            'config' => json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
            'is_active' => 1,
            'created_at' => time(),
            'updated_at' => time(),
        ]);
        wstat_json(['id' => $id]);
    }

    /** PATCH /api/alerts/channels/{id} */
    public function channelUpdate(Request $req): void
    {
        $ch = $this->ownChannel($req, (int) $req->param('id'));
        $fields = [];
        $name = trim((string) $req->input('name', ''));
        if ($name !== '') {
            if (mb_strlen($name) > 60) {
                wstat_err('渠道名称过长', 422);
            }
            $fields['name'] = $name;
        }
        $isActive = $req->input('is_active', null);
        if ($isActive !== null) {
            $fields['is_active'] = (int) (bool) $isActive;
        }
        // 传了 config 才更新(避免「只改名称」时把密钥擦掉)
        if ($req->input('config', null) !== null) {
            $type = (string) $req->input('type', (string) $ch['type']);
            if (!in_array($type, Notifier::TYPES, true)) {
                wstat_err('不支持的渠道类型', 422);
            }
            $cfg = $this->cfgArray($req->input('config'));
            $cfg = self::mergeConfig($type, $cfg, json_decode((string) $ch['config'], true) ?: []);
            if ($type !== (string) $ch['type']) {
                $fields['type'] = $type;
            }
            $fields['config'] = json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }
        if (!$fields) {
            wstat_err('无有效字段', 422);
        }
        $set = [];
        $vals = [];
        foreach ($fields as $k => $v) {
            $set[] = "`$k`=?";
            $vals[] = $v;
        }
        $set[] = 'updated_at=' . time();
        $vals[] = (int) $ch['id'];
        Db::execute('UPDATE notify_channels SET ' . implode(',', $set) . ' WHERE id=?', $vals);
        wstat_json(['ok' => true]);
    }

    /** DELETE /api/alerts/channels/{id} */
    public function channelDelete(Request $req): void
    {
        $ch = $this->ownChannel($req, (int) $req->param('id'));
        Db::execute('DELETE FROM notify_channels WHERE id=?', [(int) $ch['id']]);
        wstat_json(['ok' => true]);
    }

    /** POST /api/alerts/channels/{id}/test  发送测试消息 */
    public function channelTest(Request $req): void
    {
        $ch = $this->ownChannel($req, (int) $req->param('id'));
        $title = '通道连通性测试';
        $text = "这是一条 WebStats 测试消息。\n"
            . '渠道:' . (string) $ch['name'] . '(' . (string) $ch['type'] . ")\n"
            . '若你能看到本条消息,说明该渠道配置正确。';
        $res = Notifier::send([$ch], $title, $text);
        $one = $res['results'][0] ?? ['ok' => false, 'msg' => '未知错误'];
        if (!$one['ok']) {
            wstat_json(['ok' => false, 'msg' => $one['msg']], 200, 0, '测试发送失败:' . $one['msg']);
        }
        wstat_json(['ok' => true]);
    }

    /* ==================== 告警规则 ==================== */

    /** GET /api/alerts/rules */
    public function ruleIndex(Request $req): void
    {
        $u = Auth::requireUser($req);
        $rows = Db::select(
            'SELECT r.*, s.name AS site_name, s.domain AS site_domain
             FROM alert_rules r LEFT JOIN sites s ON s.id=r.site_id
             WHERE r.user_id=? ORDER BY r.id DESC',
            [(int) $u['id']]
        );
        foreach ($rows as &$r) {
            foreach (['id', 'site_id', 'window_min', 'min_sample', 'cooldown_min', 'is_active', 'last_fired_at'] as $k) {
                $r[$k] = (int) $r[$k];
            }
            $r['threshold'] = (float) $r['threshold'];
            $r['channel_ids'] = array_values(array_filter(array_map('intval', explode(',', (string) $r['channels']))));
            // v1.3 新列(老库缺列时补默认值,前端无需判空)
            $r['recover_notify'] = (int) ($r['recover_notify'] ?? 0);
            $r['firing'] = (int) ($r['firing'] ?? 0);
            $r['quiet_start'] = (string) ($r['quiet_start'] ?? '');
            $r['quiet_end'] = (string) ($r['quiet_end'] ?? '');
        }
        unset($r);
        wstat_json([
            'items' => $rows,
            'metrics' => AlertEngine::METRICS,
            'compares' => AlertEngine::COMPARES,
            'baselines' => AlertEngine::BASELINES,
            'levels' => AlertEngine::LEVELS,
            'recovery_available' => self::ruleExtCols(),
        ]);
    }

    /** POST /api/alerts/rules */
    public function ruleStore(Request $req): void
    {
        $u = Auth::requireUser($req);
        $uid = (int) $u['id'];
        if ((int) Db::value('SELECT COUNT(*) FROM alert_rules WHERE user_id=?', [$uid]) >= self::MAX_RULES) {
            wstat_err('告警规则数量已达上限(' . self::MAX_RULES . ')', 422);
        }
        $d = $this->ruleInput($req);
        $now = time();
        $id = Db::insert('alert_rules', array_merge($d, [
            'user_id' => $uid,
            'is_active' => 1,
            'last_fired_at' => 0,
            'created_at' => $now,
            'updated_at' => $now,
        ]));
        wstat_json(['id' => $id]);
    }

    /** PATCH /api/alerts/rules/{id} */
    public function ruleUpdate(Request $req): void
    {
        $rule = $this->ownRule($req, (int) $req->param('id'));
        $d = $this->ruleInput($req, $rule);
        $isActive = $req->input('is_active', null);
        if ($isActive !== null) {
            $d['is_active'] = (int) (bool) $isActive;
        }
        $set = [];
        $vals = [];
        foreach ($d as $k => $v) {
            $set[] = "`$k`=?";
            $vals[] = $v;
        }
        $set[] = 'updated_at=' . time();
        $vals[] = (int) $rule['id'];
        Db::execute('UPDATE alert_rules SET ' . implode(',', $set) . ' WHERE id=?', $vals);
        wstat_json(['ok' => true]);
    }

    /** DELETE /api/alerts/rules/{id} */
    public function ruleDelete(Request $req): void
    {
        $rule = $this->ownRule($req, (int) $req->param('id'));
        Db::execute('DELETE FROM alert_rules WHERE id=?', [(int) $rule['id']]);
        wstat_json(['ok' => true]);
    }

    /* ==================== 告警历史 ==================== */

    /** GET /api/alerts/logs?site_id=&level=&page=&size= */
    public function logs(Request $req): void
    {
        $u = Auth::requireUser($req);
        $page = max(1, (int) $req->input('page', 1));
        $size = min(100, max(1, (int) $req->input('size', 20)));

        $where = 'l.user_id=?';
        $args = [(int) $u['id']];
        $socialSite = (int) $req->input('site_id', 0);
        if ($socialSite > 0) {
            $where .= ' AND l.site_id=?';
            $args[] = $socialSite;
        }
        $level = trim((string) $req->input('level', ''));
        if (in_array($level, AlertEngine::LEVELS, true)) {
            $where .= ' AND l.level=?';
            $args[] = $level;
        }
        $total = (int) Db::value("SELECT COUNT(*) FROM alert_logs l WHERE $where", $args);
        $rows = Db::select(
            "SELECT l.*, r.name AS rule_name, s.name AS site_name, s.domain AS site_domain
             FROM alert_logs l
             LEFT JOIN alert_rules r ON r.id=l.rule_id
             LEFT JOIN sites s ON s.id=l.site_id
             WHERE $where ORDER BY l.id DESC LIMIT " . (($page - 1) * $size) . ",$size",
            $args
        );
        foreach ($rows as &$r) {
            $r['id'] = (int) $r['id'];
            $r['rule_id'] = (int) $r['rule_id'];
            $r['site_id'] = (int) $r['site_id'];
            $r['value'] = (float) $r['value'];
            $r['baseline'] = (float) $r['baseline'];
            $r['created_at'] = (int) $r['created_at'];
        }
        unset($r);
        wstat_json(['items' => $rows, 'total' => $total, 'page' => $page, 'size' => $size]);
    }

    /* ==================== 日报订阅 ==================== */

    /** GET /api/alerts/reports */
    public function reportIndex(Request $req): void
    {
        $u = Auth::requireUser($req);
        $rows = Db::select(
            'SELECT r.*, s.name AS site_name, s.domain AS site_domain
             FROM report_subscriptions r LEFT JOIN sites s ON s.id=r.site_id
             WHERE r.user_id=? ORDER BY r.id DESC',
            [(int) $u['id']]
        );
        foreach ($rows as &$r) {
            $r['id'] = (int) $r['id'];
            $r['site_id'] = (int) $r['site_id'];
            $r['hour'] = (int) $r['hour'];
            $r['is_active'] = (int) $r['is_active'];
            $r['channel_ids'] = array_values(array_filter(array_map('intval', explode(',', (string) $r['channels']))));
        }
        unset($r);
        wstat_json(['items' => $rows]);
    }

    /** POST /api/alerts/reports */
    public function reportStore(Request $req): void
    {
        $u = Auth::requireUser($req);
        $uid = (int) $u['id'];
        if ((int) Db::value('SELECT COUNT(*) FROM report_subscriptions WHERE user_id=?', [$uid]) >= self::MAX_REPORTS) {
            wstat_err('订阅数量已达上限(' . self::MAX_REPORTS . ')', 422);
        }
        $d = $this->reportInput($req);
        $now = time();
        $id = Db::insert('report_subscriptions', array_merge($d, [
            'user_id' => $uid,
            'is_active' => 1,
            'last_sent_day' => '',
            'last_status' => '',
            'created_at' => $now,
            'updated_at' => $now,
        ]));
        wstat_json(['id' => $id]);
    }

    /** PATCH /api/alerts/reports/{id} */
    public function reportUpdate(Request $req): void
    {
        $sub = $this->ownReport($req, (int) $req->param('id'));
        $d = $this->reportInput($req, $sub);
        $isActive = $req->input('is_active', null);
        if ($isActive !== null) {
            $d['is_active'] = (int) (bool) $isActive;
        }
        $set = [];
        $vals = [];
        foreach ($d as $k => $v) {
            $set[] = "`$k`=?";
            $vals[] = $v;
        }
        $set[] = 'updated_at=' . time();
        $vals[] = (int) $sub['id'];
        Db::execute('UPDATE report_subscriptions SET ' . implode(',', $set) . ' WHERE id=?', $vals);
        wstat_json(['ok' => true]);
    }

    /** DELETE /api/alerts/reports/{id} */
    public function reportDelete(Request $req): void
    {
        $sub = $this->ownReport($req, (int) $req->param('id'));
        Db::execute('DELETE FROM report_subscriptions WHERE id=?', [(int) $sub['id']]);
        wstat_json(['ok' => true]);
    }

    /* ==================== 概览计数 ==================== */

    /** GET /api/alerts/summary */
    public function summary(Request $req): void
    {
        $u = Auth::requireUser($req);
        $uid = (int) $u['id'];
        wstat_json([
            'channels' => (int) Db::value('SELECT COUNT(*) FROM notify_channels WHERE user_id=? AND is_active=1', [$uid]),
            'rules' => (int) Db::value('SELECT COUNT(*) FROM alert_rules WHERE user_id=? AND is_active=1', [$uid]),
            'reports' => (int) Db::value('SELECT COUNT(*) FROM report_subscriptions WHERE user_id=? AND is_active=1', [$uid]),
            'logs_24h' => (int) Db::value(
                'SELECT COUNT(*) FROM alert_logs WHERE user_id=? AND created_at>=?',
                [$uid, time() - 86400]
            ),
            'last_log' => Db::first(
                'SELECT id,level,message,created_at,status FROM alert_logs WHERE user_id=? ORDER BY id DESC LIMIT 1',
                [$uid]
            ),
        ]);
    }

    /* ==================== 内部:校验与取值 ==================== */

    private function ownChannel(Request $req, int $id): array
    {
        $u = Auth::requireUser($req);
        $ch = Db::first('SELECT * FROM notify_channels WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);
        if ($ch === null) {
            wstat_err('推送渠道不存在', 404, 404);
        }
        return $ch;
    }

    private function ownRule(Request $req, int $id): array
    {
        $u = Auth::requireUser($req);
        $r = Db::first('SELECT * FROM alert_rules WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);
        if ($r === null) {
            wstat_err('告警规则不存在', 404, 404);
        }
        return $r;
    }

    private function ownReport(Request $req, int $id): array
    {
        $u = Auth::requireUser($req);
        $r = Db::first('SELECT * FROM report_subscriptions WHERE id=? AND user_id=? LIMIT 1', [$id, (int) $u['id']]);
        if ($r === null) {
            wstat_err('订阅不存在', 404, 404);
        }
        return $r;
    }

    /** 渠道入参校验(新建) */
    private function channelInput(Request $req): array
    {
        $name = trim((string) $req->input('name', ''));
        $type = (string) $req->input('type', '');
        if ($name === '' || mb_strlen($name) > 60) {
            wstat_err('请填写渠道名称(≤60字)', 422);
        }
        if (!in_array($type, Notifier::TYPES, true)) {
            wstat_err('不支持的渠道类型', 422);
        }
        $cfg = $this->requiredConfig($type, $this->cfgArray($req->input('config')));
        return [$name, $type, $cfg];
    }

    private function cfgArray($v): array
    {
        if (is_array($v)) {
            return $v;
        }
        if (is_string($v) && trim($v) !== '') {
            $j = json_decode($v, true);
            return is_array($j) ? $j : [];
        }
        return [];
    }

    /** 必填项校验(按渠道类型) */
    private function requiredConfig(string $type, array $cfg): array
    {
        $need = static function (array $c, string $key, string $label): void {
            if (trim((string) ($c[$key] ?? '')) === '') {
                wstat_err('请填写' . $label, 422);
            }
        };
        switch ($type) {
            case 'webhook':
            case 'feishu':
            case 'dingtalk':
            case 'wecom':
                $need($cfg, 'url', 'Webhook 地址');
                break;
            case 'serverchan':
                $need($cfg, 'key', 'SendKey');
                break;
            case 'bark':
                $need($cfg, 'key', 'Bark Key');
                break;
            case 'email':
                $to = $cfg['to'] ?? '';
                $list = is_array($to) ? $to : array_filter(array_map('trim', explode(',', (string) $to)));
                if (!$list) {
                    wstat_err('请填写收件人邮箱', 422);
                }
                foreach ($list as $a) {
                    if (!Util::validEmail((string) $a)) {
                        wstat_err('收件人邮箱格式不正确: ' . $a, 422);
                    }
                }
                $cfg['to'] = array_values($list);
                break;
        }
        return $cfg;
    }

    /**
     * 合并旧配置:值为掩码「******」或空字符串的密钥字段沿用旧值,
     * 这样前端回显掩码、用户只改名称时不会把密钥清空。
     */
    private static function mergeConfig(string $type, array $incoming, array $existing): array
    {
        $secretKeys = ['secret', 'key', 'password', 'token'];
        foreach ($secretKeys as $k) {
            $v = (string) ($incoming[$k] ?? '');
            if ($v === '' || preg_match('/^\*+$/', $v)) {
                if (array_key_exists($k, $existing)) {
                    $incoming[$k] = $existing[$k];
                }
            }
        }
        if (($incoming['url'] ?? '') === '' && isset($existing['url'])) {
            $incoming['url'] = $existing['url'];
        }
        if ($type === 'email') {
            $to = $incoming['to'] ?? [];
            if (is_string($to)) {
                $to = array_filter(array_map('trim', explode(',', $to)));
            }
            if (!$to && isset($existing['to'])) {
                $incoming['to'] = $existing['to'];
            }
        }
        return $incoming;
    }

    /** 回显时把密钥打码(前端只看到掩码,保存时原样回传即可保留) */
    private static function maskConfig(array $cfg): array
    {
        foreach (['secret', 'key', 'password', 'token'] as $k) {
            if (isset($cfg[$k]) && (string) $cfg[$k] !== '') {
                $cfg[$k] = '******';
            }
        }
        return $cfg;
    }

    /**
     * 站点参数校验:0 = 全部站点;否则要求对该站点至少具备 $min 角色。
     * 规则写入场景默认要求「可编辑」——所有者与协作者可配,只读成员不可。
     */
    private function siteArg(Request $req, string $min = SiteAccess::EDITOR): int
    {
        $sid = (int) $req->input('site_id', 0);
        if ($sid <= 0) {
            return 0;
        }
        SiteAccess::require($req, $sid, $min);
        return $sid;
    }

    /** 渠道 id 列表校验(必须属于当前用户) */
    private function channelSpec(Request $req): string
    {
        $u = Auth::requireUser($req);
        $v = $req->input('channel_ids', $req->input('channels', null));
        if ($v === null) {
            return '';
        }
        $ids = is_array($v) ? $v : explode(',', (string) $v);
        $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
        if (!$ids) {
            return '';
        }
        $ph = implode(',', array_fill(0, count($ids), '?'));
        $owned = Db::select(
            "SELECT id FROM notify_channels WHERE user_id=? AND id IN ($ph)",
            [(int) $u['id'], ...$ids]
        );
        $ok = array_map(static fn ($r) => (int) $r['id'], $owned);
        return implode(',', $ok);
    }

    /** 规则入参($base 非空时表示补丁更新:未提供的字段沿用旧值) */
    private function ruleInput(Request $req, array $base = []): array
    {
        $get = static function (string $k, $def = null) use ($req, $base) {
            $v = $req->input($k, null);
            if ($v !== null) {
                return $v;
            }
            return array_key_exists($k, $base) ? $base[$k] : $def;
        };

        $name = trim((string) $get('name', ''));
        if ($name === '' || mb_strlen($name) > 100) {
            wstat_err('请填写规则名称(≤100字)', 422);
        }
        $metric = (string) $get('metric', 'pv');
        if (!in_array($metric, AlertEngine::METRICS, true)) {
            wstat_err('不支持的指标', 422);
        }
        $compare = (string) $get('compare', 'ratio_down');
        if (!in_array($compare, AlertEngine::COMPARES, true)) {
            wstat_err('不支持的比较方式', 422);
        }
        $baseline = (string) $get('baseline', 'prev_day');
        if (!in_array($baseline, AlertEngine::BASELINES, true)) {
            wstat_err('不支持的基线', 422);
        }
        $level = (string) $get('level', 'warn');
        if (!in_array($level, AlertEngine::LEVELS, true)) {
            $level = 'warn';
        }
        $window = (int) $get('window_min', 60);
        if ($window < 1 || $window > 1440) {
            wstat_err('评估窗口需在 1~1440 分钟之间', 422);
        }
        $cooldown = (int) $get('cooldown_min', 60);
        if ($cooldown < 1 || $cooldown > 10080) {
            wstat_err('冷却时间需在 1~10080 分钟之间', 422);
        }
        $minSample = (int) $get('min_sample', 10);
        if ($minSample < 0 || $minSample > 100000000) {
            wstat_err('基线样本量不合法', 422);
        }
        $threshold = $get('threshold', 0);
        if (!is_numeric($threshold)) {
            wstat_err('阈值需为数字', 422);
        }
        $threshold = (float) $threshold;
        if ($compare === 'ratio_up' || $compare === 'ratio_down') {
            if ($threshold <= 0 || $threshold > 10000) {
                wstat_err('比例类比较的阈值需大于 0(百分比)', 422);
            }
        }

        $out = [
            'site_id' => $this->siteArg($req),
            'name' => $name,
            'metric' => $metric,
            'window_min' => $window,
            'compare' => $compare,
            'baseline' => $baseline,
            'threshold' => round($threshold, 2),
            'min_sample' => $minSample,
            'level' => $level,
            'cooldown_min' => $cooldown,
            'channels' => $this->channelSpec($req),
        ];

        // v1.3 新列(恢复通知 + 静默时段):老库缺列时自动省略(优雅降级)
        if (self::ruleExtCols()) {
            $recover = (int) (bool) $get('recover_notify', array_key_exists('recover_notify', $base) ? $base['recover_notify'] : 0);
            $qs = trim((string) $get('quiet_start', array_key_exists('quiet_start', $base) ? $base['quiet_start'] : ''));
            $qe = trim((string) $get('quiet_end', array_key_exists('quiet_end', $base) ? $base['quiet_end'] : ''));
            $hm = static fn (string $s): bool => preg_match('/^([01]?\d|2[0-3]):[0-5]\d$/', $s) === 1;
            if ($qs !== '' && !$hm($qs)) {
                wstat_err('静默开始时间格式应为 HH:MM(如 23:00)', 422);
            }
            if ($qe !== '' && !$hm($qe)) {
                wstat_err('静默结束时间格式应为 HH:MM(如 07:00)', 422);
            }
            if (($qs === '') !== ($qe === '')) {
                wstat_err('静默时段需同时填写开始与结束,或都留空', 422);
            }
            $out['recover_notify'] = $recover;
            $out['quiet_start'] = $qs;
            $out['quiet_end'] = $qe;
        }
        return $out;
    }

    /** alert_rules 新列(recover_notify 等)是否存在(进程内缓存,缺列=老库未跑增量脚本) */
    private static function ruleExtCols(): bool
    {
        static $ok = null;
        if ($ok === null) {
            try {
                // tableColumns() 返回键=列名,判列必须 array_key_exists
                $c = Db::tableColumns('alert_rules');
                $ok = array_key_exists('recover_notify', $c) && array_key_exists('quiet_start', $c);
            } catch (\Throwable $e) {
                $ok = false;
            }
        }
        return $ok;
    }

    /** 订阅入参 */
    private function reportInput(Request $req, array $base = []): array
    {
        $get = static function (string $k, $def = null) use ($req, $base) {
            $v = $req->input($k, null);
            if ($v !== null) {
                return $v;
            }
            return array_key_exists($k, $base) ? $base[$k] : $def;
        };

        $freq = (string) $get('freq', 'daily');
        if (!in_array($freq, ['daily', 'weekly'], true)) {
            wstat_err('推送频率只能是 daily 或 weekly', 422);
        }
        $hour = (int) $get('hour', 9);
        if ($hour < 0 || $hour > 23) {
            wstat_err('推送小时需在 0~23 之间', 422);
        }
        $daysRaw = $get('days', '1,2,3,4,5,6,7');
        $days = is_array($daysRaw) ? $daysRaw : explode(',', (string) $daysRaw);
        $days = array_values(array_unique(array_filter(array_map('intval', $days), static fn ($d) => $d >= 1 && $d <= 7)));
        sort($days);
        if ($freq === 'weekly' && !$days) {
            wstat_err('周报至少选择一天', 422);
        }
        $name = trim((string) $get('name', ''));
        if (mb_strlen($name) > 100) {
            wstat_err('订阅名称过长', 422);
        }

        return [
            'site_id' => $this->siteArg($req),
            'name' => $name !== '' ? $name : ($freq === 'weekly' ? '每周数据周报' : '每日数据日报'),
            'freq' => $freq,
            'days' => $days ? implode(',', $days) : '1,2,3,4,5,6,7',
            'hour' => $hour,
            'channels' => $this->channelSpec($req),
        ];
    }
}