feat: 集成隐私小号功能保护用户手机号

新增隐私小号服务类,在订单创建时自动绑定隐私手机号,收货时自动解绑
在快递发货和收货流程中使用隐私小号替代真实手机号,保护用户隐私
添加数据库字段存储绑定关系,提供系统配置开关和测试脚本
This commit is contained in:
2026-04-01 00:43:51 +08:00
parent 03a6ba7961
commit 42d98407de
14 changed files with 1722 additions and 6 deletions

View File

@@ -21,6 +21,7 @@ use app\services\agent\AgentLevelServices;
use app\services\activity\coupon\StoreCouponUserServices;
use app\services\agent\DivisionServices;
use app\services\other\CityAreaServices;
use app\services\other\PrivacyPhoneServices;
use app\services\pay\PayServices;
use app\services\product\brand\StoreBrandServices;
use app\services\product\category\StoreProductCategoryServices;
@@ -196,12 +197,33 @@ class StoreOrderCreateServices extends BaseServices
$userAddress = $addressInfo['province'] . ' ' . $addressInfo['city'] . ' ' . $addressInfo['district'] . ' ' . $addressInfo['street'] . ' ' . $addressInfo['detail'];
$userLocation = $addressInfo['longitude'] . ' ' . $addressInfo['latitude'];
$type = $is_channel == 1 ? 20 : $type;
$realPhone = $addressInfo['phone'];
$orderUserPhone = $realPhone;
$bindId = '';
$privatePhoneStatus = 0;
/** @var PrivacyPhoneServices $privacyPhoneServices */
$privacyPhoneServices = app()->make(PrivacyPhoneServices::class);
if ($privacyPhoneServices->isEnabled() && $realPhone) {
try {
$privateData = $privacyPhoneServices->bindForOrder($realPhone);
if ($privateData['user_phone']) {
$orderUserPhone = $privateData['user_phone'];
$bindId = $privateData['bind_id'];
$privatePhoneStatus = $privateData['status'];
}
} catch (\Exception $e) {
Log::error('隐私小号绑定失败', ['error' => $e->getMessage()]);
}
}
$orderInfo = [
'uid' => $uid,
'type' => $type,
'order_id' => $this->getNewOrderId(),
'real_name' => $addressInfo['real_name'],
'user_phone' => $addressInfo['phone'],
'user_phone' => $orderUserPhone,
'user_address' => $userAddress,
'user_location' => $userLocation,
'cart_id' => $cartIds,
@@ -247,6 +269,8 @@ class StoreOrderCreateServices extends BaseServices
'send_gift_price' => $priceData['sendGiftPrice'],
'send_gift_mark' => $sendGiftMark,
'channel' => $is_channel,
'private_bind_id' => $bindId,
'private_phone_status' => $privatePhoneStatus,
];
if ($userInfo['user_type'] == 'wechat' || $userInfo['user_type'] == 'routine') {
/** @var WechatUserServices $wechatServices */
@@ -264,7 +288,7 @@ class StoreOrderCreateServices extends BaseServices
}
$priceData['coupon_id'] = $couponId;
$order = $this->transaction(function () use ($cartIds, $couponId, $orderInfo, $cartInfo, $key, $userInfo, $useIntegral, $priceData, $type, $activity_id, $uid, $addressId, $promotions_give) {
$order = $this->transaction(function () use ($cartIds, $couponId, $orderInfo, $cartInfo, $key, $userInfo, $useIntegral, $priceData, $type, $activity_id, $uid, $addressId, $promotions_give, $realPhone) {
//创建订单
$order = $this->dao->save($orderInfo);
if ($couponId) {

View File

@@ -294,7 +294,7 @@ class StoreOrderDeliveryServices extends BaseServices
}
$expData['com'] = $data['delivery_code'];
$expData['to_name'] = $orderInfo['real_name'];
$expData['to_tel'] = $orderInfo['user_phone'];
$expData['to_tel'] = $orderInfo['private_phone'] ?: $orderInfo['user_phone'];
$expData['to_addr'] = $orderInfo['user_address'];
$expData['from_name'] = $data['to_name'];
$expData['from_tel'] = $data['to_tel'];
@@ -346,7 +346,7 @@ class StoreOrderDeliveryServices extends BaseServices
$expressService = app()->make(ServeServices::class);
$expData['kuaidicom'] = $data['delivery_code'];
$expData['man_name'] = $orderInfo->real_name;
$expData['phone'] = $orderInfo->user_phone;
$expData['phone'] = $orderInfo->private_phone ?: $orderInfo->user_phone;
$expData['address'] = $orderInfo->user_address;
$expData['send_real_name'] = $data['to_name'];
$expData['send_phone'] = $data['to_tel'];
@@ -626,7 +626,7 @@ class StoreOrderDeliveryServices extends BaseServices
$expressService = app()->make(ServeServices::class);
$expData['com'] = $dumpInfo['com'];
$expData['to_name'] = $orderInfo->real_name;
$expData['to_tel'] = $orderInfo->user_phone;
$expData['to_tel'] = $orderInfo->private_phone ?: $orderInfo->user_phone;
$expData['to_addr'] = $orderInfo->user_address;
$expData['from_name'] = $dumpInfo['from_name'];
$expData['from_tel'] = $dumpInfo['from_tel'];

View File

@@ -25,6 +25,7 @@ use app\services\user\member\MemberCardServices;
use app\services\user\UserBillServices;
use app\services\user\UserBrokerageServices;
use app\services\user\UserServices;
use app\services\other\PrivacyPhoneServices;
use think\annotation\Inject;
use think\exception\ValidateException;
use think\facade\Log;
@@ -148,6 +149,17 @@ class StoreOrderTakeServices extends BaseServices
if (!($res1 && $res2 && $res3)) {
throw new ValidateException('收货失败!');
}
// 解绑隐私手机号 (AX模式)
// user_phone存储隐私号, private_bind_id存储真实手机号
if ($order['private_bind_id'] && $order['private_phone_status'] == 1) {
/** @var PrivacyPhoneServices $privacyPhoneServices */
$privacyPhoneServices = app()->make(PrivacyPhoneServices::class);
$unbindResult = $privacyPhoneServices->unbindForOrder($order['user_phone'], $order['private_bind_id']);
if ($unbindResult) {
$order->private_phone_status = 0;
$order->save();
}
}
return true;
}, $isTran);
}

View File

@@ -0,0 +1,329 @@
<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\services\other;
use app\services\BaseServices;
use think\facade\Log;
/**
* 隐私小号服务类 (AX模式)
* Class PrivacyPhoneServices
* @package app\services\other
*/
class PrivacyPhoneServices extends BaseServices
{
/**
* API地址
*/
private string $apiUrl = 'https://api.mixcom.cn';
/**
* 获取AppKey
* @return string
*/
public function getAppKey(): string
{
return sys_config('privacy_phone_appkey', '');
}
/**
* 获取AppSecret
* @return string
*/
public function getAppSecret(): string
{
return sys_config('privacy_phone_appsecret', '');
}
/**
* 检查功能是否开启
* @return bool
*/
public function isEnabled(): bool
{
return (bool)sys_config('privacy_phone_status', 0);
}
/**
* 生成签名
* @param array $params
* @return string
*/
private function generateSign(array $params): string
{
$secret = $this->getAppSecret();
if (empty($secret)) {
throw new \Exception('隐私小号AppSecret未配置');
}
$params = array_change_key_case($params, CASE_LOWER);
foreach ($params as $key => $val) {
if ($key == 'c' || $key == 'a' || $key == 'sign') {
unset($params[$key]);
}
if ($val === '') {
unset($params[$key]);
}
}
ksort($params);
$data = '';
foreach ($params as $key => $val) {
$data .= $val;
}
$data .= $secret;
return strtolower(sha1($data . sha1($secret)));
}
/**
* 发送POST请求
* @param string $url
* @param array $params
* @return array
*/
private function post(string $url, array $params): array
{
$params['time'] = time();
$params['appkey'] = $this->getAppKey();
$params['sign'] = $this->generateSign($params);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$result = [
'http_code' => $httpCode,
'response' => $response,
'error' => $error,
'decoded' => json_decode($response, true) ?? []
];
Log::info('隐私小号API请求', [
'url' => $url,
'params' => $params,
'result' => $result
]);
return $result;
}
/**
* 获取可用AX小号
* @param string $regionCode 区号(可选)
* @return array
*/
public function getAxNumber(string $regionCode = ''): array
{
if (!$this->isEnabled()) {
return [];
}
$params = [];
if ($regionCode) {
$params['region_code'] = $regionCode;
}
$url = $this->apiUrl . '/virtual/getaxnumber';
$result = $this->post($url, $params);
if ($result['http_code'] !== 200) {
throw new \Exception('获取隐私小号失败:HTTP ' . $result['http_code']);
}
$data = $result['decoded'];
if (empty($data['success']) || $data['code'] != 200 || empty($data['data'])) {
throw new \Exception('获取隐私小号失败:' . ($data['msg'] ?? '未知错误'));
}
return $data['data'];
}
/**
* 获取第一个可用小号
* @return string|null
*/
public function getFirstAxNumber(): ?string
{
$numbers = $this->getAxNumber();
return $numbers[0]['number'] ?? null;
}
/**
* AX绑定
* @param string $number 隐私小号
* @param string $aParty A号码(用户手机号)
* @param array $options 可选参数
* @return array
*/
public function axBind(string $number, string $aParty, array $options = []): array
{
if (!$this->isEnabled()) {
throw new \Exception('隐私小号功能未开启');
}
$params = array_merge([
'number' => $number,
'aparty' => $aParty,
'recording' => 0,
'recordhinttone' => '',
'display_call' => 0,
'callerhinttone' => '',
'calleehinttone' => '',
'enddate' => 0,
], $options);
$url = $this->apiUrl . '/virtual/axBind';
$result = $this->post($url, $params);
if ($result['http_code'] !== 200) {
throw new \Exception('AX绑定失败:HTTP ' . $result['http_code']);
}
$data = $result['decoded'];
if (empty($data['success']) || $data['code'] != 200) {
throw new \Exception('AX绑定失败:' . ($data['msg'] ?? '未知错误'));
}
return $data;
}
/**
* AX解绑
* @param string $number 隐私小号
* @param string $aParty A号码(用户手机号)
* @return array
*/
public function unAxBind(string $number, string $aParty): array
{
if (!$this->isEnabled()) {
throw new \Exception('隐私小号功能未开启');
}
$params = [
'number' => $number,
'aparty' => $aParty,
];
$url = $this->apiUrl . '/virtual/unAxBind';
$result = $this->post($url, $params);
if ($result['http_code'] !== 200) {
throw new \Exception('AX解绑失败:HTTP ' . $result['http_code']);
}
$data = $result['decoded'];
if (empty($data['success']) || $data['code'] != 200) {
throw new \Exception('AX解绑失败:' . ($data['msg'] ?? '未知错误'));
}
return $data;
}
/**
* 订单创建时绑定隐私手机号
* @param string $realPhone 用户真实手机号
* @return array ['user_phone' => 隐私号, 'bind_id' => 真实手机号, 'status' => 0]
*/
public function bindForOrder(string $realPhone): array
{
if (!$this->isEnabled()) {
return [];
}
$result = [
'user_phone' => '',
'bind_id' => '',
'status' => 0
];
try {
$number = $this->getFirstAxNumber();
if (!$number) {
Log::error('获取隐私小号失败:无可用号码');
return $result;
}
$bindResult = $this->axBind($number, $realPhone);
if (!empty($bindResult['success']) && $bindResult['code'] == 200) {
$result = [
'user_phone' => $number,
'bind_id' => $realPhone,
'status' => 1
];
Log::info('隐私小号绑定成功', [
'privacy_phone' => $number,
'real_phone' => $realPhone
]);
}
} catch (\Exception $e) {
Log::error('隐私小号绑定异常', [
'real_phone' => $realPhone,
'error' => $e->getMessage()
]);
}
return $result;
}
/**
* 订单完成时解绑隐私手机号
* @param string $privacyPhone 隐私手机号(user_phone)
* @param string $realPhone 真实手机号(private_bind_id)
* @return bool
*/
public function unbindForOrder(string $privacyPhone, string $realPhone): bool
{
if (!$this->isEnabled()) {
return false;
}
if (empty($privacyPhone)) {
return false;
}
try {
$result = $this->unAxBind($privacyPhone, $realPhone);
if (!empty($result['success']) && $result['code'] == 200) {
Log::info('隐私小号解绑成功', [
'privacy_phone' => $privacyPhone,
'real_phone' => $realPhone
]);
return true;
}
} catch (\Exception $e) {
Log::error('隐私小号解绑异常', [
'privacy_phone' => $privacyPhone,
'real_phone' => $realPhone,
'error' => $e->getMessage()
]);
}
return false;
}
}