当前位置:首页 > 技术 > 正文内容

集成企业微信实现禅道扫码登录

访客 技术 2026年10月24日 2

本指南旨在说明如何将企业微信扫码登录功能集成到禅道项目管理系统。请注意,此修改涉及修改PHP源代码,请务必在操作前备份相关文件。本方案基于禅道版本 21.3,并要求用户与企业微信账户进行绑定。

所有代码修改均位于 ZenTao/app/zentao 目录下。

核心实现步骤

  1. 在 user 模块的控制器中新增一个用于处理企业微信回调的接口,并实现登录逻辑。
  2. 在登录页面嵌入企业微信的扫码登录二维码组件。

详细操作指南

1. 新增 wxlogin 接口

a. 修改 lib/wechatapi/wechatapi.class.php

添加一个新方法,用于根据企业微信返回的 code 获取用户的 userid(相当于 OpenID)。

    /**
     * 根据扫码回调获取用户ID。
     *
     * @param string $code 企业微信返回的授权码。
     * @return string 用户ID,如果失败则返回空字符串。
     */
    public function getUserByScanCode($code)
    {
        $response = $this->queryAPI("{$this->apiUrl}auth/getuserinfo?access_token={$this->token}&code={$code}");
        if ($this->isError()) {
            // 记录错误日志,便于调试
            // file_put_contents('/tmp/my_debug.log', "扫码回调错误: code : {$code}   msg: " . json_encode($this->errors) . "\n", FILE_APPEND);
            return '';
        }
        return $response->userid;
    }
    

b. 修改 config/filter.php

配置允许通过的请求参数,防止被自动过滤。

    $filter->user->wxlogin = new stdclass();
    $filter->user->wxlogin->get['code'] = 'string';        // 回调 code
    $filter->user->wxlogin->get['state'] = 'string';       // 自定义状态参数
    $filter->user->wxlogin->get['providerID'] = 'string'; // 用于识别企业微信配置的 ID
    

c. 修改 config/zentaopms.php

将 user.wxlogin 方法加入到公共可访问列表,允许未登录用户访问。

    $config->openMethods[] = 'user.wxlogin';
    

d. 修改 module/user/control.php

