CRMEB BZ v6.0 二开:增加邮箱登录注册功能

在 CRMEB BZ v6.0 项目里,H5 端默认已经提供了账号密码登录、手机号验证码登录、手机号注册、手机号找回密码等能力。对于国内商城业务来说,这套手机号体系基本够用;但如果项目要面向企业客户、海外用户、B 端采购商,或者希望降低短信成本,邮箱登录和邮箱注册就是一个很常见的二开需求。

这篇文章记录一下基于当前项目结构扩展“邮箱登录注册”的思路。重点不是堆一份完整补丁,而是把改造边界、关键代码点和容易踩坑的地方梳理清楚,方便后续按项目实际情况落地。

一、现有登录注册链路

先看当前项目的主线。H5 端登录注册接口主要集中在:

  • 后端路由:crmeb/app/api/route/v1.php
  • 登录控制器:crmeb/app/api/controller/v1/LoginController.php
  • 登录服务:crmeb/app/services/user/LoginServices.php
  • 注册校验器:crmeb/app/api/validate/user/RegisterValidates.php
  • 用户模型:crmeb/app/model/user/User.php
  • 移动端登录页:template/uni-app/pages/users/login/index.vue
  • 前端接口封装:template/uni-app/api/user.js

项目当前的账号密码登录逻辑大致如下:

public function login($account, $password, $spread, $agent_id)
{
    $user = $this->dao->getOne(['account|phone' => $account, 'is_del' => 0]);
    if ($user) {
        if ($user->pwd !== md5((string)$password)) {
            throw new ApiException('账号或密码错误');
        }
    } else {
        throw new ApiException('账号或密码错误');
    }

    if (!$user['status']) {
        throw new ApiException('您已被禁止登录,请联系管理员');
    }

    $token = $this->createToken((int)$user['uid'], 'api');
    return ['token' => $token['token'], 'expires_time' => $token['params']['exp']];
}

也就是说,登录时会用 account|phone 查询用户。注册时,RegisterValidates 又把 account 限制成手机号:

protected $regex = ['phone' => '/^1[3456789]\d{9}$/'];

protected $rule = [
    'phone' => 'require|regex:phone',
    'account' => 'require|regex:phone',
    'captcha' => 'require|length:6',
    'password' => 'require',
];

所以要支持邮箱,需要同时处理三个地方:数据存储、后端校验与业务逻辑、前端输入与接口参数。

二、数据库字段设计

当前 eb_user 表里有 account、phone、pwd,默认没有单独的邮箱字段。虽然可以把邮箱直接塞到 account,但不建议这么做。原因有三个:

  1. 手机号和邮箱的业务语义不同,后续绑定、脱敏、后台搜索都会混在一起。
  2. 当前项目很多地方默认 phone 是手机号,强行复用容易影响订单、客服、发票等场景。
  3. 独立邮箱字段便于加索引、标记验证状态和做后台筛选。

建议新增字段:

ALTER TABLE eb_user
ADD COLUMN email varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱地址' AFTER phone,
ADD COLUMN email_status tinyint(1) NOT NULL DEFAULT 0 COMMENT '邮箱验证状态:0未验证,1已验证' AFTER email,
ADD KEY email (email) USING BTREE;

如果业务要求邮箱唯一,推荐使用普通索引加业务层校验,而不是直接给空字符串字段建唯一索引。因为大量老用户默认 email 为空,唯一索引会和历史数据冲突。更稳妥的方式是在注册和绑定时查询 email 是否已经被其他未注销用户占用。

用户模型也可以加一个搜索器,方便后台或服务层复用:

public function searchEmailAttr($query, $value)
{
    $query->where('email', $value);
}

如果后台用户列表需要按邮箱搜索,还可以把 searchLikeAttr 从 account、nickname、phone、real_name、uid 扩展到 email。

三、邮箱验证码接口设计

当前手机号验证码接口是 register/verify,内部依赖短信服务 SmsService,验证码缓存 key 是 code_手机号。邮箱验证码可以复用这套思路,但建议单独拆接口,避免短信和邮件逻辑揉在一起。

可以新增路由:

