diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e4ea31c --- /dev/null +++ b/AGENTS.md @@ -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 + +// +---------------------------------------------------------------------- + +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 diff --git a/app/controller/api/v1/Login.php b/app/controller/api/v1/Login.php index 77f9844..8ef5500 100644 --- a/app/controller/api/v1/Login.php +++ b/app/controller/api/v1/Login.php @@ -211,6 +211,7 @@ class Login */ public function verify(Request $request) { + // 1. 获取请求参数:手机号、验证码类型、防刷校验key、图形验证码类型及校验值 [$phone, $type, $key, $captchaType, $captchaVerification] = $request->postMore([ ['phone', 0], ['type', ''], @@ -219,13 +220,16 @@ class Login ['captchaVerification', ''], ], true); + // 2. 构造缓存Key:防刷校验Key与当前分钟级别的频率限制Key $keyName = 'sms.key.' . $key; $nowKey = 'sms.' . date('YmdHi'); + // 3. 校验请求合法性:检查前端传递的防刷校验Key是否存在于缓存中,防止非法/机器请求 if (!CacheService::has($keyName)) return app('json')->make(401, '发送验证码失败,请刷新页面重新获取'); + // 4. 发送频率限制(分钟级防刷):检查当前一分钟内该接口的调用总次数是否超过配置上限(默认20次) $total = 1; if (CacheService::has($nowKey)) { $total = CacheService::get($nowKey); @@ -233,23 +237,33 @@ class Login return app('json')->success('已发送'); } - //二次验证 + // 二次验证(图形验证码校验,当前代码中被注释) // try { // aj_captcha_check_two($captchaType, $captchaVerification); // } catch (\Throwable $e) { // return app('json')->fail($e->getError()); // } + // 5. 数据验证:使用 RegisterValidates 验证器专门校验手机号格式是否合法 try { validate(RegisterValidates::class)->scene('code')->check(['phone' => $phone]); } catch (ValidateException $e) { return app('json')->fail($e->getError()); } + + // 6. 获取配置:从系统配置中获取验证码的有效时长(单位:分钟),默认1分钟 $time = sys_config('verify_expire_time', 1); + + // 7. 核心业务处理:调用 LoginServices 的 verify 方法,执行如检查是否注册、生成验证码、异步派发短信发送任务等逻辑 $smsCode = $this->services->verify($phone, $type, $time, app()->request->ip()); + + // 8. 结果处理:如果短信验证码生成及派发成功 if ($smsCode) { + // 将验证码写入缓存,设置过期时间(转换为秒) CacheService::set('code_' . $phone, $smsCode, $time * 60); + // 记录/更新当前分钟的接口请求频次(有效期61秒) CacheService::set($nowKey, $total, 61); + // 触发短信发送后的事件(可用于后续扩展业务逻辑) event('sms.sendAfter', [$smsCode, $phone]); return app('json')->success('发送成功'); } else { diff --git a/app/services/order/StoreOrderCreateServices.php b/app/services/order/StoreOrderCreateServices.php index 61c8df1..e2f91c9 100644 --- a/app/services/order/StoreOrderCreateServices.php +++ b/app/services/order/StoreOrderCreateServices.php @@ -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) { diff --git a/app/services/order/StoreOrderDeliveryServices.php b/app/services/order/StoreOrderDeliveryServices.php index 8f1ceb2..f6104da 100644 --- a/app/services/order/StoreOrderDeliveryServices.php +++ b/app/services/order/StoreOrderDeliveryServices.php @@ -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']; diff --git a/app/services/order/StoreOrderTakeServices.php b/app/services/order/StoreOrderTakeServices.php index 1faf0ca..ac54e7b 100644 --- a/app/services/order/StoreOrderTakeServices.php +++ b/app/services/order/StoreOrderTakeServices.php @@ -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); } diff --git a/app/services/other/PrivacyPhoneServices.php b/app/services/other/PrivacyPhoneServices.php new file mode 100644 index 0000000..d72dd5a --- /dev/null +++ b/app/services/other/PrivacyPhoneServices.php @@ -0,0 +1,329 @@ + +// +---------------------------------------------------------------------- + +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; + } +} diff --git a/docs/mixcom_php_sdk_v2.0/index.php b/docs/mixcom_php_sdk_v2.0/index.php new file mode 100644 index 0000000..5541f34 --- /dev/null +++ b/docs/mixcom_php_sdk_v2.0/index.php @@ -0,0 +1,14 @@ +setAppkey(''); +// 这里设置秘钥 +$pn->setSecret(''); +$result = $pn->countNumber(); +print_r($result); \ No newline at end of file diff --git a/docs/mixcom_php_sdk_v2.0/lib/PrivateNumber.php b/docs/mixcom_php_sdk_v2.0/lib/PrivateNumber.php new file mode 100644 index 0000000..24eb1f0 --- /dev/null +++ b/docs/mixcom_php_sdk_v2.0/lib/PrivateNumber.php @@ -0,0 +1,457 @@ +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; + } +} diff --git a/docs/mixcom_php_sdk_v2.0/readme.txt b/docs/mixcom_php_sdk_v2.0/readme.txt new file mode 100644 index 0000000..9b652a7 --- /dev/null +++ b/docs/mixcom_php_sdk_v2.0/readme.txt @@ -0,0 +1 @@ +隐私保护小号php sdk,不使用composer自动加载的方式执行 \ No newline at end of file diff --git a/docs/privacy_phone_update.sql b/docs/privacy_phone_update.sql new file mode 100644 index 0000000..54a25e0 --- /dev/null +++ b/docs/privacy_phone_update.sql @@ -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%'; diff --git a/docs/米糠云接口文档V2.0.docx b/docs/米糠云接口文档V2.0.docx new file mode 100644 index 0000000..80635bf Binary files /dev/null and b/docs/米糠云接口文档V2.0.docx differ diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..f336405 --- /dev/null +++ b/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +php think swoole \ No newline at end of file diff --git a/tests/Other/PrivacyPhone/MikangyunApiTest.php b/tests/Other/PrivacyPhone/MikangyunApiTest.php new file mode 100644 index 0000000..0c2f19a --- /dev/null +++ b/tests/Other/PrivacyPhone/MikangyunApiTest.php @@ -0,0 +1,228 @@ + +// +---------------------------------------------------------------------- + +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"; + } +} diff --git a/tests/Other/PrivacyPhone/test_api.php b/tests/Other/PrivacyPhone/test_api.php new file mode 100644 index 0000000..71027ae --- /dev/null +++ b/tests/Other/PrivacyPhone/test_api.php @@ -0,0 +1,230 @@ + +// +---------------------------------------------------------------------- + +/** + * 米糠云隐私小号接口测试脚本 + * + * 使用方法: + * 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";