MailExport.php 10.4 KB
<?php

namespace Controller;

use Service\MailExport as MailExportService;

/**
 * 邮件导出页面
 *
 * 需要一个固定密码才能访问,密码:mailexport
 *
 * 路由:
 *  mail-export            页面(GET 展示 / POST 校验密码)
 *  mail-export/logout     退出
 *  mail-export/projects   公司项目搜索(按名称或 postid)
 *  mail-export/count      查询命中的邮件数量
 *  mail-export/start      执行导出(同步,导出完成后返回下载地址)
 *  mail-export/download   下载导出的 zip
 *
 * @author:dc
 * @time 2026/10/28 11:40
 * Class MailExport
 * @package Controller
 */
class MailExport extends Base {

    /**
     * 访问密码(固定)
     */
    private const PASSWORD = 'mailexport';

    /**
     * 登录态 cookie 名称
     */
    private const COOKIE_NAME = 'mail_export_token';

    /**
     * 登录态签名盐值,cookie 里不存明文密码
     */
    private const COOKIE_SALT = 'mail_export_2026';

    /**
     * 登录态有效期,7 天
     */
    private const COOKIE_TTL = 604800;

    /**
     * 页面
     * GET  :未登录返回密码框,已登录返回页面
     * POST :校验密码,成功后写入登录态并返回页面
     *
     * @return string
     * @author:dc
     * @time 2026/10/28 11:40
     */
    public function index(){
        if(app()->requestHas('password')){
            $password = (string)app()->request('password');
            if(!hash_equals(self::PASSWORD, $password)){
                logs('mail-export 密码错误 IP:'.($_SERVER['REMOTE_ADDR'] ?? ''));
                return $this->loginHtml('密码不正确');
            }
            $this->setAuthCookie();
            return $this->pageHtml();
        }

        if($this->isAuthed()){
            return $this->pageHtml();
        }

        return $this->loginHtml('');
    }

    /**
     * 退出
     * @return string
     * @author:dc
     * @time 2026/10/28 11:40
     */
    public function logout(){
        setcookie(self::COOKIE_NAME, '', [
            'expires'  => time() - 3600,
            'path'     => '/',
            'httponly' => true,
            'samesite' => 'Lax',
        ]);

        return $this->loginHtml('已退出');
    }

    /**
     * 搜索公司项目
     * 支持按公司名称模糊、按 postid 精确
     * @return array
     * @throws \Lib\Err
     * @author:dc
     * @time 2026/10/28 11:40
     */
    public function projects(){
        $this->needAuth();

        $keyword = trim((string)app()->request('keyword', ''));
        if($keyword === ''){
            return ['item' => []];
        }

        $db = fob_mysql();

        // 名称模糊 + postid 精确
        $where = "`company` like '%".addslashes($keyword)."%'";
        if(ctype_digit($keyword)){
            $where = "(`postid` = ".intval($keyword)." or ".$where.")";
        }

        $rows = $db->throw()->all(
            "select `postid`,`company`,`plan` from `projects` where {$where} order by `postid` desc limit 20"
        );

        if(!$rows){
            return ['item' => []];
        }

        // 已绑定邮箱数量,方便确认选的是哪个项目
        $bindCount = [];
        $postids = array_map('intval', array_column($rows, 'postid'));
        try {
            $counts = $db->throw()->all(
                "select `post_id`,count(*) as `num` from `e_mail_binds` where `post_id` in (".implode(',', $postids).") group by `post_id`"
            );
            $bindCount = array_column($counts, 'num', 'post_id');
        }catch (\Throwable $e){
            logs('mail-export 统计绑定邮箱失败:'.$e->getMessage());
        }

        $item = [];
        foreach($rows as $row){
            $item[] = [
                'postid'  => intval($row['postid']),
                'company' => (string)$row['company'],
                'plan'    => (string)$row['plan'],
                'bind'    => intval($bindCount[$row['postid']] ?? 0),
            ];
        }

        return ['item' => $item];
    }

    /**
     * 查询命中的邮件数量
     * @return array
     * @throws \Lib\Err
     * @author:dc
     * @time 2026/10/28 11:40
     */
    public function count(){
        $this->needAuth();

        $postid = intval(app()->request('postid', 0));
        $scope = $this->scope();

        if(!$postid){
            app()->e('请先选择公司项目');
        }

        return [
            'count' => MailExportService::count($postid, $scope),
            'scope' => $scope,
        ];
    }

    /**
     * 执行导出
     * @return array
     * @throws \Lib\Err
     * @author:dc
     * @time 2026/10/28 11:40
     */
    public function start(){
        $this->needAuth();

        $postid = intval(app()->request('postid', 0));
        $scope = $this->scope();
        $limit = intval(app()->request('limit', MailExportService::DEFAULT_LIMIT));

        if(!$postid){
            app()->e('请先选择公司项目');
        }

        $project = fob_mysql()->throw()->first(
            "select `postid`,`company` from `projects` where `postid` = {$postid} limit 1"
        );
        if(!$project){
            app()->e('公司项目不存在,请重新搜索');
        }

        // 导出是同步执行的,数量大时需要更长时间
        set_time_limit(0);
        ignore_user_abort(true);

        $ret = (new MailExportService())->export($postid, $scope, $limit);

        $ret['company'] = (string)$project['company'];

        logs('邮件导出 postid:'.$postid.' 范围:'.$scope.' 数量:'.$ret['total']
            .' 成功:'.$ret['success'].' 失败:'.$ret['fail'].' 耗时:'.$ret['cost'].'s IP:'.($_SERVER['REMOTE_ADDR'] ?? ''));

        return $ret;
    }