Route::post('email/verify', LoginController::class . '@emailVerify')
    ->name('emailVerify')
    ->option(['real_name' => '邮箱验证码发送']);

缓存 key 建议带上业务前缀:

$email = strtolower(trim($email));
$emailCodeKey = 'email.code.' . md5($email);
$emailMinuteKey = 'email.minute.' . md5($email) . date('YmdHi');
$emailIpKey = 'email.ip.' . app()->request->ip() . '.' . date('Ymd');

这样做有两个好处:一是不会和原来的 code_手机号 混淆,二是邮箱大小写统一转小写后缓存,避免 Test@qq.com 和 test@qq.com 被当成两个账号。

控制器示例:

public function emailVerify(Request $request)
{
    [$email, $type, $captchaType, $captchaVerification] = $request->postMore([
        ['email', ''],
        ['type', 'register'],
        ['captchaType', ''],
        ['captchaVerification', ''],
    ], true);

    $email = strtolower(trim($email));
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        return app('json')->fail('邮箱格式不正确');
    }

    try {
        aj_captcha_check_two($captchaType, $captchaVerification);
    } catch (Throwable $e) {
        return app('json')->fail($e->getMessage());
    }

    $this->services->sendEmailCode($email, $type);
    return app('json')->success('验证码发送成功');
}

这里继续保留滑块二次验证,是为了和原短信验证码接口的安全策略保持一致。验证码接口是高频攻击入口,不建议直接裸发邮件。

四、邮件发送服务封装

项目当前主要是短信验证码能力,没有完整的 SMTP 邮件服务封装。因此可以新增一个轻量服务,例如:

crmeb/app/services/message/notice/EmailService.php

配置可以放在 .env 或后台系统配置里。二开早期用 .env 最快:

[EMAIL]
SMTP_HOST = smtp.example.com
SMTP_PORT = 465
SMTP_USER = notice@example.com
SMTP_PASS = your_password
SMTP_FROM = notice@example.com
SMTP_NAME = CRMEB商城
SMTP_SECURE = ssl

如果项目已经引入 PHPMailer 或 Symfony Mailer,可以直接封装;没有依赖时,可以用 Composer 增加:

composer require phpmailer/phpmailer

服务层示例:

namespace app\services\message\notice;

use PHPMailer\PHPMailer\PHPMailer;
use think\facade\Env;

class EmailService
{
    public function sendVerifyCode(string $email, string $code, int $minutes = 5): bool
    {
        $mail = new PHPMailer(true);
        $mail->CharSet = 'UTF-8';
        $mail->isSMTP();
        $mail->Host = Env::get('email.smtp_host');
        $mail->SMTPAuth = true;
        $mail->Username = Env::get('email.smtp_user');
        $mail->Password = Env::get('email.smtp_pass');
        $mail->SMTPSecure = Env::get('email.smtp_secure', 'ssl');
        $mail->Port = (int)Env::get('email.smtp_port', 465);

        $mail->setFrom(Env::get('email.smtp_from'), Env::get('email.smtp_name', 'CRMEB商城'));
        $mail->addAddress($email);
        $mail->isHTML(true);
        $mail->Subject = '邮箱验证码';
        $mail->Body = '您的验证码是:' . $code . ',' . $minutes . '分钟内有效。';

        return $mail->send();
    }
}

实际生产环境建议把邮件发送耗时、异常和失败原因记录下来。验证码场景要求即时反馈,前期可以同步发送,后续再根据接口耗时决定是否改成队列。

五、LoginServices 增加邮箱注册能力

在 LoginServices 中新增发送邮箱验证码的方法:

public function sendEmailCode(string $email, string $type = 'register')
{
    if ($type === 'register' && $this->dao->getOne(['email' => $email, 'is_del' => 0])) {
        throw new ApiException('邮箱已注册');
    }

    if ($type === 'reset' && !$this->dao->getOne(['email' => $email, 'is_del' => 0])) {
        throw new ApiException('邮箱账号不存在');
    }

    $code = (string)rand(100000, 999999);
    $minutes = (int)sys_config('verify_expire_time', 5);

    $emailService = app()->make(EmailService::class);
    if (!$emailService->sendVerifyCode($email, $code, $minutes)) {
        throw new ApiException('邮件验证码发送失败');
    }

    CacheService::set('email.code.' . md5($email), $code, $minutes * 60);
    return true;
}

