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

384
AGENTS.md Normal file
View File

@@ -0,0 +1,384 @@
# AGENTS.md - CRMEB PRO Development Guide
## Project Overview
CRMEB PRO is an e-commerce system based on ThinkPHP 8.0 + Swoole 4. PHP 8.0+ required. This document provides guidelines for agentic coding assistants working in this codebase.
---
## 1. Build/Lint/Test Commands
### Application Commands
```bash
# Start Swoole server (main application command)
php think swoole
# Clear runtime files
php think clear
# Build route cache
php think optimize:route
# Build database schema cache
php think optimize:schema
```
### Code Generation
```bash
# Generate controller
php think make:controller api@user/User
# Generate model
php think make:model api@user/User
# Generate service (app/services/)
php think make:service api@user/User
# Generate dao (app/dao/)
php think make:dao api@user/User
# Generate validate class
php think make:validate api@user/User
# Generate event
php think make:event UserLogin
# Generate listener
php think make:listener UserLoginListener
# Generate middleware
php think make:middleware Auth
```
### Queue Commands
```bash
# Process queue jobs
php think queue:work
# Listen to queue
php think queue:listen
# Retry failed jobs
php think queue:retry
# List failed jobs
php think queue:failed
```
### Dependency Management
```bash
# Install dependencies
composer install
# Update dependencies
composer update
# Dump autoload
composer dump-autoload
```
---
## 2. Code Style Guidelines
### PSR Standards
- Follows **PSR-2** (Coding Style) and **PSR-4** (Autoloading)
### Naming Conventions
| Element | Convention | Example |
|---------|------------|---------|
| Directory | lowercase + underscore | `app/controller/admin` |
| File (.php) | CamelCase or lowercase_underscore | `UserController.php`, `common.php` |
| Class/Interface/Trait | PascalCase | `BaseServices`, `UserDao` |
| Method | camelCase | `getUserInfo()`, `createToken()` |
| Property | camelCase | `$adminInfo`, `$page` |
| Variable | camelCase | `$userId`, `$orderData` |
| Constant | UPPER_SNAKE_CASE | `PASSWORD_BCRYPT`, `MAX_RETRY` |
| Config param | lowercase + underscore | `url_route_on`, `cache.is_gzde` |
| Table/Column | lowercase + underscore | `eb_user`, `user_name` |
### File Header Template
```php
<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
namespace app\controller\admin;
```
### PHP Syntax Rules
1. **Namespace/use blocks** - Insert blank line after use statements
2. **Braces** - Class/method braces on separate lines
3. **Access modifiers** - Required for all methods and properties (`private`, `protected`, `public`)
4. **Parameters** - Comma after each param, not before
5. **Control structures** - Space after keyword (`if (` not `if(`)
6. **Assignment** - Spaces around `=` (`$arr = []`)
7. **Closing tag** - Omit `?>` for pure PHP files
8. **PHP 8.0+ features** - Use nullsafe operator (`$obj?->method()`), named arguments, union types
---
## 3. Architecture Patterns
### Directory Structure
```
app/
├── controller/ # Controllers (extend BaseController)
│ ├── admin/ # Admin panel controllers
│ ├── api/ # API controllers
│ └── supplier/ # Supplier controllers
├── services/ # Business logic layer
├── dao/ # Data access layer
├── model/ # Database models
├── validate/ # Form validation classes
├── http/
│ └── middleware/ # Middleware classes
└── jobs/ # Queue jobs
crmeb/
├── basic/ # Base classes (BaseController, BaseModel, BaseServices)
├── traits/ # Reusable traits
├── services/ # Core services
├── utils/ # Utility classes
└── exceptions/ # Custom exception classes
```
### Layer Pattern
```
Controller -> Service -> Dao -> Model
```
- **Controller**: Request handling, response formatting, auth checks
- **Service**: Business logic, transaction management
- **Dao**: Data query operations, search conditions
- **Model**: Database operations via ORM
### Class Naming
- Controllers: `{Name}Controller` (e.g., `StoreProductController`)
- Services: `{Name}Services` (e.g., `StoreProductServices`)
- DAOs: `{Name}Dao` (e.g., `StoreProductDao`)
- Models: `{Name}` (e.g., `StoreProduct`)
- Validates: `{Name}Validate` (e.g., `StoreProductValidate`)
---
## 4. Controller Guidelines
### Base Controller
```php
use app\controller\admin\AuthController; // extends BaseController
class ProductController extends AuthController
{
// Access $this->request via dependency injection
public function index(Request $request): Response
{
// Use $this->success() / $this->fail() for JSON responses
return $this->success(['list' => []]);
}
}
```
### Request Data Retrieval
```php
// Single parameter
$id = $this->request->param('id');
// Multiple parameters with defaults
[$page, $limit] = $this->request->more(['page/d', 'limit/d']);
// With default values
[$name, $status] = $this->request->more([
['name', ''],
['status', 0]
], true);
```
---
## 5. Service Guidelines
### BaseServices Pattern
```php
namespace app\services\product;
use app\services\BaseServices;
class StoreProductServices extends BaseServices
{
// Inject dao via constructor or magic method __call
public function getInfo(int $id)
{
return $this->dao->get($id);
}
}
```
### Dao Injection
Services access Dao via `$this->dao` property (injected via `ServicesTrait`).
---
## 6. Dao Guidelines
### BaseDao Pattern
```php
namespace app\dao\product;
use app\dao\BaseDao;
class StoreProductDao extends BaseDao
{
protected string $alias = 'product'; // Table alias
protected string $filterateField = []; // Fields to filter from search
protected function setModel(): string
{
return StoreProduct::class;
}
// Custom search conditions
public function search(array $where = [])
{
return parent::search($where);
}
}
```
---
## 7. Model Guidelines
### BaseModel Pattern
```php
namespace app\model\product;
use crmeb\basic\BaseModel;
use crmeb\traits\ModelTrait;
class StoreProduct extends BaseModel
{
use ModelTrait;
// Define searchers for field-based queries
protected string $name = 'store_product';
protected string $pk = 'id';
}
```
---
## 8. Validation Guidelines
### Validate Class Pattern
```php
namespace app\validate\admin\product;
use think\Validate;
class StoreProductValidate extends Validate
{
protected $rule = [
'name' => 'require|max:200',
'price' => 'require|float|egt:0',
];
protected $message = [
'name.require' => '请填写商品名称',
'price.require' => '请填写价格',
];
}
```
### Validation in Controller
```php
use app\validate\admin\product\StoreProductValidate;
public function save(StoreProductValidate $validate)
{
// Validates automatically, throws ValidateException on failure
}
```
---
## 9. Error Handling
### Custom Exceptions
```php
// In crmeb/exceptions/
throw new AuthException('无权限访问', 400);
throw new ApiException('参数错误', 400);
throw new AdminException('操作失败', 400);
```
### ExceptionHandle
The app extends ThinkPHP's exception handling to return JSON responses:
```php
// Returns: {"status": 400, "msg": "错误信息", "data": {...}}
```
---
## 10. Middleware Guidelines
### Middleware Interface Pattern
```php
namespace app\http\middleware;
use app\http\middleware\BaseMiddleware;
use app\interfaces\MiddlewareInterface;
class AuthMiddleware implements MiddlewareInterface
{
public function handle(Request $request, \Closure $next)
{
// Before logic
$response = $next($request);
// After logic
return $response;
}
}
```
---
## 11. Important Patterns
### Transactions
```php
// In services
$this->transaction(function () use ($data) {
$this->save($data);
$this->dao->incUpdate($id, 'views', 1);
});
```
### JSON Response Helper
```php
// Available in controllers via BaseController
return $this->success($data);
return $this->fail('Error message');
return app('json')->code(500)->make(400, 'msg', $data);
```
### Magic Methods in Request
```php
// These are available via __call in Request class
$uid = $this->request->uid();
$adminInfo = $this->request->adminInfo();
$tokenData = $this->request->tokenData();
```
---
## 12. Type Hints
- Use strict type hints: `public function get(int $id): ?array`
- Return types required for public methods
- Nullable types using `?` prefix