    /**
     * 下载导出的 zip
     * @return string
     * @throws \Lib\Err
     * @author:dc
     * @time 2026/10/28 11:40
     */
    public function download(){
        $this->needAuth();

        $f = (string)app()->request('f', '');
        // 只允许 目录/文件名.zip 的形式
        if(!preg_match('#^(\d{1,12}_\d{8}_\d{6}_[0-9a-f]{6})/([0-9a-zA-Z_\-\.]+\.zip)$#', $f, $match)){
            app()->e('文件地址不合法');
        }

        $path = MailExportService::baseDir().'/'.$match[1].'/'.$match[2];
        if(!is_file($path)){
            app()->e('文件不存在,可能已被清理,请重新导出');
        }

        app()->header('Content-Type', 'application/zip');
        app()->header('Content-Disposition', 'attachment; filename="'.$match[2].'"');
        app()->header('Content-Length', filesize($path));
        app()->header('Cache-Control', 'no-store');

        return file_get_contents($path);
    }

    /**
     * 导出条件
     * @return string
     * @throws \Lib\Err
     * @author:dc
     * @time 2026/10/28 11:40
     */
    private function scope():string {
        $scope = (string)app()->request('scope', '');
        if(!isset(MailExportService::scopeMap()[$scope])){
            app()->e('导出条件不正确');
        }
        return $scope;
    }

    /**
     * 未登录直接报错,前端收到后刷新页面
     * @throws \Lib\Err
     * @author:dc
     * @time 2026/10/28 11:40
     */
    private function needAuth(){
        if(!$this->isAuthed()){
            app()->e('登录已失效,请刷新页面重新输入密码', 401);
        }
    }

    /**
     * 是否已登录
     * @return bool
     * @author:dc
     * @time 2026/10/28 11:40
     */
    private function isAuthed():bool {
        return hash_equals($this->token(), (string)($_COOKIE[self::COOKIE_NAME] ?? ''));
    }

    /**
     * 登录态令牌
     * @return string
     * @author:dc
     * @time 2026/10/28 11:40
     */
    private function token():string {
        return hash_hmac('sha256', self::PASSWORD, self::COOKIE_SALT);
    }

    /**
     * 写入登录态
     * @author:dc
     * @time 2026/10/28 11:40
     */
    private function setAuthCookie(){
        setcookie(self::COOKIE_NAME, $this->token(), [
            'expires'  => time() + self::COOKIE_TTL,
            'path'     => '/',
            'httponly' => true,
            'samesite' => 'Lax',
            'secure'   => !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off',
        ]);
    }

    /**
     * 页面 html
     * @return string
     * @author:dc
     * @time 2026/10/28 11:40
     */
    private function pageHtml():string {
        $file = ROOT_PATH.'/views/mail_export.html';
        if(!is_file($file)){
            return $this->loginHtml('页面模版不存在:views/mail_export.html');
        }

        return str_replace(
            ['<!--{#host#}-->', '<!--{#time#}-->'],
            [APP_HOST, app()->nowDateTime()],
            file_get_contents($file)
        );
    }

    /**
     * 密码输入页面
     * @param string $error
     * @return string
     * @author:dc
     * @time 2026/10/28 11:40
     */
    private function loginHtml(string $error = ''):string {
        $errorHtml = $error ? '<p class="error">'.$error.'</p>' : '';

        return '<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>邮件导出 · 访问验证</title>
<style>
    *{box-sizing:border-box;}
    body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;
        background:#eef3fa;font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#243447;}
    .box{width:340px;padding:32px 28px;background:#fff;border-radius:12px;
        box-shadow:0 8px 30px rgba(31,63,107,.12);}
    h1{margin:0 0 6px;font-size:18px;font-weight:600;}
    .sub{margin:0 0 22px;color:#8a97a8;font-size:12px;}
    input{width:100%;height:40px;padding:0 12px;border:1px solid #d5dde8;border-radius:8px;
        font-size:14px;outline:none;transition:.15s;}
    input:focus{border-color:#4a7fd6;box-shadow:0 0 0 3px rgba(74,127,214,.12);}
    button{width:100%;height:40px;margin-top:14px;border:0;border-radius:8px;background:#2f6bd8;
        color:#fff;font-size:14px;cursor:pointer;transition:.15s;}
    button:hover{background:#255cc0;}
    .error{margin:12px 0 0;color:#d0342c;font-size:12px;}
</style>
</head>
<body>
<form class="box" method="post" action="/mail-export" autocomplete="off">
    <h1>邮件导出</h1>
    <p class="sub">该页面需要密码才可访问</p>
    <input type="password" name="password" placeholder="请输入访问密码" autofocus>
    <button type="submit">进入</button>
    '.$errorHtml.'
</form>
</body>
</html>';
    }

}