码桶
发现社区成员的开源项目
SiteController.php16.1 KB
<?php
/**
* 站点控制器:多站点 CRUD + 文件验证
*/
declare(strict_types=1);
namespace Wstat\Controllers;
use Wstat\Http\Request;
use Wstat\Support\Auth;
use Wstat\Support\Db;
use Wstat\Support\SiteAccess;
use Wstat\Support\SiteStore;
use Wstat\Support\Util;
class SiteController
{
/** GET /api/sites 当前用户可见站点(自有 + 被授权协作),带 role 字段 */
public function index(Request $req): void
{
$u = Auth::requireUser($req);
wstat_json(SiteAccess::sitesFor((int) $u['id']));
}
/** POST /api/sites */
public function store(Request $req): void
{
$u = Auth::requireUser($req);
$name = trim((string) $req->input('name', ''));
$domain = trim((string) $req->input('domain', ''));
$tz = (string) $req->input('timezone', 'Asia/Shanghai');
if ($name === '' || mb_strlen($name) > 100) {
wstat_err('请输入站点名称(≤100字)', 422);
}
if (!Util::validDomain($domain)) {
wstat_err('域名格式不正确(仅主机名,如 www.example.com)', 422);
}
if (!Util::validTz($tz)) {
wstat_err('时区不正确', 422);
}
$cnt = Db::value('SELECT COUNT(*) FROM sites WHERE user_id=?', [(int) $u['id']]);
if ((int) $cnt >= 50) {
wstat_err('站点数量已达上限(50)', 422);
}
// 域名可跨用户重复,但不允许同一用户重复添加
if (Db::value('SELECT id FROM sites WHERE user_id=? AND domain=?', [(int) $u['id'], $domain]) !== null) {
wstat_err('该域名已在你的站点列表中', 409);
}
$now = time();
$id = Db::insert('sites', [
'user_id' => (int) $u['id'],
'name' => $name,
'domain' => $domain,
'site_key' => Util::randBase62(10),
'timezone' => $tz,
'status' => 1,
'verify_token' => '',
'verified_at' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
wstat_json($this->own($req, (int) $id), 200);
}
/** GET /api/sites/{id} */
public function show(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'));
wstat_json($site);
}
/** PATCH /api/sites/{id} */
public function update(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'), SiteAccess::OWNER);
if ((int) $site['verified_at'] > 0) {
wstat_err('站点已验证,不可再编辑(如需变更请删除后重新创建并验证)', 409);
}
$fields = [];
$name = trim((string) $req->input('name', ''));
$domain = trim((string) $req->input('domain', ''));
$tz = (string) $req->input('timezone', '');
$status = $req->input('status', null);
if ($name !== '' && mb_strlen($name) <= 100) {
$fields['name'] = $name;
}
if ($domain !== '') {
if (!Util::validDomain($domain)) {
wstat_err('域名格式不正确', 422);
}
$dup = Db::value('SELECT id FROM sites WHERE user_id=? AND domain=? AND id<>?',
[(int) $site['user_id'], $domain, (int) $site['id']]);
if ($dup !== null) {
wstat_err('该域名已在你的站点列表中', 409);
}
$fields['domain'] = $domain;
$fields['verified_at'] = 0; // 域名变更需重新验证
$fields['verify_token'] = '';
}
if ($tz !== '') {
if (!Util::validTz($tz)) {
wstat_err('时区不正确', 422);
}
$fields['timezone'] = $tz;
}
if ($status !== null && in_array((int) $status, [0, 1], true)) {
$fields['status'] = (int) $status;
}
if (empty($fields)) {
wstat_err('无有效字段', 422);
}
$set = [];
$vals = [];
foreach ($fields as $k => $v) {
$set[] = "`$k`=?";
$vals[] = $v;
}
$vals[] = (int) $site['id'];
$vals[] = (int) $site['user_id'];
Db::execute('UPDATE sites SET ' . implode(',', $set) . ", updated_at=" . time() . ' WHERE id=? AND user_id=?', $vals);
SiteStore::invalidate((string) $site['site_key']);
wstat_json($this->own($req, (int) $site['id']));
}
/** DELETE /api/sites/{id} 删除站点并级联清理其全部统计数据 */
public function destroy(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'), SiteAccess::OWNER);
$sid = (int) $site['id'];
$uid = (int) $site['user_id'];
SiteStore::invalidate((string) $site['site_key']);
// 1) 级联删除所有含 site_id 的统计表(events 为分区表,按 site_id 删除即可)
$deleted = [
'events' => (int) Db::value('SELECT COUNT(*) FROM events WHERE site_id=?', [$sid]),
'sessions' => (int) Db::value('SELECT COUNT(*) FROM sessions WHERE site_id=?', [$sid]),
'site_daily' => (int) Db::value('SELECT COUNT(*) FROM site_daily WHERE site_id=?', [$sid]),
'funnels' => (int) Db::value('SELECT COUNT(*) FROM funnels WHERE site_id=?', [$sid]),
];
Db::execute('DELETE FROM events WHERE site_id=?', [$sid]);
Db::execute('DELETE FROM sessions WHERE site_id=?', [$sid]);
Db::execute('DELETE FROM site_daily WHERE site_id=?', [$sid]);
Db::execute('DELETE FROM funnels WHERE site_id=?', [$sid]);
// 2) 清理 Redis 实时态(计数器 / 在线集合 / 进行中会话 / 基数估算)——尽力而为
try {
$r = \Wstat\Support\Rds::get();
if ($r !== null) {
$r->del("today:$sid", "rt:min:$sid", "on:$sid");
foreach (["uv:$sid:*", "ip:$sid:*", "live:$sid:*"] as $pat) {
$cursor = 0;
do {
$keys = $r->scanKeys($pat, $cursor, 500);
if ($keys) {
$r->del(...$keys);
}
} while ($cursor !== 0);
}
}
} catch (\Throwable $e) {
/* Redis 不可用不影响删除结果 */
}
// 3) 最后删除站点行
Db::execute('DELETE FROM sites WHERE id=? AND user_id=?', [$sid, $uid]);
wstat_json(['deleted' => $deleted]);
}
/**
* GET /api/sites/{id}/verify-file 生成(或复用)验证文件信息,不做远端校验
* 返回 {file, content, verified},前端据此引导用户放置文件后调 /verify 校验。
*/
public function verifyFile(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'), SiteAccess::OWNER);
$token = (string) $site['verify_token'];
if ($token === '') {
$token = Util::randHex(16);
Db::execute('UPDATE sites SET verify_token=? WHERE id=?', [$token, (int) $site['id']]);
SiteStore::invalidate((string) $site['site_key']);
$site['verify_token'] = $token;
}
$cfg = wstat_config('verify');
$fileName = $cfg['file_prefix'] . $token . '.txt';
wstat_json([
'verified' => (int) $site['verified_at'] > 0,
'file' => $fileName,
'content' => $token,
]);
}
/**
* POST /api/sites/{id}/verify 站点文件验证(仅远端校验)
* 前置:先调用 verify-file 生成令牌并放置文件,再调用本接口。
* 服务端拉取 https/http://{domain}/wstat_verify_{token}.txt,内容与令牌一致即通过。
*/
public function verify(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'), SiteAccess::OWNER);
$token = (string) $site['verify_token'];
if ($token === '') {
wstat_json([
'verified' => false,
'hint' => '请先生成验证文件(调用 verify-file 或重新打开验证窗口)',
], 200, 0, '尚未生成验证文件');
}
$cfg = wstat_config('verify');
$fileName = $cfg['file_prefix'] . $token . '.txt';
$domain = (string) $site['domain'];
$found = false;
foreach (['https://', 'http://'] as $scheme) {
$url = $scheme . $domain . '/' . $fileName;
$body = self::httpGet($url, (int) $cfg['timeout']);
if ($body !== null && trim($body) === $token) {
$found = true;
break;
}
}
if (!$found) {
wstat_json([
'verified' => false,
'file' => $fileName,
'content' => $token,
'hint' => '无法从站点拉取文件。请确认:① 文件 ' . $fileName
. ' 已上传到 https://' . $domain . '/ 根目录且可公开访问;'
. '② 文件内容为 ' . $token . '(无多余空格/换行);③ 域名已解析且未屏蔽统计服务器。',
], 200, 0, '验证未通过,请检查文件');
}
Db::execute('UPDATE sites SET verified_at=? WHERE id=?', [time(), (int) $site['id']]);
SiteStore::invalidate((string) $site['site_key']);
wstat_json(['verified' => true, 'file' => $fileName]);
}
/**
* 当前用户可访问的站点(含归属/角色校验)。
* 默认只读即可(VIEWER);写操作显式传 SiteAccess::OWNER。
*/
private function own(Request $req, int $id, string $min = SiteAccess::VIEWER): array
{
SiteAccess::require($req, $id, $min);
$site = Db::first(
'SELECT id,user_id,name,domain,site_key,timezone,status,verify_token,verified_at,created_at
FROM sites WHERE id=? LIMIT 1', [$id]
);
if ($site === null) {
wstat_err('站点不存在', 404, 404);
}
return $site;
}
/* ==================== 成员与权限(仅站点所有者可管理) ==================== */
/** GET /api/sites/{id}/members 成员列表(含所有者) */
public function members(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'), SiteAccess::OWNER);
$sid = (int) $site['id'];
$owner = Db::first('SELECT id,email,nickname FROM users WHERE id=?', [(int) $site['user_id']]);
$items = [[
'id' => 0,
'user_id' => (int) $site['user_id'],
'email' => (string) ($owner['email'] ?? ''),
'nickname' => (string) ($owner['nickname'] ?? ''),
'role' => SiteAccess::OWNER,
'is_owner' => true,
'created_at' => (int) $site['created_at'],
]];
if (SiteAccess::hasMemberTable()) {
foreach (
Db::select(
'SELECT m.id,m.user_id,m.role,m.created_at,u.email,u.nickname
FROM site_members m JOIN users u ON u.id=m.user_id
WHERE m.site_id=? ORDER BY m.id',
[$sid]
) as $m
) {
$items[] = [
'id' => (int) $m['id'],
'user_id' => (int) $m['user_id'],
'email' => (string) $m['email'],
'nickname' => (string) $m['nickname'],
'role' => (string) $m['role'],
'is_owner' => false,
'created_at' => (int) $m['created_at'],
];
}
}
wstat_json([
'items' => $items,
'member_table' => SiteAccess::hasMemberTable(),
'roles' => [SiteAccess::EDITOR, SiteAccess::VIEWER],
]);
}
/** POST /api/sites/{id}/members 按邮箱添加成员 {email, role} */
public function memberAdd(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'), SiteAccess::OWNER);
if (!SiteAccess::hasMemberTable()) {
wstat_err('成员功能未启用:请先执行增量脚本 sql/upgrade-2026-09-11-members-alerts.sql', 409);
}
$email = trim((string) $req->input('email', ''));
$role = (string) $req->input('role', SiteAccess::VIEWER);
if (!Util::validEmail($email)) {
wstat_err('邮箱格式不正确', 422);
}
if (!SiteAccess::validRole($role)) {
wstat_err('角色只能是 editor(可编辑)或 viewer(只读)', 422);
}
$u = Db::first('SELECT id,email,nickname,status FROM users WHERE email=? LIMIT 1', [$email]);
if ($u === null) {
wstat_err('该邮箱尚未注册本站账号,请先让对方注册后再添加', 404, 404);
}
if ((int) $u['id'] === (int) $site['user_id']) {
wstat_err('站点所有者无需添加为成员', 409);
}
$exists = Db::value('SELECT id FROM site_members WHERE site_id=? AND user_id=? LIMIT 1',
[(int) $site['id'], (int) $u['id']]);
if ($exists !== null) {
wstat_err('该用户已是站点成员,可直接调整角色', 409);
}
$id = Db::insert('site_members', [
'site_id' => (int) $site['id'],
'user_id' => (int) $u['id'],
'role' => $role,
'invited_by' => (int) $this->uid($req),
'created_at' => time(),
]);
wstat_json(['id' => $id, 'role' => $role]);
}
/** PATCH /api/sites/{id}/members/{mid} 调整成员角色 {role} */
public function memberUpdate(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'), SiteAccess::OWNER);
$role = (string) $req->input('role', '');
if (!SiteAccess::validRole($role)) {
wstat_err('角色只能是 editor(可编辑)或 viewer(只读)', 422);
}
$mid = (int) $req->param('mid');
$n = Db::execute('UPDATE site_members SET role=? WHERE id=? AND site_id=?', [$role, $mid, (int) $site['id']]);
if ($n <= 0) {
wstat_err('成员不存在', 404, 404);
}
wstat_json(['ok' => true, 'role' => $role]);
}
/** DELETE /api/sites/{id}/members/{mid} 移除成员 */
public function memberRemove(Request $req): void
{
$site = $this->own($req, (int) $req->param('id'), SiteAccess::OWNER);
$mid = (int) $req->param('mid');
$n = Db::execute('DELETE FROM site_members WHERE id=? AND site_id=?', [$mid, (int) $site['id']]);
if ($n <= 0) {
wstat_err('成员不存在', 404, 404);
}
wstat_json(['ok' => true]);
}
/** GET /api/members/sites 当前用户被授权的站点(我做客的) */
public function sharedWithMe(Request $req): void
{
$u = Auth::requireUser($req);
if (!SiteAccess::hasMemberTable()) {
wstat_json(['items' => []]);
}
$rows = Db::select(
'SELECT s.id,s.name,s.domain,m.role,s.created_at,o.email AS owner_email,o.nickname AS owner_name
FROM site_members m
JOIN sites s ON s.id=m.site_id
JOIN users o ON o.id=s.user_id
WHERE m.user_id=? ORDER BY m.id DESC',
[(int) $u['id']]
);
foreach ($rows as &$r) {
$r['id'] = (int) $r['id'];
}
unset($r);
wstat_json(['items' => $rows]);
}
private function uid(Request $req): int
{
$u = Auth::requireUser($req);
return (int) $u['id'];
}
private static function httpGet(string $url, int $timeout): ?string
{
$ch = curl_init($url);
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_USERAGENT => 'WebStats-Verify/1.0',
]);
$body = curl_exec($ch);
$err = curl_errno($ch);
curl_close($ch);
return $err === 0 && is_string($body) ? $body : null;
}
}