这是控制器层,新增 wxlogin 方法。其逻辑类似于原有的 login 方法,主要改动在于调用 userZen->wxCodeLogin() 来处理企业微信登录,并获取和传递相关参数。

    /**
     * 处理企业微信扫码登录。
     *
     * @param string $referer 来源页面。
     * @access public
     * @return void
     */
    public function wxlogin($referer = '')
    {
        $code = $this->get->code ?? '';
        $providerID = $this->get->providerID ?? '';
        $state = $this->get->state ?? '';

        // 可以在此处添加日志记录,便于调试
        // file_put_contents('/tmp/my_debug.log', "wxlogin: code = {$code}, providerID = {$providerID} , state = {$state}\n", FILE_APPEND);

        $viewType = $this->app->getViewType();

        // 设置禅道序列号
        if (empty($this->config->global->sn)) {
            $this->loadModel('setting')->setSN();
        }

        // 重新加载语言项
        if ($viewType == 'json' && $this->get->lang && $this->get->lang != $this->app->getClientLang()) {
            $this->userZen->reloadLang($this->get->lang);
        }

        // 检查目录权限
        $this->userZen->checkDirPermission();

        // 设置来源网址
        $this->referer = $this->userZen->setReferer($referer);

        $loginLink = inlink('login');
        $denyLink = inlink('deny');
        $locateWebRoot = $this->config->webRoot . (helper::isWithTID() ? "?tid={$this->get->tid}" : '');
        $locateReferer = $this->referer;
        if (helper::isWithTID() && strpos($locateReferer, 'tid=') === false) {
            $locateReferer .= (strpos($locateReferer, '?') === false ? '?' : '&') . "tid={$this->get->tid}";
        }

        // 如果用户已登录,则直接返回
        if ($this->user->isLogon()) {
            return $this->send($this->userZen->responseForLogon($this->referer, $viewType, $loginLink, $denyLink, $locateReferer, $locateWebRoot));
        }

        // 处理企业微信登录逻辑
        $result = $this->userZen->wxCodeLogin($this->referer, $viewType, $loginLink, $denyLink, $locateReferer, $locateWebRoot, $code, $providerID, $state);
        if ($result) {
            // 清理输出缓冲区
            while (ob_get_level()) {
                ob_end_clean();
            }
            return $this->send($result);
        }

        // 登录失败,显示登录页面
        helper::setcookie('tab', '', time());
        $loginExpired = !(preg_match("/(m=|\/)(index)(&f=|-)(index)(&|-|\.)?/", strtolower($this->referer), $output) || $this->referer == $this->config->webRoot || empty($this->referer) || preg_match("/\/www\/$/", strtolower($this->referer), $output));
        if ($this->cookie->logout) {
            $loginExpired = false;
        }

        $this->view->title = $this->lang->user->login;
        $this->view->plugins = $this->loadModel('extension')->getExpiringPlugins(true);
        $this->view->unsafeSites = $this->loadModel('misc')->checkOneClickPackage();
        $this->view->rand = updateSessionRandom();
        $this->view->keepLogin = $this->cookie->keepLogin ? $this->cookie->keepLogin : 'off';
        $this->view->sn = zget($this->config->global, 'sn', '');
        $this->view->referer = $this->referer;
        $this->view->loginExpired = $loginExpired;
        $this->display();
    }

    /**
     * 自定义登录失败响应。
     * @param string $messageExt 附加错误信息。
     * @return array 响应数组。
     */
    public function responseForLoginFailExt($messageExt)
    {
        return array('result' => 'fail', 'message' => $messageExt);
    }
    

e. 修改 module/user/zen.php