再新增邮箱注册方法。核心思路是:email 写入独立字段,email_status 标记已验证,phone 保持为空,注册成功后仍然触发新人奖励和注册事件。

public function emailRegister(string $email, string $password, $spread, string $user_type = 'h5')
{
    $email = strtolower(trim($email));

    if ($this->dao->getOne(['email' => $email, 'is_del' => 0])) {
        throw new ApiException('邮箱已注册');
    }

    $data = [];
    $data['account'] = $email;
    $data['email'] = $email;
    $data['email_status'] = 1;
    $data['phone'] = '';
    $data['pwd'] = md5((string)$password);
    $data['real_name'] = '';
    $data['birthday'] = 0;
    $data['card_id'] = '';
    $data['mark'] = '';
    $data['addres'] = '';
    $data['user_type'] = $user_type;
    $data['add_time'] = time();
    $data['add_ip'] = app('request')->ip();
    $data['last_time'] = time();
    $data['last_ip'] = app('request')->ip();
    $data['nickname'] = substr($email, 0, strpos($email, '@')) ?: '邮箱用户';
    $data['avatar'] = sys_config('h5_avatar');
    $data['status'] = 1;

    $user = $this->dao->save($data);
    if (!$user) {
        throw new ApiException('注册失败');
    }

    app()->make(UserServices::class)->rewardNewUser((int)$user->uid);
    event('UserRegisterListener', [$spread, $user_type, $data['nickname'], $user->uid, 1]);

    return $user;
}

这里保留了原注册流程里的新人奖励、注册事件和推广关系逻辑,避免新增邮箱注册后丢失项目原有的运营能力。

六、邮箱登录查询条件改造

账号密码登录可以直接扩展原来的查询条件:

$user = $this->dao->getOne([
    ['is_del', '=', 0],
    ['account|phone|email', '=', $account],
]);

如果希望手机号和邮箱都可以从同一个输入框登录,这样改最简单。

不过要注意一个细节:老项目里 account 字段长度是 varchar(32),邮箱可能超过 32 位。如果继续把邮箱写入 account,长邮箱会被截断。因此建议同步把 account 扩到 100:

ALTER TABLE eb_user
MODIFY COLUMN account varchar(100) NOT NULL DEFAULT '' COMMENT '用户账号';

或者更稳一点:邮箱注册时 account 生成一个内部账号,比如 email_用户ID,真正登录只查 email 字段。这样能减少对旧逻辑的影响。

七、控制器增加邮箱注册接口

在 LoginController 中可以新增:

public function emailRegister(Request $request)
{
    [$email, $captcha, $password, $spread] = $request->postMore([
        ['email', ''],
        ['captcha', ''],
        ['password', ''],
        ['spread', 0],
    ], true);

    $email = strtolower(trim($email));
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        return app('json')->fail('邮箱格式不正确');
    }
    if (!$captcha || strlen($captcha) !== 6) {
        return app('json')->fail('验证码错误');
    }
    if (strlen(trim($password)) < 6 || strlen(trim($password)) > 32) {
        return app('json')->fail('账号密码必须是在6到32位之间');
    }
    if (md5($password) == md5('123456')) {
        return app('json')->fail('密码太过简单,请输入较为复杂的密码');
    }

    $cacheKey = 'email.code.' . md5($email);
    $verifyCode = CacheService::get($cacheKey);
    if (!$verifyCode) {
        return app('json')->fail('请先获取验证码');
    }
    if ($verifyCode != $captcha) {
        return app('json')->fail('验证码错误');
    }

    $this->services->emailRegister($email, $password, $spread, 'h5');
    CacheService::delete($cacheKey);

    return app('json')->success('注册成功');
}

路由新增:

Route::post('email/register', LoginController::class . '@emailRegister')
    ->name('emailRegister')
    ->option(['real_name' => '邮箱注册']);

如果希望“邮箱验证码登录”也支持未注册自动创建用户,可以仿照现有 mobile 方法,再新增 emailLoginByCode。安全角度上,邮箱验证码登录建议和邮箱注册分开,避免用户误输邮箱时自动创建大量无效账户。

