mirror of
https://gitee.com/ShopeX/ECShopX
synced 2026-08-11 21:55:32 +08:00
4.5.0
This commit is contained in:
@@ -2042,7 +2042,7 @@ class AftersalesService
|
||||
->from('aftersales_detail', 'ad')
|
||||
->leftJoin('ad', 'aftersales', 'a', 'ad.aftersales_bn = a.aftersales_bn');
|
||||
|
||||
$row = 'a.aftersales_bn,a.order_id,ad.item_bn,ad.item_name,ad.num,a.aftersales_type,a.aftersales_status,a.create_time,ad.refund_fee,a.progress,a.description,a.reason,a.refuse_reason,a.memo,ad.distributor_id,ad.company_id';
|
||||
$row = 'a.aftersales_bn,a.order_id,ad.item_bn,ad.item_name,ad.num,a.aftersales_type,a.aftersales_status,a.create_time,ad.refund_fee,a.progress,a.description,a.reason,a.refuse_reason,a.memo,ad.distributor_id,ad.company_id,a.memo,a.distributor_remark';
|
||||
|
||||
$criteria = $this->getFilter($filter, $criteria);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace CompanysBundle\Http\Api\V1\Action;
|
||||
use App\Http\Controllers\Controller as BaseController;
|
||||
|
||||
use CompanysBundle\Services\CommonLangModService;
|
||||
use CompanysBundle\Services\MailSettingActivationBaseUrlResolver;
|
||||
use CompanysBundle\Services\SettingService;
|
||||
use OrdersBundle\Services\TradeSetting\CancelService;
|
||||
use OrdersBundle\Services\TradeSettingService;
|
||||
@@ -2050,8 +2051,22 @@ class Setting extends BaseController
|
||||
* @SWG\Parameter(
|
||||
* name="EMAIL_PASSWORD",
|
||||
* in="query",
|
||||
* description="SMTP密码",
|
||||
* required=true,
|
||||
* description="SMTP密码;留空保留原密码;可回传 GET /mail/setting 返回的密文",
|
||||
* required=false,
|
||||
* type="string",
|
||||
* ),
|
||||
* @SWG\Parameter(
|
||||
* name="EMAIL_ACTIVATION_H5_DOMAIN",
|
||||
* in="query",
|
||||
* description="H5邮箱激活根地址(选填,建议无末尾/);不传则保留原值;传空字符串可清空",
|
||||
* required=false,
|
||||
* type="string",
|
||||
* ),
|
||||
* @SWG\Parameter(
|
||||
* name="EMAIL_ACTIVATION_PC_DOMAIN",
|
||||
* in="query",
|
||||
* description="PC邮箱激活根地址(选填);不传则保留原值;传空字符串可清空",
|
||||
* required=false,
|
||||
* type="string",
|
||||
* ),
|
||||
* @SWG\Response(
|
||||
@@ -2072,31 +2087,50 @@ class Setting extends BaseController
|
||||
{
|
||||
$companyId = app('auth')->user()->get('company_id');
|
||||
$key = 'mailSetting:' . $companyId;
|
||||
|
||||
|
||||
$inputdata = $request->all();
|
||||
$rules = [
|
||||
'EMAIL_SMTP_PORT' => ['required', 'SMTP端口不能为空'],
|
||||
'EMAIL_RELAY_HOST' => ['required', 'SMTP服务器地址不能为空'],
|
||||
'EMAIL_SENDER' => ['required|email', '发件人邮箱不能为空且格式正确'],
|
||||
'EMAIL_USER' => ['required', 'SMTP用户名不能为空'],
|
||||
'EMAIL_PASSWORD' => ['required', 'SMTP密码不能为空'],
|
||||
];
|
||||
|
||||
|
||||
$errorMessage = validator_params($inputdata, $rules);
|
||||
if ($errorMessage) {
|
||||
throw new ResourceException($errorMessage);
|
||||
}
|
||||
|
||||
|
||||
$existingRaw = app('redis')->connection('companys')->get($key);
|
||||
$existing = $existingRaw ? json_decode((string) $existingRaw, true) : [];
|
||||
if (!is_array($existing)) {
|
||||
$existing = [];
|
||||
}
|
||||
$existingPlain = (string) ($existing['EMAIL_PASSWORD'] ?? '');
|
||||
$passwordInput = array_key_exists('EMAIL_PASSWORD', $inputdata) ? (string) $inputdata['EMAIL_PASSWORD'] : '';
|
||||
$resolvedPassword = $this->resolveMailPasswordFromSaveInput($passwordInput, $existingPlain);
|
||||
if ($resolvedPassword === '') {
|
||||
throw new ResourceException('SMTP密码不能为空');
|
||||
}
|
||||
|
||||
$mailConfig = [
|
||||
'EMAIL_SMTP_PORT' => $inputdata['EMAIL_SMTP_PORT'],
|
||||
'EMAIL_RELAY_HOST' => $inputdata['EMAIL_RELAY_HOST'],
|
||||
'EMAIL_SENDER' => $inputdata['EMAIL_SENDER'],
|
||||
'EMAIL_USER' => $inputdata['EMAIL_USER'],
|
||||
'EMAIL_PASSWORD' => $inputdata['EMAIL_PASSWORD'],
|
||||
'EMAIL_PASSWORD' => $resolvedPassword,
|
||||
];
|
||||
|
||||
foreach ([MailSettingActivationBaseUrlResolver::KEY_H5, MailSettingActivationBaseUrlResolver::KEY_PC] as $optKey) {
|
||||
if (array_key_exists($optKey, $inputdata)) {
|
||||
$mailConfig[$optKey] = MailSettingActivationBaseUrlResolver::sanitizeStoredDomain((string) $inputdata[$optKey]);
|
||||
} else {
|
||||
$prev = isset($existing[$optKey]) ? (string) $existing[$optKey] : '';
|
||||
$mailConfig[$optKey] = MailSettingActivationBaseUrlResolver::sanitizeStoredDomain($prev);
|
||||
}
|
||||
}
|
||||
|
||||
app('redis')->connection('companys')->set($key, json_encode($mailConfig));
|
||||
|
||||
|
||||
return $this->response->array(['status' => true]);
|
||||
}
|
||||
|
||||
@@ -2125,7 +2159,9 @@ class Setting extends BaseController
|
||||
* @SWG\Property(property="EMAIL_RELAY_HOST", type="string", description="SMTP服务器地址"),
|
||||
* @SWG\Property(property="EMAIL_SENDER", type="string", description="发件人邮箱"),
|
||||
* @SWG\Property(property="EMAIL_USER", type="string", description="SMTP用户名"),
|
||||
* @SWG\Property(property="EMAIL_PASSWORD", type="string", description="SMTP密码"),
|
||||
* @SWG\Property(property="EMAIL_PASSWORD", type="string", description="SMTP密码(密文,基于 APP_KEY 加密,非明文)"),
|
||||
* @SWG\Property(property="EMAIL_ACTIVATION_H5_DOMAIN", type="string", description="H5邮箱激活根地址(选填)"),
|
||||
* @SWG\Property(property="EMAIL_ACTIVATION_PC_DOMAIN", type="string", description="PC邮箱激活根地址(选填)"),
|
||||
* ),
|
||||
* ),
|
||||
* ),
|
||||
@@ -2138,17 +2174,62 @@ class Setting extends BaseController
|
||||
$key = 'mailSetting:' . $companyId;
|
||||
|
||||
$data = app('redis')->connection('companys')->get($key);
|
||||
$result = $data ? json_decode($data, true) : [
|
||||
$result = $data ? json_decode((string) $data, true) : [
|
||||
'EMAIL_SMTP_PORT' => '',
|
||||
'EMAIL_RELAY_HOST' => '',
|
||||
'EMAIL_SENDER' => '',
|
||||
'EMAIL_USER' => '',
|
||||
'EMAIL_PASSWORD' => '',
|
||||
MailSettingActivationBaseUrlResolver::KEY_H5 => '',
|
||||
MailSettingActivationBaseUrlResolver::KEY_PC => '',
|
||||
];
|
||||
|
||||
if (!is_array($result)) {
|
||||
$result = [
|
||||
'EMAIL_SMTP_PORT' => '',
|
||||
'EMAIL_RELAY_HOST' => '',
|
||||
'EMAIL_SENDER' => '',
|
||||
'EMAIL_USER' => '',
|
||||
'EMAIL_PASSWORD' => '',
|
||||
MailSettingActivationBaseUrlResolver::KEY_H5 => '',
|
||||
MailSettingActivationBaseUrlResolver::KEY_PC => '',
|
||||
];
|
||||
}
|
||||
$result[MailSettingActivationBaseUrlResolver::KEY_H5] = (string) ($result[MailSettingActivationBaseUrlResolver::KEY_H5] ?? '');
|
||||
$result[MailSettingActivationBaseUrlResolver::KEY_PC] = (string) ($result[MailSettingActivationBaseUrlResolver::KEY_PC] ?? '');
|
||||
if (!empty($result['EMAIL_PASSWORD'])) {
|
||||
$result['EMAIL_PASSWORD'] = $this->encryptMailPasswordForApiResponse((string) $result['EMAIL_PASSWORD']);
|
||||
}
|
||||
|
||||
return $this->response->array($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /mail/setting:不向客户端暴露明文 SMTP 密码。
|
||||
*/
|
||||
private function encryptMailPasswordForApiResponse(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return app('fixedencrypt')->default()->encrypt($plain, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /mail/setting:空字符串表示沿用 Redis 中已存明文;非空则尝试按密文解密,失败则视为明文新密码。
|
||||
*/
|
||||
private function resolveMailPasswordFromSaveInput(string $input, string $existingPlain): string
|
||||
{
|
||||
if ($input === '') {
|
||||
return $existingPlain;
|
||||
}
|
||||
try {
|
||||
return app('fixedencrypt')->default()->decrypt($input, false);
|
||||
} catch (\Throwable $e) {
|
||||
return $input;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取酷家乐配置
|
||||
* @route GET /company/kujiale/config
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace CompanysBundle\Services;
|
||||
|
||||
use CompanysBundle\Services\Shops\WxShopsService;
|
||||
use DistributionBundle\Services\DistributorService;
|
||||
|
||||
/**
|
||||
* 与 GET /companys/setting(Companys::getCompanySetting)中 brand_name 规则一致,供邮件等无 JWT 场景按 company_id 解析。
|
||||
*/
|
||||
class CompanySettingBrandNameService
|
||||
{
|
||||
public const DEFAULT_BRAND = 'ECShopX';
|
||||
|
||||
/**
|
||||
* 解析店铺展示用品牌名:wx 店铺设置 + 自营总店 name 覆盖;trim 后为空则 DEFAULT_BRAND(中英文同一逻辑、同一字符串)。
|
||||
*/
|
||||
public function resolveForCompanyId(int $companyId): string
|
||||
{
|
||||
$shopsService = new ShopsService(new WxShopsService());
|
||||
$result = $shopsService->getWxShopsSetting($companyId);
|
||||
if (!is_array($result)) {
|
||||
$result = [];
|
||||
}
|
||||
$selfDistributorInfo = (new DistributorService())->getDistributorSelf($companyId, true);
|
||||
if (!empty($selfDistributorInfo) && $result) {
|
||||
$result['brand_name'] = $selfDistributorInfo['name'] ?? '';
|
||||
}
|
||||
$brand = trim((string) ($result['brand_name'] ?? ''));
|
||||
|
||||
return $brand !== '' ? $brand : self::DEFAULT_BRAND;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace CompanysBundle\Services;
|
||||
|
||||
/**
|
||||
* Redis mailSetting:{companyId} 中租户级「邮箱激活」根地址(与 POST /mail/setting 字段一致)。
|
||||
*/
|
||||
class MailSettingActivationBaseUrlResolver
|
||||
{
|
||||
public const KEY_H5 = 'EMAIL_ACTIVATION_H5_DOMAIN';
|
||||
|
||||
public const KEY_PC = 'EMAIL_ACTIVATION_PC_DOMAIN';
|
||||
|
||||
public static function sanitizeStoredDomain(?string $value): string
|
||||
{
|
||||
$s = str_replace(["\r", "\n"], '', trim((string) $value));
|
||||
if (strlen($s) > 512) {
|
||||
$s = substr($s, 0, 512);
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function getH5ActivationBaseUrl(int $companyId): string
|
||||
{
|
||||
return self::sanitizeStoredDomain($this->readField($companyId, self::KEY_H5));
|
||||
}
|
||||
|
||||
public function getPcActivationBaseUrl(int $companyId): string
|
||||
{
|
||||
return self::sanitizeStoredDomain($this->readField($companyId, self::KEY_PC));
|
||||
}
|
||||
|
||||
private function readField(int $companyId, string $key): string
|
||||
{
|
||||
$raw = app('redis')->connection('companys')->get('mailSetting:' . $companyId);
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
if (!is_array($decoded) || !isset($decoded[$key])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $decoded[$key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace CompanysBundle\Services;
|
||||
|
||||
use Dingo\Api\Exception\ResourceException;
|
||||
|
||||
/**
|
||||
* 邮箱注册前校验:与 POST /mail/setting 写入的 Redis mailSetting 一致,SMTP 必填 + H5 激活域名。
|
||||
*/
|
||||
class MailSettingEmailRegistrationValidator
|
||||
{
|
||||
public static function assertReadyForMemberEmailRegistration(int $companyId): void
|
||||
{
|
||||
$raw = app('redis')->connection('companys')->get('mailSetting:' . $companyId);
|
||||
$cfg = json_decode((string) $raw, true);
|
||||
if (!is_array($cfg)) {
|
||||
$cfg = [];
|
||||
}
|
||||
$port = trim((string) ($cfg['EMAIL_SMTP_PORT'] ?? ''));
|
||||
$host = trim((string) ($cfg['EMAIL_RELAY_HOST'] ?? ''));
|
||||
$sender = trim((string) ($cfg['EMAIL_SENDER'] ?? ''));
|
||||
$user = trim((string) ($cfg['EMAIL_USER'] ?? ''));
|
||||
$password = (string) ($cfg['EMAIL_PASSWORD'] ?? '');
|
||||
$h5Domain = (new MailSettingActivationBaseUrlResolver())->getH5ActivationBaseUrl($companyId);
|
||||
|
||||
if ($port === '' || $host === '' || $sender === '' || $user === '' || $password === '' || $h5Domain === '') {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_mail_config_missing'));
|
||||
}
|
||||
if (!filter_var($sender, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_mail_config_missing'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace CompanysBundle\Services;
|
||||
|
||||
/**
|
||||
* Redis mailSetting 中 EMAIL_PASSWORD 约定为 **明文**(POST /mail/setting 保存前已解密)。
|
||||
* 若历史或误操作存入了与 GET 接口一致的密文,发信前尝试解密;失败则按明文使用。
|
||||
*/
|
||||
class MailSettingStoredPasswordResolver
|
||||
{
|
||||
public static function plainForSmtp(string $stored): string
|
||||
{
|
||||
if ($stored === '') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return app('fixedencrypt')->default()->decrypt($stored, false);
|
||||
} catch (\Throwable $e) {
|
||||
return $stored;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $configMail
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function withResolvedPassword(array $configMail): array
|
||||
{
|
||||
$raw = isset($configMail['email_password']) ? (string) $configMail['email_password'] : '';
|
||||
$configMail['email_password'] = self::plainForSmtp($raw);
|
||||
|
||||
return $configMail;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function redactForLog(array $config): array
|
||||
{
|
||||
$out = $config;
|
||||
if (array_key_exists('EMAIL_PASSWORD', $out)) {
|
||||
$out['EMAIL_PASSWORD'] = ($out['EMAIL_PASSWORD'] ?? '') === '' ? '' : '***';
|
||||
}
|
||||
if (array_key_exists('email_password', $out)) {
|
||||
$out['email_password'] = ($out['email_password'] ?? '') === '' ? '' : '***';
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -37,13 +37,8 @@ class MailerService
|
||||
{
|
||||
$this->debug = false;
|
||||
if (!empty($config)) {
|
||||
// [2025-09-16 14:42:08] production.INFO: MailerService: 邮件配置:{
|
||||
// "email_smtp_port":"465",
|
||||
// "email_relay_host":"ssl:\/\/smtp.exmail.qq.com",
|
||||
// "email_user":"test@shopex.cn",
|
||||
// "email_password":"123456"
|
||||
// }
|
||||
app('log')->info('MailerService: 邮件配置:'.json_encode($config));
|
||||
$config = MailSettingStoredPasswordResolver::withResolvedPassword($config);
|
||||
app('log')->info('MailerService: 邮件配置:'.json_encode(MailSettingStoredPasswordResolver::redactForLog($config)));
|
||||
$this->smtp_port = $config['email_smtp_port'];
|
||||
$this->relay_host = $config['email_relay_host'];
|
||||
$this->user = $config['email_user'];
|
||||
@@ -83,8 +78,21 @@ class MailerService
|
||||
->setCc($cc)
|
||||
->setBody($body, 'text/html');
|
||||
|
||||
$result = $mailer->send($message);
|
||||
return $result;
|
||||
try {
|
||||
return $mailer->send($message);
|
||||
} catch (\Throwable $e) {
|
||||
$msg = $e->getMessage();
|
||||
if (stripos($msg, '535') !== false || stripos($msg, 'authentication failed') !== false || stripos($msg, 'Failed to authenticate') !== false) {
|
||||
app('log')->error('MailerService: SMTP error (check password / client secret / Tencent exmail SMTP switch)', [
|
||||
'exception' => get_class($e),
|
||||
'message' => $msg,
|
||||
'relay_host' => $this->relay_host,
|
||||
'smtp_port' => $this->smtp_port,
|
||||
'username' => $this->user,
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
//获取邮件模板
|
||||
|
||||
@@ -35,6 +35,7 @@ use MembersBundle\Entities\MembersAssociations;
|
||||
use MembersBundle\Entities\MembersInfo;
|
||||
use MembersBundle\Services\UserService;
|
||||
use MembersBundle\Services\WechatUserService;
|
||||
use MembersBundle\Services\MemberEmailVerificationService;
|
||||
use MembersBundle\Services\MemberRegSettingService;
|
||||
use WechatBundle\Services\OfficialAccountService;
|
||||
use WechatBundle\Services\OpenPlatform;
|
||||
@@ -827,16 +828,41 @@ class EspierLocalUserProvider implements UserProvider
|
||||
private function checkUser($company_id, $mobile, $password, $check_type = 'password', $vcode = '', bool $autoRegister = false, bool $silent = false)
|
||||
{
|
||||
$membersRepository = app('registry')->getManager('default')->getRepository(Members::class);
|
||||
$userEntity = $membersRepository->findOneBy(['company_id' => $company_id, 'mobile' => fixedencrypt($mobile)]);
|
||||
$isEmail = (bool) filter_var($mobile, FILTER_VALIDATE_EMAIL);
|
||||
if ($isEmail) {
|
||||
$userEntity = $membersRepository->findOneBy(['company_id' => $company_id, 'login_email' => strtolower(trim($mobile))]);
|
||||
} else {
|
||||
$userEntity = $membersRepository->findOneBy(['company_id' => $company_id, 'mobile' => fixedencrypt($mobile)]);
|
||||
}
|
||||
|
||||
// 表单验证最优先
|
||||
switch ($check_type) {
|
||||
case "mobile":
|
||||
if ($isEmail) {
|
||||
throw new ResourceException('验证类型有误!');
|
||||
}
|
||||
if (!(new MemberRegSettingService())->checkSmsVcode($mobile, $company_id, $vcode, 'login')) {
|
||||
throw new ResourceException('短信验证码错误');
|
||||
}
|
||||
break;
|
||||
case "email_otp":
|
||||
if (!$isEmail) {
|
||||
throw new ResourceException('验证类型有误!');
|
||||
}
|
||||
if (!$userEntity) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_not_registered'));
|
||||
}
|
||||
if (!$userEntity->getEmailVerifiedAt()) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_not_verified'));
|
||||
}
|
||||
if (!(new MemberEmailVerificationService())->consumeCode((int) $company_id, $mobile, MemberEmailVerificationService::PURPOSE_LOGIN, $vcode)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_code_error'));
|
||||
}
|
||||
break;
|
||||
case "password":
|
||||
if ($userEntity && $isEmail && !$userEntity->getEmailVerifiedAt()) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_not_verified'));
|
||||
}
|
||||
if ($userEntity && !$this->checkPassword($password, $userEntity->getPassword())) {
|
||||
throw new ResourceException('用户名或密码错误');
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ class FrontNoAuthMiddleWare
|
||||
$auth = app('auth')->user();
|
||||
$mid_params['auth'] = $auth->attributes;
|
||||
$mid_params['auth']['api_from'] = 'h5app';
|
||||
// attributes 未必含 company_id;后续接口(如邮箱激活)读 auth.company_id,须与 JWT 主体一致
|
||||
$jwtCompanyId = (int) $auth->get('company_id');
|
||||
if ($jwtCompanyId > 0) {
|
||||
$mid_params['auth']['company_id'] = $jwtCompanyId;
|
||||
}
|
||||
$companyId = $request->input('company_id');
|
||||
if (!$companyId) {
|
||||
$mid_params['company_id'] = $auth->get('company_id');
|
||||
|
||||
@@ -56,7 +56,8 @@ class AftersalesRecordExportService implements ExportFileInterface
|
||||
'salesman_name' => '导购',
|
||||
'order_holder' => '订单分类',
|
||||
'supplier_name' => '来源供应商',
|
||||
'self_delivery_operator_name' => '配送员'
|
||||
'self_delivery_operator_name' => '配送员',
|
||||
'distributor_remark' => '商家备注'
|
||||
];
|
||||
|
||||
public function exportData($filter)
|
||||
|
||||
@@ -51,6 +51,7 @@ class MemberExportService implements ExportFileInterface
|
||||
private $title = [
|
||||
'user_card_code' => '会员卡编号',
|
||||
'mobile' => '会员手机号',
|
||||
'login_email' => '登录邮箱',
|
||||
'name' => '用户名',
|
||||
'sex' => ' 性别',
|
||||
'username' => '姓名',
|
||||
@@ -116,6 +117,12 @@ class MemberExportService implements ExportFileInterface
|
||||
$value['username'] = data_masking('truename', (string) $value['username']);
|
||||
$value['birthday'] = data_masking('birthday', (string) $value['birthday']);
|
||||
$value['address'] = data_masking('detailedaddress', (string) $value['address']);
|
||||
if (!empty($value['login_email'])) {
|
||||
$value['login_email'] = data_masking('email', (string) $value['login_email']);
|
||||
}
|
||||
if (!empty($value['email'])) {
|
||||
$value['email'] = data_masking('email', (string) $value['email']);
|
||||
}
|
||||
}
|
||||
//会员注册时间组装
|
||||
$created_date = $value['created_year'].'-'.$value['created_month'].'-'.$value['created_day'];
|
||||
|
||||
160
src/MembersBundle/Entities/MemberEmailActivationTokens.php
Normal file
160
src/MembersBundle/Entities/MemberEmailActivationTokens.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Entities;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* 会员邮箱激活链接令牌(仅存 token 哈希)
|
||||
*
|
||||
* @ORM\Table(name="member_email_activation_tokens", options={"comment"="会员邮箱激活链接令牌"}, indexes={
|
||||
* @ORM\Index(name="idx_company_user", columns={"company_id", "user_id"}),
|
||||
* @ORM\Index(name="idx_token_hash", columns={"token_hash"}),
|
||||
* })
|
||||
* @ORM\Entity(repositoryClass="MembersBundle\Repositories\MemberEmailActivationTokensRepository")
|
||||
*/
|
||||
class MemberEmailActivationTokens
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Id
|
||||
* @ORM\Column(name="id", type="bigint", options={"comment"="主键"})
|
||||
* @ORM\GeneratedValue(strategy="AUTO")
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="company_id", type="bigint", options={"comment"="公司 ID"})
|
||||
*/
|
||||
private $company_id;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="user_id", type="bigint", options={"comment"="会员 user_id"})
|
||||
*/
|
||||
private $user_id;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="token_hash", type="string", length=64, options={"comment"="SHA-256 哈希"})
|
||||
*/
|
||||
private $token_hash;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="expires_at", type="integer", options={"comment"="过期时间戳"})
|
||||
*/
|
||||
private $expires_at;
|
||||
|
||||
/**
|
||||
* @var int|null
|
||||
*
|
||||
* @ORM\Column(name="used_at", type="integer", nullable=true, options={"comment"="使用时间戳"})
|
||||
*/
|
||||
private $used_at;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="created_at", type="integer", options={"comment"="创建时间戳"})
|
||||
*/
|
||||
private $created_at;
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return (int) $this->id;
|
||||
}
|
||||
|
||||
public function setCompanyId(int $companyId): self
|
||||
{
|
||||
$this->company_id = $companyId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompanyId(): int
|
||||
{
|
||||
return (int) $this->company_id;
|
||||
}
|
||||
|
||||
public function setUserId(int $userId): self
|
||||
{
|
||||
$this->user_id = $userId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserId(): int
|
||||
{
|
||||
return (int) $this->user_id;
|
||||
}
|
||||
|
||||
public function setTokenHash(string $tokenHash): self
|
||||
{
|
||||
$this->token_hash = $tokenHash;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTokenHash(): string
|
||||
{
|
||||
return $this->token_hash;
|
||||
}
|
||||
|
||||
public function setExpiresAt(int $expiresAt): self
|
||||
{
|
||||
$this->expires_at = $expiresAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExpiresAt(): int
|
||||
{
|
||||
return (int) $this->expires_at;
|
||||
}
|
||||
|
||||
public function setUsedAt(?int $usedAt): self
|
||||
{
|
||||
$this->used_at = $usedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsedAt(): ?int
|
||||
{
|
||||
return $this->used_at !== null ? (int) $this->used_at : null;
|
||||
}
|
||||
|
||||
public function setCreatedAt(int $createdAt): self
|
||||
{
|
||||
$this->created_at = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): int
|
||||
{
|
||||
return (int) $this->created_at;
|
||||
}
|
||||
}
|
||||
160
src/MembersBundle/Entities/MemberPasswordResetTokens.php
Normal file
160
src/MembersBundle/Entities/MemberPasswordResetTokens.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Entities;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* 会员邮箱找回密码令牌(仅存 token 哈希)
|
||||
*
|
||||
* @ORM\Table(name="member_password_reset_tokens", options={"comment"="会员邮箱找回密码令牌"}, indexes={
|
||||
* @ORM\Index(name="idx_company_user", columns={"company_id", "user_id"}),
|
||||
* @ORM\Index(name="idx_token_hash", columns={"token_hash"}),
|
||||
* })
|
||||
* @ORM\Entity(repositoryClass="MembersBundle\Repositories\MemberPasswordResetTokensRepository")
|
||||
*/
|
||||
class MemberPasswordResetTokens
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Id
|
||||
* @ORM\Column(name="id", type="bigint", options={"comment"="主键"})
|
||||
* @ORM\GeneratedValue(strategy="AUTO")
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="company_id", type="bigint", options={"comment"="公司 ID"})
|
||||
*/
|
||||
private $company_id;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="user_id", type="bigint", options={"comment"="会员 user_id"})
|
||||
*/
|
||||
private $user_id;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="token_hash", type="string", length=64, options={"comment"="SHA-256 哈希"})
|
||||
*/
|
||||
private $token_hash;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="expires_at", type="integer", options={"comment"="过期时间戳"})
|
||||
*/
|
||||
private $expires_at;
|
||||
|
||||
/**
|
||||
* @var int|null
|
||||
*
|
||||
* @ORM\Column(name="used_at", type="integer", nullable=true, options={"comment"="使用时间戳"})
|
||||
*/
|
||||
private $used_at;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="created_at", type="integer", options={"comment"="创建时间戳"})
|
||||
*/
|
||||
private $created_at;
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return (int) $this->id;
|
||||
}
|
||||
|
||||
public function setCompanyId(int $companyId): self
|
||||
{
|
||||
$this->company_id = $companyId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompanyId(): int
|
||||
{
|
||||
return (int) $this->company_id;
|
||||
}
|
||||
|
||||
public function setUserId(int $userId): self
|
||||
{
|
||||
$this->user_id = $userId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserId(): int
|
||||
{
|
||||
return (int) $this->user_id;
|
||||
}
|
||||
|
||||
public function setTokenHash(string $tokenHash): self
|
||||
{
|
||||
$this->token_hash = $tokenHash;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTokenHash(): string
|
||||
{
|
||||
return $this->token_hash;
|
||||
}
|
||||
|
||||
public function setExpiresAt(int $expiresAt): self
|
||||
{
|
||||
$this->expires_at = $expiresAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExpiresAt(): int
|
||||
{
|
||||
return (int) $this->expires_at;
|
||||
}
|
||||
|
||||
public function setUsedAt(?int $usedAt): self
|
||||
{
|
||||
$this->used_at = $usedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsedAt(): ?int
|
||||
{
|
||||
return $this->used_at !== null ? (int) $this->used_at : null;
|
||||
}
|
||||
|
||||
public function setCreatedAt(int $createdAt): self
|
||||
{
|
||||
$this->created_at = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): int
|
||||
{
|
||||
return (int) $this->created_at;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use Gedmo\Mapping\Annotation as Gedmo;
|
||||
* @ORM\Index(name="idx_company_id_user_card_code", columns={"company_id", "user_card_code"})
|
||||
* },uniqueConstraints={
|
||||
* @ORM\UniqueConstraint(name="mobile_company", columns={"mobile", "company_id"}),
|
||||
* @ORM\UniqueConstraint(name="login_email_company", columns={"login_email", "company_id"}),
|
||||
* }),
|
||||
* @ORM\Entity(repositoryClass="MembersBundle\Repositories\MembersRepository")
|
||||
*/
|
||||
@@ -86,6 +87,20 @@ class Members
|
||||
*/
|
||||
private $password;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
*
|
||||
* @ORM\Column(name="login_email", type="string", length=255, nullable=true, options={"comment":"登录邮箱(小写)"})
|
||||
*/
|
||||
private $login_email;
|
||||
|
||||
/**
|
||||
* @var int|null
|
||||
*
|
||||
* @ORM\Column(name="email_verified_at", type="integer", nullable=true, options={"comment":"邮箱验证时间戳"})
|
||||
*/
|
||||
private $email_verified_at;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
@@ -737,6 +752,42 @@ class Members
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Members
|
||||
*/
|
||||
public function setLoginEmail(?string $loginEmail)
|
||||
{
|
||||
$this->login_email = $loginEmail;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
public function getLoginEmail()
|
||||
{
|
||||
return $this->login_email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Members
|
||||
*/
|
||||
public function setEmailVerifiedAt(?int $ts)
|
||||
{
|
||||
$this->email_verified_at = $ts;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
public function getEmailVerifiedAt()
|
||||
{
|
||||
return $this->email_verified_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sourceFrom
|
||||
*
|
||||
|
||||
@@ -534,6 +534,10 @@ class UserData extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($loginEmail = trim((string) $request->input('login_email', ''))) {
|
||||
$memFilter['login_email|like'] = mb_strtolower($loginEmail);
|
||||
}
|
||||
|
||||
$memberService = new MemberService();
|
||||
$result['list'] = $memberService->getMemberList($memFilter, $page, $limit);
|
||||
$result['total_count'] = $memberService->getMemberCount($memFilter);
|
||||
@@ -758,7 +762,10 @@ class UserData extends Controller
|
||||
if (!$userIds) {
|
||||
return $this->response->array($result);
|
||||
}
|
||||
$col = "m.user_id,m.grade_id,m.mobile,m.user_card_code,m.remarks,info.username,info.sex,info.avatar";
|
||||
if ($loginEmail = trim((string) $request->get('login_email', ''))) {
|
||||
$memFilter['login_email|like'] = mb_strtolower($loginEmail);
|
||||
}
|
||||
$col = "m.user_id,m.grade_id,m.mobile,m.user_card_code,m.remarks,m.login_email,m.email_verified_at,info.username,info.sex,info.avatar";
|
||||
$result = $memberService->getMemberDataLists($memFilter, $col, 1, $limit);
|
||||
if ($result['list'] && $result['total_count']) {
|
||||
$member = array_column($result['list'], null, 'user_id');
|
||||
|
||||
@@ -47,6 +47,7 @@ use CommunityBundle\Services\CommunityChiefService;
|
||||
use CommunityBundle\Services\CommunityChiefDistributorService;
|
||||
use MembersBundle\Traits\GetCodeTrait;
|
||||
use PopularizeBundle\Services\PromoterService;
|
||||
use MembersBundle\Services\ShopRelMemberService;
|
||||
use MembersBundle\Events\CreateMemberSuccessEvent;
|
||||
use KaquanBundle\Services\UserDiscountService;
|
||||
use MembersBundle\Entities\MembersDeleteRecord;
|
||||
@@ -331,6 +332,7 @@ class Members extends Controller
|
||||
// 'mobile' => ['sometimes|regex:/^1[3456789][0-9]{9}$/', '请填写正确的手机号'],
|
||||
'remarks' => ['sometimes|string|max:255', '最多输入255字'],
|
||||
'username' => ['sometimes|string|max:50', '最多输入50字'],
|
||||
'login_email' => ['sometimes|string|max:255', '登录邮箱筛选参数过长'],
|
||||
'name' => ['sometimes|string|max:50', '最多输入50字'],
|
||||
'time_start_begin' => ['sometimes|integer', '请填写正确的开始日期'],
|
||||
'time_start_end' => ['sometimes|integer', '请填写正确的结束日期'],
|
||||
@@ -533,6 +535,12 @@ class Members extends Controller
|
||||
$value['mobile'] = data_masking('mobile', (string) $value['mobile']);
|
||||
$value['username'] = data_masking('truename', (string) $value['username']);
|
||||
$value['inviter'] = $value['inviter'] == '-' ? $value['inviter'] : data_masking('mobile', (string) $value['inviter']);
|
||||
if (!empty($value['login_email'])) {
|
||||
$value['login_email'] = data_masking('email', (string) $value['login_email']);
|
||||
}
|
||||
if (!empty($value['email'])) {
|
||||
$value['email'] = data_masking('email', (string) $value['email']);
|
||||
}
|
||||
// $value['sex'] = $value['sex'] == '0' ? '-' : data_masking('sex', (string) $value['sex']);
|
||||
}
|
||||
|
||||
@@ -834,6 +842,12 @@ class Members extends Controller
|
||||
$result['birthday'] = data_masking('birthday', (string) ($result['birthday'] ?? ''));
|
||||
$result['address'] = data_masking('detailedaddress', (string) ($result['address'] ?? ''));
|
||||
$result['sex'] = (($result['sex'] ?? '') == '0') ? '-' : data_masking('sex', (string) ($result['sex'] ?? ''));
|
||||
if (!empty($result['login_email'])) {
|
||||
$result['login_email'] = data_masking('email', (string) $result['login_email']);
|
||||
}
|
||||
if (!empty($result['email'])) {
|
||||
$result['email'] = data_masking('email', (string) $result['email']);
|
||||
}
|
||||
}
|
||||
|
||||
$filter = [
|
||||
@@ -1483,14 +1497,15 @@ class Members extends Controller
|
||||
$companyId = app('auth')->user()->get('company_id');
|
||||
|
||||
$mobile = $request->input('mobile');
|
||||
if (!$mobile && preg_match("/^1\d{10}$/", $mobile)) {
|
||||
$mobile = $mobile !== null && $mobile !== '' ? (string) $mobile : '';
|
||||
if ($mobile === '' || !preg_match('/^1[3456789]\d{9}$/', $mobile)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.mobile_error'));
|
||||
}
|
||||
$type = $request->input('type', 'sign');
|
||||
|
||||
// 校验手机号是否注册
|
||||
// 校验手机号是否注册(空数组 [] 在 PHP 中为 truthy,须用 user_id 判断)
|
||||
$memberInfo = $this->memberService->getMemberInfo(['mobile' => $mobile, 'company_id' => $companyId]);
|
||||
if ($memberInfo && $type == 'sign') {
|
||||
if (!empty($memberInfo['user_id']) && $type == 'sign') {
|
||||
throw new ResourceException(trans('MembersBundle/Members.mobile_already_registered'));
|
||||
}
|
||||
|
||||
@@ -1535,6 +1550,12 @@ class Members extends Controller
|
||||
throw new ResourceException(trans('MembersBundle/Members.missing_default_level'));
|
||||
}
|
||||
|
||||
// 与小程序 wxapp/new_login 注册一致:distributor_id → reg_distributor / op_distributor
|
||||
$distributorId = 0;
|
||||
if (isset($postData['distributor_id']) && $postData['distributor_id'] !== '' && $postData['distributor_id'] !== null) {
|
||||
$distributorId = (int) $postData['distributor_id'];
|
||||
}
|
||||
|
||||
//新增-会员信息
|
||||
$memberInfo = [
|
||||
'company_id' => $companyId,
|
||||
@@ -1542,6 +1563,8 @@ class Members extends Controller
|
||||
'mobile' => $postData['mobile'],
|
||||
'grade_id' => $defaultGradeInfo['grade_id'],
|
||||
'password' => substr(str_shuffle('QWERTYUIOPASDFGHJKLZXCVBNM1234567890qwertyuiopasdfghjklzxcvbnm'), 5, 10),
|
||||
'reg_distributor' => $distributorId,
|
||||
'op_distributor' => $distributorId,
|
||||
];
|
||||
$memberInfo['user_card_code'] = $this->getCode();
|
||||
$memberInfo['region_mobile'] = $memberInfo['mobile'];
|
||||
@@ -1570,7 +1593,7 @@ class Members extends Controller
|
||||
|
||||
//记录新会员和店铺的关系
|
||||
$dataParams = [
|
||||
'distributor_id' => $postData['distributor_id'] ?? 0,
|
||||
'distributor_id' => $distributorId,
|
||||
'user_id' => $result['user_id'],
|
||||
'company_id' => $companyId,
|
||||
'salesperson_id' => 0,
|
||||
@@ -1579,6 +1602,16 @@ class Members extends Controller
|
||||
$distributorUserService = new DistributorUserService();
|
||||
$distributorUserService->createData($dataParams);
|
||||
|
||||
if ($distributorId > 0) {
|
||||
$shopRelMemberService = new ShopRelMemberService();
|
||||
$shopRelMemberService->create([
|
||||
'user_id' => $result['user_id'],
|
||||
'company_id' => $companyId,
|
||||
'shop_id' => $distributorId,
|
||||
'shop_type' => 'distributor',
|
||||
]);
|
||||
}
|
||||
|
||||
$date = date('Ymd');
|
||||
$redisKey = 'Member:' . $companyId . ':' . $date;
|
||||
app('redis')->sadd($redisKey, $result['user_id']);
|
||||
@@ -1599,7 +1632,7 @@ class Members extends Controller
|
||||
'monitor_id' => 0,
|
||||
'inviter_id' => 0,
|
||||
'salesperson_id' => 0,
|
||||
'distributor_id' => $postData['distributor_id'] ?? 0,
|
||||
'distributor_id' => $distributorId,
|
||||
'if_register_promotion' => $ifRegisterPromotion,
|
||||
];
|
||||
event(new CreateMemberSuccessEvent($eventData));
|
||||
|
||||
@@ -29,6 +29,7 @@ use DepositBundle\Services\DepositTrade;
|
||||
use MembersBundle\Services\MemberService;
|
||||
use Dingo\Api\Exception\ResourceException;
|
||||
use CompanysBundle\Services\EmployeeService;
|
||||
use CompanysBundle\Services\MailSettingActivationBaseUrlResolver;
|
||||
use KaquanBundle\Services\MemberCardService;
|
||||
use MembersBundle\Services\SubscribeService;
|
||||
use PointBundle\Services\PointMemberService;
|
||||
@@ -52,6 +53,11 @@ use SalespersonBundle\Services\SalespersonService;
|
||||
use DistributionBundle\Services\DistributorService;
|
||||
use MembersBundle\Services\MemberArticleFavService;
|
||||
use MembersBundle\Services\MemberOperateLogService;
|
||||
use MembersBundle\Services\MemberEmailActivationService;
|
||||
use MembersBundle\Services\MemberEmailVerificationService;
|
||||
use MembersBundle\Services\MemberPasswordPolicyService;
|
||||
use MembersBundle\Services\MemberPasswordResetService;
|
||||
use MembersBundle\Services\MemberSyntheticMobileService;
|
||||
use MembersBundle\Services\MemberRegSettingService;
|
||||
use MembersBundle\Services\MembersWhitelistService;
|
||||
use Dingo\Api\Exception\StoreResourceFailedException;
|
||||
@@ -81,6 +87,36 @@ class Members extends Controller
|
||||
$this->memberService = new MemberService();
|
||||
}
|
||||
|
||||
/**
|
||||
* FrontNoAuth 邮箱等多租户接口:优先使用请求 body/query 的 company_id(与邮件链接、跨域 H5 一致),
|
||||
* 否则使用 JWT/中间件写入的 auth.company_id。
|
||||
*/
|
||||
protected function resolveFrontNoAuthTenantCompanyId(Request $request): int
|
||||
{
|
||||
$authInfo = $request->get('auth');
|
||||
if (!is_array($authInfo)) {
|
||||
$authInfo = [];
|
||||
}
|
||||
$authCompanyId = (int) ($authInfo['company_id'] ?? 0);
|
||||
if ($authCompanyId <= 0) {
|
||||
$authCompanyId = (int) ($request->attributes->get('company_id') ?? 0);
|
||||
}
|
||||
$bodyRaw = $request->input('company_id');
|
||||
$bodyCompanyId = ($bodyRaw !== null && $bodyRaw !== '') ? (int) $bodyRaw : 0;
|
||||
if ($bodyCompanyId <= 0) {
|
||||
$qCompany = $request->query('company_id');
|
||||
if ($qCompany !== null && $qCompany !== '') {
|
||||
$bodyCompanyId = (int) $qCompany;
|
||||
}
|
||||
}
|
||||
$companyId = $bodyCompanyId > 0 ? $bodyCompanyId : $authCompanyId;
|
||||
if ($companyId <= 0) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activate_company_id_missing'));
|
||||
}
|
||||
|
||||
return $companyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SWG\Get(path="/token/refresh",
|
||||
* tags={"会员"},
|
||||
@@ -288,7 +324,8 @@ class Members extends Controller
|
||||
];
|
||||
$memberInfo = $this->memberService->getMemberInfo($filter, true);
|
||||
unset($memberInfo['region_mobile']);
|
||||
$mobile = $memberInfo['mobile'];
|
||||
$mobile = (string) ($memberInfo['mobile'] ?? '');
|
||||
MemberSyntheticMobileService::stripSyntheticMobileForFrontApi($memberInfo);
|
||||
if ($memberInfo) {
|
||||
$filter['disabled'] = 0;
|
||||
//是否员工
|
||||
@@ -345,6 +382,12 @@ class Members extends Controller
|
||||
$memberInfo['open_id'] = $authInfo['open_id'] ?? '';
|
||||
// 数据脱敏
|
||||
$memberInfo['mobile'] = data_masking('mobile', (string) $memberInfo['mobile']);
|
||||
if (!empty($memberInfo['login_email'])) {
|
||||
$memberInfo['login_email'] = data_masking('email', (string) $memberInfo['login_email']);
|
||||
}
|
||||
if (!empty($memberInfo['email'])) {
|
||||
$memberInfo['email'] = data_masking('email', (string) $memberInfo['email']);
|
||||
}
|
||||
if (isset($memberInfo['requestFields']['birthday'])) {
|
||||
$memberInfo['requestFields']['birthday'] = data_masking('birthday', (string) $memberInfo['requestFields']['birthday']);
|
||||
}
|
||||
@@ -789,6 +832,7 @@ class Members extends Controller
|
||||
$this->memberService->shuyunModify($companyId, $authInfo['user_id'], $postData);
|
||||
}
|
||||
$result = $this->memberService->getMemberInfo($filter);
|
||||
MemberSyntheticMobileService::stripSyntheticMobileForFrontApi($result);
|
||||
event(new UpdateMemberSuccessEvent($result));
|
||||
$dmMemberService = new DmMemberService($companyId);
|
||||
if ($dmMemberService->isOpen) {
|
||||
@@ -891,6 +935,7 @@ class Members extends Controller
|
||||
]);
|
||||
}
|
||||
$result = $this->memberService->getMemberInfo($filter);
|
||||
MemberSyntheticMobileService::stripSyntheticMobileForFrontApi($result);
|
||||
return $this->response->array($result);
|
||||
}
|
||||
|
||||
@@ -1157,7 +1202,8 @@ class Members extends Controller
|
||||
$authInfo = $request->get('auth');
|
||||
$companyId = $authInfo['company_id'];
|
||||
$phone = $request->input('mobile');
|
||||
if (!$phone && preg_match("/^1\d{10}$/", $phone)) {
|
||||
$phone = $phone !== null && $phone !== '' ? (string) $phone : '';
|
||||
if ($phone === '' || !preg_match('/^1[3456789]\d{9}$/', $phone)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.mobile_error'));
|
||||
}
|
||||
$type = $request->input('type', 'sign');
|
||||
@@ -1165,7 +1211,7 @@ class Members extends Controller
|
||||
if (!in_array($type, $this->code_type)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.mobile_verification_type_error'));
|
||||
}
|
||||
// 校验手机号是否注册
|
||||
// 校验手机号是否注册(注意:getMemberInfo 无记录时返回空数组 [],在 PHP 中空数组为 truthy,必须用 user_id 判断是否存在会员)
|
||||
$memberService = new MemberService();
|
||||
if (($authInfo['user_id'] ?? 0) && $type == 'forgot_password') {
|
||||
$memberInfo = $memberService->getMemberInfo(['user_id' => $authInfo['user_id'], 'company_id' => $authInfo['company_id']]);
|
||||
@@ -1173,11 +1219,11 @@ class Members extends Controller
|
||||
} else {
|
||||
$memberInfo = $memberService->getMemberInfo(['mobile' => $phone, 'company_id' => $authInfo['company_id']]);
|
||||
}
|
||||
if ($memberInfo && $type == 'sign') {
|
||||
if (!empty($memberInfo['user_id']) && $type == 'sign') {
|
||||
if (!isset($memberInfo['other_params']['is_upload_member']) || $memberInfo['other_params']['is_upload_member'] != true) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.mobile_already_registered'));
|
||||
}
|
||||
} elseif (!$memberInfo && $type == 'forgot_password') {
|
||||
} elseif (empty($memberInfo['user_id']) && $type == 'forgot_password') {
|
||||
throw new ResourceException(trans('MembersBundle/Members.mobile_not_registered_yet'));
|
||||
}
|
||||
|
||||
@@ -1196,7 +1242,7 @@ class Members extends Controller
|
||||
|
||||
$exist = $membersAssociationsRepository->get(['user_id' => $authInfo['user_id'], 'unionid' => $authInfo['unionid'], 'company_id' => $authInfo['company_id'], 'user_type' => 'baidu']);
|
||||
if ($exist) {
|
||||
if (!isset($memberInfo['other_params']['is_upload_member']) || $memberInfo['other_params']['is_upload_member'] != true) {
|
||||
if (!empty($memberInfo['user_id']) && (!isset($memberInfo['other_params']['is_upload_member']) || $memberInfo['other_params']['is_upload_member'] != true)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.account_already_bound_mobile'));
|
||||
}
|
||||
}
|
||||
@@ -1205,6 +1251,241 @@ class Members extends Controller
|
||||
return $this->response->array(['message' => "短信发送成功"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送会员邮箱邮件:purpose=activate 为激活链接;purpose=login 为 6 位登录验证码。
|
||||
*/
|
||||
public function sendMemberEmailCode(Request $request)
|
||||
{
|
||||
$companyId = $this->resolveFrontNoAuthTenantCompanyId($request);
|
||||
$purpose = $request->input('purpose');
|
||||
if (!in_array($purpose, [MemberEmailVerificationService::PURPOSE_ACTIVATE, MemberEmailVerificationService::PURPOSE_LOGIN], true)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_code_purpose_invalid'));
|
||||
}
|
||||
if ($purpose === MemberEmailVerificationService::PURPOSE_ACTIVATE) {
|
||||
$emailSvc = new MemberEmailVerificationService();
|
||||
$email = $emailSvc->normalizeEmail((string) $request->input('email'));
|
||||
$repo = app('registry')->getManager('default')->getRepository(\MembersBundle\Entities\Members::class);
|
||||
$member = $repo->findOneBy(['company_id' => $companyId, 'login_email' => $email]);
|
||||
if (!$member || $member->getEmailVerifiedAt()) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_send_not_allowed'));
|
||||
}
|
||||
}
|
||||
$imgType = $purpose === MemberEmailVerificationService::PURPOSE_ACTIVATE ? 'sign' : 'login';
|
||||
$memberRegSettingService = new MemberRegSettingService();
|
||||
$token = $request->input('token');
|
||||
$yzmcode = $request->input('yzm');
|
||||
try {
|
||||
if (!$memberRegSettingService->checkImageVcode($token, $companyId, $yzmcode, $imgType)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.image_captcha_error'));
|
||||
}
|
||||
} catch (ResourceException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
throw new ResourceException($e->getMessage());
|
||||
}
|
||||
if ($purpose === MemberEmailVerificationService::PURPOSE_ACTIVATE) {
|
||||
$base = trim((string) $request->input('activation_base_url', ''));
|
||||
if ($base === '') {
|
||||
$base = (new MailSettingActivationBaseUrlResolver())->getH5ActivationBaseUrl($companyId);
|
||||
}
|
||||
if ($base === '') {
|
||||
$base = trim((string) config('common.h5_base_url'));
|
||||
}
|
||||
(new MemberEmailActivationService())->sendActivationLinkEmail(
|
||||
$companyId,
|
||||
(string) $request->input('email'),
|
||||
(string) $request->ip(),
|
||||
$request->header('X-Client-Device-Id'),
|
||||
$base
|
||||
);
|
||||
|
||||
return $this->response->array(['message' => trans('MembersBundle/Members.email_activation_link_sent')]);
|
||||
}
|
||||
(new MemberEmailVerificationService())->sendVerificationCode(
|
||||
$companyId,
|
||||
(string) $request->input('email'),
|
||||
$purpose,
|
||||
(string) $request->ip(),
|
||||
$request->header('X-Client-Device-Id')
|
||||
);
|
||||
|
||||
return $this->response->array(['message' => trans('MembersBundle/Members.email_code_sent')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅重发激活链接邮件(注册未收到等场景);图形码 sign;频控与 purpose=activate 共用 Redis 冷却键,冷却秒数见 member_email_activation_cooldown_seconds。
|
||||
*/
|
||||
public function resendActivationEmail(Request $request)
|
||||
{
|
||||
$companyId = $this->resolveFrontNoAuthTenantCompanyId($request);
|
||||
$emailSvc = new MemberEmailVerificationService();
|
||||
$email = $emailSvc->normalizeEmail((string) $request->input('email'));
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.invalid_email'));
|
||||
}
|
||||
$repo = app('registry')->getManager('default')->getRepository(\MembersBundle\Entities\Members::class);
|
||||
$member = $repo->findOneBy(['company_id' => $companyId, 'login_email' => $email]);
|
||||
if (!$member || $member->getEmailVerifiedAt()) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_send_not_allowed'));
|
||||
}
|
||||
$memberRegSettingService = new MemberRegSettingService();
|
||||
$token = $request->input('token');
|
||||
$yzmcode = $request->input('yzm');
|
||||
try {
|
||||
if (!$memberRegSettingService->checkImageVcode($token, $companyId, $yzmcode, 'sign')) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.image_captcha_error'));
|
||||
}
|
||||
} catch (ResourceException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
throw new ResourceException($e->getMessage());
|
||||
}
|
||||
$base = trim((string) $request->input('activation_base_url', ''));
|
||||
if ($base === '') {
|
||||
$base = (new MailSettingActivationBaseUrlResolver())->getH5ActivationBaseUrl($companyId);
|
||||
}
|
||||
if ($base === '') {
|
||||
$base = trim((string) config('common.h5_base_url'));
|
||||
}
|
||||
try {
|
||||
(new MemberEmailActivationService())->sendActivationLinkEmail(
|
||||
$companyId,
|
||||
$email,
|
||||
(string) $request->ip(),
|
||||
$request->header('X-Client-Device-Id'),
|
||||
$base
|
||||
);
|
||||
} catch (ResourceException $e) {
|
||||
if ($e->getMessage() === trans('MembersBundle/Members.email_code_resend_too_fast')) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_resend_too_frequent'));
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->response->array(['message' => trans('MembersBundle/Members.email_activation_link_sent')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱注册(无需邮箱验证码);注册成功后将激活邮件 **异步入队**,用户打开链接后由前端调用 activate 完成激活。
|
||||
*/
|
||||
public function registerMemberByEmail(Request $request)
|
||||
{
|
||||
$authInfo = $request->get('auth');
|
||||
if (!is_array($authInfo)) {
|
||||
$authInfo = [];
|
||||
}
|
||||
$companyId = $this->resolveFrontNoAuthTenantCompanyId($request);
|
||||
$postData = $request->all();
|
||||
if (empty($postData['email']) || empty($postData['password'])) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_register_params_missing'));
|
||||
}
|
||||
$passwordConfirm = $postData['password_confirmation'] ?? $postData['password_confirm'] ?? null;
|
||||
if ($passwordConfirm === null || $passwordConfirm === '') {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_register_password_confirm_required'));
|
||||
}
|
||||
if ((string) $postData['password'] !== (string) $passwordConfirm) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_register_password_mismatch'));
|
||||
}
|
||||
$memberRegSettingService = new MemberRegSettingService();
|
||||
try {
|
||||
if (!$memberRegSettingService->checkImageVcode($request->input('token'), $companyId, $request->input('yzm'), 'sign')) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.image_captcha_error'));
|
||||
}
|
||||
} catch (ResourceException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
throw new ResourceException($e->getMessage());
|
||||
}
|
||||
$postData['company_id'] = $companyId;
|
||||
$postData['api_from'] = $authInfo['api_from'] ?? 'h5app';
|
||||
$postData['unionid'] = $authInfo['unionid'] ?? '';
|
||||
$postData['open_id'] = $authInfo['open_id'] ?? '';
|
||||
$postData['client_ip'] = (string) $request->ip();
|
||||
$postData['device_id'] = $request->header('X-Client-Device-Id');
|
||||
$result = $this->memberService->registerMemberWithEmail($postData);
|
||||
|
||||
return $this->response->array([
|
||||
'message' => trans('MembersBundle/Members.email_register_success_check_mail'),
|
||||
'activation_email_queued' => (bool) ($result['activation_email_queued'] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱激活:校验邮件链接中的 token,写入 email_verified_at。
|
||||
*/
|
||||
public function activateMemberByEmail(Request $request)
|
||||
{
|
||||
// JSON body / form / query(部分 H5 把 token 挂在 URL query)
|
||||
$plainToken = trim((string) ($request->input('token') ?: $request->query('token', '')));
|
||||
if ($plainToken === '') {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activate_params_missing'));
|
||||
}
|
||||
$companyId = $this->resolveFrontNoAuthTenantCompanyId($request);
|
||||
$activationSvc = new MemberEmailActivationService();
|
||||
$validated = $activationSvc->validateToken($companyId, $plainToken);
|
||||
if (!$validated) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_token_invalid'));
|
||||
}
|
||||
$repo = app('registry')->getManager('default')->getRepository(\MembersBundle\Entities\Members::class);
|
||||
$member = $repo->findOneBy(['company_id' => $companyId, 'user_id' => $validated['user_id']]);
|
||||
if (!$member) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_not_registered'));
|
||||
}
|
||||
if ($member->getEmailVerifiedAt()) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_already_verified'));
|
||||
}
|
||||
$activationSvc->consumeToken($companyId, $plainToken);
|
||||
$this->memberService->updateMemberInfo(
|
||||
['email_verified_at' => time()],
|
||||
['user_id' => (int) $member->getUserId(), 'company_id' => $companyId]
|
||||
);
|
||||
|
||||
return $this->response->array([
|
||||
'message' => trans('MembersBundle/Members.email_activate_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱找回密码:发送带 token 的链接(防枚举统一文案)
|
||||
*/
|
||||
public function requestMemberPasswordResetEmail(Request $request)
|
||||
{
|
||||
$companyId = $this->resolveFrontNoAuthTenantCompanyId($request);
|
||||
$emailSvc = new MemberEmailVerificationService();
|
||||
$email = $emailSvc->normalizeEmail((string) $request->input('email'));
|
||||
$resetBase = (string) $request->input('reset_base_url', (string) config('common.h5_base_url'));
|
||||
$repo = app('registry')->getManager('default')->getRepository(\MembersBundle\Entities\Members::class);
|
||||
$member = $repo->findOneBy(['company_id' => $companyId, 'login_email' => $email]);
|
||||
if ($member) {
|
||||
$plain = (new MemberPasswordResetService())->createToken($companyId, (int) $member->getUserId());
|
||||
$url = rtrim($resetBase, '/') . '/reset-password?token=' . rawurlencode($plain) . '&company_id=' . $companyId;
|
||||
(new MemberPasswordResetService())->sendResetEmail($companyId, $email, $url);
|
||||
}
|
||||
|
||||
return $this->response->array(['message' => trans('MembersBundle/Members.password_reset_email_sent_if_exists')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用邮件中的 token 设置新密码
|
||||
*/
|
||||
public function resetMemberPasswordByEmailToken(Request $request)
|
||||
{
|
||||
$companyId = $this->resolveFrontNoAuthTenantCompanyId($request);
|
||||
$token = (string) $request->input('token');
|
||||
$password = (string) $request->input('password');
|
||||
(new MemberPasswordPolicyService())->validateOrFail($password);
|
||||
$svc = new MemberPasswordResetService();
|
||||
$row = $svc->validateToken($companyId, $token);
|
||||
if (!$row) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.password_reset_token_invalid'));
|
||||
}
|
||||
$svc->consumeToken($companyId, $token);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->memberService->updateMemberInfo(['password' => $hash], ['user_id' => $row['user_id'], 'company_id' => $companyId]);
|
||||
|
||||
return $this->response->array(['message' => 'ok']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @SWG\Get(
|
||||
|
||||
59
src/MembersBundle/Jobs/SendMemberEmailActivationJob.php
Normal file
59
src/MembersBundle/Jobs/SendMemberEmailActivationJob.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Jobs;
|
||||
|
||||
use EspierBundle\Jobs\Job;
|
||||
use MembersBundle\Services\MemberEmailActivationService;
|
||||
|
||||
/**
|
||||
* 异步发送邮箱激活链接(注册成功后派发;payload 不含 token)。
|
||||
*/
|
||||
class SendMemberEmailActivationJob extends Job
|
||||
{
|
||||
/** @var int */
|
||||
private $companyId;
|
||||
|
||||
/** @var string */
|
||||
private $email;
|
||||
|
||||
/** @var string */
|
||||
private $clientIp;
|
||||
|
||||
/** @var string|null */
|
||||
private $deviceId;
|
||||
|
||||
/** @var string */
|
||||
private $activationBaseUrl;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(
|
||||
int $companyId,
|
||||
string $email,
|
||||
string $clientIp,
|
||||
?string $deviceId,
|
||||
string $activationBaseUrl
|
||||
) {
|
||||
$this->companyId = $companyId;
|
||||
$this->email = $email;
|
||||
$this->clientIp = $clientIp;
|
||||
$this->deviceId = $deviceId;
|
||||
$this->activationBaseUrl = $activationBaseUrl;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
(new MemberEmailActivationService())->sendActivationLinkEmail(
|
||||
$this->companyId,
|
||||
$this->email,
|
||||
$this->clientIp,
|
||||
$this->deviceId,
|
||||
$this->activationBaseUrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Repositories;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class MemberEmailActivationTokensRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* 当前表名称(与原生 SQL / DBAL 协作时便于引用)
|
||||
*/
|
||||
public $table = 'member_email_activation_tokens';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Repositories;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class MemberPasswordResetTokensRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* 当前表名称(与原生 SQL / DBAL 协作时便于引用)
|
||||
*/
|
||||
public $table = 'member_password_reset_tokens';
|
||||
}
|
||||
@@ -45,6 +45,16 @@ class MembersRepository extends EntityRepository
|
||||
throw new StoreResourceFailedException("手机号为{$params['mobile']}的会员已存在!");
|
||||
}
|
||||
|
||||
if (!empty($params['login_email'])) {
|
||||
$existsEmail = $this->findOneBy([
|
||||
'company_id' => $params['company_id'],
|
||||
'login_email' => $params['login_email'],
|
||||
]);
|
||||
if ($existsEmail) {
|
||||
throw new StoreResourceFailedException(trans('MembersBundle/Members.login_email_already_exists'));
|
||||
}
|
||||
}
|
||||
|
||||
$userEntity = new Members();
|
||||
$user = $this->setUserData($userEntity, $params);
|
||||
|
||||
@@ -243,6 +253,8 @@ class MembersRepository extends EntityRepository
|
||||
'has_fp' => $userEntity->getHasFp(),
|
||||
'is_become_friend' => $userEntity->getIsBecomeFriend(),
|
||||
'op_distributor' => $userEntity->getOpDistributor(),
|
||||
'login_email' => $userEntity->getLoginEmail(),
|
||||
'email_verified_at' => $userEntity->getEmailVerifiedAt(),
|
||||
];
|
||||
|
||||
return $result;
|
||||
@@ -271,6 +283,12 @@ class MembersRepository extends EntityRepository
|
||||
if (isset($userData['password'])) {
|
||||
$userEntity->setPassword($userData['password']);
|
||||
}
|
||||
if (array_key_exists('login_email', $userData)) {
|
||||
$userEntity->setLoginEmail($userData['login_email']);
|
||||
}
|
||||
if (array_key_exists('email_verified_at', $userData)) {
|
||||
$userEntity->setEmailVerifiedAt($userData['email_verified_at']);
|
||||
}
|
||||
if (isset($userData['user_card_code'])) {
|
||||
$userEntity->setUserCardCode($userData['user_card_code']);
|
||||
}
|
||||
|
||||
143
src/MembersBundle/Services/MemberEmailActivationService.php
Normal file
143
src/MembersBundle/Services/MemberEmailActivationService.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Services;
|
||||
|
||||
use CompanysBundle\Services\CompanySettingBrandNameService;
|
||||
use CompanysBundle\Services\MailerService;
|
||||
use Dingo\Api\Exception\ResourceException;
|
||||
|
||||
/**
|
||||
* 邮箱激活:一次性链接 token(DB 存哈希),与密码重置 token 分表。
|
||||
*/
|
||||
class MemberEmailActivationService
|
||||
{
|
||||
/** H5 激活落地页路径(相对 activationBaseUrl 根,与 uni-app subpages 对齐) */
|
||||
public const ACTIVATION_EMAIL_PATH = '/subpages/auth/email-activate';
|
||||
|
||||
public function invalidatePendingForUser(int $companyId, int $userId): void
|
||||
{
|
||||
$conn = app('registry')->getConnection('default');
|
||||
$conn->executeUpdate(
|
||||
'DELETE FROM member_email_activation_tokens WHERE company_id = ? AND user_id = ? AND used_at IS NULL',
|
||||
[$companyId, $userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string 明文 token(放入邮件链接)
|
||||
*/
|
||||
public function createToken(int $companyId, int $userId): string
|
||||
{
|
||||
$this->invalidatePendingForUser($companyId, $userId);
|
||||
$plain = bin2hex(random_bytes(32));
|
||||
$hash = hash('sha256', $plain);
|
||||
$ttl = (int) (config('common.member_email_activation_token_ttl') ?: 172800);
|
||||
$expires = time() + $ttl;
|
||||
$conn = app('registry')->getConnection('default');
|
||||
$conn->insert('member_email_activation_tokens', [
|
||||
'company_id' => $companyId,
|
||||
'user_id' => $userId,
|
||||
'token_hash' => $hash,
|
||||
'expires_at' => $expires,
|
||||
'used_at' => null,
|
||||
'created_at' => time(),
|
||||
]);
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user_id:int, company_id:int}|null
|
||||
*/
|
||||
public function validateToken(int $companyId, string $plainToken): ?array
|
||||
{
|
||||
$hash = hash('sha256', $plainToken);
|
||||
$conn = app('registry')->getConnection('default');
|
||||
$row = $conn->fetchAssoc(
|
||||
'SELECT user_id, company_id, expires_at, used_at FROM member_email_activation_tokens WHERE company_id = ? AND token_hash = ?',
|
||||
[$companyId, $hash]
|
||||
);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (!empty($row['used_at'])) {
|
||||
return null;
|
||||
}
|
||||
if ((int) $row['expires_at'] < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['user_id' => (int) $row['user_id'], 'company_id' => (int) $row['company_id']];
|
||||
}
|
||||
|
||||
public function consumeToken(int $companyId, string $plainToken): void
|
||||
{
|
||||
$hash = hash('sha256', $plainToken);
|
||||
$conn = app('registry')->getConnection('default');
|
||||
$conn->executeUpdate(
|
||||
'UPDATE member_email_activation_tokens SET used_at = ? WHERE company_id = ? AND token_hash = ? AND used_at IS NULL',
|
||||
[time(), $companyId, $hash]
|
||||
);
|
||||
}
|
||||
|
||||
public function sendActivationEmail(int $companyId, string $email, string $activationUrlWithToken): void
|
||||
{
|
||||
$config = app('redis')->connection('companys')->get('mailSetting:' . $companyId);
|
||||
$config = json_decode((string) $config, true);
|
||||
if (empty($config['EMAIL_SMTP_PORT']) || empty($config['EMAIL_RELAY_HOST'])) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.company_mail_not_configured'));
|
||||
}
|
||||
$configMail = [
|
||||
'email_smtp_port' => $config['EMAIL_SMTP_PORT'],
|
||||
'email_relay_host' => $config['EMAIL_RELAY_HOST'],
|
||||
'email_user' => $config['EMAIL_USER'],
|
||||
'email_password' => (string) ($config['EMAIL_PASSWORD'] ?? ''),
|
||||
'email_sender' => $config['EMAIL_SENDER'],
|
||||
];
|
||||
$brand = (new CompanySettingBrandNameService())->resolveForCompanyId($companyId);
|
||||
$subject = trans('MembersBundle/Members.email_activation_subject');
|
||||
$body = view('members.email_activation', [
|
||||
'brand' => $brand,
|
||||
'activationUrl' => $activationUrlWithToken,
|
||||
])->render();
|
||||
$mailer = new MailerService($configMail);
|
||||
if (!$mailer->doSend($email, $subject, $body)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_send_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向未激活会员发送含激活链接的邮件(注册成功 / purpose=activate 重发)。
|
||||
*/
|
||||
public function sendActivationLinkEmail(
|
||||
int $companyId,
|
||||
string $email,
|
||||
string $clientIp,
|
||||
?string $deviceId,
|
||||
string $activationBaseUrl
|
||||
): void {
|
||||
$emailSvc = new MemberEmailVerificationService();
|
||||
$normalized = $emailSvc->normalizeEmail($email);
|
||||
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.invalid_email'));
|
||||
}
|
||||
$repo = app('registry')->getManager('default')->getRepository(\MembersBundle\Entities\Members::class);
|
||||
$member = $repo->findOneBy(['company_id' => $companyId, 'login_email' => $normalized]);
|
||||
if (!$member || $member->getEmailVerifiedAt()) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_send_not_allowed'));
|
||||
}
|
||||
$base = rtrim($activationBaseUrl, '/');
|
||||
if ($base === '') {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_base_url_required'));
|
||||
}
|
||||
$emailSvc->assertCanSendEmailPurpose($companyId, MemberEmailVerificationService::PURPOSE_ACTIVATE, $normalized, $clientIp, $deviceId);
|
||||
$userId = (int) $member->getUserId();
|
||||
$plain = $this->createToken($companyId, $userId);
|
||||
$url = $base . self::ACTIVATION_EMAIL_PATH . '?token=' . rawurlencode($plain) . '&company_id=' . $companyId;
|
||||
$this->sendActivationEmail($companyId, $normalized, $url);
|
||||
$emailSvc->setPurposeSendCooldown($companyId, MemberEmailVerificationService::PURPOSE_ACTIVATE, $normalized);
|
||||
}
|
||||
}
|
||||
201
src/MembersBundle/Services/MemberEmailVerificationService.php
Normal file
201
src/MembersBundle/Services/MemberEmailVerificationService.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Services;
|
||||
|
||||
use CompanysBundle\Services\CompanySettingBrandNameService;
|
||||
use CompanysBundle\Services\MailerService;
|
||||
use Dingo\Api\Exception\ResourceException;
|
||||
|
||||
/**
|
||||
* 邮箱验证码:与短信分 Redis 命名空间;TTL、60s 冷却、按邮箱日限、IP/设备限流。
|
||||
*/
|
||||
class MemberEmailVerificationService
|
||||
{
|
||||
/** @deprecated 使用 PURPOSE_ACTIVATE;保留常量以免历史 Redis 键文档引用断裂 */
|
||||
public const PURPOSE_SIGN = 'sign';
|
||||
|
||||
/** 限流/冷却 Redis 键片段:激活 **链接** 邮件(非 6 位码) */
|
||||
public const PURPOSE_ACTIVATE = 'activate';
|
||||
|
||||
public const PURPOSE_LOGIN = 'login';
|
||||
public const PURPOSE_FORGOT_PASSWORD = 'forgot_password';
|
||||
|
||||
public function normalizeEmail(string $email): string
|
||||
{
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
public function verifyCode(int $companyId, string $email, string $purpose, string $code): bool
|
||||
{
|
||||
$email = $this->normalizeEmail($email);
|
||||
$key = $this->codeKey($companyId, $purpose, $email);
|
||||
$redis = app('redis')->connection('members');
|
||||
$expect = $redis->get($key);
|
||||
|
||||
return $expect !== null && $expect !== false && hash_equals((string) $expect, (string) $code);
|
||||
}
|
||||
|
||||
public function consumeCode(int $companyId, string $email, string $purpose, string $code): bool
|
||||
{
|
||||
if (!$this->verifyCode($companyId, $email, $purpose, $code)) {
|
||||
return false;
|
||||
}
|
||||
$email = $this->normalizeEmail($email);
|
||||
$key = $this->codeKey($companyId, $purpose, $email);
|
||||
app('redis')->connection('members')->del($key);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成并发送 6 位验证码邮件。
|
||||
*
|
||||
* @return string 明文验证码(仅用于测试;生产不返回)
|
||||
*/
|
||||
public function sendVerificationCode(
|
||||
int $companyId,
|
||||
string $email,
|
||||
string $purpose,
|
||||
string $clientIp,
|
||||
?string $deviceId
|
||||
): string {
|
||||
$email = $this->normalizeEmail($email);
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.invalid_email'));
|
||||
}
|
||||
if ($purpose !== self::PURPOSE_LOGIN) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_code_purpose_invalid'));
|
||||
}
|
||||
|
||||
$this->assertCanSendEmailPurpose($companyId, $purpose, $email, $clientIp, $deviceId);
|
||||
|
||||
$redis = app('redis')->connection('members');
|
||||
$code = (string) random_int(100000, 999999);
|
||||
$ttl = (int) (config('common.member_email_vcode_ttl') ?: 600);
|
||||
$redis->setex($this->codeKey($companyId, $purpose, $email), $ttl, $code);
|
||||
$this->setPurposeSendCooldown($companyId, $purpose, $email);
|
||||
|
||||
$this->sendMail($companyId, $email, $purpose, $code, $ttl);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发 **登录** 6 位码或 **激活链接** 邮件前:登录 60s 冷却;激活链接为 `member_email_activation_cooldown_seconds`(默认 90s)+ 日限/IP/设备限流。
|
||||
*/
|
||||
public function assertCanSendEmailPurpose(
|
||||
int $companyId,
|
||||
string $purpose,
|
||||
string $normalizedEmail,
|
||||
string $clientIp,
|
||||
?string $deviceId
|
||||
): void {
|
||||
$redis = app('redis')->connection('members');
|
||||
$cooldownKey = $this->cooldownKey($companyId, $purpose, $normalizedEmail);
|
||||
if ($redis->exists($cooldownKey)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_code_resend_too_fast'));
|
||||
}
|
||||
$this->assertRateLimits($companyId, $purpose, $normalizedEmail, $clientIp, $deviceId);
|
||||
}
|
||||
|
||||
public function setPurposeSendCooldown(int $companyId, string $purpose, string $normalizedEmail): void
|
||||
{
|
||||
$redis = app('redis')->connection('members');
|
||||
$ttl = 60;
|
||||
if ($purpose === self::PURPOSE_ACTIVATE) {
|
||||
$ttl = (int) (config('common.member_email_activation_cooldown_seconds') ?: 90);
|
||||
if ($ttl < 1) {
|
||||
$ttl = 90;
|
||||
}
|
||||
}
|
||||
$redis->setex($this->cooldownKey($companyId, $purpose, $normalizedEmail), $ttl, '1');
|
||||
}
|
||||
|
||||
private function assertRateLimits(int $companyId, string $purpose, string $email, string $clientIp, ?string $deviceId): void
|
||||
{
|
||||
$redis = app('redis')->connection('members');
|
||||
$day = date('Ymd');
|
||||
$limit = (int) (config('common.member_email_send_limit_per_day') ?: config('common.sms_send_limit') ?: 5);
|
||||
|
||||
$emailKey = 'yzmemail:' . $companyId . ':' . $day . ':' . $purpose . ':' . sha1($email);
|
||||
$n = $redis->incr($emailKey);
|
||||
if ($redis->ttl($emailKey) === -1) {
|
||||
$redis->expire($emailKey, 3600 * 24);
|
||||
}
|
||||
if ($n > $limit) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_send_limit_exceeded'));
|
||||
}
|
||||
|
||||
if ($clientIp !== '') {
|
||||
$ipKey = 'member:email:ip:' . $companyId . ':' . $day . ':' . sha1($clientIp);
|
||||
$ipN = $redis->incr($ipKey);
|
||||
if ($redis->ttl($ipKey) === -1) {
|
||||
$redis->expire($ipKey, 3600 * 24);
|
||||
}
|
||||
$ipLimit = (int) (config('common.member_email_ip_limit_per_day') ?: 200);
|
||||
if ($ipN > $ipLimit) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_ip_limit_exceeded'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($deviceId !== null && $deviceId !== '') {
|
||||
$devKey = 'member:email:dev:' . $companyId . ':' . $day . ':' . sha1($deviceId);
|
||||
$dN = $redis->incr($devKey);
|
||||
if ($redis->ttl($devKey) === -1) {
|
||||
$redis->expire($devKey, 3600 * 24);
|
||||
}
|
||||
$devLimit = (int) (config('common.member_email_device_limit_per_day') ?: 200);
|
||||
if ($dN > $devLimit) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_device_limit_exceeded'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function codeKey(int $companyId, string $purpose, string $normalizedEmail): string
|
||||
{
|
||||
return 'member:email:vcode:' . sha1((string) $companyId) . ':' . $purpose . ':' . sha1($normalizedEmail);
|
||||
}
|
||||
|
||||
private function cooldownKey(int $companyId, string $purpose, string $normalizedEmail): string
|
||||
{
|
||||
return 'member:email:vcode_cd:' . sha1((string) $companyId) . ':' . $purpose . ':' . sha1($normalizedEmail);
|
||||
}
|
||||
|
||||
private function sendMail(int $companyId, string $to, string $purpose, string $code, int $ttlSeconds): void
|
||||
{
|
||||
$config = app('redis')->connection('companys')->get('mailSetting:' . $companyId);
|
||||
$config = json_decode((string) $config, true);
|
||||
if (empty($config['EMAIL_SMTP_PORT']) || empty($config['EMAIL_RELAY_HOST'])) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.company_mail_not_configured'));
|
||||
}
|
||||
$configMail = [
|
||||
'email_smtp_port' => $config['EMAIL_SMTP_PORT'],
|
||||
'email_relay_host' => $config['EMAIL_RELAY_HOST'],
|
||||
'email_user' => $config['EMAIL_USER'],
|
||||
'email_password' => (string) ($config['EMAIL_PASSWORD'] ?? ''),
|
||||
'email_sender' => $config['EMAIL_SENDER'],
|
||||
];
|
||||
if ($purpose === self::PURPOSE_LOGIN) {
|
||||
$brand = (new CompanySettingBrandNameService())->resolveForCompanyId($companyId);
|
||||
$subjectBrand = str_replace(["\r", "\n"], '', strip_tags($brand));
|
||||
$subject = trans('MembersBundle/Members.email_login_vcode_headline_prefix') . $subjectBrand;
|
||||
$ttlMinutes = max(1, (int) ceil($ttlSeconds / 60));
|
||||
$body = view('members.email_login_vcode', [
|
||||
'brand' => $brand,
|
||||
'code' => $code,
|
||||
'ttlMinutes' => $ttlMinutes,
|
||||
])->render();
|
||||
} else {
|
||||
$subject = trans('MembersBundle/Members.email_vcode_subject_' . $purpose);
|
||||
$body = trans('MembersBundle/Members.email_vcode_body', ['code' => $code]);
|
||||
}
|
||||
|
||||
$mailer = new MailerService($configMail);
|
||||
if (!$mailer->doSend($to, $subject, $body)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_send_failed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/MembersBundle/Services/MemberPasswordPolicyService.php
Normal file
63
src/MembersBundle/Services/MemberPasswordPolicyService.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Services;
|
||||
|
||||
use Dingo\Api\Exception\ResourceException;
|
||||
|
||||
class MemberPasswordPolicyService
|
||||
{
|
||||
/** @var string[] */
|
||||
private static $weakPasswords = [
|
||||
'12345678',
|
||||
'123456789',
|
||||
'1234567890',
|
||||
'password',
|
||||
'password123',
|
||||
'qwerty123',
|
||||
'admin123',
|
||||
'88888888',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{valid: bool, strength: string, message: string}
|
||||
*/
|
||||
public function evaluate(string $password): array
|
||||
{
|
||||
$len = strlen($password);
|
||||
$hasLetter = (bool) preg_match('/[a-zA-Z]/', $password);
|
||||
$hasDigit = (bool) preg_match('/[0-9]/', $password);
|
||||
|
||||
if ($len < 8) {
|
||||
return ['valid' => false, 'strength' => 'weak', 'message' => trans('MembersBundle/Members.password_min_length_8')];
|
||||
}
|
||||
if (!$hasLetter || !$hasDigit) {
|
||||
return ['valid' => false, 'strength' => 'weak', 'message' => trans('MembersBundle/Members.password_need_letter_and_digit')];
|
||||
}
|
||||
$lower = strtolower($password);
|
||||
foreach (self::$weakPasswords as $w) {
|
||||
if ($lower === $w || str_contains($lower, $w)) {
|
||||
return ['valid' => false, 'strength' => 'weak', 'message' => trans('MembersBundle/Members.password_too_weak')];
|
||||
}
|
||||
}
|
||||
|
||||
$strength = 'medium';
|
||||
if ($len >= 12 && preg_match('/[^a-zA-Z0-9]/', $password)) {
|
||||
$strength = 'strong';
|
||||
} elseif ($len >= 10) {
|
||||
$strength = 'strong';
|
||||
}
|
||||
|
||||
return ['valid' => true, 'strength' => $strength, 'message' => ''];
|
||||
}
|
||||
|
||||
public function validateOrFail(string $password): void
|
||||
{
|
||||
$r = $this->evaluate($password);
|
||||
if (!$r['valid']) {
|
||||
throw new ResourceException($r['message']);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/MembersBundle/Services/MemberPasswordResetService.php
Normal file
129
src/MembersBundle/Services/MemberPasswordResetService.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Services;
|
||||
|
||||
use CompanysBundle\Services\CompanySettingBrandNameService;
|
||||
use CompanysBundle\Services\MailerService;
|
||||
use Dingo\Api\Exception\ResourceException;
|
||||
|
||||
class MemberPasswordResetService
|
||||
{
|
||||
public function invalidatePendingForUser(int $companyId, int $userId): void
|
||||
{
|
||||
$conn = app('registry')->getConnection('default');
|
||||
$conn->executeUpdate(
|
||||
'DELETE FROM member_password_reset_tokens WHERE company_id = ? AND user_id = ? AND used_at IS NULL',
|
||||
[$companyId, $userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string 明文 token(放入邮件链接)
|
||||
*/
|
||||
public function createToken(int $companyId, int $userId): string
|
||||
{
|
||||
$this->invalidatePendingForUser($companyId, $userId);
|
||||
$plain = bin2hex(random_bytes(32));
|
||||
$hash = hash('sha256', $plain);
|
||||
$ttl = (int) (config('common.member_email_reset_token_ttl') ?: 1200);
|
||||
$expires = time() + $ttl;
|
||||
$conn = app('registry')->getConnection('default');
|
||||
$conn->insert('member_password_reset_tokens', [
|
||||
'company_id' => $companyId,
|
||||
'user_id' => $userId,
|
||||
'token_hash' => $hash,
|
||||
'expires_at' => $expires,
|
||||
'used_at' => null,
|
||||
'created_at' => time(),
|
||||
]);
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user_id:int, company_id:int}|null
|
||||
*/
|
||||
public function validateToken(int $companyId, string $plainToken): ?array
|
||||
{
|
||||
$hash = hash('sha256', $plainToken);
|
||||
$conn = app('registry')->getConnection('default');
|
||||
$row = $conn->fetchAssoc(
|
||||
'SELECT user_id, company_id, expires_at, used_at FROM member_password_reset_tokens WHERE company_id = ? AND token_hash = ?',
|
||||
[$companyId, $hash]
|
||||
);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (!empty($row['used_at'])) {
|
||||
return null;
|
||||
}
|
||||
if ((int) $row['expires_at'] < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['user_id' => (int) $row['user_id'], 'company_id' => (int) $row['company_id']];
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件内 href 仅允许 http(s) 绝对 URL,防止 javascript: 等协议。
|
||||
*/
|
||||
public static function isAllowedResetEmailUrl(string $url): bool
|
||||
{
|
||||
$trim = trim($url);
|
||||
if ($trim === '') {
|
||||
return false;
|
||||
}
|
||||
$parts = @parse_url($trim);
|
||||
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
$scheme = strtolower((string) $parts['scheme']);
|
||||
|
||||
return $scheme === 'http' || $scheme === 'https';
|
||||
}
|
||||
|
||||
public function consumeToken(int $companyId, string $plainToken): void
|
||||
{
|
||||
$hash = hash('sha256', $plainToken);
|
||||
$conn = app('registry')->getConnection('default');
|
||||
$conn->executeUpdate(
|
||||
'UPDATE member_password_reset_tokens SET used_at = ? WHERE company_id = ? AND token_hash = ? AND used_at IS NULL',
|
||||
[time(), $companyId, $hash]
|
||||
);
|
||||
}
|
||||
|
||||
public function sendResetEmail(int $companyId, string $email, string $resetUrlWithToken): void
|
||||
{
|
||||
$config = app('redis')->connection('companys')->get('mailSetting:' . $companyId);
|
||||
$config = json_decode((string) $config, true);
|
||||
if (empty($config['EMAIL_SMTP_PORT']) || empty($config['EMAIL_RELAY_HOST'])) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.company_mail_not_configured'));
|
||||
}
|
||||
$configMail = [
|
||||
'email_smtp_port' => $config['EMAIL_SMTP_PORT'],
|
||||
'email_relay_host' => $config['EMAIL_RELAY_HOST'],
|
||||
'email_user' => $config['EMAIL_USER'],
|
||||
'email_password' => (string) ($config['EMAIL_PASSWORD'] ?? ''),
|
||||
'email_sender' => $config['EMAIL_SENDER'],
|
||||
];
|
||||
if (!self::isAllowedResetEmailUrl($resetUrlWithToken)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_password_reset_link_invalid'));
|
||||
}
|
||||
$brand = (new CompanySettingBrandNameService())->resolveForCompanyId($companyId);
|
||||
$ttlSeconds = (int) (config('common.member_email_reset_token_ttl') ?: 1200);
|
||||
$ttlMinutes = max(1, (int) ceil($ttlSeconds / 60));
|
||||
$subject = trans('MembersBundle/Members.email_password_reset_subject');
|
||||
$body = view('members.email_password_reset', [
|
||||
'brand' => $brand,
|
||||
'resetUrl' => $resetUrlWithToken,
|
||||
'ttlMinutes' => $ttlMinutes,
|
||||
])->render();
|
||||
$mailer = new MailerService($configMail);
|
||||
if (!$mailer->doSend($email, $subject, $body)) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_send_failed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
namespace MembersBundle\Services;
|
||||
|
||||
use CompanysBundle\Services\MailSettingActivationBaseUrlResolver;
|
||||
use CompanysBundle\Services\MailSettingEmailRegistrationValidator;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use MembersBundle\Jobs\SendMemberEmailActivationJob;
|
||||
use CompanysBundle\Services\Shops\ProtocolService;
|
||||
use EspierBundle\Services\Config\ConfigRequestFieldsService;
|
||||
use KaquanBundle\Services\PackageSetService;
|
||||
@@ -58,6 +62,7 @@ use MembersBundle\Jobs\BindSalseperson;
|
||||
use ThirdPartyBundle\Services\MarketingCenter\Request as MarketingCenterRequest;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use MembersBundle\Services\MembersProtocolLogService;
|
||||
use MembersBundle\Services\MembersWhitelistService;
|
||||
use KaquanBundle\Entities\VipGradeRelUser;
|
||||
use MembersBundle\Services\ShopRelMemberService;
|
||||
use ShuyunBundle\Services\MembersService as ShuyunMembersService;
|
||||
@@ -153,6 +158,14 @@ class MemberService
|
||||
'op_distributor' => isset($params['op_distributor']) ? (int)$params['op_distributor'] : 0,
|
||||
];
|
||||
|
||||
if (!empty($params['login_email'])) {
|
||||
$memberInfo['login_email'] = $params['login_email'];
|
||||
$memberInfo['email_verified_at'] = $params['email_verified_at'] ?? null;
|
||||
if (!isset($memberInfo['email']) || $memberInfo['email'] === null || $memberInfo['email'] === '') {
|
||||
$memberInfo['email'] = $params['login_email'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($isUpdatePassword) {
|
||||
$memberInfo['password'] = password_hash($params['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
@@ -369,6 +382,87 @@ class MemberService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱注册:密码策略、占位手机号、写入 login_email;**不**校验邮箱验证码。
|
||||
* 创建时 email_verified_at 为空,注册成功后发送 **激活链接** 邮件。
|
||||
*
|
||||
* @return array 含 createMember 结果字段 + activation_email_queued(bool)
|
||||
*/
|
||||
public function registerMemberWithEmail(array $params): array
|
||||
{
|
||||
$emailSvc = new MemberEmailVerificationService();
|
||||
$email = $emailSvc->normalizeEmail($params['email']);
|
||||
(new MemberPasswordPolicyService())->validateOrFail($params['password']);
|
||||
MailSettingEmailRegistrationValidator::assertReadyForMemberEmailRegistration((int) $params['company_id']);
|
||||
$mobile = (new MemberSyntheticMobileService())->allocateUnique((int) $params['company_id']);
|
||||
$dup = $this->membersRepository->findOneBy(['company_id' => $params['company_id'], 'login_email' => $email]);
|
||||
if ($dup) {
|
||||
throw new ResourceException(trans('MembersBundle/Members.login_email_already_exists'));
|
||||
}
|
||||
$createParams = [
|
||||
'mobile' => $mobile,
|
||||
'region_mobile' => $mobile,
|
||||
'mobile_country_code' => '86',
|
||||
'company_id' => $params['company_id'],
|
||||
'wxa_appid' => $params['wxa_appid'] ?? '',
|
||||
'authorizer_appid' => $params['authorizer_appid'] ?? '',
|
||||
'sex' => $params['sex'] ?? 0,
|
||||
'username' => $params['username'] ?? randValue(8),
|
||||
'avatar' => $params['avatar'] ?? '',
|
||||
'email' => $email,
|
||||
'login_email' => $email,
|
||||
'email_verified_at' => null,
|
||||
'password' => $params['password'],
|
||||
'api_from' => $params['api_from'] ?? 'h5app',
|
||||
'auth_type' => 'local',
|
||||
'user_type' => 'local',
|
||||
'unionid' => $params['unionid'] ?? '',
|
||||
'open_id' => $params['open_id'] ?? '',
|
||||
'inviter_id' => $params['inviter_id'] ?? 0,
|
||||
'source_from' => $params['source_from'] ?? 'default',
|
||||
'source_id' => $params['source_id'] ?? 0,
|
||||
'monitor_id' => $params['monitor_id'] ?? 0,
|
||||
];
|
||||
$tips = '';
|
||||
if (!(new MembersWhitelistService())->checkWhitelistValid($params['company_id'], $mobile, $tips)) {
|
||||
throw new ResourceException($tips);
|
||||
}
|
||||
|
||||
$result = $this->createMember($createParams, true);
|
||||
|
||||
$baseUrl = (new MailSettingActivationBaseUrlResolver())->getH5ActivationBaseUrl((int) $params['company_id']);
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = trim((string) (config('common.h5_base_url') ?? ''));
|
||||
}
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = trim((string) ($params['activation_base_url'] ?? ''));
|
||||
}
|
||||
if ($baseUrl === '') {
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_base_url_required'));
|
||||
}
|
||||
|
||||
try {
|
||||
$job = new SendMemberEmailActivationJob(
|
||||
(int) $params['company_id'],
|
||||
$email,
|
||||
(string) ($params['client_ip'] ?? ''),
|
||||
isset($params['device_id']) && (string) $params['device_id'] !== '' ? (string) $params['device_id'] : null,
|
||||
$baseUrl
|
||||
);
|
||||
app(Dispatcher::class)->dispatch($job->onQueue('default'));
|
||||
} catch (\Throwable $e) {
|
||||
app('log')->error('activation email queue dispatch failed after register', [
|
||||
'company_id' => $params['company_id'] ?? null,
|
||||
'email' => $email,
|
||||
'exception' => $e::class,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
throw new ResourceException(trans('MembersBundle/Members.email_activation_queue_dispatch_failed'));
|
||||
}
|
||||
|
||||
return array_merge($result, ['activation_email_queued' => true]);
|
||||
}
|
||||
|
||||
public function dmCreateMember($params, $isUpdatePassword = false)
|
||||
{
|
||||
// 查询达摩CRM会员
|
||||
@@ -423,6 +517,14 @@ class MemberService
|
||||
'dm_card_no' => $dmMemberInfo['cardNo'] ?? '',
|
||||
];
|
||||
|
||||
if (!empty($params['login_email'])) {
|
||||
$memberInfo['login_email'] = $params['login_email'];
|
||||
$memberInfo['email_verified_at'] = $params['email_verified_at'] ?? null;
|
||||
if (!isset($memberInfo['email']) || $memberInfo['email'] === null || $memberInfo['email'] === '') {
|
||||
$memberInfo['email'] = $params['login_email'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($isUpdatePassword) {
|
||||
$memberInfo['password'] = password_hash($params['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
@@ -910,6 +1012,9 @@ class MemberService
|
||||
// $result = array_merge($member, $info);
|
||||
// $result["requestFields"] = $requestFields;
|
||||
}
|
||||
if (is_array($result)) {
|
||||
MemberSyntheticMobileService::stripPlaceholderMobileForEmailRegisteredMember($result);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -1202,7 +1307,7 @@ class MemberService
|
||||
|
||||
$conn = app('registry')->getConnection('default');
|
||||
|
||||
$mFields = "DISTINCT m.user_id,m.company_id,m.grade_id,m.mobile,m.user_card_code,m.authorizer_appid,m.wxa_appid,m.source_id,m.monitor_id,m.latest_source_id,m.latest_monitor_id,m.created,m.updated,m.created_year,m.created_month,m.created_day,m.offline_card_code,m.inviter_id,m.source_from,m.password,m.disabled,m.use_point,m.remarks,m.third_data,m.region_mobile,m.mobile_country_code,m.reg_distributor,m.reg_salesperson,m.fp_salesperson,m.has_fp,m.op_distributor,";
|
||||
$mFields = "DISTINCT m.user_id,m.company_id,m.grade_id,m.mobile,m.user_card_code,m.authorizer_appid,m.wxa_appid,m.source_id,m.monitor_id,m.latest_source_id,m.latest_monitor_id,m.created,m.updated,m.created_year,m.created_month,m.created_day,m.offline_card_code,m.inviter_id,m.source_from,m.password,m.disabled,m.use_point,m.remarks,m.third_data,m.region_mobile,m.mobile_country_code,m.reg_distributor,m.reg_salesperson,m.fp_salesperson,m.has_fp,m.op_distributor,m.login_email,m.email_verified_at,";
|
||||
$row = $mFields . 'info.username,info.name,info.sex,info.birthday,info.address,info.email,info.industry,info.income,info.edu_background,info.habbit,info.avatar';
|
||||
|
||||
$criteria = $conn->createQueryBuilder();
|
||||
@@ -1262,6 +1367,7 @@ class MemberService
|
||||
isset($value['mobile']) and $result[$key]['mobile'] = fixeddecrypt($value['mobile']);
|
||||
isset($value['username']) and $result[$key]['username'] = fixeddecrypt($value['username']);
|
||||
isset($value['nickname']) and $result[$key]['nickname'] = fixeddecrypt($value['nickname']);
|
||||
MemberSyntheticMobileService::stripPlaceholderMobileForEmailRegisteredMember($result[$key]);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
@@ -1522,7 +1628,7 @@ class MemberService
|
||||
//$criteria->andWhere($criteria->expr()->isNotNull('m.user_card_code'));
|
||||
|
||||
if ($filter) {
|
||||
$commonKey = ['company_id', 'user_id', 'created', 'updated', 'created_month', 'created_day', 'created_year', 'remarks', 'mobile', 'birthday', 'fp_salesperson', 'has_fp', 'grade_id', 'op_distributor'];
|
||||
$commonKey = ['company_id', 'user_id', 'created', 'updated', 'created_month', 'created_day', 'created_year', 'remarks', 'mobile', 'birthday', 'fp_salesperson', 'has_fp', 'grade_id', 'op_distributor', 'login_email'];
|
||||
foreach ($filter as $field => $value) {
|
||||
$list = explode('|', $field);
|
||||
if (count($list) > 1) {
|
||||
@@ -1939,6 +2045,11 @@ class MemberService
|
||||
$row = $col;
|
||||
}
|
||||
$result['list'] = $criteria->select($row)->execute()->fetchAll();
|
||||
foreach ($result['list'] as &$listRow) {
|
||||
MemberSyntheticMobileService::stripPlaceholderMobileForEmailRegisteredMember($listRow);
|
||||
}
|
||||
unset($listRow);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
81
src/MembersBundle/Services/MemberSyntheticMobileService.php
Normal file
81
src/MembersBundle/Services/MemberSyntheticMobileService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Services;
|
||||
|
||||
use Dingo\Api\Exception\ResourceException;
|
||||
|
||||
class MemberSyntheticMobileService
|
||||
{
|
||||
/**
|
||||
* 是否为邮箱注册时分配的占位手机号。
|
||||
* 当前规则:**`10` + 9 位数字**(与 {@see allocateUnique} 一致)。
|
||||
* 兼容历史数据:**`199` + 8 位数字**。
|
||||
*/
|
||||
public static function isAllocatedSyntheticMobile(?string $mobile): bool
|
||||
{
|
||||
if ($mobile === null || $mobile === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) (preg_match('/^10\d{9}$/', $mobile) || preg_match('/^199\d{8}$/', $mobile));
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台会员接口:占位手机号不展示给客户端,置为空字符串。
|
||||
*
|
||||
* @param array $member members 行或 getMemberInfo 合并后的单会员数组(引用修改)
|
||||
*/
|
||||
public static function stripSyntheticMobileForFrontApi(array &$member): void
|
||||
{
|
||||
foreach (['mobile', 'region_mobile'] as $key) {
|
||||
if (!array_key_exists($key, $member)) {
|
||||
continue;
|
||||
}
|
||||
$v = (string) $member[$key];
|
||||
if ($v !== '' && self::isAllocatedSyntheticMobile($v)) {
|
||||
$member[$key] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺端 / 导出等:已绑定登录邮箱且 mobile 为占位号时,不在接口结果中返回手机号(置空)。
|
||||
* 有真实手机号的会员(即使填写了 login_email)不受影响。
|
||||
*/
|
||||
public static function stripPlaceholderMobileForEmailRegisteredMember(array &$member): void
|
||||
{
|
||||
if (trim((string) ($member['login_email'] ?? '')) === '') {
|
||||
return;
|
||||
}
|
||||
foreach (['mobile', 'region_mobile'] as $key) {
|
||||
if (!array_key_exists($key, $member)) {
|
||||
continue;
|
||||
}
|
||||
$v = (string) $member[$key];
|
||||
if ($v !== '' && self::isAllocatedSyntheticMobile($v)) {
|
||||
$member[$key] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为邮箱注册会员分配占位手机号:**`10` 开头 + 9 位随机数字**(共 11 位),保证 `(mobile, company_id)` 唯一。
|
||||
* 非公众移动号段,仅作技术占位;前台展示见 {@see stripSyntheticMobileForFrontApi}。
|
||||
*/
|
||||
public function allocateUnique(int $companyId): string
|
||||
{
|
||||
$memberService = new MemberService();
|
||||
for ($i = 0; $i < 80; $i++) {
|
||||
$suffix = str_pad((string) random_int(0, 999999999), 9, '0', STR_PAD_LEFT);
|
||||
$candidate = '10' . $suffix;
|
||||
$existing = $memberService->getInfoByMobile($companyId, $candidate);
|
||||
if (!$existing) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
throw new ResourceException(trans('MembersBundle/Members.synthetic_mobile_failed'));
|
||||
}
|
||||
}
|
||||
38
src/MembersBundle/Services/RegisterLoginChannelService.php
Normal file
38
src/MembersBundle/Services/RegisterLoginChannelService.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright 2019-2026 ShopeX
|
||||
*
|
||||
* @deprecated 产品已改为「手机号注册/登录」与「邮箱注册/登录」**同时可用**,不再按企业做二选一互斥。
|
||||
* 本服务读写 Redis 的通道键仍可保留供运维/历史脚本使用,但 **Members 前台接口不再调用**。
|
||||
*/
|
||||
|
||||
namespace MembersBundle\Services;
|
||||
|
||||
class RegisterLoginChannelService
|
||||
{
|
||||
public const CHANNEL_MOBILE = 'mobile';
|
||||
public const CHANNEL_EMAIL = 'email';
|
||||
|
||||
private function redisKey(int $companyId): string
|
||||
{
|
||||
return 'register_login_channel:' . sha1((string) $companyId);
|
||||
}
|
||||
|
||||
public function getChannel(int $companyId): string
|
||||
{
|
||||
$raw = app('redis')->connection('members')->get($this->redisKey($companyId));
|
||||
if ($raw === null || $raw === false || $raw === '') {
|
||||
return self::CHANNEL_MOBILE;
|
||||
}
|
||||
$data = json_decode((string) $raw, true);
|
||||
$ch = $data['channel'] ?? self::CHANNEL_MOBILE;
|
||||
|
||||
return $ch === self::CHANNEL_EMAIL ? self::CHANNEL_EMAIL : self::CHANNEL_MOBILE;
|
||||
}
|
||||
|
||||
public function setChannel(int $companyId, string $channel): void
|
||||
{
|
||||
$normalized = $channel === self::CHANNEL_EMAIL ? self::CHANNEL_EMAIL : self::CHANNEL_MOBILE;
|
||||
app('redis')->connection('members')->set($this->redisKey($companyId), json_encode(['channel' => $normalized]));
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ trait MemberSearchFilter
|
||||
if (isset($postdata['username']) && $postdata['username']) {
|
||||
$filter['username'] = $postdata['username'];
|
||||
}
|
||||
if (isset($postdata['login_email']) && $postdata['login_email'] !== null) {
|
||||
$loginEmailFragment = mb_strtolower(trim((string) $postdata['login_email']));
|
||||
if ($loginEmailFragment !== '') {
|
||||
$filter['login_email|like'] = $loginEmailFragment;
|
||||
}
|
||||
}
|
||||
if (isset($postdata['name']) && $postdata['name']) {
|
||||
$filter['name'] = $postdata['name'];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
namespace OrdersBundle\Jobs;
|
||||
|
||||
use CompanysBundle\Services\MailSettingStoredPasswordResolver;
|
||||
use CompanysBundle\Services\MailerService;
|
||||
use EspierBundle\Jobs\Job;
|
||||
use CompanysBundle\Services\EmailService;
|
||||
@@ -61,14 +62,17 @@ class SendInvoiceEmailJob extends Job
|
||||
|
||||
// 发送邮件
|
||||
$config = app('redis')->connection('companys')->get('mailSetting:' . $this->data['company_id']);
|
||||
$config = json_decode($config, true);
|
||||
app('log')->info('SendInvoiceEmailJob: 邮件配置:'.json_encode($config));
|
||||
$config = json_decode((string) $config, true);
|
||||
if (!is_array($config)) {
|
||||
$config = [];
|
||||
}
|
||||
app('log')->info('SendInvoiceEmailJob: 邮件配置:'.json_encode(MailSettingStoredPasswordResolver::redactForLog($config)));
|
||||
$configMail = [
|
||||
'email_smtp_port' => $config['EMAIL_SMTP_PORT'],
|
||||
'email_relay_host' => $config['EMAIL_RELAY_HOST'],
|
||||
'email_user' => $config['EMAIL_USER'],
|
||||
'email_password' => $config['EMAIL_PASSWORD'],
|
||||
'email_sender' => $config['EMAIL_SENDER'],
|
||||
'email_smtp_port' => $config['EMAIL_SMTP_PORT'] ?? '',
|
||||
'email_relay_host' => $config['EMAIL_RELAY_HOST'] ?? '',
|
||||
'email_user' => $config['EMAIL_USER'] ?? '',
|
||||
'email_password' => (string) ($config['EMAIL_PASSWORD'] ?? ''),
|
||||
'email_sender' => $config['EMAIL_SENDER'] ?? '',
|
||||
];
|
||||
$emailService = new MailerService($configMail);
|
||||
app('log')->info('SendInvoiceEmailJob: 发送邮件开始:to:'.$to.',subject:'.$subject.',body:'.$body);
|
||||
|
||||
Reference in New Issue
Block a user