此文件类似于服务层,负责处理业务逻辑。

    /**
     * 处理企业微信扫码登录逻辑。
     *
     * @param string $referer 来源页面。
     * @param string $viewType 视图类型。
     * @param string $loginLink 登录链接。
     * @param string $denyLink 禁止访问链接。
     * @param string $locateReferer 跳转来源。
     * @param string $locateWebRoot Web根目录。
     * @param string $code 企业微信回调code。
     * @param string $providerID 企业微信配置ID。
     * @param string $state 自定义状态参数。
     * @return array 登录结果。
     */
    public function wxCodeLogin($referer = '', $viewType = '', $loginLink = '', $denyLink = '', $locateReferer = '', $locateWebRoot = '', $code = '', $providerID = '', $state = '')
    {
        $code = trim($code ?? '');
        $providerID = trim($providerID ?? '');
        $state = trim($state ?? '');

        // 可选:验证 state 参数
        // if (!$state || $state != 'your_expected_state') {
        //     return $this->responseForLoginFailExt('State参数无效');
        // }

        if (!$code) return $this->responseForLoginFailExt('Code参数缺失');
        if (!$providerID) return $this->responseForLoginFailExt('ProviderID参数缺失');

        // 加载企业微信 API 客户端
        $webhookConfig = $this->loadModel('webhook')->getByID($providerID);
        if (!$webhookConfig) {
            return $this->responseForLoginFailExt('Webhook配置未找到');
        }
        $wxConfig = json_decode($webhookConfig->secret);
        // file_put_contents('/tmp/my_debug.log', "Webhook Config: " . json_encode($webhookConfig) . " AppKey: {$wxConfig->appKey}\n", FILE_APPEND);

        $this->app->loadClass('wechatapi', true);
        $wechatApi = new wechatapi($wxConfig->appKey, $wxConfig->appSecret, $wxConfig->agentId);

        // 通过 code 获取用户 ID
        $userId = $wechatApi->getUserByScanCode($code);
        // file_put_contents('/tmp/my_debug.log', "获取到的 UserID: {$userId}\n", FILE_APPEND);
        if (!$userId) {
            return $this->responseForLoginFailExt('未能从企业微信获取用户ID');
        }

        // 在 OAUTH 表中查找关联信息
        $authInfo = $this->dao->select('*')
            ->from(TABLE_OAUTH)
            ->where('providerType')->eq('webhook')
            ->andWhere('providerID')->eq($providerID)
            ->andWhere('openID')->eq($userId) // 企业微信此处应为 userid
            ->fetch();

        if (!$authInfo) {
            return $this->responseForLoginFailExt('未找到用户与企业微信的绑定关系');
        }

        // 根据关联信息查找禅道用户
        $user = $this->dao->select('*')
            ->from(TABLE_USER)
            ->where('deleted')->eq('0')
            ->andWhere('account')->eq($authInfo->account)
            ->fetch();

        if (!$user) {
            return $this->responseForLoginFailExt('未找到关联的禅道用户');
        }

        // 执行禅道原生登录流程
        helper::setcookie('logout', false, 0);
        $user = $this->user->login($user, true, false); // 这里的 true 和 false 可能需要根据实际情况调整

        // 根据视图类型返回结果
        if ($viewType == 'json') {
            return array('status' => 'success', 'user' => $this->getUserForJSON($user));
        }

        // 判断是否需要跳转到来源页面
        if (!$referer || strpos($referer, $loginLink) !== false || strpos($referer, $denyLink) !== false || strpos($referer, 'ajax') !== false || strpos($referer, 'block') !== false) {
            return array('result' => 'success', 'locate' => $locateWebRoot);
        }

        // 解析来源页面的模块和方法
        list($module, $method) = $this->parseLoginModuleAndMethod($referer);

        // 校验模块和方法是否合法
        if (empty($module) || empty($method) || !$this->app->checkModuleName($module, false) || !$this->app->checkMethodName($module, false)) {
            return array('result' => 'success', 'locate' => $locateWebRoot);
        }

        // 检查权限并跳转
        if (common::hasPriv($module, $method)) {
            return array('result' => 'success', 'locate' => $locateReferer);
        }

        return array('result' => 'success', 'locate' => $locateWebRoot);
    }

    /**
     * 解析登录时模块和方法。
     * @param string $referer 来源URL。
     * @return array 模块和方法数组。
     */
    private function parseLoginModuleAndMethod($referer)
    {
        $pathInfo = parse_url($referer);
        if (empty($pathInfo['path'])) return array('', '');

        $path = trim($pathInfo['path'], '/');
        $segments = explode('/', $path);

        if (count($segments) >= 2) {
            // 移除可能的前缀,例如 'index.php?m='
            if ($segments[0] == 'index.php' && count($segments) >= 3) {
                if ($segments[1] == 'm') return array($segments[2], isset($segments[4]) ? $segments[4] : ''); // 假设格式为 index.php?m=module&f=method
            }
            // 简单的 m-f 结构
            if ($segments[0] == 'm' && $segments[2] == 'f') return array($segments[1], $segments[3]);
            // 另一种可能的结构
            if (isset($segments[1]) && isset($segments[3]) && $segments[2] == 'f') return array($segments[0], $segments[3]);
            
            // 尝试标准 URL 结构 /module-method.html
             if (strpos($segments[0], '-') !== false) {
                 list($module, $methodPart) = explode('-', $segments[0], 2);
                 $method = str_replace('.html', '', $methodPart);
                 return array($module, $method);
             }

        }
        return array('', '');
    }
    
    /**
     * 获取用于JSON响应的用户信息。
     * @param object $user 用户对象。
     * @return array 格式化后的用户信息。
     */
    public function getUserForJSON($user)
    {
        // 此处可以根据需要自定义返回的用户信息字段
        return array(
            'id' => $user->id,
            'account' => $user->account,
            'realname' => $user->realname,
            'avatar' => $user->avatar,
            'role' => $user->role,
        );
    }
    

