This commit is contained in:
wanghai
2026-04-17 21:00:30 +08:00
parent 14a716c821
commit 9b34f9c83b
56 changed files with 2812 additions and 56 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -0,0 +1,36 @@
<?php
/**
* Copyright 2019-2026 ShopeX
*/
namespace CompanysBundle\Services;
use CompanysBundle\Services\Shops\WxShopsService;
use DistributionBundle\Services\DistributorService;
/**
* 与 GET /companys/settingCompanys::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;
}
}

View File

@@ -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];
}
}

View File

@@ -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'));
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
//获取邮件模板

View File

@@ -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('用户名或密码错误');
}

View File

@@ -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');

View File

@@ -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)

View File

@@ -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'];

View 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;
}
}

View 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;
}
}

View File

@@ -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
*

View File

@@ -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');

View File

@@ -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));

View File

@@ -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(

View 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
);
}
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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']);
}

View 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;
/**
* 邮箱激活:一次性链接 tokenDB 存哈希),与密码重置 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);
}
}

View 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'));
}
}
}

View 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']);
}
}
}

View 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'));
}
}
}

View File

@@ -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;
}

View 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'));
}
}

View 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]));
}
}

View File

@@ -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'];
}

View File

@@ -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);