八、前端改造点

移动端登录页在:

template/uni-app/pages/users/login/index.vue

当前账号登录的输入提示是“输入手机号码”,并且 submit 方法里有账号格式限制:

if (!/^[\w\d]{5,16}$/i.test(that.account)) {
  return that.$util.Tips({ title: that.$t('请输入正确的账号') });
}

如果要支持邮箱,需要改成“手机号/邮箱/账号”混合判断:

const isPhone = /^1(3|4|5|7|8|9|6)\d{9}$/i.test(that.account);
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(that.account);
const isAccount = /^[\w\d]{5,32}$/i.test(that.account);

if (!isPhone && !isEmail && !isAccount) {
  return that.$util.Tips({ title: that.$t('请输入正确的账号、手机号或邮箱') });
}

前端接口封装可以新增:

export function emailVerify(data) {
  return request.post('email/verify', data, { noAuth: true });
}

export function emailRegister(data) {
  return request.post('email/register', data, { noAuth: true });
}

UI 上建议加一个登录方式切换:

  • 账号登录:账号/手机号/邮箱 + 密码
  • 快速登录:手机号 + 验证码
  • 邮箱注册:邮箱 + 邮箱验证码 + 密码

不要把手机号验证码和邮箱验证码塞在同一个按钮里用复杂判断硬分支,后面维护会很难。

九、找回密码和绑定邮箱

邮箱注册做完后,通常还要补两个能力:

  1. 邮箱找回密码:逻辑类似 register/reset,只是验证码缓存从 code_手机号 换成 email.code.md5(email)。
  2. 登录后绑定邮箱:适合微信/小程序用户补充邮箱,接口需要登录态,更新当前 uid 的 email 和 email_status。

绑定邮箱时要注意:

if ($this->dao->getOne([['email', '=', $email], ['uid', '<>', $uid], ['is_del', '=', 0]])) {
    throw new ApiException('该邮箱已被绑定');
}

否则不同用户可能绑定同一个邮箱,后续邮箱登录会产生歧义。

十、测试清单

邮箱登录注册属于账号体系改造,测试一定要覆盖完整:

  • 新邮箱获取验证码成功。
  • 已注册邮箱再次注册时提示“邮箱已注册”。
  • 验证码错误、过期、重复使用都能拦截。
  • 邮箱注册成功后能正常触发新人奖励和注册事件。
  • 邮箱 + 密码可以登录。
  • 原手机号登录、手机号验证码登录不受影响。
  • 被禁用用户无法通过邮箱登录。
  • 后台用户列表能按邮箱搜索。
  • 邮箱大小写统一处理,例如 Test@Email.com 和 test@email.com 视为同一邮箱。
  • 短时间重复发邮件有频控,避免被刷接口。

十一、几个容易踩坑的点

第一,邮箱字段长度要够。很多企业邮箱会超过 32 位,不能沿用原 account varchar(32) 的长度。

第二,验证码缓存 key 要和短信分开。不要继续用 code_账号 这种宽泛 key,否则手机号和邮箱可能互相覆盖。

第三,邮箱发送失败要给用户明确提示,同时记录日志,方便排查 SMTP 配置、授权码、端口、防火墙等问题。

第四,注册成功后不要绕过原项目事件。CRMEB 的注册流程里有新人奖励、推广关系、自定义事件等逻辑,邮箱注册也应该复用这些能力。

第五,前端不要只改占位文字。当前登录页里有多处手机号正则校验,必须一起调整,否则用户输入邮箱后仍然会被前端拦截。

十二、总结

给 CRMEB BZ v6.0 增加邮箱登录注册,本质上不是简单加两个接口,而是对账号体系做一次平滑扩展。比较稳的做法是:保留原手机号体系,新增独立 email 字段,增加邮箱验证码发送、邮箱注册和邮箱登录查询能力,再同步调整前端输入校验。

这样改造的好处是兼容性强。老用户仍然可以手机号登录,新用户可以邮箱注册,微信、小程序、手机号验证码等原有链路也不会被破坏。后续如果要继续做海外化、多语言商城、企业采购账户体系,也能在这个基础上继续扩展。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