f. 修改 module/user/ui/login.html.php

复制 login.html.php 并重命名为 wxlogin.html.php。

g. 修改 module/user/view/m.login.html.php

复制 m.login.html.php 并重命名为 m.wxlogin.html.php。

2. 嵌入企业微信二维码

a. 修改 module/user/ui/login.html.php

在 <head> 部分引入企业微信的 JavaScript SDK。可以选择在线引入或本地引入。

  • 在线引入:<script src="https://wwcdn.weixin.qq.com/node/open/js/wecom-jssdk-2.0.2.js"></script>
  • 本地引入:将 SDK 文件(如 wecom-jssdk-2.0.2.js)放置在 www/js/ 目录下,然后使用 <script src="<?php echo $config->webRoot;?>js/wecom-jssdk-2.0.2.js"></script>

将原有的登录 logo 部分替换为企业微信登录组件的容器,ID 设置为 wxCode。

修改前:

    cell
        (
            set::width('5/12'),
            setID('logo-box'),
            set::style(array('background-image' => 'url(' . $imgBasePath . $config->user->loginImg['bg'] . ')')),
            h::img(setID('login-logo'), set::style(array('top' => $logoVerticalMargin)),  set::src($imgBasePath . $config->user->loginImg['logo'])),
            h::img(setID('login-ai'),   set::style(array('bottom' => $aiVerticalMargin)), set::src($imgBasePath . $config->user->loginImg['ai']))
        ),
    

修改后:

    cell
        (
            set::width('5/12'),
            setID('wxCode')
        ),
    

b. 修改 module/user/js/login.ui.js

在文件末尾添加 JavaScript 代码来初始化企业微信登录组件。

    // 初始化企业微信登录组件
    const wwLoginPanel = ww.createWWLoginPanel({
        el: '#wxCode', // 挂载点
        params: {
            login_type: 'CorpApp', // 登录类型,根据企业微信应用类型选择
            appid: 'YOUR_CORP_APP_ID',   // 替换为您的企业微信应用 ID
            agentid: 'YOUR_AGENT_ID',   // 替换为您的企业微信 Agent ID
            redirect_uri: 'YOUR_ZENTAO_DOMAIN/zentao/user-wxlogin.html?providerID=ID_FROM_WEBHOOK_TABLE', // 回调地址,需包含 webhook 表中的 providerID
            state: 'YOUR_CUSTOM_STATE',    // 自定义状态参数,用于安全校验
            panel_size: 'small',       // 组件尺寸
            redirect_type: 'callback', // 跳转类型
        },
        // 登录状态检查回调
        onCheckWeComLogin({ isWeComLogin }) {
            console.log('Is WeCom Login:', isWeComLogin);
        },
        // 扫码成功后,获取 code 并自动跳转登录
        onLoginSuccess({ code }) {
            // 构建跳转链接,动态获取接口地址
            let wxloginUrl = $.createLink('user', 'wxlogin');
            // 拼接参数, providerID=X 是指 webhook 表中对应企业微信配置的 ID
            location.href = `${wxloginUrl}?code=${code}&state=YOUR_CUSTOM_STATE&providerID=ID_FROM_WEBHOOK_TABLE`;
        },
        // 登录失败回调
        onLoginFail(err) {
            console.error('Login Failed:', err);
            // 可以在此处显示错误提示
            // ui.Modal.alert('登录失败,请重试。');
        },
    });
    

请务必将 appid, agentid, redirect_uri 中的域名和 providerID 替换为您实际的配置值。

c. 修改 module/user/css/login.ui.css

在此文件中可以调整登录页面的样式。

重要提示:

  • 在企业微信管理后台的应用设置中,确保 redirect_uri 已被正确配置并允许回调。
  • 数据库的 zt_oauth 表需要预先配置好企业微信的 providerType ('webhook')、providerID(与JS中的对应)、openID(企业微信用户ID)以及对应的 account(禅道用户名)。

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。