View File

@@ -211,6 +211,7 @@ class Login
*/ */
public function verify(Request $request) public function verify(Request $request)
{ {
// 1. 获取请求参数:手机号、验证码类型、防刷校验key、图形验证码类型及校验值
[$phone, $type, $key, $captchaType, $captchaVerification] = $request->postMore([ [$phone, $type, $key, $captchaType, $captchaVerification] = $request->postMore([
['phone', 0], ['phone', 0],
['type', ''], ['type', ''],
@@ -219,13 +220,16 @@ class Login
['captchaVerification', ''], ['captchaVerification', ''],
], true); ], true);
// 2. 构造缓存Key:防刷校验Key与当前分钟级别的频率限制Key
$keyName = 'sms.key.' . $key; $keyName = 'sms.key.' . $key;
$nowKey = 'sms.' . date('YmdHi'); $nowKey = 'sms.' . date('YmdHi');
// 3. 校验请求合法性:检查前端传递的防刷校验Key是否存在于缓存中,防止非法/机器请求
if (!CacheService::has($keyName)) if (!CacheService::has($keyName))
return app('json')->make(401, '发送验证码失败,请刷新页面重新获取'); return app('json')->make(401, '发送验证码失败,请刷新页面重新获取');
// 4. 发送频率限制(分钟级防刷):检查当前一分钟内该接口的调用总次数是否超过配置上限(默认20次)
$total = 1; $total = 1;
if (CacheService::has($nowKey)) { if (CacheService::has($nowKey)) {
$total = CacheService::get($nowKey); $total = CacheService::get($nowKey);
@@ -233,23 +237,33 @@ class Login
return app('json')->success('已发送'); return app('json')->success('已发送');
} }
//二次验证 // 二次验证(图形验证码校验,当前代码中被注释)
// try { // try {
// aj_captcha_check_two($captchaType, $captchaVerification); // aj_captcha_check_two($captchaType, $captchaVerification);
// } catch (\Throwable $e) { // } catch (\Throwable $e) {
// return app('json')->fail($e->getError()); // return app('json')->fail($e->getError());
// } // }
// 5. 数据验证:使用 RegisterValidates 验证器专门校验手机号格式是否合法
try { try {
validate(RegisterValidates::class)->scene('code')->check(['phone' => $phone]); validate(RegisterValidates::class)->scene('code')->check(['phone' => $phone]);
} catch (ValidateException $e) { } catch (ValidateException $e) {
return app('json')->fail($e->getError()); return app('json')->fail($e->getError());
} }
// 6. 获取配置:从系统配置中获取验证码的有效时长(单位:分钟),默认1分钟
$time = sys_config('verify_expire_time', 1); $time = sys_config('verify_expire_time', 1);
// 7. 核心业务处理:调用 LoginServices 的 verify 方法,执行如检查是否注册、生成验证码、异步派发短信发送任务等逻辑
$smsCode = $this->services->verify($phone, $type, $time, app()->request->ip()); $smsCode = $this->services->verify($phone, $type, $time, app()->request->ip());
// 8. 结果处理:如果短信验证码生成及派发成功
if ($smsCode) { if ($smsCode) {
// 将验证码写入缓存,设置过期时间(转换为秒)
CacheService::set('code_' . $phone, $smsCode, $time * 60); CacheService::set('code_' . $phone, $smsCode, $time * 60);
// 记录/更新当前分钟的接口请求频次(有效期61秒)
CacheService::set($nowKey, $total, 61); CacheService::set($nowKey, $total, 61);
// 触发短信发送后的事件(可用于后续扩展业务逻辑)
event('sms.sendAfter', [$smsCode, $phone]); event('sms.sendAfter', [$smsCode, $phone]);
return app('json')->success('发送成功'); return app('json')->success('发送成功');
} else { } else {

View File

@@ -21,6 +21,7 @@ use app\services\agent\AgentLevelServices;
use app\services\activity\coupon\StoreCouponUserServices; use app\services\activity\coupon\StoreCouponUserServices;
use app\services\agent\DivisionServices; use app\services\agent\DivisionServices;
use app\services\other\CityAreaServices; use app\services\other\CityAreaServices;
use app\services\other\PrivacyPhoneServices;
use app\services\pay\PayServices; use app\services\pay\PayServices;
use app\services\product\brand\StoreBrandServices; use app\services\product\brand\StoreBrandServices;
use app\services\product\category\StoreProductCategoryServices; 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']; $userAddress = $addressInfo['province'] . ' ' . $addressInfo['city'] . ' ' . $addressInfo['district'] . ' ' . $addressInfo['street'] . ' ' . $addressInfo['detail'];
$userLocation = $addressInfo['longitude'] . ' ' . $addressInfo['latitude']; $userLocation = $addressInfo['longitude'] . ' ' . $addressInfo['latitude'];
$type = $is_channel == 1 ? 20 : $type; $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 = [ $orderInfo = [
'uid' => $uid, 'uid' => $uid,
'type' => $type, 'type' => $type,
'order_id' => $this->getNewOrderId(), 'order_id' => $this->getNewOrderId(),
'real_name' => $addressInfo['real_name'], 'real_name' => $addressInfo['real_name'],
'user_phone' => $addressInfo['phone'], 'user_phone' => $orderUserPhone,
'user_address' => $userAddress, 'user_address' => $userAddress,
'user_location' => $userLocation, 'user_location' => $userLocation,
'cart_id' => $cartIds, 'cart_id' => $cartIds,
@@ -247,6 +269,8 @@ class StoreOrderCreateServices extends BaseServices
'send_gift_price' => $priceData['sendGiftPrice'], 'send_gift_price' => $priceData['sendGiftPrice'],
'send_gift_mark' => $sendGiftMark, 'send_gift_mark' => $sendGiftMark,
'channel' => $is_channel, 'channel' => $is_channel,
'private_bind_id' => $bindId,
'private_phone_status' => $privatePhoneStatus,
]; ];
if ($userInfo['user_type'] == 'wechat' || $userInfo['user_type'] == 'routine') { if ($userInfo['user_type'] == 'wechat' || $userInfo['user_type'] == 'routine') {
/** @var WechatUserServices $wechatServices */ /** @var WechatUserServices $wechatServices */
@@ -264,7 +288,7 @@ class StoreOrderCreateServices extends BaseServices
} }
$priceData['coupon_id'] = $couponId; $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); $order = $this->dao->save($orderInfo);
if ($couponId) { if ($couponId) {

View File

@@ -294,7 +294,7 @@ class StoreOrderDeliveryServices extends BaseServices
} }
$expData['com'] = $data['delivery_code']; $expData['com'] = $data['delivery_code'];
$expData['to_name'] = $orderInfo['real_name']; $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['to_addr'] = $orderInfo['user_address'];
$expData['from_name'] = $data['to_name']; $expData['from_name'] = $data['to_name'];
$expData['from_tel'] = $data['to_tel']; $expData['from_tel'] = $data['to_tel'];
@@ -346,7 +346,7 @@ class StoreOrderDeliveryServices extends BaseServices
$expressService = app()->make(ServeServices::class); $expressService = app()->make(ServeServices::class);
$expData['kuaidicom'] = $data['delivery_code']; $expData['kuaidicom'] = $data['delivery_code'];
$expData['man_name'] = $orderInfo->real_name; $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['address'] = $orderInfo->user_address;
$expData['send_real_name'] = $data['to_name']; $expData['send_real_name'] = $data['to_name'];
$expData['send_phone'] = $data['to_tel']; $expData['send_phone'] = $data['to_tel'];
@@ -626,7 +626,7 @@ class StoreOrderDeliveryServices extends BaseServices
$expressService = app()->make(ServeServices::class); $expressService = app()->make(ServeServices::class);
$expData['com'] = $dumpInfo['com']; $expData['com'] = $dumpInfo['com'];
$expData['to_name'] = $orderInfo->real_name; $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['to_addr'] = $orderInfo->user_address;
$expData['from_name'] = $dumpInfo['from_name']; $expData['from_name'] = $dumpInfo['from_name'];
$expData['from_tel'] = $dumpInfo['from_tel']; $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\UserBillServices;
use app\services\user\UserBrokerageServices; use app\services\user\UserBrokerageServices;
use app\services\user\UserServices; use app\services\user\UserServices;
use app\services\other\PrivacyPhoneServices;
use think\annotation\Inject; use think\annotation\Inject;
use think\exception\ValidateException; use think\exception\ValidateException;
use think\facade\Log; use think\facade\Log;
@@ -148,6 +149,17 @@ class StoreOrderTakeServices extends BaseServices
if (!($res1 && $res2 && $res3)) { if (!($res1 && $res2 && $res3)) {
throw new ValidateException('收货失败!'); 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; return true;
}, $isTran); }, $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;
}
}

View File

@@ -0,0 +1,14 @@
<?php
date_default_timezone_set('Asia/Shanghai');
define('ROOT_PATH', dirname(__FILE__) . DIRECTORY_SEPARATOR);
require './lib/PrivateNumber.php';
$pn = new PrivateNumber();
// 这里设置appkey
$pn->setAppkey('');
// 这里设置秘钥
$pn->setSecret('');
$result = $pn->countNumber();
print_r($result);

View File

@@ -0,0 +1,457 @@
<?php
// 号码隐私保护类
class PrivateNumber
{
// 网站的appkey
private $appkey = "";
// appkey 对应的秘钥
private $secret = "";
public $apiurl = "https://api.mixcom.cn";
/**
* 设置appkey
* @param appkey
*/
public function setAppkey($appkey)
{
$this->appkey = $appkey;
}
/**
* 设置秘钥
* @param secret
*/
public function setSecret($secret)
{
$this->secret = $secret;
}
/**
* 获取服务器的时间
* @return
*/
public function getServerTime()
{
$url = $this->apiurl . "/virtual/getServerTime";
$json = $this->get($url);
return $json;
}
/**
* 获取可用的axb号码
* region_code 区号,如深圳区号为 0755, 通过这个参数查询指定地区的axb号码, 可选
* 获取号码的优先级: 绑定数量少优先 > 绑定总数少优先
* @return
*/
public function getAxbNumber($arr = array())
{
$url = $this->apiurl . "/virtual/getaxbnumber";
$params = array(
'region_code' => '',
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* axb号码绑定
* number 虚拟小号,必选
* aparty 呼叫方的号码,必选
* bparty 被叫方的号码,必选
* recording 是否开启录音,1开启, 0 不开启。使用前请先确定使用了通话录音功能
* recordhinttone 录音的提示音,可选
* maxduration 最大通话时长,单位为分钟,到了时间会自动挂断,可选
* lastminvoice 通话最后一分钟的提示音, 当最大通话时长不为0时有效,可选
* callerhinttone A拨打X通话前等待音,可选
* calleeHintTone B拨打X通话前等待音,可选
* direction 呼叫方向, 可选值0, 1, 2. 0代表可双向呼叫,可选
* enddate 解绑时间,时间戳格式,可选,0代表不设置解绑时间
* @return
*/
public function axbBind($arr = array())
{
$url = $this->apiurl . "/virtual/axbBind";
$params = array(
'number' => '',
'aparty' => '',
'bparty' => '',
'recording' => 0,
'recordhinttone' => '',
'maxduration' => 0,
'lastminvoice' => '',
'callerhinttone' => '',
'calleeHintTone' => '',
'direction' => 0,
'enddate' => 0,
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* axb号码解绑
* number 虚拟小号,必选
* aparty 呼叫方的号码,必选
* bparty 被叫方的号码,必选
* @return
*/
public function unAxbBind($arr = array())
{
$url = $this->apiurl . "/virtual/unAxbBind";
$params = array(
'number' => '',
'aparty' => '',
'bparty' => '',
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* axb号码自动绑定
* aparty 呼叫方的号码,必选
* bparty 被叫方的号码,必选
* recording 是否开启录音,1开启, 0 不开启。使用前请先确定使用了通话录音功能
* recordhinttone 录音的提示音,可选
* maxduration 最大通话时长,单位为分钟,到了时间会自动挂断,可选
* lastminvoice 通话最后一分钟的提示音, 当最大通话时长不为0时有效,可选
* callerhinttone A拨打X通话前等待音,可选
* calleeHintTone B拨打X通话前等待音,可选
* direction 呼叫方向, 可选值0, 1, 2. 0代表可双向呼叫,可选
* enddate 解绑时间,时间戳格式,可选,0代表不设置解绑时间
* @return
*/
public function axbAutoBind($arr = array())
{
$url = $this->apiurl . "/virtual/axbAutoBind";
$params = array(
'aparty' => '',
'bparty' => '',
'region_code' => '',
'defregioncode' => '',
'recording' => 0,
'recordhinttone' => '',
'maxduration' => 0,
'lastminvoice' => '',
'callerhinttone' => '',
'calleeHintTone' => '',
'direction' => 0,
'enddate' => 0,
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* 获取可用的ax号码
* region_code 区号,如深圳区号为 0755, 通过这个参数查询指定地区的ax号码, 可选
* @return
*/
public function getAxNumber($arr = array())
{
$url = $this->apiurl . "/virtual/getAxNumber";
$params = array(
'region_code' => '',
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* ax号码绑定
* number 虚拟小号,必选
* aparty 呼叫方的号码,必选
* recording 是否开启录音,1开启, 0 不开启。使用前请先确定使用了通话录音功能,可选
* recordhinttone 录音前播放的提示音,可选
* display_call 是否开启号码透传。默认为0,不开启。1为开启,开启后可能会导致部分通话无法接通,可选
* callerhinttone A拨打X通话前等待音,可选
* calleeHintTone B拨打X通话前等待音,可选
* enddate 解绑时间,时间戳格式,可选,0代表不设置解绑时间
* @return
*/
public function axBind($arr = array())
{
$url = $this->apiurl . "/virtual/axBind";
$params = array(
'number' => '',
'aparty' => '',
'recording' => 0,
'recordhinttone' => '',
'display_call' => 0,
'callerhinttone' => '',
'calleehinttone' => '',
'enddate' => 0,
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* ax号码解绑
* number 虚拟小号,必选
* aparty 呼叫方的号码,必选
* @return
*/
public function unAxBind($arr = array())
{
$url = $this->apiurl . "/virtual/unAxBind";
$params = array(
'number' => '',
'aparty' => '',
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* AX绑定呼出
* number 虚拟小号,必选
* aparty 呼叫方的号码,必选
* bparty 被叫方的号码,必选
* @return
*/
public function axDialNumber($arr = array())
{
$url = $this->apiurl . "/virtual/axdialnumber";
$params = array(
'number' => '',
'aparty' => '',
'bparty' => '',
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* ax号码自动绑定
* aparty 呼叫方的号码,必选
* region_code 需要的虚拟小号的区号, 可选
* display_call 是否开启号码透传。默认为0,不开启。1为开启,开启后可能会导致部分通话无法接通,可选
* recording 是否开启录音,1开启, 0 不开启。使用前请先确定使用了通话录音功能,可选
* recordhinttone 录音前播放的提示音,可选
* callerhinttone A拨打X通话前等待音,可选
* calleeHintTone B拨打X通话前等待音,可选
* enddate 解绑时间,时间戳格式,可选,0代表不设置解绑时间
* @return
*/
public function axAutoBind($arr = array())
{
$url = $this->apiurl . "/virtual/axAutoBind";
$params = array(
'aparty' => '',
'region_code' => '',
'defregioncode' => '',
'display_call' => 0,
'recording' => 0,
'recordhinttone' => '',
'callerhinttone' => '',
'calleehinttone' => '',
'enddate' => 0,
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* 挂断指定通话
* subscriptionid 该通话的绑定关系id
* @return
*/
public function callStop($arr)
{
$url = $this->apiurl . "/virtual/callStop";
$params = [
'subscriptionid' => '',
];
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* 获取小号的个数
* @return
*/
public function countNumber()
{
$url = $this->apiurl . "/virtual/countNumber";
$params = array();
$json = $this->post($url, $params);
return $json;
}
/**
* 查询所有的号码
* page 需要查询的页数,每页100条数据
* @return
*/
public function queryAllNumber($arr = array())
{
$url = $this->apiurl . "/virtual/queryAllNumber";
$params = array(
'page' => 1,
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* 查询号码的绑定关系
* number 虚拟小号,可选
* aparty 呼叫方的号码,可选
* bparty 被叫方的号码,可选
* @return
*/
public function queryBindRelation($arr = array())
{
$url = $this->apiurl . "/virtual/querybindrelation";
$params = array(
'number' => '',
'aparty' => '',
'bparty' => '',
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* 查询号码的绑定关系日志
* number 虚拟小号,可选
* aparty 呼叫方的号码,可选
* bparty 被叫方的号码,可选
* timestart 开始时间,可选
* timeend 结束时间,可选
* page 页数,可选
* @return
*/
public function queryRelationLog($arr = array())
{
$url = $this->apiurl . "/virtual/queryrelationlog";
$params = array(
'number' => '',
'aparty' => '',
'bparty' => '',
'timestart' => 0,
'timeend' => 0,
'page' => 1,
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* 查询通话记录
* number 虚拟小号,可选
* aparty 呼叫方的号码,可选
* bparty 被叫方的号码,可选
* timestart 开始时间,可选
* timeend 结束时间,可选
* page 页数,可选
* @return
*/
public function queryCallRecord($arr = array())
{
$url = $this->apiurl . "/virtual/querycallrecord";
$params = array(
'number' => '',
'aparty' => '',
'bparty' => '',
'timestart' => 0,
'timeend' => 0,
'page' => 1,
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
/**
* 返回录音的下载链接(在录音未删除之前有效)
* number 虚拟小号,必选
* callIdentifier 通话录音id,必选
*/
public function getRecordUrl($arr = array())
{
$url = $this->apiurl . "/virtual/getrecordurl";
$params = array(
'number' => '',
'callIdentifier' => '',
);
$params = array_merge($params, $arr);
$json = $this->post($url, $params);
return $json;
}
private function get($url)
{
$ch = curl_init();
//设置选项,包括URL
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 0);
//执行并获取HTML文档内容
$data = curl_exec($ch);
//释放curl句柄
curl_close($ch);
return $data;
}
private function post($url, $params = array())
{
$params['time'] = time();
$params['appkey'] = $this->appkey;
$params['sign'] = $this->getSign($params);
$ch = curl_init(); //初始化curl
curl_setopt($ch, CURLOPT_URL, $url); //抓取指定网页
curl_setopt($ch, CURLOPT_HEADER, 0); //设置header
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); //要求结果为字符串且输出到屏幕上
curl_setopt($ch, CURLOPT_POST, 1); //post提交方式
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$data = curl_exec($ch); //运行curl
curl_close($ch);
return $data;
}
private function getSign($params)
{
// key转换为小写
$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]);
}
}
// 对key进行排序, 按名称升序
ksort($params);
// 将数据连接,进行加密
$data = '';
foreach ($params as $key => $val) {
$data .= $val;
}
$data .= $this->secret;
$sign = strtolower(sha1($data . sha1($this->secret)));
return $sign;
}
}

View File

@@ -0,0 +1 @@
隐私保护小号php sdk,不使用composer自动加载的方式执行

View File

@@ -0,0 +1,20 @@
-- 隐私手机号功能 - 数据库改动 (AX模式)
-- 执行时间: 2026-04-01
-- 说明: AX模式下 user_phone 直接存储隐私手机号
-- private_bind_id 存储真实手机号(用于解绑)
-- private_phone_status 存储绑定状态
-- 1. 订单表添加字段
ALTER TABLE `eb_store_order`
ADD COLUMN `private_bind_id` VARCHAR(18) NOT NULL DEFAULT '' COMMENT '隐私绑定真实手机号(用于解绑)' AFTER `user_address`,
ADD COLUMN `private_phone_status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '隐私手机绑定状态:0未绑定,1已绑定' AFTER `private_bind_id`;
-- 2. 系统配置表添加隐私手机号配置
INSERT INTO `eb_system_config` (`id`, `is_store`, `menu_name`, `type`, `input_type`, `config_tab_id`, `parameter`, `upload_type`, `required`, `width`, `high`, `value`, `info`, `desc`, `sort`, `status`) VALUES
(NULL, 0, 'privacy_phone_status', 'radio', 'input', 1, '0=>关闭\n1=>开启', 0, '', 0, 0, '0', '隐私手机号功能', '开启后订单发货使用隐私手机号', 0, 1),
(NULL, 0, 'privacy_phone_appkey', 'text', 'input', 1, '', 0, 'required:true', 100, 0, '', '米糠云AppKey', '米糠云小号平台AppKey', 0, 1),
(NULL, 0, 'privacy_phone_appsecret', 'text', 'input', 1, '', 0, 'required:true', 100, 0, '', '米糠云AppSecret', '米糠云小号平台AppSecret', 0, 1);
-- 查询验证
-- SELECT id, order_id, user_phone, private_bind_id, private_phone_status FROM eb_store_order LIMIT 10;
-- SELECT * FROM eb_system_config WHERE menu_name LIKE 'privacy_phone%';

Binary file not shown.

3
run.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
php think swoole

View File

@@ -0,0 +1,228 @@
<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
namespace Tests\Other\PrivacyPhone;
use PHPUnit\Framework\TestCase;
/**
* 米糠云隐私小号接口测试
*/
class MikangyunApiTest extends TestCase
{
private string $appkey = '0f2494a0e258c85575d1e2aa68e07687';
private string $appsecret = ''; // TODO: 请填写完整的AppSecret
private string $serverRoot = 'http://api.mixcom.cn';
/**
* 生成签名
* @param array $params
* @return string
*/
private function generateSign(array $params): string
{
$params = array_filter($params, function ($v, $k) {
return $v !== '' && $k !== 'sign';
}, ARRAY_FILTER_USE_BOTH);
ksort($params);
$string = '';
foreach ($params as $v) {
$string .= $v;
}
return strtolower(sha1($string . sha1($this->appsecret)));
}
/**
* 发送POST请求
* @param string $url
* @param array $params
* @return array
*/
private function post(string $url, array $params): array
{
$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, http_build_query($params));
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
return [
'http_code' => $httpCode,
'response' => $response,
'error' => $error,
'decoded' => json_decode($response, true) ?? []
];
}
/**
* 测试获取AXB可用小号
*/
public function testGetAxbNumber()
{
echo "\n========== 测试获取AXB可用小号 ==========\n";
if (empty($this->appsecret)) {
$this->markTestSkipped('请先填写完整的AppSecret');
}
$time = time();
$params = [
'appkey' => $this->appkey,
'time' => $time,
];
$params['sign'] = $this->generateSign($params);
$url = $this->serverRoot . '/virtual/getAxbNumber';
echo "请求URL: $url\n";
echo "请求参数: " . json_encode($params, JSON_UNESCAPED_UNICODE) . "\n";
$result = $this->post($url, $params);
echo "HTTP状态码: {$result['http_code']}\n";
echo "响应内容: {$result['response']}\n";
if ($result['error']) {
echo "curl错误: {$result['error']}\n";
}
$this->assertEquals(200, $result['http_code'], 'HTTP状态码应为200');
$data = $result['decoded'];
$this->assertArrayHasKey('success', $data, '响应应包含success字段');
$this->assertArrayHasKey('code', $data, '响应应包含code字段');
$this->assertArrayHasKey('data', $data, '响应应包含data字段');
if ($data['success'] && !empty($data['data'])) {
echo "\n获取成功!可用小号列表:\n";
foreach ($data['data'] as $item) {
echo " - 号码: {$item['number']}, 区号: {$item['region_code']}, 绑定数: {$item['bindcount']}\n";
}
}
return $data;
}
/**
* 测试AXB绑定
* @depends testGetAxbNumber
*/
public function testAxbBind($axbData)
{
echo "\n========== 测试AXB绑定 ==========\n";
if (empty($this->appsecret)) {
$this->markTestSkipped('请先填写完整的AppSecret');
}
if (empty($axbData['data']) || empty($axbData['data'][0]['number'])) {
$this->markTestSkipped('无可用小号,无法测试绑定');
}
// TODO: 请填写测试用的A号码(用户手机号)和B号码(商家/快递手机号)
$aParty = ''; // TODO: 填写A号码
$bParty = ''; // TODO: 填写B号码
if (empty($aParty) || empty($bParty)) {
$this->markTestSkipped('请先填写A号码和B号码进行测试');
}
$number = $axbData['data'][0]['number'];
$time = time();
$params = [
'appkey' => $this->appkey,
'time' => $time,
'number' => $number,
'aparty' => $aParty,
'bparty' => $bParty,
];
$params['sign'] = $this->generateSign($params);
$url = $this->serverRoot . '/virtual/axbBind';
echo "请求URL: $url\n";
echo "请求参数: " . json_encode($params, JSON_UNESCAPED_UNICODE) . "\n";
$result = $this->post($url, $params);
echo "HTTP状态码: {$result['http_code']}\n";
echo "响应内容: {$result['response']}\n";
$this->assertEquals(200, $result['http_code'], 'HTTP状态码应为200');
$data = $result['decoded'];
$this->assertTrue($data['success'] ?? false, '绑定应成功');
$this->assertEquals(200, $data['code'] ?? 0, '响应码应为200');
echo "\n绑定成功!\n";
echo "隐私小号: $number\n";
echo "A号码: $aParty\n";
echo "B号码: $bParty\n";
return [
'number' => $number,
'aparty' => $aParty,
'bparty' => $bParty,
];
}
/**
* 测试AXB解绑
* @depends testAxbBind
*/
public function testUnAxbBind($bindData)
{
echo "\n========== 测试AXB解绑 ==========\n";
if (empty($this->appsecret)) {
$this->markTestSkipped('请先填写完整的AppSecret');
}
$time = time();
$params = [
'appkey' => $this->appkey,
'time' => $time,
'number' => $bindData['number'],
'aparty' => $bindData['aparty'],
'bparty' => $bindData['bparty'],
];
$params['sign'] = $this->generateSign($params);
$url = $this->serverRoot . '/virtual/unAxbBind';
echo "请求URL: $url\n";
echo "请求参数: " . json_encode($params, JSON_UNESCAPED_UNICODE) . "\n";
$result = $this->post($url, $params);
echo "HTTP状态码: {$result['http_code']}\n";
echo "响应内容: {$result['response']}\n";
$this->assertEquals(200, $result['http_code'], 'HTTP状态码应为200');
$data = $result['decoded'];
$this->assertTrue($data['success'] ?? false, '解绑应成功');
echo "\n解绑成功!\n";
}
}

View File

@@ -0,0 +1,230 @@
<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
/**
* 米糠云隐私小号接口测试脚本
*
* 使用方法:
* 1. 填写下方配置信息
* 2. 命令行执行: php tests/Other/PrivacyPhone/test_api.php
*/
// 配置信息
$appkey = '0f2494a0e258c85575d1e2aa68e07687';
$appsecret = 'SZWgOa'; // 完整AppSecret
$aParty = '13693687880'; // A号码 - 用户手机号
$serverRoot = 'https://api.mixcom.cn'; // 注意:SDK中使用的是https
/**
* 生成签名 - 参照SDK实现
*/
function generateSign(array $params, string $secret): string
{
// key转换为小写
$params = array_change_key_case($params, CASE_LOWER);
// 排除不需要加密的参数 (key为c、a或sign)
foreach ($params as $key => $val) {
if ($key == 'c' || $key == 'a' || $key == 'sign') {
unset($params[$key]);
}
if ($val === '') {
unset($params[$key]);
}
}
// 对key进行排序, 按名称升序
ksort($params);
// 将数据连接,进行加密
$data = '';
foreach ($params as $key => $val) {
$data .= $val;
}
$data .= $secret;
$sign = strtolower(sha1($data . sha1($secret)));
echo "\n[签名调试]\n";
echo "key小写转换后: " . json_encode(array_keys($params)) . "\n";
echo "排除后参数: " . json_encode($params) . "\n";
echo "参与签名数据拼接: $data\n";
echo "sha1(secret): " . sha1($secret) . "\n";
echo "最终签名: $sign\n";
return $sign;
}
/**
* 发送POST请求
*/
function post(string $url, array $params): array
{
$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);
return [
'http_code' => $httpCode,
'response' => $response,
'error' => $error,
'decoded' => json_decode($response, true) ?? []
];
}
/**
* 打印结果
*/
function printResult(string $title, array $result): void
{
echo "\n========== $title ==========\n";
echo "HTTP状态码: {$result['http_code']}\n";
echo "响应内容: {$result['response']}\n";
if ($result['error']) {
echo "curl错误: {$result['error']}\n";
}
echo "\n";
}
// ========== 开始测试 ==========
echo "米糠云隐私小号接口测试\n";
echo "========================\n";
echo "时间: " . date('Y-m-d H:i:s') . "\n";
echo "AppKey: $appkey\n";
echo "AppSecret: " . substr($appsecret, 0, 6) . "***\n";
echo "A号码: $aParty\n";
echo "API地址: $serverRoot\n";
// 检查配置
if (empty($appsecret) || empty($aParty)) {
echo "\n❌ 错误: 请填写完整的AppSecret和A号码\n";
exit(1);
}
// 测试1: 获取AXB可用小号
echo "\n[测试1] 获取AXB可用小号\n";
$params = [
'appkey' => $appkey,
'time' => time(),
];
$params['sign'] = generateSign($params, $appsecret);
$url = $serverRoot . '/virtual/getaxbnumber';
echo "请求URL: $url\n";
echo "请求参数: " . json_encode($params, JSON_UNESCAPED_UNICODE) . "\n";
$result = post($url, $params);
printResult('获取AXB可用小号响应', $result);
if ($result['http_code'] !== 200) {
echo "❌ HTTP请求失败\n";
exit(1);
}
$data = $result['decoded'];
if (empty($data['success'])) {
echo "❌ 接口返回失败\n";
echo "错误码: " . ($data['code'] ?? 'N/A') . "\n";
echo "错误信息: " . ($data['msg'] ?? 'N/A') . "\n";
exit(1);
}
if (empty($data['data'])) {
echo "❌ 没有可用的隐私小号\n";
exit(1);
}
echo "✅ 获取成功!可用小号列表:\n";
foreach ($data['data'] as $index => $item) {
echo " [" . ($index + 1) . "] 号码: {$item['number']}, 区号: {$item['region_code']}, 绑定数: {$item['bindcount']}\n";
}
$availableNumber = $data['data'][0]['number'];
$bParty = $availableNumber; // 使用获取到的第一个可用号作为B号码进行测试
// 测试2: AXB绑定
echo "\n[测试2] AXB绑定\n";
echo "隐私小号(X): $availableNumber\n";
echo "A号码(用户): $aParty\n";
echo "B号码(商家): $bParty\n";
$params = [
'appkey' => $appkey,
'time' => time(),
'number' => $availableNumber,
'aparty' => $aParty,
'bparty' => $bParty,
];
$params['sign'] = generateSign($params, $appsecret);
$url = $serverRoot . '/virtual/axbBind';
echo "请求URL: $url\n";
echo "请求参数: " . json_encode($params, JSON_UNESCAPED_UNICODE) . "\n";
$result = post($url, $params);
printResult('AXB绑定响应', $result);
$data = $result['decoded'];
if (!empty($data['success']) && $data['code'] == 200) {
echo "✅ 绑定成功!\n";
// 测试3: AXB解绑
echo "\n[测试3] AXB解绑\n";
$params = [
'appkey' => $appkey,
'time' => time(),
'number' => $availableNumber,
'aparty' => $aParty,
'bparty' => $bParty,
];
$params['sign'] = generateSign($params, $appsecret);
$url = $serverRoot . '/virtual/unAxbBind';
echo "请求URL: $url\n";
echo "请求参数: " . json_encode($params, JSON_UNESCAPED_UNICODE) . "\n";
$result = post($url, $params);
printResult('AXB解绑响应', $result);
$data = $result['decoded'];
if (!empty($data['success']) && $data['code'] == 200) {
echo "✅ 解绑成功!\n";
} else {
echo "❌ 解绑失败\n";
echo "错误信息: " . ($data['msg'] ?? 'N/A') . "\n";
}
} else {
echo "❌ 绑定失败\n";
echo "错误码: " . ($data['code'] ?? 'N/A') . "\n";
echo "错误信息: " . ($data['msg'] ?? 'N/A') . "\n";
}
echo "\n========== 测试完成 ==========\n";